Compare commits

..
Author SHA1 Message Date
iris-ai e44dea34b4 Record which widget is drawing a subtree that changed hands
A subtree can be reused whole under a different parent -- same box, same
layer, same region node, clean -- and nothing in the drawing says it
moved. Two things read who its parent is, and both were wrong after one
of these.

The old parent still listed it as a child, and a parent's next draw
undraws whatever is missing from that list: two spans under one root,
with the root swapping which of them it holds, drew the subtree under
the new span and then erased it when the old one drew. The move is
recorded on both sides where `draw_inner` already writes what the ask
decided, rather than guarded at each reader.

Its depth was also the one it had under the old parent, which is what
the settling walk orders by, so a change made under it afterwards
settled at the wrong point in the frame. `try_reuse` re-walks the
subtree's depths, and only where the top of it moved, which is what
makes that free in the ordinary case.

Two tests: one shape where the subtree's box does not move and the span
it left erases it, one where it changes depth and the change made under
it has to reach the span it moved to. Each fails without one half.
2026-09-17 13:05:15 -04:00
iris-ai a0693acc56 Let a resize settle through the walk, and drop the stale-answer guard
A resize drew the root outside `redraw_updates`, top-down over a tree
with dirty widgets still in it, which is the one entry point
`dirty_size_under` was guarding: since `a92c6ac` settles a frame strictly
bottom-up, no fuzzer could tell whether that guard still did anything
anywhere else. Closing the entry point retires the guard rather than
keeping a check for a hole reasoned rather than measured.

The root is marked instead, and only where the new output falls outside
what its answer holds for. That range is the intersection of everything
under it, so admitting the new output says the whole tree still stands,
and nothing above the root moved -- the window is no entry to rewrite.
Marking it unconditionally would have cost the root its own `Holds`: a
leaf root that scales with its box was drawn again on every resize.

`dirty_size_under` goes at both call sites. `resize` takes `Widgets`
because a mark is what it now leaves behind.
2026-09-17 13:00:32 -04:00
iris-ai 25e456e0b5 Say what the fuzzers can no longer tell about the stale-answer guard
Dropping `dirty_size_under` from it now passes every run there is. It stays
for the one entry the bottom-up ordering does not reach -- `update` draws
the root for a resize before `redraw_updates` runs -- which is a hole
reasoned rather than measured, and the note says which.
2026-09-17 05:12:44 -04:00
iris-ai 53b00c68e9 Find a span's leftover boundary through the inverse it already has
The decision used a rounded division, `total.px.div(fixed)`, where the room
the children get is a floored multiply, so the boundary and the drawing it
guards were two expressions for one length and disagreed at the edge of it.
`room` is that length as a `Len`, `room.to_px` is the multiply, and
`Holds::through` is its exact preimage -- so ask `room` whether anything is
left and hand the answer back through the same expression.

The three branches go with the division. They were the sign of `1 - rel`:
the fixed parts growing slower than the box, faster, or exactly with it, and
`through` reads that sign already. Forty lines become twelve, one `div`
leaves layout, and the boundary is the drawing's own.

Green on the suite, the shrinker at 400 seeds of depth 5, the oracle at 1000
seeds of depth 6 and 120 in debug, and 2000 seeds at depth 4 over all
fifteen cases. `tabs`, `view`, `minimal` and `random` byte-identical.
2026-09-17 05:02:45 -04:00
10 changed files with 220 additions and 144 deletions

No files matched your search

+8
View File
@@ -74,4 +74,12 @@ impl ActiveData {
pub fn holds_at(&self, px: crate::PxVec2) -> bool {
self.holds[0].contains(px.x) && self.holds[1].contains(px.y)
}
/// Whether what it answered still stands for a box of these pixel
/// lengths -- the box it was asked in, where `holds` is about the box its
/// answer then chose.
pub fn answers_at(&self, px: crate::PxVec2) -> bool {
let (_, holds) = self.answer;
holds[0].contains(px.x) && holds[1].contains(px.y)
}
}
+10 -12
View File
@@ -168,20 +168,10 @@ impl<'a> Painter<'a> {
let region_node = self.rsc.widgets().is_region_node(id.id());
let declared = self.declared_lens(id);
let align = self.rsc.widgets().alignment(id.id());
// A rule this box was already chosen from is not resolved into it a
// second time. The box is that rule's length already, so resolving
// it again takes the fraction twice -- a widget declaring half of a
// stack, in the stack its own answer made half a row, is a quarter
// of the row. Pixels survive it, being the same length wherever they
// are taken from, which is why only a share ever shrank.
let resolve = AXES.map(|axis| match decided[axis as usize] {
true => None,
false => declared[axis as usize],
});
// Composing `FULL` through a box is not quite the identity in f32,
// so a child with nothing declared keeps the box it would have had.
let local = match resolve.iter().any(Option::is_some) {
true => declared_box(region, resolve, align),
let local = match declared.iter().any(Option::is_some) {
true => declared_box(region, declared, align),
false => region,
};
let within = match local == UiRegion::FULL {
@@ -403,6 +393,14 @@ impl<'a> Painter<'a> {
.is_some()
}
/// The part of this widget's box that something of `size` takes, at the
/// near edge. A container that reports one child's size gives every child
/// this, so what it draws is inside what it says it occupies.
pub fn box_of(&self, size: Size) -> UiRegion {
let lens = placed_lens(size, [None; 2], [false; 2]);
placed_box(UiRegion::FULL, lens, RegionAlign::NEAR)
}
/// This widget's box in pixels. Reading it makes the drawing one that
/// holds for this box only, until `holds` says how far it goes.
pub fn px_size(&mut self) -> PxVec2 {
+56 -40
View File
@@ -43,7 +43,9 @@ pub struct UiRenderState {
old_root: Option<WidgetId>,
/// Whether the output has changed since the last update. A frame is
/// owed for that whether or not anything has to be drawn again.
/// owed for that whether or not anything has to be drawn again: every
/// fraction becomes pixels against the output, in the shader's uniform
/// as well as here.
resized: bool,
/// A widget's move slot, which outlives any one `ActiveData`: a redraw
/// replaces that while its children go on pointing at the slot.
@@ -83,13 +85,28 @@ impl UiRenderState {
/// size is applied where a fraction becomes pixels -- here in `to_px`,
/// and in the shader by its uniform. A resize therefore rewrites no
/// retained entry at all.
pub fn resize(&mut self, size: impl Into<Vec2>) {
///
/// The root is the only widget a resize marks, and only where the new
/// output falls outside what its answer holds for: that range is the
/// intersection of everything under it, so admitting the new output says
/// the whole tree still stands. Where it does not, the ordinary walk
/// draws the root, and each widget's own range decides how far down the
/// new length reaches.
pub fn resize(&mut self, size: impl Into<Vec2>, widgets: &mut Widgets) {
let size = PxVec2::from_f32(size.into());
if size == self.output_size {
return;
}
self.output_size = size;
self.resized = true;
let Some(root) = self.old_root else { return };
let stands = self
.active
.get(&root)
.is_some_and(|active| active.answers_at(active.given_len.to_px(size)));
if !stands {
widgets.needs_redraw.insert(root);
}
}
/// The root is asked about in the output: the window is where a fraction
@@ -143,17 +160,6 @@ impl UiRenderState {
if self.root_changed(root) {
self.redraw_all(root, rsc);
self.old_root = root.map(|r| r.id());
} else if let Some(root) = root
&& self.resized
{
// The output is the root's box, so a resize is that box changing
// length, found the way every other box change is found. Before
// anything dirty settles, so that whatever a new output draws
// again is drawn once, in the box it will have.
let region = Self::root_region(root.id(), rsc.widgets());
let info = self.root_info(region);
let answer = self.draw_inner(root.id(), region, info, None, rsc);
self.active.get_mut(&root.id()).unwrap().answer = answer;
}
self.resized = false;
if rsc.widgets().has_updates() {
@@ -196,14 +202,10 @@ impl UiRenderState {
diag::draw_request(id, info.parent, region, info.px, info.region_node);
}
let align = rsc.widgets().alignment(id);
// Nothing this widget has is an answer while something it measured
// is dirty: settling that changes what it would report, and a widget
// settled inside its parent's draw tells nobody -- the comparison
// that marks a reader is in `redraw`, which is not what asked here.
// Both retained routes are an answer, so the question is asked once
// rather than by each of them.
let stale =
rsc.widgets().needs_redraw.contains(&id) || self.dirty_size_under(id, rsc.widgets());
// Nothing this widget measured can be dirty while it draws: layout is
// one bottom-up walk, so anything deeper has settled or deferred to
// its own parent, and a deferred one leaves that parent marked.
let stale = rsc.widgets().needs_redraw.contains(&id);
let replace_answer = self.answer_invalid.remove(&id) || (self.replace_answers && stale);
let retained = match replace_answer || stale {
true => None,
@@ -251,7 +253,18 @@ impl UiRenderState {
active.answer = settled;
active.decided = info.decided;
active.own_align = align;
active.depth = info.depth;
// A subtree can be reused whole under a different parent -- same box,
// same layer, same region node -- and nothing in the drawing says it
// changed hands. Two things read who its parent is: a deferral, which
// marks whoever has it to draw, and the old parent's list of children,
// which its next draw undraws whatever is missing from.
let old_parent = std::mem::replace(&mut active.parent, info.parent);
if old_parent != info.parent
&& let Some(old_parent) = old_parent
&& let Some(old_parent) = self.active.get_mut(&old_parent)
{
old_parent.children.retain(|child| *child != id);
}
settled
}
@@ -484,7 +497,7 @@ impl UiRenderState {
parent_move: MoveIdx,
widgets: &Widgets,
) -> Option<(Size, [Holds; 2])> {
if widgets.needs_redraw.contains(&id) || self.dirty_size_under(id, widgets) {
if widgets.needs_redraw.contains(&id) {
return None;
}
let active = self.active.get(&id)?;
@@ -509,22 +522,7 @@ impl UiRenderState {
{
return None;
}
let (size, holds) = active.answer;
(holds[0].contains(info.px.x) && holds[1].contains(info.px.y)).then_some((size, holds))
}
/// Whether anything whose size this widget's own size was read from is
/// dirty, which makes what it would answer not yet known. It also keeps
/// a reader that asks first from laying out twice, which is all it was
/// here for while a changed size was thought to reach its reader in any
/// order; it does not, where the change settles inside the reader's own
/// draw.
fn dirty_size_under(&self, id: WidgetId, widgets: &Widgets) -> bool {
self.active.get(&id).is_some_and(|active| {
active.size_deps.iter().any(|child| {
widgets.needs_redraw.contains(child) || self.dirty_size_under(*child, widgets)
})
})
active.answers_at(info.px).then_some(active.answer)
}
/// The pixel lengths of the box a widget was given and of the box it was
@@ -638,12 +636,12 @@ impl UiRenderState {
self.remap_subtree(id, &remap, info.parent_move, rsc);
}
}
self.redepth(id, info.depth);
let active = self.active.get_mut(&id).unwrap();
active.region = region;
active.given = region;
active.given_len = info.given_len;
active.offer_len = info.offer_len;
active.depth = info.depth;
#[cfg(feature = "layout-diagnostics")]
{
match (moved, has_region_node) {
@@ -667,6 +665,24 @@ impl UiRenderState {
Some(answer)
}
/// A reused subtree keeps its shape, so every widget in it moves by the
/// same amount -- and where the top of it did not move, none of it did,
/// which is what makes this free in the ordinary case.
fn redepth(&mut self, id: WidgetId, depth: usize) {
let Some(active) = self.active.get_mut(&id) else {
return;
};
if active.depth == depth {
return;
}
active.depth = depth;
let children = active.children.len();
for index in 0..children {
let child = self.active[&id].children[index];
self.redepth(child, depth + 1);
}
}
/// Re-expresses an ordinary retained subtree in a new parent region.
/// An independently movable descendant needs only its own region changed;
/// its contents stay in that region's coordinate space.
-1
View File
@@ -26,7 +26,6 @@ impl DefaultAppState for State {
.wrap(true)
.text_align(Align::LEFT)
.pad(16)
.width(rel(1.0))
.background(panel());
// Each one takes the whole width, because `text_align` puts the
+1 -1
View File
@@ -251,7 +251,7 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
ui_state.renderer.draw();
}
WindowEvent::Resized(size) => {
render.resize((size.width, size.height));
render.resize((size.width, size.height), rsc.widgets_mut());
ui_state.renderer.resize(size)
}
WindowEvent::KeyboardInput { event, .. } => {
+3 -3
View File
@@ -144,9 +144,9 @@ impl Harness {
// bound that comes with `SyncSender` is far past anything a test
// leaves unread.
let (send, updates) = sync_channel(1024);
let rsc = DefaultRsc::init(Arc::new(Queue(send)));
let mut rsc = DefaultRsc::init(Arc::new(Queue(send)));
let mut render = UiRenderState::new();
render.resize(size);
render.resize(size, rsc.widgets_mut());
Self {
rsc,
render,
@@ -161,7 +161,7 @@ impl Harness {
}
pub fn resize(&mut self, size: impl Into<Vec2>) {
self.render.resize(size);
self.render.resize(size, self.rsc.widgets_mut());
}
/// Changes a length rule after the fact, the way `.width()` sets one.
+16 -34
View File
@@ -46,43 +46,26 @@ impl Widget for Span {
|sum, len| sum + *len,
);
// What is left for the shares to divide: the box less everything
// fixed, as a length of the box rather than a number of pixels.
let room = Len::rel_max() - Len::from_parts(total.rel, total.px);
// Whether anything is left over is a question in pixels: `rel(0.5)`
// beside 300 px is full at 600 and overfull at 400. The room to
// divide is `len * fixed - total.px`, and the length where it runs
// out is exactly the box a parent sizing itself from this answer
// hands back -- which is why this used to need a margin either side
// of the boundary, and why it does not now: that box and this sum are
// whole counts of the same step, and both routes to it land on the
// same count. What the generated oracle checks is the consequence,
// since which children exist at all turns on this.
let fixed = Rel::ONE - total.rel;
// beside 300 px is full at 600 and overfull at 400. Asked of `room`
// itself, and answered back through the same expression, so the
// boundary is the drawing's own and not a second way of finding it:
// the three cases a rounded division needed -- the fixed parts
// growing slower than the box, faster, or exactly with it -- are the
// sign of `room.rel`, which `through` already reads. What the
// generated oracle checks is the consequence, since which children
// exist at all turns on this.
let mut shares = false;
if total.leftover > Weight::ZERO {
let current = painter.px_len(axis);
let holds = if fixed > Rel::ZERO {
// The box length the fixed parts alone fill.
let full = total.px.div(fixed);
shares = current > full;
match shares {
true => Holds::from(full.next_up()..=Px::MAX),
false => Holds::from(Px::MIN..=full),
}
} else if fixed < Rel::ZERO {
// The relative parts grow faster than the box does, so here
// a shorter box is the one that leaves room.
let full = total.px.div(fixed);
shares = current < full;
match shares {
true => Holds::from(Px::MIN..=full.next_down()),
false => Holds::from(full..=Px::MAX),
}
} else {
// The relative parts take exactly the box, whatever it is, so
// the only room is what negative pixels leave.
shares = total.px < Px::ZERO;
Holds::ANY
shares = room.to_px(painter.px_len(axis)) > Px::ZERO;
let holds = match shares {
true => Holds::from(Px::STEP..=Px::MAX),
false => Holds::from(Px::MIN..=Px::ZERO),
};
painter.holds(axis, holds);
painter.holds(axis, holds.through(room));
}
// Across itself a span is as long as its longest child -- unless a
@@ -99,7 +82,6 @@ impl Widget for Span {
// row.
let mut fixed = Len::rel_min();
let mut taken = Weight::ZERO;
let room = Len::rel_max() - Len::from_parts(total.rel, total.px);
let mut start = Len::rel_min();
let mut ortho = LayoutLen::ZERO;
for (child, len) in self.children.iter().zip(&lens) {
+17 -28
View File
@@ -13,42 +13,31 @@ impl Widget for Stack {
StackSize::Default => None,
StackSize::Child(i) => Some(i),
};
// Every child gets the whole of this stack's box, the sizing one
// included, and the stack is then handed a box of the length that
// child asked for. Not the part of the box that length takes: the
// stack's own box becomes that length, and taking the fraction of it
// again is the fraction twice -- a child asking for half of a stack
// that is already half a row would have a quarter of the row.
//
// It cannot be told apart by asking whether this box is the answer
// yet, either. A drawing has to be a function of the box alone, since
// moving the stack into the box it asked for reuses the drawing by
// scaling it, and a drawing made a fraction of one box is right in
// any other. So: fractions of this box throughout, and the move is
// the whole of the difference.
let region = UiRegion::FULL;
// Whichever child sizes the stack is asked here and not again below,
// on the layer it ends up on: a retained drawing belongs to the layer
// it was made on, so measuring it anywhere else costs a second
// drawing of it. Its box is its own answer, so the answer is not
// placed inside it again.
// Whichever child sizes the stack decides the box every child gets.
// The stack reports that size, so a child given a longer box would
// draw outside what the stack says it occupies.
let size = match sizing.and_then(|i| self.children.get(i).map(|c| (i, c))) {
// On the layer that child ends up on, so the ask below is a reuse
// rather than a second drawing of it somewhere else: a retained
// drawing belongs to the layer it was made on.
Some((i, child)) => {
painter.child_layer_at(i);
painter
.widget_at(child, region, region.size(), [true; 2])
.size()
painter.widget(child).size()
}
None => Size::LEFTOVER,
};
let region = painter.box_of(size);
for (i, child) in self.children.iter().enumerate() {
if sizing == Some(i) {
continue;
}
painter.child_layer_at(i);
// A box that owes nothing to this child's own answer: where it
// sits in one bigger than itself is its own business.
painter.widget_within(child, region);
// The sizing child placed its own content in the box its answer
// decided, and this box was derived from that answer, so applying
// its alignment again here would place it twice. Every other
// child is handed a box that owes nothing to its own answer, and
// where it sits in one bigger than itself is its own business.
match sizing == Some(i) {
true => painter.widget_at(child, region, region.size(), [true; 2]),
false => painter.widget_within(child, region),
};
}
size
}
-25
View File
@@ -82,31 +82,6 @@ fn a_text_in_a_span_wraps_at_the_room_left_rather_than_the_whole_row() {
assert!(crowded > whole_row, "{crowded} against {whole_row}");
}
/// A stack takes its size from one child and gives every child that size, so
/// a child asking for half of it is asking for half of what it is itself the
/// size of. Once the stack has been placed at the length it reported that
/// length is the box, and taking the fraction of it again takes it twice:
/// half a row became a quarter, and a further stack around it a further half.
/// Nothing pinned it because a pixel is the same length wherever it is taken
/// from, so only a share ever shrank -- and warm and cold shrink alike, so no
/// oracle saw it either.
#[test]
fn a_stack_sized_by_a_child_does_not_take_that_childs_fraction_twice() {
let mut h = Harness::new((400, 200));
let half = rect(Color::RED).width(rel(0.5)).add(&mut h.rsc);
let behind = rect(Color::BLUE).add(&mut h.rsc);
let stack = Stack {
children: vec![behind.add_strong(&mut h.rsc), half.add_strong(&mut h.rsc)],
size: StackSize::Child(1),
}
.add(&mut h.rsc);
h.set_root((stack,).span(Dir::RIGHT).width(rel(1.0)));
assert_corners!(h, stack, (0, 0), (200, 200));
assert_corners!(h, half, (0, 0), (200, 200));
assert_corners!(h, behind, (0, 0), (200, 200));
}
/// The same reading through a pad: its inset is the whole box less the
/// padding, so half of the inset plus the padding is half the box plus one
/// padding, not two.
+109
View File
@@ -628,3 +628,112 @@ fn a_masked_widget_redrawn_on_its_own_sets_its_mask_again() {
h.frame();
assert_corners!(h, inner, (100, 0), (400, 200));
}
/// The two spans a subtree changes hands between, and the branch that is not
/// in the tree yet -- kept alive by the test until it is.
struct Handover {
leaf: WidgetId,
first: WeakWidget<Span>,
second: WeakWidget<Span>,
root: WeakWidget<Span>,
spare: StrongWidget,
}
/// A subtree that changes hands while its box does not move, so nothing about
/// reusing its drawing says it changed parents. `deeper` puts a span between
/// the root and `second`, so it changes depth by changing hands as well.
fn plant_handover(h: &mut Harness, moved: bool, deeper: bool, width: f32) -> Handover {
let leaf = rect(Color::RED).add(&mut h.rsc);
let sized = leaf.width(width).add(&mut h.rsc);
let holder = (sized,).span(Dir::RIGHT).add(&mut h.rsc);
let first = Span {
children: match moved {
true => Vec::new(),
false => vec![holder.add_strong(&mut h.rsc)],
},
dir: Dir::RIGHT,
gap: Px::ZERO,
}
.add(&mut h.rsc);
let second = Span {
children: match moved {
true => vec![holder.add_strong(&mut h.rsc)],
false => Vec::new(),
},
dir: Dir::RIGHT,
gap: Px::ZERO,
}
.add(&mut h.rsc);
let branch = match deeper {
true => (second,).span(Dir::RIGHT).add_strong(&mut h.rsc),
false => second.add_strong(&mut h.rsc),
};
let (in_tree, spare) = match moved {
true => (branch, first.add_strong(&mut h.rsc)),
false => (first.add_strong(&mut h.rsc), branch),
};
let root = Span {
children: vec![in_tree],
dir: Dir::RIGHT,
gap: Px::ZERO,
}
.add(&mut h.rsc);
h.state.root = Some(root.add_strong(&mut h.rsc));
Handover {
leaf: sized.id(),
first,
second,
root,
spare,
}
}
/// Moves the subtree and swaps the branch it sits in for the one it left.
fn hand_over(h: &mut Harness, tree: Handover) -> WidgetId {
let holder = h.rsc[tree.first].children.remove(0);
h.rsc[tree.second].children.push(holder);
h.rsc[tree.root].children.clear();
h.rsc[tree.root].children.push(tree.spare);
h.frame();
tree.leaf
}
#[test]
fn a_subtree_that_changed_parents_is_not_undrawn_by_the_one_it_left() {
let mut warm = Harness::new((400, 200));
let tree = plant_handover(&mut warm, false, false, 40.0);
warm.frame();
let leaf = hand_over(&mut warm, tree);
let mut cold = Harness::new((400, 200));
let grown = plant_handover(&mut cold, true, false, 40.0);
cold.frame();
assert_eq!(
warm.region(&leaf),
cold.region(&grown.leaf),
"the span it left still listed it and undrew it"
);
}
#[test]
fn a_subtree_that_changed_parents_settles_at_the_depth_it_moved_to() {
let mut warm = Harness::new((400, 200));
let tree = plant_handover(&mut warm, false, true, 40.0);
warm.frame();
let leaf = hand_over(&mut warm, tree);
// After it has changed hands, so what has to reach the new parent is a
// change made under the subtree it now holds.
warm.set_len(leaf, Axis::X, LayoutLen::px(90.0));
warm.frame();
let mut cold = Harness::new((400, 200));
let grown = plant_handover(&mut cold, true, true, 90.0);
cold.frame();
assert_eq!(
warm.region(&leaf),
cold.region(&grown.leaf),
"the span it moved to is the one the change has to reach"
);
}