From 0e838e9dd1204462125350a597733cb607e9ca1a Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Sun, 20 Sep 2026 17:31:53 -0400 Subject: [PATCH] Retain request dependencies only when discovery supplies the answer --- core/src/ui/painter.rs | 56 +++++++++++++++------- core/src/ui/render_state.rs | 2 +- core/src/widget/widgets.rs | 4 +- tests/cases/deferred.rs | 34 +++++++++----- tests/layout_diagnostics.rs | 41 ++++++++++++++-- tests/revision_cost.rs | 94 ++++++++++++++++++++++++++++++++----- tests/scenario/mod.rs | 2 +- 7 files changed, 183 insertions(+), 50 deletions(-) diff --git a/core/src/ui/painter.rs b/core/src/ui/painter.rs index 9efaad5..bca8571 100644 --- a/core/src/ui/painter.rs +++ b/core/src/ui/painter.rs @@ -87,29 +87,37 @@ impl<'a> Painter<'a> { child: &StrongWidget, axis: Axis, ) -> Option { - self.request_deps.push(child.id()); if self.rsc.widgets().size_rules(child.id())[axis].bound() == crate::Bound::ANY && let Some(len) = self.size_hint(child, axis) { + self.request_deps.push(child.id()); return Some(len.into()); } - let rel_base = self.rel_base(axis); + let start = self.request_deps.len(); let mut requests = SizeRequests { arena: &mut self.state.requests, measured: None, widgets: self.rsc.widgets(), dependencies: &mut self.request_deps, - rel_base, + rel_base: self.rel_base[axis], }; - let request = requests.widget(child, axis)?; - // An intrinsic fixed answer still draws in the offered room and is - // moved afterwards. Only a declaration or a share chooses its ask box. - (request.has_leftover() - || matches!( - self.rsc.widgets().size_rules(child.id())[axis], - crate::SizeRule::Request(_) - )) - .then_some(request) + // Intrinsic fixed content must keep its offered box for wrapping; + // only a declaration or a share chooses the box it is drawn in. + let request = requests.widget(child, axis).filter(|request| { + request.has_leftover() + || matches!( + self.rsc.widgets().size_rules(child.id())[axis], + crate::SizeRule::Request(_) + ) + }); + if request.is_some() { + self.rel_base(axis); + } else { + // A discarded request contributes no dependency: the measured + // draw below records the size and box it actually used instead. + self.request_deps.truncate(start); + } + request } /// Completes discovery after a child was measured. Only this call may use @@ -120,20 +128,32 @@ impl<'a> Painter<'a> { axis: Axis, len: LayoutLen, ) -> RequestedLen { - let rel_base = self.rel_base(axis); + let start = self.request_deps.len(); let bound = self.rsc.widgets().size_rules(child.id())[axis].bound(); let mut requests = SizeRequests { arena: &mut self.state.requests, measured: Some(&self.state.active), widgets: self.rsc.widgets(), dependencies: &mut self.request_deps, - rel_base, + rel_base: self.rel_base[axis], }; - match requests.widget(child, axis) { - Some(request) if request.linear().is_none() && request.has_leftover() => request, - _ if len.leftover > Weight::ZERO => requests.bounded(len.into(), bound), - _ => len.into(), + if let Some(request) = requests.widget(child, axis) + && request.linear().is_none() + && request.has_leftover() + { + self.rel_base(axis); + return request; } + let request = if len.leftover > Weight::ZERO { + requests.bounded(len.into(), bound) + } else { + len.into() + }; + self.request_deps.truncate(start); + if len.leftover > Weight::ZERO && bound != crate::Bound::ANY { + self.rel_base(axis); + } + request } /// A deferred comparison reads this window when the allocation is solved. diff --git a/core/src/ui/render_state.rs b/core/src/ui/render_state.rs index f3abcdd..cc377bf 100644 --- a/core/src/ui/render_state.rs +++ b/core/src/ui/render_state.rs @@ -429,7 +429,7 @@ impl UiRenderState { // rel base is the answer wherever the ask declared a length: it was // resolved into the rel base when the widget was asked, and resolving // it again here would take the fraction of a fraction. - let rules = rsc.widgets().size_rules(id); + let rules = rsc.widgets().size_rules(id).clone(); let ruled = |axis: Axis, reported: LayoutLen| { if matches!(rules[axis], crate::SizeRule::Request(_)) { return info.rel_base[axis].into(); diff --git a/core/src/widget/widgets.rs b/core/src/widget/widgets.rs index b5b8916..fd465ac 100644 --- a/core/src/widget/widgets.rs +++ b/core/src/widget/widgets.rs @@ -128,8 +128,8 @@ impl Widgets { } /// The length rules whoever draws this widget applies to its box. - pub fn size_rules(&self, id: impl IdLike) -> SizeRules { - self.data(id).unwrap().size.clone() + pub fn size_rules(&self, id: impl IdLike) -> &SizeRules { + &self.data(id).unwrap().size } /// Sets one axis's rule. The widget is marked rather than its parent diff --git a/tests/cases/deferred.rs b/tests/cases/deferred.rs index 89ec25b..b1bebdf 100644 --- a/tests/cases/deferred.rs +++ b/tests/cases/deferred.rs @@ -92,18 +92,28 @@ fn request_edits_in_a_nested_child_reach_the_allocator() { #[test] fn adding_a_bound_to_a_previously_unbounded_share_reallocates_the_row() { - let mut h = Harness::new((300, 100)); - let a = rect(Color::RED).add(&mut h.rsc); - let b = rect(Color::BLUE).add(&mut h.rsc); - h.set_root((a, b).span(Dir::RIGHT)); - h.resize((400, 100)); - h.frame(); - h.rsc - .widgets_mut() - .set_size_rule(a, Axis::X, SizeRule::Max(Len::px(80.0))); - h.frame(); - assert_corners!(h, a, (0, 0), (80, 100)); - assert_corners!(h, b, (80, 0), (400, 100)); + for hinted in [true, false] { + let mut h = Harness::new((300, 100)); + let a = if hinted { + rect(Color::RED).add_strong(&mut h.rsc).any() + } else { + h.rsc.widgets_mut().add_strong(Unhinted).any() + }; + let id = a.id(); + let b = rect(Color::BLUE).add(&mut h.rsc); + let mut row = Span::empty(Dir::RIGHT); + row.push(a); + row.push(b.add_strong(&mut h.rsc)); + h.set_root(row); + h.resize((400, 100)); + h.frame(); + h.rsc + .widgets_mut() + .set_size_rule(id, Axis::X, SizeRule::Max(Len::px(80.0))); + h.frame(); + assert_corners!(h, id, (0, 0), (80, 100)); + assert_corners!(h, b, (80, 0), (400, 100)); + } } #[test] diff --git a/tests/layout_diagnostics.rs b/tests/layout_diagnostics.rs index 7c9890a..7f940c5 100644 --- a/tests/layout_diagnostics.rs +++ b/tests/layout_diagnostics.rs @@ -13,11 +13,12 @@ //! //! `IRIS_PHASE` is `cold`, `repaint`, `many`, `size`, `scroll`, `resize`, or //! `all`. `IRIS_SEED`, `IRIS_DEPTH`, and `IRIS_FRAMES` select the load, and -//! `IRIS_DIRTY` how many widgets `many` marks at once. +//! `IRIS_DIRTY` how many widgets `many` marks at once. `IRIS_UNBOUNDED=1` +//! removes intrinsic bounds while preserving the rest of the generated tree. use iris::harness::Harness; use iris::prelude::*; -use iris::random::{Edits, Tree, grow}; +use iris::random::{Edits, Tree, build, plan}; use std::time::Instant; const OUTPUT: (f32, f32) = (1920.0, 1200.0); @@ -125,9 +126,25 @@ fn rig_edits() -> Edits { } } +fn fixture(harness: &mut Harness, seed: u64, depth: usize) -> (StrongWidget, Tree) { + let mut plan = plan(seed, depth, &rig_edits()); + if env("IRIS_UNBOUNDED", 0_u8) != 0 { + plan.walk_mut(&mut |node| { + if let Some(rules) = &mut node.size { + for axis in Axis::BOTH { + if rules[axis].bound() != Bound::ANY { + rules[axis] = SizeRule::Free; + } + } + } + }); + } + build(&mut harness.rsc, &plan) +} + fn warm(seed: u64, depth: usize) -> (Harness, Tree) { let mut harness = Harness::new(OUTPUT); - let (root, tree) = grow(&mut harness.rsc, seed, depth, &rig_edits()); + let (root, tree) = fixture(&mut harness, seed, depth); harness.state.root = Some(root); harness.frame(); println!( @@ -209,7 +226,7 @@ fn layout_cost() { if selected("cold") { let mut harness = Harness::new(OUTPUT); - let (root, tree) = grow(&mut harness.rsc, seed, depth, &rig_edits()); + let (root, tree) = fixture(&mut harness, seed, depth); harness.state.root = Some(root); println!( "fixture: seed {seed}, depth {depth}, {} widgets", @@ -276,3 +293,19 @@ fn layout_cost() { }); } } + +#[cfg(feature = "layout-diagnostics")] +#[test] +fn repainting_measured_text_does_not_invalidate_its_span() { + use iris::core::layout_diagnostics as diag; + + let mut h = Harness::new((400, 200)); + let text = wtext("a paragraph that fits").wrap(true).add(&mut h.rsc); + h.set_root((text, wtext("another paragraph")).span(Dir::DOWN)); + let _ = diag::take(); + h.rsc.widgets_mut().mark_for_redraw(text); + h.frame(); + let report = diag::take(); + assert_eq!(report.distinct_widgets(), 1); + assert_eq!(report.hot_widgets()[0].id, text.id()); +} diff --git a/tests/revision_cost.rs b/tests/revision_cost.rs index 413e19c..cdf7ecf 100644 --- a/tests/revision_cost.rs +++ b/tests/revision_cost.rs @@ -1,19 +1,22 @@ -//! What a resize frame costs and what it holds, on a tree the revision before -//! #16 also builds. -//! -//! Deliberately written in the API subset `43ce8c7` and this branch share, so -//! the same source can be dropped into an old worktree and measured there: -//! that is the only like-for-like comparison with the code the retained -//! layout replaced. The random tree cannot carry one, because the generator -//! itself changed with the work. +//! Text-layout workloads with stable paragraphs for comparisons across revisions. +//! PR #19's base uses the older spelling of the fixed 40-pixel width and has +//! no diagnostics. The random generator changed with layout, so it cannot +//! provide the same workload across the full PR. //! //! ROWS=40 FRAMES=500 cargo test --release --test revision_cost \ //! -- --ignored --nocapture resize_cost +//! PHASE=edit ROWS=40 FRAMES=2000 cargo test --release --test revision_cost \ +//! -- --ignored --nocapture text_updates_cost //! ROWS=2000 cargo test --release --test revision_cost \ //! -- --ignored --nocapture text_memory //! -//! Wall time on this machine varies with CPU frequency; take the number from -//! `perf stat -e instructions:u` on the test binary directly. +//! `text_updates_cost` selects idle, repaint, edit, or scroll with `PHASE`. +//! It alternates a short suffix for edits so later frames do not get a longer +//! paragraph than earlier ones. These are CPU fixtures, with no GPU submission. +//! +//! Use repeated `perf stat -e instructions:u` runs on the executable directly; +//! process totals include font loading and the cold frame, so compare identical +//! row and frame counts. Wall time on this machine is not a stable comparison. use iris::harness::Harness; use iris::prelude::*; @@ -137,9 +140,10 @@ fn resize_cost() { println!("paragraph {at}: {:?}", h.region(id)); } - // Two widths in turn is the friendly case for anything that remembers an - // answer, so `SWEEP=1` never repeats one -- a drag rather than a toggle. + // The sweep cycles 256 widths, avoiding the two-width cache-friendly case. let sweep = env("SWEEP", 0_usize) != 0; + #[cfg(feature = "layout-diagnostics")] + let _ = iris::core::layout_diagnostics::take(); let mut elapsed = Vec::with_capacity(frames); for frame in 0..frames { let narrower = match sweep { @@ -151,6 +155,11 @@ fn resize_cost() { h.frame(); elapsed.push(start.elapsed().as_secs_f64() * 1000.0); } + #[cfg(feature = "layout-diagnostics")] + print!( + "{}", + iris::core::layout_diagnostics::take().per_frame(frames) + ); elapsed.sort_by(|a, b| a.partial_cmp(b).unwrap()); println!( "resize: {frames} frames, min {:.3} ms, median {:.3} ms, p99 {:.3} ms, \ @@ -203,3 +212,64 @@ fn text_memory() { } report("after settling"); } + +#[test] +#[ignore = "measurement, not a check"] +fn text_updates_cost() { + let rows = env("ROWS", 40_usize); + let frames = env("FRAMES", 1000_usize); + let phase = env("PHASE", String::from("edit")); + assert!(rows > 0 && frames > 0); + assert!(["idle", "repaint", "edit", "scroll"].contains(&phase.as_str())); + let mut h = Harness::new(OUTPUT); + let mut rng = Rng(1); + let mut col = Span::empty(Dir::DOWN); + let first = wtext(words(&mut rng, 12, 52)) + .size(16) + .wrap(true) + .add(&mut h.rsc); + col.push(first.add_strong(&mut h.rsc)); + for _ in 1..rows { + col.push( + wtext(words(&mut rng, 12, 52)) + .size(16) + .wrap(true) + .add_strong(&mut h.rsc), + ); + } + let root = col.scrollable().add(&mut h.rsc); + h.set_root(root); + #[cfg(feature = "layout-diagnostics")] + let _ = iris::core::layout_diagnostics::take(); + let original = h.rsc[first].content.to_string(); + let alternate = format!("{original} another word"); + let start = Instant::now(); + for frame in 0..frames { + match phase.as_str() { + "idle" => {} + "repaint" => { + let _ = h.rsc.widgets_mut().get_dyn_mut(first.id()); + } + "edit" => { + h.rsc[first].content.clear(); + h.rsc[first].content.push_str(if frame % 2 == 0 { + &alternate + } else { + &original + }); + } + "scroll" => h.rsc[root].scroll(if frame % 2 == 0 { -12.0 } else { 12.0 }), + _ => unreachable!(), + } + h.frame(); + } + println!( + "{phase}: {rows} rows, {frames} frames, {:.1} ms", + start.elapsed().as_secs_f64() * 1000.0 + ); + #[cfg(feature = "layout-diagnostics")] + print!( + "{}", + iris::core::layout_diagnostics::take().per_frame(frames) + ); +} diff --git a/tests/scenario/mod.rs b/tests/scenario/mod.rs index 549d192..9e797b3 100644 --- a/tests/scenario/mod.rs +++ b/tests/scenario/mod.rs @@ -356,7 +356,7 @@ fn change(case: Case, warm: &mut Harness, tree: &mut Tree, plan: &Plan, rng: &mu /// out by hand. A failure is a lead; the fast test that replaces it has to be /// buildable from what the failure printed. fn describe(id: WidgetId, h: &Harness) -> String { - let rules = h.rsc.widgets().size_rules(id); + let rules = h.rsc.widgets().size_rules(id).clone(); // A bound prints as itself: a failure is reproduced from what it printed, // and a rule shown as "no rule" cannot be written out again. let rule = |r: SizeRule| match r {