From c44bd198ee58ec9cec885f86e76893d64dcc2997 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Thu, 17 Sep 2026 16:40:54 -0400 Subject: [PATCH] Retain child frames relative to the container extent --- core/src/ui/active.rs | 17 ++- core/src/ui/painter.rs | 109 ++++++++++++------ core/src/ui/render_state.rs | 29 +++-- src/widget/position/pad.rs | 2 +- src/widget/position/stack.rs | 12 +- tests/cases/retained.rs | 209 +++++++++++++++++++++++++++++++++++ 6 files changed, 316 insertions(+), 62 deletions(-) diff --git a/core/src/ui/active.rs b/core/src/ui/active.rs index 10de432..7cb56f7 100644 --- a/core/src/ui/active.rs +++ b/core/src/ui/active.rs @@ -41,7 +41,7 @@ pub struct ActiveData { pub textures: Vec, pub primitives: Vec, pub mask_region: Option, - pub inherited_children: Vec, + pub(crate) extent_children: Vec<(WidgetId, ExtentPlacement)>, pub children: Vec, /// The children whose size this widget read while drawing. pub size_deps: Vec, @@ -87,3 +87,18 @@ impl ActiveData { }) } } + +#[derive(Clone, Copy, Debug)] +pub(crate) enum ExtentPlacement { + Inherit, + Within(UiRegion), +} + +impl ExtentPlacement { + pub fn resolve(self, extent: UiRegion) -> (UiRegion, [Option; 2]) { + match self { + Self::Inherit => (UiRegion::FULL, [Some(extent.x), Some(extent.y)]), + Self::Within(local) => (local.within(&extent), [None; 2]), + } + } +} diff --git a/core/src/ui/painter.rs b/core/src/ui/painter.rs index aee438c..bd829e4 100644 --- a/core/src/ui/painter.rs +++ b/core/src/ui/painter.rs @@ -1,9 +1,9 @@ #[cfg(feature = "layout-diagnostics")] use crate::layout_diagnostics::{self as diag, Counter}; use crate::{ - Axis, DrawRegion, Holds, LayoutLen, Len, Px, PxVec2, RegionAlign, RenderedText, - RetainedPrimitive, Size, StrongWidget, TextAttrs, TextBuffer, TextData, TextureHandle, - UiRegion, UiRenderState, UiRsc, UiSpan, UiVec2, Weight, WidgetId, Widgets, + Axis, DrawRegion, ExtentPlacement, Holds, LayoutLen, Len, Px, PxVec2, RegionAlign, + RenderedText, RetainedPrimitive, Size, StrongWidget, TextAttrs, TextBuffer, TextData, + TextureHandle, UiRegion, UiRenderState, UiRsc, UiSpan, UiVec2, Weight, WidgetId, Widgets, render::{ GlyphPrimitive, Mask, MaskIdx, MoveIdx, Primitive, PrimitiveInst, PrimitiveKind, TexturePrimitive, @@ -40,7 +40,7 @@ pub struct Painter<'a> { pub(super) textures: Vec, pub(super) primitives: Vec, pub(super) mask_region: Option, - pub(super) inherited_children: Vec, + pub(super) extent_children: Vec<(WidgetId, ExtentPlacement)>, pub(super) extent_own: [Holds; 2], pub(super) extent_under: [Holds; 2], pub(super) children: Vec, @@ -154,7 +154,13 @@ 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, false) + self.widget_at_inner( + id, + UiRegion::FULL, + [Some(own.x), Some(own.y)], + Some(ExtentPlacement::Inherit), + false, + ) } /// What a widget's rules declare its lengths to be, which whoever draws @@ -170,20 +176,29 @@ impl<'a> Painter<'a> { /// this frame; what it answered is still something this widget asked. pub fn undraw(&mut self, id: &StrongWidget) { self.children.retain(|child| *child != id.id()); - self.inherited_children.retain(|child| *child != id.id()); + self.extent_children.retain(|(child, _)| *child != id.id()); self.state.undraw_rec(id.id(), self.rsc); } - /// Draws a widget somewhere within this one. `region` is in this widget's - /// own region, and is the child's own region: what its declared lengths - /// and its report are fractions of. Where its drawing sits inside that is - /// its own answer placed by its alignment. + /// Draws a child in a frame relative to this widget's frame or extent. + /// A plain `UiRegion` is frame-relative. `DrawRegion::Extent` keeps the + /// child's frame attached to the extent without reading `placement()`. + /// The child places its answer within that frame by its own alignment. pub fn widget_within<'s, W: ?Sized>( &'s mut self, id: &'s StrongWidget, - region: UiRegion, + region: impl Into, ) -> DrawResult<'s, 'a, W> { - self.widget_at(id, region, [None; 2]) + match region.into() { + DrawRegion::Frame(region) => self.widget_at(id, region, [None; 2]), + DrawRegion::Extent(local) => self.widget_at_inner( + id, + local.within(&self.placement), + [None; 2], + Some(ExtentPlacement::Within(local)), + false, + ), + } } /// Draws a widget in `region`, saying where in it the drawing goes. @@ -206,7 +221,7 @@ impl<'a> Painter<'a> { region: UiRegion, placement: [Option; 2], ) -> DrawResult<'s, 'a, W> { - self.widget_at_inner(id, region, placement, false, false) + self.widget_at_inner(id, region, placement, None, false) } fn widget_at_inner<'s, W: ?Sized>( @@ -214,15 +229,12 @@ impl<'a> Painter<'a> { id: &'s StrongWidget, region: UiRegion, placement: [Option; 2], - inherited: bool, + extent: Option, measuring: bool, ) -> DrawResult<'s, 'a, W> { - if inherited { - if !self.inherited_children.contains(&id.id()) { - self.inherited_children.push(id.id()); - } - } else { - self.inherited_children.retain(|child| *child != id.id()); + self.extent_children.retain(|(child, _)| *child != id.id()); + if let Some(extent) = extent { + self.extent_children.push((id.id(), extent)); } let region_node = self.rsc.widgets().is_region_node(id.id()); let declared = self.declared_lens(id); @@ -285,28 +297,53 @@ impl<'a> Painter<'a> { measuring, self.rsc, ); - // Whatever the child's answer holds for keeps this one to the boxes - // that give the child a length inside it. for axis in AXES { let n = axis as usize; - let frame = holds.frame[n].through(local.axis(axis).len()); - self.under[n] = self.under[n].and(frame); - if inherited && declared[n].is_none() { - self.extent_under[n] = self.extent_under[n].and(holds.extent[n]); - self.reads_placement |= holds.placement.is_some(); - } else { - let extent = placement[n].unwrap_or(UiSpan::FULL).len(); - self.under[n] = self.under[n].and( - holds.extent[n] - .through(extent) - .through(local.axis(axis).len()), - ); + match extent { + Some(ExtentPlacement::Inherit) if declared[n].is_none() => { + self.under[n] = + self.under[n].and(holds.frame[n].through(local.axis(axis).len())); + self.extent_under[n] = self.extent_under[n].and(holds.extent[n]); + self.reads_placement |= holds.placement.is_some(); + } + Some(ExtentPlacement::Within(part)) + if declared[n].is_none() + && part.axis(axis).start.rel == crate::Rel::ZERO + && part.axis(axis).end.rel == crate::Rel::ONE => + { + let dependent = holds.frame[n] + .and(holds.extent[n]) + .through(part.axis(axis).len()); + self.extent_under[n] = self.extent_under[n].and(dependent); + } + _ => { + let chosen = placement[n].unwrap_or(UiSpan::FULL).len(); + self.under[n] = self.under[n] + .and(holds.frame[n].through(local.axis(axis).len())) + .and( + holds.extent[n] + .through(chosen) + .through(local.axis(axis).len()), + ); + // Fractional endpoints compose before pixel evaluation. Their + // difference cannot be inverted through the extent length alone. + if matches!(extent, Some(ExtentPlacement::Within(_))) && declared[n].is_none() { + self.reads_placement = true; + } + } } } + // A fractional report is composed into the parent frame, so its + // value can change with the extent even when the drawing holds. + let reads_placement = matches!(extent, Some(ExtentPlacement::Within(_))) + && AXES.into_iter().any(|axis| { + declared[axis as usize].is_none() && size.axis(axis).rel != crate::Rel::ZERO + }); DrawResult { child: id, painter: self, size: in_parent_frame(size, local.size(), declared), + reads_placement, } } @@ -363,7 +400,7 @@ impl<'a> Painter<'a> { .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) + .widget_at_inner(child, region, offered, None, true) .len(axis); }; #[cfg(feature = "layout-diagnostics")] @@ -604,6 +641,7 @@ pub struct DrawResult<'p, 'a, W: ?Sized> { painter: &'p mut Painter<'a>, child: &'p StrongWidget, size: Size, + reads_placement: bool, } impl DrawResult<'_, '_, W> { @@ -614,6 +652,7 @@ impl DrawResult<'_, '_, W> { diag::size_read(self.child.id(), self.painter.id, self.size); } self.painter.depend_on(self.child); + self.painter.reads_placement |= self.reads_placement; self.size } diff --git a/core/src/ui/render_state.rs b/core/src/ui/render_state.rs index 1d7b688..6b64539 100644 --- a/core/src/ui/render_state.rs +++ b/core/src/ui/render_state.rs @@ -300,7 +300,7 @@ impl UiRenderState { && let Some(old_parent) = self.active.get_mut(&old_parent) { old_parent.children.retain(|child| *child != id); - old_parent.inherited_children.retain(|child| *child != id); + old_parent.extent_children.retain(|(child, _)| *child != id); } settled } @@ -373,7 +373,7 @@ impl UiRenderState { textures: Vec::new(), primitives: Vec::new(), mask_region: None, - inherited_children: Vec::new(), + extent_children: Vec::new(), children: Vec::new(), offered: Vec::new(), offered_px: info.offered_px, @@ -410,7 +410,7 @@ impl UiRenderState { textures, primitives, mask_region, - inherited_children, + extent_children, extent_own, extent_under, children, @@ -516,7 +516,7 @@ impl UiRenderState { textures, primitives, mask_region, - inherited_children, + extent_children, children, size_deps, declared: declared_lens(rsc.widgets(), id), @@ -765,19 +765,18 @@ impl UiRenderState { } let parent_move = active.move_idx; let mask = active.mask; - let children = active.inherited_children.len(); + let children = active.extent_children.len(); for index in 0..children { - let child = self.active[&id].inherited_children[index]; + let (child, extent) = self.active[&id].extent_children[index]; + let (child_region, chosen) = extent.resolve(placement); let active = &self.active[&child]; - let (child_local, chosen) = ask_box( - UiRegion::FULL, - active.declared, - active.own_align, - [Some(placement.x), Some(placement.y)], - ); + let (child_local, chosen) = + ask_box(child_region, active.declared, active.own_align, chosen); + // Keep the slot chosen from measurement: the final draw can + // report a different size, for example after text reflows. let child_placement = UiRegion { - x: chosen[0].unwrap_or(UiSpan::FULL), - y: chosen[1].unwrap_or(UiSpan::FULL), + x: chosen[0].unwrap_or(active.placement.x), + y: chosen[1].unwrap_or(active.placement.y), }; let child_info = DrawInfo { layer: active.layer, @@ -937,7 +936,7 @@ impl UiRenderState { textures: Vec::new(), primitives: Vec::new(), mask_region: None, - inherited_children: Vec::new(), + extent_children: Vec::new(), children: Vec::new(), size_deps: Vec::new(), move_idx: info.parent_move, diff --git a/src/widget/position/pad.rs b/src/widget/position/pad.rs index 1257c26..5782b02 100644 --- a/src/widget/position/pad.rs +++ b/src/widget/position/pad.rs @@ -13,7 +13,7 @@ impl Widget for Pad { // it; where the box is bigger -- a share of a row, a rule over this // widget -- the slack is the inner's to sit in, and forcing the near // edge pinned it to a corner it had not asked for. - let inside = self.padding.region_of(painter.placement()); + let inside = DrawRegion::Extent(self.padding.region()); let inner = painter.widget_within(&self.inner, inside).size(); Size { x: LayoutLen { diff --git a/src/widget/position/stack.rs b/src/widget/position/stack.rs index ef19b47..a14e389 100644 --- a/src/widget/position/stack.rs +++ b/src/widget/position/stack.rs @@ -13,8 +13,6 @@ impl Widget for Stack { StackSize::Default => None, StackSize::Child(i) => Some(i), }; - // This stack's own box, which is `FULL` until its answer is known. - let placement = painter.placement(); // Whichever child sizes the stack keeps the stack's whole region as // its own -- the stack is the length that child asked for, so taking // the fraction of the stack's box again would take it twice -- and is @@ -25,13 +23,7 @@ impl Widget for Stack { // drawing belongs to the layer it was made on. Some((i, child)) => { painter.child_layer_at(i); - painter - .widget_at( - child, - UiRegion::FULL, - [Some(placement.x), Some(placement.y)], - ) - .size() + painter.widget(child).size() } None => Size::LEFTOVER, }; @@ -43,7 +35,7 @@ impl Widget for Stack { // Every other child has the stack's own box for its region, since // the stack is what contains it, and where it sits in one bigger // than itself is its own business. - painter.widget_within(child, placement); + painter.widget_within(child, DrawRegion::Extent(UiRegion::FULL)); } size } diff --git a/tests/cases/retained.rs b/tests/cases/retained.rs index d07a6c5..0485748 100644 --- a/tests/cases/retained.rs +++ b/tests/cases/retained.rs @@ -1020,3 +1020,212 @@ fn glyph_origins_compose_identically_when_drawn_and_when_retained() { } } } + +#[test] +fn padding_and_stack_frames_follow_the_extent_without_drawing_again() { + struct Observed { + widget: W, + draws: Rc>, + } + impl Widget for Observed { + fn draw(&mut self, painter: &mut Painter) -> Size { + self.draws.set(self.draws.get() + 1); + self.widget.draw(painter) + } + } + struct Frame { + child: StrongWidget, + extent: UiRegion, + } + impl Widget for Frame { + fn draw(&mut self, painter: &mut Painter) -> Size { + painter.widget_at( + &self.child, + UiRegion::FULL, + [Some(self.extent.x), Some(self.extent.y)], + ); + Size::LEFTOVER + } + } + for node in [false, true] { + let plant = |h: &mut Harness, extent| { + let draws = Rc::new(Cell::new(0)); + let leaf = rect(Color::BLUE).masked().add(&mut h.rsc); + h.rsc.widgets_mut().set_region_node(leaf, node); + let fixed = rect(Color::RED).width(31).height(19).add(&mut h.rsc); + let stack = Observed { + widget: Stack { + children: vec![leaf.add_strong(&mut h.rsc), fixed.add_strong(&mut h.rsc)], + size: StackSize::Default, + }, + draws: draws.clone(), + } + .add_strong(&mut h.rsc); + let pad = Observed { + widget: Pad { + inner: stack, + padding: Padding::uniform(7).with_left(13), + }, + draws: draws.clone(), + } + .add_strong(&mut h.rsc); + let root = Frame { child: pad, extent }.add(&mut h.rsc); + h.set_root(root); + (root, leaf, fixed, draws) + }; + let mut warm = Harness::new((403, 211)); + let (root, leaf, fixed, draws) = plant(&mut warm, UiRegion::FULL); + for (start, end) in [(0.13, 0.83), (-0.17, 1.23), (0.31, 0.67)] { + let extent = UiRegion::new( + UiSpan::new(Len::rel(start) + Len::px(3.125), Len::rel(end)), + UiSpan::new(Len::px(11.25), Len::rel(end)), + ); + let before = draws.get(); + warm.rsc[root].extent = extent; + warm.frame(); + assert_eq!(draws.get(), before); + let mut cold = Harness::new((403, 211)); + let (_, other, other_fixed, _) = plant(&mut cold, extent); + for (a, b) in [(leaf.id(), other.id()), (fixed.id(), other_fixed.id())] { + assert_eq!(warm.region(&a), cold.region(&b)); + assert_eq!(primitive_bounds(&warm, a), primitive_bounds(&cold, b)); + } + let mask = |h: &Harness, id: WidgetId| { + let active = &h.render.active[&id]; + let mask = &h.rsc.ui().masks[active.mask.idx()]; + h.render + .moves + .resolve(mask.move_idx, mask.region) + .to_px(h.render.output_size()) + }; + assert_eq!(mask(&warm, leaf.id()), mask(&cold, other.id())); + } + } +} + +#[test] +fn moving_an_extent_child_preserves_the_slot_chosen_from_its_measurement() { + struct Measured; + impl Widget for Measured { + fn draw(&mut self, painter: &mut Painter) -> Size { + let width = painter.px_len(Axis::X); + painter.primitive(RectPrimitive::color(Color::BLUE)); + Size::from((80, if width > Px::from_int(100) { 40 } else { 60 })) + } + } + struct Frame { + child: StrongWidget, + start: f32, + } + impl Widget for Frame { + fn draw(&mut self, painter: &mut Painter) -> Size { + painter.widget_at( + &self.child, + UiRegion::FULL, + [ + Some(UiSpan::new( + Len::px(self.start), + Len::px(self.start + 200.0), + )), + Some(UiSpan::FULL), + ], + ); + Size::LEFTOVER + } + } + let mut h = Harness::new((400, 200)); + let leaf = Measured.add(&mut h.rsc); + let stack = (leaf,).stack().add_strong(&mut h.rsc); + let root = Frame { + child: stack, + start: 0.0, + } + .add(&mut h.rsc); + h.set_root(root); + assert_corners!(h, leaf, (60, 80), (140, 120)); + h.rsc[root].start = 30.0; + h.frame(); + assert_corners!(h, leaf, (90, 80), (170, 120)); + assert_eq!( + primitive_bounds(&h, leaf.id()), + vec![h.region(&leaf).unwrap()] + ); +} + +#[test] +fn extent_frames_keep_fractional_reports_and_numeric_dependencies_valid() { + struct Container { + child: StrongWidget, + region: UiRegion, + } + impl Widget for Container { + fn draw(&mut self, painter: &mut Painter) -> Size { + painter + .widget_within(&self.child, DrawRegion::Extent(self.region)) + .size() + } + } + struct Frame { + child: StrongWidget, + extent: UiRegion, + answer: Rc>, + } + impl Widget for Frame { + fn draw(&mut self, painter: &mut Painter) -> Size { + self.answer.set( + painter + .widget_at( + &self.child, + UiRegion::FULL, + [Some(self.extent.x), Some(self.extent.y)], + ) + .size(), + ); + Size::LEFTOVER + } + } + for fractional in [false, true] { + for region in [ + UiRegion::FULL, + UiRegion::new(UiSpan::new(Len::rel(0.13), Len::rel(0.79)), UiSpan::FULL), + ] { + let plant = |h: &mut Harness, extent| { + let size = if fractional { + Size { + x: rel(0.5), + y: LayoutLen::px(27), + } + } else { + Size::from((80, 27)) + }; + let (leaf, _) = counted(h, size, !fractional); + let child = Container { + child: leaf.add_strong(&mut h.rsc), + region, + } + .add_strong(&mut h.rsc); + let answer = Rc::new(Cell::new(Size::ZERO)); + let root = Frame { + child, + extent, + answer: answer.clone(), + } + .add(&mut h.rsc); + h.set_root(root); + (root, leaf, answer) + }; + let mut warm = Harness::new((403, 211)); + let (root, leaf, answer) = plant(&mut warm, UiRegion::FULL); + for width in [191.125, 297.25, 83.75] { + let extent = + UiRegion::new(UiSpan::new(Len::px(13.125), Len::px(width)), UiSpan::FULL); + warm.rsc[root].extent = extent; + warm.frame(); + let mut cold = Harness::new((403, 211)); + let (_, other, other_answer) = plant(&mut cold, extent); + assert_eq!(answer.get(), other_answer.get()); + assert_eq!(warm.region(&leaf), cold.region(&other)); + } + } + } +}