Creating or freeing a frame or region re-derives only what it touches (#915)

Any write that changed the layout graph's roster (a frame or region created, freed, re-owned or re-pointed) made the next resolve derive the whole graph: about 1.35 ms for one `CreateTexture` or `CreateFrame` on the stock UI, against 0.05 ms for a no-op.

A write now names the nodes it touched, and the resolve re-derives just those in the cached graph: a joining node is added with its edges, a leaving one is removed and its dependents seeded, a staying one has its edges and inputs re-synced. The full derivation stays the fallback. A region's owner and an art change are now layout inputs that name their region. Verify now also compares the graph itself.

A randomized test checks rects and graph against a scratch solve after every batch of creates, frees, re-owns and re-anchors. `CreateTexture` now costs 0.14 ms, `CreateFrame` 0.15 ms, a backdrop rebuild 0.22 ms.

Fixes #903

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-12 01:05:23 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 55729baff9
commit fa5568dcdc
32 changed files with 1318 additions and 376 deletions
+108 -29
View File
@@ -73,6 +73,19 @@ fn app_frame_ticked(s: &mut UiScript, dt: f32) {
s.resolve();
}
/// A forced rebuild counts a derivation: the positive control behind every zero asserted on
/// `layout_derivations`, now that births derive nothing.
fn derivations_move(s: &mut UiScript) {
let before = s.layout_derivations();
s.force_full_layout_resolve();
s.resolve();
assert!(
s.layout_derivations() > before,
"a forced rebuild must count a derivation — reading zero means `layout_derivations` never \
moves and every zero asserted on it is vacuous"
);
}
/// A tooltip whose lines change width every call, as in a hover sweep across a bag grid.
fn install_changing_tooltip(s: &UiScript, owner: &str, func: &str) {
s.run(&format!(
@@ -267,8 +280,7 @@ fn a_region_moving_every_frame_costs_one_gate_walk_on_the_shipped_ui() {
/// Those walks cost zero derivations of the layout graph: a moving region names its node, so the
/// resolve re-hashes one node, not the roster; a derivation per frame means a write site fell back
/// to the conservative `touch_layout`. The VM ticks, so the shipped handlers are held to it too,
/// and a birth must derive, which proves the counter moves.
/// to the conservative `touch_layout`. The VM ticks, so the shipped handlers are held to it too.
#[test]
fn a_region_moving_every_frame_costs_no_graph_derivation_on_the_shipped_ui() {
benilla_formats::wow_data_or_skip!();
@@ -290,18 +302,18 @@ fn a_region_moving_every_frame_costs_no_graph_derivation_on_the_shipped_ui() {
)
.unwrap();
// Positive control: a birth moves the roster, so it must derive.
// Births join the cached graph without a derivation.
let born_at = s.layout_derivations();
for _ in 0..6 {
s.run("bench_spark2_frame()").unwrap();
app_frame_ticked(&mut s, 1.0 / 144.0);
}
assert!(
s.layout_derivations() > born_at,
"creating a frame and a texture must derive the graph — a birth moves the roster, which \
is the one thing the per-node ledger cannot describe. Reading zero here means \
`layout_derivations` never moves and the assertion below is vacuous."
assert_eq!(
s.layout_derivations() - born_at,
0,
"creating a frame and a texture derived the layout graph: a birth names its nodes, which join the cached graph"
);
derivations_move(&mut s);
let derives_before = s.layout_derivations();
let walks_before = s.layout_gate_walks();
@@ -435,17 +447,18 @@ fn a_tooltip_line_flipping_wrapped_to_plain_costs_no_graph_derivation() {
)
.unwrap();
// Positive control: the owner's birth and the line pool's growth move the roster and derive.
// Births join the cached graph without a derivation.
let born_at = s.layout_derivations();
for _ in 0..8 {
s.run("flip_change()").unwrap();
app_frame(&mut s);
}
assert!(
s.layout_derivations() > born_at,
"creating the owner and growing the tooltip's line pool must derive the graph — reading \
zero here means `layout_derivations` never moves and the assertion below is vacuous."
assert_eq!(
s.layout_derivations() - born_at,
0,
"creating the owner and growing the line pool derived the layout graph: a birth names its nodes, which join the cached graph"
);
derivations_move(&mut s);
let derives_before = s.layout_derivations();
for _ in 0..10 {
@@ -493,17 +506,18 @@ fn a_hover_sweep_across_owners_costs_no_graph_derivation() {
)
.unwrap();
// Positive control: twelve births and the line pool's growth move the roster and derive.
// Births join the cached graph without a derivation.
let born_at = s.layout_derivations();
for _ in 0..40 {
s.run("sweep_change()").unwrap();
app_frame(&mut s);
}
assert!(
s.layout_derivations() > born_at,
"creating twelve owners and growing the line pool must derive the graph — reading zero \
here means `layout_derivations` never moves and the assertion below proves nothing."
assert_eq!(
s.layout_derivations() - born_at,
0,
"creating twelve owners and growing the line pool derived the layout graph: a birth names its nodes, which join the cached graph"
);
derivations_move(&mut s);
// Two laps of the twelve owners: every step re-hovers a button the graph already knows.
let derives_before = s.layout_derivations();
@@ -515,9 +529,9 @@ fn a_hover_sweep_across_owners_costs_no_graph_derivation() {
assert_eq!(
derives, 0,
"24 hovers across 12 owners derived the layout graph {derives} times — one per slot \
crossed. `SetOwner` re-points ONE node's anchor and must patch that node's edges \
(`Model::touch_layout_retarget_frame`); the conservative touch re-derives the whole \
roster on every slot the cursor passes over."
crossed. `SetOwner` re-points ONE node's anchor and must name that node \
(`Model::touch_layout_frame`), whose edges re-derive in the cached graph; the \
conservative touch re-derives the whole roster on every slot the cursor passes over."
);
}
@@ -556,16 +570,18 @@ fn an_action_bar_hover_sweep_costs_no_graph_derivation() {
)
.unwrap();
// Positive control: the births and the line pool's growth are structural.
// Births join the cached graph without a derivation.
let born_at = s.layout_derivations();
for _ in 0..40 {
s.run("bar_hover()").unwrap();
app_frame(&mut s);
}
assert!(
s.layout_derivations() > born_at,
"creating twelve buttons must derive the graph — zero here makes the assertion vacuous."
assert_eq!(
s.layout_derivations() - born_at,
0,
"creating twelve buttons derived the layout graph: a birth names its nodes, which join the cached graph"
);
derivations_move(&mut s);
let derives_before = s.layout_derivations();
for _ in 0..24 {
@@ -623,16 +639,18 @@ fn a_bag_addon_hover_sweep_costs_no_graph_derivation() {
)
.unwrap();
// Positive control: the births and the line pool's growth are structural.
// Births join the cached graph without a derivation.
let born_at = s.layout_derivations();
for _ in 0..40 {
s.run("bag_hover()").unwrap();
app_frame(&mut s);
}
assert!(
s.layout_derivations() > born_at,
"creating twelve slots must derive the graph — zero here makes the assertion vacuous."
assert_eq!(
s.layout_derivations() - born_at,
0,
"creating twelve slots derived the layout graph: a birth names its nodes, which join the cached graph"
);
derivations_move(&mut s);
let derives_before = s.layout_derivations();
for _ in 0..24 {
@@ -647,3 +665,64 @@ fn a_bag_addon_hover_sweep_costs_no_graph_derivation() {
EMPTY set and names its node like any other."
);
}
/// What one structural change costs on the full shipped UI, per run, resolve and extract: a no-op,
/// a `SetWidth`, a `CreateTexture`, a `CreateFrame` and a backdrop rebuild, each as the mean of
/// 200, with the graph derivations it paid beside it.
#[test]
#[ignore = "bench, run explicitly"]
fn structural_change_costs() {
let mut s = settled_default_ui();
s.run(
r#"
BenchHost = CreateFrame("Frame", "BenchHost", UIParent)
BenchHost:SetPoint("CENTER", 0, 0); BenchHost:SetWidth(200); BenchHost:SetHeight(100)
bench_n = 0
bench_spec = { bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", tile = true, tileSize = 16,
edgeSize = 16, insets = { left = 4, right = 4, top = 4, bottom = 4 } }
"#,
)
.unwrap();
for _ in 0..4 {
app_frame(&mut s);
}
let cases: [(&str, &str); 5] = [
("no-op", "bench_n = bench_n + 1"),
(
"SetWidth",
"bench_n = bench_n + 1; BenchHost:SetWidth(200 + math.mod(bench_n, 2))",
),
(
"CreateTexture",
"bench_n = bench_n + 1; local t = BenchHost:CreateTexture(nil, 'ARTWORK'); \
t:SetWidth(4); t:SetHeight(4); t:SetPoint('TOPLEFT', BenchHost, 'TOPLEFT', \
math.mod(bench_n, 50), 0)",
),
(
"CreateFrame",
"bench_n = bench_n + 1; local f = CreateFrame('Frame', nil, BenchHost); \
f:SetWidth(4); f:SetHeight(4); f:SetPoint('TOPLEFT', BenchHost, 'TOPLEFT', \
math.mod(bench_n, 50), 0)",
),
(
"SetBackdrop",
"bench_n = bench_n + 1; BenchHost:SetBackdrop(bench_spec)",
),
];
const N: u32 = 200;
for (name, lua) in cases {
let d0 = s.layout_derivations();
let t = Instant::now();
for _ in 0..N {
s.run(lua).unwrap();
app_frame(&mut s);
std::hint::black_box(s.extract());
}
println!(
"STRUCT {name:<14} {:.3} ms/iter derivations/iter={:.2}",
ms(t) / f64::from(N),
(s.layout_derivations() - d0) as f64 / f64::from(N),
);
}
}
+6 -7
View File
@@ -331,16 +331,13 @@ impl Installed {
/// the next resolve runs them. Each piece is a fresh Texture linked at the tail of the region
/// list, so a second backdrop's pieces come after any region made in between.
pub(crate) fn install(model: &mut Model, fh: FrameHandle, spec: Option<Backdrop>) {
// One layout touch for the whole swap, the pieces it frees and the ones it builds: the next
// resolve re-derives the graph once either way.
// Each freed piece names its node, and each built one below, so the next resolve takes the
// swap through the cached graph.
let old = model.backdrops.remove(&fh);
for rh in old.iter().flat_map(|o| o.pieces).flatten() {
super::region::release_region(model, rh);
super::region::free_region(model, rh);
}
let Some(spec) = spec.filter(|_| model.arena.frame(fh).is_some()) else {
if old.is_some() {
model.touch_layout();
}
return;
};
let owner = model.frame_id(fh);
@@ -399,6 +396,9 @@ pub(crate) fn install(model: &mut Model, fh: FrameHandle, spec: Option<Backdrop>
);
}
}
for rh in pieces.iter().flatten() {
model.touch_layout_region(*rh);
}
model.backdrops.insert(
fh,
Installed {
@@ -407,7 +407,6 @@ pub(crate) fn install(model: &mut Model, fh: FrameHandle, spec: Option<Backdrop>
ran_for: None,
},
);
model.touch_layout();
if let Some(rect) = model.resolved.get(&fh).copied() {
run_texcoords(model, fh, rect);
}
+4 -3
View File
@@ -284,7 +284,7 @@ fn ensure_slot(lua: &Lua, this: &Table, slot: Slot) -> mlua::Result<u32> {
.create_region(h, kind, layer, 0)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
model.region_data.insert(rh, RegionData::default());
model.touch_layout(); // a region entered the layout gate's read set
model.touch_layout_region(rh);
if let Some(frame) = model.arena.frame_mut(h) {
if let KindState::Button(bs) = &mut frame.kind_state {
slot.set(bs, Some(rh));
@@ -418,6 +418,7 @@ fn set_slot_texture(
if let Some(blend) = blend {
data.blend = blend;
}
model.touch_layout_art(rh);
Ok(())
}
@@ -571,8 +572,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
}
super::region::link_region(&mut model, rh, owner, DrawLayer::Artwork);
adopt_label(&mut model, owner, rh);
// The layout's read set moved, and `GetTextWidth` reads the new label's extents.
model.touch_layout();
// A new owner, so a new scale for its node, and `GetTextWidth` reads its extents.
model.touch_layout_region(rh);
model.touch_measure(rh);
Ok(())
})?,
+2 -1
View File
@@ -115,7 +115,7 @@ fn ensure_slot(lua: &Lua, this: &Table, slot: Slot, layer: Option<DrawLayer>) ->
.create_region(h, RegionKind::Texture, layer.unwrap_or(slot.layer()), 0)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
model.region_data.insert(rh, RegionData::default());
model.touch_layout(); // a region entered the layout gate's read set
model.touch_layout_region(rh);
if let Some(frame) = model.arena.frame_mut(h) {
if let KindState::ColorSelect(s) = &mut frame.kind_state {
slot.set(s, rh);
@@ -225,6 +225,7 @@ fn install_slot_texture(
// The empty form: the region now exists and stays file-less.
_ => {}
}
model.touch_layout_art(rh);
Ok(())
})?,
)?;
@@ -474,6 +474,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
data.texture = path;
data.circular = true;
data.portrait_unit = None;
model.touch_layout_art(rh);
Ok(())
})?,
)?;
+1 -1
View File
@@ -461,7 +461,7 @@ fn ensure_text_region_in(model: &mut Model, h: FrameHandle) -> Option<RegionHand
};
apply_text_region_justify(&mut data, multi_line);
slot.insert(data);
model.touch_layout(); // a region entered the layout gate's read set
model.touch_layout_region(rh);
}
if let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
eb.text_region = Some(rh);
@@ -58,9 +58,7 @@ pub(super) fn seat_text_region(model: &mut Model, h: FrameHandle) {
data.anchors = anchors;
}
data.size = size;
if !same_anchors {
model.touch_layout();
} else if size_moved {
if !same_anchors || size_moved {
model.touch_layout_region(rh);
}
if size_moved {
+556 -110
View File
@@ -97,11 +97,13 @@ fn frame_node_hash(input: &LayoutInput, over: Option<Anchor>) -> u64 {
node.finish()
}
/// A region's per-node input hash: its anchors, explicit size and measured text extent.
/// A region's per-node input hash: its anchors, explicit size, measured text extent and owner,
/// whose effective scale the sweep reads (`None` for an owner with no layout id).
#[inline]
fn region_node_hash(data: &RegionData) -> u64 {
pub(super) fn region_node_hash(data: &RegionData, owner: Option<u32>) -> u64 {
let mut node = InputFingerprint::default();
node.anchors(&data.anchors);
node.feed(owner.map_or(u64::MAX, u64::from));
match data.size {
Some((w, h)) => {
node.f32(w);
@@ -168,6 +170,11 @@ const REGION_TAG: u32 = 0x8000_0000;
/// Per-node layout scope, so a resolve touches only what moved. Indexed densely by layout id, as
/// frames and regions share one counter (`Model::next_id`); nothing allocates on a steady frame.
///
/// The graph is derived in full once, then kept: a write names the ids it touched
/// ([`Model::touch_layout_frame`]), and the next resolve re-derives just those nodes against the
/// model ([`UiScript::rederive_named`]), joining, leaving or re-pointing them in place, the way a
/// retained layout tree marks a node dirty rather than rebuilding itself.
#[derive(Default)]
pub(crate) struct LayoutScope {
/// Per id: the hash the last converged resolve saw, or [`NO_NODE`]; a mismatch seeds dirty.
@@ -182,8 +189,10 @@ pub(crate) struct LayoutScope {
dep_head: Vec<u32>,
dep_to: Vec<u32>,
dep_next: Vec<u32>,
/// Edge slots [`Self::retarget`] freed, for [`Self::edge`] to reuse rather than grow `dep_to`.
/// Edge slots [`Self::unlink_edge`] freed, for [`Self::edge`] to reuse rather than grow `dep_to`.
dep_free: Vec<u32>,
/// Per id: the targets of the edges it recorded, in order, so a re-derive unlinks exactly those.
out: Vec<Vec<u32>>,
/// Per id: in the dirty closure.
dirty: Vec<bool>,
/// The closure's worklist.
@@ -192,12 +201,20 @@ pub(crate) struct LayoutScope {
plan: Vec<(FrameHandle, u32, Option<Anchor>)>,
/// The cached roster of live, anchored regions.
regions: Vec<RegionRow>,
/// Per ScrollFrame id, the live child it held when last derived: the one frame whose anchors
/// its override replaces.
scroll_child: HashMap<u32, FrameHandle>,
/// Per child, the ScrollFrames holding it, `(handle, id)`: [`Self::scroll_child`] inverted, so
/// a re-derived frame finds its override without a scan.
claims: HashMap<FrameHandle, Vec<(FrameHandle, u32)>>,
/// An incremental pass's `(id, hash)` writes to [`Self::last`], applied only on convergence.
staged: Vec<(u32, u64)>,
/// A re-derive's target list, reused.
targets: Vec<u32>,
}
/// One live, anchored region as the round loop consumes it, built once per derivation.
#[derive(Clone, Copy)]
/// One live, anchored region as the round loop consumes it, a row of the cached roster.
#[derive(Clone, Copy, PartialEq)]
struct RegionRow {
rh: RegionHandle,
/// The layout id: the solver's array index and the scope's node key.
@@ -221,6 +238,9 @@ impl LayoutScope {
self.node_of.resize(n, u32::MAX);
self.dep_head.clear();
self.dep_head.resize(n, u32::MAX);
// Cleared in place, so a re-derivation reuses each list's allocation.
self.out.iter_mut().for_each(Vec::clear);
self.out.resize_with(n, Vec::new);
self.dirty.clear();
self.dirty.resize(n, false);
self.dep_to.clear();
@@ -229,24 +249,28 @@ impl LayoutScope {
self.stack.clear();
self.plan.clear();
self.regions.clear();
self.scroll_child.clear();
self.claims.clear();
self.staged.clear();
}
/// The incremental entry: the cached graph survives, and only the dirty marks reset.
fn begin_incremental(&mut self) {
/// The incremental entry: the cached graph survives, grown to `n` ids, and only the dirty
/// marks reset.
fn begin_incremental(&mut self, n: usize) {
if self.node_of.len() < n {
self.node_of.resize(n, u32::MAX);
self.dep_head.resize(n, u32::MAX);
self.out.resize_with(n, Vec::new);
}
if self.last.len() < self.node_of.len() {
self.last.resize(self.node_of.len(), NO_NODE);
}
self.dirty.clear();
self.dirty.resize(self.node_of.len(), false);
self.stack.clear();
self.staged.clear();
}
/// Whether `id` is a node of the cached graph, the only kind a precise touch may name.
pub(crate) fn has_node(&self, id: u32) -> bool {
self.node_of
.get(id as usize)
.is_some_and(|&n| n != u32::MAX)
}
/// Record that node `from` reads `to`'s rect, so a change at `to` reaches `from`.
#[inline]
fn edge(&mut self, from: u32, to: u32) {
@@ -269,23 +293,33 @@ impl LayoutScope {
*head = e;
}
/// Re-point node `id`'s outgoing edges after an anchor retarget. `old` and `new` are its full
/// anchor-target lists, so duplicate anchors stay exact. `false` means a conservative touch:
/// `id` has no node, or a ScrollFrame override, not its anchors, supplies its edges.
pub(crate) fn retarget(&mut self, id: u32, old: &[u32], new: &[u32]) -> bool {
let Some(&n) = self.node_of.get(id as usize) else {
/// [`Self::edge`], remembered as one of `from`'s own, for a later [`Self::relink`].
#[inline]
fn link(&mut self, from: u32, to: u32) {
self.edge(from, to);
if let Some(out) = self.out.get_mut(from as usize) {
out.push(to);
}
}
/// Point node `id`'s edges at `targets`, unlinking the ones it recorded; `true` when they
/// differed, which the caller seeds, as the node now reads other rects.
fn relink(&mut self, id: u32, targets: &[u32]) -> bool {
let Some(out) = self.out.get_mut(id as usize) else {
return false;
};
if n == u32::MAX {
if out.as_slice() == targets {
return false;
}
if n & REGION_TAG == 0 && self.plan[n as usize].2.is_some() {
return false; // a ScrollFrame override owns this frame's edges, not its anchors
}
for &t in old {
let old = std::mem::take(out);
for &t in &old {
self.unlink_edge(t, id);
}
for &t in new {
let mut old = old;
old.clear();
old.extend_from_slice(targets);
self.out[id as usize] = old;
for &t in targets {
self.edge(id, t);
}
true
@@ -325,6 +359,73 @@ impl LayoutScope {
}
}
/// A re-derived node's verdict: stage and seed it when its hash or its edges moved. Every edge
/// source (anchor targets, the owner, the override) is in the hash, so `relinked` only guards
/// against a hash that stops covering one.
#[inline]
fn note(&mut self, id: u32, hash: u64, relinked: bool) {
if relinked || hash != self.last.get(id as usize).copied().unwrap_or(NO_NODE) {
self.staged.push((id, hash));
self.seed(id);
}
}
/// Record that ScrollFrame `sid` (`sh`) now holds `child`; the child it held before, if it
/// changed, so the caller re-derives both.
fn hold(
&mut self,
sh: FrameHandle,
sid: u32,
child: Option<FrameHandle>,
) -> Option<FrameHandle> {
let before = match child {
Some(c) => self.scroll_child.insert(sid, c),
None => self.scroll_child.remove(&sid),
};
if before == child {
return None;
}
if let Some(old) = before {
if let Some(v) = self.claims.get_mut(&old) {
v.retain(|&(_, s)| s != sid);
if v.is_empty() {
self.claims.remove(&old);
}
}
}
if let Some(c) = child {
self.claims.entry(c).or_default().push((sh, sid));
}
before
}
/// Whether `id` is a node whose converged hash is `hash`: a write that leaves it there moved
/// nothing the resolve reads.
pub(crate) fn holds(&self, id: u32, hash: u64) -> bool {
self.node_of
.get(id as usize)
.is_some_and(|&n| n != u32::MAX)
&& self.last.get(id as usize) == Some(&hash)
}
/// Drop region node `id` from the roster with its edges, seeding it so what read its rect
/// re-resolves without it. Returns the row it held.
fn remove_region(&mut self, id: u32, n: u32) -> RegionRow {
let idx = (n & !REGION_TAG) as usize;
let row = self.regions.swap_remove(idx);
if let Some(moved) = self.regions.get(idx) {
#[allow(clippy::cast_possible_truncation)]
{
self.node_of[moved.id as usize] = idx as u32 | REGION_TAG;
}
}
self.node_of[id as usize] = u32::MAX;
self.relink(id, &[]);
self.staged.push((id, NO_NODE));
self.seed(id);
row
}
/// Close the dirty set under "depends on": whatever reads a dirty rect, transitively.
fn close(&mut self) {
while let Some(id) = self.stack.pop() {
@@ -353,7 +454,7 @@ impl LayoutScope {
}
/// [`Self::commit`] for an incremental pass: only the re-hashed slots. `last_screen` stays,
/// since a structural change always takes the derivation.
/// since a screen move always takes the derivation.
fn commit_incremental(&mut self) {
for &(id, h) in &self.staged {
self.last[id as usize] = h;
@@ -365,12 +466,196 @@ impl LayoutScope {
pub(crate) fn invalidate(&mut self) {
self.last_screen = None;
self.last.clear();
// The roster and `node_of` too: `has_node` must not vouch for a node the memory forgot.
self.plan.clear();
self.regions.clear();
self.node_of.clear();
self.staged.clear();
}
/// `WOW_LAYOUT_VERIFY`'s graph check: where this cached graph differs from `fresh`, derived
/// from scratch on the same model, in roster, per-node hash, edges or ScrollFrames.
fn diverges_from(&self, fresh: &LayoutScope) -> Option<(u32, String)> {
let at = |v: &[u32], id: usize| v.get(id).copied().unwrap_or(u32::MAX);
let hash = |s: &LayoutScope, id: usize| s.last.get(id).copied().unwrap_or(NO_NODE);
let sorted = |v: Option<&Vec<u32>>| {
let mut v = v.cloned().unwrap_or_default();
v.sort_unstable();
v
};
let incoming = |s: &LayoutScope, id: usize| {
let mut v = Vec::new();
let mut e = at(&s.dep_head, id);
while e != u32::MAX {
v.push(s.dep_to[e as usize]);
e = s.dep_next[e as usize];
}
v.sort_unstable();
v
};
let over_eq = |a: Option<Anchor>, b: Option<Anchor>| match (a, b) {
(None, None) => true,
(Some(a), Some(b)) => super::object::anchor_bits_eq(&a, &b),
_ => false,
};
let n = self
.node_of
.len()
.max(fresh.node_of.len())
.max(self.last.len())
.max(fresh.last.len());
for id in 0..n {
let (a, b) = (at(&self.node_of, id), at(&fresh.node_of, id));
let row = |s: &LayoutScope, n: u32| -> String {
match n {
u32::MAX => "absent".into(),
n if n & REGION_TAG != 0 => {
let r = s.regions[(n & !REGION_TAG) as usize];
format!("region {:?} owner {:?}", r.rh, r.owner)
}
n => format!(
"frame {:?} override {:?}",
s.plan[n as usize].0, s.plan[n as usize].2
),
}
};
let same_node = match (a, b) {
(u32::MAX, u32::MAX) => true,
(u32::MAX, _) | (_, u32::MAX) => false,
(a, b) if (a ^ b) & REGION_TAG != 0 => false,
(a, b) if a & REGION_TAG != 0 => {
self.regions[(a & !REGION_TAG) as usize]
== fresh.regions[(b & !REGION_TAG) as usize]
}
(a, b) => {
let (x, y) = (self.plan[a as usize], fresh.plan[b as usize]);
x.0 == y.0 && x.1 == y.1 && over_eq(x.2, y.2)
}
};
if !same_node {
return Some((
id as u32,
format!(
"id {id}: the cached graph holds {}, a fresh derivation {}",
row(self, a),
row(fresh, b)
),
));
}
if hash(self, id) != hash(fresh, id) {
return Some((
id as u32,
format!(
"id {id} ({}): the cached hash is stale against a fresh derivation",
row(self, a)
),
));
}
let (oa, ob) = (sorted(self.out.get(id)), sorted(fresh.out.get(id)));
if a != u32::MAX && oa != ob {
return Some((
id as u32,
format!(
"id {id} ({}): reads {oa:?} in the cached graph, {ob:?} fresh",
row(self, a)
),
));
}
let (ia, ib) = (incoming(self, id), incoming(fresh, id));
if ia != ib {
return Some((
id as u32,
format!("id {id}: read by {ia:?} in the cached graph, {ib:?} fresh"),
));
}
}
let held = |s: &LayoutScope| {
let mut v: Vec<(u32, FrameHandle)> =
s.scroll_child.iter().map(|(&id, &c)| (id, c)).collect();
v.sort_unstable_by_key(|e| e.0);
v
};
let (sa, sb) = (held(self), held(fresh));
if sa != sb {
return Some((0, format!("ScrollFrames hold {sa:?} cached, {sb:?} fresh")));
}
// The inverse must agree with its source, entry for entry.
let claimed: usize = self.claims.values().map(Vec::len).sum();
let inverse_ok = claimed == sa.len()
&& sa.iter().all(|&(id, c)| {
self.claims
.get(&c)
.is_some_and(|v| v.iter().any(|&(_, s)| s == id))
});
(!inverse_ok).then(|| {
(
0,
format!("ScrollFrame claims {:?} disagree with {sa:?}", self.claims),
)
})
}
}
/// The layout id of the first frame or region whose rect differs between the model and `frames`
/// and `regions`, in id order.
fn first_rect_diff(
model: &Model,
frames: &HashMap<FrameHandle, Rect>,
regions: &HashMap<RegionHandle, Rect>,
) -> Option<u32> {
let mut ids: Vec<u32> = Vec::new();
for (h, r) in &model.resolved {
if frames.get(h) != Some(r) {
ids.extend(model.frame_to_id.get(h));
}
}
for h in frames.keys().filter(|h| !model.resolved.contains_key(h)) {
ids.extend(model.frame_to_id.get(h));
}
for (h, r) in &model.region_resolved {
if regions.get(h) != Some(r) {
ids.extend(model.region_to_id.get(h));
}
}
for h in regions
.keys()
.filter(|h| !model.region_resolved.contains_key(h))
{
ids.extend(model.region_to_id.get(h));
}
let differs = frames != &model.resolved || regions != &model.region_resolved;
ids.into_iter().min().or(differs.then_some(u32::MAX))
}
/// A node's layout inputs as the model holds them, for `WOW_LAYOUT_VERIFY`'s graph report.
fn node_inputs(model: &Model, id: u32) -> String {
if let Some(h) = model.id_to_frame.get(&id) {
let f = model.arena.frame(*h);
return format!(
"frame {:?} {:?}, effective scale {:?}, clamped {:?}",
f.and_then(|f| f.name.clone()),
model.layout_inputs.get(h),
f.map(|f| f.effective_scale),
f.map(|f| f.clamped_to_screen),
);
}
let Some(rh) = model.id_to_region.get(&id) else {
return "no live frame or region".into();
};
let owner = model.arena.region(*rh).map(|r| r.owner);
let owner_id = owner.and_then(|o| model.frame_to_id.get(&o));
match model.region_data.get(rh) {
Some(d) => format!(
"region owner {owner:?} (id {owner_id:?}), anchors {:?}, size {:?}, measured {:?} \
(text {} bytes), texture {:?}, fill {}",
d.anchors,
d.size,
d.measured.map(|m| (m.w, m.h)),
d.text.as_deref().map_or(0, str::len),
d.texture,
d.fill.is_some()
),
None => format!("region owner {owner:?} with no data"),
}
}
/// `WOW_LAYOUT_PROF=1`: the per-solve `[layout-prof]` and `[layout-pre]` lines on stderr.
@@ -505,11 +790,20 @@ impl UiScript {
return;
}
// ── `WOW_LAYOUT_VERIFY`: the incremental pass's falsifier ─────────────
// Re-run the frame from scratch and require identical rects: this catches a write that
// moved a node without naming it, or changed an anchor's target as if value-only.
model.layout_verify_recheck = false;
if let Some(why) = Self::scratch_divergence(model) {
panic!("WOW_LAYOUT_VERIFY: {why}");
}
}
/// Re-solve the model from scratch on a fresh graph and compare it with the converged cached
/// one: the rects, then the graph itself (roster, per-node hashes, edges). This catches a write
/// that moved a node without naming it, and a re-derive that joined, dropped or re-pointed one
/// wrong. The cached graph is put back after, so a run of incremental passes is checked step
/// by step, not reset at each; the meters keep the incremental pass's values.
pub(super) fn scratch_divergence(model: &mut Model) -> Option<String> {
let cached = std::mem::take(&mut model.layout_scope);
let before = (model.resolved.clone(), model.region_resolved.clone());
// The meters keep the incremental pass's values, which tests assert on, not the re-run's.
let meters = (
model.layout_solves,
model.layout_rounds,
@@ -521,8 +815,9 @@ impl UiScript {
model.layout_touched = None; // no ledger: derive the graph
model.layout_epoch_resolved = None; // past tier 1
model.layout_fingerprint = None; // past tier 2
model.layout_scope.invalidate(); // full scope
Self::resolve_layout_inner(model);
Self::resolve_layout_inner(model); // a fresh scope: full scope
let graph = cached.diverges_from(&model.layout_scope);
model.layout_scope = cached;
model.pending_size_changed.truncate(queued);
model.warnings.truncate(warned);
(
@@ -532,17 +827,36 @@ impl UiScript {
model.layout_gate_walks,
model.layout_derives,
) = meters;
assert!(
before.0 == model.resolved && before.1 == model.region_resolved,
"WOW_LAYOUT_VERIFY: an INCREMENTAL layout pass disagreed with a full derivation of \
the same frame — a write site named the wrong node, named one while changing an \
anchor's target, or moved layout inputs without naming any. \
frames {} -> {}, regions {} -> {}",
before.0.len(),
model.resolved.len(),
before.1.len(),
model.region_resolved.len(),
);
// The first node whose rect differs, with its inputs; the graph's divergence, if any,
// usually names the cause.
let report = |id: u32| format!("id {id}, inputs now: {}", node_inputs(model, id));
let graph = graph.map(|(id, why)| format!("{why} ({})", report(id)));
if let Some(id) = first_rect_diff(model, &before.0, &before.1) {
let rect = |frames: &HashMap<FrameHandle, Rect>,
regions: &HashMap<RegionHandle, Rect>| {
match (model.id_to_frame.get(&id), model.id_to_region.get(&id)) {
(Some(h), _) => frames.get(h).copied(),
(_, Some(rh)) => regions.get(rh).copied(),
_ => None,
}
};
return Some(format!(
"an INCREMENTAL layout pass disagreed with a full derivation of the same frame — \
a write site named the wrong node or moved layout inputs without naming any. \
First rect to differ: {}, incremental {:?}, fresh {:?}. Graph: {}",
report(id),
rect(&before.0, &before.1),
rect(&model.resolved, &model.region_resolved),
graph.as_deref().unwrap_or("same as fresh"),
));
}
graph.map(|why| {
format!(
"the cached layout graph diverged from a fresh derivation after an INCREMENTAL \
pass — a write changed a node's roster, edges or inputs without naming it, or \
the re-derive mis-applied one. {why}"
)
})
}
fn resolve_layout_inner(model: &mut Model) {
@@ -574,10 +888,12 @@ impl UiScript {
// ── The graph: the ledger's cache, or a fresh derivation ─────────────────────────────
// A `Some` ledger claims every write since the graph was built named its node and kept
// the graph's shape, so the cached roster, edges and hashes stand; `None` derives.
let touched = model.layout_touched.take();
let fp = touched
.is_none()
.then(|| Self::derive_layout_graph(model, &mut pre));
// The named nodes re-derive in place; a change the cached graph cannot take derives.
let incremental = match model.layout_touched.take() {
Some(named) => Self::rederive_named(model, named, &mut pre),
None => false,
};
let fp = (!incremental).then(|| Self::derive_layout_graph(model, &mut pre));
let Model {
arena,
@@ -602,42 +918,7 @@ impl UiScript {
texture_size_probe,
..
} = model;
// ── The incremental seed ─────────────────────────────────────────────
// Re-hash only the named nodes and seed those whose hash moved. It runs before the gate
// because on this path it is the verdict: an empty seed set means nothing moved.
let mut full_scope = false;
if let Some(touched) = &touched {
scope.begin_incremental();
for &id in touched {
let Some(&n) = scope.node_of.get(id as usize) else {
continue;
};
if n == u32::MAX {
continue;
}
let now = if n & REGION_TAG != 0 {
let rh = scope.regions[(n & !REGION_TAG) as usize].rh;
match region_data.get(&rh) {
Some(d) => region_node_hash(d),
None => continue,
}
} else {
let (h, _, over) = scope.plan[n as usize];
match layout_inputs.get(&h) {
Some(i) => frame_node_hash(i, over),
None => continue,
}
};
// A named write that did not move the hash (a same-value `SetPoint`) seeds nothing.
if now != scope.last.get(id as usize).copied().unwrap_or(NO_NODE) {
scope.staged.push((id, now));
scope.seed(id);
}
}
scope.close();
pre.seed = pre.lap();
pre.incremental = true;
}
// An incremental pass has no fingerprint; its verdict is `staged`, exactly the seed set.
let gate_skips = match fp {
Some(_) => *layout_fingerprint == fp,
@@ -675,9 +956,9 @@ impl UiScript {
// ── The scope: which nodes this solve may touch ────
// A node with unchanged inputs and no moved dependency recomputes to the rect it holds, so
// the solve runs over the dirty closure and feeds other rects in as externals. Full scope
// when there is no memory yet, the screen moved, or under verify. The incremental pass
// seeded its closure above.
if touched.is_none() {
// when there is no memory yet, the screen moved, or under verify. An incremental pass
// seeded its closure in `rederive_named`.
if !incremental {
// A node that died past the new id count has no edges recorded (the derivation sized
// them to the count), so only a full scope reaches what anchored to it.
let died_past_end = scope
@@ -943,7 +1224,7 @@ impl UiScript {
}
*layout_fingerprint = fp;
// Adopted only on convergence; an incremental pass adopts only what it re-hashed.
if touched.is_some() {
if incremental {
scope.commit_incremental();
} else {
scope.commit(*screen);
@@ -953,7 +1234,7 @@ impl UiScript {
// Only here: the cycle bail below leaves it `None`.
*layout_touched = Some(Vec::new());
// Under verify, `resolve_layout` re-derives this frame from scratch and compares.
*layout_verify_recheck = touched.is_some() && verify;
*layout_verify_recheck = incremental && verify;
// A converged solve closes tier 1, at `epoch_at_entry` rather than the live epoch,
// so a touch during this pass (the tooltip pre-pass, a minted id) re-opens it. The
// fingerprint hashes inputs only, so the next unmutated resolve would match it.
@@ -1025,6 +1306,7 @@ impl UiScript {
// `0x787100` passes them to `SetPoint`: y up, so a positive vertical lifts the child. Its
// authored anchors and size are untouched, so `SetScrollChild(nil)` needs no restore.
let mut scroll_child_anchor: HashMap<FrameHandle, Anchor> = HashMap::new();
let mut scroll_holds: Vec<(FrameHandle, u32, FrameHandle)> = Vec::new();
for &(h, id) in &ids {
let Some(frame) = arena.frame(h) else {
continue;
@@ -1039,6 +1321,7 @@ impl UiScript {
if arena.frame(child).is_none() {
continue; // a stale/destroyed child contributes no override
}
scroll_holds.push((h, id, child));
scroll_child_anchor.insert(
child,
Anchor::new(
@@ -1066,11 +1349,15 @@ impl UiScript {
// ── The change gate ───────────────────────────────────────────────────────────────────
// Fingerprint what the rounds read, to skip them when nothing moved since the last
// converged resolve: the screen; each live frame's id, synced `LayoutInput` and
// ScrollFrame override; each anchored region's anchors, explicit size, measured extent
// and liveness. Seeded rects are outputs, a pure function of these, so they are not hashed.
// ScrollFrame override; each anchored region's node hash (anchors, explicit size, measured
// extent, the art of an unsized axis, owner) and liveness. Seeded rects are outputs, a pure
// function of these, so they are not hashed.
// The same walk hashes each node alone into `LayoutScope::now` and records its edges.
// Sized past `next_id` for the ids the region walk below may mint.
scope.begin_full(*next_id as usize + region_data.len() + 1);
for (h, id, child) in scroll_holds {
scope.hold(h, id, Some(child));
}
pre.begin = pre.lap();
let mut fp = InputFingerprint::default();
fp.rect(*screen);
@@ -1095,10 +1382,10 @@ impl UiScript {
// A frame reads its anchor targets' rects, or only its ScrollFrame override's target,
// as the round loop's `set_frame_anchored` does.
match over {
Some(a) => scope.edge(id, a.relative_to),
Some(a) => scope.link(id, a.relative_to),
None => {
for a in &input.anchors {
scope.edge(id, a.relative_to);
scope.link(id, a.relative_to);
}
}
}
@@ -1119,22 +1406,12 @@ impl UiScript {
}
fed_regions += 1;
let live = arena.region(rh);
let owner_id = live.and_then(|r| frame_to_id.get(&r.owner).copied());
// The node hash covers every input the sweep reads (anchors, size, measure, the art
// of an unsized axis, the owner), so the two tiers read one set.
let node = region_node_hash(data, owner_id);
fp.feed(rh.fingerprint_bits());
fp.anchors(&data.anchors);
match data.size {
Some((w, h)) => {
fp.f32(w);
fp.f32(h);
}
None => fp.feed(u64::MAX),
}
match data.measured {
Some(m) => {
fp.f32(m.w);
fp.f32(m.h);
}
None => fp.feed(u64::MAX),
}
fp.feed(node);
if let Some(r) = live {
// Mint an id for a region that never needed one, without `Model::region_id`'s
// epoch bump, which would re-dirty the running resolve.
@@ -1151,15 +1428,15 @@ impl UiScript {
#[allow(clippy::cast_possible_truncation)]
let idx = scope.regions.len() as u32 | REGION_TAG;
if let Some(slot) = scope.now.get_mut(id as usize) {
*slot = region_node_hash(data);
*slot = node;
scope.node_of[id as usize] = idx;
}
// A region reads its anchor targets' rects and its owner's scale.
for a in &data.anchors {
scope.edge(id, a.relative_to);
scope.link(id, a.relative_to);
}
if let Some(&owner_id) = frame_to_id.get(&r.owner) {
scope.edge(id, owner_id);
if let Some(owner_id) = owner_id {
scope.link(id, owner_id);
}
scope.regions.push(RegionRow {
rh,
@@ -1178,6 +1455,175 @@ impl UiScript {
fp
}
/// Re-derive each named node against the model, in the cached graph: a node joins the roster
/// with its edges (a frame or region born, a region anchored), leaves it with its edges
/// unlinked (a region freed or cleared), or keeps its slot with its edges re-pointed and its
/// inputs re-synced, exactly as [`Self::derive_layout_graph`] would build it. Each node whose
/// hash or edges moved is seeded, and the closure taken. `false` gives up for the derivation:
/// a frame leaving the roster, or a frame two ScrollFrames claim, whose override the
/// derivation picks by map order.
fn rederive_named(model: &mut Model, mut named: Vec<u32>, pre: &mut PreambleProf) -> bool {
let Model {
arena,
layout_inputs,
region_data,
region_resolved,
frame_to_id,
id_to_frame,
id_to_region,
next_id,
screen,
layout_scope: scope,
..
} = model;
scope.begin_incremental(*next_id as usize);
// ScrollFrames first, so a child re-derived after them reads the claims they recorded.
let is_scroller = |id: u32| {
id_to_frame
.get(&id)
.and_then(|&h| arena.frame(h))
.is_some_and(|f| f.kind == FrameKind::ScrollFrame)
};
named.sort_unstable_by_key(|&id| (!is_scroller(id), id));
named.dedup();
let mut targets = std::mem::take(&mut scope.targets);
let mut at = 0;
while at < named.len() {
let id = named[at];
at += 1;
let Some(&n) = scope.node_of.get(id as usize) else {
continue; // past every minted id: never a node
};
let is_region_node = n != u32::MAX && n & REGION_TAG != 0;
// A frame: every live frame with an id is a node.
if let Some(&h) = id_to_frame.get(&id) {
let Some(frame) = arena.frame(h).filter(|_| frame_to_id.get(&h) == Some(&id))
else {
if n != u32::MAX {
scope.targets = targets;
return false;
}
continue;
};
if is_region_node {
scope.targets = targets;
return false;
}
// A ScrollFrame records the live child it holds, and that child re-derives after
// it, as the override is the child's; so does a child it let go.
if frame.kind == FrameKind::ScrollFrame {
let child = match &frame.kind_state {
KindState::Scroll(state) => {
state.child.filter(|&c| arena.frame(c).is_some())
}
_ => None,
};
let let_go = scope.hold(h, id, child);
for c in child.into_iter().chain(let_go) {
named.extend(frame_to_id.get(&c));
}
}
// The override of the one ScrollFrame holding this frame; a child two hold gives
// up, as the derivation picks between them by map order.
let over = match scope.claims.get(&h).map(Vec::as_slice) {
None | Some([]) => None,
Some(&[(sh, sid)]) => match arena.frame(sh).map(|f| &f.kind_state) {
Some(KindState::Scroll(state)) => Some(Anchor::new(
Point::TopLeft,
sid,
Point::TopLeft,
state.horizontal,
state.vertical,
)),
_ => None,
},
Some(_) => {
scope.targets = targets;
return false;
}
};
// The derivation's sync of the arena's scale and clamp into the layout input.
let (scale, clamp) = (frame.effective_scale, frame.clamped_to_screen);
let input = layout_inputs.entry(h).or_default();
input.scale = scale;
input.clamp = clamp;
if clamp {
input.extent_x = screen.right;
input.extent_y = screen.top;
}
targets.clear();
match over {
Some(a) => targets.push(a.relative_to),
None => targets.extend(input.anchors.iter().map(|a| a.relative_to)),
}
let hash = frame_node_hash(input, over);
let relinked = if n == u32::MAX {
#[allow(clippy::cast_possible_truncation)]
{
scope.node_of[id as usize] = scope.plan.len() as u32;
}
scope.plan.push((h, id, over));
scope.relink(id, &targets);
true
} else {
scope.plan[n as usize].2 = over;
scope.relink(id, &targets)
};
scope.note(id, hash, relinked);
continue;
}
// A region: a node while it lives and has an anchor.
let live = id_to_region.get(&id).and_then(|&rh| {
let r = arena.region(rh)?;
let data = region_data.get(&rh).filter(|d| !d.anchors.is_empty())?;
Some((rh, r, data))
});
let Some((rh, r, data)) = live else {
if is_region_node {
let row = scope.remove_region(id, n);
region_resolved.remove(&row.rh);
} else if n != u32::MAX {
scope.targets = targets;
return false;
}
continue;
};
if n != u32::MAX && !is_region_node {
scope.targets = targets;
return false;
}
let owner_id = frame_to_id.get(&r.owner).copied();
targets.clear();
targets.extend(data.anchors.iter().map(|a| a.relative_to));
targets.extend(owner_id);
let hash = region_node_hash(data, owner_id);
let row = RegionRow {
rh,
id,
owner: r.owner,
is_fontstring: matches!(r.kind, crate::widget::RegionKind::FontString),
};
let relinked = if n == u32::MAX {
#[allow(clippy::cast_possible_truncation)]
{
scope.node_of[id as usize] = scope.regions.len() as u32 | REGION_TAG;
}
scope.regions.push(row);
scope.relink(id, &targets);
true
} else {
scope.regions[(n & !REGION_TAG) as usize] = row;
scope.relink(id, &targets)
};
scope.note(id, hash, relinked);
}
scope.targets = targets;
scope.close();
pre.seed = pre.lap();
pre.incremental = true;
true
}
/// FontStrings with text whose stored measure is stale, for the host to measure: call after
/// [`UiScript::resolve`], answer with [`UiScript::set_measured_text`], and resolve again.
pub fn fontstrings_needing_measure(&mut self) -> Vec<MeasureRequest> {
+1 -2
View File
@@ -141,8 +141,7 @@ impl super::UiScript {
}
}
if changed {
// The whole graph, not the frame: a restored row may repoint an anchor.
model.touch_layout();
model.touch_layout_frame(h);
}
}
}
+1
View File
@@ -134,6 +134,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
data.texture = None;
data.fill = None;
data.circular = true;
model.touch_layout_art(rh);
Ok(Value::Integer(1))
})?,
)?;
@@ -193,7 +193,7 @@ pub(super) fn ensure_font_region(
let mut data = crate::script::RegionData::default();
data.justify.set_h(JustifyH::Left);
model.region_data.insert(rh, data);
model.touch_layout();
model.touch_layout_region(rh);
Some(rh)
}
+86 -75
View File
@@ -119,9 +119,10 @@ pub(crate) struct Model {
/// Tier 1 of the resolve gate: every layout write bumps it, and a resolve returns at once while
/// it equals `layout_epoch_resolved`. Never bump it per frame: tier 2 hashes the whole UI.
pub(crate) layout_epoch: u64,
/// Layout ids named by writes since the last converged resolve, each write claiming it left the
/// cached graph's shape alone, so a resolve seeds its dirty closure from them. `None` derives
/// the graph in full; `WOW_LAYOUT_VERIFY` checks every incremental pass against a full one.
/// Layout ids named by writes since the last converged resolve: the resolve re-derives each
/// in the cached graph (born, freed, re-pointed or re-valued alike) and seeds its dirty
/// closure from them. `None` derives the graph in full; `WOW_LAYOUT_VERIFY` checks every
/// incremental pass's rects and graph against a fresh derivation.
pub(crate) layout_touched: Option<Vec<u32>>,
/// Regions a write may have re-keyed for text measure (text, font, outline, wrap, scale) since
/// the last sweep, which asks only these; `None` walks all. A site that forgets to name its
@@ -144,8 +145,9 @@ pub(crate) struct Model {
/// cost, which `layout_solves` misses. Verify mode walks anyway, so assert on this only from a
/// crate that depends on `benilla-ui`; inside this crate, assert on `layout_solves`.
pub(crate) layout_gate_walks: u64,
/// Full layout-graph derivations: 0 on a steady UI, so a rise means a write fell back to
/// `touch_layout`. The `WOW_LAYOUT_VERIFY` re-run's derivation is not counted.
/// Full layout-graph derivations: 0 on a steady UI, births and frees included, so a rise means
/// a write named no node (`touch_layout`) or a re-derive gave up. The `WOW_LAYOUT_VERIFY`
/// re-run's derivation is not counted.
pub(crate) layout_derives: u64,
/// The last solve's width, `(frames solved, regions swept)`.
pub(crate) layout_last_scope: (usize, usize),
@@ -1500,8 +1502,22 @@ impl Model {
self.next_id += 1;
self.frame_to_id.insert(h, id);
self.id_to_frame.insert(id, h);
// Every creation path mints here, so a new frame always opens the layout gate.
self.touch_layout();
// Every creation path mints here, so a new frame always joins the graph. A region already
// a node on it gains its owner edge and the owner's id in its hash, so it re-derives too.
self.name_layout_node(id);
let owned: Vec<u32> = self
.arena
.frame(h)
.map(|f| {
f.regions
.iter()
.filter_map(|rh| self.region_to_id.get(rh).copied())
.collect()
})
.unwrap_or_default();
for rid in owned {
self.name_layout_node(rid);
}
id
}
@@ -1514,8 +1530,8 @@ impl Model {
self.next_id += 1;
self.region_to_id.insert(h, id);
self.id_to_region.insert(id, h);
// A new region can join the resolve's external set, so it opens the gate too.
self.touch_layout();
// Named, so an anchored region joins the graph; an unanchored one re-derives to nothing.
self.name_layout_node(id);
id
}
@@ -1527,39 +1543,78 @@ impl Model {
let changed = self.arena.frame(h).is_some_and(|f| f.scale != scale);
self.arena.set_scale(h, scale);
if changed {
self.touch_layout();
// The effective scale cascades down the subtree, an input of each frame's node.
self.touch_layout_subtree(h);
self.touch_measure_all();
}
}
/// A layout write that names no node. The resolve's tooltip pre-pass must not call this, or
/// the gate never closes; a missed call is caught by `WOW_LAYOUT_VERIFY`.
/// A layout write that names no node, for what moves every node at once (the screen, every
/// measure): the next resolve derives the graph in full. The resolve's tooltip pre-pass must not
/// call this, or the gate never closes; a missed call is caught by `WOW_LAYOUT_VERIFY`.
pub(crate) fn touch_layout(&mut self) {
// Naming nothing, the next resolve derives the graph in full: slow, never wrong.
self.give_up_naming();
self.bump_layout_epoch();
}
/// A write that moved one region's offsets, size or measured extent, not its targets or
/// liveness; a region missing from the cached graph falls back to `touch_layout`.
/// A write to region `rh`'s layout inputs or liveness: its anchors, size, measured extent,
/// art or owner. Its node re-derives at the next resolve, joining or leaving the graph as it
/// gained or lost its anchors. An id is minted only for an anchored region, the only kind that
/// can be a node; an anchorless one with no id has nothing in the graph to move.
pub(crate) fn touch_layout_region(&mut self, rh: RegionHandle) {
match self.region_to_id.get(&rh) {
Some(&id) => self.touch_layout_node(id),
None => self.touch_layout(),
Some(&id) => self.name_layout_node(id),
None if self
.region_data
.get(&rh)
.is_some_and(|d| !d.anchors.is_empty()) =>
{
self.region_id(rh); // mints and names
}
None => self.bump_layout_epoch(),
}
}
/// The same for a frame: an anchor offset or size, nothing that moves an edge or the roster.
/// A write to region `rh`'s art, its texture or solid fill: a layout input only where an
/// anchored region takes an axis authored 0 from it ([`super::layout::content_span`]), and
/// named only when it moved the node's hash, so a repaint of the same art leaves the gate shut.
pub(crate) fn touch_layout_art(&mut self, rh: RegionHandle) {
let Some(d) = self.region_data.get(&rh) else {
return;
};
if d.anchors.is_empty() || d.size.is_some_and(|(w, h)| w != 0.0 && h != 0.0) {
return;
}
if let Some(&id) = self.region_to_id.get(&rh) {
let owner = self
.arena
.region(rh)
.and_then(|r| self.frame_to_id.get(&r.owner).copied());
if self
.layout_scope
.holds(id, super::layout::region_node_hash(d, owner))
{
return;
}
}
self.touch_layout_region(rh);
}
/// The same for frame `h`: its anchors, size, scale, clamp or scroll override.
pub(crate) fn touch_layout_frame(&mut self, h: FrameHandle) {
match self.frame_to_id.get(&h) {
Some(&id) => self.touch_layout_node(id),
None => self.touch_layout(),
Some(&id) => self.name_layout_node(id),
None if self.arena.frame(h).is_some() => {
self.frame_id(h); // mints and names
}
None => self.bump_layout_epoch(),
}
}
/// A `SetParent` under `root`: only the subtree's scale moves, as anchor targets are ids fixed
/// at `SetPoint`. Addon pools reparent hundreds of frames a redraw, so this stays precise.
pub(crate) fn touch_layout_reparent(&mut self, root: FrameHandle) {
/// Every frame under `root`, inclusive, for a write that moves the effective scale down a
/// subtree (`SetParent`, `SetScale`); anchor targets are ids fixed at `SetPoint`, so only the
/// scale moves. Addon pools reparent hundreds of frames a redraw, so this stays precise.
pub(crate) fn touch_layout_subtree(&mut self, root: FrameHandle) {
let mut subtree = vec![root];
let mut at = 0;
while at < subtree.len() {
@@ -1569,53 +1624,11 @@ impl Model {
subtree.extend(f.children.iter().copied());
}
}
// Write the new scale here: only a full derivation syncs it, and a named node skips that.
let Model {
arena,
layout_inputs,
..
} = self;
for &h in &subtree {
if let Some(f) = arena.frame(h) {
layout_inputs.entry(h).or_default().scale = f.effective_scale;
}
}
for h in subtree {
self.touch_layout_frame(h);
}
}
/// A write changed frame `h`'s anchor targets; `old` and `new` are its full target lists, so
/// the cached edges are re-pointed. Falls back to `touch_layout` when the scope refuses.
pub(crate) fn touch_layout_retarget_frame(&mut self, h: FrameHandle, old: &[u32], new: &[u32]) {
match self.frame_to_id.get(&h).copied() {
// Already conservative: the next resolve rebuilds every edge, so patching is wasted.
Some(id)
if self.layout_touched.is_some() && self.layout_scope.retarget(id, old, new) =>
{
self.touch_layout_node(id);
}
_ => self.touch_layout(),
}
}
/// `touch_layout_retarget_frame` for a region.
pub(crate) fn touch_layout_retarget_region(
&mut self,
rh: RegionHandle,
old: &[u32],
new: &[u32],
) {
match self.region_to_id.get(&rh).copied() {
Some(id)
if self.layout_touched.is_some() && self.layout_scope.retarget(id, old, new) =>
{
self.touch_layout_node(id);
}
_ => self.touch_layout(),
}
}
/// Names `rh` on the measure ledger for a write that may move its measure key.
pub(crate) fn touch_measure(&mut self, rh: RegionHandle) {
if let Some(list) = &mut self.measure_dirty {
@@ -1633,7 +1646,7 @@ impl Model {
pub(crate) fn note_layout_frame_write(&mut self, h: FrameHandle) {
if self.layout_touched.is_some() {
match self.frame_to_id.get(&h) {
Some(&id) if self.layout_scope.has_node(id) => {
Some(&id) => {
self.layout_touched
.as_mut()
.expect("checked above")
@@ -1648,7 +1661,7 @@ impl Model {
pub(crate) fn note_layout_region_write(&mut self, rh: RegionHandle) {
if self.layout_touched.is_some() {
match self.region_to_id.get(&rh) {
Some(&id) if self.layout_scope.has_node(id) => {
Some(&id) => {
self.layout_touched
.as_mut()
.expect("checked above")
@@ -1659,14 +1672,12 @@ impl Model {
}
}
/// Names `id` as what this write moved, if the cached graph has it and naming is still on.
fn touch_layout_node(&mut self, id: u32) {
let in_graph = self.layout_scope.has_node(id);
match &mut self.layout_touched {
// Already conservative: a precise touch cannot un-say an imprecise one.
None => {}
Some(_) if !in_graph => self.give_up_naming(),
Some(list) => list.push(id),
/// Names layout id `id` as what this write moved, while naming is still on, whether or not
/// the cached graph holds it yet: the resolve re-derives it either way.
pub(crate) fn name_layout_node(&mut self, id: u32) {
// Already conservative: a precise touch cannot un-say an imprecise one.
if let Some(list) = &mut self.layout_touched {
list.push(id);
}
self.bump_layout_epoch();
}
+8 -13
View File
@@ -586,8 +586,12 @@ impl Plate {
raid_data.anchors = vec![Anchor::new(Point::Right, plate_id, Point::Left, 0.0, 0.0)];
raid_data.size = Some((g.raid_size, g.raid_size));
// The first lay-out adds anchor targets, so the full touch; once per geometry change.
model.touch_layout();
// Once per geometry change: each node it wrote re-derives.
model.touch_layout_frame(frame);
model.touch_layout_frame(bar);
for rh in [border, glow, name, level, skull, raid] {
model.touch_layout_region(rh);
}
model.nameplates.plates[i].laid_out = Some(g);
}
@@ -606,16 +610,11 @@ impl Plate {
let last = p.last.clone();
let world_id = model.frame_id(world);
// Re-seated whenever it moves, as the reference does (`0x509ec0`). A moved offset keeps
// the graph's edges, so the precise touch; the first seat adds an edge, so the retarget.
// Re-seated whenever it moves, as the reference does (`0x509ec0`).
if last
.as_ref()
.is_none_or(|l| l.top_centre != state.top_centre)
{
let fresh = model
.layout_inputs
.get(&frame)
.is_none_or(|i| i.anchors.is_empty());
let input = model.layout_inputs.entry(frame).or_default();
input.anchors = vec![Anchor::new(
Point::Top,
@@ -624,11 +623,7 @@ impl Plate {
state.top_centre.0,
state.top_centre.1,
)];
if fresh {
model.touch_layout_retarget_frame(frame, &[], &[world_id]);
} else {
model.touch_layout_frame(frame);
}
model.touch_layout_frame(frame);
}
if last.is_none() {
+1 -1
View File
@@ -24,7 +24,7 @@ mod layout_methods;
pub(crate) use layout_methods::eff_scale;
pub(crate) mod movable;
pub(crate) mod toplevel;
pub(crate) use layout_methods::{anchor_bits_eq, anchor_retarget_is_structural};
pub(crate) use layout_methods::anchor_bits_eq;
pub(crate) use movable::{advance_move, advance_size, FrameMove, FrameSizing};
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -152,7 +152,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let changed = !d.anchors.is_empty();
d.anchors.clear();
if changed {
model.touch_layout();
model.touch_layout_region(rh);
}
model.region_id(rh)
};
@@ -346,7 +346,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
model.arena.reparent_finish(h, new_parent);
// A reparent moves the subtree's effective scale, a layout input and part of every
// descendant FontString's measure key.
model.touch_layout_reparent(h);
model.touch_layout_subtree(h);
model.touch_measure_all();
}
if was_visible {
@@ -676,7 +676,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let changed = model.arena.is_clamped_to_screen(h) != clamp;
model.arena.set_clamped_to_screen(h, clamp);
if changed {
model.touch_layout();
model.touch_layout_frame(h);
}
Ok(())
})?,
@@ -36,17 +36,16 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
// Every layout setter mutates only on a real change, so a per-frame caller setting the
// same value never reopens the layout gate. Clearing is a retarget to no targets.
let old: Option<Vec<u32>> = match model.layout_inputs.get_mut(&h) {
// same value never reopens the layout gate.
let cleared = match model.layout_inputs.get_mut(&h) {
Some(input) if !input.anchors.is_empty() => {
let old = input.anchors.iter().map(|a| a.relative_to).collect();
input.anchors.clear();
Some(old)
true
}
_ => None,
_ => false,
};
if let Some(old) = old {
model.touch_layout_retarget_frame(h, &old, &[]);
if cleared {
model.touch_layout_frame(h);
}
Ok(())
})?;
@@ -125,7 +124,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
if !same {
input.anchors.clear();
input.anchors.extend_from_slice(&pair);
model.touch_layout();
model.touch_layout_frame(h);
}
Ok(())
},
@@ -298,38 +297,13 @@ fn set_point(lua: &Lua, this: &Table, args: &MultiValue) -> mlua::Result<()> {
.iter()
.any(|a| a.point == point);
if !same_at_tail {
// Target lists are collected only for a retarget: the value-only change is the per-frame
// idiom (a dragged window) and must stay allocation-free.
let structural = anchor_retarget_is_structural(&input.anchors, &new);
let old_targets: Option<Vec<u32>> =
structural.then(|| input.anchors.iter().map(|a| a.relative_to).collect());
input.anchors.retain(|a| a.point != point);
input.anchors.push(new);
match old_targets {
None => model.touch_layout_frame(h),
Some(old) => {
let new_targets: Vec<u32> = model.layout_inputs[&h]
.anchors
.iter()
.map(|a| a.relative_to)
.collect();
model.touch_layout_retarget_frame(h, &old, &new_targets);
}
}
model.touch_layout_frame(h);
}
Ok(())
}
/// Whether this `SetPoint` changes the node's anchor targets, mirroring the setters' retain and
/// push: value-only exactly when one existing anchor has this point and keeps its target.
pub(crate) fn anchor_retarget_is_structural(anchors: &[Anchor], new: &Anchor) -> bool {
let mut same_point = anchors.iter().filter(|a| a.point == new.point);
match (same_point.next(), same_point.next()) {
(Some(old), None) => old.relative_to != new.relative_to,
_ => true,
}
}
/// Bit-exact anchor equality, as the layout gate's fingerprint compares, so the two always agree.
pub(crate) fn anchor_bits_eq(a: &Anchor, b: &Anchor) -> bool {
a.point == b.point
+17 -30
View File
@@ -4,9 +4,7 @@
use mlua::{Lua, MultiValue, Table, Value};
use super::object::anchor_args::{parse_set_point, resolve_rel_target, UNNAMED};
use super::object::{
anchor_bits_eq, anchor_retarget_is_structural, decode_id, id_to_lud, NamedTarget,
};
use super::object::{anchor_bits_eq, decode_id, id_to_lud, NamedTarget};
use super::region_map::{set_shared, Side};
use super::{
Model, REG_FONTSTRING_META, REG_FONTSTRING_METHODS, REG_REGION_META, REG_REGION_METHODS,
@@ -80,7 +78,7 @@ pub(super) fn link_region(
) {
if model.arena.set_region_owner(rh, Some(owner)) {
// The owner supplies the region's scale to layout, so a re-link is a layout change.
model.touch_layout();
model.touch_layout_region(rh);
}
if let Some(r) = model.arena.region_mut(rh) {
r.draw_layer = layer;
@@ -123,7 +121,11 @@ pub(super) fn install_into_slot(
if let Some(f) = model.arena.frame_mut(owner) {
set(&mut f.kind_state, incoming);
}
model.touch_layout();
// The displaced region's free named its node; the incoming one re-derives with its new owner
// and points.
if let Some(rh) = incoming {
model.touch_layout_region(rh);
}
Ok(true)
}
@@ -175,6 +177,7 @@ pub(super) fn clear_texture(model: &mut Model, rh: RegionHandle) {
data.texture = None;
data.fill = None;
data.portrait_unit = None;
model.touch_layout_art(rh);
}
/// Apply the font parts an XML element supplies (`font=`, `<FontHeight>`, `outline=`), any of
@@ -265,6 +268,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
data.texture = None;
data.fill = None;
data.circular = true;
model.touch_layout_art(rh);
Ok(())
})?,
)?;
@@ -287,6 +291,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
data.texture = Some(path);
data.circular = true;
data.portrait_unit = None;
model.touch_layout_art(rh);
Ok(())
})?,
)?;
@@ -421,7 +426,7 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
};
if model.arena.set_region_owner(rh, new_owner) {
// The owner supplies the region's scale to layout, so a re-link is a layout change.
model.touch_layout();
model.touch_layout_region(rh);
}
Ok(())
},
@@ -566,18 +571,14 @@ pub(super) fn region_type_name(model: &Model, rh: RegionHandle) -> &'static str
/// wrapper raises as stale; the reference frees too, as `CSimpleButton::SetFontString 0x778d20`
/// deletes the label it replaces.
pub(crate) fn free_region(model: &mut Model, rh: RegionHandle) {
release_region(model, rh);
// A removed node takes its edges with it: a structural change, so the full touch.
model.touch_layout();
}
/// [`free_region`] without its layout touch, for a caller that frees several and touches once.
pub(crate) fn release_region(model: &mut Model, rh: RegionHandle) {
model.region_data.remove(&rh);
model.region_resolved.remove(&rh);
if let Some(id) = model.region_to_id.remove(&rh) {
model.id_to_region.remove(&id);
model.region_names.retain(|_, v| *v != id);
// Its node leaves the cached graph with its edges at the next resolve, and whatever
// anchored to it re-resolves without it.
model.name_layout_node(id);
}
model.arena.destroy_region(rh);
}
@@ -618,7 +619,7 @@ pub(crate) fn implicit_creation_anchor(model: &mut Model, rh: RegionHandle) {
}
RegionKind::Title => return,
}
model.touch_layout();
model.touch_layout_region(rh);
}
/// The middle-row point a justify word selects, as the FontString post-step (`0x771480`) and the
@@ -647,7 +648,7 @@ pub(crate) fn anchor_unanchored_at(
return;
}
data.anchors = vec![Anchor::new(point, owner_id, point, 0.0, 0.0)];
model.touch_layout();
model.touch_layout_region(rh);
}
/// [`implicit_creation_anchor`] for the loader, which holds region wrappers.
@@ -708,23 +709,9 @@ pub(super) fn region_set_point(lua: &Lua, this: &Table, args: &MultiValue) -> ml
.iter()
.any(|a| a.point == point);
if !same_at_tail {
// The same targets are a value change on this node; a moved target set is a retarget.
let structural = anchor_retarget_is_structural(&data.anchors, &new);
let old_targets: Option<Vec<u32>> =
structural.then(|| data.anchors.iter().map(|a| a.relative_to).collect());
data.anchors.retain(|a| a.point != point);
data.anchors.push(new);
match old_targets {
None => model.touch_layout_region(rh),
Some(old) => {
let new_targets: Vec<u32> = model.region_data[&rh]
.anchors
.iter()
.map(|a| a.relative_to)
.collect();
model.touch_layout_retarget_region(rh, &old, &new_targets);
}
}
model.touch_layout_region(rh);
}
Ok(())
}
@@ -14,15 +14,14 @@ use super::{
};
/// Install the layout methods into `m`.
/// `ClearAllPoints` on a region (`0x767ed0`): a retarget onto no targets, so the cached graph
/// unlinks the edges with no re-derive.
/// `ClearAllPoints` on a region (`0x767ed0`): the region leaves the cached graph with its edges.
pub(in crate::script) fn clear_points(model: &mut Model, rh: crate::widget::RegionHandle) {
let d = model.region_data.entry(rh).or_default();
let old: Option<Vec<u32>> =
(!d.anchors.is_empty()).then(|| d.anchors.iter().map(|a| a.relative_to).collect());
// Mutates only on a real change; an unanchored region leaves the graph.
let cleared = !d.anchors.is_empty();
d.anchors.clear();
if let Some(old) = old {
model.touch_layout_retarget_region(rh, &old, &[]);
if cleared {
model.touch_layout_region(rh);
}
}
@@ -155,7 +154,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
if !same {
data.anchors.clear();
data.anchors.extend_from_slice(&pair);
model.touch_layout();
model.touch_layout_region(rh);
}
Ok(())
},
+13 -15
View File
@@ -123,7 +123,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let rh = region_handle_of(lua, &this)?;
// Scoped so the model borrow ends before the layout touch; the path arm drops it
// mid-way to call the host probe.
let (loaded, derived) = {
let loaded = {
let mut model = lua.app_data_mut::<Model>().expect("model");
// SetTexture drops any portrait mask and live-unit binding.
let data = model.region_data.entry(rh).or_default();
@@ -139,11 +139,6 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
if !same_path && !colour_form {
data.desaturated = false;
}
// An anchored region with an axis sized 0 takes that span from its art, so a
// new texture moves it; an anchorless region never resolves, so for it this
// is only a paint.
let derived = !data.anchors.is_empty()
&& data.size.is_none_or(|(w, h)| w == 0.0 || h == 0.0);
// Both forms write the one texture slot (`+0xcc`), a file or an 8×8 solid, so
// each clears the other; neither touches the vertex colour (`+0xb8`), so a
// tint outlives its art.
@@ -202,15 +197,13 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
}
_ => false,
};
(loaded, derived)
loaded
};
// Only the art-sized shape can move; touching the layout on every repaint would
// reopen the resolve's change gate each frame.
if derived {
lua.app_data_mut::<Model>()
.expect("model")
.touch_layout_region(rh);
}
// Only the art-sized shape can move, and only a new art: touching the layout on
// every repaint would reopen the resolve's change gate each frame.
lua.app_data_mut::<Model>()
.expect("model")
.touch_layout_art(rh);
Ok(if loaded {
Value::Number(1.0)
} else {
@@ -490,6 +483,11 @@ impl crate::script::UiScript {
/// as `CSimpleTexture::GetWidth` (`0x770720`) falls back to the texels; without one such a
/// region stays where it was.
pub fn set_texture_size_probe(&mut self, probe: crate::script::TextureSizeProbe) {
self.model_mut().texture_size_probe = Some(probe);
let mut model = self.model_mut();
model.texture_size_probe = Some(probe);
// Every art-sized region's span may move, and no per-node hash holds a probe's answer: a
// full-scope rebuild, as for a screen move.
model.layout_scope.invalidate();
model.touch_layout();
}
}
+20 -9
View File
@@ -166,15 +166,19 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
}
};
// Every call marks the range (`0x786d16`), the same child again included.
let changed = with_scroll(lua, &this, |s| {
let changed = s.child != new_child;
s.child = new_child;
let old_child = with_scroll(lua, &this, |s| {
s.range_dirty = true;
changed
std::mem::replace(&mut s.child, new_child)
})?;
if changed {
// The child is an input of the layout resolve, so a real change dirties it.
lua.app_data_mut::<Model>().expect("model").touch_layout();
if old_child != new_child {
// The child takes this frame's override anchor and the old one drops it; the
// ScrollFrame's node re-derives its new child with it.
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
model.touch_layout_frame(h);
if let Some(old) = old_child {
model.touch_layout_frame(old);
}
}
Ok(())
})?,
@@ -211,7 +215,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
if !changed {
return Ok(());
}
lua.app_data_mut::<Model>().expect("model").touch_layout();
// The child's override offset: the ScrollFrame's node re-derives its child.
let h = frame_handle_of(lua, &this)?;
lua.app_data_mut::<Model>()
.expect("model")
.touch_layout_frame(h);
fire_scroll(lua, &this, "OnVerticalScroll", px)
})?,
)?;
@@ -241,7 +249,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
if !changed {
return Ok(());
}
lua.app_data_mut::<Model>().expect("model").touch_layout();
let h = frame_handle_of(lua, &this)?;
lua.app_data_mut::<Model>()
.expect("model")
.touch_layout_frame(h);
fire_scroll(lua, &this, "OnHorizontalScroll", px)
})?,
)?;
@@ -318,8 +318,11 @@ fn build(model: &mut Model, fh: FrameHandle, blocks: &[Block]) {
}
}
// Each new block joins the graph; the old ones' frees named theirs.
for &rh in &made {
model.touch_layout_region(rh);
}
model.simple_html.entry(fh).or_default().blocks = made;
model.touch_layout();
}
/// A block's anchor (`SetPoint` `0x767c70`): block 0 to the frame, block N to the previous text
+2 -1
View File
@@ -73,7 +73,7 @@ fn ensure_thumb(lua: &Lua, this: &Table, layer: Option<DrawLayer>) -> mlua::Resu
)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
model.region_data.insert(rh, RegionData::default());
model.touch_layout(); // a region entered the layout gate's read set
model.touch_layout_region(rh);
if let Some(frame) = model.arena.frame_mut(h) {
if let KindState::Slider(s) = &mut frame.kind_state {
s.thumb = Some(rh);
@@ -229,6 +229,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
}
_ => {}
}
model.touch_layout_art(rh);
Ok(())
})?,
)?;
+2 -1
View File
@@ -78,7 +78,7 @@ fn ensure_bar(lua: &Lua, this: &Table, layer: Option<DrawLayer>) -> mlua::Result
)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
model.region_data.insert(rh, RegionData::default());
model.touch_layout(); // a region entered the layout gate's read set
model.touch_layout_region(rh);
if let Some(frame) = model.arena.frame_mut(h) {
if let KindState::StatusBar(sb) = &mut frame.kind_state {
sb.bar = Some(rh);
@@ -228,6 +228,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
}
_ => {}
}
model.touch_layout_art(rh);
Ok(())
})?,
)?;
+1
View File
@@ -195,6 +195,7 @@ fn emblem_texture(lua: &Lua, this: Value, texture: Value, upper: bool) -> mlua::
data.fill = None;
data.portrait_unit = None;
data.circular = false;
model.touch_layout_art(rh);
Ok(())
}
+1
View File
@@ -198,6 +198,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let art = model.taxi.as_ref().map(|t| t.art.clone());
if let Some(art) = art {
model.region_data.entry(rh).or_default().texture = Some(art);
model.touch_layout_art(rh);
}
Ok(())
})?,
@@ -839,17 +839,5 @@ fn a_teardown_re_resolves_what_anchored_to_a_piece() {
incremental == full,
"the dirty-scope solve matches a full one"
);
eprintln!(
"DBG {:?}",
s.eval::<(Option<f32>, Option<f32>, i64)>(
"return dep:GetLeft(), dep:GetTop(), dep:GetNumPoints()"
)
);
eprintln!(
"DBG2 {:?}",
s.eval::<(Option<String>, Option<String>)>(
"local p, r = dep:GetPoint(1) return p, r and r:GetObjectType()"
)
);
assert!(!drawn(&s), "its anchor target is gone");
}
@@ -438,8 +438,8 @@ fn a_paint_only_write_leaves_tier_one_closed() {
assert_eq!(solves(&s), before);
}
/// A retarget patches one node's edges in the cached graph instead of re-deriving it, so the new
/// edge must land: moving the new target moves the node, and moving the old one does not.
/// A retarget re-derives one node's edges in the cached graph instead of the whole graph, so the
/// new edge must land: moving the new target moves the node, and moving the old one does not.
#[test]
fn a_retargeted_anchor_follows_its_new_target() {
let mut s = script();
@@ -0,0 +1,449 @@
//! Structural layout writes (a node born, freed, re-owned or re-pointed) through the cached graph:
//! every resolve must equal a from-scratch solve of the same model, rects and graph alike.
use super::common::script;
use crate::script::UiScript;
/// Resolve, then require the cached graph and its rects to equal a from-scratch solve.
fn resolve_matches_scratch(s: &mut UiScript, what: &str) {
s.resolve();
let mut model = s.model_mut();
if let Some(why) = UiScript::scratch_divergence(&mut model) {
panic!("{what}: {why}");
}
}
/// A region's owner supplies its scale, so re-owning it under a scaled frame moves its rect.
#[test]
fn a_region_reowned_under_a_scaled_frame_takes_the_new_scale() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
x = CreateFrame("Frame", "X", nil); x:SetWidth(100); x:SetHeight(100)
x:SetPoint("CENTER", nil, "CENTER", 0, 0)
a = CreateFrame("Frame", "A", nil); a:SetWidth(10); a:SetHeight(10)
a:SetPoint("TOPLEFT", nil, "TOPLEFT", 0, 0)
b = CreateFrame("Frame", "B", nil); b:SetWidth(10); b:SetHeight(10)
b:SetPoint("TOPLEFT", nil, "TOPLEFT", 0, 0); b:SetScale(2)
t = a:CreateTexture(nil, "ARTWORK"); t:SetTexture(1, 1, 1)
t:SetWidth(8); t:SetHeight(8); t:SetPoint("TOPLEFT", x, "TOPLEFT", 5, -5)
"#,
)
.expect("setup");
s.resolve();
s.resolve();
s.run("t:SetParent(b)").expect("re-own");
resolve_matches_scratch(&mut s, "re-owned region");
}
/// xorshift64*, so a failing seed replays.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 >> 12;
self.0 ^= self.0 << 25;
self.0 ^= self.0 >> 27;
self.0.wrapping_mul(0x2545_f491_4f6c_dd1d)
}
/// In `1..=n`, as Lua indexes.
fn pick(&mut self, n: usize) -> usize {
(self.next() % n as u64) as usize + 1
}
fn coin(&mut self, in_n: u64) -> bool {
self.next().is_multiple_of(in_n)
}
fn off(&mut self) -> i64 {
(self.next() % 41) as i64 - 20
}
}
const POINTS: [&str; 9] = [
"TOPLEFT",
"TOP",
"TOPRIGHT",
"LEFT",
"CENTER",
"RIGHT",
"BOTTOMLEFT",
"BOTTOM",
"BOTTOMRIGHT",
];
/// The Lua state the steps act on: `N` every node in creation order, `F` the frames, `R` the
/// regions, `B` the buttons, `S` the ScrollFrames, `owned[child]` the ScrollFrame that holds it.
/// `deps[o][point]` mirrors each anchor's target and `held[c]` each scroll override, so `target`
/// picks any node, older or newer, whose rect does not already read `o`'s: the graph stays acyclic.
const SETUP: &str = r#"
N, F, R, B, S, at, owned, deps, held = {}, {}, {}, {}, {}, {}, {}, {}, {}
function add(o) table.insert(N, o); at[o] = table.getn(N); deps[o] = {} end
-- Whether `a`'s rect reads `b`'s, through anchors and scroll overrides.
function reaches(a, b)
local seen, stack = {}, { a }
while table.getn(stack) > 0 do
local x = table.remove(stack)
if x == b then return true end
if not seen[x] then
seen[x] = true
for _, t in (deps[x] or {}) do table.insert(stack, t) end
if held[x] then table.insert(stack, held[x]) end
end
end
return false
end
-- A node `o` may anchor to: one of a few picks over the whole roster, else the root.
function target(o, k)
local n = table.getn(N)
for i = 0, 7 do
local t = N[math.mod(k * 7 + i * 13, n) + 1]
if t ~= o and not reaches(t, o) then return t end
end
return root
end
newer = 0
function point(o, p, t, rp, x, y)
o:SetPoint(p, t, rp, x, y); deps[o][p] = t
if at[t] and at[t] > at[o] then newer = newer + 1 end
end
function clear(o) o:ClearAllPoints(); deps[o] = {} end
function all(o, t) o:SetAllPoints(t); deps[o] = { TOPLEFT = t, BOTTOMRIGHT = t } end
root = CreateFrame("Frame", "UIParent"); root:SetWidth(400); root:SetHeight(300)
root:SetPoint("CENTER", nil, "CENTER", 0, 0)
add(root); table.insert(F, root)
spec = { bgFile = "bg", edgeFile = "edge", edgeSize = 8, insets = { left = 2, right = 2, top = 2, bottom = 2 } }
"#;
/// One write in a batch: a Lua chunk, run under `pcall` (a step may name a region another step
/// freed, which raises, as it would in the client), or the host's UI scale.
enum Write {
Lua(String),
UiScale(f32),
}
/// One random structural or value write.
fn step(rng: &mut Rng) -> Write {
let p = POINTS[rng.pick(9) - 1];
let rp = POINTS[rng.pick(9) - 1];
let (x, y) = (rng.off(), rng.off());
let k = rng.next() % 1000;
let k2 = rng.next() % 1000;
let w = if rng.coin(4) { 0 } else { rng.pick(60) };
let h = if rng.coin(4) { 0 } else { rng.pick(60) };
let lua = match rng.next() % 20 {
// A frame born under any frame, anchored to any node it may read.
0 | 1 => {
let kind = if rng.coin(3) { "Button" } else { "Frame" };
format!(
"local par = F[math.mod({k}, table.getn(F)) + 1]
local f = CreateFrame('{kind}', nil, par); add(f); table.insert(F, f)
if '{kind}' == 'Button' then table.insert(B, f) end
f:SetWidth({w}); f:SetHeight({h})
point(f, '{p}', target(f, {k2}), '{rp}', {x}, {y})"
)
}
// A texture or font string born on any frame, anchored once or twice.
2 | 3 => {
let (make, art) = if rng.coin(3) {
("CreateFontString", "")
} else if rng.coin(2) {
("CreateTexture", "t:SetTexture(1, 0, 0)")
} else {
("CreateTexture", "")
};
let second = if rng.coin(2) {
format!("point(t, '{rp}', target(t, {}), '{p}', {y}, {x})", k2 + 7)
} else {
String::new()
};
format!(
"local t = F[math.mod({k}, table.getn(F)) + 1]:{make}(nil, 'ARTWORK')
add(t); table.insert(R, t); {art}
if {w} > 0 then t:SetWidth({w}) end; if {h} > 0 then t:SetHeight({h}) end
point(t, '{p}', target(t, {k2}), '{rp}', {x}, {y}); {second}"
)
}
// A backdrop built or torn down: nine pieces born or freed at once.
4 => format!(
"local f = F[math.mod({k}, table.getn(F)) + 1]
if f:GetBackdrop() then f:SetBackdrop(nil) else f:SetBackdrop(spec) end"
),
// A node anchored to a backdrop piece, so a teardown frees what something reads.
5 => format!(
"local f = F[math.mod({k}, table.getn(F)) + 1]
local piece = ({{ f:GetRegions() }})[1]
if piece and at[f] and not at[piece] then
deps[piece] = {{ f }}
local t = f:CreateTexture(nil, 'OVERLAY'); add(t); table.insert(R, t)
t:SetWidth(5); t:SetHeight(5); point(t, '{p}', piece, '{rp}', {x}, {y})
end"
),
// A button's slot region set, or freed by a nil; or two freed in one pass.
6 => format!(
"local n = table.getn(B)
if n > 0 then
local b = B[math.mod({k}, n) + 1]
if b:GetNormalTexture() and {k2} < 400 then b:SetNormalTexture(nil)
elseif {k2} < 700 then
b:SetNormalTexture(nil); B[math.mod({k2}, n) + 1]:SetNormalTexture(nil)
else b:SetNormalTexture('slot' .. {k2}) end
end"
),
// A frame re-parented, its subtree's scale with it.
7 => format!(
"local f = F[math.mod({k}, table.getn(F)) + 1]
local par = F[math.mod({k2}, table.getn(F)) + 1]
if f ~= root and par ~= f then pcall(f.SetParent, f, par) end"
),
// A frame's own scale, the root's included: a whole-UI subtree.
8 => format!(
"F[math.mod({k}, table.getn(F)) + 1]:SetScale({})",
[0.5, 0.75, 1.0, 1.25, 2.0][(k2 % 5) as usize]
),
// A region re-owned, and re-anchored in the same pass.
9 => format!(
"if table.getn(R) > 0 then
local t = R[math.mod({k}, table.getn(R)) + 1]
t:SetParent(F[math.mod({k2}, table.getn(F)) + 1])
if {k} < 500 then point(t, '{p}', target(t, {k2}), '{rp}', {x}, {y}) end
end"
),
// An anchor re-pointed, cleared or set to all points, on a frame or a region.
10 | 11 => {
let verb = match k % 4 {
0 => "clear(o)".to_string(),
1 => format!("all(o, target(o, {k2}))"),
_ => format!("point(o, '{p}', target(o, {k2}), '{rp}', {x}, {y})"),
};
format!(
"local o = N[math.mod({k2}, table.getn(N)) + 1]
if o ~= root then {verb} end"
)
}
// A node leaving the graph and rejoining it in one pass.
12 => format!(
"local o = N[math.mod({k}, table.getn(N)) + 1]
if o ~= root then clear(o); point(o, '{p}', target(o, {k2}), '{rp}', {x}, {y}) end"
),
// A size.
13 => format!(
"local o = N[math.mod({k}, table.getn(N)) + 1]
if o ~= root then o:SetWidth({w}); o:SetHeight({h}) end"
),
// A ScrollFrame born, or one taking a frame it does not read as its child, or scrolling.
14 | 15 => format!(
"if {k} < 300 or table.getn(S) == 0 then
local par = F[math.mod({k2}, table.getn(F)) + 1]
local sf = CreateFrame('ScrollFrame', nil, par); add(sf); table.insert(F, sf)
table.insert(S, sf); sf:SetWidth(50); sf:SetHeight(50)
point(sf, '{p}', target(sf, {k}), '{rp}', {x}, {y})
else
local sf = S[math.mod({k2}, table.getn(S)) + 1]
if {k} < 650 then
local n = table.getn(F)
for i = 0, n - 1 do
local c = F[math.mod({k} + i, n) + 1]
if c ~= root and c ~= sf and not owned[c] and not reaches(sf, c) then
local old = sf:GetScrollChild()
if old then owned[old] = nil; held[old] = nil end
sf:SetScrollChild(c); owned[c] = sf; held[c] = sf
break
end
end
elseif {k} < 800 then sf:SetVerticalScroll({x})
else sf:SetHorizontalScroll({y}) end
end"
),
// Clamped to the screen.
16 => format!(
"F[math.mod({k}, table.getn(F)) + 1]:SetClampedToScreen({})",
k2.is_multiple_of(2)
),
// Art on a region whose size may take it.
17 => format!(
"if table.getn(R) > 0 then
local t = R[math.mod({k}, table.getn(R)) + 1]
if t.SetTexture then if {k2} < 500 then t:SetTexture(0, 1, 0) else t:SetTexture(nil) end end
end"
),
// The host's UI scale, `UIParent`'s whole subtree.
18 => return Write::UiScale([0.64, 0.8, 1.0][(k % 3) as usize]),
// Two backdrops torn down or built in one pass.
_ => format!(
"local n = table.getn(F)
for _, f in {{ F[math.mod({k}, n) + 1], F[math.mod({k2}, n) + 1] }} do
if f:GetBackdrop() then f:SetBackdrop(nil) else f:SetBackdrop(spec) end
end"
),
};
Write::Lua(lua)
}
/// A random run of births, frees, re-parents, re-owns, retargets, scrolls and scale changes, in
/// batches of one to four writes per resolve: after every resolve the cached graph and its rects
/// equal a from-scratch solve, and none of them derived the graph.
#[test]
fn random_structural_writes_resolve_as_a_scratch_solve_would() {
for seed in [
0x9e37_79b9_7f4a_7c15u64,
0xdead_beef_cafe_f00d,
0x0123_4567_89ab_cdef,
0x5555_aaaa_1234_9876,
] {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(SETUP).expect("setup");
s.run("raised = 0").expect("counter");
s.resolve();
let derived_at = s.layout_derivations();
let mut rng = Rng(seed);
let mut writes = 0;
for n in 0..300 {
let batch = rng.pick(4);
let mut log = String::new();
for _ in 0..batch {
writes += 1;
match step(&mut rng) {
Write::Lua(chunk) => {
s.run(&format!(
"if not pcall(function() {chunk} end) then raised = raised + 1 end"
))
.unwrap_or_else(|e| panic!("seed {seed:#x} batch {n}: {e}\n{chunk}"));
log.push_str(&chunk);
}
Write::UiScale(v) => {
s.set_ui_scale(v);
log.push_str(&format!("set_ui_scale({v})"));
}
}
log.push('\n');
}
resolve_matches_scratch(&mut s, &format!("seed {seed:#x} batch {n}:\n{log}"));
}
let (nodes, raised, frames, regions, newer) = s
.eval::<(i64, i64, i64, i64, i64)>(
"return table.getn(N), raised, table.getn(F), table.getn(R), newer",
)
.expect("count");
eprintln!(
"seed {seed:#x}: {writes} writes, {nodes} nodes ({frames} frames, {regions} \
regions), {raised} raised, {newer} anchors to a newer node"
);
assert!(
nodes > 100,
"seed {seed:#x}: the run built only {nodes} nodes"
);
assert!(
newer > 20,
"seed {seed:#x}: only {newer} anchors pointed at a newer node"
);
assert!(
raised * 10 < writes,
"seed {seed:#x}: {raised} of {writes} writes raised"
);
assert_eq!(
s.layout_derivations(),
derived_at,
"seed {seed:#x}: a structural write derived the graph instead of naming its nodes"
);
}
}
/// A frame two ScrollFrames claim takes the derivation, whose override the cached graph cannot
/// name; the result still equals a scratch solve, and a lone claim stays incremental.
#[test]
fn a_child_two_scrollframes_claim_falls_back_to_the_derivation() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
a = CreateFrame("ScrollFrame", "SA"); a:SetWidth(50); a:SetHeight(50)
a:SetPoint("TOPLEFT", nil, "TOPLEFT", 10, -10)
b = CreateFrame("ScrollFrame", "SB"); b:SetWidth(50); b:SetHeight(50)
b:SetPoint("TOPLEFT", nil, "TOPLEFT", 200, -10)
c = CreateFrame("Frame", "C"); c:SetWidth(20); c:SetHeight(20)
c:SetPoint("CENTER", nil, "CENTER", 0, 0)
"#,
)
.expect("setup");
s.resolve();
let derived = s.layout_derivations();
s.run("a:SetScrollChild(c); a:SetVerticalScroll(5)")
.expect("one claim");
resolve_matches_scratch(&mut s, "one claim");
assert_eq!(s.layout_derivations(), derived, "one claim is named");
s.run("b:SetScrollChild(c)").expect("two claims");
resolve_matches_scratch(&mut s, "two claims");
assert_eq!(
s.layout_derivations(),
derived + 1,
"two claims give up for the derivation"
);
}
/// A frame whose id is minted after a region on it joined the graph (the engine builds some
/// frames before any id, as the nameplate pool does): the mint re-derives the region, which then
/// reads the owner's scale.
#[test]
fn an_owner_minted_after_its_region_joined_takes_the_owner_edge() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"root = CreateFrame("Frame", "Root"); root:SetWidth(100); root:SetHeight(100)
root:SetPoint("CENTER", nil, "CENTER", 0, 0)"#,
)
.expect("setup");
s.resolve();
let (host, rh) = {
let mut m = s.model_mut();
let root = m.arena.lookup("Root").expect("root");
let root_id = m.frame_id(root);
let host = m
.arena
.create(crate::widget::FrameKind::Frame, None, Some(root));
let rh = m
.arena
.create_region(
host,
crate::widget::RegionKind::Texture,
crate::order::DrawLayer::Artwork,
0,
)
.expect("region");
let d = m.region_data.entry(rh).or_default();
d.size = Some((8.0, 8.0));
d.anchors = vec![crate::layout::Anchor::new(
crate::layout::Point::TopLeft,
root_id,
crate::layout::Point::TopLeft,
5.0,
-5.0,
)];
m.touch_layout_region(rh);
(host, rh)
};
resolve_matches_scratch(&mut s, "a region on an id-less owner");
{
let mut m = s.model_mut();
assert!(
!m.frame_to_id.contains_key(&host),
"the owner has no id yet"
);
m.set_frame_scale(host, 2.0);
m.frame_id(host);
}
resolve_matches_scratch(&mut s, "the owner's id minted");
let r = s
.model_ref()
.region_resolved
.get(&rh)
.copied()
.expect("rect");
assert!(
((r.right - r.left) - 16.0).abs() < 1e-3,
"the region takes its now-known owner's scale: {r:?}"
);
}
@@ -26,6 +26,7 @@ mod host_state;
mod input;
mod keyboard;
mod layout_gate;
mod layout_structure;
mod measure;
mod minimap;
mod model_clock;
+10 -14
View File
@@ -189,7 +189,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> {
}];
model.region_data.insert(left, d);
model.touch_measure(left); // an adopted cell can arrive text-in-hand
model.touch_layout(); // a line row entered the layout graph
model.touch_layout_region(left); // a line row entered the layout graph
}
let left_id = model.region_id(left);
@@ -217,7 +217,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> {
d.anchors = vec![Anchor::new(Point::Right, left_id, Point::Right, 0.0, 0.0)];
model.region_data.insert(right, d);
model.touch_measure(right); // an adopted cell can arrive text-in-hand
model.touch_layout(); // a line row entered the layout graph
model.touch_layout_region(right); // a line row entered the layout graph
}
let right_id = model.region_id(right);
@@ -447,16 +447,16 @@ fn apply_anchor(
TooltipAnchor::BottomLeft => Some((Point::TopRight, Point::BottomLeft)),
TooltipAnchor::Cursor | TooltipAnchor::None | TooltipAnchor::Preserve => None,
};
// The drop is a named retarget to no targets, not a conservative touch.
if matches!(anchor, TooltipAnchor::Cursor | TooltipAnchor::None) {
let dropped: Option<Vec<u32>> = match model.layout_inputs.get_mut(&h) {
let dropped = match model.layout_inputs.get_mut(&h) {
Some(input) if !input.anchors.is_empty() => {
Some(input.anchors.drain(..).map(|a| a.relative_to).collect())
input.anchors.clear();
true
}
_ => None,
_ => false,
};
if let Some(old_targets) = dropped {
model.touch_layout_retarget_frame(h, &old_targets, &[]);
if dropped {
model.touch_layout_frame(h);
}
}
if let Some((own, rel)) = pts {
@@ -467,11 +467,8 @@ fn apply_anchor(
let same = input.anchors.len() == 1
&& crate::script::object::anchor_bits_eq(&input.anchors[0], &new);
if !same {
// A named retarget: the cached layout graph re-points this edge instead of being
// rebuilt.
let old_targets: Vec<u32> = input.anchors.iter().map(|a| a.relative_to).collect();
input.anchors = vec![new];
model.touch_layout_retarget_frame(h, &old_targets, &[owner_id]);
model.touch_layout_frame(h);
}
}
}
@@ -716,9 +713,8 @@ fn cursor_anchor(model: &mut Model, h: FrameHandle) {
if same {
return;
}
let old_targets: Vec<u32> = input.anchors.iter().map(|a| a.relative_to).collect();
input.anchors = vec![new];
model.touch_layout_retarget_frame(h, &old_targets, &[super::SCREEN]);
model.touch_layout_frame(h);
}
pub(super) fn layout_tooltips(model: &mut Model) {
@@ -241,7 +241,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
d.justify.set_h(crate::script::JustifyH::Right);
d.anchors = vec![Anchor::new(Point::Right, left_id, Point::Right, 0.0, 0.0)];
}
model.touch_layout(); // two line rows entered the layout graph
model.touch_layout_region(lh); // two line rows entered the layout graph
model.touch_layout_region(rh);
let t = tip_mut(&mut model, h)?;
t.left_lines.push(lh);
t.right_lines.push(rh);