The tick ran the OnUpdate walk without resolving the layout around it, so a resize made in an event handler fired `OnSizeChanged` after the walk, and some walk slots resolved on their own to compensate. Separately, `OnSizeChanged` gated on 2⁻²² in benilla's units, and an emptied FontString kept its old measured box in the incremental layout. In the reference, the pump drains the layout queue before the walk and again after it, each drain firing `OnSizeChanged`, then re-picks the hover. ApplyRect compares sizes against 2⁻²² in its own units, where the screen diagonal is 1. The tick now drains before and after the walk and drops the stand-in resolves. Size gates scale 2⁻²² by the layout unit, and a FontString's text going empty or back names it on the layout when its node changes. Tests pin each. Fixes #912 Fixes #741 Fixes #914 Fixes #911 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
470 lines
18 KiB
Rust
470 lines
18 KiB
Rust
//! 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::{MeasureRequest, TextMeasure, UiScript};
|
|
|
|
/// A stand-in font engine, so a text-sized FontString takes a box: 6 wide per character, 12 tall.
|
|
struct Block;
|
|
|
|
impl TextMeasure for Block {
|
|
fn measure(&mut self, req: &MeasureRequest) -> (f32, f32, f32) {
|
|
let w = req.text.chars().count() as f32 * 6.0;
|
|
(w, 12.0, w)
|
|
}
|
|
}
|
|
|
|
/// 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() % 21 {
|
|
// 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]),
|
|
// A font string's text set, emptied or restored: its measured box comes and goes.
|
|
20 => format!(
|
|
"local n = table.getn(R)
|
|
if n > 0 then
|
|
local t = R[math.mod({k}, n) + 1]
|
|
if t.SetText then t:SetText(({{ '', 'hello', 'a longer line', nil }})[{}]) end
|
|
end",
|
|
k2 % 4 + 1
|
|
),
|
|
// 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, scale changes and text
|
|
/// set, emptied and restored, 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.set_text_measurer(Box::new(Block));
|
|
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:?}"
|
|
);
|
|
}
|