Constrain offered boxes with independent widget size bounds

This commit is contained in:
iris-ai committed 2026-09-20 19:47:32 -04:00
1 parent 4cb6f6882a
commit 2ac0843cb2
18 files changed
+554 -379

No files matched your search

+7 -7
View File
@@ -35,16 +35,16 @@ static ALLOCATOR: Counting = Counting;
#[test]
fn unchanged_tree_reuses_layout_storage() {
for deferred in [false, true] {
for rule in [
SizeRule::FREE,
leftover(1).clamp(20, 80).into(),
SizeRule::clamp(20.into(), 80.into()),
] {
let mut h = Harness::new((600, 200));
let mut children: Vec<StrongWidget> = Vec::new();
for _ in 0..8 {
let a = rect(Color::RED).add(&mut h.rsc);
if deferred {
h.rsc
.widgets_mut()
.set_size_rule(a, Axis::X, leftover(1).clamp(20, 80).into());
}
h.rsc.widgets_mut().set_size_rule(a, Axis::X, rule.clone());
let row = (a, rect(Color::BLUE))
.span(Dir::RIGHT)
.add_strong(&mut h.rsc);
@@ -74,7 +74,7 @@ fn unchanged_tree_reuses_layout_storage() {
h.frame();
}
let allocations = COUNT.replace(None).unwrap();
println!("deferred={deferred}: {allocations} allocations over 100 resize frames");
println!("rule={rule:?}: {allocations} allocations over 100 resize frames");
assert_eq!(allocations, 0);
}
}
+60
View File
@@ -0,0 +1,60 @@
//! CPU comparison of bounds attributes and the former wrapper, using the
//! same builder calls and geometry. Run the release executable under perf;
//! process totals include the cold frame. MODE=plain|exact|cap, REDRAW=0|1.
use iris::{harness::Harness, prelude::*};
#[test]
#[ignore = "instruction-count measurement"]
fn bounds_cost() {
let mode = std::env::var("MODE").unwrap_or_else(|_| "cap".into());
let redraw = std::env::var("REDRAW").is_ok_and(|value| value == "1");
let frames = std::env::var("FRAMES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(2000);
let mut h = Harness::new((300, 512));
let mut column = Span::empty(Dir::DOWN);
let mut leaves = Vec::new();
for _ in 0..128 {
let first = match mode.as_str() {
"plain" => rect(Color::RED).add_strong(&mut h.rsc).any(),
"exact" => rect(Color::RED).width(40).add_strong(&mut h.rsc).any(),
"cap" => rect(Color::RED).max_width(80).add_strong(&mut h.rsc).any(),
_ => panic!("unknown MODE {mode}"),
};
leaves.push(first.id());
let second = rect(Color::BLUE).add_strong(&mut h.rsc);
let row = Span {
children: vec![first, second],
dir: Dir::RIGHT,
gap: Px::ZERO,
};
column.push(row.height(4).add_strong(&mut h.rsc));
}
h.set_root(column);
let ids: Vec<_> = h.render.active.keys().copied().collect();
println!(
"mode={mode}, widgets={}, rule_bytes={}",
ids.len(),
std::mem::size_of::<SizeRule>()
);
for frame in 0..frames {
if redraw {
for &id in &ids {
h.rsc.widgets_mut().mark_for_redraw(id);
}
}
let width = if frame % 2 == 0 { 100 } else { 300 };
h.resize((width, 512));
h.frame();
let expected = match mode.as_str() {
"plain" => width / 2,
"exact" => 40,
"cap" => (width / 2).min(80),
_ => unreachable!(),
};
for id in &leaves {
assert_eq!(h.region(id).unwrap().size().x, Px::from_int(expected));
}
}
}
+22 -13
View File
@@ -84,7 +84,7 @@ fn request_edits_in_a_nested_child_reach_the_allocator() {
let inner = (a,).span(Dir::RIGHT).add(&mut h.rsc);
let tail = rect(Color::BLUE).add(&mut h.rsc);
h.set_root((inner, tail).span(Dir::RIGHT));
h.rsc.ui_mut().widgets.get_mut(&a).unwrap().x = Some(Len::px(40.0));
h.rsc.widgets_mut().set_max_len(a, Axis::X, Len::px(40.0));
h.frame();
assert_corners!(h, a, (0, 0), (40, 100));
assert_corners!(h, tail, (40, 0), (300, 100));
@@ -109,7 +109,7 @@ fn adding_a_bound_to_a_previously_unbounded_share_reallocates_the_row() {
h.frame();
h.rsc
.widgets_mut()
.set_size_rule(id, Axis::X, SizeRule::Max(Len::px(80.0)));
.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));
@@ -209,7 +209,7 @@ fn bounds_also_apply_to_shares_discovered_by_drawing() {
let a = h.rsc.widgets_mut().add_strong(Unhinted);
h.rsc
.widgets_mut()
.set_size_rule(a.id(), Axis::X, SizeRule::Max(Len::px(80.0)));
.set_size_rule(a.id(), Axis::X, SizeRule::max(Len::px(80.0)));
let id = a.id();
let b = rect(Color::BLUE).add_strong(&mut h.rsc);
let b_id = b.id();
@@ -245,7 +245,7 @@ fn a_measured_nested_share_keeps_its_comparison_for_the_outer_span() {
let a_id = a.id();
h.rsc
.widgets_mut()
.set_size_rule(a_id, Axis::X, SizeRule::Max(Len::px(80.0)));
.set_size_rule(a_id, Axis::X, SizeRule::max(Len::px(80.0)));
let b = rect(Color::BLUE).add_strong(&mut h.rsc);
let b_id = b.id();
let inner = h.rsc.widgets_mut().add_strong(Span {
@@ -289,14 +289,11 @@ fn relative_bounds_on_a_hinted_child_track_the_offer_before_its_declared_size()
let id = natural.id();
h.rsc
.widgets_mut()
.set_size_rule(id, Axis::X, SizeRule::Max(Len::rel(0.75)));
.set_size_rule(id, Axis::X, SizeRule::max(Len::rel(0.75)));
h.rsc.widgets_mut().set_size_rule(
id,
Axis::Y,
SizeRule::Clamp {
min: Len::rel(0.25),
max: Len::rel(0.75),
},
SizeRule::clamp(Len::rel(0.25), Len::rel(0.75)),
);
let inner = h.rsc.widgets_mut().add_strong(Stack {
children: vec![natural],
@@ -313,10 +310,7 @@ fn relative_bounds_on_a_hinted_child_track_the_offer_before_its_declared_size()
h.rsc.widgets_mut().set_size_rule(
bounded.id(),
Axis::X,
SizeRule::Clamp {
min: Len::rel(0.25),
max: Len::rel(0.75),
},
SizeRule::clamp(Len::rel(0.25), Len::rel(0.75)),
);
let fixed = wtext("one line, overflowing whatever it is given")
.size(16)
@@ -389,3 +383,18 @@ fn moving_scroll_content_preserves_its_resolved_expression_size() {
h.frame();
assert_corners!(h, leaf_id, (0, 240), (300, 360));
}
#[test]
fn an_intrinsic_share_cap_uses_the_allocators_fractional_base() {
let mut h = Harness::new((300, 100));
let head = rect(Color::BLUE).width(30).add(&mut h.rsc);
let bounded = rect(Color::RED).max_width(Len::rel(0.25)).add(&mut h.rsc);
let tail = rect(Color::GREEN).add(&mut h.rsc);
h.set_root((head, bounded, tail).span(Dir::RIGHT));
assert_corners!(h, bounded, (30, 0), (105, 100));
assert_corners!(h, tail, (105, 0), (300, 100));
h.resize((400, 100));
h.frame();
assert_corners!(h, bounded, (30, 0), (130, 100));
assert_corners!(h, tail, (130, 0), (400, 100));
}
+226 -25
View File
@@ -824,7 +824,7 @@ fn row_under_fractions(kid: Option<LayoutLen>, gap: f32, box_w: f32) -> (Vec<Px>
if let Some(len) = kid {
h.rsc
.widgets_mut()
.set_size_rule(r.id(), Axis::X, SizeRule::Exact(len));
.set_size_rule(r.id(), Axis::X, SizeRule::from(len));
}
ids.push(r.id());
kids.push(r.add_strong(&mut h.rsc));
@@ -1005,10 +1005,6 @@ fn a_region_node_root_is_a_region_node() {
assert_eq!(h.region(&probe).unwrap().size().x, Px::from_int(900));
}
/// A bound is a rule about what a widget answers: it holds the length that
/// reaches whoever asked and leaves the box alone. Here the content is 400
/// wide in a 250 window, so a cap cuts what the row reports and a floor
/// raises it, while the rects inside stay where the 250 box put them.
#[test]
fn a_bound_holds_what_a_widget_answers() {
let row = |rule: SizeRule| {
@@ -1023,51 +1019,37 @@ fn a_bound_holds_what_a_widget_answers() {
h.region(&left).unwrap().size().x,
)
};
let (capped, left) = row(SizeRule::Max(Len::px(300.0)));
let (capped, left) = row(SizeRule::max(Len::px(300.0)));
assert_eq!(capped, Px::from_int(300), "the cap, not the 400 drawn");
assert_eq!(left, Px::from_int(200), "the box the children were given");
let (floored, _) = row(SizeRule::Min(Len::px(600.0)));
let (floored, _) = row(SizeRule::min(Len::px(600.0)));
assert_eq!(floored, Px::from_int(600), "the floor, not the 400 drawn");
let (free, _) = row(SizeRule::Free);
let (free, _) = row(SizeRule::FREE);
assert_eq!(free, Px::from_int(400), "what it drew");
}
/// A cap on the box is `MaxSize`, which asks its child in the shorter of the
/// cap and its own box. That is the box a text wraps at and a scroll takes
/// its viewport from, so it cannot be had by holding the answer.
#[test]
fn a_cap_widget_asks_its_child_in_the_shorter_box() {
fn a_cap_attribute_narrows_the_widgets_box() {
let mut h = Harness::new((400, 200));
// A fraction of its box, so it says what box it was asked in.
let fills = rect(Color::RED).width(rel(1.0)).add(&mut h.rsc);
let capped = fills.max_width(300).add(&mut h.rsc);
assert_eq!(fills.id(), capped.id());
h.set_root(capped);
assert_eq!(h.region(&fills).unwrap().size().x, Px::from_int(300));
assert_eq!(
h.region(&capped).unwrap().size().x,
Px::from_int(300),
"as long as its child used"
);
// A child that asked for a share takes the box the cap allows, and the
// share itself passes up: whoever divides one is this widget's parent.
let mut h = Harness::new((400, 200));
let share = rect(Color::RED).add(&mut h.rsc);
let capped = share.max_width(300).add(&mut h.rsc);
h.set_root(capped);
assert_eq!(h.region(&share).unwrap().size().x, Px::from_int(300));
assert_eq!(h.region(&capped).unwrap().size().x, Px::from_int(400));
}
/// Which of the cap and the box is shorter is a question in pixels, so it is
/// asked again wherever the answer can change -- and the widget asking it is
/// drawn again whenever its own box is, which is what keeps the two in step.
#[test]
fn a_cap_widget_is_decided_again_on_either_side_of_the_crossing() {
fn a_cap_attribute_is_decided_again_on_either_side_of_the_crossing() {
let mut h = Harness::new((400, 200));
let probe = rect(Color::RED).add(&mut h.rsc);
h.set_root(probe.max_width(300));
@@ -1116,3 +1098,222 @@ fn a_cap_holds_an_answer_that_overflowed_its_box() {
// row draws 400 of it. Its answer is the cap, and the window centres it.
assert_corners!(h, row, (-25, 0), (275, 200));
}
struct Offered {
seen: Rc<Cell<PxVec2>>,
answer: Size,
}
impl Widget for Offered {
fn draw(&mut self, painter: &mut Painter) -> Size {
self.seen.set(painter.px_size());
painter.primitive(RectPrimitive::color(Color::RED));
self.answer
}
}
#[test]
fn bounds_constrain_the_offer_without_replacing_an_intrinsic_answer() {
for axis in Axis::BOTH {
for node in [false, true] {
let mut h = Harness::new((400, 400));
let seen = Rc::new(Cell::new(PxVec2::ZERO));
let probe = h.rsc.widgets_mut().add_strong(Offered {
seen: seen.clone(),
answer: Size::px(Vec2::new(40.0, 40.0)),
});
let id = probe.id();
h.rsc.widgets_mut().set_region_node(id, node);
h.rsc.widgets_mut().set_max_len(id, axis, 100.into());
h.state.root = Some(probe);
h.frame();
assert_eq!(seen.get()[axis], Px::from_int(100));
assert_eq!(h.region(&id).unwrap().size()[axis], Px::from_int(40));
h.rsc.widgets_mut().set_min_len(id, axis, 60.into());
h.frame();
assert_eq!(h.region(&id).unwrap().size()[axis], Px::from_int(60));
h.resize((50, 50));
h.frame();
assert_eq!(seen.get()[axis], Px::from_int(60));
h.rsc.widgets_mut().set_size_rule(id, axis, SizeRule::FREE);
h.frame();
assert_eq!(seen.get()[axis], Px::from_int(50));
assert_eq!(h.region(&id).unwrap().size()[axis], Px::from_int(40));
}
}
}
#[test]
fn a_cap_attribute_is_the_scroll_viewport() {
for node in [false, true] {
let mut h = Harness::new((400, 400));
let content = rect(Color::RED).height(400).add(&mut h.rsc);
let inner = content.add_strong(&mut h.rsc);
let scroll = Scroll::new(inner, Axis::Y).max_height(100).add(&mut h.rsc);
h.rsc.widgets_mut().set_region_node(scroll, node);
h.set_root(scroll);
assert_corners!(h, scroll, (0, 150), (400, 250));
assert_corners!(h, content, (0, -150), (400, 250));
h.rsc.widgets_mut().get_mut(&scroll).unwrap().scroll(1000.0);
h.frame();
assert_corners!(h, content, (0, 150), (400, 550));
h.resize((400, 80));
h.frame();
assert_corners!(h, scroll, (0, 0), (400, 80));
assert_corners!(h, content, (0, 0), (400, 400));
}
}
#[test]
fn a_cap_attribute_wraps_text_before_it_answers() {
let mut h = Harness::new((400, 500));
let text = wtext("one two three four five six seven eight nine ten")
.size(16)
.wrap(true)
.max_width(80)
.align(Align::TOP_LEFT)
.add(&mut h.rsc);
h.set_root(text);
let capped = h.region(&text).unwrap().size();
assert!(capped.x <= Px::from_int(80));
assert!(capped.y > Px::from_int(30));
h.rsc
.widgets_mut()
.set_size_rule(text, Axis::X, SizeRule::FREE);
h.frame();
let free = h.region(&text).unwrap().size();
assert!(free.x > capped.x);
assert!(free.y < capped.y);
}
#[test]
fn dimensions_and_bounds_are_independent_attributes_in_either_order() {
for bounds_first in [false, true] {
let mut h = Harness::new((400, 400));
let probe = rect(Color::RED).add(&mut h.rsc);
let bounded = if bounds_first {
probe
.max_width(80)
.min_height(60)
.width(120)
.height(40)
.add(&mut h.rsc)
} else {
probe
.width(120)
.height(40)
.max_width(80)
.min_height(60)
.add(&mut h.rsc)
};
assert_eq!(probe.id(), bounded.id());
h.set_root(bounded);
assert_eq!(
h.region(&probe).unwrap().size(),
PxVec2::from_f32((80, 60).into())
);
h.rsc.widgets_mut().set_len(probe, Axis::X, 50);
h.rsc.widgets_mut().set_len(probe, Axis::Y, 100);
h.frame();
assert_eq!(
h.region(&probe).unwrap().size(),
PxVec2::from_f32((50, 100).into())
);
}
}
#[test]
fn changing_a_share_cap_replaces_it_without_losing_the_share() {
let mut h = Harness::new((300, 100));
let capped = rect(Color::RED)
.max_width(80)
.width(leftover(1))
.add(&mut h.rsc);
let sibling = rect(Color::BLUE).add(&mut h.rsc);
h.set_root((capped, sibling).span(Dir::RIGHT));
assert_corners!(h, capped, (0, 0), (80, 100));
assert_corners!(h, sibling, (80, 0), (300, 100));
h.rsc.widgets_mut().set_max_len(capped, Axis::X, 160.into());
h.frame();
assert_corners!(h, capped, (0, 0), (150, 100));
assert_corners!(h, sibling, (150, 0), (300, 100));
}
// Reduced from seed 104 at depth 5: a widget widening its own window
// contract must not erase the bound's crossing at a quarter-window of 173.
#[test]
fn a_widgets_window_contract_cannot_widen_its_bounds_contract() {
fn tree(h: &mut Harness) -> WidgetId {
let content = rect(Color::RED)
.width(137)
.min_height(194)
.add_strong(&mut h.rsc);
let scroll = Scroll::new(content, Axis::X).add_strong(&mut h.rsc);
let probe = rect(Color::RED).add_strong(&mut h.rsc);
let id = probe.id();
let wide = rect(Color::GREEN).add_strong(&mut h.rsc);
let narrow = rect(Color::BLUE).add_strong(&mut h.rsc);
let branch = iris::random::Branch {
probe,
wide,
narrow,
threshold: 459.0,
}
.min_width(94)
.max_width(173)
.add_strong(&mut h.rsc);
let stack = Stack {
children: vec![scroll, branch],
size: StackSize::Child(0),
}
.add_strong(&mut h.rsc);
let mut children: Vec<StrongWidget> = (0..3)
.map(|_| rect(Color::RED).add_strong(&mut h.rsc).any())
.collect();
children.push(stack);
h.set_root(
Span {
children,
dir: Dir::RIGHT,
gap: Px::ZERO,
}
.height(rel(1)),
);
id
}
let mut warm = Harness::new((1920, 1200));
let probe = tree(&mut warm);
warm.resize((640, 900));
warm.frame();
assert_corners!(warm, probe, (480, 0), (640, 40));
let mut cold = Harness::new((640, 900));
let other = tree(&mut cold);
assert_eq!(warm.region(&probe), cold.region(&other));
}
#[test]
fn a_fixed_declaration_keeps_its_cap_when_the_row_has_no_leftover() {
let mut h = Harness::new((100, 100));
let fixed = rect(Color::RED).width(200).max_width(100).add(&mut h.rsc);
let share = rect(Color::BLUE).add(&mut h.rsc);
h.set_root((fixed, share).span(Dir::RIGHT));
assert_corners!(h, fixed, (0, 0), (100, 100));
}
#[test]
fn a_new_bound_reaches_the_parent_even_when_the_current_answer_is_unchanged() {
let mut h = Harness::new((400, 100));
let leaf = rect(Color::RED).add(&mut h.rsc);
let inner = leaf.add_strong(&mut h.rsc);
let root = Scroll::new(inner, Axis::Y)
.pad(Padding::uniform(0))
.width(154)
.height(100)
.add(&mut h.rsc);
h.set_root(root);
assert_eq!(h.region(&leaf).unwrap().size().x, Px::from_int(154));
h.rsc.widgets_mut().set_min_len(leaf, Axis::X, 140.into());
h.rsc.widgets_mut().set_len(root, Axis::X, 78);
h.frame();
assert_corners!(h, leaf, (130, 0), (270, 100));
}
+2 -2
View File
@@ -31,8 +31,8 @@ fn some_edits(seed: u64, of: &Plan) -> Edits {
(
i,
SizeRules {
x: SizeRule::Exact(LayoutLen::LEFTOVER),
y: SizeRule::Free,
x: SizeRule::from(LayoutLen::LEFTOVER),
y: SizeRule::FREE,
},
)
})
+27 -9
View File
@@ -51,15 +51,33 @@ fn relative_intrinsic_bounds_agree_warm_and_cold() {
grown.walk_mut(&mut |node| {
if let Some(rules) = &mut node.size {
for axis in Axis::BOTH {
rules[axis] = match rules[axis] {
SizeRule::Min(_) => SizeRule::Min(Len::rel(0.25)),
SizeRule::Max(_) => SizeRule::Max(Len::rel(0.75)),
SizeRule::Clamp { .. } => SizeRule::Clamp {
min: Len::rel(0.25),
max: Len::rel(0.75),
},
ref rule => rule.clone(),
};
let bound = &mut rules[axis].bound;
if bound.min.is_some() {
bound.min = Some(Len::rel(0.25));
}
if bound.max.is_some() {
bound.max = Some(Len::rel(0.75));
}
}
}
});
});
}
#[test]
fn preferred_requests_with_independent_bounds_agree_warm_and_cold() {
check_requests(|grown| {
let mut index = 0;
grown.walk_mut(&mut |node| {
if let Some(rules) = &mut node.size {
for axis in Axis::BOTH {
index += 1;
rules[axis].request = Some(match index % 4 {
0 => leftover(1).into(),
1 => rel(0.5).into(),
2 => px(80).into(),
_ => (leftover(1) + px(30)).min(leftover(2)),
});
}
}
});
+1 -1
View File
@@ -264,7 +264,7 @@ fn layout_cost() {
harness
.rsc
.widgets_mut()
.set_size_rule(sized, Axis::X, SizeRule::Exact(len));
.set_size_rule(sized, Axis::X, SizeRule::from(len));
});
}
+3 -14
View File
@@ -200,8 +200,8 @@ fn mark(warm: &mut Harness, tree: &Tree, step: usize) {
fn a_rule(rng: &mut Rng) -> SizeRule {
let len = Len::px(20.0 + rng.below(180) as f32);
match rng.below(4) {
0 => SizeRule::Max(len),
1 => SizeRule::Min(len),
0 => SizeRule::max(len),
1 => SizeRule::min(len),
_ => LayoutLen::from(len).into(),
}
}
@@ -357,18 +357,7 @@ fn change(case: Case, warm: &mut Harness, tree: &mut Tree, plan: &Plan, rng: &mu
/// buildable from what the failure printed.
fn describe(id: WidgetId, h: &Harness) -> String {
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 {
SizeRule::Free => "-".into(),
SizeRule::Exact(len) => format!("{len}"),
SizeRule::Request(request) => format!("{request:?}"),
SizeRule::Min(min) => format!(">{}", LayoutLen::from(min)),
SizeRule::Max(max) => format!("<{}", LayoutLen::from(max)),
SizeRule::Clamp { min, max } => {
format!(">{}<{}", LayoutLen::from(min), LayoutLen::from(max))
}
};
let rule = |r: SizeRule| format!("{r:?}");
let align = h.rsc.widgets().alignment(id);
let side = |a: AxisAlign| {
if a == AxisAlign::NEG {