Files
iris/core/src/ui/render_state.rs
T
iris-aiandClaude Opus 5 bf9438087a Say what the settle order is holding up
`try_reuse` asks whether the widget in front of it is dirty and, if not,
hands its parent the size it last reported. Nothing asks whether a dirty
widget sits under it through the size dependencies -- which is the check
`retained_size` makes, for exactly this reason, on the path that does not
draw.

What covers the gap is the order `redraw_updates` settles in: taking the
deepest dirty widget first means that by the time a reader draws, what it
reads has already drawn and propagated. Drawing in any other order
returns a stale size. Measured rather than reasoned: picking whatever the
dirty set yields first fails seed 2 of `tests/generated.rs` with 24
widgets wrong, a subtree keeping a 317 px width where a cold tree has
147, and the traces are identical until a `Span` reports 317 against 147
from the same child sizes -- it had reused a subtree holding a `SetSize`
whose declared width had changed.

So the coupling is real and was written down nowhere. Say it in both
places, since a reader of either would otherwise conclude the order is
about cost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:57:08 -04:00

768 lines
28 KiB
Rust

#[cfg(feature = "layout-diagnostics")]
use crate::layout_diagnostics::{self as diag, Counter, ReuseOutcome, TimerKind};
use crate::{
ActiveData, Axis, DrawLayers, IdLike, MaskIdx, MoveIdx, Moves, OnResize, Painter, PixelRegion,
Size, StrongWidget, UiRegion, UiRsc, WidgetId, Widgets,
util::{HashMap, HashSet, Vec2},
};
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>,
pub layers: DrawLayers,
pub(super) output_size: Vec2,
old_root: Option<WidgetId>,
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>,
/// What has already been drawn during the pass under way, so a widget
/// reached by redrawing an ancestor is not drawn again on its own
/// account. Emptied when the pass ends.
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.
slots: HashMap<WidgetId, MoveIdx>,
pub moves: Moves,
}
impl UiRenderState {
pub fn new() -> Self {
Self {
active: Default::default(),
layers: Default::default(),
output_size: Vec2::ZERO,
old_root: None,
resized: [false; 2],
invalid_sizes: Default::default(),
resize_marks: Default::default(),
draw_started: Default::default(),
slots: Default::default(),
moves: Default::default(),
}
}
pub fn resize(&mut self, size: impl Into<Vec2>) {
let size = size.into();
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 {
self.output_size
}
pub fn update<'a>(&mut self, root: impl Into<Option<&'a StrongWidget>>, rsc: &mut dyn UiRsc) {
#[cfg(feature = "layout-diagnostics")]
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() {
let widgets = rsc.widgets();
let len = widgets.waiting.len();
let all: Vec<_> = widgets
.waiting
.iter()
.map(|&w| format!("'{}' ({w:?})", widgets.label(w)))
.collect();
panic!(
"{len} widget(s) were never upgraded\n\
this is likely a memory leak; consider upgrading to strong if you plan on using it later\n\
weak widgets: {all:#?}"
);
}
let root = root.into();
if self.root_changed(root) {
self.redraw_all(root, rsc);
self.old_root = root.map(|r| r.id());
} 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.
{
#[cfg(feature = "layout-diagnostics")]
let _marking = diag::timer(TimerKind::ResizeMarking);
let dependents: Vec<_> = self
.active
.iter()
.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")]
diag::bump(Counter::ResizeDependents);
rsc.widgets_mut().needs_redraw.insert(id);
if let Some(top) = self.mark_readers(id, rsc) {
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; 2];
self.invalid_sizes.clear();
self.resize_marks.clear();
self.draw_started.clear();
}
fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) {
#[cfg(feature = "layout-diagnostics")]
let _layout = diag::timer(TimerKind::FullLayout);
self.clear(rsc);
// free all resources & cache
if let Some(id) = root {
self.draw_inner(
0,
id.id(),
UiRegion::FULL,
None,
MoveIdx::NONE,
false,
MaskIdx::NONE,
None,
rsc,
);
}
}
// TODO: should prolly make a DrawInfo struct or smth for everything other than rsc
#[allow(clippy::too_many_arguments)]
pub(super) fn draw_inner(
&mut self,
layer: usize,
id: WidgetId,
region: UiRegion,
parent: Option<WidgetId>,
parent_move: MoveIdx,
slotted: bool,
mask: MaskIdx,
old_children: Option<Vec<WidgetId>>,
rsc: &mut dyn UiRsc,
) -> Size {
#[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::DrawRequests);
diag::draw_request(id, parent, region, self.px_of(parent_move, region), slotted);
}
let mut old_children = old_children.unwrap_or_default();
if self.active.contains_key(&id) {
if let Some(size) = self.try_reuse(id, region, parent_move, rsc) {
return size;
}
// if not, then maintain resize and track old children to remove unneeded
let active = self.remove(id, false, rsc).unwrap();
old_children = active.children;
}
// draw widget
let (move_idx, local) = match 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, parent_move, region), UiRegion::FULL),
false => {
self.drop_slot(id);
(parent_move, region)
}
};
let px = self.px_of(move_idx, local);
rsc.widgets_mut().needs_redraw.remove(&id);
self.draw_started.insert(id);
let mut painter = Painter {
state: self,
region: local,
mask,
layer,
id,
textures: Vec::new(),
primitives: Vec::new(),
children: Vec::new(),
size_deps: Vec::new(),
size_box_inputs: [false; 2],
size_output_inputs: [false; 2],
reads_output: [false; 2],
move_idx,
rsc,
};
#[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::WidgetDraws);
diag::draw_widget(id, painter.rsc.widgets().label(id));
}
let mut widget = painter.rsc.widgets().get_dyn_dynamic(id);
let size = widget.draw(&mut painter);
drop(widget);
#[cfg(feature = "layout-diagnostics")]
diag::size_reported(id, size);
let Painter {
state: _,
rsc: _,
region: _,
mask,
textures,
primitives,
children,
size_deps,
size_box_inputs,
size_output_inputs,
reads_output,
move_idx,
layer,
id,
} = painter;
debug_assert!(
Self::hints_agree(id, size, rsc),
"'{}' ({id:?}) drew a size its size_hint disagrees with",
rsc.widgets().label(id)
);
// add to active
let active = ActiveData {
id,
region,
size,
px,
parent,
textures,
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) {
self.remove_rec(*c, rsc);
}
}
rsc.on_draw(&active);
self.active.insert(id, active);
self.invalid_sizes.remove(&id);
self.resize_marks.remove(&id);
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.
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);
self.moves.set(idx, region);
return idx;
}
let idx = self.moves.push(parent, region);
self.slots.insert(id, idx);
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.
fn drop_slot(&mut self, id: WidgetId) {
if let Some(idx) = self.slots.remove(&id) {
self.moves.remove(idx);
}
}
/// The pixel size of a region held in `slot`'s coordinates.
fn px_of(&self, slot: MoveIdx, region: UiRegion) -> Vec2 {
self.moves
.resolve(slot, region)
.size()
.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(
&mut self,
id: WidgetId,
region: UiRegion,
parent_move: MoveIdx,
rsc: &mut dyn UiRsc,
) -> Option<Size> {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::ReuseAttempts);
// Only its own dirtiness, not anything dirty under it that could
// change the size this hands back. What makes that safe is the order
// `redraw_updates` settles in, and nothing else: by the time a reader
// draws, everything dirty below it has been drawn and has propagated.
// Draw in another order and this returns a stale size -- measured, on
// seed 2 of `tests/generated.rs`.
if rsc.widgets().needs_redraw.contains(&id) {
#[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::ReuseDirty);
diag::reuse(id, ReuseOutcome::Dirty);
}
return None;
}
let active = self.active.get(&id)?;
// 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 != parent_move {
#[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::ReuseWrongParent);
diag::reuse(id, ReuseOutcome::WrongParent);
}
return None;
}
let (size, old_region, slot, old_px) =
(active.size, active.region, active.move_idx, active.px);
// In pixels, because `region` is a fraction of a slot's box and that
// box may be what changed -- an unchanged fraction of a box half the
// size is half the widget.
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 = pixel_len_changed(old_px.axis(axis), px.axis(axis));
}
if !changed.iter().any(|&c| c) && old_region == region {
#[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::ReuseExact);
diag::reuse(id, ReuseOutcome::Exact);
}
return Some(size);
}
// Only a placed widget can be given a different box without drawing
// again: everything it drew is a fraction of its slot's box, so one
// entry says where all of it went.
if slot == parent_move {
#[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::ReuseUnslotted);
diag::reuse(id, ReuseOutcome::Unslotted);
}
return None;
}
if changed.iter().any(|&c| c) {
let widget = rsc.widgets().get_dyn(id)?;
let redraws = AXES
.into_iter()
.zip(changed)
.any(|(axis, c)| c && widget.on_resize(axis) != OnResize::Scale);
// Anything under it that has to be drawn again is drawn by drawing
// this, because whatever reads that widget's size sits in between
// and has to lay out around what it comes to.
if redraws {
#[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::ReuseOwnResize);
diag::reuse(id, ReuseOutcome::OwnResize);
}
return None;
}
if self.redraws_under(id, changed, rsc) {
#[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::ReuseDescendantResize);
diag::reuse(id, ReuseOutcome::DescendantResize);
}
return None;
}
}
self.moves.set(slot, region);
let active = self.active.get_mut(&id).unwrap();
active.region = region;
#[cfg(feature = "layout-diagnostics")]
{
diag::bump(Counter::ReuseMoved);
diag::reuse(id, ReuseOutcome::Moved);
}
Some(size)
}
/// Whether anything under `id` would have to be drawn again for the box
/// it is a fraction of changing length, `changed` saying which axes of
/// that box did.
///
/// A part of a box with no relative extent on an axis is a fixed length,
/// held as offsets from that box's start, and composing anything into it
/// leaves no relative extent either. So a widget whose own box did not
/// change length has no descendant whose box did, and the walk stops
/// there -- an 80-wide child of a widened row is not asked at all.
fn redraws_under(&self, id: WidgetId, changed: [bool; 2], rsc: &dyn UiRsc) -> bool {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::ResizeChecks);
let Some(active) = self.active.get(&id) else {
return false;
};
let size_deps = &active.size_deps;
active.children.iter().any(|&child| {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::ResizeCheckChildren);
let Some(data) = self.active.get(&child) else {
return false;
};
let Some(widget) = rsc.widgets().get_dyn(child) else {
return true;
};
// What it drew to learn this child's size was the child in *this*
// box, so a different box is a different answer -- unless the
// child gave an exact one without being drawn at all.
if size_deps.contains(&child) {
let measured = AXES
.into_iter()
.zip(changed)
.any(|(axis, c)| c && widget.size_hint(axis).is_none());
if measured {
return true;
}
}
let mut own = changed;
for (axis, c) in AXES.into_iter().zip(own.iter_mut()) {
*c &= data.region.axis(axis).len().rel != 0.0;
}
if !own.iter().any(|&c| c) {
return false;
}
let redraws = AXES
.into_iter()
.zip(own)
.any(|(axis, c)| c && widget.on_resize(axis) != OnResize::Scale);
redraws || self.redraws_under(child, own, rsc)
})
}
fn hints_agree(id: WidgetId, size: Size, rsc: &dyn UiRsc) -> bool {
let Some(widget) = rsc.widgets().get_dyn(id) else {
return true;
};
AXES.into_iter().all(|axis| {
widget
.size_hint(axis)
.is_none_or(|hint| hint == size.axis(axis))
})
}
/// NOTE: instance textures are cleared and self.textures freed
fn remove(&mut self, id: WidgetId, undraw: bool, rsc: &mut dyn UiRsc) -> Option<ActiveData> {
let mut active = self.active.remove(&id);
if let Some(active) = &mut active {
for h in &active.primitives {
let mask = self.layers.free(h);
if mask != MaskIdx::NONE {
rsc.ui_mut().masks.remove(mask);
}
}
active.textures.clear();
rsc.ui_mut().textures.free();
if undraw {
rsc.on_undraw(active);
}
}
active
}
fn remove_rec(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) -> Option<ActiveData> {
let inst = self.remove(id, true, rsc);
if let Some(inst) = &inst {
for c in &inst.children {
self.remove_rec(*c, rsc);
}
}
// After the descendants, whose slots name this one as their parent.
if let Some(idx) = self.slots.remove(&id) {
self.moves.remove(idx);
}
inst
}
fn clear(&mut self, rsc: &mut dyn UiRsc) {
for (_, active) in self.active.drain() {
rsc.on_undraw(&active);
}
self.slots.clear();
self.moves.clear();
self.layers.clear();
self.invalid_sizes.clear();
self.resize_marks.clear();
self.draw_started.clear();
rsc.widgets_mut().needs_redraw.clear();
rsc.free();
}
pub fn redraw_updates(&mut self, rsc: &mut dyn UiRsc) {
#[cfg(feature = "layout-diagnostics")]
let _layout = diag::timer(TimerKind::IncrementalLayout);
// A reader's answer is only valid after every dirty size it reads has
// settled, and taking the deepest first is what arranges that --
// `try_reuse` hands back a retained size without asking whether
// anything dirty sits under it, so this order is load-bearing for the
// answer and not only for the cost. Equal-depth widgets are
// independent, so their order does not matter. Resize dirtiness already marks whole reader chains, so
// choosing their shallowest roots coalesces descendants that share a
// 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.iter().any(|&resized| resized) {
true => dirty.min_by_key(|&id| self.depth(id)),
false => dirty.max_by_key(|&id| self.depth(id)),
}
} {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::QueuePops);
self.redraw(id, rsc);
}
rsc.free();
}
fn depth(&self, id: WidgetId) -> usize {
let mut depth = 0;
let mut at = Some(id);
while let Some(id) = at {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::DepthSteps);
at = self.active.get(&id).and_then(|active| active.parent);
depth += 1;
}
depth
}
pub fn root_changed<'a>(&self, root: impl Into<Option<&'a StrongWidget>>) -> bool {
root.into().map(|r| r.id()) != self.old_root
}
pub fn needs_redraw<'a>(
&self,
root: impl Into<Option<&'a StrongWidget>>,
widgets: &Widgets,
) -> bool {
self.root_changed(root)
|| self.resized.iter().any(|&resized| resized)
|| widgets.has_updates()
}
pub fn active_widgets(&self) -> usize {
self.active.len()
}
pub fn debug(&self, widgets: &Widgets, label: &str) -> impl Iterator<Item = &ActiveData> {
self.active.iter().filter_map(move |(&id, inst)| {
let l = widgets.label(id);
if l == label { Some(inst) } else { None }
})
}
pub fn debug_layers(&self) {
for ((idx, depth), draws) in self.layers.iter_depth() {
let indent = " ".repeat(depth * 2);
let counts: Vec<String> = draws
.primitives()
.iter()
.map(|l| l.as_ref().map_or(0, |l| l.instances().len()).to_string())
.collect();
println!("{indent}{idx}: [{}]", counts.join(", "));
}
}
/// Where a widget is on screen: its box composed through the boxes it
/// sits within, which is the walk the vertex shader does.
pub fn window_region(&self, id: &impl IdLike) -> Option<PixelRegion> {
let active = self.active.get(&id.id())?;
let region = self.moves.resolve(active.parent_move, active.region);
Some(region.to_px(self.output_size))
}
/// 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| {
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")]
diag::bump(Counter::EagerReaderRedraws);
self.redraw(top, rsc);
rsc.widgets_mut().needs_redraw.remove(&id);
return;
}
rsc.widgets_mut().needs_redraw.remove(&id);
if self.draw_started.contains(&id) {
return;
}
let Some(active) = self.remove(id, false, rsc) else {
return;
};
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::LocalRedraws);
let old_size = active.size;
let size = self.draw_inner(
active.layer,
id,
active.region,
active.parent,
active.parent_move,
active.move_idx != active.parent_move,
active.mask,
Some(active.children),
rsc,
);
if size != old_size {
#[cfg(feature = "layout-diagnostics")]
diag::bump(Counter::SizeChanges);
if let Some(parent) = self.active.get(&id).and_then(|active| active.parent)
&& self
.active
.get(&parent)
.is_some_and(|active| active.size_deps.contains(&id))
{
// 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);
}
}
}
/// The furthest ancestor that read this widget's size, directly or through
/// widgets that did the same, marking everything below it on the way.
fn mark_readers(&self, id: WidgetId, rsc: &mut dyn UiRsc) -> Option<WidgetId> {
let mut top = None;
let mut at = id;
while let Some(active) = self.active.get(&at)
&& let Some(parent) = active.parent
&& self
.active
.get(&parent)
.is_some_and(|p| p.size_deps.contains(&at))
{
rsc.widgets_mut().needs_redraw.insert(at);
top = Some(parent);
at = parent;
}
top
}
}
impl Default for UiRenderState {
fn default() -> Self {
Self::new()
}
}