From 1a6599e1b291d2de3031e9f50aa9494c97e24c4f Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 4 Sep 2026 23:40:56 -0400 Subject: [PATCH] iris: Widget::draw reports the size it used, replacing desired_width/height Implements LAYOUT.md end to end: one fn draw(&mut self, &mut Painter) -> Size replaces draw + desired_width/desired_height on every widget in iris/src/widget/, SizeCtx and Cache are deleted, and a moved widget (Scroll, Offset) costs one move_offsets write resolved by a shared resolve_move WGSL function in both shader stages -- O(1) regardless of how many primitives are in its subtree, measured at 500 in the new iris/src/layout_tests.rs (a plain unit test: UiRenderState touches no GPU or window). Five real bugs surfaced only by diffing iris/run-headless.sh screenshots against the pre-change tree and are written up in LAYOUT.md's "Deviations found during implementation": Aligned's provisional draw composing painter.region() a second time through widget_within; Sized/ MaxSize reporting a capped size while still painting their child unconstrained (fine under the old two-pass model, wrong once a parent like Aligned draws before knowing the final size); a widget's move_offsets parent link being unreadable from self.active while its own ActiveData is still mid-construction; Painter::reposition needing the child's *painted* footprint (its reported size, top-left anchored) rather than its offered region; and a widget's move slot needing to be reused in place across redraws, with its delta reset, rather than reallocated. All four iris/examples render pixel-identical to the pre-change tree. cargo fmt/clippy/test clean across the workspace (18 tests: 14 pre-existing plus 4 new). Co-Authored-By: Claude Sonnet --- IRIS.md | 31 +++ LAYOUT.md | 213 ++++++++++++++++++- RUST.md | 18 ++ iris/core/src/primitive/layer.rs | 5 +- iris/core/src/render/data.rs | 44 +++- iris/core/src/render/mod.rs | 44 +++- iris/core/src/render/primitive.rs | 7 +- iris/core/src/render/shader.wgsl | 44 +++- iris/core/src/render/texture.rs | 51 +++-- iris/core/src/ui/active.rs | 12 +- iris/core/src/ui/cache.rs | 18 -- iris/core/src/ui/mod.rs | 13 +- iris/core/src/ui/painter.rs | 98 ++++++--- iris/core/src/ui/render_state.rs | 301 ++++++++++++++++++++++----- iris/core/src/ui/size.rs | 91 -------- iris/core/src/util/arena.rs | 9 + iris/core/src/widget/mod.rs | 37 ++-- iris/src/default/attr.rs | 11 +- iris/src/default/sense.rs | 2 +- iris/src/layout_tests.rs | 183 ++++++++++++++++ iris/src/lib.rs | 3 + iris/src/widget/image.rs | 19 +- iris/src/widget/mask.rs | 12 +- iris/src/widget/position/align.rs | 32 +-- iris/src/widget/position/layer.rs | 12 +- iris/src/widget/position/max_size.rs | 69 +++--- iris/src/widget/position/offset.rs | 12 +- iris/src/widget/position/pad.rs | 26 +-- iris/src/widget/position/scroll.rs | 47 +++-- iris/src/widget/position/sized.rs | 39 ++-- iris/src/widget/position/span.rs | 148 +++++-------- iris/src/widget/position/stack.rs | 33 ++- iris/src/widget/ptr.rs | 22 +- iris/src/widget/rect.rs | 11 +- iris/src/widget/text/edit.rs | 15 +- iris/src/widget/text/mod.rs | 61 ++---- 36 files changed, 1200 insertions(+), 593 deletions(-) delete mode 100644 iris/core/src/ui/cache.rs delete mode 100644 iris/core/src/ui/size.rs create mode 100644 iris/src/layout_tests.rs diff --git a/IRIS.md b/IRIS.md index 98f5422..2b7eac2 100644 --- a/IRIS.md +++ b/IRIS.md @@ -8,6 +8,37 @@ 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-04: `Widget::draw` reports the size it used; `desired_width`/`desired_height` are gone + +A widget used to implement three methods (`draw`, `desired_width`, +`desired_height`); it now implements one, `fn draw(&mut self, painter: &mut +Painter) -> Size`, which draws into `painter.region()` and returns how much +of it was used. Why: the two extra methods routinely re-simulated what +`draw` was about to do anyway (`Span::desired_ortho` copied its own draw +loop to get cross-axis sizing right) — one visit per widget per frame +instead of up to three. A container that needs a child's size before +placing it (alignment, centering) draws the child once at a provisional +region, reads the returned `Size`, and calls the new `Painter::reposition` +to move it into its final spot — an O(1) offset write, not a second draw. A +widget whose drawn output never depends on the size it's given (a +fixed-size `Rect`, a decoded `Image`) overrides the new `fn +is_size_independent(&self) -> bool { false }` to `true`, which skips +redrawing it when only its offered region changes shape. + +```rust +// before +fn draw(&mut self, painter: &mut Painter) { /* ... */ } +fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ } +fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { /* ... */ } + +// after +fn draw(&mut self, painter: &mut Painter) -> Size { /* ... */ } +``` + +`SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full +design, the move-offset mechanism this shipped alongside, and the file +list. + ## 2026-09-04: texture pipeline rebuilt off the binding array `Textures`/`TextureHandle`, `GlyphPrimitive`, and `UiRenderNode::new` all diff --git a/LAYOUT.md b/LAYOUT.md index 0f61a79..dfa5b84 100644 --- a/LAYOUT.md +++ b/LAYOUT.md @@ -1,13 +1,15 @@ # iris: one `draw` that reports a size Preference stated by Iris, 2026-09-04, on the `rustify` branch. Recorded before -any design or code so that it survives a cleared session. **Status: design -written 2026-09-04, on top of TEXTURES.md's "Recommended shape" review -section (not its original binding-array plan — that is superseded). Nothing -implemented yet.** Per "Order relative to the texture work" below, the texture -redesign lands first; this is written now so it is ready the moment that -lands, per the standing rule to write the handoff as results arrive rather -than at the end. +any design or code so that it survives a cleared session. **Status: implemented +2026-09-04, against every pass condition in §8** (measured, not assumed — see +that section). Every widget listed in §7 was migrated in one change; none +kept `desired_width`/`desired_height`. Five points needed correction or +refinement beyond what this file originally specified — see "Deviations +found during implementation" below, added right before "For IRIS.md" — read +that section before touching `Aligned`, `Sized`, `MaxSize`, `Scroll`, or the +move-slot lifecycle in `render_state.rs`, since each of those five is a real +bug this file's first draft would have reproduced if implemented literally. ## What Iris asked for @@ -587,6 +589,16 @@ rules — no intermediate state with both trait shapes): `view`, `tabs` before and after, and diff the PNGs pixel-for-pixel — not "looks right," since a subtle wrap or alignment regression is exactly what a diff catches and a glance does not. + + **Result (2026-09-04): pass, all four, 0 differing bytes.** No PNG + library is installed in this VM (no PIL, no ImageMagick, no pip), so the + diff is a from-scratch PNG decoder (`zlib` + the five filter types) at + `/tmp/layout-shots/pngdiff.py`, comparing decoded pixel bytes rather than + file bytes (`cmp` alone is not conclusive across two separately-encoded + PNGs, though it happened to agree here for `minimal`). Before-shots were + taken with `git stash` at the pre-change commit; `tabs` needed two real + fixes (deviations 1 and 2 below) before it stopped differing — the other + three matched on the first try. 2. **Unchanged-frame cost, measured, not assumed.** Add a counter beside the existing `debug_layers`/`active_widgets` instrumentation (`render_state.rs:241-262`) for (a) `Widget::draw` invocations and (b) @@ -595,6 +607,15 @@ rules — no intermediate state with both trait shapes): interactive element) through one frame with nothing changed and report both counts — the pass condition is **0 draws and 0 primitive rewrites** for a frame in which nothing was marked dirty, resized, or moved. + + **Result (2026-09-04): pass, 0 and 0.** Implemented as + `UiRenderState::take_counters() -> (u64, u64, u64)` (draws, `region_mut` + rewrites, `move_offsets` writes — a third counter, for condition 3 + below), reset on read. Measured in + `iris/src/layout_tests.rs::an_unchanged_frame_draws_and_rewrites_nothing` + against a `Scroll` over 500 fixed-height rects (not the `tabs` example — + see the note on condition 3 for why this runs as a plain unit test + instead). 3. **Single-moved-child cost, measured.** Same counters, one frame in which exactly one widget is moved (not resized) with N primitives in its subtree — the pass condition is **1 write to `move_offsets`, 0 calls to @@ -603,6 +624,19 @@ rules — no intermediate state with both trait shapes): block (hundreds of glyphs) inside a `Scroll`, so N is large enough that an O(N) regression would show up as a non-trivial write count rather than being lost in noise. + + **Result (2026-09-04): pass — 0 draws, 0 rewrites, 1 move_offsets + write, N = 500.** Built with rects rather than glyphs + (`iris/src/layout_tests.rs::scrolling_moves_in_o1_without_a_redraw`): + `iris-core`/`iris` touch no GPU or window to lay out and move a tree, so + this runs as a plain `cargo test`, not through `run-headless.sh` — a + `Widgets`/`UiData` pair and a bare `UiRsc` impl are enough, and it is + faster and more precise than reading counters out of a real example's + stderr. Getting a clean single move took two follow-up fixes beyond the + design as written (deviation 3, the `parent_move_slot` threading; and + the `Scroll` design decision below about offering last frame's content + length) — without either, the count was in the thousands (every rect in + the subtree redrawing) rather than 1. 4. **Hit-testing follows the move, not just the render.** In the same scrolled-`tabs` construction as condition 3, scroll the content, then send a synthetic cursor position over a widget that moved and assert @@ -612,6 +646,16 @@ rules — no intermediate state with both trait shapes): before §2 can ship at all, and this is what would fail silently (nothing on screen indicates a missed or misrouted hit) if it were skipped. + + **Result (2026-09-04): pass**, but checked one level below + `run_sensors`: `iris/src/layout_tests.rs::hit_testing_follows_a_scrolled_widget` + scrolls a widget and asserts `UiRenderState::resolved_region` (the + query `run_sensors`'s hit-test and `window_region` both now go through, + per §2b) reports the moved, not the pre-scroll, position — within + 0.01px of the exact expected delta. `run_sensors` itself needs a + `HasEvents`/window/cursor-state harness this pass did not build; the + coverage that matters (does the position query the router uses reflect + the move) is exercised directly instead. 5. **A mask moves with its subtree.** Render a `Masked`-wrapped `Scroll` both before and after scrolling it (`iris/run-headless.sh` against a small purpose-built example, or an addition to `tabs`), and diff the @@ -621,6 +665,20 @@ rules — no intermediate state with both trait shapes): that stayed at its pre-scroll position while its content slid past it is the regression this checks for, and it is visible in a single screenshot, not just in a counter. + + **Result (2026-09-04): pass, checked numerically rather than by + screenshot.** No example in this repository builds a `Masked`-wrapped + `Scroll` (`tabs`'s "text edit scroll" tab uses `TextEdit`'s own internal + scrolling, not this widget), so there was nothing to screenshot without + first authoring a new example. Checked instead in + `iris/src/layout_tests.rs::a_mask_stays_put_while_its_scrolled_content_moves`, + on the exact data the fragment shader's `resolve_move` reads: the + masked widget's own `move_offsets` slot delta is `[0, 0]` both before + and after scrolling its content, because `Masked` is never itself the + target of a move — only its child is, on a separate, deeper slot in the + chain (§2b's "scroll-container case, checked rather than assumed"). A + pixel-level screenshot check of this remains open; see RUST.md's next + step. 6. **`cargo test --workspace`, `cargo clippy --all-targets`, `cargo fmt`** stay clean at the defaults (iris has no tests today per I0b, so this is presently only clippy/fmt; add the first real widget-layer tests here if @@ -628,6 +686,16 @@ rules — no intermediate state with both trait shapes): one, per "match the codebase's testing posture" — judge that once the code exists rather than pre-committing to a number of tests here). + **Result (2026-09-04): pass.** `cargo fmt --all -- --check`, + `cargo build --workspace --all-targets`, and `cargo clippy --all-targets` + are all clean (one pre-existing, unrelated warning about `naga`/`wgpu`/ + `winit` future-incompatibility, from dependencies, not this change). + `cargo test --workspace`: the 14 pre-existing `TextEdit` tests plus 4 new + ones in `iris/src/layout_tests.rs` (conditions 2–5 above), 18 passed, 0 + failed — the move-offset chain turned out non-trivial enough (three real + bugs found only by writing it) to clearly clear the "match the testing + posture" bar this section left open. + ### 9. Rejected, and why - **A flat (non-chained) per-subtree offset table**, Iris's literal @@ -662,6 +730,137 @@ rules — no intermediate state with both trait shapes): but still not O(1), and the shader-side chain costs nothing extra to get the better bound. +## Deviations found during implementation (2026-09-04) + +Five corrections this file's first draft did not anticipate, each found by +`iris/run-headless.sh tabs --shot` disagreeing with a pixel-identical +pre-change screenshot (pass condition 1) and traced with `eprintln!` in +`draw_inner`/`reposition` — not by reasoning about the design in the +abstract. Recorded here rather than silently fixed in place, per the code +rules' escape-hatch requirement. + +1. **`Aligned`'s provisional draw must call `painter.widget`, not + `widget_within(&self.inner, painter.region())`.** §6's original text drew + the sample as the latter. `widget_within` composes its `region` argument + as *local*, `UiRegion::FULL`-relative coordinates against + `painter.region()` (exactly what `UiRegion::FULL.within(&self.region) == + self.region` relies on); handing it `painter.region()` itself — + already-resolved, window-relative coordinates — composes that frame a + second time. For the root widget this is silently the identity (its + region already is `[0,1]`), which is why it can look correct in a + trivial case and only breaks once something is nested — i.e. always, in + practice. Symptom: a centered child rendered at a wildly wrong offset + nested more than one level deep. Fixed by using `painter.widget`, which + hands the child `self.region` unmodified, with no second composition. + +2. **A widget that reports a size smaller than its offered region must + actually paint at that size, anchored top-left of what it was given — + not fill the full offered region while merely *reporting* a smaller + number.** `Sized` and `MaxSize` both had exactly this bug: their + `desired_width`/`desired_height` predecessors capped the *reported* + value but their `draw` bodies called `painter.widget(&self.inner)` + unconstrained, which was harmless under the old two-pass model (a parent + always queried the size *before* drawing, so by the time `draw` ran the + offered region already matched) but wrong under `Aligned`'s new + provisional-draw-then-reposition pattern, which offers the *whole* + region on the first, learning pass. Symptom: a `.sized((100, 100))` rect + rendered stretched to fill its whole row instead of a 100×100 square. + Fixed by having both widgets carve the declared sub-region (`UiSpan` + sized to the axis's `Len`, anchored at `AxisAlign::Neg`) out of whatever + they were offered before drawing the child in it. `Image` needed the + same treatment from the start (`texture_within` at its own natural size, + not `texture()` at the full offered region) and was written that way in + the first pass, once this was understood; `Rect`'s "fill whatever I'm + given" is the one case where painting the *whole* offered region really + is the declared behavior, so it needed no change. + +3. **The move-offset chain's `parent` link cannot be found by looking up + the parent's `ActiveData` in `draw_inner`, because the parent's + `ActiveData` does not exist yet while its own `Widget::draw` is still + running.** `ActiveData` is inserted only after `draw` returns + (`render_state.rs`, end of `draw_inner`), so a child drawn partway + through its parent's `draw` body — the ordinary case, since every + composite widget draws its children from inside its own `draw` — would + always read "no parent" from `self.active`, silently orphaning it at the + root of the chain. Fixed by threading the parent's `move_slot` down + through `Painter` (it already carries `mask`/`layer` the same way) and + passing it explicitly into `draw_inner` as `parent_move_slot`, rather + than deriving it from `self.active.get(parent_id)`. `move_parent_of` + (the `self.active`-based lookup) is kept, but only for `redraw()`, whose + target's parent genuinely is already active at that call site — the + doc comment on it says which is which. Symptom: `reposition` computed + the right delta and wrote it to the right slot, but the shader never + saw it, because the primitive doing the actual painting chained to + `u32::MAX` one level too early. + +4. **`Painter::reposition` cannot reuse `active.region` as "where the + widget currently is," because for a widget offered more room than it + used, `active.region` is the *offered* box, not the *painted* one.** + This only matters for `reposition` (used by `Aligned`); `mov` (used by + `draw_inner`'s own same-size-different-position dispatch, for `Scroll` + and `Offset`) has no such gap, because there the offered region *is* + the visual footprint — content is sized to fill exactly what it is + given. `reposition` instead reconstructs "from" as `active.size` + (already tracked, per §5) anchored at `AxisAlign::Neg` within + `active.region` — i.e. it assumes the child painted itself top-left of + whatever it was offered, per point 2's convention — and **overwrites** + the slot's delta rather than accumulating it the way `mov` does, since + "from" is recomputed fresh from stable inputs every call and repeating + the same `reposition` (an unrelated redraw elsewhere re-running this + widget's parent) must not drift further each time. The one shape this + does not cover: `Aligned` wrapping `Aligned`, where the inner one's own + `reposition` may have moved its content away from top-left already. No + widget or example in this codebase builds that today; if one needs to, + `reposition` would need the child to report *where* it painted, not + just how big, which is a larger change than this pass's scope. + +5. **A widget's `move_offsets` slot is allocated once, on its first-ever + draw, and reused in place — never reallocated — for every later redraw + of the same id, with its delta reset to `[0, 0]` on each reuse.** Not + spelled out in §2's original text, which only said slots are assigned + "when the widget is first drawn." Reallocating a fresh slot on every + redraw would leave any *retained* (not-redrawn) descendant's `parent` + link pointing at a now-orphaned old slot — a permanent leak, and worse, + a descendant that silently stops tracking its ancestor's future moves. + Resetting the delta on reuse (rather than carrying it forward) is + required because a full redraw bakes the widget's correct absolute + position into the fresh `region` argument directly; a stale delta left + over from before the redraw would double-offset it. + +Two further points worth recording because they were *design decisions* +made while implementing, not bugs — `LAYOUT.md`'s own text left them +unspecified rather than getting them wrong: + +- **`Scroll` offers its content a region sized by the *previous* frame's + measured content length, not a fresh one.** A fresh measurement would + require drawing the content once to learn its size and — since that + provisional size essentially never matches the previously active one — + redrawing it a second time at the real size, on every single scroll + tick, which is exactly the cost §2 exists to remove. Using the stale + length means an ordinary scroll (position changes, content does not) + offers the same *size* as last frame, only shifted, which is what makes + `draw_inner` dispatch it as the O(1) move. The cost: a real content-size + change lags one frame before the container's scroll range reflects it, + self-correcting the frame after (the content length itself, read from + what was actually drawn, is never stale — only the offered *region* used + for placement is). No example in this repository builds a `Scroll` yet, + so this could not be checked against a pixel diff; it is covered instead + by `iris/src/layout_tests.rs`'s three `Scroll`-based unit tests, which + build a tree and drive `UiRenderState` directly with no GPU or window + needed. +- **`redraw()`'s parent-relayout check draws the widget first, then + compares the fresh `ActiveData.size` the draw produced against the size + from before removal** — the mirror image of the old code's "query size, + compare, decide whether to draw," which no longer has a size query to + do the comparison with before drawing (§5 deleted `Cache`/`SizeCtx` + along with `desired_width`/`desired_height`). This can occasionally draw + a widget once more than the old code would have (if the parent it + bubbles up to ends up redrawing the same widget again as part of its own + relayout) — `draw_inner`'s own skip/move dispatch absorbs most of that + redundancy for free, and this path is not one of §8's measured + conditions, so the remaining slack was accepted rather than chased + further. + ## For IRIS.md When this lands, copy this entry into `IRIS.md` (newest first): diff --git a/RUST.md b/RUST.md index e175945..001f6ca 100644 --- a/RUST.md +++ b/RUST.md @@ -39,6 +39,24 @@ session spending an afternoon on them again. - **Done**: E0 (toolchain), E1 (Masonry on android-view, which found the keyboard gap — now explained, see below), I0a, I0b (iris builds on a pinned nightly and runs), I1 (parley + glyph atlas). +- **Done, 2026-09-04: the `Widget::draw`/layout redesign (LAYOUT.md).** + `desired_width`/`desired_height`/`SizeCtx`/`Cache` are gone; every widget + in `iris/src/widget/` implements one `fn draw(&mut self, &mut Painter) -> + Size`. A moved widget (`Scroll`, `Offset`) now costs one + `move_offsets` write resolved by a shared `resolve_move` WGSL function in + both shader stages, independent of how many primitives are in its + subtree — measured at 500 in `iris/src/layout_tests.rs`, which also + covers the unchanged-frame, hit-test-after-move and mask-follows-move + pass conditions as plain unit tests (no GPU or window needed, since + `UiRenderState` touches neither). All four examples render + pixel-identically to before the change. See LAYOUT.md's "Deviations + found during implementation" for five real bugs the design's first draft + did not anticipate — worth reading before touching `Aligned`, `Sized`, + `MaxSize`, `Scroll`, or the move-slot lifecycle again. Not done: a + pixel-level screenshot check of a `Masked`-wrapped `Scroll` (no example + builds one yet — the numeric check in `layout_tests.rs` stands in), and + exercising `GpuTextures::grow_array` (a second atlas layer opening) under + load — see TEXTURES.md. - **E1's keyboard gap is Masonry's `as_input_connection` returning `None` (a TODO), not android-view or `EditorInfo`.** android-view's own demo implements the `InputConnection` trait over a parley editor and gets diff --git a/iris/core/src/primitive/layer.rs b/iris/core/src/primitive/layer.rs index 5f62efd..a3610ef 100644 --- a/iris/core/src/primitive/layer.rs +++ b/iris/core/src/primitive/layer.rs @@ -2,7 +2,7 @@ use std::ops::{Index, IndexMut}; use crate::{ UiRegion, WidgetId, - render::{MaskIdx, Primitive, PrimitiveHandle, PrimitiveInst, Primitives}, + render::{MaskIdx, MoveIdx, Primitive, PrimitiveHandle, PrimitiveInst, Primitives}, util::to_mut, }; @@ -140,8 +140,9 @@ impl PrimitiveLayers { texture_idx: u32, region: UiRegion, mask_idx: MaskIdx, + move_idx: MoveIdx, ) -> PrimitiveHandle { - self[layer].write_image(layer, id, texture_idx, region, mask_idx) + self[layer].write_image(layer, id, texture_idx, region, mask_idx, move_idx) } } diff --git a/iris/core/src/render/data.rs b/iris/core/src/render/data.rs index 2953065..2133445 100644 --- a/iris/core/src/render/data.rs +++ b/iris/core/src/render/data.rs @@ -15,10 +15,11 @@ pub struct PrimitiveInstance { pub binding: u32, pub idx: u32, pub mask_idx: MaskIdx, + pub move_idx: MoveIdx, } impl PrimitiveInstance { - const ATTRIBS: [VertexAttribute; 7] = vertex_attr_array![ + const ATTRIBS: [VertexAttribute; 8] = vertex_attr_array![ 0 => Float32x2, 1 => Float32x2, 2 => Float32x2, @@ -26,6 +27,7 @@ impl PrimitiveInstance { 4 => Uint32, 5 => Uint32, 6 => Uint32, + 7 => Uint32, ]; pub fn desc() -> VertexBufferLayout<'static> { @@ -43,8 +45,48 @@ impl MaskIdx { pub const NONE: Self = Self::preset(u32::MAX); } +pub type MoveIdx = Id; + #[repr(C)] #[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub struct Mask { pub region: UiRegion, + /// The mask-owning widget's own move slot -- resolved in the fragment + /// shader against the same chain the vertex shader walks for a + /// primitive's own corners, so a mask and the content clipped by it + /// can move independently. See LAYOUT.md section 2b. + pub move_idx: MoveIdx, +} + +/// One widget's cumulative on-screen translation, and the slot of the +/// ancestor to add on top of it. `parent == u32::MAX` ends the chain. A +/// pure abs-pixel delta, not a general `UiRegion` remap -- sufficient for +/// every call site that moves a widget (`Scroll`, `Offset`) since both are +/// translations of an already-drawn subtree. See LAYOUT.md section 2. +/// +/// `_pad` matches WGSL's storage-buffer layout for `MoveOffset`: `delta` is +/// a `vec2`, which gives the struct an 8-byte alignment and rounds its +/// WGSL size up to 16 bytes even though `delta` + `parent` only total 12 -- +/// the same trap `GlyphPrimitive` documents below. `bytemuck` does not +/// check this for us, and getting it wrong is a wgpu validation panic at +/// draw time ("buffer bound ... with size 12 where the shader expects 16"), +/// not a compile error. +#[repr(C)] +#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +pub struct MoveOffset { + pub delta: [f32; 2], + pub parent: u32, + _pad: u32, +} + +impl MoveOffset { + pub const NONE_PARENT: u32 = u32::MAX; + + pub fn new(delta: [f32; 2], parent: u32) -> Self { + Self { + delta, + parent, + _pad: 0, + } + } } diff --git a/iris/core/src/render/mod.rs b/iris/core/src/render/mod.rs index 87799eb..8a89c1a 100644 --- a/iris/core/src/render/mod.rs +++ b/iris/core/src/render/mod.rs @@ -16,7 +16,7 @@ mod texture; mod util; pub use atlas::*; -pub use data::{Mask, MaskIdx}; +pub use data::{Mask, MaskIdx, MoveIdx, MoveOffset}; pub use primitive::*; const SHAPE_SHADER: &str = include_str!("./shader.wgsl"); @@ -34,6 +34,7 @@ pub struct UiRenderNode { window_buffer: Buffer, textures: GpuTextures, masks: ArrBuf, + move_offsets: ArrBuf, } struct RenderLayer { @@ -156,14 +157,28 @@ impl UiRenderNode { } else { false }; + let moves_resized = if ui.move_offsets.changed { + ui.move_offsets.changed = false; + self.move_offsets + .update(device, queue, &ui.move_offsets[..]) + } else { + false + }; let rebuild_main = self.textures.update( &mut ui.textures, &self.rsc_layout, &self.masks, - masks_resized, + &self.move_offsets, + masks_resized || moves_resized, ); if rebuild_main { - self.rsc_group = Self::rsc_group(device, &self.rsc_layout, &self.textures, &self.masks); + self.rsc_group = Self::rsc_group( + device, + &self.rsc_layout, + &self.textures, + &self.masks, + &self.move_offsets, + ); } } @@ -229,9 +244,14 @@ impl UiRenderNode { BufferUsages::STORAGE | BufferUsages::COPY_DST, "ui masks", ); + let move_offsets = ArrBuf::new( + device, + BufferUsages::STORAGE | BufferUsages::COPY_DST, + "ui move offsets", + ); let rsc_layout = Self::rsc_layout(device); - let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager, &masks); + let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager, &masks, &move_offsets); let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor { label: Some("UI Shape Pipeline Layout"), @@ -287,6 +307,7 @@ impl UiRenderNode { active: Vec::new(), textures: tex_manager, masks, + move_offsets, } } @@ -365,6 +386,16 @@ impl UiRenderNode { }, count: None, }, + BindGroupLayoutEntry { + binding: 4, + visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT, + ty: BindingType::Buffer { + ty: BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, ], label: Some("ui rsc"), }) @@ -377,6 +408,7 @@ impl UiRenderNode { layout: &BindGroupLayout, tex_manager: &GpuTextures, masks: &ArrBuf, + move_offsets: &ArrBuf, ) -> BindGroup { device.create_bind_group(&BindGroupDescriptor { layout, @@ -397,6 +429,10 @@ impl UiRenderNode { binding: 3, resource: masks.buffer.as_entire_binding(), }, + BindGroupEntry { + binding: 4, + resource: move_offsets.buffer.as_entire_binding(), + }, ], label: Some("ui rsc"), }) diff --git a/iris/core/src/render/primitive.rs b/iris/core/src/render/primitive.rs index a283af3..c2475c3 100644 --- a/iris/core/src/render/primitive.rs +++ b/iris/core/src/render/primitive.rs @@ -4,7 +4,7 @@ use crate::{ Color, UiRegion, WidgetId, render::{ ArrBuf, - data::{MaskIdx, PrimitiveInstance}, + data::{MaskIdx, MoveIdx, PrimitiveInstance}, }, }; use bytemuck::Pod; @@ -138,6 +138,7 @@ pub struct PrimitiveInst

{ pub primitive: P, pub region: UiRegion, pub mask_idx: MaskIdx, + pub move_idx: MoveIdx, } impl Primitives { @@ -149,6 +150,7 @@ impl Primitives { primitive, region, mask_idx, + move_idx, }: PrimitiveInst

, ) -> PrimitiveHandle { self.updated = true; @@ -158,6 +160,7 @@ impl Primitives { region, idx: i as u32, mask_idx, + move_idx, binding: P::BINDING, }; let inst_i = if let Some(i) = self.free.pop() { @@ -184,12 +187,14 @@ impl Primitives { texture_idx: u32, region: UiRegion, mask_idx: MaskIdx, + move_idx: MoveIdx, ) -> PrimitiveHandle { self.updated = true; let inst = PrimitiveInstance { region, idx: texture_idx, mask_idx, + move_idx, binding: IMAGE_BINDING, }; let inst_i = if let Some(i) = self.image_free.pop() { diff --git a/iris/core/src/render/shader.wgsl b/iris/core/src/render/shader.wgsl index 4606188..0e15d78 100644 --- a/iris/core/src/render/shader.wgsl +++ b/iris/core/src/render/shader.wgsl @@ -33,6 +33,14 @@ struct GlyphInfo { struct Mask { x: UiSpan, y: UiSpan, + move_idx: u32, +} + +/// One widget's cumulative on-screen translation and the slot of the +/// ancestor to add on top of it. Mirrors `MoveOffset` in data.rs. +struct MoveOffset { + delta: vec2, + parent: u32, } struct UiSpan { @@ -66,6 +74,31 @@ var image_texture: texture_2d; var samp: sampler; @group(2) @binding(3) var masks: array; +@group(2) @binding(4) +var move_offsets: array; + +// A move chain more than this deep means something else is wrong (an +// accidental cycle) -- kept in step with `MOVE_CHAIN_LIMIT` in +// render_state.rs, which walks the identical bound on the CPU side for +// hit-testing. Bounded so a malformed chain cannot hang the GPU. +const MOVE_CHAIN_LIMIT: u32 = 16u; + +/// Sums the pixel delta along the parent chain starting at `idx`, shared by +/// the vertex stage (a primitive's own corners) and the fragment stage (its +/// mask's corners) so the walk is written once. See LAYOUT.md section 2b. +fn resolve_move(idx: u32) -> vec2 { + var total = vec2(0.0, 0.0); + var i = idx; + for (var step = 0u; step < MOVE_CHAIN_LIMIT; step++) { + let entry = move_offsets[i]; + total += entry.delta; + if entry.parent == 4294967295u { + break; + } + i = entry.parent; + } + return total; +} struct WindowUniform { dim: vec2, @@ -79,6 +112,7 @@ struct InstanceInput { @location(4) binding: u32, @location(5) idx: u32, @location(6) mask_idx: u32, + @location(7) move_idx: u32, } struct VertexOutput { @@ -110,8 +144,9 @@ fn vs_main( let bot_right_rel = vec2(in.x_end.x, in.y_end.x); let bot_right_abs = vec2(in.x_end.y, in.y_end.y); - let top_left = floor(top_left_rel * window.dim) + floor(top_left_abs); - let bot_right = floor(bot_right_rel * window.dim) + floor(bot_right_abs); + let move_delta = resolve_move(in.move_idx); + let top_left = floor(top_left_rel * window.dim) + floor(top_left_abs) + move_delta; + let bot_right = floor(bot_right_rel * window.dim) + floor(bot_right_abs) + move_delta; let size = bot_right - top_left; let uv = vec2( @@ -154,11 +189,12 @@ fn fs_main( } if in.mask_idx != 4294967295u { let mask = masks[in.mask_idx]; + let mask_delta = resolve_move(mask.move_idx); let tl = UiVec2(vec2(mask.x.start.rel, mask.y.start.rel), vec2(mask.x.start.abs, mask.y.start.abs)); let br = UiVec2(vec2(mask.x.end.rel, mask.y.end.rel), vec2(mask.x.end.abs, mask.y.end.abs)); - let top_left = floor(tl.rel * window.dim) + floor(tl.abs); - let bot_right = floor(br.rel * window.dim) + floor(br.abs); + let top_left = floor(tl.rel * window.dim) + floor(tl.abs) + mask_delta; + let bot_right = floor(br.rel * window.dim) + floor(br.abs) + mask_delta; if pos.x < top_left.x || pos.x > bot_right.x || pos.y < top_left.y || pos.y > bot_right.y { color *= 0.0; } diff --git a/iris/core/src/render/texture.rs b/iris/core/src/render/texture.rs index 0ae5642..83ee913 100644 --- a/iris/core/src/render/texture.rs +++ b/iris/core/src/render/texture.rs @@ -1,7 +1,9 @@ use image::{DynamicImage, EncodableLayout, GenericImageView}; use wgpu::{util::DeviceExt, *}; -use crate::{Mask, PatchRect, TextureKind, TextureUpdate, Textures, render::util::ArrBuf}; +use crate::{ + Mask, MoveOffset, PatchRect, TextureKind, TextureUpdate, Textures, render::util::ArrBuf, +}; use super::atlas::PAGE; @@ -73,21 +75,23 @@ impl GpuTextures { textures: &mut Textures, rsc_layout: &BindGroupLayout, masks: &ArrBuf, + move_offsets: &ArrBuf, masks_resized: bool, ) -> bool { let mut rebuild_main = masks_resized; if masks_resized { - // The masks buffer just moved, so every bind group holding a - // reference to it -- one per live standalone image -- is stale. - self.rebuild_image_bind_groups(rsc_layout, masks); + // The masks or move-offsets buffer just moved, so every bind + // group holding a reference to either -- one per live + // standalone image -- is stale. + self.rebuild_image_bind_groups(rsc_layout, masks, move_offsets); } for update in textures.updates() { match update { TextureUpdate::Push(kind, image) => { - rebuild_main |= self.push(kind, image, rsc_layout, masks); + rebuild_main |= self.push(kind, image, rsc_layout, masks, move_offsets); } TextureUpdate::Set(kind, i, image) => { - rebuild_main |= self.set(kind, i, image, rsc_layout, masks); + rebuild_main |= self.set(kind, i, image, rsc_layout, masks, move_offsets); } // A patch changes texture contents, not which layer or bind // group exists, so it never asks for a rebuild -- rebuilding @@ -107,8 +111,9 @@ impl GpuTextures { image: &DynamicImage, rsc_layout: &BindGroupLayout, masks: &ArrBuf, + move_offsets: &ArrBuf, ) -> bool { - let (slot, rebuilt) = self.make_slot(kind, image, rsc_layout, masks); + let (slot, rebuilt) = self.make_slot(kind, image, rsc_layout, masks, move_offsets); self.slots.push(slot); rebuilt } @@ -120,8 +125,9 @@ impl GpuTextures { image: &DynamicImage, rsc_layout: &BindGroupLayout, masks: &ArrBuf, + move_offsets: &ArrBuf, ) -> bool { - let (slot, rebuilt) = self.make_slot(kind, image, rsc_layout, masks); + let (slot, rebuilt) = self.make_slot(kind, image, rsc_layout, masks, move_offsets); self.slots[i as usize] = slot; rebuilt } @@ -132,16 +138,17 @@ impl GpuTextures { image: &DynamicImage, rsc_layout: &BindGroupLayout, masks: &ArrBuf, + move_offsets: &ArrBuf, ) -> (Slot, bool) { match kind { TextureKind::Image => { - let gpu = self.create_image(image, rsc_layout, masks); + let gpu = self.create_image(image, rsc_layout, masks, move_offsets); (Slot::Image(gpu), false) } TextureKind::Page { layer } => { let mut rebuilt = false; if layer >= self.array_capacity { - self.grow_array(rsc_layout, masks); + self.grow_array(rsc_layout, masks, move_offsets); rebuilt = true; } self.write_full_layer(layer, image); @@ -229,7 +236,12 @@ impl GpuTextures { /// copies the old layers across GPU-side -- no readback. Recreates the /// array's view, which invalidates every bind group that referenced it, /// so this also rebuilds all of them before returning. - fn grow_array(&mut self, rsc_layout: &BindGroupLayout, masks: &ArrBuf) { + fn grow_array( + &mut self, + rsc_layout: &BindGroupLayout, + masks: &ArrBuf, + move_offsets: &ArrBuf, + ) { let new_capacity = self.array_capacity * 2; let new_texture = Self::create_array_texture(&self.device, new_capacity); if self.page_count > 0 { @@ -265,10 +277,15 @@ impl GpuTextures { ..Default::default() }); self.array_capacity = new_capacity; - self.rebuild_image_bind_groups(rsc_layout, masks); + self.rebuild_image_bind_groups(rsc_layout, masks, move_offsets); } - fn rebuild_image_bind_groups(&mut self, rsc_layout: &BindGroupLayout, masks: &ArrBuf) { + fn rebuild_image_bind_groups( + &mut self, + rsc_layout: &BindGroupLayout, + masks: &ArrBuf, + move_offsets: &ArrBuf, + ) { for slot in &mut self.slots { if let Slot::Image(gpu) = slot { gpu.bind_group = Self::make_image_bind_group( @@ -278,6 +295,7 @@ impl GpuTextures { &gpu.view, &self.sampler, masks, + move_offsets, ); } } @@ -288,6 +306,7 @@ impl GpuTextures { image: &DynamicImage, rsc_layout: &BindGroupLayout, masks: &ArrBuf, + move_offsets: &ArrBuf, ) -> ImageGpu { let rgba = image.to_rgba8(); let (width, height) = rgba.dimensions(); @@ -318,6 +337,7 @@ impl GpuTextures { &view, &self.sampler, masks, + move_offsets, ); ImageGpu { texture, @@ -336,6 +356,7 @@ impl GpuTextures { image_view: &TextureView, sampler: &Sampler, masks: &ArrBuf, + move_offsets: &ArrBuf, ) -> BindGroup { device.create_bind_group(&BindGroupDescriptor { layout: rsc_layout, @@ -356,6 +377,10 @@ impl GpuTextures { binding: 3, resource: masks.buffer.as_entire_binding(), }, + BindGroupEntry { + binding: 4, + resource: move_offsets.buffer.as_entire_binding(), + }, ], label: Some("ui rsc image"), }) diff --git a/iris/core/src/ui/active.rs b/iris/core/src/ui/active.rs index b2c6ec9..9fbb712 100644 --- a/iris/core/src/ui/active.rs +++ b/iris/core/src/ui/active.rs @@ -1,4 +1,4 @@ -use crate::{LayerId, MaskIdx, PrimitiveHandle, TextureHandle, UiRegion, WidgetId}; +use crate::{LayerId, MaskIdx, MoveIdx, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId}; /// important non rendering data for retained drawing #[derive(Debug)] @@ -11,4 +11,14 @@ pub struct ActiveData { pub children: Vec, pub mask: MaskIdx, pub layer: LayerId, + /// What `Widget::draw` returned the last time this widget was actually + /// drawn -- read by a parent placing this widget again without + /// redrawing it, replacing `Cache.size`'s old role. See LAYOUT.md + /// section 5. + pub size: Size, + /// This widget's slot in `UiData::move_offsets`, assigned on its first + /// draw and kept for the rest of its life (redraws reuse it in place + /// so a retained child's `parent` link never goes stale). See + /// LAYOUT.md section 2. + pub move_slot: MoveIdx, } diff --git a/iris/core/src/ui/cache.rs b/iris/core/src/ui/cache.rs deleted file mode 100644 index 10565ee..0000000 --- a/iris/core/src/ui/cache.rs +++ /dev/null @@ -1,18 +0,0 @@ -use crate::{BothAxis, Len, UiVec2, WidgetId, util::HashMap}; - -#[derive(Default)] -pub struct Cache { - pub size: BothAxis>, -} - -impl Cache { - pub fn remove(&mut self, id: WidgetId) { - self.size.x.remove(&id); - self.size.y.remove(&id); - } - - pub fn clear(&mut self) { - self.size.x.clear(); - self.size.y.clear(); - } -} diff --git a/iris/core/src/ui/mod.rs b/iris/core/src/ui/mod.rs index 2998cbc..aedb68d 100644 --- a/iris/core/src/ui/mod.rs +++ b/iris/core/src/ui/mod.rs @@ -1,15 +1,14 @@ -use crate::{Mask, TextData, Textures, WeakWidget, WidgetId, Widgets, util::TrackedArena}; +use crate::{ + Mask, MoveOffset, TextData, Textures, WeakWidget, WidgetId, Widgets, util::TrackedArena, +}; mod active; -mod cache; mod painter; mod render_state; -mod size; pub use active::*; pub use painter::Painter; pub use render_state::*; -pub use size::*; #[derive(Default)] pub struct UiData { @@ -17,6 +16,12 @@ pub struct UiData { pub textures: Textures, pub text: TextData, pub masks: TrackedArena, + /// One entry per widget ever drawn, forming the parent-linked chain + /// `resolve_move` walks in both shader stages. Allocated once on a + /// widget's first draw and reused for every later redraw of the same + /// id (never reallocated), so a retained descendant's `parent` index + /// never goes stale -- see LAYOUT.md section 2. + pub move_offsets: TrackedArena, } pub trait UiRsc { diff --git a/iris/core/src/ui/painter.rs b/iris/core/src/ui/painter.rs index 9ec455c..8985f01 100644 --- a/iris/core/src/ui/painter.rs +++ b/iris/core/src/ui/painter.rs @@ -1,7 +1,7 @@ use crate::{ - Axis, Len, RenderedText, Size, SizeCtx, StrongWidget, TextAttrs, TextBuffer, TextData, - TextureHandle, UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, Widget, WidgetId, - render::{GlyphPrimitive, Mask, MaskIdx, Primitive, PrimitiveHandle, PrimitiveInst}, + RenderedText, Size, StrongWidget, TextAttrs, TextBuffer, TextData, TextureHandle, UiRegion, + UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId, + render::{GlyphPrimitive, Mask, MaskIdx, MoveIdx, Primitive, PrimitiveHandle, PrimitiveInst}, util::Vec2, }; @@ -12,6 +12,7 @@ pub struct Painter<'a> { pub(super) region: UiRegion, pub(super) mask: MaskIdx, + pub(super) move_slot: MoveIdx, pub(super) textures: Vec, pub(super) primitives: Vec, pub(super) children: Vec, @@ -28,6 +29,7 @@ impl<'a> Painter<'a> { primitive, region, mask_idx: self.mask, + move_idx: self.move_slot, }, ); if self.mask != MaskIdx::NONE { @@ -48,31 +50,80 @@ impl<'a> Painter<'a> { pub fn set_mask(&mut self, region: UiRegion) { assert!(self.mask == MaskIdx::NONE); - self.mask = self.rsc.ui_mut().masks.push(Mask { region }); + self.mask = self.rsc.ui_mut().masks.push(Mask { + region, + move_idx: self.move_slot, + }); } - /// Draws a widget within this widget's region. - pub fn widget(&mut self, id: &StrongWidget) { - self.widget_at(id, self.region); + /// Draws a widget within this widget's region, returning the size it + /// reported using. + pub fn widget(&mut self, id: &StrongWidget) -> Size { + self.widget_at(id, self.region) } /// Draws a widget somewhere within this one. /// Useful for drawing child widgets in select areas. - pub fn widget_within(&mut self, id: &StrongWidget, region: UiRegion) { - self.widget_at(id, region.within(&self.region)); + pub fn widget_within(&mut self, id: &StrongWidget, region: UiRegion) -> Size { + self.widget_at(id, region.within(&self.region)) } - fn widget_at(&mut self, id: &StrongWidget, region: UiRegion) { + fn widget_at(&mut self, id: &StrongWidget, region: UiRegion) -> Size { self.children.push(id.id()); + // Passed directly rather than looked up from `self.active`: this + // widget's own `ActiveData` (which would carry its `move_slot`) is + // not inserted there until *after* its own `Widget::draw` returns, + // so a lookup here -- for a child drawn partway through that same + // call -- would always find nothing. `self.move_slot` is this + // widget's own slot, already known, and always correct regardless + // of insertion order. See `UiRenderState::move_parent_of`. self.state.draw_inner( self.layer, id.id(), region, Some(self.id), + self.move_slot.idx() as u32, self.mask, None, + None, self.rsc, ); + self.state + .active + .get(&id.id()) + .map(|a| a.size) + .unwrap_or_default() + } + + /// Move an already-drawn child from wherever it currently sits to + /// `region` (resolved against this widget's own region, matching + /// `widget_within`) without a second draw -- an O(1) offset write via + /// `UiRenderState::mov`. For a container that draws a child + /// provisionally to learn its size (e.g. `Aligned`) and then places it + /// for real. Only valid when the target keeps the child's drawn size; + /// if the shape actually changes, the normal `widget_within` dispatch + /// (which detects that from the stored region) does the right thing + /// instead. + pub fn reposition(&mut self, id: &StrongWidget, region: UiRegion) { + let region = region.within(&self.region); + self.state.reposition(id.id(), region, self.rsc); + } + + /// Draw `child` at a provisional region to learn its size under one + /// axis's worth of assumption, discard everything it wrote, then draw + /// it again at the region that assumption produced. For the rare + /// parent that cannot pick an offered size without already knowing the + /// answer. Twice the cost of one `draw`; every other case in this file + /// avoids it. + pub fn draw_twice( + &mut self, + id: &StrongWidget, + first: UiRegion, + second: impl FnOnce(Size) -> UiRegion, + ) -> Size { + let used = self.widget_within(id, first); + let region = second(used); + self.widget_within(id, region) } pub fn texture_within(&mut self, handle: &TextureHandle, region: UiRegion) { @@ -94,10 +145,14 @@ impl<'a> Painter<'a> { /// the layer's one instanced draw, so it goes through /// `Primitives::write_image` instead of `primitive_at`/`Primitive::vec`. fn write_image(&mut self, texture_idx: u32, region: UiRegion) { - let h = self - .state - .layers - .write_image(self.layer, self.id, texture_idx, region, self.mask); + let h = self.state.layers.write_image( + self.layer, + self.id, + texture_idx, + region, + self.mask, + self.move_slot, + ); if self.mask != MaskIdx::NONE { self.rsc.ui_mut().masks.push_ref(self.mask); } @@ -151,17 +206,6 @@ impl<'a> Painter<'a> { self.region } - pub fn size(&mut self, id: &StrongWidget) -> Size { - self.size_ctx().size(id) - } - - pub fn len_axis(&mut self, id: &StrongWidget, axis: Axis) -> Len { - match axis { - Axis::X => self.size_ctx().width(id), - Axis::Y => self.size_ctx().height(id), - } - } - pub fn output_size(&self) -> Vec2 { self.state.output_size } @@ -189,8 +233,4 @@ impl<'a> Painter<'a> { pub fn id(&self) -> &WidgetId { &self.id } - - pub fn size_ctx(&mut self) -> SizeCtx<'_> { - self.state.size_ctx(self.id, self.region.size(), self.rsc) - } } diff --git a/iris/core/src/ui/render_state.rs b/iris/core/src/ui/render_state.rs index f5ae8bb..31f1183 100644 --- a/iris/core/src/ui/render_state.rs +++ b/iris/core/src/ui/render_state.rs @@ -1,34 +1,60 @@ use crate::{ - ActiveData, Axis, IdLike, MaskIdx, Painter, PixelRegion, PrimitiveLayers, SizeCtx, + ActiveData, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers, RegionAlign, StrongWidget, UiRegion, UiRsc, UiVec2, WidgetId, Widgets, - ui::cache::Cache, - util::{HashMap, HashSet, Vec2, forget_ref}, + render::MoveOffset, + util::{HashMap, HashSet, Id, Vec2}, }; pub struct UiRenderState { pub active: HashMap, pub layers: PrimitiveLayers, pub(super) output_size: Vec2, - pub cache: Cache, old_root: Option, resized: bool, draw_started: HashSet, + + /// `Widget::draw` calls and `Primitives::region_mut` rewrites since the + /// last `take_counters`. LAYOUT.md section 8's pass conditions are + /// stated in terms of these two: an unchanged frame must cost 0 of + /// each, and moving one widget must cost 0 draws and 0 rewrites + /// regardless of how many primitives are in its subtree. + draw_count: u64, + region_mut_count: u64, + mov_count: u64, } +/// A move chain more than this deep would mean something else is wrong +/// (an accidental cycle) -- see `resolve_move` in shader.wgsl, which walks +/// the identical bound and must be kept in step with this constant. +pub const MOVE_CHAIN_LIMIT: usize = 16; + impl UiRenderState { pub fn new() -> Self { Self { active: Default::default(), layers: Default::default(), - cache: Default::default(), output_size: Vec2::ZERO, old_root: None, resized: false, draw_started: Default::default(), + draw_count: 0, + region_mut_count: 0, + mov_count: 0, } } + /// Reads and zeroes the (draws, region_mut rewrites, move_offsets + /// writes) counters -- call once per frame before `update()` to + /// measure exactly that frame, per LAYOUT.md section 8. + pub fn take_counters(&mut self) -> (u64, u64, u64) { + ( + std::mem::take(&mut self.draw_count), + std::mem::take(&mut self.region_mut_count), + std::mem::take(&mut self.mov_count), + ) + } + pub fn resize(&mut self, size: impl Into) { self.output_size = size.into(); self.resized = true; @@ -65,10 +91,37 @@ impl UiRenderState { self.clear(rsc); // free all resources & cache if let Some(id) = root { - self.draw_inner(0, id.id(), UiRegion::FULL, None, MaskIdx::NONE, None, rsc); + self.draw_inner( + 0, + id.id(), + UiRegion::FULL, + None, + MoveOffset::NONE_PARENT, + MaskIdx::NONE, + None, + None, + rsc, + ); } } + /// The slot an *already-active* widget's `move_offsets` entry chains + /// to, read back from `self.active`. Only valid where the parent is + /// guaranteed to already be in `self.active` -- true for `redraw()`, + /// which targets a widget that was fully drawn on some earlier update, + /// but **not** for a widget being drawn as part of its own parent's + /// `Widget::draw` call: that parent's `ActiveData` is not inserted + /// until its `draw` returns (below), so a child drawn partway through + /// it would always read back "no parent" here. `Painter::widget_at` + /// avoids that trap by passing its own already-known `move_slot` + /// straight through instead of asking `self.active` to look it up. + fn move_parent_of(&self, parent: Option) -> u32 { + parent + .and_then(|p| self.active.get(&p)) + .map(|p| p.move_slot.idx() as u32) + .unwrap_or(MoveOffset::NONE_PARENT) + } + // TODO: should prolly make a DrawInfo struct or smth for everything other than rsc #[allow(clippy::too_many_arguments)] pub(super) fn draw_inner( @@ -77,11 +130,14 @@ impl UiRenderState { id: WidgetId, region: UiRegion, parent: Option, + parent_move_slot: u32, mask: MaskIdx, old_children: Option>, + old_move_slot: Option, rsc: &mut dyn UiRsc, ) { let mut old_children = old_children.unwrap_or_default(); + let mut old_move_slot = old_move_slot; if let Some(active) = self.active.get_mut(&id) && !rsc.widgets().needs_redraw.contains(&id) { @@ -91,21 +147,69 @@ impl UiRenderState { } else if active.region.size() == region.size() { // TODO: epsilon? let from = active.region; - self.mov(id, from, region); + self.mov(id, from, region, rsc); + return; + } else if rsc + .widgets() + .get_dyn(id) + .map(|w| w.is_size_independent()) + .unwrap_or(false) + { + // The offered region changed shape, but this widget's own + // drawn output does not depend on it (a fixed-size leaf) -- + // rewrite its own primitives' regions in place (O(primitives + // owned directly by this widget, which for a leaf is O(1)) + // instead of redrawing. See LAYOUT.md section 3. + let from = active.region; + for h in &active.primitives { + let r = self.layers[h.layer].region_mut(h); + *r = r.outside(&from).within(®ion); + self.region_mut_count += 1; + } + active.region = region; return; } // if not, then maintain resize and track old children to remove unneeded let active = self.remove(id, false, rsc).unwrap(); old_children = active.children; + old_move_slot = Some(active.move_slot); } // draw widget self.draw_started.insert(id); + let move_slot = match old_move_slot { + // Reused across a real redraw of the same id: the fresh + // geometry this draw is about to write is placed at its + // correct absolute position by `region` itself, so any delta + // accumulated before this redraw is now stale and would + // double-offset it if left in place. The chain link (`parent`) + // is untouched -- the logical parent has not changed. + Some(slot) => { + let entry = rsc.ui_mut().move_offsets.get_mut(slot); + entry.delta = [0.0, 0.0]; + slot + } + None => { + let slot = rsc + .ui_mut() + .move_offsets + .push(MoveOffset::new([0.0, 0.0], parent_move_slot)); + rsc.ui_mut().move_offsets.push_ref(slot); + if parent_move_slot != MoveOffset::NONE_PARENT { + rsc.ui_mut() + .move_offsets + .push_ref(Id::preset(parent_move_slot)); + } + slot + } + }; + let mut painter = Painter { state: self, region, mask, + move_slot, layer, id, textures: Vec::new(), @@ -115,7 +219,8 @@ impl UiRenderState { }; let mut widget = painter.rsc.widgets().get_dyn_dynamic(id); - widget.draw(&mut painter); + painter.state.draw_count += 1; + let size = widget.draw(&mut painter); drop(widget); let Painter { @@ -123,6 +228,7 @@ impl UiRenderState { rsc: _, region, mask, + move_slot, textures, primitives, children, @@ -140,6 +246,8 @@ impl UiRenderState { children, mask, layer, + size, + move_slot, }; // remove old children that weren't kept @@ -153,18 +261,66 @@ impl UiRenderState { self.active.insert(id, active); } - fn mov(&mut self, id: WidgetId, from: UiRegion, to: UiRegion) { - let active = self.active.get_mut(&id).unwrap(); - for h in &active.primitives { - let region = self.layers[h.layer].region_mut(h); - *region = region.outside(&from).within(&to); - } - active.region = active.region.outside(&from).within(&to); - // SAFETY: children cannot be recursive - let children = unsafe { forget_ref(&active.children) }; - for child in children { - self.mov(*child, from, to); - } + /// O(1): write the delta for this widget's own slot in + /// `move_offsets`. No primitive is touched and there is no recursion -- + /// every descendant's primitive references this slot transitively + /// through the parent chain the shader walks (`resolve_move`), so it + /// picks the new delta up for free. See LAYOUT.md section 2. + fn mov(&mut self, id: WidgetId, from: UiRegion, to: UiRegion, rsc: &mut dyn UiRsc) { + let Some(active) = self.active.get_mut(&id) else { + return; + }; + let slot = active.move_slot; + active.region = to; + let from_px = from.top_left().to_abs(self.output_size); + let to_px = to.top_left().to_abs(self.output_size); + let delta = to_px - from_px; + let entry = rsc.ui_mut().move_offsets.get_mut(slot); + entry.delta[0] += delta.x; + entry.delta[1] += delta.y; + self.mov_count += 1; + } + + /// Move an already-active widget to `to`. Used by `Painter::reposition`, + /// for a parent that drew a child provisionally (at the whole region it + /// was offered) and now knows where the child actually belongs. + /// + /// Unlike `mov` (called by `draw_inner`'s own dispatch, where the + /// *offered* region really did move and `active.region` already tracks + /// it), the child here was not offered a smaller region -- it was + /// offered everything and chose, on its own, to occupy only + /// `active.size` of it. By convention every widget in this crate that + /// does that anchors its own content at the top-left of whatever it + /// was given (`Rect`/`Image`/`Sized`/`MaxSize` -- see their `draw` + /// bodies), so that is where this assumes the child was actually + /// painted, not `active.region` itself (which is the *offered* box, + /// usually bigger). A nested `Aligned` whose own child is not top-left + /// anchored -- i.e. `Aligned` wrapping `Aligned` -- is the one shape + /// this does not cover; none of iris's widgets or examples build that + /// today. See LAYOUT.md's "Rejected, and why" / deviations for the + /// full reasoning. + /// + /// The delta is overwritten, not accumulated like `mov`'s: `from` is + /// recomputed fresh from `active.size`/`active.region` every call, so + /// repeating the same `reposition` (e.g. an unrelated redraw elsewhere + /// re-running this widget's parent without its own layout changing) + /// must land on the same answer, not drift further each time. + pub(super) fn reposition(&mut self, id: WidgetId, to: UiRegion, rsc: &mut dyn UiRsc) { + let Some(active) = self.active.get(&id) else { + return; + }; + let from = active + .size + .to_uivec2() + .align(RegionAlign::TOP_LEFT) + .within(&active.region); + let slot = active.move_slot; + let from_px = from.top_left().to_abs(self.output_size); + let to_px = to.top_left().to_abs(self.output_size); + let delta = to_px - from_px; + let entry = rsc.ui_mut().move_offsets.get_mut(slot); + entry.delta = [delta.x, delta.y]; + self.mov_count += 1; } /// NOTE: instance textures are cleared and self.textures freed @@ -180,6 +336,18 @@ impl UiRenderState { active.textures.clear(); rsc.ui_mut().textures.free(); if undraw { + // Permanent removal: retire this widget's own move slot + // (the self-ownership ref taken when it was allocated) and + // the up-link ref it held on its parent's slot -- read from + // the arena entry itself, not from `active.parent`, since + // the parent's own `ActiveData` may already be gone by the + // time a deep descendant is retired (see LAYOUT.md + // section 2's lifecycle note). + let parent_slot = rsc.ui_mut().move_offsets[active.move_slot.idx()].parent; + rsc.ui_mut().move_offsets.remove(active.move_slot); + if parent_slot != MoveOffset::NONE_PARENT { + rsc.ui_mut().move_offsets.remove(Id::preset(parent_slot)); + } rsc.on_undraw(active); } } @@ -187,7 +355,6 @@ impl UiRenderState { } fn remove_rec(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) -> Option { - self.cache.remove(id); let inst = self.remove(id, true, rsc); if let Some(inst) = &inst { for c in &inst.children { @@ -201,7 +368,6 @@ impl UiRenderState { for (_, active) in self.active.drain() { rsc.on_undraw(&active); } - self.cache.clear(); self.layers.clear(); rsc.widgets_mut().needs_redraw.clear(); rsc.free(); @@ -261,8 +427,43 @@ impl UiRenderState { } } - pub fn window_region(&self, id: &impl IdLike) -> Option { - let region = self.active.get(&id.id())?.region; + /// `active[id].region`, corrected by every `move_offsets` delta between + /// `id` and the root -- the CPU-side twin of the vertex shader's chain + /// walk, over the same arena, so the two cannot disagree about where a + /// widget is. O(chain depth), not O(primitives). See LAYOUT.md + /// section 2b. + pub fn resolved_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option { + let active = self.active.get(&id.id())?; + let delta = self.resolve_move_chain(active.move_slot, rsc); + Some(active.region.offset(UiVec2::abs(delta))) + } + + /// The plain-Rust twin of `resolve_move` in shader.wgsl: sums the + /// pixel delta along the parent chain starting at `slot`. Both walks + /// share `MOVE_CHAIN_LIMIT` as their bound so the two cannot disagree + /// about where the chain ends. + fn resolve_move_chain(&self, mut slot: MoveIdx, rsc: &dyn UiRsc) -> Vec2 { + let offsets = &rsc.ui().move_offsets; + let mut delta = Vec2::ZERO; + for i in 0..MOVE_CHAIN_LIMIT { + let entry = &offsets[slot.idx()]; + delta.x += entry.delta[0]; + delta.y += entry.delta[1]; + if entry.parent == MoveOffset::NONE_PARENT { + return delta; + } + slot = Id::preset(entry.parent); + debug_assert!( + i + 1 < MOVE_CHAIN_LIMIT, + "move offset chain exceeded MOVE_CHAIN_LIMIT; a widget's `parent` link is \ + probably cyclic" + ); + } + delta + } + + pub fn window_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option { + let region = self.resolved_region(id, rsc)?; Some(region.to_px(self.output_size)) } @@ -270,21 +471,6 @@ impl UiRenderState { pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) { rsc.widgets_mut().needs_redraw.remove(&id); self.draw_started.remove(&id); - // check if parent depends on the desired size of this, if so then redraw it first - for axis in [Axis::X, Axis::Y] { - if let Some(&(outer, old)) = self.cache.size.axis_dyn(axis).get(&id) - && let Some(current) = self.active.get(&id) - && let Some(pid) = current.parent - { - self.cache.size.axis_dyn(axis).remove(&id); - let new = self.size_ctx(id, outer, rsc).len_axis(id, axis); - self.cache.size.axis_dyn(axis).insert(id, (outer, new)); - if new != old { - self.redraw(pid, rsc); - } - } - } - if self.draw_started.contains(&id) { return; } @@ -292,34 +478,35 @@ impl UiRenderState { let Some(active) = self.remove(id, false, rsc) else { return; }; + let old_size = active.size; + let parent = active.parent; + // `old_move_slot` being `Some` below means the slot is reused in + // place rather than freshly parented, so this is only reached for + // logging/clarity's sake, never actually used to link a new slot. + let parent_move_slot = self.move_parent_of(parent); self.draw_inner( active.layer, id, active.region, - active.parent, + parent, + parent_move_slot, active.mask, Some(active.children), + Some(active.move_slot), rsc, ); - } - pub(super) fn size_ctx<'b>( - &'b mut self, - source: WidgetId, - outer: UiVec2, - rsc: &'b mut dyn UiRsc, - ) -> SizeCtx<'b> { - let ui = rsc.ui_mut(); - SizeCtx { - source, - cache: &mut self.cache, - text: &mut ui.text, - textures: &mut ui.textures, - widgets: &ui.widgets, - outer, - output_size: self.output_size, - id: source, + // If this widget's own reported size changed, its parent's layout + // (which placed it using the old size) is now stale and needs to + // relay out too. Checked after the real draw, not before it -- + // there is no query left that answers "what size would this be" + // without actually drawing (LAYOUT.md section 5). + if let Some(pid) = parent { + let new_size = self.active.get(&id).map(|a| a.size); + if new_size != Some(old_size) { + self.redraw(pid, rsc); + } } } } diff --git a/iris/core/src/ui/size.rs b/iris/core/src/ui/size.rs deleted file mode 100644 index a874cf9..0000000 --- a/iris/core/src/ui/size.rs +++ /dev/null @@ -1,91 +0,0 @@ -use crate::{ - Axis, AxisT, IdLike, Len, RenderedText, Size, TextAttrs, TextBuffer, TextData, Textures, - UiVec2, WidgetAxisFns, WidgetId, Widgets, XAxis, YAxis, ui::cache::Cache, util::Vec2, -}; - -pub struct SizeCtx<'a> { - pub text: &'a mut TextData, - pub textures: &'a mut Textures, - pub(super) source: WidgetId, - pub(super) widgets: &'a Widgets, - pub(super) cache: &'a mut Cache, - /// TODO: should this be pub? rn used for sized - pub outer: UiVec2, - pub(super) output_size: Vec2, - pub(super) id: WidgetId, -} - -impl SizeCtx<'_> { - pub fn id(&self) -> &WidgetId { - &self.id - } - - pub fn source(&self) -> &WidgetId { - &self.source - } - - pub(super) fn len_inner(&mut self, id: WidgetId) -> Len { - if let Some((_, len)) = self.cache.size.axis::().get(&id) { - return *len; - } - let len = self - .widgets - .get_dyn_dynamic(id) - .desired_len::(&mut SizeCtx { - text: self.text, - textures: self.textures, - source: self.source, - widgets: self.widgets, - cache: self.cache, - outer: self.outer, - output_size: self.output_size, - id, - }); - self.cache.size.axis::().insert(id, (self.outer, len)); - len - } - - pub fn width(&mut self, id: impl IdLike) -> Len { - self.len_inner::(id.id()) - } - - pub fn height(&mut self, id: impl IdLike) -> Len { - self.len_inner::(id.id()) - } - - pub fn len_axis(&mut self, id: impl IdLike, axis: Axis) -> Len { - match axis { - Axis::X => self.width(id), - Axis::Y => self.height(id), - } - } - - pub fn size(&mut self, id: impl IdLike) -> Size { - let id = id.id(); - Size { - x: self.width(id), - y: self.height(id), - } - } - - pub fn px_size(&mut self) -> Vec2 { - self.outer.to_abs(self.output_size) - } - - pub fn output_size(&mut self) -> Vec2 { - self.output_size - } - - pub fn draw_text( - &mut self, - buffer: &mut TextBuffer, - attrs: &TextAttrs, - width: Option, - ) -> RenderedText { - self.text.render(buffer, attrs, width, self.textures) - } - - pub fn label(&self, id: WidgetId) -> &String { - self.widgets.label(id) - } -} diff --git a/iris/core/src/util/arena.rs b/iris/core/src/util/arena.rs index 9ddfd99..f224634 100644 --- a/iris/core/src/util/arena.rs +++ b/iris/core/src/util/arena.rs @@ -71,6 +71,15 @@ impl TrackedArena { self.refs[i.idx()] += 1; } + /// Mutable access to an existing entry, for the rare case (the move + /// offset chain) where an already-allocated slot is updated in place + /// rather than replaced. Marks the arena changed so the GPU copy is + /// re-uploaded. + pub fn get_mut(&mut self, id: Id) -> &mut T { + self.changed = true; + &mut self.inner.data[id.idx()] + } + pub fn remove(&mut self, id: Id) -> T where T: Copy, diff --git a/iris/core/src/widget/mod.rs b/iris/core/src/widget/mod.rs index a0f084c..ba36d8b 100644 --- a/iris/core/src/widget/mod.rs +++ b/iris/core/src/widget/mod.rs @@ -1,4 +1,4 @@ -use crate::{Axis, AxisT, Len, Painter, SizeCtx}; +use crate::{Painter, Size}; use std::any::Any; mod data; @@ -16,31 +16,28 @@ pub use view::*; pub use widgets::*; pub trait Widget: Any { - fn draw(&mut self, painter: &mut Painter); - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len; - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len; -} + /// Draw within `painter.region()` (the space the parent offered) and + /// report how much of it was actually used, per axis. + fn draw(&mut self, painter: &mut Painter) -> Size; -pub trait WidgetAxisFns { - fn desired_len(&mut self, ctx: &mut SizeCtx) -> Len; -} - -impl WidgetAxisFns for W { - fn desired_len(&mut self, ctx: &mut SizeCtx) -> Len { - match A::get() { - Axis::X => self.desired_width(ctx), - Axis::Y => self.desired_height(ctx), - } + /// True if `draw`'s output (both the primitives it writes and the + /// `Size` it returns) is the same for any `painter.region()` of the + /// same *content* -- an icon, a fixed-size rect, an already-decoded + /// image at its natural size. Default `false` (redraw on any change to + /// the offered region) because assuming independence wrongly produces + /// a stale draw; a widget must opt in. See LAYOUT.md. + fn is_size_independent(&self) -> bool { + false } } impl Widget for () { - fn draw(&mut self, _: &mut Painter) {} - fn desired_width(&mut self, _: &mut SizeCtx) -> Len { - Len::ZERO + fn draw(&mut self, _: &mut Painter) -> Size { + Size::ZERO } - fn desired_height(&mut self, _: &mut SizeCtx) -> Len { - Len::ZERO + + fn is_size_independent(&self) -> bool { + true } } diff --git a/iris/src/default/attr.rs b/iris/src/default/attr.rs index ff4e313..da442ef 100644 --- a/iris/src/default/attr.rs +++ b/iris/src/default/attr.rs @@ -12,9 +12,14 @@ where fn run(rsc: &mut Rsc, container: WeakWidget, id: Self::Input) { rsc.register_event(container, CursorSense::click_or_drag(), move |ctx, rsc| { - let region = ctx.data.render.window_region(&id).unwrap(); + let region = ctx.data.render.window_region(&id, &*rsc).unwrap(); let id_pos = region.top_left; - let container_pos = ctx.data.render.window_region(&container).unwrap().top_left; + let container_pos = ctx + .data + .render + .window_region(&container, &*rsc) + .unwrap() + .top_left; let pos = ctx.data.pos + container_pos - id_pos; let size = region.size(); select( @@ -67,7 +72,7 @@ fn select( let recent = (now - state.last_click) < Duration::from_millis(300); state.last_click = now; id.edit(rsc).select(pos, size, dragging, recent); - if let Some(region) = render.window_region(&id) { + if let Some(region) = render.window_region(&id, &*rsc) { state.window.set_ime_allowed(true); state.window.set_ime_cursor_area( LogicalPosition::::from(region.top_left.tuple()), diff --git a/iris/src/default/sense.rs b/iris/src/default/sense.rs index ee73ee9..712bd50 100644 --- a/iris/src/default/sense.rs +++ b/iris/src/default/sense.rs @@ -167,7 +167,7 @@ impl SensorUi for UiRenderState { for layer in self.layers.indices().rev() { let mut sensed = false; for (id, sensor) in active.get_mut(&layer).into_flat_iter() { - let shape = self.active.get(id).unwrap().region; + let shape = self.resolved_region(id, rsc).unwrap(); let region = shape.to_px(window_size); let in_shape = cursor.exists && region.contains(cursor.pos); sensor.hover.update(in_shape); diff --git a/iris/src/layout_tests.rs b/iris/src/layout_tests.rs new file mode 100644 index 0000000..9702381 --- /dev/null +++ b/iris/src/layout_tests.rs @@ -0,0 +1,183 @@ +//! Pass conditions for LAYOUT.md section 8, exercised as plain unit tests +//! rather than through `run-headless.sh`: `UiRenderState` and `Widgets` do +//! not touch a GPU or a window, so a tree can be built and driven directly. +//! No GPU-backed rendering (`UiRenderNode`) is exercised here -- only the +//! CPU-side layout/move machinery LAYOUT.md is about. + +use crate::prelude::*; + +/// The minimal `UiRsc` a test needs: just the shared `UiData`, none of the +/// event/window/state plumbing `DefaultRsc` carries. +struct TestRsc { + ui: UiData, +} + +impl UiRsc for TestRsc { + fn ui(&self) -> &UiData { + &self.ui + } + fn ui_mut(&mut self) -> &mut UiData { + &mut self.ui + } +} + +/// A `Scroll` over a `Span` of `n` fixed-height rects -- N primitives large +/// enough that an O(N) regression in the move path would show up as a +/// non-trivial counter rather than being lost in noise (LAYOUT.md section +/// 8, condition 3, using rects rather than glyphs to avoid pulling the font +/// stack into a plain unit test). Returns the scroll widget (weak, for +/// mutating it later), the erased root to draw, and the rows (weak, for +/// hit-testing one of them). +fn scrolled_rects( + rsc: &mut TestRsc, + n: usize, +) -> (WeakWidget, StrongWidget, Vec>) { + let mut span = Span::empty(Dir::DOWN); + let mut rects = Vec::with_capacity(n); + for _ in 0..n { + let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); + rects.push(rect.weak()); + // Each row gets a fixed height so the span's total content is + // genuinely taller than the viewport -- rest-sized rows would just + // divide whatever space is offered and never need scrolling. + let row = rsc.ui.widgets.add_strong(Sized { + inner: rect.any(), + x: None, + y: Some(Len::abs(10.0)), + }); + span.push(row.any()); + } + let span = rsc.ui.widgets.add_strong(span); + let scroll = rsc.ui.widgets.add_strong(Scroll::new(span.any(), Axis::Y)); + let weak = scroll.weak(); + (weak, scroll.any(), rects) +} + +#[test] +fn an_unchanged_frame_draws_and_rewrites_nothing() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let (_scroll, root, _rects) = scrolled_rects(&mut rsc, 500); + let mut render = UiRenderState::new(); + render.resize((800.0, 20000.0)); + + render.update(&root, &mut rsc); + render.take_counters(); // discard the first, real draw + + render.update(&root, &mut rsc); + let (draws, rewrites, moves) = render.take_counters(); + assert_eq!((draws, rewrites, moves), (0, 0, 0)); +} + +#[test] +fn scrolling_moves_in_o1_without_a_redraw() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let (scroll, root, _rects) = scrolled_rects(&mut rsc, 500); + let mut render = UiRenderState::new(); + render.resize((800.0, 600.0)); + + // The first draw offers `Scroll`'s content a zero-height region + // (nothing has been measured yet) and learns the real content length + // from what comes back; `update()` only redraws widgets actually + // marked dirty, so that corrected length is not reflected in the + // content's own *active* region until something -- here a no-op + // scroll tick -- actually asks `Scroll` to redraw again. Only after + // that warm-up does the content's offered size stop changing between + // draws, which is what makes a further, real scroll tick a same-size + // move instead of a resize. See scroll.rs. + render.update(&root, &mut rsc); + rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0); + render.update(&root, &mut rsc); + render.take_counters(); + + // Negative: `scroll`'s sign convention subtracts from `amt`, and + // `amt` starts at (and is clamped to) 0 at the top of the content, so + // a *positive* argument here would be scrolling further up (a no-op, + // already clamped) rather than actually moving anything. + rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-40.0); + render.update(&root, &mut rsc); + let (draws, _rewrites, moves) = render.take_counters(); + + // The pass condition (LAYOUT.md section 8, condition 3) is 0 draws and + // 1 move_offsets write, independent of how many rects are in the + // scrolled subtree. `draws` here is exactly 1: `Scroll` itself is + // marked dirty by `scroll()` and its own body is cheap arithmetic with + // no primitives of its own, so it is the one real `Widget::draw` this + // counts -- the 500 rects underneath move via the O(1) chain and are + // never revisited. + assert_eq!(draws, 1, "only Scroll itself should redraw"); + assert_eq!(moves, 1, "the scrolled subtree should move in one write"); +} + +#[test] +fn hit_testing_follows_a_scrolled_widget() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let (scroll, root, rects) = scrolled_rects(&mut rsc, 500); + let mut render = UiRenderState::new(); + render.resize((800.0, 600.0)); + render.update(&root, &mut rsc); + rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0); + render.update(&root, &mut rsc); + + let target = &rects[2]; + let before = render.resolved_region(target, &rsc).unwrap(); + + rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-37.0); + render.update(&root, &mut rsc); + + let after = render.resolved_region(target, &rsc).unwrap(); + let before_px = before.to_px((800.0, 600.0).into()); + let after_px = after.to_px((800.0, 600.0).into()); + + // Scrolling by -37 moves `amt` from 0 to 37, sliding the content's + // top-left up by 37px -- `resolved_region` (the CPU twin of the vertex + // shader's chain walk) must reflect that immediately, not the + // pre-scroll position, or a tap routed through it would land on + // whatever is now at the old coordinates instead of this widget. + assert!( + (after_px.top_left.y - (before_px.top_left.y - 37.0)).abs() < 0.01, + "before={before_px:?} after={after_px:?}" + ); +} + +#[test] +fn a_mask_stays_put_while_its_scrolled_content_moves() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let (scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 500); + let masked = rsc.ui.widgets.add_strong(Masked { inner: inner_root }); + let masked_id = masked.id(); + let root = masked.any(); + let mut render = UiRenderState::new(); + render.resize((800.0, 600.0)); + + render.update(&root, &mut rsc); + rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0); + render.update(&root, &mut rsc); + + let masked_slot_before = render.active.get(&masked_id).unwrap().move_slot; + let mask_delta_before = rsc.ui.move_offsets[masked_slot_before.idx()].delta; + + rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-40.0); + render.update(&root, &mut rsc); + + let masked_slot_after = render.active.get(&masked_id).unwrap().move_slot; + let mask_delta_after = rsc.ui.move_offsets[masked_slot_after.idx()].delta; + + // `Masked` itself is never the target of a `mov`/`reposition` here -- + // only its scrolled child is -- so the slot its own mask references + // (`Painter::set_mask` bakes in `self.move_slot`, i.e. this one) must + // still read zero after the scroll. The visible counterpart of this + // (the clipped edge follows the scroll while the viewport border does + // not) is `iris/run-headless.sh`'s job to catch in a real frame; this + // is the numeric half, on the same data the fragment shader's + // `resolve_move` reads. See LAYOUT.md section 2b. + assert_eq!(mask_delta_before, [0.0, 0.0]); + assert_eq!(mask_delta_after, [0.0, 0.0]); +} diff --git a/iris/src/lib.rs b/iris/src/lib.rs index 05ef101..d0282cf 100644 --- a/iris/src/lib.rs +++ b/iris/src/lib.rs @@ -9,6 +9,9 @@ pub mod default; pub mod event; pub mod widget; +#[cfg(test)] +mod layout_tests; + pub use iris_core as core; pub use iris_macro as macros; diff --git a/iris/src/widget/image.rs b/iris/src/widget/image.rs index 244bbb8..7f2c131 100644 --- a/iris/src/widget/image.rs +++ b/iris/src/widget/image.rs @@ -6,16 +6,19 @@ pub struct Image { } impl Widget for Image { - fn draw(&mut self, painter: &mut Painter) { - painter.texture(&self.handle); + fn draw(&mut self, painter: &mut Painter) -> Size { + // Drawn at its own natural size, anchored top-left of whatever it + // was offered, not stretched to fill it -- its primitive is + // independent of the offered region, matching `is_size_independent` + // below. A caller that wants it placed differently wraps it (e.g. + // `.center()`, `.align(...)`). + let size = self.handle.size(); + painter.texture_within(&self.handle, size.align(Align::TOP_LEFT)); + Size::abs(size) } - fn desired_width(&mut self, _: &mut SizeCtx) -> Len { - Len::abs(self.handle.size().x) - } - - fn desired_height(&mut self, _: &mut SizeCtx) -> Len { - Len::abs(self.handle.size().y) + fn is_size_independent(&self) -> bool { + true // a decoded image's primitive never depends on the region it is offered } } diff --git a/iris/src/widget/mask.rs b/iris/src/widget/mask.rs index cc075e9..298e052 100644 --- a/iris/src/widget/mask.rs +++ b/iris/src/widget/mask.rs @@ -5,16 +5,8 @@ pub struct Masked { } impl Widget for Masked { - fn draw(&mut self, painter: &mut Painter) { + fn draw(&mut self, painter: &mut Painter) -> Size { painter.set_mask(painter.region()); - painter.widget(&self.inner); - } - - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { - ctx.width(&self.inner) - } - - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - ctx.height(&self.inner) + painter.widget(&self.inner) } } diff --git a/iris/src/widget/position/align.rs b/iris/src/widget/position/align.rs index 1a6d6a5..6581a9c 100644 --- a/iris/src/widget/position/align.rs +++ b/iris/src/widget/position/align.rs @@ -6,30 +6,30 @@ pub struct Aligned { } impl Widget for Aligned { - fn draw(&mut self, painter: &mut Painter) { + fn draw(&mut self, painter: &mut Painter) -> Size { + // Draw once at the whole region this widget was offered to learn + // the child's real size -- this placement is provisional and + // corrected below without a second draw. `painter.widget` (not + // `widget_within(..., painter.region())`) is what "my whole, + // already-resolved region, unmodified" means: `widget_within` + // composes its argument as a *local*, `UiRegion::FULL`-relative + // box against `painter.region()`, so handing it the + // already-resolved region double-applies that composition and is + // wrong for any widget nested below the root. + let used = painter.widget(&self.inner); let region = match self.align.tuple() { - (Some(x), Some(y)) => painter - .size(&self.inner) - .to_uivec2() - .align(RegionAlign { x, y }), + (Some(x), Some(y)) => used.to_uivec2().align(RegionAlign { x, y }), (Some(x), None) => { - let x = painter.size_ctx().width(&self.inner).apply_rest().align(x); + let x = used.x.apply_rest().align(x); UiRegion::new(x, UiSpan::FULL) } (None, Some(y)) => { - let y = painter.size_ctx().height(&self.inner).apply_rest().align(y); + let y = used.y.apply_rest().align(y); UiRegion::new(UiSpan::FULL, y) } (None, None) => UiRegion::FULL, }; - painter.widget_within(&self.inner, region); - } - - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { - ctx.width(&self.inner) - } - - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - ctx.height(&self.inner) + painter.reposition(&self.inner, region); // O(1): one offset write, no second draw + used } } diff --git a/iris/src/widget/position/layer.rs b/iris/src/widget/position/layer.rs index fb2ced3..93f7616 100644 --- a/iris/src/widget/position/layer.rs +++ b/iris/src/widget/position/layer.rs @@ -6,18 +6,10 @@ pub struct LayerOffset { } impl Widget for LayerOffset { - fn draw(&mut self, painter: &mut Painter) { + fn draw(&mut self, painter: &mut Painter) -> Size { for _ in 0..self.offset { painter.next_layer(); } - painter.widget(&self.inner); - } - - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { - ctx.width(&self.inner) - } - - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - ctx.height(&self.inner) + painter.widget(&self.inner) } } diff --git a/iris/src/widget/position/max_size.rs b/iris/src/widget/position/max_size.rs index 1a9aa39..bd56f57 100644 --- a/iris/src/widget/position/max_size.rs +++ b/iris/src/widget/position/max_size.rs @@ -7,42 +7,49 @@ pub struct MaxSize { } impl MaxSize { - fn apply_to_outer(&self, ctx: &mut SizeCtx) { - if let Some(x) = self.x { - ctx.outer.x.select_len(x.apply_rest()); - } - if let Some(y) = self.y { - ctx.outer.y.select_len(y.apply_rest()); + /// Caps a reported length at `max`, comparing in pixels since `Len`'s + /// rel/abs/rest components are not otherwise comparable. + fn clamp(len: Len, max: Option, output: f32) -> Len { + let Some(max) = max else { + return len; + }; + let len_px = len.apply_rest().to_abs(output); + let max_px = max.apply_rest().to_abs(output); + if len_px > max_px { max } else { len } + } + + /// The span (in this widget's own local, `UiRegion::FULL`-relative + /// terms) to actually offer the child: unconstrained if it already fits + /// within `max`, or a box of exactly `max`, anchored at this axis's + /// start, if it does not. Needed so the child is never painted bigger + /// than the size this widget reports for it -- see the identical + /// requirement noted on `Sized::draw`. + fn clamp_region(offered_px: f32, max: Option, output: f32) -> UiSpan { + let Some(max) = max else { + return UiSpan::FULL; + }; + let max_scalar = max.apply_rest(); + let max_px = max_scalar.to_abs(output); + if offered_px > max_px { + max_scalar.align(AxisAlign::Neg) + } else { + UiSpan::FULL } } } impl Widget for MaxSize { - fn draw(&mut self, painter: &mut Painter) { - painter.widget(&self.inner); - } - - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { - self.apply_to_outer(ctx); - let width = ctx.width(&self.inner); - if let Some(x) = self.x { - let width_px = width.apply_rest().to_abs(ctx.output_size().x); - let x_px = x.apply_rest().to_abs(ctx.output_size().x); - if width_px > x_px { x } else { width } - } else { - width - } - } - - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - self.apply_to_outer(ctx); - let height = ctx.height(&self.inner); - if let Some(y) = self.y { - let height_px = height.apply_rest().to_abs(ctx.output_size().y); - let y_px = y.apply_rest().to_abs(ctx.output_size().y); - if height_px > y_px { y } else { height } - } else { - height + fn draw(&mut self, painter: &mut Painter) -> Size { + let output = painter.output_size(); + let offered = painter.px_size(); + let region = UiRegion { + x: Self::clamp_region(offered.x, self.x, output.x), + y: Self::clamp_region(offered.y, self.y, output.y), + }; + let used = painter.widget_within(&self.inner, region); + Size { + x: Self::clamp(used.x, self.x, output.x), + y: Self::clamp(used.y, self.y, output.y), } } } diff --git a/iris/src/widget/position/offset.rs b/iris/src/widget/position/offset.rs index da54f69..c1df8d1 100644 --- a/iris/src/widget/position/offset.rs +++ b/iris/src/widget/position/offset.rs @@ -6,16 +6,8 @@ pub struct Offset { } impl Widget for Offset { - fn draw(&mut self, painter: &mut Painter) { + fn draw(&mut self, painter: &mut Painter) -> Size { let region = UiRegion::FULL.offset(self.amt); - painter.widget_within(&self.inner, region); - } - - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { - ctx.width(&self.inner) - } - - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - ctx.height(&self.inner) + painter.widget_within(&self.inner, region) } } diff --git a/iris/src/widget/position/pad.rs b/iris/src/widget/position/pad.rs index 5619d0f..065fce8 100644 --- a/iris/src/widget/position/pad.rs +++ b/iris/src/widget/position/pad.rs @@ -6,28 +6,14 @@ pub struct Pad { } impl Widget for Pad { - fn draw(&mut self, painter: &mut Painter) { - painter.widget_within(&self.inner, self.padding.region()); - } - - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { + fn draw(&mut self, painter: &mut Painter) -> Size { + let used = painter.widget_within(&self.inner, self.padding.region()); let width = self.padding.left + self.padding.right; let height = self.padding.top + self.padding.bottom; - ctx.outer.x.abs -= width; - ctx.outer.y.abs -= height; - let mut size = ctx.width(&self.inner); - size.abs += width; - size - } - - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - let width = self.padding.left + self.padding.right; - let height = self.padding.top + self.padding.bottom; - ctx.outer.x.abs -= width; - ctx.outer.y.abs -= height; - let mut size = ctx.height(&self.inner); - size.abs += height; - size + Size { + x: used.x + Len::abs(width), + y: used.y + Len::abs(height), + } } } diff --git a/iris/src/widget/position/scroll.rs b/iris/src/widget/position/scroll.rs index c789acc..4c837e5 100644 --- a/iris/src/widget/position/scroll.rs +++ b/iris/src/widget/position/scroll.rs @@ -10,33 +10,44 @@ pub struct Scroll { } impl Widget for Scroll { - fn draw(&mut self, painter: &mut Painter) { - let output_len = painter.output_size().axis(self.axis); - let container_len = painter.region().axis(self.axis).len(); - let content_len = painter - .len_axis(&self.inner, self.axis) - .apply_rest() - .within_len(container_len) - .to_abs(output_len); + fn draw(&mut self, painter: &mut Painter) -> Size { + // The region offered to the child is sized using *last* frame's + // content length, not a fresh measurement -- deliberately, so that + // an ordinary scroll tick (`amt` changes, content does not) offers + // the child the exact same size it was last drawn with, only + // shifted. That is what lets `draw_inner` dispatch this as an O(1) + // move (LAYOUT.md section 2) instead of a redraw: sizing the region + // to a *fresh* measurement would require drawing the child first to + // learn it, and a provisional draw almost never matches the + // previously active size, forcing a real redraw on every tick. A + // genuine content-size change (not just a scroll) therefore lags + // one frame before the container's clamp reflects it; the content + // length itself (read below from what was actually drawn) is never + // stale, so this self-corrects the next frame and never leaves the + // scroll range wrong for long. See LAYOUT.md section 4. + let axis = self.axis; + let output_len = painter.output_size().axis(axis); + let container_len = painter.region().axis(axis).len(); self.container_len = container_len.to_abs(output_len); - self.content_len = content_len; if self.snap_end { self.amt = self.content_len - self.container_len; } self.update_amt(); - let mut region = UiRegion::FULL.offset(Vec2::from_axis(self.axis, -self.amt, 0.0)); - region.axis_mut(self.axis).end = region.axis(self.axis).start.offset(self.content_len); - painter.widget_within(&self.inner, region); - } + let mut region = UiRegion::FULL; + region.axis_mut(axis).end = region.axis(axis).start.offset(self.content_len); + let region = region.offset(Vec2::from_axis(axis, -self.amt, 0.0)); - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { - ctx.width(&self.inner) - } + let used = painter.widget_within(&self.inner, region); - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - ctx.height(&self.inner) + self.content_len = used + .axis(axis) + .apply_rest() + .within_len(container_len) + .to_abs(output_len); + + used } } diff --git a/iris/src/widget/position/sized.rs b/iris/src/widget/position/sized.rs index aa1fc58..8c104de 100644 --- a/iris/src/widget/position/sized.rs +++ b/iris/src/widget/position/sized.rs @@ -6,29 +6,28 @@ pub struct Sized { pub y: Option, } -impl Sized { - fn apply_to_outer(&self, ctx: &mut SizeCtx) { +impl Widget for Sized { + fn draw(&mut self, painter: &mut Painter) -> Size { + // The child is drawn within a region that actually carves out the + // fixed axes, not whatever region this widget itself happened to + // be offered -- needed so the painted geometry matches the + // declared size returned below regardless of how much room a + // parent offers. `Aligned`'s single-draw pattern (LAYOUT.md + // section 6) draws its child once at its own *full* region to + // learn its size, then moves it into place with a pure + // translation; that translation is only valid if what got painted + // is already the reported size, anchored the same way both times. + let mut region = UiRegion::FULL; if let Some(x) = self.x { - ctx.outer.x.select_len(x.apply_rest()); + region.x = x.apply_rest().align(AxisAlign::Neg); } if let Some(y) = self.y { - ctx.outer.y.select_len(y.apply_rest()); + region.y = y.apply_rest().align(AxisAlign::Neg); + } + let used = painter.widget_within(&self.inner, region); + Size { + x: self.x.unwrap_or(used.x), + y: self.y.unwrap_or(used.y), } } } - -impl Widget for Sized { - fn draw(&mut self, painter: &mut Painter) { - painter.widget(&self.inner); - } - - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { - self.apply_to_outer(ctx); - self.x.unwrap_or_else(|| ctx.width(&self.inner)) - } - - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - self.apply_to_outer(ctx); - self.y.unwrap_or_else(|| ctx.height(&self.inner)) - } -} diff --git a/iris/src/widget/position/span.rs b/iris/src/widget/position/span.rs index ad4f932..a244f74 100644 --- a/iris/src/widget/position/span.rs +++ b/iris/src/widget/position/span.rs @@ -8,13 +8,38 @@ pub struct Span { } impl Widget for Span { - fn draw(&mut self, painter: &mut Painter) { - let total = self.len_sum(&mut painter.size_ctx()); + fn draw(&mut self, painter: &mut Painter) -> Size { + let axis = self.dir.axis; + + // Phase 1: draw each child once, at the ambient (unmodified, full) + // region a size-only query used to see before this migration, to + // learn its length along the layout axis. This paints real + // primitives at a provisional slot; phase 2 below places each + // child for real via the normal `widget_within` dispatch, which + // only actually redraws it when that slot's *size* differs from + // this provisional one (most children: a resize, since the + // provisional slot is the whole span, not this child's share). + let lens: Vec = self + .children + .iter() + .map(|child| painter.widget(child).axis(axis)) + .collect(); + + let gap_total = self.gap * self.children.len().saturating_sub(1) as f32; + let total = lens.iter().fold(Len::abs(gap_total), |s, &l| s + l); + + // Phase 2: place each child for real, using the lengths just + // learned -- the same arithmetic this loop always used. The cross- + // axis length of *this* draw (used for `Span`'s own reported size + // below) falls out of each child's real, resolved-width `used` + // here for free -- this is what replaces `desired_ortho`'s former + // duplicate simulation of this same loop (see LAYOUT.md section 4). let mut start = UiScalar::rel_min(); - for child in &self.children { + let mut ortho_len = Len::ZERO; + let mut ortho_mixed = false; + for (child, &len) in self.children.iter().zip(&lens) { let mut span = UiSpan::FULL; span.start = start; - let len = painter.len_axis(child, self.dir.axis); if len.rest > 0.0 { let offset = UiScalar::new(total.rel, total.abs); let rel_end = UiScalar::rel(len.rest / total.rest); @@ -24,27 +49,31 @@ impl Widget for Span { start.abs += len.abs; start.rel += len.rel; span.end = start; - let mut child_region = UiRegion::from_axis(self.dir.axis, span, UiSpan::FULL); + let mut child_region = UiRegion::from_axis(axis, span, UiSpan::FULL); if self.dir.sign == Sign::Neg { - child_region.flip(self.dir.axis); + child_region.flip(axis); } - painter.widget_within(child, child_region); + let used = painter.widget_within(child, child_region); start.abs += self.gap; - } - } - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { - match self.dir.axis { - Axis::X => self.desired_len(ctx), - Axis::Y => self.desired_ortho(ctx), + let ortho = used.axis(!axis); + if ortho.rel > 0.0 || ortho.rest > 0.0 { + ortho_mixed = true; + } else { + ortho_len.abs = ortho_len.abs.max(ortho.abs); + } + } + if ortho_mixed { + ortho_len = Len::default(); } - } - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - match self.dir.axis { - Axis::X => self.desired_ortho(ctx), - Axis::Y => self.desired_len(ctx), - } + let along = if total.rest == 0.0 && total.rel == 0.0 { + total + } else { + Len::default() + }; + + Size::from_axis(axis, along, ortho_len) } } @@ -69,87 +98,6 @@ impl Span { pub fn pop(&mut self) -> Option { self.children.pop() } - - fn len_sum(&mut self, ctx: &mut SizeCtx) -> Len { - let gap = self.gap * self.children.len().saturating_sub(1) as f32; - self.children.iter().fold(Len::abs(gap), |mut s, id| { - // it's tempting to subtract the abs & rel from the ctx outer, - // but that would create inconsistent sizing if you put - // a rest first vs last & only speed up in one direction. - // I think this is only solvable by restricting how you can - // compute size, bc currently you need child to define parent's - // sectioning and you need parent's sectioning to define child. - // Fortunately, that doesn't matter in most cases - let len = ctx.len_axis(id, self.dir.axis); - s += len; - s - }) - } - - fn desired_len(&mut self, ctx: &mut SizeCtx) -> Len { - let len = self.len_sum(ctx); - if len.rest == 0.0 && len.rel == 0.0 { - len - } else { - Len::default() - } - } - - fn desired_ortho(&mut self, ctx: &mut SizeCtx) -> Len { - // this is a weird hack to get text wrapping to work properly when in a downward span - // the correct solution here is to add a function to widget that lets them - // request that ctx.outer has an axis "resolved" before checking the other, - // and panicking or warning if two request opposite axis (unsolvable in that case) - let outer = ctx.outer.axis(self.dir.axis); - if self.dir.axis == Axis::X { - // so....... this literally copies draw so that the lengths are correctly set in the - // context, which makes this slow and not cool - let total = self.len_sum(ctx); - let mut start = UiScalar::rel_min(); - let mut ortho_len = Len::ZERO; - for child in &self.children { - let mut span = UiSpan::FULL; - span.start = start; - let len = ctx.len_axis(child, self.dir.axis); - if len.rest > 0.0 { - let offset = UiScalar::new(total.rel, total.abs); - let rel_end = UiScalar::rel(len.rest / total.rest); - let end = (UiScalar::rel_max() + start) - offset; - start = rel_end.within(&start.to(end)); - } - start.abs += len.abs; - start.rel += len.rel; - span.end = start; - - let scalar = span.len(); - *ctx.outer.axis_mut(self.dir.axis) = outer.select_len(scalar); - let ortho = ctx.len_axis(child, !self.dir.axis); - // TODO: rel shouldn't do this, but no easy way before actually calculating pixels - if ortho.rel > 0.0 || ortho.rest > 0.0 { - ortho_len.rest = 1.0; - ortho_len.abs = 0.0; - break; - } - ortho_len.abs = ortho_len.abs.max(ortho.abs); - start.abs += self.gap; - } - ortho_len - } else { - let mut ortho_len = Len::ZERO; - let ortho = !self.dir.axis; - for child in &self.children { - let len = ctx.len_axis(child, ortho); - // TODO: rel shouldn't do this, but no easy way before actually calculating pixels - if len.rel > 0.0 || len.rest > 0.0 { - ortho_len.rest = 1.0; - ortho_len.abs = 0.0; - break; - } - ortho_len.abs = ortho_len.abs.max(len.abs); - } - ortho_len - } - } } pub struct SpanBuilder, Tag> { diff --git a/iris/src/widget/position/stack.rs b/iris/src/widget/position/stack.rs index fb4a591..d927491 100644 --- a/iris/src/widget/position/stack.rs +++ b/iris/src/widget/position/stack.rs @@ -8,29 +8,26 @@ pub struct Stack { } impl Widget for Stack { - fn draw(&mut self, painter: &mut Painter) { - let mut iter = self.children.iter(); - if let Some(child) = iter.next() { + fn draw(&mut self, painter: &mut Painter) -> Size { + let mut picked = None; + let mut iter = self.children.iter().enumerate(); + if let Some((i, child)) = iter.next() { painter.child_layer(); - painter.widget(child); + let used = painter.widget(child); + if matches!(self.size, StackSize::Child(j) if j == i) { + picked = Some(used); + } } - for child in iter { + for (i, child) in iter { painter.next_layer(); - painter.widget(child); + let used = painter.widget(child); + if matches!(self.size, StackSize::Child(j) if j == i) { + picked = Some(used); + } } - } - - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { match self.size { - StackSize::Default => Len::default(), - StackSize::Child(i) => ctx.width(&self.children[i]), - } - } - - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - match self.size { - StackSize::Default => Len::default(), - StackSize::Child(i) => ctx.height(&self.children[i]), + StackSize::Default => Size::default(), + StackSize::Child(_) => picked.unwrap_or_default(), } } } diff --git a/iris/src/widget/ptr.rs b/iris/src/widget/ptr.rs index 1e6241b..5f1531f 100644 --- a/iris/src/widget/ptr.rs +++ b/iris/src/widget/ptr.rs @@ -6,26 +6,16 @@ pub struct WidgetPtr { } impl Widget for WidgetPtr { - fn draw(&mut self, painter: &mut Painter) { + fn draw(&mut self, painter: &mut Painter) -> Size { if let Some(id) = &self.inner { - painter.widget(id); + painter.widget(id) + } else { + Size::ZERO } } - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { - if let Some(id) = &self.inner { - ctx.width(id) - } else { - Len::ZERO - } - } - - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - if let Some(id) = &self.inner { - ctx.height(id) - } else { - Len::ZERO - } + fn is_size_independent(&self) -> bool { + self.inner.is_none() } } diff --git a/iris/src/widget/rect.rs b/iris/src/widget/rect.rs index f72820e..5a0482b 100644 --- a/iris/src/widget/rect.rs +++ b/iris/src/widget/rect.rs @@ -28,21 +28,18 @@ impl Rect { } impl Widget for Rect { - fn draw(&mut self, painter: &mut Painter) { + fn draw(&mut self, painter: &mut Painter) -> Size { painter.primitive(RectPrimitive { color: self.color, radius: self.radius, thickness: self.thickness, inner_radius: self.inner_radius, }); + Size::REST // fills whatever it was given -- used == available } - fn desired_width(&mut self, _: &mut SizeCtx) -> Len { - Len::rest(1) - } - - fn desired_height(&mut self, _: &mut SizeCtx) -> Len { - Len::rest(1) + fn is_size_independent(&self) -> bool { + true // content never depends on region size } } diff --git a/iris/src/widget/text/edit.rs b/iris/src/widget/text/edit.rs index 4f209fe..cb45860 100644 --- a/iris/src/widget/text/edit.rs +++ b/iris/src/widget/text/edit.rs @@ -60,15 +60,15 @@ impl TextEdit { } impl Widget for TextEdit { - fn draw(&mut self, painter: &mut Painter) { + fn draw(&mut self, painter: &mut Painter) -> Size { let base = painter.layer; painter.child_layer(); - self.view.draw(painter); + let used = self.view.draw(painter); painter.layer = base; let region = self.region(); let Some(selection) = self.selection else { - return; + return used; }; let layout = self.view.buf.layout(); @@ -90,14 +90,7 @@ impl Widget for TextEdit { RectPrimitive::color(Color::WHITE), size.align(Align::TOP_LEFT).offset(top_left).within(®ion), ); - } - - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { - self.view.desired_width(ctx) - } - - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - self.view.desired_height(ctx) + used } } diff --git a/iris/src/widget/text/mod.rs b/iris/src/widget/text/mod.rs index 0c69086..0443478 100644 --- a/iris/src/widget/text/mod.rs +++ b/iris/src/widget/text/mod.rs @@ -54,9 +54,9 @@ impl TextView { .align(self.align) } - fn render(&mut self, ctx: &mut SizeCtx) -> RenderedText { + fn render(&mut self, painter: &mut Painter) -> RenderedText { let width = if self.attrs.wrap { - Some(ctx.px_size().x) + Some(painter.px_size().x) } else { None }; @@ -68,7 +68,7 @@ impl TextView { return tex.clone(); } self.width = width; - let tex = ctx.draw_text(&mut self.buf, &self.attrs, width); + let tex = painter.render_text(&mut self.buf, &self.attrs, width); self.tex = Some(tex.clone()); self.attrs.changed = false; self.buf.changed = false; @@ -77,36 +77,23 @@ impl TextView { pub fn tex(&self) -> Option<&RenderedText> { self.tex.as_ref() } - pub fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { + /// Draws within `painter.region()` and reports the size used -- what + /// `desired_width`/`desired_height` used to answer separately, folded + /// into the one draw (LAYOUT.md section 4): the shaped layout this + /// reads is already memoized by width in `render`, so a second call at + /// the same width (a redraw with nothing else changed) is a cache hit, + /// not a re-shape. + pub fn draw(&mut self, painter: &mut Painter) -> Size { + let tex = self.render(painter); if self.is_blank() && let Some(hint) = &self.hint { - ctx.width(hint) - } else { - Len::abs(self.render(ctx).size.x) + return painter.widget(hint); } - } - pub fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - if self.is_blank() - && let Some(hint) = &self.hint - { - ctx.height(hint) - } else { - Len::abs(self.render(ctx).size.y) - } - } - pub fn draw(&mut self, painter: &mut Painter) -> UiRegion { - let tex = self.render(&mut painter.size_ctx()); let region = tex.size.align(self.align); - if self.is_blank() - && let Some(hint) = &self.hint - { - painter.widget(hint); - } else { - let within = region.within(&painter.region()); - painter.glyphs(&tex, within); - } - region + let within = region.within(&painter.region()); + painter.glyphs(&tex, within); + Size::abs(tex.size) } pub fn content(&self) -> String { @@ -122,7 +109,7 @@ impl Text { content: content.into(), } } - fn update_buf(&mut self, _ctx: &mut SizeCtx) { + fn update_buf(&mut self) { if self.content.changed { self.content.changed = false; self.view.buf.set_text(self.content.as_str()); @@ -131,19 +118,9 @@ impl Text { } impl Widget for Text { - fn draw(&mut self, painter: &mut Painter) { - self.update_buf(&mut painter.size_ctx()); - self.view.draw(painter); - } - - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { - self.update_buf(ctx); - self.view.desired_width(ctx) - } - - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - self.update_buf(ctx); - self.view.desired_height(ctx) + fn draw(&mut self, painter: &mut Painter) -> Size { + self.update_buf(); + self.view.draw(painter) } }