From 1ffe6ea067d40aaedd5c655a2593fe15b4be5bc2 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Tue, 15 Sep 2026 01:02:06 -0400 Subject: [PATCH] Note the immediate-mode path Iris proposed, and what it would not catch Co-Authored-By: Claude Opus 5 --- docs/IRIS_EXTRACTION_HANDOFF.md | 127 ++- docs/bench/parked-ortho-and-asserts.patch | 1142 +++++++++++++++++++++ 2 files changed, 1267 insertions(+), 2 deletions(-) create mode 100644 docs/bench/parked-ortho-and-asserts.patch diff --git a/docs/IRIS_EXTRACTION_HANDOFF.md b/docs/IRIS_EXTRACTION_HANDOFF.md index 03f7810..aa6181f 100644 --- a/docs/IRIS_EXTRACTION_HANDOFF.md +++ b/docs/IRIS_EXTRACTION_HANDOFF.md @@ -379,6 +379,40 @@ sweep passes. ### Settling a dirty set, and why the order is not free to change +**There are two ways to obtain a retained size and only one of them is +guarded.** `Painter::known_len` -> `RenderState::retained_size` is the path +that answers a child's length without drawing it, and it refuses on +`size_is_invalid(id) || dirty_size_under(id)` -- it asks the deep question. +`try_reuse` is the path that keeps a child's *drawing* for a new box, and it +asks only whether that widget is itself in `needs_redraw`; the size comes back +as a by-product of the reuse succeeding, and never got the guard the other path +has. So a reader is not reaching past anything: it asks a clean child, and the +child answers from a record that is stale only because something below it has +not settled. **The settle order is standing in for a missing check on one of +two implementations of the same concept**, which is why the order is +load-bearing for the answer and why that was easy to miss. + +That also says why bolting `dirty_size_under` onto `try_reuse` costs about what +the sort saves. On the `many` phase at 130 of 260 dirty the guarded path runs +47 times a frame and `try_reuse` runs 569 -- so the O(subtree) walk moves from +a cold path to one twelve times hotter. **The owner rejected building a maintained count for this on +2026-09-14**, and the reasoning stands: the count is only clarity, it costs a +pairing obligation at every site that marks or consumes a mark plus a cost in +every shipped frame, and bottom-up is needed for its own sake regardless. A +count that drifts low is a silent stale size, which is worse than what it +replaces. Its one remaining argument would have been freedom to settle in any +order, and the rows below measure that as costing more than it saves. + +What guards the invariant instead: `a_long_run_of_seeds_agrees` compares a warm +incremental frame against a cold rebuild of the same tree over a hundred seeds, +which is what caught the defect on seed 2 and is a stronger oracle than an +assertion. A `debug_assert!(!self.dirty_size_under(..))` on the two paths in +`try_reuse` that return a size would make the dependence on the order fail +loudly there rather than silently -- those are the exact and moved paths, 133 +calls a frame on the `many` load rather than all 569 attempts -- and it +compiles out of release, matching what `depth` already does against +`walked_depth`. + **Why picking any dirty widget does not work, found 2026-09-14.** `try_reuse` asks whether the widget in front of it is dirty and, if not, hands its parent the size it last reported. It does not ask whether a dirty widget sits under it @@ -458,6 +492,60 @@ hits and only two lines differ between the builds. Nothing here rests on that difference, but it means those two rows are worth re-measuring before anyone builds on them. +### An immediate-mode path, proposed by the owner + +Iris raised this on 2026-09-15, as something to have rather than something to +do now: a way to render with nothing cached at all, redrawing everything cold. +Per widget would be nicer, but a switch on the ui, or a second entry point +alongside `update`, is the useful start. Two uses -- the performance floor a +retained layout is measured against, and a second opinion on correctness that +does not share any of the retained machinery. + +Worth recording what it would and would not have caught. Not the `SetSize` +defect below: that one is a first frame laying out wrongly, with no retained +state involved, so an immediate path would have reproduced it faithfully. It +would catch anything where keeping a drawing is what goes wrong, which is what +`generated.rs` uses a cold `Harness` for today -- and a cold `Harness` is a +weaker instrument, because building a second tree is not the same code path as +refusing to reuse the first. + +### A span's size across its own axis + +`Span` reported `max(children abs)` across its axis, except that one child with +any `rel` or `rest` flipped the whole span to `Len::REST` -- a discontinuity +its own `TODO` admitted. So any disagreement about a child's reported size +moved the span between tight and filled rather than by a little, and every +child is placed `FULL` across the axis, so they all inherit the move together. +That is what the depth-5 seed-10 divergence looks like: three texts, identical +heights, all jumping from 874 px wide to 306. + +`OrthoSize::{Fill, Children}` replaces it, chosen per span rather than inferred +from what the children happen to report. `Children` propagates the largest +child whole -- a child at `rel` 0.5 makes the span `rel` 0.5 -- and compares +candidates in pixels only when one has a share the other does not, since that +is the only case the components cannot be compared directly. Reading pixels +costs `OnResize::Scale` across the axis, which is why the choice is explicit. + +**It is circular, and this is the open question.** `Children` reads the span's +own box to decide which child is longest, and what it reports decides that box: +offered 900 it may report 306, be given 306, and on the next draw compare +against 306 and pick a different child. `adding_and_removing_span_children` +seed 13 diverges in release with 112 widgets wrong, at `DEPTH = 4`. The +comparison wants a reference that does not depend on the answer -- the offered +box rather than the settled one -- which is stable until a `Children` span +nests inside another. This is the cross-axis twin of the along-axis +double-shaping in LAYOUT.md ยง4, not a separate defect. + +**Depth 4 is not enough.** `tests/generated.rs` ran at `DEPTH = 4` and the +generator branches two to four ways per level, so depth is exponential in width +and a deep tree cannot be reached by raising it. `IRIS_GENERATED_DEPTH` and +`IRIS_GENERATED_SEEDS` now select the load. At depth 5 the first 300 seeds +already fail -- seed 10, `AddThree`, a wrapping text under a span whose axis is +**Y**, which the comment on `a_long_run_of_seeds_agrees` claims is the stable +case. That claim is wrong, and the "7 of these 90 / 30 with it" numbers beside +it do not match a sweep that passes clean at depth 4; re-measure before +trusting them. + ### Dirtying many widgets at once **A frame that dirties many widgets at once was not being checked, and it is @@ -469,8 +557,43 @@ to the rig, with `IRIS_DIRTY` widgets marked per frame. At 130 of 260 widgets, set is scanned once per widget settled, and a `HashSet` is walked by capacity rather than by length. Memoizing the depth walk within one scan does not pay (it trades parent lookups for memo lookups, 4% more instructions); the fix is -to stop rescanning, which changes the order widgets settle in and is hers to -agree first. +to stop rescanning. + +**That fix does not have to change the order, and the cost is not the +choosing.** Measured 2026-09-14 on the `many` phase at 130 of 260 dirty, all +4.7% of it is one symbol: hashbrown's `RawIterRange::fold_impl`, the table walk +under `max_by_key`. Since `3f7cd82` the key is a field read, so what is left is +the cost of *finding* an element in a set walked by capacity, once per widget +settled -- not the cost of deciding between them. A depth-bucketed worklist in +`UiRenderState` keeps the order exactly and makes the pop O(1); the objection +that `needs_redraw` lives on `Widgets` and is inserted from places with no view +of the tree does not apply to it, because the buckets are a hint rather than a +second truth. A bucket entry whose mark has since been consumed is skipped on +pop for one hash lookup, so the obligation is only never to miss an insert, +never to stay in step. + +**Going order-free is not the thing to buy.** `iter().next()` in place of the +deepest-first pick, with no check added, takes widget draws from 502 to 670 per +frame and instructions from 6.84M to 9.01M. The extra draws are not a parent +drawn twice on its own: the same widgets appear at the top of the per-widget +draw counts in both orders, at the same trial widths (a `Text` under +`SetSize < Scroll < Span` is drawn at 14 distinct widths either way), with +every count about 1.5x. The measure-by-drawing loop is not exploring more, it +is being re-entered. What drives the re-entry is that boxes change more often: +`try_reuse` refusals for a changed box go from 79 to 116 own-resize and 126 to +187 descendant-resize, because a reader settled before its children hands out +boxes from sizes that are about to move, and when they move every child in the +subtree refuses reuse. The `dirty_size_under` check is what buys that back, +which is why the order-free rows above come out 3.4% dearer at 130 dirty rather +than 32%. So the order is paying for itself in draws, and the count maintained +up the reader chain would be a way to afford dropping something worth keeping. + +One counter does not fit that account and was not chased down: queue pops fall +from 87 to 80 and local redraws from 66 to 59, where re-marking a reader should +raise both. The likely reading is that a re-marked reader is consumed inside a +later pop's subtree draw rather than popped on its own, but it is inferred from +`redraw`'s escalation path rather than traced. `EagerReaderRedraws` is 4 and 3, +so the escalation itself is not where the draws come from. **What a frame is made of now**, `perf record` on the `many` phase at 130 of 260 dirty, which is the heaviest thing the rig has: `Layers::write` 16%, diff --git a/docs/bench/parked-ortho-and-asserts.patch b/docs/bench/parked-ortho-and-asserts.patch new file mode 100644 index 0000000..948649c --- /dev/null +++ b/docs/bench/parked-ortho-and-asserts.patch @@ -0,0 +1,1142 @@ +diff --git a/core/src/orientation/align.rs b/core/src/orientation/align.rs +index 876208a..ff79dbf 100644 +--- a/core/src/orientation/align.rs ++++ b/core/src/orientation/align.rs +@@ -144,10 +144,10 @@ impl UiScalar { + pub const fn align(&self, align: AxisAlign) -> UiSpan { + let rel = align.rel(); + let mut start = UiScalar::rel(rel); +- start.abs -= self.abs * rel; ++ start.px -= self.px * rel; + start.rel -= self.rel * rel; + let mut end = UiScalar::rel(rel); +- end.abs += self.abs * (1.0 - rel); ++ end.px += self.px * (1.0 - rel); + end.rel += self.rel * (1.0 - rel); + UiSpan { start, end } + } +diff --git a/core/src/orientation/len.rs b/core/src/orientation/len.rs +index 8725211..5afeb81 100644 +--- a/core/src/orientation/len.rs ++++ b/core/src/orientation/len.rs +@@ -9,14 +9,14 @@ pub struct Size { + + #[derive(Debug, Clone, Copy, PartialEq)] + pub struct Len { +- pub abs: f32, ++ pub px: f32, + pub rel: f32, + pub rest: f32, + } + + impl From for Len { + fn from(value: N) -> Self { +- Len::abs(value.to_f32()) ++ Len::px(value.to_f32()) + } + } + +@@ -46,10 +46,10 @@ impl Size { + y: Len::REST, + }; + +- pub fn abs(v: Vec2) -> Self { ++ pub fn px(v: Vec2) -> Self { + Self { +- x: Len::abs(v.x), +- y: Len::abs(v.y), ++ x: Len::px(v.x), ++ y: Len::px(v.y), + } + } + +@@ -97,13 +97,13 @@ impl Size { + + impl Len { + pub const ZERO: Self = Self { +- abs: 0.0, ++ px: 0.0, + rel: 0.0, + rest: 0.0, + }; + + pub const REST: Self = Self { +- abs: 0.0, ++ px: 0.0, + rel: 0.0, + rest: 1.0, + }; +@@ -111,27 +111,27 @@ impl Len { + pub fn apply_rest(&self) -> UiScalar { + UiScalar { + rel: self.rel + if self.rest > 0.0 { 1.0 } else { 0.0 }, +- abs: self.abs, ++ px: self.px, + } + } + +- pub fn abs(abs: impl UiNum) -> Self { ++ pub fn px(px: impl UiNum) -> Self { + Self { +- abs: abs.to_f32(), ++ px: px.to_f32(), + rel: 0.0, + rest: 0.0, + } + } + pub fn rel(rel: impl UiNum) -> Self { + Self { +- abs: 0.0, ++ px: 0.0, + rel: rel.to_f32(), + rest: 0.0, + } + } + pub fn rest(ratio: impl UiNum) -> Self { + Self { +- abs: 0.0, ++ px: 0.0, + rel: 0.0, + rest: ratio.to_f32(), + } +@@ -141,31 +141,31 @@ impl Len { + pub mod len_fns { + use super::*; + +- pub fn abs(abs: impl UiNum) -> Len { ++ pub fn px(px: impl UiNum) -> Len { + Len { +- abs: abs.to_f32(), ++ px: px.to_f32(), + rel: 0.0, + rest: 0.0, + } + } + pub fn rel(rel: impl UiNum) -> Len { + Len { +- abs: 0.0, ++ px: 0.0, + rel: rel.to_f32(), + rest: 0.0, + } + } + pub fn rest(ratio: impl UiNum) -> Len { + Len { +- abs: 0.0, ++ px: 0.0, + rel: 0.0, + rest: ratio.to_f32(), + } + } + } + +-impl_op!(Len Add add; abs rel rest); +-impl_op!(Len Sub sub; abs rel rest); ++impl_op!(Len Add add; px rel rest); ++impl_op!(Len Sub sub; px rel rest); + + impl_op!(Size Add add; x y); + impl_op!(Size Sub sub; x y); +@@ -184,8 +184,8 @@ impl std::fmt::Display for Size { + + impl std::fmt::Display for Len { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +- if self.abs != 0.0 { +- write!(f, "{} abs;", self.abs)?; ++ if self.px != 0.0 { ++ write!(f, "{} px;", self.px)?; + } + if self.rel != 0.0 { + write!(f, "{} rel;", self.rel)?; +diff --git a/core/src/orientation/pos.rs b/core/src/orientation/pos.rs +index d2fc8f4..6a31276 100644 +--- a/core/src/orientation/pos.rs ++++ b/core/src/orientation/pos.rs +@@ -23,11 +23,11 @@ impl UiVec2 { + Self { x, y } + } + +- pub const fn abs(abs: impl const Into) -> Self { +- let abs = abs.into(); ++ pub const fn px(px: impl const Into) -> Self { ++ let px = px.into(); + Self { +- x: UiScalar::abs(abs.x), +- y: UiScalar::abs(abs.y), ++ x: UiScalar::px(px.x), ++ y: UiScalar::px(px.y), + } + } + +@@ -70,10 +70,10 @@ impl UiVec2 { + } + } + +- pub fn to_abs(&self, rel: Vec2) -> Vec2 { ++ pub fn to_px(&self, rel: Vec2) -> Vec2 { + Vec2 { +- x: self.x.to_abs(rel.x), +- y: self.y.to_abs(rel.y), ++ x: self.x.to_px(rel.x), ++ y: self.y.to_px(rel.y), + } + } + +@@ -92,8 +92,8 @@ impl UiVec2 { + } + } + +- pub fn get_abs(&self) -> Vec2 { +- (self.x.abs, self.y.abs).into() ++ pub fn get_px(&self) -> Vec2 { ++ (self.x.px, self.y.px).into() + } + + pub fn get_rel(&self) -> Vec2 { +@@ -102,15 +102,15 @@ impl UiVec2 { + + pub fn abs_mut(&mut self) -> Vec2View<'_> { + Vec2View { +- x: &mut self.x.abs, +- y: &mut self.y.abs, ++ x: &mut self.x.px, ++ y: &mut self.y.px, + } + } + } + + impl Display for UiVec2 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +- write!(f, "rel{};abs{}", self.get_rel(), self.get_abs()) ++ write!(f, "rel{};px{}", self.get_rel(), self.get_px()) + } + } + +@@ -118,8 +118,8 @@ impl_op!(UiVec2 Add add; x y); + impl_op!(UiVec2 Sub sub; x y); + + const impl From for UiVec2 { +- fn from(abs: Vec2) -> Self { +- Self::abs(abs) ++ fn from(px: Vec2) -> Self { ++ Self::px(px) + } + } + +@@ -127,8 +127,8 @@ const impl From<(T, U)> for UiVec2 + where + (T, U): const Destruct, + { +- fn from(abs: (T, U)) -> Self { +- Self::abs(abs) ++ fn from(px: (T, U)) -> Self { ++ Self::px(px) + } + } + +@@ -136,34 +136,34 @@ where + #[derive(Debug, Copy, Clone, PartialEq, bytemuck::Pod, Default, bytemuck::Zeroable)] + pub struct UiScalar { + pub rel: f32, +- pub abs: f32, ++ pub px: f32, + } + + impl Eq for UiScalar {} + impl Hash for UiScalar { + fn hash(&self, state: &mut H) { + state.write_u32(self.rel.to_bits()); +- state.write_u32(self.abs.to_bits()); ++ state.write_u32(self.px.to_bits()); + } + } + +-impl_op!(UiScalar Add add; rel abs); +-impl_op!(UiScalar Sub sub; rel abs); ++impl_op!(UiScalar Add add; rel px); ++impl_op!(UiScalar Sub sub; rel px); + + impl UiScalar { +- pub const ZERO: Self = Self { rel: 0.0, abs: 0.0 }; +- pub const FULL: Self = Self { rel: 1.0, abs: 0.0 }; ++ pub const ZERO: Self = Self { rel: 0.0, px: 0.0 }; ++ pub const FULL: Self = Self { rel: 1.0, px: 0.0 }; + +- pub const fn new(rel: f32, abs: f32) -> Self { +- Self { rel, abs } ++ pub const fn new(rel: f32, px: f32) -> Self { ++ Self { rel, px } + } + + pub const fn rel(rel: f32) -> Self { +- Self { rel, abs: 0.0 } ++ Self { rel, px: 0.0 } + } + +- pub const fn abs(abs: f32) -> Self { +- Self { rel: 0.0, abs } ++ pub const fn px(px: f32) -> Self { ++ Self { rel: 0.0, px } + } + + pub const fn rel_min() -> Self { +@@ -177,28 +177,28 @@ impl UiScalar { + pub const fn max(&self, other: Self) -> Self { + Self { + rel: self.rel.max(other.rel), +- abs: self.abs.max(other.abs), ++ px: self.px.max(other.px), + } + } + + pub const fn min(&self, other: Self) -> Self { + Self { + rel: self.rel.min(other.rel), +- abs: self.abs.min(other.abs), ++ px: self.px.min(other.px), + } + } + + pub const fn offset(mut self, amt: f32) -> Self { +- self.abs += amt; ++ self.px += amt; + self + } + + pub const fn within(&self, span: &UiSpan) -> Self { + let anchor = self.rel.lerp(span.start.rel, span.end.rel); +- let offset = self.abs + self.rel.lerp(span.start.abs, span.end.abs); ++ let offset = self.px + self.rel.lerp(span.start.px, span.end.px); + Self { + rel: anchor, +- abs: offset, ++ px: offset, + } + } + +@@ -215,15 +215,15 @@ impl UiScalar { + + pub const fn flip(&mut self) { + self.rel = 1.0 - self.rel; +- self.abs = -self.abs; ++ self.px = -self.px; + } + + pub const fn to(&self, end: Self) -> UiSpan { + UiSpan { start: *self, end } + } + +- pub const fn to_abs(&self, rel: f32) -> f32 { +- self.rel * rel + self.abs ++ pub const fn to_px(&self, rel: f32) -> f32 { ++ self.rel * rel + self.px + } + } + +@@ -255,7 +255,7 @@ impl UiSpan { + self.start.flip(); + self.end.flip(); + std::mem::swap(&mut self.start.rel, &mut self.end.rel); +- std::mem::swap(&mut self.start.abs, &mut self.end.abs); ++ std::mem::swap(&mut self.start.px, &mut self.end.px); + } + + pub const fn shift(&mut self, offset: UiScalar) { +@@ -338,8 +338,8 @@ impl UiRegion { + + pub fn to_px(&self, size: Vec2) -> PixelRegion { + PixelRegion { +- top_left: self.top_left().get_rel() * size + self.top_left().get_abs(), +- bot_right: self.bot_right().get_rel() * size + self.bot_right().get_abs(), ++ top_left: self.top_left().get_rel() * size + self.top_left().get_px(), ++ bot_right: self.bot_right().get_rel() * size + self.bot_right().get_px(), + } + } + +diff --git a/core/src/render/shader/prelude.wgsl b/core/src/render/shader/prelude.wgsl +index 42bd824..2b98a50 100644 +--- a/core/src/render/shader/prelude.wgsl ++++ b/core/src/render/shader/prelude.wgsl +@@ -40,7 +40,7 @@ const CHAIN_LIMIT: u32 = 64u; + fn scalar_within(s: UiScalar, p: UiSpan) -> UiScalar { + return UiScalar( + mix(p.start.rel, p.end.rel, s.rel), +- s.abs + mix(p.start.abs, p.end.abs, s.rel), ++ s.px + mix(p.start.px, p.end.px, s.rel), + ); + } + +@@ -69,7 +69,7 @@ struct UiSpan { + + struct UiScalar { + rel: f32, +- abs: f32, ++ px: f32, + } + + struct InstanceInput { +@@ -104,12 +104,12 @@ fn vs_main( + ); + let r = resolve_move(in.move_idx, local); + let top_left_rel = vec2(r.x.start.rel, r.y.start.rel); +- let top_left_abs = vec2(r.x.start.abs, r.y.start.abs); ++ let top_left_px = vec2(r.x.start.px, r.y.start.px); + let bot_right_rel = vec2(r.x.end.rel, r.y.end.rel); +- let bot_right_abs = vec2(r.x.end.abs, r.y.end.abs); ++ let bot_right_px = vec2(r.x.end.px, r.y.end.px); + +- let top_left = floor(top_left_rel * window.dim) + floor(top_left_abs); +- let bot_right = floor(bot_right_rel * window.dim) + floor(bot_right_abs); ++ let top_left = floor(top_left_rel * window.dim) + floor(top_left_px); ++ let bot_right = floor(bot_right_rel * window.dim) + floor(bot_right_px); + let size = bot_right - top_left; + + let uv = vec2( +@@ -136,12 +136,12 @@ fn masked(in: VertexOutput, color: vec4) -> vec4 { + // clips content that moves inside it. + let m = resolve_move(mask.move_idx, Region(mask.x, mask.y)); + let tl = vec2(m.x.start.rel, m.y.start.rel); +- let tl_abs = vec2(m.x.start.abs, m.y.start.abs); ++ let tl_px = vec2(m.x.start.px, m.y.start.px); + let br = vec2(m.x.end.rel, m.y.end.rel); +- let br_abs = vec2(m.x.end.abs, m.y.end.abs); ++ let br_px = vec2(m.x.end.px, m.y.end.px); + +- let top_left = floor(tl * window.dim) + floor(tl_abs); +- let bot_right = floor(br * window.dim) + floor(br_abs); ++ let top_left = floor(tl * window.dim) + floor(tl_px); ++ let bot_right = floor(br * window.dim) + floor(br_px); + let pos = in.clip_position.xy; + if pos.x < top_left.x || pos.x > bot_right.x || pos.y < top_left.y || pos.y > bot_right.y { + return color * 0.0; +diff --git a/core/src/render/shader/rect.wgsl b/core/src/render/shader/rect.wgsl +index 6d8694d..5a01564 100644 +--- a/core/src/render/shader/rect.wgsl ++++ b/core/src/render/shader/rect.wgsl +@@ -34,6 +34,6 @@ fn distance_from_rect(pixel_pos: vec2, rect_center: vec2, rect_corner: + // vec from center to pixel + let p = pixel_pos - rect_center; + // vec from inner rect corner to pixel +- let q = abs(p) - (rect_corner - radius); ++ let q = px(p) - (rect_corner - radius); + return length(max(q, vec2(0.0))) - radius; + } +diff --git a/core/src/ui/painter.rs b/core/src/ui/painter.rs +index 5a403ff..db3ad0e 100644 +--- a/core/src/ui/painter.rs ++++ b/core/src/ui/painter.rs +@@ -260,9 +260,9 @@ impl<'a> Painter<'a> { + let mut region = origin; + region.x.end = region.x.start; + region.y.end = region.y.start; +- let mut region = region.offset(UiVec2::abs(glyph.offset)); +- region.x.end = region.x.start + UiScalar::abs(glyph.entry.width as f32); +- region.y.end = region.y.start + UiScalar::abs(glyph.entry.height as f32); ++ let mut region = region.offset(UiVec2::px(glyph.offset)); ++ region.x.end = region.x.start + UiScalar::px(glyph.entry.width as f32); ++ region.y.end = region.y.start + UiScalar::px(glyph.entry.height as f32); + self.write( + kind, + GlyphPrimitive { +@@ -306,7 +306,7 @@ impl<'a> Painter<'a> { + self.reads_output = [true; 2]; + self.size_box_inputs = [true; 2]; + let region = self.state.moves.resolve(self.move_idx, self.region); +- region.size().to_abs(self.state.output_size) ++ region.size().to_px(self.state.output_size) + } + + /// One axis of this widget's box in pixels. Prefer this to +@@ -318,7 +318,7 @@ impl<'a> Painter<'a> { + region + .size() + .axis(axis) +- .to_abs(self.state.output_size.axis(axis)) ++ .to_px(self.state.output_size.axis(axis)) + } + + pub fn text_data(&mut self) -> &mut TextData { +diff --git a/core/src/ui/render_state.rs b/core/src/ui/render_state.rs +index 1dc40b7..12c1e61 100644 +--- a/core/src/ui/render_state.rs ++++ b/core/src/ui/render_state.rs +@@ -325,7 +325,7 @@ impl UiRenderState { + self.moves + .resolve(slot, region) + .size() +- .to_abs(self.output_size) ++ .to_px(self.output_size) + } + + /// A clean widget's retained size, when the offered pixel axes which +@@ -435,6 +435,11 @@ impl UiRenderState { + diag::bump(Counter::ReuseExact); + diag::reuse(id, ReuseOutcome::Exact); + } ++ debug_assert!( ++ !self.dirty_size_under(id, rsc.widgets()), ++ "reused a retained size with a dirty widget under it; \ ++ the settle order in `redraw_updates` is what prevents this" ++ ); + self.keep_depth(id, depth); + return Some(size); + } +@@ -475,6 +480,11 @@ impl UiRenderState { + return None; + } + } ++ debug_assert!( ++ !self.dirty_size_under(id, rsc.widgets()), ++ "reused a retained size with a dirty widget under it; \ ++ the settle order in `redraw_updates` is what prevents this" ++ ); + self.moves.set(slot, region); + self.keep_depth(id, depth); + let active = self.active.get_mut(&id).unwrap(); +diff --git a/src/random.rs b/src/random.rs +index 921b1e8..4204d1d 100644 +--- a/src/random.rs ++++ b/src/random.rs +@@ -142,7 +142,7 @@ impl Grow<'_, Rsc> { + + fn len(&mut self) -> Option { + match self.rng.below(4) { +- 0 => Some(Len::abs(20.0 + self.rng.below(180) as f32)), ++ 0 => Some(Len::px(20.0 + self.rng.below(180) as f32)), + 1 => Some(Len::REST), + _ => None, + } +@@ -258,10 +258,17 @@ impl Grow<'_, Rsc> { + let attach = edit.attach.min(spares.len()); + children.extend(spares.drain(..attach)); + let dir = [Dir::RIGHT, Dir::DOWN, Dir::LEFT, Dir::UP][self.rng.below(4)]; ++ // Both ways of sizing across the axis, since which one a span uses ++ // decides whether it reads its own pixel length. ++ let ortho = match self.rng.chance() { ++ true => OrthoSize::Children, ++ false => OrthoSize::Fill, ++ }; + let id = Span { + children, + dir, + gap: self.rng.below(3) as f32 * 4.0, ++ ortho, + } + .add(self.rsc); + self.tree.ids.push(id.id()); +diff --git a/src/widget/image.rs b/src/widget/image.rs +index cc7d1bc..7491167 100644 +--- a/src/widget/image.rs ++++ b/src/widget/image.rs +@@ -8,11 +8,11 @@ pub struct Image { + impl Widget for Image { + fn draw(&mut self, painter: &mut Painter) -> Size { + painter.primitive(&self.handle); +- Size::abs(self.handle.size()) ++ Size::px(self.handle.size()) + } + + fn size_hint(&self, axis: Axis) -> Option { +- Some(Len::abs(self.handle.size().axis(axis))) ++ Some(Len::px(self.handle.size().axis(axis))) + } + + fn on_resize(&self, _: Axis) -> OnResize { +diff --git a/src/widget/position/max_size.rs b/src/widget/position/max_size.rs +index 714e534..a73171c 100644 +--- a/src/widget/position/max_size.rs ++++ b/src/widget/position/max_size.rs +@@ -19,7 +19,7 @@ impl Widget for MaxSize { + + fn capped(len: Len, max: Option, output: f32) -> Len { + match max { +- Some(max) if len.apply_rest().to_abs(output) > max.apply_rest().to_abs(output) => max, ++ Some(max) if len.apply_rest().to_px(output) > max.apply_rest().to_px(output) => max, + _ => len, + } + } +diff --git a/src/widget/position/pad.rs b/src/widget/position/pad.rs +index 6894421..e7cf5d9 100644 +--- a/src/widget/position/pad.rs ++++ b/src/widget/position/pad.rs +@@ -12,11 +12,11 @@ impl Widget for Pad { + .size(); + Size { + x: Len { +- abs: inner.x.abs + self.padding.left + self.padding.right, ++ px: inner.x.px + self.padding.left + self.padding.right, + ..inner.x + }, + y: Len { +- abs: inner.y.abs + self.padding.top + self.padding.bottom, ++ px: inner.y.px + self.padding.top + self.padding.bottom, + ..inner.y + }, + } +@@ -55,10 +55,10 @@ impl Padding { + } + pub fn region(&self) -> UiRegion { + let mut region = UiRegion::FULL; +- region.x.start.abs += self.left; +- region.y.start.abs += self.top; +- region.x.end.abs -= self.right; +- region.y.end.abs -= self.bottom; ++ region.x.start.px += self.left; ++ region.y.start.px += self.top; ++ region.x.end.px -= self.right; ++ region.y.end.px -= self.bottom; + region + } + pub fn x(amt: impl UiNum) -> Self { +diff --git a/src/widget/position/scroll.rs b/src/widget/position/scroll.rs +index cfac561..22c6e71 100644 +--- a/src/widget/position/scroll.rs ++++ b/src/widget/position/scroll.rs +@@ -12,7 +12,7 @@ pub struct Scroll { + impl Widget for Scroll { + fn draw(&mut self, painter: &mut Painter) -> Size { + let output_len = painter.output_len(self.axis); +- let container_len = UiScalar::abs(painter.px_len(self.axis)); ++ let container_len = UiScalar::px(painter.px_len(self.axis)); + // Draw in the whole container only when its scrolling-axis length is + // not already known, then place it at the scrolled offset. + let known_len = painter.known_len(&self.inner, self.axis, UiRegion::FULL); +@@ -22,8 +22,8 @@ impl Widget for Scroll { + .unwrap_or_else(|| child.unwrap().axis(self.axis)) + .apply_rest() + .within_len(container_len) +- .to_abs(output_len); +- self.container_len = container_len.to_abs(output_len); ++ .to_px(output_len); ++ self.container_len = container_len.to_px(output_len); + self.content_len = content_len; + + if self.snap_end { +diff --git a/src/widget/position/span.rs b/src/widget/position/span.rs +index 7fe29cc..82d5b5b 100644 +--- a/src/widget/position/span.rs ++++ b/src/widget/position/span.rs +@@ -1,10 +1,28 @@ + use crate::prelude::*; + use std::marker::PhantomData; + ++/// What a span reports across its own axis. ++/// ++/// Sizing to the children needs their lengths compared, and a length is an ++/// `px`, a `rel` and a `rest` together, so which is largest is only decided ++/// once they are resolved against a box. [`OrthoSize::Children`] therefore ++/// reads the span's own pixel length on that axis, and gives up scaling on a ++/// resize for it. ++#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] ++pub enum OrthoSize { ++ /// Takes whatever it is offered. ++ #[default] ++ Fill, ++ /// Reports its largest child, whole: a child at `rel` 0.5 makes the span ++ /// `rel` 0.5, rather than collapsing to an absolute length. ++ Children, ++} ++ + pub struct Span { + pub children: Vec, + pub dir: Dir, + pub gap: f32, ++ pub ortho: OrthoSize, + } + + impl Widget for Span { +@@ -24,26 +42,26 @@ impl Widget for Span { + Some(len) => len, + None => painter.place(child, region).len(axis), + }; +- cursor.abs += len.abs + self.gap; ++ cursor.px += len.px + self.gap; + cursor.rel += len.rel; + lens.push(len); + } + + let gap = self.gap * self.children.len().saturating_sub(1) as f32; +- let total = lens.iter().fold(Len::abs(gap), |sum, len| sum + *len); ++ let total = lens.iter().fold(Len::px(gap), |sum, len| sum + *len); + + let mut start = UiScalar::rel_min(); +- let mut ortho = Len::ZERO; ++ let mut ortho: Option = None; + for (child, len) in self.children.iter().zip(&lens) { + let mut span = UiSpan::FULL; + span.start = start; + if len.rest > 0.0 { +- let offset = UiScalar::new(total.rel, total.abs); ++ let offset = UiScalar::new(total.rel, total.px); + let rel_end = UiScalar::rel(len.rest / total.rest); + let end = (UiScalar::rel_max() + start) - offset; + start = rel_end.within(&start.to(end)); + } +- start.abs += len.abs; ++ start.px += len.px; + start.rel += len.rel; + span.end = start; + let mut region = UiRegion::from_axis(axis, span, UiSpan::FULL); +@@ -51,35 +69,65 @@ impl Widget for Span { + region.flip(axis); + } + let used = painter.place(child, region).size().axis(!axis); +- // TODO: rel shouldn't do this, but no easy way before actually calculating pixels +- if used.rel > 0.0 || used.rest > 0.0 { +- ortho = Len::REST; +- } else if ortho.rest == 0.0 { +- ortho.abs = ortho.abs.max(used.abs); +- } +- start.abs += self.gap; ++ // TODO: a child shorter than the span across its axis is still ++ // drawn in the whole of it, because a span cannot align one ++ // without placing it a second time -- which is a second draw at a ++ // different length, the thing this sizing exists to avoid. Wants ++ // alignment built into placement. ++ ortho = match ortho { ++ Some(ortho) => Some(longer(painter, !axis, ortho, used)), ++ None => Some(used), ++ }; ++ start.px += self.gap; + } + + let along = match total.rest == 0.0 && total.rel == 0.0 { + true => total, + false => Len::default(), + }; ++ let ortho = match self.ortho { ++ OrthoSize::Fill => Len::REST, ++ OrthoSize::Children => ortho.unwrap_or(Len::ZERO), ++ }; + Size::from_axis(axis, along, ortho) + } + + /// Every child is placed in fractions and offsets of the span's own box, + /// so a longer box holds the same layout and the children follow it. +- fn on_resize(&self, _: Axis) -> OnResize { +- OnResize::Scale ++ /// Across its axis [`OrthoSize::Children`] is the exception: which child ++ /// is longest is resolved in pixels, so a box of a new length can have a ++ /// different answer. ++ fn on_resize(&self, axis: Axis) -> OnResize { ++ match self.ortho == OrthoSize::Children && axis != self.dir.axis { ++ true => OnResize::Redraw, ++ false => OnResize::Scale, ++ } + } + } + ++/// The longer of two lengths across an axis, comparing in pixels only when ++/// one of them has a share the other does not, since that is the only case ++/// the components cannot be compared directly. ++fn longer(painter: &mut Painter, axis: Axis, a: Len, b: Len) -> Len { ++ let share = |len: &Len| len.rel != 0.0 || len.rest != 0.0; ++ if !share(&a) && !share(&b) { ++ return if b.px > a.px { b } else { a }; ++ } ++ let px = painter.px_len(axis); ++ let resolve = |len: Len| { ++ let scalar = len.apply_rest(); ++ scalar.rel * px + scalar.px ++ }; ++ if resolve(b) > resolve(a) { b } else { a } ++} ++ + impl Span { + pub fn empty(dir: Dir) -> Self { + Self { + children: Vec::new(), + dir, + gap: 0.0, ++ ortho: OrthoSize::Fill, + } + } + +@@ -88,6 +136,11 @@ impl Span { + self + } + ++ pub fn ortho(mut self, ortho: OrthoSize) -> Self { ++ self.ortho = ortho; ++ self ++ } ++ + pub fn push(&mut self, w: StrongWidget) { + self.children.push(w); + } +@@ -101,6 +154,7 @@ pub struct SpanBuilder, + } + +@@ -115,6 +169,7 @@ impl, Tag> WidgetFnTrait + children: self.children.add(rsc).arr.into_iter().collect(), + dir: self.dir, + gap: self.gap, ++ ortho: self.ortho, + } + } + } +@@ -127,6 +182,7 @@ impl, Tag> + children, + dir, + gap: 0.0, ++ ortho: OrthoSize::Fill, + _pd: PhantomData, + } + } +@@ -135,6 +191,11 @@ impl, Tag> + self.gap = gap.to_f32(); + self + } ++ ++ pub fn ortho(mut self, ortho: OrthoSize) -> Self { ++ self.ortho = ortho; ++ self ++ } + } + + impl std::ops::Deref for Span { +diff --git a/src/widget/text/edit.rs b/src/widget/text/edit.rs +index 5eafe59..a5d82fe 100644 +--- a/src/widget/text/edit.rs ++++ b/src/widget/text/edit.rs +@@ -280,7 +280,7 @@ impl<'a> TextEditCtx<'a> { + } + + pub fn select(&mut self, pos: Vec2, size: Vec2, drag: bool, recent: bool) { +- let pos = pos - self.text.region().top_left().to_abs(size); ++ let pos = pos - self.text.region().top_left().to_px(size); + let prev_sel = self.text.selection; + let prev_hit = self.text.double_hit; + +diff --git a/src/widget/text/mod.rs b/src/widget/text/mod.rs +index 9c1b142..6f2bc4c 100644 +--- a/src/widget/text/mod.rs ++++ b/src/widget/text/mod.rs +@@ -72,7 +72,7 @@ impl TextView { + + let tex = self.render(painter); + let region = tex.size.align(align); +- let size = Size::abs(tex.size); ++ let size = Size::px(tex.size); + let within = region.within(&painter.region()); + painter.glyphs(tex, within); + (region, size) +diff --git a/tests/chain_cost.rs b/tests/chain_cost.rs +index fc05aa3..2b36ece 100644 +--- a/tests/chain_cost.rs ++++ b/tests/chain_cost.rs +@@ -80,7 +80,7 @@ fn fill(ui: &mut UiData, render: &mut UiRenderState, depth: usize) { + slot = render.moves.push(slot, UiRegion::FULL); + } + +- let px = |v: f32| UiScalar { rel: 0.0, abs: v }; ++ let px = |v: f32| UiScalar { rel: 0.0, px: v }; + for i in 0..INSTANCES { + let x = (i % (SIZE as usize / 2)) as f32 * 2.0; + let y = (i / (SIZE as usize / 2)) as f32; +diff --git a/tests/generated.rs b/tests/generated.rs +index e572c7d..b3187c9 100644 +--- a/tests/generated.rs ++++ b/tests/generated.rs +@@ -16,7 +16,12 @@ use iris::harness::Harness; + use iris::prelude::*; + use iris::random::{Edits, Lens, Rng, SpanEdit, Tree, grow}; + +-const DEPTH: usize = 4; ++fn depth() -> usize { ++ std::env::var("IRIS_GENERATED_DEPTH") ++ .ok() ++ .and_then(|d| d.parse().ok()) ++ .unwrap_or(4) ++} + const SEEDS: [u64; 7] = [1, 2, 3, 5, 8, 13, 98]; + const REGION_EPSILON_PX: f32 = 0.05; + +@@ -38,7 +43,7 @@ fn same_region(got: Option, want: Option) -> bool { + } + + fn plant(h: &mut Harness, seed: u64, edits: &Edits) -> Tree { +- let (root, tree) = grow(&mut h.rsc, seed, DEPTH, edits); ++ let (root, tree) = grow(&mut h.rsc, seed, depth(), edits); + h.state.root = Some(root); + h.frame(); + tree +@@ -46,8 +51,8 @@ fn plant(h: &mut Harness, seed: u64, edits: &Edits) -> Tree { + + fn resize_one(h: &mut Harness, tree: &Tree, idx: usize, rng: &mut Rng) -> Lens { + let lens = [ +- Some(Len::abs(20.0 + rng.below(180) as f32)), +- Some(Len::abs(20.0 + rng.below(180) as f32)), ++ Some(Len::px(20.0 + rng.below(180) as f32)), ++ Some(Len::px(20.0 + rng.below(180) as f32)), + ]; + let sized = &mut h.rsc[tree.sized[idx]]; + sized.x = lens[0]; +@@ -298,6 +303,39 @@ fn repainted_together(seed: u64) { + assert_same(seed, what, (&warm, &grown), (&cold, &same)); + } + ++/// A size change and a spread of repaints in the same frame. Alone, each ++/// settles one dependency path at a time; together, a reader escalated to the ++/// top of its chain by a changed box redraws a whole subtree while marks ++/// elsewhere are still outstanding, which is the case the settle order does ++/// not order. ++fn changed_every_size_while_repainting(seed: u64) { ++ let mut warm = Harness::new((900, 1200)); ++ let grown = plant(&mut warm, seed, &Edits::default()); ++ if grown.sized.is_empty() { ++ return; ++ } ++ ++ let mut rng = Rng::new(seed ^ 0xa11); ++ let sizes = edit_every(&mut warm, &grown, &mut rng); ++ for &id in grown.ids.iter().step_by(5) { ++ warm.rsc.widgets_mut().get_dyn_mut(id); ++ } ++ warm.frame(); ++ ++ let mut cold = Harness::new((900, 1200)); ++ let same = plant( ++ &mut cold, ++ seed, ++ &Edits { ++ sizes, ++ ..Default::default() ++ }, ++ ); ++ ++ let what = "every size at once, while repainting"; ++ assert_same(seed, what, (&warm, &grown), (&cold, &same)); ++} ++ + fn resized(seed: u64) { + let mut warm = Harness::new((1920, 1200)); + let grown = plant(&mut warm, seed, &Edits::default()); +@@ -349,6 +387,13 @@ fn many_widgets_redrawing_at_once_leaves_every_box_where_it_was() { + SEEDS.into_iter().for_each(repainted_together); + } + ++#[test] ++fn a_size_change_while_many_widgets_repaint_lands_the_same_way() { ++ SEEDS ++ .into_iter() ++ .for_each(changed_every_size_while_repainting); ++} ++ + #[test] + fn a_resize_lands_where_starting_at_that_size_would() { + SEEDS.into_iter().for_each(resized); +@@ -394,11 +439,17 @@ fn a_long_run_of_seeds_agrees() { + .ok() + .and_then(|seed| seed.parse().ok()) + .map(|seed| seed..=seed) +- .unwrap_or(1..=100); ++ .unwrap_or_else(|| { ++ 1..=std::env::var("IRIS_GENERATED_SEEDS") ++ .ok() ++ .and_then(|n| n.parse().ok()) ++ .unwrap_or(100) ++ }); + for seed in seeds { + changed_size(seed); + changed_every_size(seed); + repainted_together(seed); ++ changed_every_size_while_repainting(seed); + resized(seed); + resized_then_changed(seed); + for shuffle in SHUFFLES { +diff --git a/tests/layout.rs b/tests/layout.rs +index 8ae1a10..569e9d3 100644 +--- a/tests/layout.rs ++++ b/tests/layout.rs +@@ -54,7 +54,7 @@ fn a_child_drawn_twice_moves_once() { + h.set_root((left, centered).span(Dir::RIGHT)); + assert_corners!(h, inner, (100, 0), (300, 200)); + +- h.rsc[left].x = Some(Len::abs(150)); ++ h.rsc[left].x = Some(Len::px(150)); + h.frame(); + + assert_corners!(h, inner, (150, 0), (350, 200)); +@@ -104,7 +104,7 @@ fn a_fixed_box_is_drawn_again_rather_than_stretched() { + h.set_root(stack.align(Align::TOP)); + assert_corners!(h, panel, (0, 0), (400, 100)); + +- h.rsc[leaf].y = Some(Len::abs(250)); ++ h.rsc[leaf].y = Some(Len::px(250)); + h.frame(); + + assert_corners!(h, panel, (0, 0), (400, 250)); +@@ -119,7 +119,7 @@ fn a_moved_subtree_takes_its_children_with_it() { + h.set_root((first, row).span(Dir::DOWN)); + assert_corners!(h, inner, (10, 50), (390, 70)); + +- h.rsc[first].y = Some(Len::abs(80)); ++ h.rsc[first].y = Some(Len::px(80)); + h.frame(); + + // The row is the same shape somewhere else, so one slot moved it and +@@ -140,7 +140,7 @@ fn a_fixed_length_child_keeps_it_when_the_box_around_it_grows() { + assert_corners!(h, fixed, (100, 0), (150, 200)); + assert_corners!(h, rest, (150, 0), (400, 200)); + +- h.rsc[bar].x = Some(Len::abs(200)); ++ h.rsc[bar].x = Some(Len::px(200)); + h.frame(); + + // The panel's box is 100 shorter, so the fixed child is the same 50 wide +@@ -163,7 +163,7 @@ fn a_box_with_a_fixed_length_can_be_stretched_on_its_other_axis() { + h.set_root((bar, column).span(Dir::RIGHT)); + assert_corners!(h, inner, (110, 10), (390, 30)); + +- h.rsc[bar].x = Some(Len::abs(200)); ++ h.rsc[bar].x = Some(Len::px(200)); + h.frame(); + + assert_corners!(h, inner, (210, 10), (390, 30)); +diff --git a/tests/layout_diagnostics.rs b/tests/layout_diagnostics.rs +index 9240324..77942a8 100644 +--- a/tests/layout_diagnostics.rs ++++ b/tests/layout_diagnostics.rs +@@ -216,7 +216,7 @@ fn layout_cost() { + trace_selected(&tree); + let sized = tree.sized[0]; + run("size", frames, &mut harness, move |harness, frame| { +- harness.rsc[sized].x = Some(Len::abs(100.0 + (frame % 2) as f32 * 40.0)); ++ harness.rsc[sized].x = Some(Len::px(100.0 + (frame % 2) as f32 * 40.0)); + }); + } + +diff --git a/tests/replace_cost.rs b/tests/replace_cost.rs +index e0dd964..c92ce02 100644 +--- a/tests/replace_cost.rs ++++ b/tests/replace_cost.rs +@@ -37,7 +37,7 @@ fn replacing_rows_every_frame() { + } + h.set_root(span); + for i in 0..FRAMES { +- h.rsc[first].y = Some(Len::abs(40.0 + (i % 2) as f32)); ++ h.rsc[first].y = Some(Len::px(40.0 + (i % 2) as f32)); + h.frame(); + } + } +diff --git a/tests/retained.rs b/tests/retained.rs +index ac4437f..816a62b 100644 +--- a/tests/retained.rs ++++ b/tests/retained.rs +@@ -156,7 +156,7 @@ impl Widget for FromHint { + fn draw(&mut self, painter: &mut Painter) -> Size { + let len = painter.size_hint(&self.inner, Axis::Y).unwrap(); + let mut region = UiRegion::FULL; +- region.y.end = region.y.start.offset(len.abs); ++ region.y.end = region.y.start.offset(len.px); + painter.widget_within(&self.inner, region); + Size::REST + } +@@ -173,7 +173,7 @@ fn a_parent_that_only_read_a_hint_relays_out_when_the_hint_changes() { + h.set_root(parent); + assert_corners!(h, inner, (0, 0), (400, 80)); + +- h.rsc[inner].y = Some(Len::abs(120)); ++ h.rsc[inner].y = Some(Len::px(120)); + h.frame(); + + assert_corners!(h, inner, (0, 0), (400, 120)); +@@ -187,7 +187,7 @@ struct ReadsOutput { + impl Widget for ReadsOutput { + fn draw(&mut self, painter: &mut Painter) -> Size { + self.draws.set(self.draws.get() + 1); +- Size::abs(painter.output_size() / 4.0) ++ Size::px(painter.output_size() / 4.0) + } + } + +@@ -198,7 +198,7 @@ struct ReadsWidth { + impl Widget for ReadsWidth { + fn draw(&mut self, painter: &mut Painter) -> Size { + self.draws.set(self.draws.get() + 1); +- Size::abs((painter.output_len(Axis::X) / 4.0, 20.0).into()) ++ Size::px((painter.output_len(Axis::X) / 4.0, 20.0).into()) + } + } + +@@ -287,12 +287,12 @@ fn subpixel_box_changes_accumulate_from_the_last_draw() { + let settled = draws.get(); + + for width in [100.02, 100.04, 100.05] { +- h.rsc[first].size.x = Len::abs(width); ++ h.rsc[first].size.x = Len::px(width); + h.frame(); + assert_eq!(draws.get(), settled); + } + +- h.rsc[first].size.x = Len::abs(100.06); ++ h.rsc[first].size.x = Len::px(100.06); + h.frame(); + assert_eq!(draws.get(), settled + 1); + } +@@ -342,13 +342,13 @@ fn a_change_two_levels_under_its_reader_still_reaches_it() { + // Every wrapper up to the outer pad read the size below it, so the outer + // pad is what draws again -- and the span it hands the box to is the same + // size as before, which is what lets a draw reuse its way past the leaf. +- let (leaf, _) = counted(&mut h, Size::abs((100, 100).into()), OnResize::Redraw); ++ let (leaf, _) = counted(&mut h, Size::px((100, 100).into()), OnResize::Redraw); + let padded = leaf.pad(10).add(&mut h.rsc); + let below = rect(Color::RED).add(&mut h.rsc); + h.set_root((padded, below).span(Dir::DOWN).pad(12)); + assert_corners!(h, below, (12, 132), (388, 388)); + +- h.rsc[leaf].size = Size::abs((100, 200).into()); ++ h.rsc[leaf].size = Size::px((100, 200).into()); + h.frame(); + + assert_corners!(h, below, (12, 232), (388, 388)); +@@ -387,7 +387,7 @@ fn stretching_a_subtree_carries_the_children_in_it() { + let settled = draws.get(); + assert_corners!(h, inner, (0, 40), (400, 400)); + +- h.rsc[first].y = Some(Len::abs(80)); ++ h.rsc[first].y = Some(Len::px(80)); + h.frame(); + + assert_eq!( +@@ -411,7 +411,7 @@ fn a_widened_row_redraws_what_reads_its_length_and_nothing_else() { + h.set_root((bar, row).span(Dir::RIGHT)); + let (settled_wrap, settled_back) = (wrap_draws.get(), back_draws.get()); + +- h.rsc[bar].x = Some(Len::abs(200)); ++ h.rsc[bar].x = Some(Len::px(200)); + h.frame(); + + // The span reads every child's size, so redrawing one takes the span +@@ -437,7 +437,7 @@ fn a_declared_length_child_is_not_redrawn_when_the_box_around_it_grows() { + h.set_root((bar, row).span(Dir::RIGHT)); + let settled = draws.get(); + +- h.rsc[bar].x = Some(Len::abs(200)); ++ h.rsc[bar].x = Some(Len::px(200)); + h.frame(); + + assert_eq!(draws.get(), settled, "its own length did not change"); +diff --git a/tests/revision_cost.rs b/tests/revision_cost.rs +index 2007362..a6d5a16 100644 +--- a/tests/revision_cost.rs ++++ b/tests/revision_cost.rs +@@ -94,11 +94,7 @@ fn build(h: &mut Harness, rows: usize) -> Vec { + let mut col = Span::empty(Dir::DOWN); + for _ in 0..rows { + let mut row = Span::empty(Dir::RIGHT); +- row.push( +- rect(Color::RED) +- .width(Len::abs(40.0)) +- .add_strong(&mut h.rsc), +- ); ++ row.push(rect(Color::RED).width(Len::px(40.0)).add_strong(&mut h.rsc)); + let mut body = Span::empty(Dir::DOWN); + let para = wtext(words(&mut rng, 12, 52)) + .size(16)