From f6242aa33cb3d096678a3aadeac030f3d28714c9 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Sat, 19 Sep 2026 02:09:41 -0400 Subject: [PATCH] Take a span child's length from its hint, and ask it once in its slot A share child was drawn in the measuring room and again in its slot, and one record holding two questions made every local change under it defer to the span. Where a rule or a hint gives the length along the span, the first ask answers nothing the rule does not, so the child is asked once, in its slot; the widgets that always report the whole of their box now say so. A hint with a fraction resolves against the frame and pins it. The dump rig prints every cold layout so a change to it shows in a diff. --- core/src/ui/painter.rs | 26 ++++++++++---- src/random.rs | 4 +++ src/widget/mask.rs | 4 +++ src/widget/position/scroll.rs | 4 +++ src/widget/position/span.rs | 64 +++++++++++++++++++++-------------- src/widget/position/stack.rs | 9 +++++ tests/layout_dump.rs | 42 +++++++++++++++++++++++ 7 files changed, 120 insertions(+), 33 deletions(-) create mode 100644 tests/layout_dump.rs diff --git a/core/src/ui/painter.rs b/core/src/ui/painter.rs index 4052f88..c625923 100644 --- a/core/src/ui/painter.rs +++ b/core/src/ui/painter.rs @@ -259,17 +259,24 @@ impl<'a> Painter<'a> { declared_lens(self.rsc.widgets(), id.id()) } - /// What a child says its length is without being drawn, if it can say. - /// Asking counts as reading its size. + /// What a child says its length is without being drawn, if it can say, + /// as the length its draw would report: a fraction in it is resolved + /// against this widget's frame, which is the frame a child asked with + /// nothing narrowed gets. Asking counts as reading its size. pub fn size_hint(&mut self, id: &StrongWidget, axis: Axis) -> Option { let widgets = self.rsc.widgets(); // A rule is the answer where there is one: it wins over whatever the // widget would draw, so it has to win over what the widget says too. - let hint = widgets.size_rules(id.id()).axis(axis).exact().or_else(|| { - widgets - .get_dyn(id.id()) - .and_then(|widget| widget.size_hint(axis)) - }); + let hint = widgets + .size_rules(id.id()) + .axis(axis) + .exact() + .or_else(|| { + widgets + .get_dyn(id.id()) + .and_then(|widget| widget.size_hint(axis)) + }) + .map(|hint| hint.within_len(self.frame.axis(axis))); #[cfg(feature = "layout-diagnostics")] diag::hint_read(id.id(), self.id, axis, hint); match hint { @@ -277,6 +284,11 @@ impl<'a> Painter<'a> { #[cfg(feature = "layout-diagnostics")] diag::bump(Counter::HintHits); self.depend_on(id); + // A fraction was just resolved against this frame, so what + // this draw does with it is a function of the frame's length. + if hint.rel != Rel::ZERO { + self.frame_own_len[axis as usize] = Some(self.frame.axis(axis)); + } Some(hint) } None => { diff --git a/src/random.rs b/src/random.rs index b761532..9b6575a 100644 --- a/src/random.rs +++ b/src/random.rs @@ -142,6 +142,10 @@ impl Widget for Branch { }; Size::LEFTOVER } + + fn size_hint(&self, _: Axis) -> Option { + Some(LayoutLen::LEFTOVER) + } } pub struct Spanned { diff --git a/src/widget/mask.rs b/src/widget/mask.rs index df302e7..82bb622 100644 --- a/src/widget/mask.rs +++ b/src/widget/mask.rs @@ -15,4 +15,8 @@ impl Widget for Masked { // draw, and the framework would place the drawing it clipped away. Size::LEFTOVER } + + fn size_hint(&self, _: Axis) -> Option { + Some(LayoutLen::LEFTOVER) + } } diff --git a/src/widget/position/scroll.rs b/src/widget/position/scroll.rs index 9771df1..b04c1a5 100644 --- a/src/widget/position/scroll.rs +++ b/src/widget/position/scroll.rs @@ -72,6 +72,10 @@ impl Widget for Scroll { // is. Size::LEFTOVER } + + fn size_hint(&self, _: Axis) -> Option { + Some(LayoutLen::LEFTOVER) + } } impl Scroll { diff --git a/src/widget/position/span.rs b/src/widget/position/span.rs index 327bf9a..0140469 100644 --- a/src/widget/position/span.rs +++ b/src/widget/position/span.rs @@ -22,26 +22,38 @@ impl Widget for Span { // whole of the row: a span is what contains its children there, and // nothing divides that axis. let across = Place::Within(Part::All); - // A length for every child before their final slots are chosen. The - // frame passes through unchanged, so `rel(0.5)` is half the area this - // span was given whatever else is in it and wherever this child sits - // among them; what it is asked in is the room left from the cursor, - // because a text has to wrap at the width actually there. This is - // the one ask a fixed child gets: its slot is its answer, and the - // drawing is moved there once the shares are known. + // A length for every child before their final slots are chosen: from + // a hint where one says, and from drawing otherwise. The frame passes + // through unchanged, so `rel(0.5)` is half the area this span was + // given whatever else is in it and wherever this child sits among + // them; what a drawn child is asked in is the room left from the + // cursor, because a text has to wrap at the width actually there. + // This is the one ask a drawn fixed child gets: its slot is its + // answer, and the drawing is moved there once the shares are known. + // A hinted child is asked once, in its slot. let mut cursor = Len::rel_min(); - let mut sizes = Vec::with_capacity(self.children.len()); + let mut lens = Vec::with_capacity(self.children.len()); + let mut measured = Vec::with_capacity(self.children.len()); for child in &self.children { - let room = Place::Within(Part::From(along(cursor, far))); - let size = painter - .widget_at(child, [None; 2], axis.pair(room, across)) - .size(); - let len = size.axis(axis); + let size = match painter.size_hint(child, axis) { + Some(len) => { + measured.push(None); + len + } + None => { + let room = Place::Within(Part::From(along(cursor, far))); + let size = painter + .widget_at(child, [None; 2], axis.pair(room, across)) + .size(); + measured.push(Some(size)); + size.axis(axis) + } + }; + let len = size; cursor.px += len.px + self.gap; cursor.rel += len.rel; - sizes.push(size); + lens.push(len); } - let lens: Vec = sizes.iter().map(|size| size.axis(axis)).collect(); let gaps = self .gap @@ -92,8 +104,8 @@ impl Widget for Span { let mut taken = Weight::ZERO; let mut start = Len::rel_min(); let mut ortho = LayoutLen::ZERO; - for (child, size) in self.children.iter().zip(&sizes) { - let len = size.axis(axis); + for ((child, len), measured) in self.children.iter().zip(&lens).zip(&measured) { + let len = *len; // A child asking for nothing but a part of what is left over, // when nothing is, is not drawn at all. One that also asked for // pixels or a fraction keeps those and overflows. @@ -115,20 +127,20 @@ impl Widget for Span { // answer inside again. A share is decided here and nowhere // else: its slot narrows its frame, and the child is asked in // it, since a text wraps at the width it is actually given. A - // fixed child's slot is its own answer, so its drawing is put - // there as it is. + // fixed child's slot is its own answer, so a drawing made in the + // room is put there as it is, and one not made yet is made here. let slot = along(from, start); let place = axis.pair(Place::Fill(Part::From(slot)), across); - let used = match len.leftover > Weight::ZERO && shares { - true => { - let mut narrow = [None; 2]; - narrow[axis as usize] = Some(slot.len()); - painter.widget_at(child, narrow, place).len(!axis) - } - false => { + let mut narrow = [None; 2]; + if len.leftover > Weight::ZERO && shares { + narrow[axis as usize] = Some(slot.len()); + } + let used = match (measured, narrow[axis as usize]) { + (Some(size), None) => { painter.place_at(child, place); size.axis(!axis) } + _ => painter.widget_at(child, narrow, place).len(!axis), }; if shrinks { // Choosing between a fixed and a relative length from the diff --git a/src/widget/position/stack.rs b/src/widget/position/stack.rs index 6932465..05f97fd 100644 --- a/src/widget/position/stack.rs +++ b/src/widget/position/stack.rs @@ -49,6 +49,15 @@ impl Widget for Stack { } size } + + /// Without a sizing child a stack is whatever box it is given, which it + /// can say without drawing anything. + fn size_hint(&self, _: Axis) -> Option { + match self.size { + StackSize::Default => Some(LayoutLen::LEFTOVER), + StackSize::Child(_) => None, + } + } } #[derive(Default, Debug)] diff --git a/tests/layout_dump.rs b/tests/layout_dump.rs new file mode 100644 index 0000000..3671afe --- /dev/null +++ b/tests/layout_dump.rs @@ -0,0 +1,42 @@ +//! Prints where a cold layout puts every widget of many grown trees, so two +//! commits can be compared on cold layout alone. The warm/cold oracle cannot +//! see a change that moves cold layout, since both of its sides move; this +//! can, by diffing its output across the change: +//! +//! IRIS_DUMP_SEEDS=400 IRIS_DUMP_DEPTH=5 cargo test --release \ +//! --test layout_dump -- --ignored --nocapture > /tmp/before.txt +//! +//! then the same after, and `diff` the two. A line is one widget: the seed, +//! its index in creation order, and its box in window pixels, or `-` where +//! it is not drawn. + +use iris::harness::Harness; +use iris::random::{Edits, grow}; + +fn env(name: &str, fallback: T) -> T { + std::env::var(name) + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(fallback) +} + +#[test] +#[ignore = "a dump to diff across commits, not a check"] +fn every_cold_layout_is_printed() { + let seeds = env("IRIS_DUMP_SEEDS", 400_u64); + let depth = env("IRIS_DUMP_DEPTH", 5_usize); + let mut out = String::new(); + for seed in 1..=seeds { + let mut harness = Harness::new((1920.0, 1200.0)); + let (root, tree) = grow(&mut harness.rsc, seed, depth, &Edits::default()); + harness.state.root = Some(root); + harness.frame(); + for (index, id) in tree.ids.iter().enumerate() { + match harness.region(id) { + Some(region) => out.push_str(&format!("{seed} {index} {region:?}\n")), + None => out.push_str(&format!("{seed} {index} -\n")), + } + } + } + print!("{out}"); +}