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

+127 -71
View File
@@ -4,6 +4,8 @@ use crate::{
util::{HashMap, HashSet, Vec2, forget_ref},
};
const AXES: [Axis; 2] = [Axis::X, Axis::Y];
pub struct UiRenderState {
pub active: HashMap<WidgetId, ActiveData>,
pub layers: DrawLayers,
@@ -82,7 +84,17 @@ 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,
MoveIdx::NONE,
false,
MaskIdx::NONE,
None,
rsc,
);
}
}
@@ -94,13 +106,15 @@ impl UiRenderState {
id: WidgetId,
region: UiRegion,
parent: Option<WidgetId>,
parent_move: MoveIdx,
slotted: bool,
mask: MaskIdx,
old_children: Option<Vec<WidgetId>>,
rsc: &mut dyn UiRsc,
) -> Size {
let mut old_children = old_children.unwrap_or_default();
if self.active.contains_key(&id) {
if let Some(size) = self.try_reuse(id, region, rsc) {
if let Some(size) = self.try_reuse(id, region, parent_move, rsc) {
return size;
}
// if not, then maintain resize and track old children to remove unneeded
@@ -109,14 +123,21 @@ impl UiRenderState {
}
// draw widget
let move_idx = self.move_slot(id, parent);
self.moves.set(move_idx, UiRegion::FULL);
let (move_idx, local) = match slotted {
// Its box becomes its slot's, so it draws in the slot's own
// coordinates and the box it was given is one entry to rewrite.
true => (self.move_slot(id, parent_move, region), UiRegion::FULL),
false => {
self.drop_slot(id);
(parent_move, region)
}
};
rsc.widgets_mut().needs_redraw.remove(&id);
self.draw_started.insert(id);
let mut painter = Painter {
state: self,
region,
region: local,
mask,
layer,
id,
@@ -136,7 +157,7 @@ impl UiRenderState {
let Painter {
state: _,
rsc: _,
region,
region: _,
mask,
textures,
primitives,
@@ -166,6 +187,7 @@ impl UiRenderState {
size_deps,
reads_output,
move_idx,
parent_move,
mask,
layer,
};
@@ -182,101 +204,133 @@ impl UiRenderState {
size
}
/// The slot a widget's drawing is positioned through, made on its first
/// draw and kept until it stops being drawn.
fn move_slot(&mut self, id: WidgetId, parent: Option<WidgetId>) -> MoveIdx {
/// The slot a widget's box is held in, made on its first placed draw and
/// kept until it stops being drawn -- a redraw replaces its `ActiveData`
/// while descendants go on naming the slot.
fn move_slot(&mut self, id: WidgetId, parent: MoveIdx, region: UiRegion) -> MoveIdx {
if let Some(&idx) = self.slots.get(&id) {
self.moves.set_parent(idx, parent);
self.moves.set(idx, region);
return idx;
}
let parent = parent
.and_then(|p| self.slots.get(&p).copied())
.unwrap_or(MoveIdx::NONE);
let idx = self.moves.push(parent);
let idx = self.moves.push(parent, region);
self.slots.insert(id, idx);
idx
}
/// Gives up a slot a widget no longer needs, because it is drawn somewhere
/// that does not place it. Its descendants name it, so this is only
/// reached where they are about to be drawn again.
fn drop_slot(&mut self, id: WidgetId) {
if let Some(idx) = self.slots.remove(&id) {
self.moves.remove(idx);
}
}
/// The drawing a widget already has, kept for a new box if the box has not
/// changed in a way it depends on.
fn try_reuse(&mut self, id: WidgetId, region: UiRegion, rsc: &dyn UiRsc) -> Option<Size> {
fn try_reuse(
&mut self,
id: WidgetId,
region: UiRegion,
parent_move: MoveIdx,
rsc: &mut dyn UiRsc,
) -> Option<Size> {
if rsc.widgets().needs_redraw.contains(&id) {
return None;
}
let active = self.active.get(&id)?;
let (size, old, slot) = (active.size, active.region, active.move_idx);
// TODO: epsilon?
if old.size() == region.size() {
// The right shape and only somewhere else, which is one slot to
// write however much is under it. Both boxes are in the
// coordinates its parent drew, so the chain above applies alike.
let moved =
region.to_px(self.output_size).top_left - old.to_px(self.output_size).top_left;
self.moves.set(slot, UiRegion::FULL.offset(moved));
return Some(size);
}
if !self.reusable(id, region, rsc) || !old.stretchable() {
// Drawn somewhere else in the tree: its box is in coordinates it no
// longer sits in, and its slot names the wrong parent.
if active.parent_move != parent_move {
return None;
}
// Its drawing stands, re-expressed as the same fractions of the box.
self.stretch(id, old, region);
let (size, old, slot) = (active.size, active.region, active.move_idx);
if old == region {
return Some(size);
}
// Only a placed widget can be given a different box without drawing
// again: everything it drew is a fraction of its slot's box, so one
// entry says where all of it went.
if slot == parent_move {
return None;
}
let mut changed = [false; 2];
for (axis, c) in AXES.into_iter().zip(changed.iter_mut()) {
*c = region.axis(axis).len() != old.axis(axis).len();
}
if changed.iter().any(|&c| c) {
let widget = rsc.widgets().get_dyn(id)?;
let redraws = AXES
.into_iter()
.zip(changed)
.any(|(axis, c)| c && widget.on_resize(axis) != OnResize::Scale);
if redraws {
return None;
}
}
self.moves.set(slot, region);
self.active.get_mut(&id).unwrap().region = region;
if changed.iter().any(|&c| c) {
self.mark_resized(id, changed, rsc);
}
Some(size)
}
/// Whether the widget can keep the drawing it has and be given `region`
/// instead, asked one axis at a time: a change on an axis it does not
/// depend on costs nothing, whatever it depends on elsewhere.
fn reusable(&self, id: WidgetId, region: UiRegion, rsc: &dyn UiRsc) -> bool {
/// Marks every descendant whose drawing cannot survive the box it is a
/// fraction of changing length, `changed` saying which axes of that box
/// did.
///
/// A part of a box with no relative extent on an axis is a fixed length,
/// held as offsets from that box's start, and composing anything into it
/// leaves no relative extent either. So a widget whose own box did not
/// change length has no descendant whose box did, and the walk stops
/// there.
fn mark_resized(&mut self, id: WidgetId, changed: [bool; 2], rsc: &mut dyn UiRsc) {
let Some(active) = self.active.get(&id) else {
return false;
return;
};
let Some(widget) = rsc.widgets().get_dyn(id) else {
return false;
};
[Axis::X, Axis::Y].into_iter().all(|axis| {
let offered = region.axis(axis).len();
let had = active.region.axis(axis).len();
match widget.on_resize(axis) {
OnResize::Scale => true,
// `Translate` is not acted on yet, and cannot be until a
// drawing can sit somewhere other than its box. `region` is
// both the box a widget was given and the box its primitives
// are in, and `mov` remaps from it -- so carrying a drawing at
// its old size while the box grows makes the next move stretch
// it. The offset chain is what separates the two.
OnResize::Translate | OnResize::Redraw => offered == had,
// SAFETY: children cannot be recursive
let children = unsafe { forget_ref(&active.children) };
for &child in children {
let Some(data) = self.active.get(&child) else {
continue;
};
let region = data.region;
let mut own = changed;
for (axis, c) in AXES.into_iter().zip(own.iter_mut()) {
*c &= region.axis(axis).len().rel != 0.0;
}
})
if !own.iter().any(|&c| c) {
continue;
}
let redraws = match rsc.widgets().get_dyn(child) {
Some(widget) => AXES
.into_iter()
.zip(own)
.any(|(axis, c)| c && widget.on_resize(axis) != OnResize::Scale),
None => true,
};
match redraws {
true => {
rsc.widgets_mut().needs_redraw.insert(child);
}
false => self.mark_resized(child, own, rsc),
}
}
}
fn hints_agree(id: WidgetId, size: Size, rsc: &dyn UiRsc) -> bool {
let Some(widget) = rsc.widgets().get_dyn(id) else {
return true;
};
[Axis::X, Axis::Y].into_iter().all(|axis| {
AXES.into_iter().all(|axis| {
widget
.size_hint(axis)
.is_none_or(|hint| hint == size.axis(axis))
})
}
/// Rewrites a subtree's regions as the same fractions of a new box, for a
/// change of length that a slot cannot express. Every region it rewrites
/// is a region some slot was a delta from, so those go back to zero.
fn stretch(&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.stretch(&from, &to);
}
active.region = active.region.stretch(&from, &to);
self.moves.set(active.move_idx, UiRegion::FULL);
// SAFETY: children cannot be recursive
let children = unsafe { forget_ref(&active.children) };
for child in children {
self.stretch(*child, from, to);
}
}
/// NOTE: instance textures are cleared and self.textures freed
fn remove(&mut self, id: WidgetId, undraw: bool, rsc: &mut dyn UiRsc) -> Option<ActiveData> {
let mut active = self.active.remove(&id);
@@ -363,11 +417,11 @@ impl UiRenderState {
}
}
/// Where a widget is on screen: the box it drew against plus whatever the
/// chain above has moved since, which is the walk the vertex shader does.
/// Where a widget is on screen: its box composed through the boxes it
/// sits within, which is the walk the vertex shader does.
pub fn window_region(&self, id: &impl IdLike) -> Option<PixelRegion> {
let active = self.active.get(&id.id())?;
let region = self.moves.resolve(active.move_idx, active.region);
let region = self.moves.resolve(active.parent_move, active.region);
Some(region.to_px(self.output_size))
}
@@ -400,6 +454,8 @@ impl UiRenderState {
id,
active.region,
active.parent,
active.parent_move,
active.move_idx != active.parent_move,
active.mask,
Some(active.children),
rsc,