Give a slot to the children a container places, and nothing else

A widget's region is now held in the coordinates of the slot it draws in
rather than the window's, and `Painter::place` is how a container asks for a
slot: it draws a child it decides the box of and may decide again. Everything
under that slot is a fraction of its box, so placing the child a second time
is one entry to write whether it moved or changed length. A child drawn any
other way has no slot and shares its nearest ancestor's.

That is what keeps the chain short. `chain_cost` measured depth as the cost
-- free to 8, +42.6% at 16 -- and a slot per widget put a transcript's glyphs
past that for nothing, since almost every slot was zero. `Span`, `Aligned`
and `Scroll` are the containers that re-place a child after drawing it, and
`tests/layout.rs` pins that four widgets between a span and a leaf leave the
leaf's chain one deep.

`UiRegion::stretch`, `UiRegion::stretchable` and `UiScalar::stretch` are
gone. Nothing is inverted any more: a box that changed length is written to
its slot, and the descendants recompose against it in the shader. That also
retires the case the guard existed for, where a fixed length has no fraction
to recover -- `tests/layout.rs` now stretches a 40-tall row on its other
axis, which `stretchable` refused outright.

What still walks the CPU is deciding who must draw again, which no chain can
answer: `mark_resized` descends from the widget whose box changed and marks
anything whose own box changed length and whose drawing reads it. A part of
a box with no relative extent on an axis is a fixed length, and composing
into it leaves none either, so the walk stops where a length did not change
-- an 80-wide child in a widened row is not redrawn though it says `Redraw`.

`Span`, `Pad`, `Stack`, `Offset`, `Aligned`, `SetSize` and `LayerOffset` say
`Scale`: each places in fractions and offsets of its own box and none reads
the box's pixel length. `Scroll` and `MaxSize` do read pixels and stay
`Redraw`.

45 tests pass, five of them new. Render verification comes after the CPU
side, per the owner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-14 12:25:37 -04:00
1 parent 1f9dc48b80
commit d98969158f
18 files changed
+332 -181

No files matched your search

+8 -2
View File
@@ -9,14 +9,20 @@ impl Widget for Aligned {
fn draw(&mut self, painter: &mut Painter) -> Size {
// Drawn where it may be too big, then given its aligned box once its
// size is known.
let size = painter.widget(&self.inner).size();
let size = painter.place(&self.inner, UiRegion::FULL).size();
let region = match self.align.tuple() {
(Some(x), Some(y)) => size.to_uivec2().align(RegionAlign { x, y }),
(Some(x), None) => UiRegion::new(size.x.apply_rest().align(x), UiSpan::FULL),
(None, Some(y)) => UiRegion::new(UiSpan::FULL, size.y.apply_rest().align(y)),
(None, None) => UiRegion::FULL,
};
painter.widget_within(&self.inner, region);
painter.place(&self.inner, region);
size
}
/// The aligned box is a fraction of its own, so the child keeps its
/// length and stays against the edge it was aligned to.
fn on_resize(&self, _: Axis) -> OnResize {
OnResize::Scale
}
}
+4
View File
@@ -12,4 +12,8 @@ impl Widget for LayerOffset {
}
painter.widget(&self.inner).size()
}
fn on_resize(&self, _: Axis) -> OnResize {
OnResize::Scale
}
}
+4
View File
@@ -10,4 +10,8 @@ impl Widget for Offset {
let region = UiRegion::FULL.offset(self.amt);
painter.widget_within(&self.inner, region).size()
}
fn on_resize(&self, _: Axis) -> OnResize {
OnResize::Scale
}
}
+6
View File
@@ -21,6 +21,12 @@ impl Widget for Pad {
},
}
}
/// The padding is an offset from each edge, so a longer box pads the same
/// amount and the child takes the rest.
fn on_resize(&self, _: Axis) -> OnResize {
OnResize::Scale
}
}
pub struct Padding {
+3 -3
View File
@@ -12,10 +12,10 @@ pub struct Scroll {
impl Widget for Scroll {
fn draw(&mut self, painter: &mut Painter) -> Size {
let output_len = painter.output_size().axis(self.axis);
let container_len = painter.region().axis(self.axis).len();
let container_len = UiScalar::abs(painter.px_size().axis(self.axis));
// Drawn in the whole container to learn its length, then placed at
// the scrolled offset.
let child = painter.widget(&self.inner).size();
let child = painter.place(&self.inner, UiRegion::FULL).size();
let content_len = child
.axis(self.axis)
.apply_rest()
@@ -31,7 +31,7 @@ impl Widget for Scroll {
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);
painter.place(&self.inner, region);
child
}
}
+4
View File
@@ -23,4 +23,8 @@ impl Widget for SetSize {
Axis::Y => self.y,
}
}
fn on_resize(&self, _: Axis) -> OnResize {
OnResize::Scale
}
}
+8 -2
View File
@@ -17,7 +17,7 @@ impl Widget for Span {
.iter()
.map(|child| match painter.size_hint(child, axis) {
Some(len) => len,
None => painter.widget(child).len(axis),
None => painter.place(child, UiRegion::FULL).len(axis),
})
.collect();
@@ -42,7 +42,7 @@ impl Widget for Span {
if self.dir.sign == Sign::Neg {
region.flip(axis);
}
let used = painter.widget_within(child, region).size().axis(!axis);
let used = painter.place(child, region).size().axis(!axis);
// TODO: rel shouldn't do this, but no easy way before actually calculating pixels
if used.rel > 0.0 || used.rest > 0.0 {
ortho = Len::REST;
@@ -58,6 +58,12 @@ impl Widget for Span {
};
Size::from_axis(axis, along, ortho)
}
/// Every child is placed in fractions and offsets of the span's own box,
/// so a longer box holds the same layout and the children follow it.
fn on_resize(&self, _: Axis) -> OnResize {
OnResize::Scale
}
}
impl Span {
+4
View File
@@ -28,6 +28,10 @@ impl Widget for Stack {
}
size
}
fn on_resize(&self, _: Axis) -> OnResize {
OnResize::Scale
}
}
#[derive(Default, Debug)]