`mov` accumulates a delta onto the slot and `reposition` overwrote it, and both legitimately land on one widget in one frame: `List::place`'s Bottom-known branch offers a row a same-size box that has moved (`mov`), then corrects the placement inside it when the row's cached height no longer matches what the row reports (`reposition`). That is what a wrapped transcript row hit, and what the `move_applied == ZERO` debug assert was standing in for -- an assert against a case that happens is not a guarantee, it is a crash. The slot means `move_applied + repositioned` now, both halves recorded on `ActiveData`, so `reposition` adds the move rather than dropping it and stays idempotent. The assert it replaces is a `debug_assert_eq!` that the slot still holds that sum on entry -- i.e. that nothing but those two ever wrote it. Test: `a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement`, which draws the child at the offered position (-100px) rather than the placement (100px) without the fix. Verified against the `.wrap(true)` repro from docs/IRIS_TODO.md (draws correctly, no panic) and an emulator bench run with assertions live.
788 lines
34 KiB
Rust
788 lines
34 KiB
Rust
use crate::{
|
|
ActiveData, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers, RegionAlign,
|
|
StrongWidget, UiRegion, UiRsc, UiVec2, WidgetId, Widgets,
|
|
render::{IMAGE_BINDING, MoveOffset},
|
|
util::{HashMap, HashSet, Id, Vec2},
|
|
};
|
|
|
|
pub struct UiRenderState {
|
|
pub active: HashMap<WidgetId, ActiveData>,
|
|
pub layers: PrimitiveLayers,
|
|
pub(super) output_size: Vec2,
|
|
/// Physical pixels per `dp` -- see `Len::dp`'s field doc. `1.0` (an
|
|
/// unscaled display) until a backend that knows its own density calls
|
|
/// `set_density` (Android's `content_scale`, read at `surface_changed`
|
|
/// time); the winit backend has no analogous per-monitor value wired up
|
|
/// yet and stays at the default.
|
|
pub(super) density: f32,
|
|
|
|
old_root: Option<WidgetId>,
|
|
resized: bool,
|
|
/// The widgets whose `Widget::draw` is on the stack right now -- so
|
|
/// [`Self::redraw`] can tell "this widget needs drawing again" from
|
|
/// "an ancestor is drawing it at this very moment", where a second
|
|
/// draw would leave the first one's primitives behind with nothing
|
|
/// owning them. An id is inserted immediately before `draw` is called
|
|
/// and removed the moment it returns (both in `draw_inner`), so this
|
|
/// is empty between frames -- asserted at the end of `update`.
|
|
///
|
|
/// It used to only ever be inserted into, and `redraw` removed the id
|
|
/// *before* testing for it, which made the test constant `false`: the
|
|
/// guard could never fire and the set grew by one entry per widget
|
|
/// ever drawn and was never emptied.
|
|
draw_started: HashSet<WidgetId>,
|
|
|
|
/// The widget currently holding exclusive pointer input, if any --
|
|
/// `iris::sense::SensorUi::run_sensors` reads and clears this every
|
|
/// call. Interior mutability (a `Mutex`, not a bare `Cell`, since a
|
|
/// `CursorData` reaching this through an async `task_on` handler needs
|
|
/// `Send`/`Sync`) because `run_sensors` takes `&self` (widgets are
|
|
/// dispatched to, not owned, at that layer) and this render state is
|
|
/// the one structure both backends (winit, android-view) already hold
|
|
/// across frames, the same way `old_root`/`resized` are -- see
|
|
/// `iris::sense`'s pointer-capture doc for why a drag needs this: once
|
|
/// a gesture has committed to panning or selecting, every later sample
|
|
/// of it must reach the same widget even if the finger has moved off
|
|
/// whatever hit region first noticed the press. Never held across an
|
|
/// await or another lock -- every access here is a single get/set.
|
|
captured: std::sync::Mutex<Option<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,
|
|
/// Text layouts actually computed -- bumped by `Painter::render_text`,
|
|
/// which `TextView::render` only reaches on a cache miss.
|
|
pub(super) shape_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,
|
|
density: 1.0,
|
|
old_root: None,
|
|
resized: false,
|
|
draw_started: Default::default(),
|
|
captured: Default::default(),
|
|
draw_count: 0,
|
|
region_mut_count: 0,
|
|
mov_count: 0,
|
|
shape_count: 0,
|
|
}
|
|
}
|
|
|
|
/// Reads and zeroes the (draws, region_mut rewrites, move_offsets
|
|
/// writes, text shapes) counters -- call once per frame before
|
|
/// `update()` to measure exactly that frame, per LAYOUT.md section 8.
|
|
///
|
|
/// The fourth is the one a draw count cannot stand in for: a widget
|
|
/// can be redrawn without re-shaping (`TextView::render` memoizes by
|
|
/// width) and re-shaped without any extra draw, and it is re-shaping
|
|
/// that the per-block transcript row exists to avoid -- see
|
|
/// `transcript_ui`'s `a_delta_into_a_long_reply_shapes_one_block`.
|
|
pub fn take_counters(&mut self) -> (u64, 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),
|
|
std::mem::take(&mut self.shape_count),
|
|
)
|
|
}
|
|
|
|
pub fn resize(&mut self, size: impl Into<Vec2>) {
|
|
self.output_size = size.into();
|
|
self.resized = true;
|
|
}
|
|
|
|
/// Sets the physical-pixels-per-dp ratio every `Len::dp` in the tree
|
|
/// resolves against from the next layout pass on -- see `density`'s
|
|
/// field doc. Not folded into `resize` because the two change on
|
|
/// different triggers (a surface resize on every rotation or keyboard
|
|
/// open; a density change only if the app follows the display to a
|
|
/// different screen, which Android surfaces separately).
|
|
pub fn set_density(&mut self, density: f32) {
|
|
self.density = density;
|
|
}
|
|
|
|
pub fn density(&self) -> f32 {
|
|
self.density
|
|
}
|
|
|
|
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();
|
|
debug_assert!(
|
|
self.draw_started.is_empty(),
|
|
"a previous frame left {} widget(s) marked as mid-draw",
|
|
self.draw_started.len(),
|
|
);
|
|
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);
|
|
}
|
|
#[cfg(debug_assertions)]
|
|
debug_assert!(self.primitive_counts_agree(), "{}", self.orphan_report(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,
|
|
MaskIdx::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>,
|
|
old_own_mask: MaskIdx,
|
|
rsc: &mut dyn UiRsc,
|
|
) {
|
|
let mut old_children = old_children.unwrap_or_default();
|
|
let mut old_move_slot = old_move_slot;
|
|
let mut own_mask = old_own_mask;
|
|
// Consumed here, not merely read: this call *is* the redraw the mark
|
|
// asked for, and leaving the mark set is what stranded a widget's
|
|
// primitives. `Painter::draw_twice` calls this twice for the same id
|
|
// in one frame (`List::place`'s measurement pass), and on the second
|
|
// call the still-set mark took the whole `if let` below -- including
|
|
// the `remove` that frees the first draw's primitives -- out of play,
|
|
// so `active.insert` at the end overwrote the only handles that could
|
|
// ever have freed them. The result is a full second copy of the row,
|
|
// drawn every frame from then on at the oversized measurement region
|
|
// and, with `List` setting no mask, outside the list's own bounds:
|
|
// the doubled `Compacted:` row in docs/bench/iris-phone-v2-2026-09-06.md.
|
|
// The same shape reaches any dirty widget an ancestor redraws first.
|
|
let dirty = rsc.widgets_mut().needs_redraw.remove(&id);
|
|
if let Some(active) = self.active.get_mut(&id)
|
|
&& !dirty
|
|
{
|
|
// 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;
|
|
}
|
|
// `move_applied` is deliberately **not** touched here,
|
|
// unlike in `mov`: it counts the part of this widget's own
|
|
// move-slot delta that `region` has already absorbed, and
|
|
// this branch writes no delta at all -- the primitives were
|
|
// moved directly. Counting one would make
|
|
// `resolved_region` subtract a distance the chain never
|
|
// held, putting the hit box short of the drawing by
|
|
// exactly this step. See `ActiveData::move_applied`, and
|
|
// `a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at`.
|
|
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);
|
|
own_mask = active.own_mask;
|
|
} else if dirty && self.active.contains_key(&id) {
|
|
// Dirty and already drawn: none of the fast paths above may be
|
|
// taken (the widget's own content changed, so its old primitives
|
|
// say nothing about its new ones), but they are also the only
|
|
// thing that frees them. Same two lines, reached the other way.
|
|
let active = self.remove(id, false, rsc).unwrap();
|
|
old_children = active.children;
|
|
old_move_slot = Some(active.move_slot);
|
|
own_mask = active.own_mask;
|
|
}
|
|
|
|
// draw widget
|
|
let reentrant = !self.draw_started.insert(id);
|
|
debug_assert!(
|
|
!reentrant,
|
|
"widget {id:?} is being drawn while its own draw is already on the stack; \
|
|
the second draw's primitives would orphan the first's"
|
|
);
|
|
|
|
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
|
|
}
|
|
};
|
|
|
|
// The mask this widget was drawn *under*, kept aside because
|
|
// `Painter::set_mask` overwrites `painter.mask` with the widget's
|
|
// own new one -- and `ActiveData::mask`'s only consumer is
|
|
// `redraw`, which feeds it back in as the *inherited* mask. Storing
|
|
// the set one instead handed a `Masked` its own mask on every
|
|
// targeted redraw, tripping `set_mask`'s nested-mask assert:
|
|
// `assertion failed: self.mask == MaskIdx::NONE`, an abort the
|
|
// first time the composer's scroll area was redrawn on the
|
|
// emulator.
|
|
let inherited_mask = mask;
|
|
let mut painter = Painter {
|
|
state: self,
|
|
region,
|
|
mask,
|
|
move_slot,
|
|
own_mask,
|
|
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);
|
|
// A reported length is consumed by containers that read `abs`,
|
|
// `rel` and `rest` straight off it (`Span`'s placement, `Pad`'s
|
|
// addition), so an unresolved `dp` in one is silently worth zero
|
|
// -- see `Len::fold_dp`, which is what a widget reporting a
|
|
// caller-declared size has to put it through.
|
|
debug_assert!(
|
|
size.x.dp == 0.0 && size.y.dp == 0.0,
|
|
"widget {id:?} reported an unresolved `dp` size ({size:?}); \
|
|
report `Len::fold_dp(painter.density())` instead"
|
|
);
|
|
drop(widget);
|
|
painter.state.draw_started.remove(&id);
|
|
|
|
let Painter {
|
|
state: _,
|
|
rsc: _,
|
|
region,
|
|
mask: _,
|
|
move_slot,
|
|
own_mask,
|
|
textures,
|
|
primitives,
|
|
children,
|
|
layer,
|
|
id,
|
|
} = painter;
|
|
|
|
// add to active
|
|
let active = ActiveData {
|
|
id,
|
|
region,
|
|
parent,
|
|
textures,
|
|
primitives,
|
|
children,
|
|
mask: inherited_mask,
|
|
layer,
|
|
size,
|
|
move_slot,
|
|
own_mask,
|
|
move_applied: Vec2::ZERO,
|
|
repositioned: Vec2::ZERO,
|
|
};
|
|
|
|
// 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;
|
|
active.move_applied += delta;
|
|
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 move_applied = active.move_applied;
|
|
let repositioned = active.repositioned;
|
|
let from = active
|
|
.size
|
|
.to_uivec2(self.density)
|
|
.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;
|
|
// Not `delta` alone: a parent may have `mov`ed this widget to a
|
|
// region that itself moved earlier in the same frame, and that
|
|
// part of the slot is `move_applied`'s, not this call's. Writing
|
|
// `delta` on its own dropped it and put the content back at the
|
|
// pre-move position. `from` is computed against `active.region`,
|
|
// which `mov` already updated, so `delta` is purely the placement
|
|
// inside the region and the two summands never overlap.
|
|
let entry = rsc.ui_mut().move_offsets.get_mut(slot);
|
|
debug_assert_eq!(
|
|
entry.delta,
|
|
[
|
|
move_applied.x + repositioned.x,
|
|
move_applied.y + repositioned.y
|
|
],
|
|
"widget {id:?}'s move slot was written by something other than `mov`/`reposition`; \
|
|
the slot is theirs and means `move_applied + repositioned` -- see `ActiveData`"
|
|
);
|
|
entry.delta = [move_applied.x + delta.x, move_applied.y + delta.y];
|
|
if let Some(active) = self.active.get_mut(&id) {
|
|
active.repositioned = delta;
|
|
}
|
|
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 {
|
|
// A captured widget that goes away mid-gesture (List's
|
|
// virtualisation retiring a row, a rebuild) must not leave
|
|
// the pointer permanently captured by an id nothing will
|
|
// ever draw again -- `captured`'s own path out.
|
|
if *self.captured.lock().unwrap() == Some(id) {
|
|
*self.captured.lock().unwrap() = None;
|
|
}
|
|
// 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).
|
|
if active.own_mask != MaskIdx::NONE {
|
|
// The self-ownership ref `Painter::set_mask` took when
|
|
// it allocated this widget's own mask slot.
|
|
rsc.ui_mut().masks.remove(active.own_mask);
|
|
}
|
|
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()
|
|
}
|
|
|
|
/// Primitive instances still bound for the GPU whose owner is no
|
|
/// longer in `active`, or whose owner's `ActiveData` no longer names
|
|
/// them: a copy nothing can move, clip, resize or free, redrawn every
|
|
/// frame at whatever position it last had. `(layer, inst_idx, owner)`
|
|
/// each.
|
|
///
|
|
/// Asserted empty at the end of every [`Self::update`], because this
|
|
/// is exactly the shape of the duplicated transcript row on Iris's
|
|
/// phone (`docs/bench/iris-phone-v2-2026-09-06.md`): counting
|
|
/// `active` alone cannot see it, since the orphan's owner is very
|
|
/// much alive -- it is the *earlier* set of primitives that got
|
|
/// stranded when the widget was drawn a second time without the first
|
|
/// draw being freed. O(primitives), debug builds only.
|
|
pub fn orphaned_primitives(&self) -> Vec<(usize, usize, WidgetId)> {
|
|
let mut orphans = Vec::new();
|
|
for (layer, primitives) in self.layers.iter() {
|
|
for (inst_idx, owner, is_image) in primitives.live_instances() {
|
|
let owned = self.active.get(&owner).is_some_and(|a| {
|
|
a.primitives.iter().any(|h| {
|
|
h.layer == layer
|
|
&& h.inst_idx == inst_idx
|
|
&& (h.binding == IMAGE_BINDING) == is_image
|
|
})
|
|
});
|
|
if !owned {
|
|
orphans.push((layer, inst_idx, owner));
|
|
}
|
|
}
|
|
}
|
|
orphans
|
|
}
|
|
|
|
/// Whether every primitive still bound for the GPU is owned by a live
|
|
/// widget, decided by counting rather than by walking: an orphan is a
|
|
/// live instance no `ActiveData` names, so it can only ever make the
|
|
/// live count exceed the owned one. O(active widgets) -- a few dozen --
|
|
/// against [`Self::orphaned_primitives`]'s O(primitives), which on a
|
|
/// transcript is tens of thousands and made a debug build on a phone
|
|
/// too slow to finish a benchmark run.
|
|
fn primitive_counts_agree(&self) -> bool {
|
|
let live: usize = self.layers.iter().map(|(_, p)| p.live_count()).sum();
|
|
let owned: usize = self.active.values().map(|a| a.primitives.len()).sum();
|
|
live == owned
|
|
}
|
|
|
|
/// The message [`Self::update`]'s orphan assert prints -- built here
|
|
/// rather than inline so the (allocating, O(primitives)) work only
|
|
/// happens on the failing path.
|
|
#[cfg(debug_assertions)]
|
|
fn orphan_report(&self, rsc: &dyn UiRsc) -> String {
|
|
let orphans = self.orphaned_primitives();
|
|
let mut lines: Vec<String> = orphans
|
|
.iter()
|
|
.take(8)
|
|
.map(|(layer, idx, owner)| {
|
|
let alive = self.active.contains_key(owner);
|
|
format!(
|
|
" layer {layer} instance {idx}: owner '{}' ({owner:?}), owner still active: {alive}",
|
|
rsc.widgets().label(*owner),
|
|
)
|
|
})
|
|
.collect();
|
|
if orphans.len() > lines.len() {
|
|
lines.push(format!(" ... and {} more", orphans.len() - lines.len()));
|
|
}
|
|
format!(
|
|
"{} primitive(s) are drawn but owned by nobody -- a stale copy \
|
|
nothing will ever move or free:\n{}",
|
|
orphans.len(),
|
|
lines.join("\n"),
|
|
)
|
|
}
|
|
|
|
/// Give `id` exclusive pointer input from the next `run_sensors` call
|
|
/// on -- see `captured`'s field doc. Overwrites any previous capture
|
|
/// (a gesture that starts a new one has already decided the old one
|
|
/// is over).
|
|
pub fn capture_pointer(&self, id: WidgetId) {
|
|
*self.captured.lock().unwrap() = Some(id);
|
|
}
|
|
|
|
/// Release exclusive pointer input, if any is held -- called once
|
|
/// `run_sensors` has delivered the terminal `Drop` to the capturing
|
|
/// widget, or by that widget itself if it decides the gesture is over
|
|
/// some other way.
|
|
pub fn release_pointer(&self) {
|
|
*self.captured.lock().unwrap() = None;
|
|
}
|
|
|
|
/// The widget currently holding exclusive pointer input, if any.
|
|
pub fn captured_pointer(&self) -> Option<WidgetId> {
|
|
*self.captured.lock().unwrap()
|
|
}
|
|
|
|
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())?;
|
|
// The chain sum is what the shader adds to this widget's
|
|
// *primitives*, which were written before any of those moves.
|
|
// `region`, unlike them, has already been shifted by whatever
|
|
// part of this widget's own slot `mov` put there -- see
|
|
// `ActiveData::move_applied`, which is exactly that part.
|
|
let delta = self.resolve_move_chain(active.move_slot, rsc) - active.move_applied;
|
|
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);
|
|
// An ancestor is drawing this widget right now, and that draw is
|
|
// about to write fresh primitives for it. Drawing it a second time
|
|
// here would leave one of the two copies on screen with nothing
|
|
// owning it -- see `draw_started`'s own doc.
|
|
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),
|
|
active.own_mask,
|
|
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()
|
|
}
|
|
}
|