From 7601aa2a5da5ada065a1954320e31d3cc8d99e24 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Thu, 17 Sep 2026 15:53:24 -0400 Subject: [PATCH] Measure container children without intermediate placement --- core/src/ui/painter.rs | 36 +++++++++++++++++++--------------- core/src/ui/render_state.rs | 25 +++++++++++++---------- src/widget/position/scroll.rs | 5 +---- src/widget/position/span.rs | 5 +---- tests/cases/retained.rs | 37 +++++++++++++++++++++++++++++++++++ tests/layout_diagnostics.rs | 11 +++++++---- 6 files changed, 81 insertions(+), 38 deletions(-) diff --git a/core/src/ui/painter.rs b/core/src/ui/painter.rs index 7360cc1..3c4fd0c 100644 --- a/core/src/ui/painter.rs +++ b/core/src/ui/painter.rs @@ -139,7 +139,7 @@ impl<'a> Painter<'a> { /// around one child wants, since its box is the child's. pub fn widget<'s, W: ?Sized>(&'s mut self, id: &'s StrongWidget) -> DrawResult<'s, 'a, W> { let own = self.placement; - self.widget_at_inner(id, UiRegion::FULL, [Some(own.x), Some(own.y)], true) + self.widget_at_inner(id, UiRegion::FULL, [Some(own.x), Some(own.y)], true, false) } /// What a widget's rules declare its lengths to be, which whoever draws @@ -191,7 +191,7 @@ impl<'a> Painter<'a> { region: UiRegion, placement: [Option; 2], ) -> DrawResult<'s, 'a, W> { - self.widget_at_inner(id, region, placement, false) + self.widget_at_inner(id, region, placement, false, false) } fn widget_at_inner<'s, W: ?Sized>( @@ -200,6 +200,7 @@ impl<'a> Painter<'a> { region: UiRegion, placement: [Option; 2], inherited: bool, + measuring: bool, ) -> DrawResult<'s, 'a, W> { if inherited { if !self.inherited_children.contains(&id.id()) { @@ -266,6 +267,7 @@ impl<'a> Painter<'a> { placement, }, None, + measuring, self.rsc, ); // Whatever the child's answer holds for keeps this one to the boxes @@ -321,32 +323,34 @@ impl<'a> Painter<'a> { } } - /// A child's length in the region it is about to be offered, if it can - /// be had without drawing it: from its hint, or from a drawing it already - /// has that holds for that box. - pub fn known_len( + /// Measures a child's length from its hint, a retained answer, or `draw`. + /// A fresh draw evaluates the offer without placing its answer. The caller + /// must later place or undraw the child. + pub fn measure_len( &mut self, child: &StrongWidget, axis: Axis, region: UiRegion, placement: [Option; 2], - ) -> Option { + ) -> LayoutLen { + let offered = placement; let declared = self.declared_lens(child); let align = self.rsc.widgets().alignment(child.id()); let (local, placement) = ask_box(region, declared, align, placement); let first_ask = self.at_offer && !self.offered.contains(&child.id()); if let Some(hint) = self.size_hint(child, axis) { - return Some(hint); + return hint; } let px = local.size().to_px(self.px); - let (size, holds) = self.state.retained_size( - child.id(), - px, - placement, - self.move_idx, - self.rsc.widgets(), - )?; + let retained = + self.state + .retained_size(child.id(), px, placement, self.move_idx, self.rsc.widgets()); + let Some((size, holds)) = retained else { + return self + .widget_at_inner(child, region, offered, false, true) + .len(axis); + }; #[cfg(feature = "layout-diagnostics")] diag::bump(Counter::RetainedSizeHits); self.depend_on(child); @@ -364,7 +368,7 @@ impl<'a> Painter<'a> { for (axis, under) in AXES.into_iter().zip(self.under.iter_mut()) { *under = under.and(holds[axis as usize].through(local.axis(axis).len())); } - Some(in_parent_frame(size, local.size(), declared).axis(axis)) + in_parent_frame(size, local.size(), declared).axis(axis) } /// Whether this is the first box a child is asked about in during a draw diff --git a/core/src/ui/render_state.rs b/core/src/ui/render_state.rs index 1f509cc..1d7b688 100644 --- a/core/src/ui/render_state.rs +++ b/core/src/ui/render_state.rs @@ -193,7 +193,7 @@ impl UiRenderState { if let Some(id) = root { let region = Self::root_region(id.id(), rsc.widgets()); let info = self.root_info(region); - self.draw_inner(id.id(), region, info, None, rsc); + self.draw_inner(id.id(), region, info, None, false, rsc); } } @@ -213,6 +213,7 @@ impl UiRenderState { region: UiRegion, info: DrawInfo, mut old: Option, + measuring: bool, rsc: &mut dyn UiRsc, ) -> (Size, LayoutHolds) { #[cfg(feature = "layout-diagnostics")] @@ -239,16 +240,20 @@ impl UiRenderState { self.draw_at(id, region, info.offered_placement(), info, old.take(), rsc) }); - let declared = declared_lens(rsc.widgets(), id); // Where the drawing goes, in the region's own coordinates: what the // parent chose, and on any axis it left open, what the answer took of // the region placed by the widget's alignment. The region itself does // not change, so nothing under it resolves a fraction a second time. - let lens = placed_lens(answer.0, declared, info.decided()); - let own = placed_box(UiRegion::FULL, lens, align); - let placement = UiRegion { - x: info.placement[0].unwrap_or(own.x), - y: info.placement[1].unwrap_or(own.y), + let placement = if measuring { + info.offered_placement() + } else { + let declared = declared_lens(rsc.widgets(), id); + let lens = placed_lens(answer.0, declared, info.decided()); + let own = placed_box(UiRegion::FULL, lens, align); + UiRegion { + x: info.placement[0].unwrap_or(own.x), + y: info.placement[1].unwrap_or(own.y), + } }; self.place(id, region, placement, info, rsc); @@ -1137,7 +1142,7 @@ impl UiRenderState { #[cfg(feature = "layout-diagnostics")] diag::bump(Counter::LocalRedraws); let old = self.remove(id, false, rsc); - self.draw_inner(id, region, info, old, rsc); + self.draw_inner(id, region, info, old, false, rsc); return true; }; let (given_px, offered_px) = self.asked_px(id); @@ -1179,9 +1184,9 @@ impl UiRenderState { placement: info.offer_placement, ..info }; - let answer = self.draw_inner(id, given, offered, old, rsc); + let answer = self.draw_inner(id, given, offered, old, false, rsc); if info.placement != offered.placement { - self.draw_inner(id, given, info, None, rsc); + self.draw_inner(id, given, info, None, false, rsc); } if Some(answer) != was_answer { // Its parent chose its box knowing the old answer, so it lays out diff --git a/src/widget/position/scroll.rs b/src/widget/position/scroll.rs index 63dc95f..58f31f3 100644 --- a/src/widget/position/scroll.rs +++ b/src/widget/position/scroll.rs @@ -17,10 +17,7 @@ impl Widget for Scroll { let whole = UiRegion::FULL; let own = painter.placement(); let answer_len = - match painter.known_len(&self.inner, self.axis, whole, [Some(own.x), Some(own.y)]) { - Some(len) => len, - None => painter.widget(&self.inner).size().axis(self.axis), - }; + painter.measure_len(&self.inner, self.axis, whole, [Some(own.x), Some(own.y)]); let content = answer_len.apply_leftover(); self.container_len = container_len; self.content_len = content.to_px(container_len); diff --git a/src/widget/position/span.rs b/src/widget/position/span.rs index 345d309..795e60d 100644 --- a/src/widget/position/span.rs +++ b/src/widget/position/span.rs @@ -37,10 +37,7 @@ impl Widget for Span { // from the cursor, because a text has to wrap at the width // actually there. let room = axis.pair(Some(along(cursor, far)), None); - let len = match painter.known_len(child, axis, region, room) { - Some(len) => len, - None => painter.widget_at(child, region, room).len(axis), - }; + let len = painter.measure_len(child, axis, region, room); cursor.px += len.px + self.gap; cursor.rel += len.rel; lens.push(len); diff --git a/tests/cases/retained.rs b/tests/cases/retained.rs index 6676152..38add1e 100644 --- a/tests/cases/retained.rs +++ b/tests/cases/retained.rs @@ -914,3 +914,40 @@ fn resizing_a_fixed_frame_recomposes_its_contents_without_drawing_them() { assert_eq!(mask(&warm, leaf.id()), mask(&cold, other.id())); } } + +#[test] +fn a_span_does_not_place_its_measurement_before_assigning_the_childs_slot() { + struct MeasuredBox(Rc>); + + impl Widget for MeasuredBox { + fn draw(&mut self, painter: &mut Painter) -> Size { + self.0.set(self.0.get() + 1); + painter.px_size(); + painter.primitive(RectPrimitive::color(Color::BLUE)); + Size::from((100, 50)) + } + } + + let mut h = Harness::new((400, 200)); + let draws = Rc::new(Cell::new(0)); + let leaf = MeasuredBox(draws.clone()).add(&mut h.rsc); + h.set_root((leaf,).span(Dir::RIGHT).width(rel(1.0)).height(rel(1.0))); + + assert_eq!(draws.get(), 3); + assert_corners!(h, leaf, (0, 75), (100, 125)); + assert_eq!( + primitive_bounds(&h, leaf.id()), + vec![h.region(&leaf.id()).unwrap()] + ); + h.frame(); + assert_eq!(draws.get(), 3); + + h.resize((600, 300)); + h.frame(); + assert_eq!(draws.get(), 6); + assert_corners!(h, leaf, (0, 125), (100, 175)); + assert_eq!( + primitive_bounds(&h, leaf.id()), + vec![h.region(&leaf.id()).unwrap()] + ); +} diff --git a/tests/layout_diagnostics.rs b/tests/layout_diagnostics.rs index db4e618..0ad5a95 100644 --- a/tests/layout_diagnostics.rs +++ b/tests/layout_diagnostics.rs @@ -5,11 +5,11 @@ //! cargo test --release --features layout-diagnostics \ //! --test layout_diagnostics -- --ignored --nocapture //! -//! Uninstrumented hardware totals for one phase: +//! Build the uninstrumented test with `cargo test --release --test +//! layout_diagnostics --no-run`, then run the emitted executable directly: //! -//! IRIS_PHASE=resize IRIS_FRAMES=1000 perf stat \ -//! -e cycles:u,instructions:u cargo test --release \ -//! --test layout_diagnostics -- --ignored --nocapture +//! IRIS_PHASE=resize IRIS_FRAMES=10000 perf stat -r 7 \ +//! -e cycles:u,instructions:u /path/to/layout_diagnostics --ignored --nocapture //! //! `IRIS_PHASE` is `cold`, `repaint`, `many`, `size`, `scroll`, `resize`, or //! `all`. `IRIS_SEED`, `IRIS_DEPTH`, and `IRIS_FRAMES` select the load, and @@ -134,6 +134,9 @@ fn report(label: &str, mut elapsed: Vec, _harness: &Harness) { { let diagnostics = iris::core::layout_diagnostics::take(); print!("{}", diagnostics.per_frame(frames)); + for event in diagnostics.traces() { + println!(" {event:?}"); + } for callsite in diagnostics.hot_text().iter().take(3) { let mut ancestry = Vec::new(); let mut id = Some(callsite.id);