Measure a dirty widget where its parent asked, not in a box its answer decided

A local redraw drew a dirty widget in the box it was placed in. When a reader
decided that box from the widget's own answer -- an aligned span sized to its
children, a text at the tail of a row, a scroll's content -- the old answer is a
fixed point of measuring there whatever the content now says, so the layout had
two stable answers and which one it reached depended on the tree's history.
`tests/unsettled.rs` has the two shrunk cases: the four-widget aligned span,
and a scroll placing a pass-through `SetSize` in a box the content decided,
where the span under it was placed once and nothing at its own edge said so.

`ActiveData::offered_px` keeps the pixel size of the box the parent first asked
about the child in, whether through `known_len` or a first `place`, beside `px`,
the box it drew against. A dirty widget whose size reads an axis on which some
reader up its chain gave what it read a box other than the one it asked in is
not drawn locally: the chain is marked and the parent of the highest such
placement draws, since above it every box is a constraint rather than an
answer. The walk goes up the whole reader chain because a pass-through hands a
derived box down unchanged.

`Scroll` read its box's length for the clamp through `px_len`, which records
the reported size as depending on it, and it does not: its size is its
content's. That made every scroll tick a size question asked in a derived box,
at 34x the instructions. `Painter::px_len_for_draw` is the read that records
nothing. Instructions per frame on the depth-8 rig against the previous head:
`many` at 32 dirty 0.66M to 0.74M, at 130 dirty 27.7M to 26.5M, `resize` 15.8M
to 15.0M, `scroll`, `repaint` and `size` unchanged. The shrinking fuzzer passes
200 trees at depth 7 in all four cases, the hundred-seed sweep passes, and the
five reference renders and the resize render are byte-identical.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Fable 5.1 committed 2026-09-15 02:16:56 -04:00
1 parent 65f68bbb8a
commit 02ff8c7454
6 files changed
+200 -51

No files matched your search

+4
View File
@@ -13,6 +13,10 @@ pub struct ActiveData {
/// it is a fraction of a slot's box, and the same fraction of a box that
/// has since changed is a different number of pixels.
pub px: Vec2,
/// The pixel size of the box its parent first asked about it in, before
/// knowing what it came to. `px` may be a box derived from that answer,
/// and a size measured there is only the same answer asked again.
pub offered_px: Vec2,
pub parent: Option<WidgetId>,
/// How far down the tree it was drawn, the root being 1. Carried down a
/// draw rather than worked out by walking up, so it is right for every
+28 -1
View File
@@ -21,6 +21,9 @@ pub struct Painter<'a> {
pub(super) textures: Vec<TextureHandle>,
pub(super) primitives: Vec<PrimitiveHandle>,
pub(super) children: Vec<WidgetId>,
/// The children asked about so far, so the first box each was asked
/// about is the one recorded as its offer.
pub(super) offered: Vec<WidgetId>,
/// The children whose size this widget read while drawing.
pub(super) size_deps: Vec<WidgetId>,
/// Offered pixel axes which can affect the size this draw reports.
@@ -141,6 +144,7 @@ impl<'a> Painter<'a> {
None,
self.rsc,
);
self.offer(id.id(), region);
DrawResult {
child: id,
painter: self,
@@ -182,6 +186,8 @@ impl<'a> Painter<'a> {
axis: Axis,
region: UiRegion,
) -> Option<Len> {
let region = region.within(&self.region);
self.offer(child.id(), region);
if let Some(hint) = self.size_hint(child, axis) {
return Some(hint);
}
@@ -189,12 +195,12 @@ impl<'a> Painter<'a> {
.map(|size| size.axis(axis))
}
/// `region` in this widget's own coordinates.
fn retained_size<W: ?Sized>(
&mut self,
child: &StrongWidget<W>,
region: UiRegion,
) -> Option<Size> {
let region = region.within(&self.region);
let (size, box_inputs, output_inputs) =
self.state
.retained_size(child.id(), region, self.move_idx, self.rsc.widgets())?;
@@ -204,6 +210,20 @@ impl<'a> Painter<'a> {
Some(size)
}
/// Records the box a child was first asked about in this draw. Any later
/// box this draw gives it was decided knowing its answer, so a size the
/// child measures there is not an answer to this widget's question.
fn offer(&mut self, child: WidgetId, region: UiRegion) {
if self.offered.contains(&child) {
return;
}
self.offered.push(child);
let px = self.state.px_of(self.move_idx, region);
if let Some(active) = self.state.active.get_mut(&child) {
active.offered_px = px;
}
}
/// Depends on a length the child gave without being drawn. A hint is
/// context-free, so this depends on the child but on no pixel axis.
fn depend_on_hint<W: ?Sized>(&mut self, child: &StrongWidget<W>) {
@@ -309,6 +329,13 @@ impl<'a> Painter<'a> {
/// [`Self::px_size`] when the other axis cannot affect the reported size.
pub fn px_len(&mut self, axis: Axis) -> f32 {
self.size_box_inputs[axis as usize] = true;
self.px_len_for_draw(axis)
}
/// One axis of this widget's box in pixels, for a draw whose reported
/// size does not follow from it -- a clamp or a position. Nothing records
/// the read, so a size that does depend on it would go stale.
pub fn px_len_for_draw(&self, axis: Axis) -> f32 {
let region = self.state.moves.resolve(self.move_idx, self.region);
region
.size()
+71 -23
View File
@@ -203,7 +203,7 @@ impl UiRenderState {
parent_move: MoveIdx,
slotted: bool,
mask: MaskIdx,
old_children: Option<Vec<WidgetId>>,
mut old: Option<ActiveData>,
rsc: &mut dyn UiRsc,
) -> Size {
#[cfg(feature = "layout-diagnostics")]
@@ -211,14 +211,12 @@ impl UiRenderState {
diag::bump(Counter::DrawRequests);
diag::draw_request(id, parent, region, self.px_of(parent_move, region), slotted);
}
let mut old_children = old_children.unwrap_or_default();
if self.active.contains_key(&id) {
if let Some(size) = self.try_reuse(id, region, depth, parent_move, rsc) {
return size;
}
// 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 = self.remove(id, false, rsc);
}
// draw widget
@@ -232,6 +230,12 @@ impl UiRenderState {
}
};
let px = self.px_of(move_idx, local);
// Drawn again in a box its parent already decided: the offer is the
// one recorded when the parent first asked, not this box.
let (old_children, offered_px) = match old {
Some(old) => (old.children, old.offered_px),
None => (Vec::new(), px),
};
rsc.widgets_mut().needs_redraw.remove(&id);
self.draw_started.insert(id);
@@ -244,6 +248,7 @@ impl UiRenderState {
textures: Vec::new(),
primitives: Vec::new(),
children: Vec::new(),
offered: Vec::new(),
size_deps: Vec::new(),
depth,
size_box_inputs: [false; 2],
@@ -271,6 +276,7 @@ impl UiRenderState {
textures,
primitives,
children,
offered: _,
size_deps,
size_box_inputs,
size_output_inputs,
@@ -292,6 +298,7 @@ impl UiRenderState {
region,
size,
px,
offered_px,
parent,
depth,
textures,
@@ -347,7 +354,7 @@ impl UiRenderState {
}
/// The pixel size of a region held in `slot`'s coordinates.
fn px_of(&self, slot: MoveIdx, region: UiRegion) -> Vec2 {
pub(super) fn px_of(&self, slot: MoveIdx, region: UiRegion) -> Vec2 {
self.moves
.resolve(slot, region)
.size()
@@ -736,16 +743,25 @@ impl UiRenderState {
// A widget can only answer whether its size changed by drawing in the
// box its parent chose. If that box changed in pixels, its retained
// placement is stale and the highest size reader must choose the new
// box first. Otherwise the widget can draw locally, and its readers
// only matter if the returned size actually changed.
// box first. The same holds when the box was decided from the
// widget's own answer: measuring there again can only repeat it,
// whatever the content now says. Otherwise the widget can draw
// locally, and its readers only matter if the returned size actually
// changed.
let box_changed = self.active.get(&id).is_some_and(|active| {
let px = self.px_of(active.parent_move, active.region);
AXES.into_iter()
.any(|axis| pixel_len_changed(active.px.axis(axis), px.axis(axis)))
});
if box_changed && let Some(top) = self.mark_readers(id, rsc) {
let top = match box_changed {
true => self.top_reader(id),
false => None,
}
.or_else(|| self.derived_box_reader(id));
if let Some(top) = top {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::EagerReaderRedraws);
self.mark_below(id, top, rsc);
self.redraw(top, rsc);
rsc.widgets_mut().needs_redraw.remove(&id);
return;
@@ -772,7 +788,7 @@ impl UiRenderState {
active.parent_move,
active.move_idx != active.parent_move,
active.mask,
Some(active.children),
Some(active),
rsc,
);
@@ -795,24 +811,56 @@ impl UiRenderState {
}
}
/// The furthest ancestor that read this widget's size, directly or through
/// widgets that did the same, marking everything below it on the way.
fn mark_readers(&self, id: WidgetId, rsc: &mut dyn UiRsc) -> Option<WidgetId> {
/// The highest reader up the chain that gave what it read a box other
/// than the one it asked in, on an axis this widget's size reads. Above
/// it every box is a constraint rather than an answer. It is the highest
/// and not the nearest because a pass-through hands a derived box down
/// unchanged.
fn derived_box_reader(&self, id: WidgetId) -> Option<WidgetId> {
let reads = self.active.get(&id)?.size_box_inputs;
let mut top = None;
let mut at = id;
while let Some(active) = self.active.get(&at)
&& let Some(parent) = active.parent
&& self
.active
.get(&parent)
.is_some_and(|p| p.size_deps.contains(&at))
{
rsc.widgets_mut().needs_redraw.insert(at);
top = Some(parent);
at = parent;
for (active, parent) in self.reader_chain(id) {
let px = self.px_of(active.parent_move, active.region);
if AXES.into_iter().zip(reads).any(|(axis, r)| {
r && pixel_len_changed(active.offered_px.axis(axis), px.axis(axis))
}) {
top = Some(parent);
}
}
top
}
/// The furthest ancestor that read this widget's size, directly or through
/// widgets that did the same.
fn top_reader(&self, id: WidgetId) -> Option<WidgetId> {
self.reader_chain(id).last().map(|(_, parent)| parent)
}
/// Each widget from `id` upward whose parent read its size, with that
/// parent.
fn reader_chain(&self, id: WidgetId) -> impl Iterator<Item = (&ActiveData, WidgetId)> {
let mut at = Some(id);
std::iter::from_fn(move || {
let active = self.active.get(&at?)?;
let parent = active.parent?;
let read = self.active.get(&parent)?.size_deps.contains(&active.id);
at = read.then_some(parent);
read.then_some((active, parent))
})
}
/// Marks everything from `id` up to, and not including, `top`, so that
/// drawing `top` draws each of them rather than reusing it.
fn mark_below(&self, id: WidgetId, top: WidgetId, rsc: &mut dyn UiRsc) {
let mut at = id;
while at != top {
rsc.widgets_mut().needs_redraw.insert(at);
let Some(parent) = self.active.get(&at).and_then(|active| active.parent) else {
return;
};
at = parent;
}
}
}
impl Default for UiRenderState {