Move a subtree by writing one slot

`try_reuse`'s pure-translation case now writes the widget's move slot
instead of remapping every primitive in its subtree. Counted on a span
of 20 rows, each five primitives deep, when the row above them changes
height:

  before   100 primitive region writes
  now        0, and 20 slot writes -- one per row the span re-placed

`window_region` walks the same chain on the CPU, so hit testing and
anyone asking in window pixels see a widget where the shader draws it.
`Moves::resolve` stops at `CHAIN_LIMIT` like the shader, and asserts in
debug that it got to the end rather than running out.

Two things fall out of it. Rewriting a region is the one thing a slot
cannot express, so `mov` zeroes the slots of everything it rewrites: a
region is what its slot was a delta from. And `try_reuse` loses its
`old == region` shortcut, which was wrong once a slot exists -- a widget
offered exactly the box it drew against has to have its delta cleared,
not skipped.

`Moves` lives on `UiRenderState` rather than `UiData`, because the draw
is what produces it and `window_region` should not need the ui's
resources to answer where something is. The renderer already takes both.

A slot is retired in `remove_rec`, after the descendants whose slots
name it as their parent. Either order is correct here -- nothing can
claim a freed index while a subtree is coming down, since `on_undraw`
cannot reach the slots -- but this way `remove`'s `undraw` flag only
notifies rather than also deciding slot lifetime, and the retirement
sits beside the recursion it follows.

Checked: fmt, clippy and 41 tests. `tabs` (with the image replay),
`view`, `minimal` and `text` all still render byte-identical, and the
live sway resize round trip -- which re-places most of the tree at the
same size, so it is the slot path throughout -- matches a cold start at
each size.
This commit is contained in:
iris-ai committed 2026-09-14 03:15:17 -04:00
1 parent f9ef7514e7
commit 8223a55cfb
4 files changed
+68 -32

No files matched your search

+3 -3
View File
@@ -133,9 +133,9 @@ impl UiRenderNode {
ui.masks.changed = false; ui.masks.changed = false;
regroup |= self.masks.update(device, queue, &ui.masks[..]); regroup |= self.masks.update(device, queue, &ui.masks[..]);
} }
if ui.moves.changed { if ui_render.moves.changed {
ui.moves.changed = false; ui_render.moves.changed = false;
regroup |= self.moves.update(device, queue, ui.moves.entries()); regroup |= self.moves.update(device, queue, ui_render.moves.entries());
} }
if regroup { if regroup {
self.shared_group = Self::shared_group( self.shared_group = Self::shared_group(
+8 -5
View File
@@ -25,12 +25,10 @@ pub struct UiData {
pub textures: Textures, pub textures: Textures,
pub text: TextData, pub text: TextData,
pub masks: TrackedArena<Mask, u32>, pub masks: TrackedArena<Mask, u32>,
/// Where each widget's drawing sits relative to its parent's slot, so
/// moving a subtree writes one entry rather than every descendant's
/// primitives.
pub moves: Moves,
} }
/// Where each widget's drawing sits relative to its parent's slot, so moving
/// a subtree writes one entry rather than every descendant's primitives.
#[derive(Default)] #[derive(Default)]
pub struct Moves { pub struct Moves {
arena: Arena<MoveOffset, u32>, arena: Arena<MoveOffset, u32>,
@@ -64,12 +62,17 @@ impl Moves {
let mut at = idx; let mut at = idx;
for _ in 0..CHAIN_LIMIT { for _ in 0..CHAIN_LIMIT {
if at == MoveIdx::NONE { if at == MoveIdx::NONE {
break; return total;
} }
let entry = self.arena[at.idx()]; let entry = self.arena[at.idx()];
total += entry.delta; total += entry.delta;
at = entry.parent; at = entry.parent;
} }
debug_assert!(
at == MoveIdx::NONE,
"a move chain longer than {CHAIN_LIMIT} resolves to the wrong place, \
and the shader stops at the same depth"
);
total total
} }
+40 -24
View File
@@ -1,6 +1,6 @@
use crate::{ use crate::{
ActiveData, Axis, DrawLayers, IdLike, MaskIdx, MoveIdx, OnResize, Painter, PixelRegion, Remap, ActiveData, Axis, DrawLayers, IdLike, MaskIdx, MoveIdx, Moves, OnResize, Painter, PixelRegion,
Size, StrongWidget, UiRegion, UiRsc, WidgetId, Widgets, Remap, Size, StrongWidget, UiRegion, UiRsc, WidgetId, Widgets,
util::{HashMap, HashSet, Vec2, forget_ref}, util::{HashMap, HashSet, Vec2, forget_ref},
}; };
@@ -14,7 +14,8 @@ pub struct UiRenderState {
draw_started: HashSet<WidgetId>, draw_started: HashSet<WidgetId>,
/// A widget's move slot, which outlives any one `ActiveData`: a redraw /// A widget's move slot, which outlives any one `ActiveData`: a redraw
/// replaces that while its children go on pointing at the slot. /// replaces that while its children go on pointing at the slot.
moves: HashMap<WidgetId, MoveIdx>, slots: HashMap<WidgetId, MoveIdx>,
pub moves: Moves,
} }
impl UiRenderState { impl UiRenderState {
@@ -26,6 +27,7 @@ impl UiRenderState {
old_root: None, old_root: None,
resized: false, resized: false,
draw_started: Default::default(), draw_started: Default::default(),
slots: Default::default(),
moves: Default::default(), moves: Default::default(),
} }
} }
@@ -107,7 +109,8 @@ impl UiRenderState {
} }
// draw widget // draw widget
let move_idx = self.move_slot(id, parent, rsc); let move_idx = self.move_slot(id, parent);
self.moves.set(move_idx, Vec2::ZERO);
rsc.widgets_mut().needs_redraw.remove(&id); rsc.widgets_mut().needs_redraw.remove(&id);
self.draw_started.insert(id); self.draw_started.insert(id);
@@ -181,20 +184,15 @@ impl UiRenderState {
/// The slot a widget's drawing is positioned through, made on its first /// The slot a widget's drawing is positioned through, made on its first
/// draw and kept until it stops being drawn. /// draw and kept until it stops being drawn.
fn move_slot( fn move_slot(&mut self, id: WidgetId, parent: Option<WidgetId>) -> MoveIdx {
&mut self, if let Some(&idx) = self.slots.get(&id) {
id: WidgetId,
parent: Option<WidgetId>,
rsc: &mut dyn UiRsc,
) -> MoveIdx {
if let Some(&idx) = self.moves.get(&id) {
return idx; return idx;
} }
let parent = parent let parent = parent
.and_then(|p| self.moves.get(&p).copied()) .and_then(|p| self.slots.get(&p).copied())
.unwrap_or(MoveIdx::NONE); .unwrap_or(MoveIdx::NONE);
let idx = rsc.ui_mut().moves.push(parent); let idx = self.moves.push(parent);
self.moves.insert(id, idx); self.slots.insert(id, idx);
idx idx
} }
@@ -205,12 +203,18 @@ impl UiRenderState {
return None; return None;
} }
let active = self.active.get(&id)?; let active = self.active.get(&id)?;
let (size, old) = (active.size, active.region); let (size, old, slot) = (active.size, active.region, active.move_idx);
if old == region { // TODO: epsilon?
if old.size() == region.size() {
// The right shape and only somewhere else, which is one slot to
// write however much is under it. Both boxes are in the
// coordinates its parent drew, so the chain above applies alike.
let moved =
region.to_px(self.output_size).top_left - old.to_px(self.output_size).top_left;
self.moves.set(slot, moved);
return Some(size); return Some(size);
} }
// TODO: epsilon? if !self.reusable(id, region, rsc) {
if old.size() != region.size() && !self.reusable(id, region, rsc) {
return None; return None;
} }
// Its drawing stands, if the new box can be reached from the old one. // Its drawing stands, if the new box can be reached from the old one.
@@ -255,6 +259,9 @@ impl UiRenderState {
}) })
} }
/// Rewrites a subtree's regions into a new box, for a change a slot
/// cannot express. Every region it rewrites is a region some slot was a
/// delta from, so those go back to zero.
fn mov(&mut self, id: WidgetId, remap: &Remap) { fn mov(&mut self, id: WidgetId, remap: &Remap) {
let active = self.active.get_mut(&id).unwrap(); let active = self.active.get_mut(&id).unwrap();
for h in &active.primitives { for h in &active.primitives {
@@ -262,6 +269,7 @@ impl UiRenderState {
*region = remap.apply(*region); *region = remap.apply(*region);
} }
active.region = remap.apply(active.region); active.region = remap.apply(active.region);
self.moves.set(active.move_idx, Vec2::ZERO);
// SAFETY: children cannot be recursive // SAFETY: children cannot be recursive
let children = unsafe { forget_ref(&active.children) }; let children = unsafe { forget_ref(&active.children) };
for child in children { for child in children {
@@ -282,9 +290,6 @@ impl UiRenderState {
active.textures.clear(); active.textures.clear();
rsc.ui_mut().textures.free(); rsc.ui_mut().textures.free();
if undraw { if undraw {
if let Some(idx) = self.moves.remove(&id) {
rsc.ui_mut().moves.remove(idx);
}
rsc.on_undraw(active); rsc.on_undraw(active);
} }
} }
@@ -298,6 +303,10 @@ impl UiRenderState {
self.remove_rec(*c, rsc); 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 inst
} }
@@ -305,8 +314,8 @@ impl UiRenderState {
for (_, active) in self.active.drain() { for (_, active) in self.active.drain() {
rsc.on_undraw(&active); rsc.on_undraw(&active);
} }
self.slots.clear();
self.moves.clear(); self.moves.clear();
rsc.ui_mut().moves.clear();
self.layers.clear(); self.layers.clear();
rsc.widgets_mut().needs_redraw.clear(); rsc.widgets_mut().needs_redraw.clear();
rsc.free(); rsc.free();
@@ -354,9 +363,16 @@ impl UiRenderState {
} }
} }
/// Where a widget is on screen: the box it drew against plus whatever the
/// chain above has moved since, which is the walk the vertex shader does.
pub fn window_region(&self, id: &impl IdLike) -> Option<PixelRegion> { pub fn window_region(&self, id: &impl IdLike) -> Option<PixelRegion> {
let region = self.active.get(&id.id())?.region; let active = self.active.get(&id.id())?;
Some(region.to_px(self.output_size)) let moved = self.moves.resolve(active.move_idx);
let region = active.region.to_px(self.output_size);
Some(PixelRegion {
top_left: region.top_left + moved,
bot_right: region.bot_right + moved,
})
} }
/// redraws a widget that's currently active (drawn) /// redraws a widget that's currently active (drawn)
+17
View File
@@ -109,3 +109,20 @@ fn a_fixed_box_is_drawn_again_rather_than_stretched() {
assert_corners!(h, panel, (0, 0), (400, 250)); assert_corners!(h, panel, (0, 0), (400, 250));
} }
#[test]
fn a_moved_subtree_takes_its_children_with_it() {
let mut h = Harness::new((400, 400));
let first = rect(Color::RED).height(40).add(&mut h.rsc);
let inner = rect(Color::BLUE).add(&mut h.rsc);
let row = inner.pad(10).height(40).add(&mut h.rsc);
h.set_root((first, row).span(Dir::DOWN));
assert_corners!(h, inner, (10, 50), (390, 70));
h.rsc[first].y = Some(Len::abs(80));
h.frame();
// The row is the same shape somewhere else, so one slot moved it and
// `inner`'s own region was never rewritten.
assert_corners!(h, inner, (10, 90), (390, 110));
}