Make Iris layout dependencies explicit

This commit is contained in:
iris committed 2026-09-09 22:35:03 -04:00
1 parent e5fee03da8
commit 4bc23172fd
28 files changed
+679 -227

No files matched your search

+51 -39
View File
@@ -1,4 +1,4 @@
# iris: one `draw` that reports a size
# iris: one `draw` that records a size
Iris, 2026-09-04:
@@ -8,9 +8,13 @@ Iris, 2026-09-04:
> done after as well. This should be done efficiently like everything else
> tries to do right now.
**Implemented 2026-09-04.** Every widget was migrated in one change; none
kept `desired_width`/`desired_height`. What is kept below is the design as
it stands, the five corrections implementation forced (read those before
**Implemented 2026-09-04; size dependencies made explicit 2026-09-09.**
Every widget was migrated in one change; none kept
`desired_width`/`desired_height`. `draw` no longer returns its size directly:
it records it once on its `Painter`, and a parent that reads a child draw's
`DrawResult::size()` records the retained dependency between them. What is
kept below is the design as it stands, the corrections implementation forced
(read those before
touching `Aligned`, `Sized`, `MaxSize`, `Scroll` or the move-slot lifecycle
in `render_state.rs` -- each is a real bug the first draft would have
reproduced), and the two later additions that build on it. The
@@ -25,7 +29,7 @@ having been carried out.
```rust
pub trait Widget: Any {
fn draw(&mut self, painter: &mut Painter) -> Size;
fn draw(&mut self, painter: &mut Painter);
fn size_hint(&self, axis: Axis) -> Option<Len> { None }
@@ -35,6 +39,12 @@ pub trait Widget: Any {
}
```
Every implementation calls `painter.set_size(size)` exactly once. 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
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
@@ -281,22 +291,25 @@ always a leaf: `Rect`, `Image`, a fixed glyph). A widget that returns
`false` (the default) is redrawn in full whenever `available` changes,
which is correct always, just not free.
**Size propagation goes both ways in the same frame.** A resized child first
walks upward through exactly the ancestors whose cached size changes. That
measurement pass gives each parent the new size but necessarily drew the
branch in its old boxes. As the recursion returns, `redraw_and_settle` revisits
those changed widgets from the outside in, after their parents have assigned
the final boxes. Otherwise a newly appended child can retain the provisional
(even inverted) region it was measured in until another update happens. The
downward work is confined to the branch that changed; unchanged descendants
still take `draw_inner`'s retained fast path. Each downward visit is exactly
one redraw, not another upward propagation: a wrapping child can have no fixed
point when an ancestor shrink-wraps it (a trailing space alternated between one
line in the offered width and two lines in its reported natural width). Feeding
that answer back into the same branch recursively overflowed Android's native
UI-thread stack before Rust could report a panic. A container that intends a
wrapping child to occupy its width declares that constraint explicitly; the
message composer does so on both sides of its vertical `ScrollArea`.
**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`. Before a frame draws,
`redraw_updates` follows only those dependency edges from each dirty child and
marks the affected ancestors dirty. It then selects the highest dirty roots and
draws them top-down. Drawing never synchronously invalidates or invokes a
parent, so there is no layout recursion and no provisional child draw on a
different layer.
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.
For a stacking container, retained child lengths are cached per axis: a child's
width remains reusable while the parent changes width, and its height remains
reusable while the parent changes height. A change on the orthogonal axis does
invalidate it in both directions. This is a generic constraint rule, not a
text exception; wrapped text is merely the common example of height depending
on width.
### 4. Wrapped text, and "needs child height before choosing width"
@@ -343,17 +356,15 @@ buffer. That check is kept exactly as it is; it is the caching mechanism,
and it already operates at (id, region) granularity, which subsumes "(id,
available size)" once size *is* what a region change means.
What is added: `ActiveData` gains `pub size: Size` — the value `draw`
returned, stored the moment it is (`draw_inner`, alongside building the
`ActiveData` struct at `:134-143`). This is what a parent placing this
widget for a second frame without redrawing it (because nothing changed)
reads instead of recomputing — it replaces `Cache.size`'s role of "answer a
size question without a full draw" with "read the size of the last actual
draw," which is always available because `draw_inner`'s skip path is only
reachable once the widget has been drawn at least once. `Cache::remove`/
`Cache::clear` (`cache.rs:9-17`) are deleted with the type; `ActiveData`
already has an equivalent lifecycle (removed in `remove`/`remove_rec`,
`render_state.rs:171-198`, freed with the widget).
`ActiveData::size` stores the value the widget recorded with
`Painter::set_size`. This is what a parent placing the widget for a second
frame without redrawing it reads instead of recomputing — it replaces
`Cache.size`'s role of "answer a size question without a full draw" with
"read the size of the last actual draw." `ActiveData::size_dependencies`
stores the direct children whose `DrawResult::size()` or known length the
widget observed during that same draw; the next draw replaces the list, so a
dependency disappears as soon as the widget stops reading it. Both fields
have `ActiveData`'s existing lifecycle through `remove`/`remove_rec`.
### 6. Before / after
@@ -374,10 +385,10 @@ impl Widget for Rect {
```rust
// after
impl Widget for Rect {
fn draw(&mut self, painter: &mut Painter) -> Size {
fn draw(&mut self, painter: &mut Painter) {
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
painter.set_size(Size::REST); // fills whatever it was given
}
fn is_size_independent(&self) -> bool { true } // content never depends on region size
}
@@ -408,12 +419,12 @@ impl Widget for Aligned {
```rust
// after
impl Widget for Aligned {
fn draw(&mut self, painter: &mut Painter) -> Size {
fn draw(&mut self, painter: &mut Painter) {
let full = painter.region();
// Draw once at the full region to learn the child's real size --
// this placement is provisional and corrected below without a
// second draw.
let used = painter.widget_within(&self.inner, full);
let used = painter.widget_within(&self.inner, full).size();
let region = match self.align.tuple() {
(Some(x), Some(y)) => used.to_uivec2().align(RegionAlign { x, y }).within(&full),
(Some(x), None) => used.x.apply_rest().align(x).within(&full),
@@ -421,14 +432,15 @@ impl Widget for Aligned {
(None, None) => full,
};
painter.place(&self.inner, region);
used
painter.set_size(used);
}
}
```
`Painter::widget_within`/`widget`/`widget_at` (`painter.rs:55-76`) change
return type from `()` to `Size`, carrying the child's `draw` result back —
the only signature change needed to let a parent see what its child used.
return type from `()` to `DrawResult`. Calling `.size()` reads the size the
child recorded on its painter and records the parent's dependency on that
answer; leaving it unread records no dependency.
`Painter::place` moves an already-drawn child when its used area fits the
target box, and redraws it when the target changes its size. `SizeCtx` and
`Painter::size_ctx`/`size`/`len_axis` (`painter.rs:141-150,