Replace placement calls with region nodes

This commit is contained in:
iris-ai committed 2026-09-15 18:02:28 -04:00
1 parent f437495309
commit 71c9c39523
23 files changed
+374 -170

No files matched your search

+11 -11
View File
@@ -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<WidgetId>,
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<WidgetId>,
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) {
+3 -3
View File
@@ -32,14 +32,14 @@ pub struct ActiveData {
pub children: Vec<WidgetId>,
/// The children whose size this widget read while drawing.
pub size_deps: Vec<WidgetId>,
/// 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<Len>; 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,
+30 -13
View File
@@ -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<f32>) -> 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<RangeInclusive<f32>> for Holds {
fn from(range: RangeInclusive<f32>) -> 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()));
}
}
+11 -28
View File
@@ -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<W>) -> 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<W>,
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<W>,
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<W>,
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<f32>) {
let holds = Holds::from(range);
pub fn holds(&mut self, axis: Axis, holds: impl Into<Holds>) {
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",
+134 -49
View File
@@ -17,8 +17,7 @@ pub(super) struct DrawInfo {
pub parent: Option<WidgetId>,
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<Self> {
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()
+6 -1
View File
@@ -35,7 +35,7 @@ impl<T, I: IdNum> Arena<T, I> {
self.data[i]
}
pub fn get_mut(&mut self, id: Id<I>) -> &mut T {
pub(crate) fn get_mut(&mut self, id: Id<I>) -> &mut T {
&mut self.data[id.idx()]
}
}
@@ -75,6 +75,11 @@ impl<T, I: IdNum> TrackedArena<T, I> {
self.refs[i.idx()] += 1;
}
pub fn get_mut(&mut self, id: Id<I>) -> &mut T {
self.changed = true;
self.inner.get_mut(id)
}
pub fn remove(&mut self, id: Id<I>) -> T
where
T: Copy,
+2
View File
@@ -3,6 +3,7 @@ use crate::Widget;
pub struct WidgetData {
pub widget: Box<dyn Widget>,
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,
}
}
+18
View File
@@ -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())
}