Implements LAYOUT.md end to end: one fn draw(&mut self, &mut Painter) -> Size replaces draw + desired_width/desired_height on every widget in iris/src/widget/, SizeCtx and Cache are deleted, and a moved widget (Scroll, Offset) costs one move_offsets write resolved by a shared resolve_move WGSL function in both shader stages -- O(1) regardless of how many primitives are in its subtree, measured at 500 in the new iris/src/layout_tests.rs (a plain unit test: UiRenderState touches no GPU or window). Five real bugs surfaced only by diffing iris/run-headless.sh screenshots against the pre-change tree and are written up in LAYOUT.md's "Deviations found during implementation": Aligned's provisional draw composing painter.region() a second time through widget_within; Sized/ MaxSize reporting a capped size while still painting their child unconstrained (fine under the old two-pass model, wrong once a parent like Aligned draws before knowing the final size); a widget's move_offsets parent link being unreadable from self.active while its own ActiveData is still mid-construction; Painter::reposition needing the child's *painted* footprint (its reported size, top-left anchored) rather than its offered region; and a widget's move slot needing to be reused in place across redraws, with its delta reset, rather than reallocated. All four iris/examples render pixel-identical to the pre-change tree. cargo fmt/clippy/test clean across the workspace (18 tests: 14 pre-existing plus 4 new). Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
519 lines
20 KiB
Rust
519 lines
20 KiB
Rust
use crate::{
|
|
ActiveData, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers, RegionAlign,
|
|
StrongWidget, UiRegion, UiRsc, UiVec2, WidgetId, Widgets,
|
|
render::MoveOffset,
|
|
util::{HashMap, HashSet, Id, Vec2},
|
|
};
|
|
|
|
pub struct UiRenderState {
|
|
pub active: HashMap<WidgetId, ActiveData>,
|
|
pub layers: PrimitiveLayers,
|
|
pub(super) output_size: Vec2,
|
|
|
|
old_root: Option<WidgetId>,
|
|
resized: bool,
|
|
draw_started: HashSet<WidgetId>,
|
|
|
|
/// `Widget::draw` calls and `Primitives::region_mut` rewrites since the
|
|
/// last `take_counters`. LAYOUT.md section 8's pass conditions are
|
|
/// stated in terms of these two: an unchanged frame must cost 0 of
|
|
/// each, and moving one widget must cost 0 draws and 0 rewrites
|
|
/// regardless of how many primitives are in its subtree.
|
|
draw_count: u64,
|
|
region_mut_count: u64,
|
|
mov_count: u64,
|
|
}
|
|
|
|
/// A move chain more than this deep would mean something else is wrong
|
|
/// (an accidental cycle) -- see `resolve_move` in shader.wgsl, which walks
|
|
/// the identical bound and must be kept in step with this constant.
|
|
pub const MOVE_CHAIN_LIMIT: usize = 16;
|
|
|
|
impl UiRenderState {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
active: Default::default(),
|
|
layers: Default::default(),
|
|
output_size: Vec2::ZERO,
|
|
old_root: None,
|
|
resized: false,
|
|
draw_started: Default::default(),
|
|
draw_count: 0,
|
|
region_mut_count: 0,
|
|
mov_count: 0,
|
|
}
|
|
}
|
|
|
|
/// Reads and zeroes the (draws, region_mut rewrites, move_offsets
|
|
/// writes) counters -- call once per frame before `update()` to
|
|
/// measure exactly that frame, per LAYOUT.md section 8.
|
|
pub fn take_counters(&mut self) -> (u64, u64, u64) {
|
|
(
|
|
std::mem::take(&mut self.draw_count),
|
|
std::mem::take(&mut self.region_mut_count),
|
|
std::mem::take(&mut self.mov_count),
|
|
)
|
|
}
|
|
|
|
pub fn resize(&mut self, size: impl Into<Vec2>) {
|
|
self.output_size = size.into();
|
|
self.resized = true;
|
|
}
|
|
|
|
pub fn update<'a>(&mut self, root: impl Into<Option<&'a StrongWidget>>, rsc: &mut dyn UiRsc) {
|
|
// 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.needs_redraw_all(root) {
|
|
self.redraw_all(root, rsc);
|
|
self.old_root = root.map(|r| r.id());
|
|
self.resized = false;
|
|
} else if rsc.widgets().has_updates() {
|
|
self.redraw_updates(rsc);
|
|
}
|
|
}
|
|
|
|
fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) {
|
|
self.clear(rsc);
|
|
// free all resources & cache
|
|
if let Some(id) = root {
|
|
self.draw_inner(
|
|
0,
|
|
id.id(),
|
|
UiRegion::FULL,
|
|
None,
|
|
MoveOffset::NONE_PARENT,
|
|
MaskIdx::NONE,
|
|
None,
|
|
None,
|
|
rsc,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The slot an *already-active* widget's `move_offsets` entry chains
|
|
/// to, read back from `self.active`. Only valid where the parent is
|
|
/// guaranteed to already be in `self.active` -- true for `redraw()`,
|
|
/// which targets a widget that was fully drawn on some earlier update,
|
|
/// but **not** for a widget being drawn as part of its own parent's
|
|
/// `Widget::draw` call: that parent's `ActiveData` is not inserted
|
|
/// until its `draw` returns (below), so a child drawn partway through
|
|
/// it would always read back "no parent" here. `Painter::widget_at`
|
|
/// avoids that trap by passing its own already-known `move_slot`
|
|
/// straight through instead of asking `self.active` to look it up.
|
|
fn move_parent_of(&self, parent: Option<WidgetId>) -> u32 {
|
|
parent
|
|
.and_then(|p| self.active.get(&p))
|
|
.map(|p| p.move_slot.idx() as u32)
|
|
.unwrap_or(MoveOffset::NONE_PARENT)
|
|
}
|
|
|
|
// 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_slot: u32,
|
|
mask: MaskIdx,
|
|
old_children: Option<Vec<WidgetId>>,
|
|
old_move_slot: Option<MoveIdx>,
|
|
rsc: &mut dyn UiRsc,
|
|
) {
|
|
let mut old_children = old_children.unwrap_or_default();
|
|
let mut old_move_slot = old_move_slot;
|
|
if let Some(active) = self.active.get_mut(&id)
|
|
&& !rsc.widgets().needs_redraw.contains(&id)
|
|
{
|
|
// check to see if we can skip drawing first
|
|
if active.region == region {
|
|
return;
|
|
} else if active.region.size() == region.size() {
|
|
// TODO: epsilon?
|
|
let from = active.region;
|
|
self.mov(id, from, region, rsc);
|
|
return;
|
|
} else if rsc
|
|
.widgets()
|
|
.get_dyn(id)
|
|
.map(|w| w.is_size_independent())
|
|
.unwrap_or(false)
|
|
{
|
|
// The offered region changed shape, but this widget's own
|
|
// drawn output does not depend on it (a fixed-size leaf) --
|
|
// rewrite its own primitives' regions in place (O(primitives
|
|
// owned directly by this widget, which for a leaf is O(1))
|
|
// instead of redrawing. See LAYOUT.md section 3.
|
|
let from = active.region;
|
|
for h in &active.primitives {
|
|
let r = self.layers[h.layer].region_mut(h);
|
|
*r = r.outside(&from).within(®ion);
|
|
self.region_mut_count += 1;
|
|
}
|
|
active.region = region;
|
|
return;
|
|
}
|
|
// if not, then maintain resize and track old children to remove unneeded
|
|
let active = self.remove(id, false, rsc).unwrap();
|
|
old_children = active.children;
|
|
old_move_slot = Some(active.move_slot);
|
|
}
|
|
|
|
// draw widget
|
|
self.draw_started.insert(id);
|
|
|
|
let move_slot = match old_move_slot {
|
|
// Reused across a real redraw of the same id: the fresh
|
|
// geometry this draw is about to write is placed at its
|
|
// correct absolute position by `region` itself, so any delta
|
|
// accumulated before this redraw is now stale and would
|
|
// double-offset it if left in place. The chain link (`parent`)
|
|
// is untouched -- the logical parent has not changed.
|
|
Some(slot) => {
|
|
let entry = rsc.ui_mut().move_offsets.get_mut(slot);
|
|
entry.delta = [0.0, 0.0];
|
|
slot
|
|
}
|
|
None => {
|
|
let slot = rsc
|
|
.ui_mut()
|
|
.move_offsets
|
|
.push(MoveOffset::new([0.0, 0.0], parent_move_slot));
|
|
rsc.ui_mut().move_offsets.push_ref(slot);
|
|
if parent_move_slot != MoveOffset::NONE_PARENT {
|
|
rsc.ui_mut()
|
|
.move_offsets
|
|
.push_ref(Id::preset(parent_move_slot));
|
|
}
|
|
slot
|
|
}
|
|
};
|
|
|
|
let mut painter = Painter {
|
|
state: self,
|
|
region,
|
|
mask,
|
|
move_slot,
|
|
layer,
|
|
id,
|
|
textures: Vec::new(),
|
|
primitives: Vec::new(),
|
|
children: Vec::new(),
|
|
rsc,
|
|
};
|
|
|
|
let mut widget = painter.rsc.widgets().get_dyn_dynamic(id);
|
|
painter.state.draw_count += 1;
|
|
let size = widget.draw(&mut painter);
|
|
drop(widget);
|
|
|
|
let Painter {
|
|
state: _,
|
|
rsc: _,
|
|
region,
|
|
mask,
|
|
move_slot,
|
|
textures,
|
|
primitives,
|
|
children,
|
|
layer,
|
|
id,
|
|
} = painter;
|
|
|
|
// add to active
|
|
let active = ActiveData {
|
|
id,
|
|
region,
|
|
parent,
|
|
textures,
|
|
primitives,
|
|
children,
|
|
mask,
|
|
layer,
|
|
size,
|
|
move_slot,
|
|
};
|
|
|
|
// 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);
|
|
}
|
|
|
|
/// O(1): write the delta for this widget's own slot in
|
|
/// `move_offsets`. No primitive is touched and there is no recursion --
|
|
/// every descendant's primitive references this slot transitively
|
|
/// through the parent chain the shader walks (`resolve_move`), so it
|
|
/// picks the new delta up for free. See LAYOUT.md section 2.
|
|
fn mov(&mut self, id: WidgetId, from: UiRegion, to: UiRegion, rsc: &mut dyn UiRsc) {
|
|
let Some(active) = self.active.get_mut(&id) else {
|
|
return;
|
|
};
|
|
let slot = active.move_slot;
|
|
active.region = to;
|
|
let from_px = from.top_left().to_abs(self.output_size);
|
|
let to_px = to.top_left().to_abs(self.output_size);
|
|
let delta = to_px - from_px;
|
|
let entry = rsc.ui_mut().move_offsets.get_mut(slot);
|
|
entry.delta[0] += delta.x;
|
|
entry.delta[1] += delta.y;
|
|
self.mov_count += 1;
|
|
}
|
|
|
|
/// Move an already-active widget to `to`. Used by `Painter::reposition`,
|
|
/// for a parent that drew a child provisionally (at the whole region it
|
|
/// was offered) and now knows where the child actually belongs.
|
|
///
|
|
/// Unlike `mov` (called by `draw_inner`'s own dispatch, where the
|
|
/// *offered* region really did move and `active.region` already tracks
|
|
/// it), the child here was not offered a smaller region -- it was
|
|
/// offered everything and chose, on its own, to occupy only
|
|
/// `active.size` of it. By convention every widget in this crate that
|
|
/// does that anchors its own content at the top-left of whatever it
|
|
/// was given (`Rect`/`Image`/`Sized`/`MaxSize` -- see their `draw`
|
|
/// bodies), so that is where this assumes the child was actually
|
|
/// painted, not `active.region` itself (which is the *offered* box,
|
|
/// usually bigger). A nested `Aligned` whose own child is not top-left
|
|
/// anchored -- i.e. `Aligned` wrapping `Aligned` -- is the one shape
|
|
/// this does not cover; none of iris's widgets or examples build that
|
|
/// today. See LAYOUT.md's "Rejected, and why" / deviations for the
|
|
/// full reasoning.
|
|
///
|
|
/// The delta is overwritten, not accumulated like `mov`'s: `from` is
|
|
/// recomputed fresh from `active.size`/`active.region` every call, so
|
|
/// repeating the same `reposition` (e.g. an unrelated redraw elsewhere
|
|
/// re-running this widget's parent without its own layout changing)
|
|
/// must land on the same answer, not drift further each time.
|
|
pub(super) fn reposition(&mut self, id: WidgetId, to: UiRegion, rsc: &mut dyn UiRsc) {
|
|
let Some(active) = self.active.get(&id) else {
|
|
return;
|
|
};
|
|
let from = active
|
|
.size
|
|
.to_uivec2()
|
|
.align(RegionAlign::TOP_LEFT)
|
|
.within(&active.region);
|
|
let slot = active.move_slot;
|
|
let from_px = from.top_left().to_abs(self.output_size);
|
|
let to_px = to.top_left().to_abs(self.output_size);
|
|
let delta = to_px - from_px;
|
|
let entry = rsc.ui_mut().move_offsets.get_mut(slot);
|
|
entry.delta = [delta.x, delta.y];
|
|
self.mov_count += 1;
|
|
}
|
|
|
|
/// 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 {
|
|
// Permanent removal: retire this widget's own move slot
|
|
// (the self-ownership ref taken when it was allocated) and
|
|
// the up-link ref it held on its parent's slot -- read from
|
|
// the arena entry itself, not from `active.parent`, since
|
|
// the parent's own `ActiveData` may already be gone by the
|
|
// time a deep descendant is retired (see LAYOUT.md
|
|
// section 2's lifecycle note).
|
|
let parent_slot = rsc.ui_mut().move_offsets[active.move_slot.idx()].parent;
|
|
rsc.ui_mut().move_offsets.remove(active.move_slot);
|
|
if parent_slot != MoveOffset::NONE_PARENT {
|
|
rsc.ui_mut().move_offsets.remove(Id::preset(parent_slot));
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
inst
|
|
}
|
|
|
|
fn clear(&mut self, rsc: &mut dyn UiRsc) {
|
|
for (_, active) in self.active.drain() {
|
|
rsc.on_undraw(&active);
|
|
}
|
|
self.layers.clear();
|
|
rsc.widgets_mut().needs_redraw.clear();
|
|
rsc.free();
|
|
}
|
|
|
|
pub fn redraw_updates(&mut self, rsc: &mut dyn UiRsc) {
|
|
while let Some(&id) = rsc.widgets().needs_redraw.iter().next() {
|
|
self.redraw(id, rsc);
|
|
}
|
|
rsc.free();
|
|
}
|
|
|
|
pub fn root_changed<'a>(&self, root: impl Into<Option<&'a StrongWidget>>) -> bool {
|
|
root.into().map(|r| r.id()) != self.old_root
|
|
}
|
|
|
|
/// What `update` will redraw everything for. Named and shared with
|
|
/// `needs_redraw` rather than written out twice, because the two must
|
|
/// agree: `needs_redraw` is what asks for the frame that `update` would
|
|
/// draw, so a condition in one and not the other is a frame nobody
|
|
/// requests and a stale window. `resized` was missing from `needs_redraw`,
|
|
/// which is latent on Wayland only because winit asks for a redraw after a
|
|
/// resize by itself -- a resize changes neither the root nor any widget,
|
|
/// so nothing else here would have asked.
|
|
fn needs_redraw_all<'a>(&self, root: impl Into<Option<&'a StrongWidget>>) -> bool {
|
|
self.root_changed(root) || self.resized
|
|
}
|
|
|
|
pub fn needs_redraw<'a>(
|
|
&self,
|
|
root: impl Into<Option<&'a StrongWidget>>,
|
|
widgets: &Widgets,
|
|
) -> bool {
|
|
self.needs_redraw_all(root) || 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), primitives) in self.layers.iter_depth() {
|
|
let indent = " ".repeat(depth * 2);
|
|
let len = primitives.instances().len();
|
|
print!("{indent}{idx}: {len} primitives");
|
|
if len >= 1 {
|
|
print!(" ({})", primitives.instances()[0].binding);
|
|
}
|
|
println!();
|
|
}
|
|
}
|
|
|
|
/// `active[id].region`, corrected by every `move_offsets` delta between
|
|
/// `id` and the root -- the CPU-side twin of the vertex shader's chain
|
|
/// walk, over the same arena, so the two cannot disagree about where a
|
|
/// widget is. O(chain depth), not O(primitives). See LAYOUT.md
|
|
/// section 2b.
|
|
pub fn resolved_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<UiRegion> {
|
|
let active = self.active.get(&id.id())?;
|
|
let delta = self.resolve_move_chain(active.move_slot, rsc);
|
|
Some(active.region.offset(UiVec2::abs(delta)))
|
|
}
|
|
|
|
/// The plain-Rust twin of `resolve_move` in shader.wgsl: sums the
|
|
/// pixel delta along the parent chain starting at `slot`. Both walks
|
|
/// share `MOVE_CHAIN_LIMIT` as their bound so the two cannot disagree
|
|
/// about where the chain ends.
|
|
fn resolve_move_chain(&self, mut slot: MoveIdx, rsc: &dyn UiRsc) -> Vec2 {
|
|
let offsets = &rsc.ui().move_offsets;
|
|
let mut delta = Vec2::ZERO;
|
|
for i in 0..MOVE_CHAIN_LIMIT {
|
|
let entry = &offsets[slot.idx()];
|
|
delta.x += entry.delta[0];
|
|
delta.y += entry.delta[1];
|
|
if entry.parent == MoveOffset::NONE_PARENT {
|
|
return delta;
|
|
}
|
|
slot = Id::preset(entry.parent);
|
|
debug_assert!(
|
|
i + 1 < MOVE_CHAIN_LIMIT,
|
|
"move offset chain exceeded MOVE_CHAIN_LIMIT; a widget's `parent` link is \
|
|
probably cyclic"
|
|
);
|
|
}
|
|
delta
|
|
}
|
|
|
|
pub fn window_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<PixelRegion> {
|
|
let region = self.resolved_region(id, rsc)?;
|
|
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) {
|
|
rsc.widgets_mut().needs_redraw.remove(&id);
|
|
self.draw_started.remove(&id);
|
|
if self.draw_started.contains(&id) {
|
|
return;
|
|
}
|
|
|
|
let Some(active) = self.remove(id, false, rsc) else {
|
|
return;
|
|
};
|
|
let old_size = active.size;
|
|
let parent = active.parent;
|
|
// `old_move_slot` being `Some` below means the slot is reused in
|
|
// place rather than freshly parented, so this is only reached for
|
|
// logging/clarity's sake, never actually used to link a new slot.
|
|
let parent_move_slot = self.move_parent_of(parent);
|
|
|
|
self.draw_inner(
|
|
active.layer,
|
|
id,
|
|
active.region,
|
|
parent,
|
|
parent_move_slot,
|
|
active.mask,
|
|
Some(active.children),
|
|
Some(active.move_slot),
|
|
rsc,
|
|
);
|
|
|
|
// If this widget's own reported size changed, its parent's layout
|
|
// (which placed it using the old size) is now stale and needs to
|
|
// relay out too. Checked after the real draw, not before it --
|
|
// there is no query left that answers "what size would this be"
|
|
// without actually drawing (LAYOUT.md section 5).
|
|
if let Some(pid) = parent {
|
|
let new_size = self.active.get(&id).map(|a| a.size);
|
|
if new_size != Some(old_size) {
|
|
self.redraw(pid, rsc);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for UiRenderState {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|