Retain request dependencies only when discovery supplies the answer

This commit is contained in:
iris-ai committed 2026-09-20 17:31:53 -04:00
1 parent 8780b40bb7
commit 0e838e9dd1
7 files changed
+183 -50

No files matched your search

+38 -18
View File
@@ -87,29 +87,37 @@ impl<'a> Painter<'a> {
child: &StrongWidget<W>,
axis: Axis,
) -> Option<RequestedLen> {
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.
+1 -1
View File
@@ -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();
+2 -2
View File
@@ -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
+22 -12
View File
@@ -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]
+37 -4
View File
@@ -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());
}
+82 -12
View File
@@ -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)
);
}
+1 -1
View File
@@ -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 {