From 71c9c39523775e33785d15d0200d50a16d6ecf04 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Tue, 15 Sep 2026 18:02:28 -0400 Subject: [PATCH] Replace placement calls with region nodes --- core/src/layout_diagnostics.rs | 22 ++-- core/src/ui/active.rs | 6 +- core/src/ui/holds.rs | 43 +++++--- core/src/ui/painter.rs | 39 ++----- core/src/ui/render_state.rs | 183 ++++++++++++++++++++++++--------- core/src/util/arena.rs | 7 +- core/src/widget/data.rs | 2 + core/src/widget/widgets.rs | 18 ++++ src/random.rs | 6 +- src/widget/position/align.rs | 5 +- src/widget/position/scroll.rs | 6 +- src/widget/position/span.rs | 28 ++--- src/widget/trait_fns.rs | 12 ++- tests/chain_cost.rs | 5 +- tests/determinism.rs | 6 +- tests/generated.rs | 17 ++- tests/layout.rs | 48 +++++++-- tests/layout_diagnostics.rs | 4 +- tests/replace_cost.rs | 8 +- tests/retained.rs | 34 ++++-- tests/scroll.rs | 35 +++++++ tests/trace_unsettled.rs | 4 +- tests/unsettled.rs | 6 +- 23 files changed, 374 insertions(+), 170 deletions(-) diff --git a/core/src/layout_diagnostics.rs b/core/src/layout_diagnostics.rs index baaa5f8..1cdf04d 100644 --- a/core/src/layout_diagnostics.rs +++ b/core/src/layout_diagnostics.rs @@ -28,7 +28,7 @@ pub(crate) enum Counter { Updates, DrawRequests, WidgetDraws, - PlaceCalls, + RegionNodeDraws, SizeReads, HintHits, HintMisses, @@ -38,7 +38,7 @@ pub(crate) enum Counter { ReuseMoved, ReuseDirty, ReuseWrongParent, - ReuseUnslotted, + ReuseRemapped, ReuseOutside, QueuePops, DepthReads, @@ -60,7 +60,7 @@ impl Counter { "updates", "draw requests", "widget draws", - "place calls", + "region-node draws", "draw-result size reads", "hint hits", "hint misses", @@ -70,7 +70,7 @@ impl Counter { "reuse moved", "reuse: dirty", "reuse: wrong parent", - "reuse: unslotted", + "reuse remapped", "reuse: outside what it holds for", "redraw queue pops", "depth reads", @@ -242,7 +242,7 @@ pub enum ReuseOutcome { Moved, Dirty, WrongParent, - Unslotted, + Remapped, Outside, Undrawn, } @@ -256,7 +256,7 @@ pub enum TraceEvent { parent: Option, region: UiRegion, pixel_size: Vec2, - slotted: bool, + region_node: bool, }, Reuse { id: WidgetId, @@ -266,7 +266,7 @@ pub enum TraceEvent { id: WidgetId, size: Size, }, - Placed { + RegionNode { id: WidgetId, parent: WidgetId, region: UiRegion, @@ -352,7 +352,7 @@ pub(crate) fn draw_request( parent: Option, region: UiRegion, pixel_size: Vec2, - slotted: bool, + region_node: bool, ) { trace( id, @@ -361,7 +361,7 @@ pub(crate) fn draw_request( parent, region, pixel_size, - slotted, + region_node, }, ); } @@ -374,8 +374,8 @@ pub(crate) fn size_reported(id: WidgetId, size: Size) { trace(id, TraceEvent::SizeReported { id, size }); } -pub(crate) fn placed(id: WidgetId, parent: WidgetId, region: UiRegion) { - trace(id, TraceEvent::Placed { id, parent, region }); +pub(crate) fn region_node(id: WidgetId, parent: WidgetId, region: UiRegion) { + trace(id, TraceEvent::RegionNode { id, parent, region }); } pub(crate) fn size_read(id: WidgetId, reader: WidgetId, size: Size) { diff --git a/core/src/ui/active.rs b/core/src/ui/active.rs index 8f9f50c..d60527d 100644 --- a/core/src/ui/active.rs +++ b/core/src/ui/active.rs @@ -32,14 +32,14 @@ pub struct ActiveData { pub children: Vec, /// The children whose size this widget read while drawing. pub size_deps: Vec, - /// The slot its primitives are positioned through: its own if its parent - /// placed it, otherwise the nearest ancestor that has one. + /// The movable region its primitives are positioned through: its own when + /// opted in, otherwise the nearest ancestor's. pub move_idx: MoveIdx, /// The declared lengths whoever drew this widget resolved into its box. /// A change to one moves a box this widget cannot fix by drawing again, /// and comparing them is what says so. pub declared: [Option; 2], - /// The slot `region` is given in, which is whatever its parent drew in. + /// The movable region whose coordinates `region` uses. pub parent_move: MoveIdx, pub mask: MaskIdx, pub layer: LayerId, diff --git a/core/src/ui/holds.rs b/core/src/ui/holds.rs index 47d1f1b..61e8e6d 100644 --- a/core/src/ui/holds.rs +++ b/core/src/ui/holds.rs @@ -25,11 +25,27 @@ impl Holds { }; pub const fn at(len: f32) -> Self { - Self { lo: len, hi: len } + Self::tolerant(len, len) + } + + const fn tolerant(lo: f32, hi: f32) -> Self { + Self { + lo: lo - HOLDS_EPSILON_PX, + hi: hi + HOLDS_EPSILON_PX, + } + } + + /// A range whose endpoints are exact, for a widget decision with a hard + /// boundary rather than an accumulated coordinate-rounding difference. + pub fn exact(range: RangeInclusive) -> Self { + Self { + lo: *range.start(), + hi: *range.end(), + } } pub fn contains(&self, len: f32) -> bool { - len >= self.lo - HOLDS_EPSILON_PX && len <= self.hi + HOLDS_EPSILON_PX + len >= self.lo && len <= self.hi } pub fn and(self, other: Self) -> Self { @@ -57,10 +73,7 @@ impl Holds { impl From> for Holds { fn from(range: RangeInclusive) -> Self { - Self { - lo: *range.start(), - hi: *range.end(), - } + Self::tolerant(*range.start(), *range.end()) } } @@ -70,12 +83,16 @@ mod tests { #[test] fn through_reverses_a_range_for_a_negative_fraction() { - assert_eq!( - Holds { lo: 20.0, hi: 40.0 }.through(UiScalar::new(-0.5, 10.0)), - Holds { - lo: -60.0, - hi: -20.0 - } - ); + let holds = Holds::from(20.0..=40.0).through(UiScalar::new(-0.5, 10.0)); + assert!((holds.lo - -60.1).abs() < 0.001); + assert!((holds.hi - -19.9).abs() < 0.001); + } + + #[test] + fn an_exact_open_boundary_does_not_admit_the_boundary() { + let boundary = 10.0_f32; + let above = Holds::exact(boundary.next_up()..=f32::INFINITY); + assert!(!above.contains(boundary)); + assert!(above.contains(boundary.next_up())); } } diff --git a/core/src/ui/painter.rs b/core/src/ui/painter.rs index ac5cfcd..58302b7 100644 --- a/core/src/ui/painter.rs +++ b/core/src/ui/painter.rs @@ -10,8 +10,6 @@ use crate::{ ui::render_state::DrawInfo, util::Vec2, }; -use std::ops::RangeInclusive; - const AXES: [Axis; 2] = [Axis::X, Axis::Y]; /// makes your surfaces look pretty @@ -40,8 +38,8 @@ pub struct Painter<'a> { pub(super) own: [Holds; 2], /// What the children it asked about and drew keep it to. pub(super) under: [Holds; 2], - /// The slot this widget's primitives are positioned through: its own if - /// its parent placed it, otherwise the nearest ancestor that has one. + /// The movable region this widget's primitives are positioned through: + /// its own when opted in, otherwise the nearest ancestor's. pub(super) move_idx: MoveIdx, pub layer: usize, pub(super) depth: usize, @@ -101,7 +99,7 @@ impl<'a> Painter<'a> { /// Draws a widget within this widget's region. pub fn widget<'s, W: ?Sized>(&'s mut self, id: &'s StrongWidget) -> DrawResult<'s, 'a, W> { - self.widget_at(id, UiRegion::FULL, false) + self.widget_at(id, UiRegion::FULL) } /// Draws a widget somewhere within this one. @@ -110,7 +108,7 @@ impl<'a> Painter<'a> { id: &'s StrongWidget, region: UiRegion, ) -> DrawResult<'s, 'a, W> { - self.widget_at(id, region, false) + self.widget_at(id, region) } /// What a widget declares its lengths to be, which whoever draws it @@ -125,22 +123,6 @@ impl<'a> Painter<'a> { AXES.map(|axis| declared_len(widget, axis)) } - /// Draws a child this widget decides the box of, and may decide again - /// once it knows what the child came to. The child gets a slot of its - /// own, so placing it a second time writes one entry however much it - /// drew -- moved or resized alike, since everything under the slot is - /// held as a fraction of its box. A child drawn any other way has no slot - /// and can only be given a different box by drawing again. - pub fn place<'s, W: ?Sized>( - &'s mut self, - id: &'s StrongWidget, - region: UiRegion, - ) -> DrawResult<'s, 'a, W> { - #[cfg(feature = "layout-diagnostics")] - diag::bump(Counter::PlaceCalls); - self.widget_at(id, region, true) - } - /// Takes back a child that was drawn only to find out how long it is. /// Its drawing is dropped and it is not one of this widget's children /// this frame; what it answered is still something this widget asked. @@ -155,8 +137,8 @@ impl<'a> Painter<'a> { &'s mut self, id: &'s StrongWidget, region: UiRegion, - slotted: bool, ) -> DrawResult<'s, 'a, W> { + let region_node = self.rsc.widgets().is_region_node(id.id()); let declared = self.declared_lens(id); // Composing `FULL` through a box is not quite the identity in f32, // so a child with nothing declared keeps the box it would have had. @@ -169,8 +151,9 @@ impl<'a> Painter<'a> { false => local.within(&self.region), }; #[cfg(feature = "layout-diagnostics")] - if slotted { - diag::placed(id.id(), self.id, within); + if region_node { + diag::bump(Counter::RegionNodeDraws); + diag::region_node(id.id(), self.id, within); } // A child listed twice would be moved twice. if !self.children.contains(&id.id()) { @@ -190,7 +173,7 @@ impl<'a> Painter<'a> { parent: Some(self.id), depth: self.depth + 1, parent_move: self.move_idx, - slotted, + region_node, mask: self.mask, offer, offered_px: self.px_within_offer(offer), @@ -371,8 +354,8 @@ impl<'a> Painter<'a> { /// holds for -- the same primitives, in the same fractions and offsets /// of the box, and the same reported size. A widget that read its /// length in pixels holds for that one alone until it says otherwise. - pub fn holds(&mut self, axis: Axis, range: RangeInclusive) { - let holds = Holds::from(range); + pub fn holds(&mut self, axis: Axis, holds: impl Into) { + let holds = holds.into(); debug_assert!( holds.contains(self.state.px_of(self.move_idx, self.region).axis(axis)), "'{}' ({:?}) says its drawing holds for lengths that leave out its own box", diff --git a/core/src/ui/render_state.rs b/core/src/ui/render_state.rs index 0178716..6c67702 100644 --- a/core/src/ui/render_state.rs +++ b/core/src/ui/render_state.rs @@ -17,8 +17,7 @@ pub(super) struct DrawInfo { pub parent: Option, pub depth: usize, pub parent_move: MoveIdx, - /// Whether the widget gets a slot of its own to be placed through. - pub slotted: bool, + pub region_node: bool, pub mask: MaskIdx, /// The box it was first asked about in, as a part of its parent's, and /// that box in pixels. @@ -88,7 +87,7 @@ impl UiRenderState { parent: None, depth: 1, parent_move: self.root_move, - slotted: false, + region_node: false, mask: MaskIdx::NONE, offer: UiRegion::FULL, offered_px: self.output_size, @@ -169,7 +168,7 @@ impl UiRenderState { info.parent, region, self.px_of(info.parent_move, region), - info.slotted, + info.region_node, ); } if self.active.contains_key(&id) { @@ -181,14 +180,17 @@ impl UiRenderState { } // draw widget - let (move_idx, local) = match info.slotted { - // Its box becomes its slot's, so it draws in the slot's own - // coordinates and the box it was given is one entry to rewrite. - true => (self.move_slot(id, info.parent_move, region), UiRegion::FULL), - false => { - self.drop_slot(id); - (info.parent_move, region) - } + let (move_idx, local, retired_move) = match info.region_node { + // Its box becomes its movable region, so it draws in that + // region's coordinates and its box is one entry to rewrite. + true => ( + self.move_slot(id, info.parent_move, region), + UiRegion::FULL, + None, + ), + // Keep the old entry alive until every descendant has migrated. + // Reusing its index sooner could make an old parent look current. + false => (info.parent_move, region, self.slots.remove(&id)), }; let (old_children, old_answer) = match old { Some(old) => (old.children, Some(old.answer)), @@ -257,8 +259,8 @@ impl UiRenderState { let holds = [own[0].and(under[0]), own[1].and(under[1])]; debug_assert!( holds[0].contains(px.x) && holds[1].contains(px.y), - "'{}' ({id:?}) drew something that does not hold for its own box", - rsc.widgets().label(id) + "'{}' ({id:?}) drew in {px:?}, outside the ranges it reported: {holds:?}", + rsc.widgets().label(id), ); for c in &old_children { @@ -266,6 +268,9 @@ impl UiRenderState { self.undraw_rec(*c, rsc); } } + if let Some(idx) = retired_move { + self.moves.remove(idx); + } // What it asked about and did not draw is still something it asked, // and a change there has to reach it. Asking answered whatever mark // it had: a hint is read live, and a drawing is not kept past one. @@ -278,7 +283,7 @@ impl UiRenderState { parent: Some(id), depth: info.depth + 1, parent_move: move_idx, - slotted: false, + region_node: false, mask, offer: UiRegion::FULL, offered_px: px, @@ -317,9 +322,8 @@ impl UiRenderState { size } - /// The slot a widget's box is held in, made on its first placed draw and - /// kept until it stops being drawn -- a redraw replaces its `ActiveData` - /// while descendants go on naming the slot. + /// Keeps a region node's entry across redraws because descendants retain + /// its index. fn move_slot(&mut self, id: WidgetId, parent: MoveIdx, region: UiRegion) -> MoveIdx { if let Some(&idx) = self.slots.get(&id) { self.moves.set_parent(idx, parent); @@ -331,9 +335,7 @@ impl UiRenderState { idx } - /// Gives up a slot a widget no longer needs, because it is drawn somewhere - /// that does not place it. Its descendants name it, so this is only - /// reached where they are about to be drawn again. + /// Removes a region node only after its descendants stop naming it. fn drop_slot(&mut self, id: WidgetId) { if let Some(idx) = self.slots.remove(&id) { self.moves.remove(idx); @@ -417,6 +419,10 @@ impl UiRenderState { diag::reuse(id, ReuseOutcome::Undrawn); return None; } + let has_region_node = active.move_idx != active.parent_move; + if has_region_node != info.region_node { + return None; + } // Drawn somewhere else in the tree: its box is in coordinates it no // longer sits in, and its slot names the wrong parent. if active.parent_move != info.parent_move { @@ -439,21 +445,15 @@ impl UiRenderState { return None; } let moved = active.region != region; - // Only a placed widget can be given a different region without - // drawing again: it has an entry of its own to say where it went, - // where an unslotted one shares its parent's and has nothing to - // write. - if moved && active.move_idx == info.parent_move { - #[cfg(feature = "layout-diagnostics")] - { - diag::bump(Counter::ReuseUnslotted); - diag::reuse(id, ReuseOutcome::Unslotted); - } - return None; - } - let (size, slot) = (active.size, active.move_idx); + let (size, old_region, slot, mask) = + (active.size, active.region, active.move_idx, info.mask); if moved { - self.moves.set(slot, region); + if has_region_node { + self.moves.set(slot, region); + } else { + let remap = RegionRemap::new(old_region, region)?; + self.remap_subtree(id, remap, info.parent_move, mask, rsc); + } } let active = self.active.get_mut(&id).unwrap(); active.region = region; @@ -461,14 +461,19 @@ impl UiRenderState { active.depth = info.depth; #[cfg(feature = "layout-diagnostics")] { - match moved { - true => diag::bump(Counter::ReuseMoved), - false => diag::bump(Counter::ReuseExact), + match (moved, has_region_node) { + (true, true) => diag::bump(Counter::ReuseMoved), + (true, false) => diag::bump(Counter::ReuseRemapped), + (false, _) => diag::bump(Counter::ReuseExact), } diag::reuse( id, if moved { - ReuseOutcome::Moved + if has_region_node { + ReuseOutcome::Moved + } else { + ReuseOutcome::Remapped + } } else { ReuseOutcome::Exact }, @@ -477,6 +482,42 @@ impl UiRenderState { Some(size) } + /// Re-expresses an ordinary retained subtree in a new parent region. + /// An independently movable descendant needs only its own region changed; + /// its contents stay in that region's coordinate space. + fn remap_subtree( + &mut self, + id: WidgetId, + remap: RegionRemap, + parent_move: MoveIdx, + inherited_mask: MaskIdx, + rsc: &mut dyn UiRsc, + ) { + let active = self.active.get_mut(&id).unwrap(); + if active.move_idx != parent_move { + let region = remap.apply(active.region); + active.region = region; + self.moves.set(active.move_idx, region); + return; + } + for handle in &active.primitives { + let region = self.layers[handle.layer].region_mut(handle); + *region = remap.apply(*region); + } + active.region = remap.apply(active.region); + let mask = active.mask; + let children = active.children.len(); + if mask != inherited_mask && mask != MaskIdx::NONE { + let mask = rsc.ui_mut().masks.get_mut(mask); + debug_assert_eq!(mask.move_idx, parent_move); + mask.region = remap.apply(mask.region); + } + for index in 0..children { + let child = self.active[&id].children[index]; + self.remap_subtree(child, remap, parent_move, mask, rsc); + } + } + fn hints_agree(id: WidgetId, size: Size, rsc: &dyn UiRsc) -> bool { let Some(widget) = rsc.widgets().get_dyn(id) else { return true; @@ -716,15 +757,15 @@ impl UiRenderState { return; } let region = active.region; - let slotted = active.move_idx != active.parent_move; + let region_node = active.parent.is_some() && rsc.widgets().is_region_node(id); let offered_px = self.offered_px(id); let at_offer = same_px(self.px_of(active.parent_move, region), offered_px); // Asked again where its parent asked: a box decided from its own // answer gives that answer back whatever the content now says. Only - // a placed widget can be drawn in a box other than the one it has, - // so one that was not is a question for its parent. + // a region node can be redrawn away from its current box without + // first involving the parent that chose that box. if let Some(parent) = active.parent - && !slotted + && !region_node && !at_offer { rsc.widgets_mut().needs_redraw.insert(id); @@ -737,7 +778,7 @@ impl UiRenderState { parent: active.parent, depth: active.depth, parent_move: active.parent_move, - slotted, + region_node, mask: active.mask, offer: active.offer, offered_px, @@ -762,8 +803,8 @@ impl UiRenderState { return; }; if answer != was_answer { - // Left where it was asked: the parent lays out again, and places - // it. + // Left where it was asked: the parent lays out again and chooses + // its final box. #[cfg(feature = "layout-diagnostics")] { diag::bump(Counter::SizeChanges); @@ -775,9 +816,8 @@ impl UiRenderState { if at_offer { return; } - // Then where its parent placed it, which is the box that answer - // decided. Kept as it is if it holds there; otherwise what it comes - // to there is the parent's business too. + // Then in the final box its parent chose from that answer. It is kept + // if it holds there; otherwise its result is the parent's business. self.draw_inner(id, region, info, None, rsc); let active = &self.active[&id]; if (active.size, active.holds) != was { @@ -790,6 +830,51 @@ fn same_px(a: Vec2, b: Vec2) -> bool { Holds::at(a.x).contains(b.x) && Holds::at(a.y).contains(b.y) } +/// A retained region rewritten from one parent box into another. A fixed +/// source extent can be translated but cannot recover fractions for a resize. +#[derive(Clone, Copy)] +struct RegionRemap { + from: UiRegion, + to: UiRegion, +} + +impl RegionRemap { + fn new(from: UiRegion, to: UiRegion) -> Option { + AXES.into_iter() + .all(|axis| { + let from = from.axis(axis); + from.start.rel != from.end.rel || from.len() == to.axis(axis).len() + }) + .then_some(Self { from, to }) + } + + fn apply(self, region: UiRegion) -> UiRegion { + UiRegion { + x: self.apply_span(region.x, self.from.x, self.to.x), + y: self.apply_span(region.y, self.from.y, self.to.y), + } + } + + fn apply_span(self, span: UiSpan, from: UiSpan, to: UiSpan) -> UiSpan { + UiSpan { + start: self.apply_scalar(span.start, from, to), + end: self.apply_scalar(span.end, from, to), + } + } + + fn apply_scalar(self, scalar: UiScalar, from: UiSpan, to: UiSpan) -> UiScalar { + let extent = from.end.rel - from.start.rel; + if extent == 0.0 { + return scalar + to.start - from.start; + } + let fraction = (scalar.rel - from.start.rel) / extent; + let from_px = from.start.px + fraction * (from.end.px - from.start.px); + let to_rel = to.start.rel + fraction * (to.end.rel - to.start.rel); + let to_px = to.start.px + fraction * (to.end.px - to.start.px); + UiScalar::new(to_rel, scalar.px - from_px + to_px) + } +} + impl Default for UiRenderState { fn default() -> Self { Self::new() diff --git a/core/src/util/arena.rs b/core/src/util/arena.rs index 46e5eca..8107c22 100644 --- a/core/src/util/arena.rs +++ b/core/src/util/arena.rs @@ -35,7 +35,7 @@ impl Arena { self.data[i] } - pub fn get_mut(&mut self, id: Id) -> &mut T { + pub(crate) fn get_mut(&mut self, id: Id) -> &mut T { &mut self.data[id.idx()] } } @@ -75,6 +75,11 @@ impl TrackedArena { self.refs[i.idx()] += 1; } + pub fn get_mut(&mut self, id: Id) -> &mut T { + self.changed = true; + self.inner.get_mut(id) + } + pub fn remove(&mut self, id: Id) -> T where T: Copy, diff --git a/core/src/widget/data.rs b/core/src/widget/data.rs index 8d693e7..fc0abbd 100644 --- a/core/src/widget/data.rs +++ b/core/src/widget/data.rs @@ -3,6 +3,7 @@ use crate::Widget; pub struct WidgetData { pub widget: Box, pub label: String, + pub(super) region_node: bool, /// dynamic borrow checking pub borrowed: bool, } @@ -16,6 +17,7 @@ impl WidgetData { Self { widget: Box::new(widget), label, + region_node: false, borrowed: false, } } diff --git a/core/src/widget/widgets.rs b/core/src/widget/widgets.rs index 6098aa6..3e5f04e 100644 --- a/core/src/widget/widgets.rs +++ b/core/src/widget/widgets.rs @@ -100,6 +100,24 @@ impl Widgets { self.data_mut(id.id()).unwrap().label = label; } + /// Whether this widget owns a movable retained region. + pub fn is_region_node(&self, id: impl IdLike) -> bool { + self.data(id).unwrap().region_node + } + + /// Chooses whether this widget's retained drawing has one movable region + /// of its own. Changing the boundary redraws the subtree once so every + /// primitive names the right coordinate space. + pub fn set_region_node(&mut self, id: impl IdLike, region_node: bool) { + let id = id.id(); + let data = self.data_mut(id).unwrap(); + if data.region_node == region_node { + return; + } + data.region_node = region_node; + self.needs_redraw.insert(id); + } + pub fn data_mut(&mut self, id: impl IdLike) -> Option<&mut WidgetData> { self.vec.get_mut(id.id()) } diff --git a/src/random.rs b/src/random.rs index 96e64b2..47f7321 100644 --- a/src/random.rs +++ b/src/random.rs @@ -100,14 +100,14 @@ impl Widget for Branch { fn draw(&mut self, painter: &mut Painter) -> Size { let mut top = UiRegion::FULL; top.y.end = top.y.start.offset(40.0); - let measured = painter.place(&self.probe, top).len(Axis::X); + let measured = painter.widget_within(&self.probe, top).len(Axis::X); let px = measured.apply_leftover().to_px(painter.px_len(Axis::X)); let mut below = UiRegion::FULL; below.y.start = below.y.start.offset(40.0); match px > self.threshold { - true => painter.place(&self.wide, below), - false => painter.place(&self.narrow, below), + true => painter.widget_within(&self.wide, below), + false => painter.widget_within(&self.narrow, below), }; Size::LEFTOVER } diff --git a/src/widget/position/align.rs b/src/widget/position/align.rs index 8b20086..239859e 100644 --- a/src/widget/position/align.rs +++ b/src/widget/position/align.rs @@ -29,14 +29,15 @@ impl Widget for Aligned { // Drawn where it may be too big only when the aligned axes are not // already known, then given its aligned box once its size is known. let had_size = known.is_some(); - let size = known.unwrap_or_else(|| painter.place(&self.inner, UiRegion::FULL).size()); + let size = + known.unwrap_or_else(|| painter.widget_within(&self.inner, UiRegion::FULL).size()); let region = match self.align.tuple() { (Some(x), Some(y)) => size.to_uivec2().align(RegionAlign { x, y }), (Some(x), None) => UiRegion::new(size.x.apply_leftover().align(x), UiSpan::FULL), (None, Some(y)) => UiRegion::new(UiSpan::FULL, size.y.apply_leftover().align(y)), (None, None) => UiRegion::FULL, }; - let placed = painter.place(&self.inner, region).size(); + let placed = painter.widget_within(&self.inner, region).size(); if had_size { placed } else { size } } } diff --git a/src/widget/position/scroll.rs b/src/widget/position/scroll.rs index 9e58c2b..3c6f9d9 100644 --- a/src/widget/position/scroll.rs +++ b/src/widget/position/scroll.rs @@ -13,12 +13,12 @@ impl Widget for Scroll { fn draw(&mut self, painter: &mut Painter) -> Size { let container_len = painter.px_len(self.axis); // Draw in the whole container only when its scrolling-axis length is - // not already known, then place it at the scrolled offset. + // not already known, then draw it at the scrolled offset. let (answer_len, measured) = match painter.known_len(&self.inner, self.axis, UiRegion::FULL) { Some(len) => (len, None), None => { - let size = painter.place(&self.inner, UiRegion::FULL).size(); + let size = painter.widget_within(&self.inner, UiRegion::FULL).size(); (size.axis(self.axis), Some(size)) } }; @@ -43,7 +43,7 @@ impl Widget for Scroll { let mut region = UiRegion::FULL.offset(Vec2::from_axis(self.axis, -self.amt, 0.0)); region.axis_mut(self.axis).end = region.axis(self.axis).start.offset(self.content_len); - let placed = painter.place(&self.inner, region).size(); + let placed = painter.widget_within(&self.inner, region).size(); measured.unwrap_or_else(|| Size::from_axis(self.axis, answer_len, placed.axis(!self.axis))) } } diff --git a/src/widget/position/span.rs b/src/widget/position/span.rs index 6db1365..873650f 100644 --- a/src/widget/position/span.rs +++ b/src/widget/position/span.rs @@ -21,8 +21,8 @@ pub struct Span { impl Widget for Span { fn draw(&mut self, painter: &mut Painter) -> Size { let axis = self.dir.axis; - // A length for every child before any is placed: from its own hint - // where it has one, and from drawing it where it does not. + // A length for every child before their final boxes are chosen: from + // a hint where one exists, and from drawing otherwise. let mut cursor = UiScalar::rel_min(); let mut lens = Vec::with_capacity(self.children.len()); for child in &self.children { @@ -33,7 +33,7 @@ impl Widget for Span { let region = UiRegion::from_axis(axis, span, UiSpan::FULL); let len = match painter.known_len(child, axis, region) { Some(len) => len, - None => painter.place(child, region).len(axis), + None => painter.widget_within(child, region).len(axis), }; cursor.px += len.px + self.gap; cursor.rel += len.rel; @@ -47,24 +47,28 @@ impl Widget for Span { // beside 300 px is full at 600 and overfull at 400. The answer is // the same on either side of the length the fixed parts alone fill. let fixed = 1.0 - total.rel; - let shares = total.leftover > 0.0 && fixed * painter.px_len(axis) > total.px; + let mut shares = false; if total.leftover > 0.0 { - let range = if fixed > 0.0 { + let current = painter.px_len(axis); + let holds = if fixed > 0.0 { let full = total.px / fixed; + shares = current > full; match shares { - true => full..=f32::INFINITY, - false => f32::NEG_INFINITY..=full, + true => Holds::exact(full.next_up()..=f32::INFINITY), + false => Holds::exact(f32::NEG_INFINITY..=full), } } else if fixed < 0.0 { let full = total.px / fixed; + shares = current < full; match shares { - true => f32::NEG_INFINITY..=full, - false => full..=f32::INFINITY, + true => Holds::exact(f32::NEG_INFINITY..=full.next_down()), + false => Holds::exact(full..=f32::INFINITY), } } else { - f32::NEG_INFINITY..=f32::INFINITY + shares = total.px < 0.0; + Holds::ANY }; - painter.holds(axis, range); + painter.holds(axis, holds); } let mut start = UiScalar::rel_min(); @@ -93,7 +97,7 @@ impl Widget for Span { if self.dir.sign == Sign::Neg { region.flip(axis); } - let placed = painter.place(child, region); + let placed = painter.widget_within(child, region); if self.ortho == OrthoSize::Children { let used = placed.len(!axis); // Choosing between a fixed and a relative length from the diff --git a/src/widget/trait_fns.rs b/src/widget/trait_fns.rs index f76b9a2..4fd3d05 100644 --- a/src/widget/trait_fns.rs +++ b/src/widget/trait_fns.rs @@ -31,6 +31,14 @@ widget_trait! { } } + fn region_node(self) -> impl WidgetIdFn { + |state| { + let id = self.add(state); + state.ui_mut().widgets.set_region_node(id, true); + id + } + } + fn sized(self, size: impl Into) -> impl WidgetFn { let size = size.into(); move |state| SetSize { @@ -85,7 +93,9 @@ widget_trait! { fn scrollable(self) -> impl WidgetIdFn where Rsc: HasEvents { move |state| { - Scroll::new(self.add_strong(state), Axis::Y) + let inner = self.add(state); + state.ui_mut().widgets.set_region_node(inner, true); + Scroll::new(inner.upgrade(state), Axis::Y) .on(CursorSense::Scroll, |ctx, rsc| { let delta = ctx.data.scroll_delta.y * 50.0; ctx.widget(rsc).scroll(delta); diff --git a/tests/chain_cost.rs b/tests/chain_cost.rs index 2b36ece..8b2ec45 100644 --- a/tests/chain_cost.rs +++ b/tests/chain_cost.rs @@ -1,6 +1,5 @@ -//! What the vertex shader's move-chain walk costs, against how deep the chain -//! is. Every active widget owns a slot, so the depth a primitive resolves -//! through is its depth in the widget tree. +//! What the vertex shader's move-chain walk costs, against how many nested +//! region nodes a primitive resolves through. //! //! cargo test --release --test chain_cost -- --ignored --nocapture //! diff --git a/tests/determinism.rs b/tests/determinism.rs index aefa077..3700035 100644 --- a/tests/determinism.rs +++ b/tests/determinism.rs @@ -23,14 +23,14 @@ impl Widget for BranchesOnMeasurement { fn draw(&mut self, painter: &mut Painter) -> Size { let mut top = UiRegion::FULL; top.y.end = top.y.start.offset(40.0); - let measured = painter.place(&self.probe, top).len(Axis::X); + let measured = painter.widget_within(&self.probe, top).len(Axis::X); let px = measured.apply_leftover().to_px(painter.px_len(Axis::X)); let mut below = UiRegion::FULL; below.y.start = below.y.start.offset(40.0); match px > self.threshold { - true => painter.place(&self.wide, below), - false => painter.place(&self.narrow, below), + true => painter.widget_within(&self.wide, below), + false => painter.widget_within(&self.narrow, below), }; Size::LEFTOVER } diff --git a/tests/generated.rs b/tests/generated.rs index 83c0b59..dc7e08a 100644 --- a/tests/generated.rs +++ b/tests/generated.rs @@ -1,10 +1,9 @@ //! Random trees, checked against building the same tree cold. //! -//! A frame reaches its layout by keeping most of the last one: slots -//! rewritten, some widgets drawn again, the rest untouched. The property here -//! is that what comes out is the tree a cold start would have produced, so -//! anything the retained path carried over that it should not have shows up -//! as a difference in somebody's box. +//! A frame reaches its layout by keeping most of the last one: movable regions +//! or primitive boxes rewritten, some widgets drawn again, the rest untouched. +//! The result must be the tree a cold start would have produced, so anything +//! wrongly retained shows up as a difference in somebody's box. //! //! `iris::random` grows the tree and `examples/random.rs` draws one. A seed is //! the whole reproduction; `a_long_run_of_seeds_agrees` is the ignored sweep @@ -220,8 +219,8 @@ fn describe(id: WidgetId, h: &Harness) -> String { } /// Every widget in one tree against the matching widget in the other. A -/// mismatch prints the widget's ancestry, marking the ones that own a slot, -/// since where two trees disagree is rarely where the cause is. +/// mismatch prints the widget's ancestry, marking region nodes, since where +/// two trees disagree is rarely where the cause is. fn assert_same(seed: u64, what: &str, warm: (&Harness, &Tree), cold: (&Harness, &Tree)) { let ((wh, wt), (ch, ct)) = (warm, cold); assert_eq!(wt.ids.len(), ct.ids.len(), "seed {seed}: different trees"); @@ -243,11 +242,11 @@ fn assert_same(seed: u64, what: &str, warm: (&Harness, &Tree), cold: (&Harness, let mut at = Some(w); while let Some(id) = at { let active = &wh.render.active[&id]; - let slot = match active.move_idx == active.parent_move { + let node = match active.move_idx == active.parent_move { true => "", false => "*", }; - chain.push(format!("{}{slot}", describe(id, wh))); + chain.push(format!("{}{node}", describe(id, wh))); at = active.parent; } println!( diff --git a/tests/layout.rs b/tests/layout.rs index 85f7690..9157106 100644 --- a/tests/layout.rs +++ b/tests/layout.rs @@ -119,8 +119,8 @@ fn a_resize_lands_where_a_cold_start_would() { #[test] fn a_fixed_box_is_drawn_again_rather_than_stretched() { let mut h = Harness::new((400, 400)); - // The panel fills a stack sized by its sibling, so it is drawn in the - // whole box and then placed in the shorter one. Reusing it in that fixed + // The panel fills a stack sized by its sibling, so it is first asked in + // the whole box and then given the shorter one. Reusing it in that fixed // box afterwards would leave it whatever height it happened to have. let panel = rect(Color::BLUE).add(&mut h.rsc); let leaf = rect(Color::RED).height(100).add(&mut h.rsc); @@ -142,15 +142,15 @@ fn a_moved_subtree_takes_its_children_with_it() { let mut h = Harness::new((400, 400)); let first = rect(Color::RED).height(40).add(&mut h.rsc); let inner = rect(Color::BLUE).add(&mut h.rsc); - let row = inner.pad(10).height(40).add(&mut h.rsc); + let row = inner.pad(10).height(40).region_node().add(&mut h.rsc); h.set_root((first, row).span(Dir::DOWN)); assert_corners!(h, inner, (10, 50), (390, 70)); h.rsc[first].y = Some(Len::px(80)); h.frame(); - // The row is the same shape somewhere else, so one slot moved it and - // `inner`'s own region was never rewritten. + // The row opted into one movable region, so its descendants follow one + // entry rather than having their primitive regions rewritten. assert_corners!(h, inner, (10, 90), (390, 110)); } @@ -202,21 +202,29 @@ fn a_box_with_a_fixed_length_can_be_stretched_on_its_other_axis() { } #[test] -fn only_a_container_that_places_its_children_lengthens_the_chain() { +fn only_a_region_node_lengthens_the_chain_and_it_can_be_removed() { let mut h = Harness::new((400, 200)); let leaf = rect(Color::BLUE).add(&mut h.rsc); - // Four widgets between the span and the leaf, none of which places what - // it draws, so all of them share the span's slot. let buried = leaf.pad(4).pad(4).pad(4).pad(4).add(&mut h.rsc); let bar = rect(Color::RED).width(100).add(&mut h.rsc); h.set_root((bar, buried).span(Dir::RIGHT)); - let slot = h.render.active[&leaf.id()].parent_move; + let move_idx = h.render.active[&leaf.id()].parent_move; + assert_eq!(h.render.moves.depth(move_idx), 1, "only the root region"); + + h.rsc.widgets_mut().set_region_node(buried, true); + h.frame(); + let move_idx = h.render.active[&leaf.id()].parent_move; assert_eq!( - h.render.moves.depth(slot), + h.render.moves.depth(move_idx), 2, - "the span above the leaf, and the root the window is held in" + "the opted-in widget's region and the root region" ); + + h.rsc.widgets_mut().set_region_node(buried, false); + h.frame(); + let move_idx = h.render.active[&leaf.id()].parent_move; + assert_eq!(h.render.moves.depth(move_idx), 1); } /// A span that sizes from its children passes their `leftover` weight up @@ -412,3 +420,21 @@ fn only_a_pure_leftover_child_disappears_when_nothing_is_left() { // is leftover is omitted. assert_corners!(h, mixed, (100, 0), (120, 20)); } + +#[test] +fn leftover_children_disappear_at_the_exact_fixed_content_boundary() { + let mut h = Harness::new((100, 100)); + let first = rect(Color::RED).height(90).add(&mut h.rsc); + let a = rect(Color::GREEN).add(&mut h.rsc); + let b = rect(Color::BLUE).add(&mut h.rsc); + let inner = (a, b).span(Dir::DOWN).gap(4).add(&mut h.rsc); + h.set_root((first, inner).span(Dir::DOWN)); + assert!(h.region(&a).is_some()); + assert!(h.region(&b).is_some()); + + h.rsc[first].y = Some(Len::px(96.0)); + h.frame(); + + assert!(h.region(&a).is_none()); + assert!(h.region(&b).is_none()); +} diff --git a/tests/layout_diagnostics.rs b/tests/layout_diagnostics.rs index 77942a8..2eb4eca 100644 --- a/tests/layout_diagnostics.rs +++ b/tests/layout_diagnostics.rs @@ -30,7 +30,7 @@ fn a_selected_widget_retains_its_layout_events() { diagnostics::clear_traced_widgets(); let _ = diagnostics::take(); let mut harness = Harness::new((400, 200)); - let leaf = rect(Color::RED).add(&mut harness.rsc); + let leaf = rect(Color::RED).region_node().add(&mut harness.rsc); let other = rect(Color::BLUE).add(&mut harness.rsc); let root = (leaf, other).span(Dir::RIGHT).add(&mut harness.rsc); harness.set_root(root); @@ -46,7 +46,7 @@ fn a_selected_widget_retains_its_layout_events() { report .traces() .iter() - .any(|event| matches!(event, TraceEvent::Placed { id, .. } if *id == leaf.id())) + .any(|event| matches!(event, TraceEvent::RegionNode { id, .. } if *id == leaf.id())) ); assert!( report diff --git a/tests/replace_cost.rs b/tests/replace_cost.rs index c92ce02..c422196 100644 --- a/tests/replace_cost.rs +++ b/tests/replace_cost.rs @@ -1,4 +1,4 @@ -//! What re-placing a subtree costs per frame, as a load for a counter rather +//! What remapping a subtree costs per frame, as a load for a counter rather //! than a check. A span of 200 fixed-height rows, five primitives each, with //! the row above them changing height every frame, so every row below is //! offered a box the same shape somewhere else. @@ -6,9 +6,7 @@ //! cargo test --release --test replace_cost -- --ignored //! perf stat -e instructions:u target/release/.../replace_cost-* --ignored //! -//! Wall time is the wrong number here; see `draw_cost.rs`. Measured on -//! 2026-09-14 at 1.98M instructions per frame, against 2.38M for rewriting -//! each row's regions instead and 7.13M for redrawing them. +//! Wall time is the wrong number here; see `draw_cost.rs`. use iris::harness::Harness; use iris::prelude::*; @@ -18,7 +16,7 @@ const FRAMES: usize = 200; #[test] #[ignore = "measurement, not a check"] -fn replacing_rows_every_frame() { +fn remapping_rows_every_frame() { let mut h = Harness::new((1920, 1200)); let first = rect(Color::RED).height(40).add(&mut h.rsc); let mut span = Span::empty(Dir::DOWN); diff --git a/tests/retained.rs b/tests/retained.rs index 6f841a2..611ef86 100644 --- a/tests/retained.rs +++ b/tests/retained.rs @@ -111,6 +111,28 @@ fn a_leaf_that_ignores_its_box_is_not_drawn_again_when_the_box_changes() { assert_corners!(h, second, (150, 0), (400, 200)); } +#[test] +fn moving_an_ordinary_subtree_remaps_its_mask() { + let mut h = Harness::new((400, 200)); + let (first, _) = counted(&mut h, Size::from((100, 200)), false); + let inner = rect(Color::BLUE).add(&mut h.rsc); + let masked = inner.masked().add(&mut h.rsc); + h.set_root((first, masked).span(Dir::RIGHT)); + + h.rsc[first].size = Size::from((150, 200)); + h.frame(); + + let active = &h.render.active[&masked.id()]; + assert_eq!( + h.rsc.ui().masks[active.mask.idx()].region, + UiRegion::new( + UiSpan::new(UiScalar::px(150.0), UiScalar::rel_max()), + UiSpan::FULL, + ) + ); + assert_corners!(h, inner, (150, 0), (400, 200)); +} + #[test] fn a_leaf_that_depends_on_its_box_is_drawn_again_when_the_box_changes() { let mut h = Harness::new((400, 200)); @@ -121,7 +143,7 @@ fn a_leaf_that_depends_on_its_box_is_drawn_again_when_the_box_changes() { h.frame(); // The preceding fixed child makes the remaining box this child's real - // box, so measuring it also draws it in its final place. + // box, so measuring it also draws it in its final box. assert_eq!(draws.get(), settled + 1); assert_corners!(h, second, (150, 0), (400, 200)); } @@ -132,7 +154,7 @@ fn a_span_child_that_declares_its_length_is_drawn_once() { let (told, told_draws) = counted(&mut h, Size::from((100, 200)), false); let (asked, asked_draws) = counted(&mut h, Size::from((100, 200)), true); // The span takes one child's length from its hint and has to draw the - // other to find out, so only the second is drawn before it is placed. + // other to find out, so only the second is drawn before its final box. let hinted = told.width(100).add(&mut h.rsc); h.set_root((hinted, asked).span(Dir::RIGHT)); @@ -140,7 +162,7 @@ fn a_span_child_that_declares_its_length_is_drawn_once() { assert_eq!( asked_draws.get(), 2, - "drawn to be measured, then again to be placed" + "drawn to be measured, then again in its final box" ); } @@ -173,10 +195,10 @@ fn a_repaint_that_keeps_its_size_does_not_relay_out() { } #[test] -fn a_placed_child_survives_the_next_frame() { +fn a_span_child_survives_the_next_frame() { let mut h = Harness::new((400, 200)); - // Both children declare a length, so the span places them from their hints - // rather than drawing them to find out. + // Both children declare a length, so the span chooses their boxes from + // hints rather than drawing them to find out. let top = rect(Color::RED).height(80).add(&mut h.rsc); let bottom = rect(Color::BLUE).height(120).add(&mut h.rsc); h.set_root((top, bottom).span(Dir::DOWN)); diff --git a/tests/scroll.rs b/tests/scroll.rs index 24a2d73..32c5e8d 100644 --- a/tests/scroll.rs +++ b/tests/scroll.rs @@ -3,6 +3,41 @@ use iris::harness::{Harness, assert_corners}; use iris::prelude::*; +#[test] +fn scrollable_enables_a_region_node_but_raw_scroll_does_not() { + let mut h = Harness::new((100, 100)); + let default_child = ().add(&mut h.rsc); + let _default = default_child.scrollable().add(&mut h.rsc); + assert!(h.rsc.widgets().is_region_node(default_child)); + h.rsc.widgets_mut().set_region_node(default_child, false); + assert!(!h.rsc.widgets().is_region_node(default_child)); + + let raw_child = ().add(&mut h.rsc); + let _raw = Scroll::new(raw_child.add_strong(&mut h.rsc), Axis::Y).add(&mut h.rsc); + assert!(!h.rsc.widgets().is_region_node(raw_child)); + + let explicit = ().region_node().add(&mut h.rsc); + assert!(h.rsc.widgets().is_region_node(explicit)); +} + +#[test] +fn a_scrollable_child_can_drop_its_region_node() { + let mut h = Harness::new((400, 200)); + let top = rect(Color::RED).height(200).add(&mut h.rsc); + let bottom = rect(Color::BLUE).height(200).add(&mut h.rsc); + let content = (top, bottom).span(Dir::DOWN).add(&mut h.rsc); + h.set_root(content.scrollable()); + h.rsc.widgets_mut().set_region_node(content, false); + h.frame(); + + h.move_to((200, 100)); + h.scroll((0, 1)); + h.frame(); + + assert!(!h.rsc.widgets().is_region_node(content)); + assert_corners!(h, top, (0, -150), (400, 50)); +} + #[test] fn a_wheel_scrolls_the_content_and_stops_at_its_end() { let mut h = Harness::new((400, 200)); diff --git a/tests/trace_unsettled.rs b/tests/trace_unsettled.rs index c866caa..6150514 100644 --- a/tests/trace_unsettled.rs +++ b/tests/trace_unsettled.rs @@ -62,8 +62,8 @@ fn dump(label: &str, report: &diag::Report, text: WidgetId) { TraceEvent::SizeRead { id, reader, size } if *id == text => { println!(" size read by {reader:?}: {size}") } - TraceEvent::Placed { id, parent, region } if *id == text => { - println!(" placed by {parent:?} at {region:?}") + TraceEvent::RegionNode { id, parent, region } if *id == text => { + println!(" region node under {parent:?} at {region:?}") } TraceEvent::Reuse { id, outcome } if *id == text => println!(" reuse: {outcome:?}"), _ => {} diff --git a/tests/unsettled.rs b/tests/unsettled.rs index baa02ac..ee1deab 100644 --- a/tests/unsettled.rs +++ b/tests/unsettled.rs @@ -216,8 +216,8 @@ fn swapping_two_children_lands_where_growing_them_that_way_does() { /// Eight widgets, shrunk from 80. The scroll decides how wide to make its /// content from what the content says, and hands that box down through a -/// pass-through; the span under it was placed once, in that box, so nothing -/// at its own edge says the box was its own answer. +/// pass-through; the span under it was given that box once, so nothing at its +/// own edge says the box was its own answer. fn plant_scrolled(h: &mut Harness, swapped: bool) -> (Vec, [WeakWidget; 2]) { let words = "Wrapping shapes one source into as many lines as the box leaves room for,"; let text = wtext(words).size(16).wrap(true).add(&mut h.rsc); @@ -277,7 +277,7 @@ fn plant_scrolled(h: &mut Harness, swapped: bool) -> (Vec, [WeakWidget } #[test] -fn a_span_placed_once_in_a_box_its_answer_decided() { +fn a_span_given_the_box_its_answer_decided_matches_a_cold_layout() { let mut warm = Harness::new((640, 900)); let (ids, spans) = plant_scrolled(&mut warm, false); warm.frame();