diff --git a/docs/LAYOUT.md b/docs/LAYOUT.md index 53a0906..8ca09fa 100644 --- a/docs/LAYOUT.md +++ b/docs/LAYOUT.md @@ -4,9 +4,10 @@ A widget draws once and records its size on the `Painter`. Reading a child `DrawResult::size()` records a retained size dependency; drawing the child without reading that result does not make the parent's size depend on it. -§1 and §2 have landed in Iris (#16 and #18) and the notes below have been -brought to what shipped rather than what was proposed; §3 to §6 describe the -same design as it stands. `docs/HANDOFF.md` has the invariants +§1, §2 and §3 have landed in Iris (#16 and #18) and the notes below have been +brought to what shipped rather than what was proposed; §4 to §6 describe the +same design as it stands, and name types that have since been replaced where +they were written before it. `docs/HANDOFF.md` has the invariants the code now rests on and what is still to do. ## Design @@ -36,11 +37,12 @@ pub trait Widget: Any { fn draw(&mut self, painter: &mut Painter) -> Size; fn size_hint(&self, axis: Axis) -> Option { None } - - fn on_resize(&self, axis: Axis) -> OnResize { OnResize::Redraw } } ``` +Two methods, not three: `on_resize` was proposed here and shipped, and §3 +below replaced it with the `Holds` interval a widget declares while drawing. + A widget returns what it used of the box it was given. A child draw returns a `DrawResult` that keeps the painter borrowed; calling `.size()` on that result reads the child's retained size and records that the current @@ -48,15 +50,14 @@ widget depends on it. Dropping the result without reading it draws the child without making the parent's own size depend on the child's. No `available` parameter: `Painter` already carries the region the parent -handed down (`Painter::region()`, `core/src/ui/painter.rs:137`) and already -exposes the pixel-resolved form (`px_size()`, `:156`) and the output surface -size (`output_size()`, `:152`). Passing it again would be the same value -under a second name. `desired_width`/`desired_height` (`core/src/widget/mod.rs:20-21`) -and `WidgetAxisFns::desired_len` (`:24-35`) are deleted outright — not +handed down (`Painter::region()`) and already exposes the pixel-resolved form +(`px_size()`) and the output surface size (`output_size()`). Passing it again +would be the same value under a second name. `desired_width`/`desired_height` +and `WidgetAxisFns::desired_len` are deleted outright — not deprecated, not kept as a fallback — because a widget that implements both `draw` and `desired_*` for the same thing is exactly the "two names for one concept" the code rules call out, and it is what today's `Span::desired_ortho` -(`iris/src/widget/position/span.rs:98-152`) already complains about in its +(as it was then) already complains about in its own comment: "this literally copies draw so that the lengths are correctly set in the context, which makes this slow and not cool." Folding sizing into `draw` deletes that duplicate simulation, not just moves it. @@ -67,97 +68,89 @@ lying hint fails a debug assertion when the widget is drawn. ### 2. O(1) subtree movement -Every active widget owns a slot in `UiData::move_offsets`. A slot stores an -absolute-pixel delta and its parent slot; each primitive instance stores the -slot of the widget that drew it. The vertex shader walks this bounded chain -and adds the accumulated translation. Moving a subtree therefore writes one -slot instead of rewriting every descendant primitive. +A widget opts into one independently movable region with `.region_node()`, or +`Widgets::set_region_node` at runtime; `.scrollable()` sets it once as its +convenient default. A node holds a whole **box** -- a `UiRegion` in its parent +node's coordinates, `UiRegion::FULL` being the identity -- and each primitive +instance names the node it was drawn under. Moving a subtree through a node +writes one entry. A widget without the property shares the nearest ancestor's +node, and moving it remaps its retained primitive, mask and active regions +instead, stopping at any descendant node after rewriting that one entry. -The parent chain is required for independently movable nested subtrees, such -as a swipeable row inside a scrolling list. A flat offset table would require -rewriting the row whenever an ancestor moved and would restore the very -O(subtree) work this design removes. Chain depth is bounded in both Rust and -WGSL. +A box rather than a translation, because a pixel-space offset would scale a +child that has to keep its pixel length; the fraction and the offset in a +`UiScalar` are what tell the two apart. The parent chain is what makes nested +movable subtrees work -- a swipeable row inside a scrolling list -- and a flat +table would rewrite the row whenever an ancestor moved, which is the +`O(subtree)` work this removes. `CHAIN_LIMIT` bounds the walk at 64 in both +Rust (`core/src/ui/mod.rs`) and WGSL, so a malformed cycle resolves the same +way on each side. -`Painter::place` draws a child whose box its parent decides and may decide -again, and gives that child a slot of its own; `widget` and `widget_within` do -not, and share the nearest ancestor's. A slot carries a whole **box**, not a -translation: a pixel-space scale and offset would scale a child that has to -keep its pixel length, and the `rel`/`abs` pair is what distinguishes the two. -(That slots carry translation only was an agent's choice on 2026-09-04, never -asked for, and #18 replaced it.) +`Moves::resolve` performs the same walk on the CPU for hit testing, +accessibility and window-coordinate queries, and the shader's `resolve_move` +mirrors it. Coordinates cross as whole counts of `1/1024` px and `1/2^24` of +a box, which the shader decodes from constants the Rust side prepends: the +grid is stated once. Masks carry their own node and resolve it independently, +so a stationary viewport clips content that moves inside it. -`UiRenderState::resolved_region` performs the same chain walk on the CPU for -hit-testing, accessibility, and public window-coordinate queries. Masks store -the move slot of their owning widget and resolve it independently in the -fragment shader, so a stationary viewport can clip moving content. - -Slots follow `ActiveData`'s lifecycle. Removing a widget recursively retires -its slot only after descendants are gone, and a reused arena slot is reset -before new primitives can reference it. `Primitives::set_instance` also -cancels a dirty mark when provisional layout restores the original bytes, so -CPU-only measurement positions are never uploaded. +Nodes follow `ActiveData`'s lifecycle. Removing one retires its entry only +after every descendant has migrated, since reusing the index sooner would +make an old parent look current. Changing the property redraws the subtree +once, to rebuild the coordinate boundary; it belongs to widget identity, +which is safe because a widget has one parent. ### 3. Resize scope A resize is "the region a widget's parent offers it changes such that the -widget's draw might produce different output" — as opposed to a move, which -by construction cannot (§2 is scoped to pure translation). Two independent -narrowings apply, and both are real, measured properties of the code as it -stands rather than new machinery: +widget's draw might produce different output" -- as opposed to a move, which +by construction cannot. Two independent narrowings apply, and both are +measured properties of the code rather than new machinery: **(a) A window resize does not, by itself, require touching most widgets.** -`shader.wgsl` recomputes every primitive's pixel position from `window.dim` -and the primitive's stored `rel`/`abs` pair every frame, already, on the GPU. -A widget laid out purely in `rel`/`abs` terms is therefore already correct -after a resize with zero CPU work. Calls to `Painter::px_size` and -`Painter::output_size` mark both concrete-pixel axes; `px_len(axis)` and -`output_len(axis)` mark only the axis actually read. Only widgets whose read -axes changed by more than 0.05 physical pixels become dirty. The comparison -is against each widget's last actual draw, so smaller changes accumulate -rather than disappearing event by event. +The shader recomputes every primitive's position from `window.dim` and the +primitive's stored fraction and offset every frame, already, on the GPU. A +widget laid out purely in those terms is therefore correct after a resize +with no CPU work at all. -All pixel-dependent leaves are marked before layout begins, along with every -chain of parents that read their sizes. Resize then settles the shallowest -shared readers first, under the new output, so overlapping dependency paths -are drawn once. Ordinary content changes use the opposite order: deepest -dirty widgets first, with a changed returned size propagated one reader edge -at a time. Re-reporting the current output size is a no-op. +What decides the rest is `Holds`, one interval of box lengths per axis: +*give this widget any box in here and it draws the same thing and reports the +same size*. A widget that never reads its box in pixels holds for every +length. Reading `Painter::px_len(axis)` or `px_size()` narrows the interval +to the length read, and `Painter::holds` is how a widget widens it again by +saying what its drawing actually depends on -- a greedy line break holds from +its longest line up to the width it was made at. A parent holds for whatever +keeps every child it asked about or drew inside its own range, each child's +interval translated into lengths of the parent's box. -**(b) A widget's `available` (its parent's offered region) can change -without the widget's *content* changing — this is what `Widget::on_resize` -answers, per axis.** When a container's own layout shifts (a sibling grew or -shrank, changing this widget's offered box), a widget that says `Scale` on the -axes that changed is not redrawn: everything it drew is a fraction of its own -slot's box, so writing that one box moves and stretches all of it. `Span`, -`Pad`, `Stack`, `Offset`, `Aligned`, `SetSize` and `LayerOffset` say `Scale`; -`Scroll` and `MaxSize` read their box in pixels and cannot. `Redraw`, the -default, is correct always and free never. `Translate` — an unchanged drawing -placed somewhere else in a bigger box — is reserved: nothing reads it until a -widget can say where in that box its drawing belongs, which is the alignment -work. +This replaced `Widget::on_resize` and its `Scale`/`Redraw`/`Translate` +answers, which said the same thing per widget type and could not say *how +far*. There is no per-widget resize mode now: a widget that reads nothing is +never redrawn for a resize, one that reads its width is redrawn when its +width leaves the interval it declared, and the interval is the whole of the +statement. Do not restore `Translate`; a retained subtree that only moves is +remapped through the box chain of §2, exactly. -**Size invalidation travels upward before drawing; drawing itself travels only -downward.** Every active widget retains the direct children whose size it read -through `DrawResult::size()` or `Painter::known_len`. `redraw_updates` takes -one id from the dirty set, follows only those dependency edges upward and marks -that path dirty, then redraws its highest already-dirty ancestor. Drawing that -ancestor consumes the marks of every dirty descendant it reaches; the loop -then takes whatever remains. Drawing never synchronously invalidates or invokes -a parent, so there is no layout recursion and no provisional child draw on a -different layer. +Lengths are whole counts of `1/1024` px, so "the box changed" is equality +rather than a tolerance: a change too small to reach the next step is not a +change, and one that reaches it is, however little of a pixel it is worth. -An exact `size_hint` stops propagation when both axes still equal the retained -size. Otherwise propagation is deliberately conservative: the child may have -changed size, and only its dependent ancestors can assign the final boxes. -Unchanged descendants still take `draw_inner`'s retained skip-or-move path. -An active widget also retains which offered-box and output axes flowed into -the size it reported, directly or through a child size it read. A container -may use that answer for the same prospective box when every observed input is -still within 0.05 physical pixels; content dirtiness anywhere in its size -dependency subtree rejects the answer. This is a generic constraint rule, not -a text exception. Wrapped text is merely the common example: it reads width, -so changing only height leaves its answer valid. +**(b) Size invalidation travels upward before drawing; drawing itself travels +only downward.** Every active widget retains the direct children whose size it +read through `DrawResult::size()` or `Painter::known_len`. `redraw_updates` +takes one id from the dirty set, follows only those dependency edges upward +and marks that path dirty, then redraws its highest already-dirty ancestor. +Drawing that ancestor consumes the marks of every dirty descendant it +reaches; the loop then takes whatever remains. Drawing never synchronously +invalidates or invokes a parent, so there is no layout recursion. + +Dirty widgets settle deepest-first, and `dirty_size_under` stops a reader +taking a retained answer while something below that answer is still dirty -- +an optimisation against laying out twice rather than a second validity +mechanism. An exact `size_hint` stops propagation when both axes still equal +the retained size; otherwise propagation is deliberately conservative, since +only a dependent ancestor can assign the final boxes. This is a generic +constraint rule, not a text exception. Wrapped text is merely the common +example: it reads width, so changing only height leaves its answer valid. ### 4. Wrapped text, and "needs child height before choosing width"