Retain layout sizes by pixel axis

This commit is contained in:
iris-ai committed 2026-09-14 17:47:21 -04:00
1 parent 82fa6c1123
commit b1b3eca1c0
11 files changed
+371 -69

No files matched your search

+2
View File
@@ -33,6 +33,7 @@ pub(crate) enum Counter {
SizeReads,
HintHits,
HintMisses,
RetainedSizeHits,
ReuseAttempts,
ReuseExact,
ReuseMoved,
@@ -67,6 +68,7 @@ impl Counter {
"draw-result size reads",
"hint hits",
"hint misses",
"retained size hits",
"reuse attempts",
"reuse exact",
"reuse moved",
+10 -2
View File
@@ -19,8 +19,16 @@ pub struct ActiveData {
pub children: Vec<WidgetId>,
/// The children whose size this widget read while drawing.
pub size_deps: Vec<WidgetId>,
/// Whether it read the output's size, and so is wrong when that changes.
pub reads_output: bool,
/// Offered pixel axes which flowed into this widget's reported size,
/// directly or through a child size it read.
pub size_box_inputs: [bool; 2],
/// Output axes read while producing `size`, distinct from the widget's
/// own box when that box has a fixed pixel length.
pub size_output_inputs: [bool; 2],
/// The output dimensions against which those dependencies were observed.
pub output_px: Vec2,
/// Output axes it read directly or while resolving its offered box.
pub reads_output: [bool; 2],
/// The slot its primitives are positioned through: its own if its parent
/// placed it, otherwise the nearest ancestor that has one.
pub move_idx: MoveIdx,
+87 -6
View File
@@ -23,7 +23,10 @@ pub struct Painter<'a> {
pub(super) children: Vec<WidgetId>,
/// The children whose size this widget read while drawing.
pub(super) size_deps: Vec<WidgetId>,
pub(super) reads_output: bool,
/// Offered pixel axes which can affect the size this draw reports.
pub(super) size_box_inputs: [bool; 2],
pub(super) size_output_inputs: [bool; 2],
pub(super) reads_output: [bool; 2],
/// The slot this widget's primitives are positioned through: its own if
/// its parent placed it, otherwise the nearest ancestor that has one.
pub(super) move_idx: MoveIdx,
@@ -158,7 +161,7 @@ impl<'a> Painter<'a> {
Some(hint) => {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::HintHits);
self.depend_on_size(id);
self.depend_on_size(id, false);
Some(hint)
}
None => {
@@ -169,10 +172,66 @@ impl<'a> Painter<'a> {
}
}
fn depend_on_size<W: ?Sized>(&mut self, child: &StrongWidget<W>) {
/// A retained child length valid under the region it is about to be
/// offered. Unlike a hint, this is contextual: it is kept only when none
/// of the offered pixel axes which produced it changed.
pub fn known_len<W: ?Sized>(
&mut self,
child: &StrongWidget<W>,
axis: Axis,
region: UiRegion,
) -> Option<Len> {
if let Some(hint) = self.size_hint(child, axis) {
return Some(hint);
}
self.retained_size(child, region)
.map(|size| size.axis(axis))
}
fn retained_size<W: ?Sized>(
&mut self,
child: &StrongWidget<W>,
region: UiRegion,
) -> Option<Size> {
let region = region.within(&self.region);
let (size, box_inputs, output_inputs) =
self.state
.retained_size(child.id(), region, self.move_idx, self.rsc.widgets())?;
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::RetainedSizeHits);
self.depend_on_size_inputs(child, box_inputs, output_inputs);
Some(size)
}
fn depend_on_size<W: ?Sized>(&mut self, child: &StrongWidget<W>, inherit_inputs: bool) {
let (box_inputs, output_inputs) = match inherit_inputs {
true => self
.state
.active
.get(&child.id())
.map_or(([false; 2], [false; 2]), |active| {
(active.size_box_inputs, active.size_output_inputs)
}),
false => ([false; 2], [false; 2]),
};
self.depend_on_size_inputs(child, box_inputs, output_inputs);
}
fn depend_on_size_inputs<W: ?Sized>(
&mut self,
child: &StrongWidget<W>,
box_inputs: [bool; 2],
output_inputs: [bool; 2],
) {
if !self.size_deps.contains(&child.id()) {
self.size_deps.push(child.id());
}
for (own, child) in self.size_box_inputs.iter_mut().zip(box_inputs) {
*own |= child;
}
for (own, child) in self.size_output_inputs.iter_mut().zip(output_inputs) {
*own |= child;
}
}
pub fn render_text(
@@ -220,19 +279,41 @@ impl<'a> Painter<'a> {
/// The output's size in pixels. A widget that reads it draws again when
/// the output changes, since nothing else can put that right.
pub fn output_size(&mut self) -> Vec2 {
self.reads_output = true;
self.reads_output = [true; 2];
self.size_output_inputs = [true; 2];
self.state.output_size
}
/// One axis of the output in pixels. Prefer this to [`Self::output_size`]
/// when the other axis cannot affect the size this widget reports.
pub fn output_len(&mut self, axis: Axis) -> f32 {
self.reads_output[axis as usize] = true;
self.size_output_inputs[axis as usize] = true;
self.state.output_size.axis(axis)
}
/// This widget's box in pixels. Resolved against the output's size and
/// the boxes it sits within, so a widget that reads it draws again when
/// the output changes.
pub fn px_size(&mut self) -> Vec2 {
self.reads_output = true;
self.reads_output = [true; 2];
self.size_box_inputs = [true; 2];
let region = self.state.moves.resolve(self.move_idx, self.region);
region.size().to_abs(self.state.output_size)
}
/// One axis of this widget's box in pixels. Prefer this to
/// [`Self::px_size`] when the other axis cannot affect the reported size.
pub fn px_len(&mut self, axis: Axis) -> f32 {
self.reads_output[axis as usize] = true;
self.size_box_inputs[axis as usize] = true;
let region = self.state.moves.resolve(self.move_idx, self.region);
region
.size()
.axis(axis)
.to_abs(self.state.output_size.axis(axis))
}
pub fn text_data(&mut self) -> &mut TextData {
&mut self.rsc.ui_mut().text
}
@@ -270,7 +351,7 @@ impl<W: ?Sized> DrawResult<'_, '_, W> {
diag::bump(Counter::SizeReads);
diag::size_read(self.child.id(), self.painter.id, self.size);
}
self.painter.depend_on_size(self.child);
self.painter.depend_on_size(self.child, true);
self.size
}
+128 -19
View File
@@ -7,6 +7,11 @@ use crate::{
};
const AXES: [Axis; 2] = [Axis::X, Axis::Y];
const LAYOUT_EPSILON_PX: f32 = 0.05;
fn pixel_len_changed(old: f32, new: f32) -> bool {
(old - new).abs() > LAYOUT_EPSILON_PX
}
pub struct UiRenderState {
pub active: HashMap<WidgetId, ActiveData>,
@@ -14,7 +19,14 @@ pub struct UiRenderState {
pub(super) output_size: Vec2,
old_root: Option<WidgetId>,
resized: bool,
resized: [bool; 2],
/// Content/state dirtiness whose retained size cannot answer a layout
/// question until that widget has drawn again.
invalid_sizes: HashSet<WidgetId>,
/// Marks introduced only to traverse resize dependency paths. Unlike
/// content dirtiness, these may retain an answer whose observed pixel
/// axes did not change.
resize_marks: HashSet<WidgetId>,
draw_started: HashSet<WidgetId>,
/// A widget's move slot, which outlives any one `ActiveData`: a redraw
/// replaces that while its children go on pointing at the slot.
@@ -29,7 +41,9 @@ impl UiRenderState {
layers: Default::default(),
output_size: Vec2::ZERO,
old_root: None,
resized: false,
resized: [false; 2],
invalid_sizes: Default::default(),
resize_marks: Default::default(),
draw_started: Default::default(),
slots: Default::default(),
moves: Default::default(),
@@ -38,10 +52,10 @@ impl UiRenderState {
pub fn resize(&mut self, size: impl Into<Vec2>) {
let size = size.into();
if size != self.output_size {
self.output_size = size;
self.resized = true;
for (axis, resized) in AXES.into_iter().zip(self.resized.iter_mut()) {
*resized |= size.axis(axis) != self.output_size.axis(axis);
}
self.output_size = size;
}
pub fn output_size(&self) -> Vec2 {
@@ -53,6 +67,10 @@ impl UiRenderState {
diag::bump(Counter::Updates);
#[cfg(feature = "layout-diagnostics")]
let _update = diag::timer(TimerKind::Update);
self.invalid_sizes.clear();
self.invalid_sizes
.extend(rsc.widgets().needs_redraw.iter().copied());
self.resize_marks.clear();
// safety mechanism for memory leaks; might wanna return a result instead so user can
// decide whether to panic or not
if !rsc.widgets().waiting.is_empty() {
@@ -73,7 +91,7 @@ impl UiRenderState {
if self.root_changed(root) {
self.redraw_all(root, rsc);
self.old_root = root.map(|r| r.id());
} else if self.resized {
} else if self.resized.iter().any(|&resized| resized) {
// A region is a fraction of the output plus an offset, resolved
// against the window in the shader, so a resize moves the whole
// drawing on its own. Only a widget that read pixels can be wrong.
@@ -83,7 +101,19 @@ impl UiRenderState {
let dependents: Vec<_> = self
.active
.iter()
.filter_map(|(&id, active)| active.reads_output.then_some(id))
.filter_map(|(&id, active)| {
AXES.into_iter()
.zip(self.resized)
.any(|(axis, changed)| {
changed
&& active.reads_output[axis as usize]
&& pixel_len_changed(
active.output_px.axis(axis),
self.output_size.axis(axis),
)
})
.then_some(id)
})
.collect();
for id in dependents {
#[cfg(feature = "layout-diagnostics")]
@@ -93,12 +123,21 @@ impl UiRenderState {
rsc.widgets_mut().needs_redraw.insert(top);
}
}
self.resize_marks.extend(
rsc.widgets()
.needs_redraw
.iter()
.filter(|id| !self.invalid_sizes.contains(id))
.copied(),
);
}
}
if rsc.widgets().has_updates() {
self.redraw_updates(rsc);
}
self.resized = false;
self.resized = [false; 2];
self.invalid_sizes.clear();
self.resize_marks.clear();
}
fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) {
@@ -174,7 +213,9 @@ impl UiRenderState {
primitives: Vec::new(),
children: Vec::new(),
size_deps: Vec::new(),
reads_output: false,
size_box_inputs: [false; 2],
size_output_inputs: [false; 2],
reads_output: [false; 2],
move_idx,
rsc,
};
@@ -199,6 +240,8 @@ impl UiRenderState {
primitives,
children,
size_deps,
size_box_inputs,
size_output_inputs,
reads_output,
move_idx,
layer,
@@ -222,13 +265,15 @@ impl UiRenderState {
primitives,
children,
size_deps,
size_box_inputs,
size_output_inputs,
output_px: self.output_size,
reads_output,
move_idx,
parent_move,
mask,
layer,
};
// remove old children that weren't kept
for c in &old_children {
if !active.children.contains(c) {
@@ -238,6 +283,8 @@ impl UiRenderState {
rsc.on_draw(&active);
self.active.insert(id, active);
self.invalid_sizes.remove(&id);
self.resize_marks.remove(&id);
size
}
@@ -272,6 +319,60 @@ impl UiRenderState {
.to_abs(self.output_size)
}
/// A clean widget's retained size, when the offered pixel axes which
/// produced that answer are unchanged. This observes the old answer only;
/// it does not move or otherwise reuse the widget's drawing.
pub(super) fn retained_size(
&self,
id: WidgetId,
region: UiRegion,
parent_move: MoveIdx,
widgets: &Widgets,
) -> Option<(Size, [bool; 2], [bool; 2])> {
if self.size_is_invalid(id, widgets) || self.dirty_size_under(id, widgets) {
return None;
}
let active = self.active.get(&id)?;
if active.parent_move != parent_move {
return None;
}
let px = self.px_of(parent_move, region);
let valid_box = AXES
.into_iter()
.zip(active.size_box_inputs)
.all(|(axis, depends)| {
!depends || !pixel_len_changed(active.px.axis(axis), px.axis(axis))
});
let valid_output =
AXES.into_iter()
.zip(active.size_output_inputs)
.all(|(axis, depends)| {
!depends
|| !pixel_len_changed(
active.output_px.axis(axis),
self.output_size.axis(axis),
)
});
(valid_box && valid_output).then_some((
active.size,
active.size_box_inputs,
active.size_output_inputs,
))
}
fn size_is_invalid(&self, id: WidgetId, widgets: &Widgets) -> bool {
self.invalid_sizes.contains(&id)
|| (widgets.needs_redraw.contains(&id) && !self.resize_marks.contains(&id))
}
fn dirty_size_under(&self, id: WidgetId, widgets: &Widgets) -> bool {
self.active.get(&id).is_some_and(|active| {
active.size_deps.iter().any(|child| {
self.size_is_invalid(*child, widgets) || self.dirty_size_under(*child, widgets)
})
})
}
/// The drawing a widget already has, kept for a new box if the box has not
/// changed in a way it depends on.
fn try_reuse(
@@ -310,7 +411,7 @@ impl UiRenderState {
let px = self.px_of(parent_move, region);
let mut changed = [false; 2];
for (axis, c) in AXES.into_iter().zip(changed.iter_mut()) {
*c = px.axis(axis) != old_px.axis(axis);
*c = pixel_len_changed(old_px.axis(axis), px.axis(axis));
}
if !changed.iter().any(|&c| c) && old_region == region {
#[cfg(feature = "layout-diagnostics")]
@@ -360,7 +461,6 @@ impl UiRenderState {
self.moves.set(slot, region);
let active = self.active.get_mut(&id).unwrap();
active.region = region;
active.px = px;
#[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::ReuseMoved);
@@ -472,6 +572,8 @@ impl UiRenderState {
self.slots.clear();
self.moves.clear();
self.layers.clear();
self.invalid_sizes.clear();
self.resize_marks.clear();
rsc.widgets_mut().needs_redraw.clear();
rsc.free();
}
@@ -486,7 +588,7 @@ impl UiRenderState {
// reader and gives each changing box its final constraints first.
while let Some(id) = {
let dirty = rsc.widgets().needs_redraw.iter().copied();
match self.resized {
match self.resized.iter().any(|&resized| resized) {
true => dirty.min_by_key(|&id| self.depth(id)),
false => dirty.max_by_key(|&id| self.depth(id)),
}
@@ -519,7 +621,9 @@ impl UiRenderState {
root: impl Into<Option<&'a StrongWidget>>,
widgets: &Widgets,
) -> bool {
self.root_changed(root) || self.resized || widgets.has_updates()
self.root_changed(root)
|| self.resized.iter().any(|&resized| resized)
|| widgets.has_updates()
}
pub fn active_widgets(&self) -> usize {
@@ -556,16 +660,20 @@ impl UiRenderState {
/// redraws a widget that's currently active (drawn)
pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) {
self.draw_started.remove(&id);
if rsc.widgets().needs_redraw.contains(&id) && !self.resize_marks.contains(&id) {
self.invalid_sizes.insert(id);
}
// A widget can only answer whether its size changed by drawing in the
// box its parent chose. If that box changed in pixels, its retained
// placement is stale and the highest size reader must choose the new
// box first. Otherwise the widget can draw locally, and its readers
// only matter if the returned size actually changed.
let box_changed = self
.active
.get(&id)
.is_some_and(|active| self.px_of(active.parent_move, active.region) != active.px);
if (self.resized || box_changed)
let box_changed = self.active.get(&id).is_some_and(|active| {
let px = self.px_of(active.parent_move, active.region);
AXES.into_iter()
.any(|axis| pixel_len_changed(active.px.axis(axis), px.axis(axis)))
});
if (self.resized.iter().any(|&resized| resized) || box_changed)
&& let Some(top) = self.mark_readers(id, rsc)
{
#[cfg(feature = "layout-diagnostics")]
@@ -611,6 +719,7 @@ impl UiRenderState {
// Propagate one dependency edge at a time. If drawing the reader
// does not change its own size, nothing above it can observe this.
rsc.widgets_mut().needs_redraw.insert(parent);
self.invalid_sizes.insert(parent);
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::ReaderEdges);
}