Make the frame a length of the window and the box a region

There is one coordinate unit, the window. Every box in the tree is a
region in window units and a widget's frame is a length in the same
units, which is only what fractions resolve against, so the box need not
be the frame and padding can take from both without either becoming the
other. A region node's entry is a translation -- a rel 1 region anchored
where its box starts -- rather than a box, so nothing composes a frame
back up a chain and a node that moves is one entry write.

Padding is then an inset of both: its pixels come off the frame, so
rel(1.0) under it fills the padded widget rather than overflowing it,
and off the box, so what is drawn sits inside. A length a container
decides for a child's frame is a length of the window like everything
else here -- a row's slot, padding's frame less its pixels, or the box a
stack's sizing child decided, which arrives as Part::Sized -- because a
slot of a row is not a fraction of anything the row can name, the same
reason a node entry is a translation. A declaration is a fraction of
whichever of those reached it, and is the only one that also places the
box.

Frame validity is a pin beside the box's, not a range: a range of window
pixels cannot say which frame an answer is a fraction of, since two
frames are different lengths at the same window size. A widget pins its
frame by reading it or by being answered with it under a fractional
rule, and the pin composes up wherever a length of this frame is what
reached the child.

Also here, because the diagnosis needed them: the shrinker reports the
shrunk tree's own divergence with each level's frame, ask, box and size
warm against cold, and there is a size-resize case -- a change and then a
resize, the order that shows an answer kept as a fraction of the wrong
length, which every other case compares at the window it was made at.

Three defects the reports found, each pinned: a rule changed over two
pads relocated the column under them instead of dividing it again (seed
59, depth 5, resize-size), a share inside padding had the padding taken
off twice, and a root resolved its own rule twice.

fmt and clippy clean with and without layout-diagnostics, 121 suite, 20
core, 11 generated, the 400-seed depth-5 shrinker over all sixteen cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-19 00:14:46 -04:00
1 parent 0ef87ebfcf
commit 1512d8418b
15 files changed
+680 -444

No files matched your search

+60 -4
View File
@@ -103,6 +103,11 @@ pub enum Case {
/// A resize and then a size change, so a retained answer is asked to
/// survive two different kinds of invalidation in a row.
ResizeSize,
/// A size change and then a resize, which is the other order and not the
/// same test: a length answered as a fraction of one box and kept as a
/// fraction of another agrees at the size it was changed at and parts
/// from it at every other one.
SizeResize,
/// A few declared sizes.
Size,
/// Every declared size at once, so every reader of a size has a changed
@@ -119,12 +124,13 @@ pub enum Case {
Shuffle(Shuffle),
}
pub const ALL: [Case; 15] = [
pub const ALL: [Case; 16] = [
Case::Repaint,
Case::RepaintSome,
Case::Resize,
Case::ResizeRepaint,
Case::ResizeSize,
Case::SizeResize,
Case::Size,
Case::EverySize,
Case::Align,
@@ -146,6 +152,7 @@ impl Case {
Self::Resize => "resize",
Self::ResizeRepaint => "resize-repaint",
Self::ResizeSize => "resize-size",
Self::SizeResize => "size-resize",
Self::Size => "size",
Self::EverySize => "every-size",
Self::Align => "align",
@@ -170,6 +177,15 @@ impl Case {
_ => (STILL, STILL),
}
}
/// The window the warm tree is taken to after the change, where the case
/// is about what the change left behind rather than about the change.
fn then_resize(self) -> Option<(f32, f32)> {
match self {
Self::SizeResize => Some(INNER),
_ => None,
}
}
}
fn mark(warm: &mut Harness, tree: &Tree, step: usize) {
@@ -284,7 +300,7 @@ fn change(case: Case, warm: &mut Harness, tree: &mut Tree, plan: &Plan, rng: &mu
warm.frame();
return out;
}
Case::Size | Case::ResizeSize => Edits {
Case::Size | Case::ResizeSize | Case::SizeResize => Edits {
sizes: some_sizes(warm, tree, rng),
..Default::default()
},
@@ -384,6 +400,17 @@ fn describe_widget(id: WidgetId, h: &Harness) -> String {
label
}
/// One widget's layout as it stands: the frame its fractions resolved
/// against, the box it was asked in, the box its drawing went in, and what
/// it reported. In window units, which is what both trees are in.
fn record(id: WidgetId, h: &Harness) -> String {
let active = &h.render.active[&id];
format!(
"frame {} ask {} box {} size {}",
active.frame, active.offer_part, active.extent, active.size,
)
}
/// Runs `case` on the tree `plan` describes, warm and cold, and says where
/// the two disagree. `seed` chooses only the values a case picks at random,
/// so one plan under one case is one comparison however it was reached.
@@ -400,12 +427,29 @@ pub fn diverges(plan: &Plan, case: Case, seed: u64) -> Option<String> {
warm.frame();
}
let cold_plan = change(case, &mut warm, &mut tree, plan, &mut Rng::new(seed));
// Whatever the change left, seen at another window: an answer kept as a
// fraction of the wrong length is the same number of pixels where it was
// made and a different one everywhere else.
let end = match case.then_resize() {
Some(after) => {
warm.resize(after);
warm.frame();
after
}
None => end,
};
let mut cold = Harness::new(end);
let (root, cold_tree) = build(&mut cold.rsc, &cold_plan);
cold.state.root = Some(root);
cold.frame();
let places: HashMap<WidgetId, usize> = tree
.ids
.iter()
.enumerate()
.map(|(i, &id)| (id, i))
.collect();
let mut drawn = 0;
for (i, (&w, &c)) in tree.ids.iter().zip(&cold_tree.ids).enumerate() {
let (got, want) = (warm.region(&w), cold.region(&c));
@@ -416,6 +460,7 @@ pub fn diverges(plan: &Plan, case: Case, seed: u64) -> Option<String> {
// Where two trees disagree is rarely where the cause is, so the
// ancestry comes with it, marking the widgets that own a region.
let mut chain = Vec::new();
let mut records = Vec::new();
let mut at = Some(w);
while let Some(id) = at {
let active = &warm.render.active[&id];
@@ -424,11 +469,22 @@ pub fn diverges(plan: &Plan, case: Case, seed: u64) -> Option<String> {
false => "*",
};
chain.push(format!("{}{node}", describe(id, &warm)));
// What each level was asked in on both sides, since the level
// where the two stop agreeing is the one to look at rather than
// the leaf that reported the difference.
let cold_id = places.get(&id).and_then(|&i| cold_tree.ids.get(i));
records.push(format!(
" {}\n warm {}\n cold {}",
describe(id, &warm),
record(id, &warm),
cold_id.map_or("-".into(), |&id| record(id, &cold)),
));
at = active.parent;
}
return Some(format!(
"widget {i}\n warm {got:?}\n cold {want:?}\n {}",
chain.join(" < ")
"widget {i}\n warm {got:?}\n cold {want:?}\n {}\n{}",
chain.join(" < "),
records.join("\n"),
));
}
match drawn {