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

+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 {