A resolve keeps a rect that moved under 1.12.1's 1e-5 threshold (#929)

Every resolve stored the rect it computed, however small the move, so float noise in anchor chains moved rects, hit edges and size-change notifications where the reference keeps the old ones.

In the reference, the layout drain compares each of a node's four new edges with the rect it last stored. If every edge moved less than 1e-5 in its units (where the screen diagonal is 1), it keeps the stored rect and skips the store, its dependents and `ApplyRect`. A scale change, a new font or new text clears the resolved bit, so the next resolve stores without the test.

A resolve now keeps a frame's or region's rect under that threshold, scaled by the layout unit, and a scale change or a new measure stores the exact rect.

Tests cover jitter, a move just over, slow drift, a region and the forced stores.

Fixes #919

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-12 02:26:23 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent b0ad656aaa
commit 56f7fa003a
8 changed files with 424 additions and 17 deletions
+108 -3
View File
@@ -21,8 +21,9 @@ const UNSET: f64 = f64::INFINITY;
/// (`Model::size_eps`).
pub const SIZE_EPS: f64 = 2.384_185_791_015_625e-7;
/// The least edge move against the cached rect for the client to re-store it and `ApplyRect`, 1e-5
/// (`0x80c5c8`, in `0x768d20`). Recorded, not applied: the caller's own dirty gate decides.
/// The least edge move against the cached rect for the drain's resolve to store the new rect and
/// `ApplyRect`, 1e-5 (`0x3727c5ac` at `0x80c5c8`, tested in `0x768d20`), in the reference's layout
/// units; in benilla's, this times the layout unit (`Model::resolve_eps`). See [`keeps_rect`].
pub const RESOLVE_EPS: f64 = f32::from_bits(0x3727_c5ac) as f64;
/// Point id to X column: left 0, center 1, right 2.
@@ -348,6 +349,18 @@ pub fn size_changed(old: Rect, new: Rect, eps: f64) -> bool {
dw.abs() >= eps || dh.abs() >= eps
}
/// The drain's keep test (`0x768d20`, `0x768d6e`–`0x768dc0`): every edge of `new` moved less than
/// `eps` from the cached rect `old`, the deltas taken in `f64` from the `f32` edges. A kept rect is
/// not stored and `ApplyRect` does not run (the return at `0x768dc2`); an edge at `eps` or past it,
/// or a NaN, applies.
pub fn keeps_rect(old: Rect, new: Rect, eps: f64) -> bool {
let near = |a: f32, b: f32| (f64::from(a) - f64::from(b)).abs() < eps;
near(new.left, old.left)
&& near(new.right, old.right)
&& near(new.bottom, old.bottom)
&& near(new.top, old.top)
}
// ── The composed resolver: `assemble` over six recursion-guarded edge resolvers ──────────────
/// An [`Anchor`] with its target's rect looked up, `None` where `GetRect` would fail.
@@ -629,6 +642,11 @@ pub struct LayoutSolver {
is_frame: Vec<bool>,
/// Externals as seeded, frames as they resolve.
rect: Vec<Option<Rect>>,
/// Per frame, the rect it held before this round, which [`keeps_rect`] holds a new one against;
/// `None` stores the new rect whole, as the reference does while the resolved bit is clear.
cached: Vec<Option<Rect>>,
/// The keep test's threshold in the rects' units; 0 keeps nothing.
keep_eps: f64,
/// Kahn in-degree and reverse edges; each inner `Vec` is cleared, never freed.
indeg: Vec<u32>,
dependents: Vec<Vec<Handle>>,
@@ -655,6 +673,7 @@ impl LayoutSolver {
self.input.resize_with(need, LayoutInput::default);
self.is_frame.resize(need, false);
self.rect.resize(need, None);
self.cached.resize(need, None);
self.indeg.resize(need, 0);
self.dependents.resize_with(need, Vec::new);
self.marked.resize(need, false);
@@ -677,6 +696,7 @@ impl LayoutSolver {
let i = h as usize;
self.is_frame[i] = false;
self.rect[i] = None;
self.cached[i] = None;
self.indeg[i] = 0;
self.dependents[i].clear();
self.marked[i] = false;
@@ -722,6 +742,19 @@ impl LayoutSolver {
self.live.push(h);
}
/// Give registered frame `h` the rect it holds, so a new one within the keep threshold
/// ([`set_keep_eps`](Self::set_keep_eps)) leaves it standing, for `h` and its dependents alike.
pub fn set_cached(&mut self, h: Handle, rect: Rect) {
self.ensure(h);
self.touch(h);
self.cached[h as usize] = Some(rect);
}
/// The keep test's threshold, [`RESOLVE_EPS`] in the rects' units; kept across rounds.
pub fn set_keep_eps(&mut self, eps: f64) {
self.keep_eps = eps;
}
fn copy_fields(dst: &mut LayoutInput, src: &LayoutInput) {
dst.width = src.width;
dst.height = src.height;
@@ -799,7 +832,10 @@ impl LayoutSolver {
resolve_rect(input, |t| rects.get(t as usize).copied().flatten())
};
match outcome {
ResolveOutcome::Resolved(r) => self.rect[h as usize] = Some(r),
ResolveOutcome::Resolved(r) => {
let kept = self.cached[h as usize].filter(|&c| keeps_rect(c, r, self.keep_eps));
self.rect[h as usize] = Some(kept.unwrap_or(r));
}
ResolveOutcome::Unresolvable => self.unresolvable.push(h),
}
}
@@ -1184,6 +1220,75 @@ mod tests {
assert!(s.cycle().contains(&A));
}
/// `0x768d20`'s four strict `<` tests: every edge under `eps` keeps, one at `eps` applies.
#[test]
fn keeps_rect_is_strict_on_every_edge() {
let old = Rect::new(0.0, 0.0, 40.0, 100.0);
let eps = 0.5;
assert!(keeps_rect(old, Rect::new(0.25, -0.25, 40.25, 99.75), eps));
for moved in [
Rect::new(0.5, 0.0, 40.0, 100.0),
Rect::new(0.0, -0.5, 40.0, 100.0),
Rect::new(0.0, 0.0, 40.5, 100.0),
Rect::new(0.0, 0.0, 40.0, 100.5),
Rect::new(f32::NAN, 0.0, 40.0, 100.0),
] {
assert!(!keeps_rect(old, moved, eps), "{moved:?}");
}
assert!(!keeps_rect(old, old, 0.0), "a zero threshold keeps nothing");
}
/// A frame whose new rect is within the threshold of its cached one resolves to the cached
/// rect, and its dependents read that; past it, the new rect stands.
#[test]
fn the_solver_keeps_a_cached_rect_under_the_threshold() {
const A: Handle = 1;
const B: Handle = 2;
let solve = |x: f32| {
let mut s = LayoutSolver::new();
s.begin();
s.set_keep_eps(0.0128);
s.set_external(SCREEN, screen_rect());
s.set_frame(
A,
&LayoutInput::sized(
vec![Anchor::new(
Point::BottomLeft,
SCREEN,
Point::BottomLeft,
x,
0.0,
)],
20.0,
20.0,
),
);
s.set_cached(A, Rect::new(0.0, 10.0, 20.0, 30.0));
s.set_frame(
B,
&LayoutInput::sized(
vec![Anchor::new(
Point::BottomLeft,
A,
Point::BottomRight,
0.0,
0.0,
)],
5.0,
5.0,
),
);
s.solve();
(s.rect(A).unwrap(), s.rect(B).unwrap())
};
let (a, b) = solve(10.01);
assert_eq!(a, Rect::new(0.0, 10.0, 20.0, 30.0));
assert_eq!(b.left, 30.0);
let (a, b) = solve(10.02);
assert_eq!(a.left, 10.02);
assert_eq!(b.left, a.right);
}
#[test]
fn size_changed_epsilon() {
let r = Rect::new(0.0, 0.0, 40.0, 100.0);
+83 -12
View File
@@ -904,6 +904,7 @@ impl UiScript {
.map(|&h| (h, model.resolved.get(&h).copied()))
.collect();
let size_eps = model.size_eps();
let resolve_eps = model.resolve_eps();
pre.watched = pre.lap();
// ── 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
@@ -936,14 +937,26 @@ impl UiScript {
layout_rounds,
pending_size_changed,
texture_size_probe,
layout_force,
..
} = model;
let mut full_scope = false;
// The nodes this resolve stores without the keep test; a write that cleared the resolved
// bit of no node (an unanchored region's measure) leaves nothing to store.
let mut forced = std::mem::take(layout_force);
forced.retain(|&id| {
scope
.node_of
.get(id as usize)
.is_some_and(|&n| n != u32::MAX)
});
// An incremental pass has no fingerprint; its verdict is `staged`, exactly the seed set.
let gate_skips = match fp {
Some(_) => *layout_fingerprint == fp,
None => scope.staged.is_empty(),
};
// A forced node is solved even with its inputs unmoved.
let gate_skips = forced.is_empty()
&& match fp {
Some(_) => *layout_fingerprint == fp,
None => scope.staged.is_empty(),
};
pre.report(gate_skips, scope.plan.len(), scope.regions.len());
// Under verify, a frame tier 1 judged quiet must pass tier 2 too; a divergence is a write
// path missing its `touch_layout()`.
@@ -974,7 +987,8 @@ impl UiScript {
*layout_solves += 1;
}
// ── The scope: which nodes this solve may touch ────
// A node with unchanged inputs and no moved dependency recomputes to the rect it holds, so
// A node with unchanged inputs and no moved dependency recomputes to the rect it holds (the
// keep test holds a recompute against the rect it kept, so a re-run is a no-op), 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. An incremental pass
// seeded its closure in `rederive_named`.
@@ -1014,6 +1028,19 @@ impl UiScript {
scope.close();
}
}
// A forced node, and whatever reads it, is solved: the store it owes moves its rect.
for &id in &forced {
scope.seed(id);
}
scope.close();
let mut force_mask: Vec<bool> = Vec::new();
if !forced.is_empty() {
force_mask.resize(scope.node_of.len(), false);
for &id in &forced {
force_mask[id as usize] = true;
}
}
let is_forced = |id: u32| force_mask.get(id as usize).copied().unwrap_or(false);
// A dirty frame is solved, a dirty region swept, and whatever they read is seeded at its
// cached rect. A dirty region is seeded too, since the frame pass runs before the sweep.
let mut solve_frames: Vec<u32> = Vec::new();
@@ -1054,6 +1081,32 @@ impl UiScript {
}
// This solve's width, beside `layout_solves` (how often) and `layout_rounds` (how deep).
*layout_last_scope = (solve_frames.len(), sweep_regions.len());
// ── The keep test's cached rects ──────────────────────────────────────────────────────
// `0x768d20` holds each new rect against the one its last store left (`[+0x40..+0x4c]`)
// and keeps that one when every edge moved less than 1e-5 layout units, so a slow drift
// builds until it crosses. The cache is the rect at this resolve's entry: a mid-pass round
// is our fixpoint's, not a store. A forced node, a frame with no rect, a region whose
// owner's scale moved (the cascade reaches its regions, `0x76ad06`), has no cache.
let entry_frames: Vec<Option<Rect>> = solve_frames
.iter()
.map(|&i| {
let (h, id, _) = scope.plan[i as usize];
(!is_forced(id))
.then(|| resolved.get(&h).copied())
.flatten()
})
.collect();
let entry_regions: Vec<Option<Rect>> = sweep_regions
.iter()
.map(|&i| {
let row = scope.regions[i as usize];
let owner_forced = frame_to_id.get(&row.owner).is_some_and(|&o| is_forced(o));
(!is_forced(row.id) && !owner_forced)
.then(|| region_resolved.get(&row.rh).copied())
.flatten()
})
.collect();
solver.set_keep_eps(resolve_eps);
let round_cap = scope.plan.len() + region_data.len() + 2;
// `WOW_LAYOUT_PROF=1`: rounds, graph size, and the frame-solve and region-sweep split.
let prof = layout_prof_enabled();
@@ -1080,7 +1133,7 @@ impl UiScript {
solver.set_external(id, *r);
}
}
for &i in &solve_frames {
for (&i, &cached) in solve_frames.iter().zip(&entry_frames) {
let (h, id, over) = scope.plan[i as usize];
let Some(input) = layout_inputs.get(&h) else {
continue;
@@ -1089,6 +1142,9 @@ impl UiScript {
Some(a) => solver.set_frame_anchored(id, input, a),
None => solver.set_frame(id, input),
}
if let Some(c) = cached {
solver.set_cached(id, c);
}
}
solver.solve();
for &i in &solve_frames {
@@ -1116,7 +1172,7 @@ impl UiScript {
}
let t_reg = prof.then(std::time::Instant::now);
let mut scratch = LayoutInput::default();
for &ri in &sweep_regions {
for (&ri, &cached) in sweep_regions.iter().zip(&entry_regions) {
let RegionRow {
rh,
id: region_id,
@@ -1197,6 +1253,10 @@ impl UiScript {
continue;
};
let rect = Rect::new(bottom, left, top, right);
// The keep test, as for a frame: the same `0x768d20` resolves regions.
let rect = cached
.filter(|&c| layout::keeps_rect(c, rect, resolve_eps))
.unwrap_or(rect);
// Into the solver too: a later region in this sweep that anchors here needs it.
solver.set_external(region_id, rect);
if region_resolved.get(&rh) != Some(&rect) {
@@ -1302,6 +1362,7 @@ impl UiScript {
next_id,
screen,
layout_scope: scope,
layout_force,
..
} = model;
@@ -1312,12 +1373,16 @@ impl UiScript {
ids.push((h, id));
}
}
for &(h, _) in &ids {
for &(h, id) in &ids {
let (scale, clamp) = arena
.frame(h)
.map(|f| (f.effective_scale, f.clamped_to_screen))
.unwrap_or((1.0, false));
let input = layout_inputs.entry(h).or_default();
// A moved effective scale clears the resolved bit (`0x768360` from `0x76acd5`).
if input.scale.to_bits() != scale.to_bits() {
layout_force.push(id);
}
input.scale = scale;
// Clamp-to-screen is a frame property (geometry flags bit 4), so every placement path
// clamps alike, against the live window size.
@@ -1366,8 +1431,9 @@ impl UiScript {
// The reference resolves one graph in which frames and regions anchor each other; ours
// alternates a frame solve and a region sweep until a round changes no rect, compared
// exactly. The round cap is the node count, so only a cycle exhausts it, warned like the
// reference's resolving-flag bail. Every rect is recomputed from inputs and externals, so
// last frame's rects are a legal seed: only dead or unanchored regions drop.
// reference's resolving-flag bail. Every rect is recomputed from inputs and externals and
// held against its own cached rect, so last frame's rects are a legal seed: only dead or
// unanchored regions drop.
region_resolved.retain(|rh, _| {
arena.region(*rh).is_some()
&& region_data.get(rh).is_some_and(|d| !d.anchors.is_empty())
@@ -1378,8 +1444,9 @@ impl UiScript {
// 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 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.
// extent, the art of an unsized axis, owner) and liveness. Seeded rects are outputs, a
// function of these and of the rects the keep test held them against, which a re-run with
// the same inputs keeps again, 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);
@@ -1502,6 +1569,7 @@ impl UiScript {
next_id,
screen,
layout_scope: scope,
layout_force,
..
} = model;
scope.begin_incremental(*next_id as usize);
@@ -1573,6 +1641,9 @@ impl UiScript {
// 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();
if input.scale.to_bits() != scale.to_bits() {
layout_force.push(id);
}
input.scale = scale;
input.clamp = clamp;
if clamp {
+2 -1
View File
@@ -66,8 +66,9 @@ pub(super) fn ensure_measured_in(model: &mut Model, rh: RegionHandle) {
}
if moved {
// Only a box that moved touches the layout, as in `set_measured_text`: a same-width
// re-measure (a countdown tick) must not cost a whole-roster hash.
// re-measure (a countdown tick) must not cost a whole-roster hash. Stored whole, as there.
model.touch_layout_region(rh);
model.layout_force.push(req.id);
}
}
+3 -1
View File
@@ -939,8 +939,10 @@ impl UiScript {
d.measured = Some(new);
}
if moved {
// Touched per region, so the layout gate need not re-walk the whole roster.
// Touched per region, so the layout gate need not re-walk the whole roster; stored
// whole, as the reference's `SetText` clears the resolved bit (`0x771ea4`).
model.touch_layout_region(rh);
model.layout_force.push(id);
}
}
}
+7
View File
@@ -133,6 +133,12 @@ pub(crate) struct Model {
pub(crate) msg_swept: std::collections::HashMap<crate::widget::FrameHandle, (u64, u64)>,
/// Lines the message sweep hashed; a settled second sweep hashes none.
pub(crate) msg_lines_hashed: u64,
/// Layout ids whose next resolve stores the rect without the keep test
/// ([`crate::layout::keeps_rect`]), the reference's resolved bit cleared: a frame whose
/// effective scale moved (`0x768360`), whose regions follow it, and a FontString whose new
/// measure landed, benilla's moment for `SetText` and `SetFont` (`0x771ea4`, `0x7715e0`). Each
/// is named on the layout too, so the resolve that drains this list solves it.
pub(crate) layout_force: Vec<u32>,
/// Under `WOW_LAYOUT_VERIFY`, the resolve just taken was incremental and owes a full re-run.
pub(crate) layout_verify_recheck: bool,
/// Per-node input hashes and dirty-closure scratch: which nodes a let-through resolve solves.
@@ -1128,6 +1134,7 @@ impl Model {
last_click: HashMap::new(),
pending_size_changed: Vec::new(),
text_flips: Vec::new(),
layout_force: Vec::new(),
errors: Vec::new(),
pending_error_dispatch: Vec::new(),
diagnostics: Default::default(),
@@ -41,6 +41,12 @@ impl Model {
crate::layout::SIZE_EPS * f64::from(self.layout_unit())
}
/// The drain's keep threshold, 1e-5 in the reference's layout units (`[0x80c5c8]`, tested in
/// `0x768d20`), in ours: [`crate::layout::RESOLVE_EPS`] times the layout unit.
pub(crate) fn resolve_eps(&self) -> f64 {
crate::layout::RESOLVE_EPS * f64::from(self.layout_unit())
}
/// The implicit rect: a pane with no authored size takes its file's bounding-box extent, in
/// layout units, as the reference's geometry overrides answer it (`0x76d080`, `0x76d0d0`).
pub(crate) fn apply_implicit_rect(&mut self, h: FrameHandle) {
@@ -0,0 +1,214 @@
//! The drain's keep test (`0x768d20`): a resolve whose four new edges all moved less than 1e-5
//! (`[0x80c5c8]`, `0x768d6e`–`0x768dc0`) against the cached rect keeps that rect and returns at
//! `0x768dc2`, before the store and `ApplyRect`, so nothing moves: no rect, no `OnSizeChanged`, no
//! hit rect, no dependent. The threshold is in the reference's layout units, the screen diagonal
//! as 1: at 1024×768 the layout unit is 1280, so 0.0128 here.
use super::common::script;
use crate::script::UiScript;
/// 1e-5 layout units at 1024×768, in ours.
const KEEP: f64 = 0.0128;
/// A mouse-enabled panel spanned by two anchors, `Panel` at `(100, 100)`–`(300, 150)`, with a
/// child pinned to its top right and an `OnSizeChanged` counter.
fn panel() -> UiScript {
let mut s = script();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
fires = 0
Panel = CreateFrame("Frame", "Panel")
Panel:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100)
Panel:SetPoint("TOPRIGHT", nil, "BOTTOMLEFT", 300, 150)
Panel:EnableMouse(true)
Panel:SetScript("OnSizeChanged", function() fires = fires + 1 end)
Pin = CreateFrame("Frame", "Pin")
Pin:SetPoint("BOTTOMLEFT", Panel, "TOPRIGHT", 0, 0)
Pin:SetWidth(10); Pin:SetHeight(10)
"#,
)
.unwrap();
s.resolve();
assert_eq!(s.eval::<i64>("return fires").unwrap(), 1, "the birth");
s
}
fn right(s: &mut UiScript, name: &str) -> f64 {
s.eval::<f64>(&format!("return {name}:GetRight()")).unwrap()
}
fn set_right(s: &mut UiScript, x: f64) {
s.run(&format!(
r#"Panel:SetPoint("TOPRIGHT", nil, "BOTTOMLEFT", {x}, 150)"#
))
.unwrap();
s.resolve();
}
/// An anchor offset jittered by 1e-6 layout units keeps the cached rect: the right edge reads
/// back unmoved, `OnSizeChanged` stays quiet though the width moved past `ApplyRect`'s 2⁻²², the
/// hit rect does not grow over the cursor, and the frame pinned to it does not move.
#[test]
fn a_jitter_under_the_keep_threshold_keeps_the_rect() {
let mut s = panel();
set_right(&mut s, 300.0 + KEEP / 10.0);
assert_eq!(right(&mut s, "Panel"), 300.0, "the cached rect stands");
assert_eq!(s.eval::<i64>("return fires").unwrap(), 1, "no ApplyRect");
assert_eq!(
s.hit_test_name(300.0006, 120.0),
None,
"the hit rect is the kept rect's"
);
assert_eq!(
right(&mut s, "Pin"),
310.0,
"a kept rect moves no dependent"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// An edge moved just over the threshold applies, and one just under keeps.
#[test]
fn a_move_just_over_the_threshold_applies() {
let mut s = panel();
set_right(&mut s, 300.0125);
assert_eq!(right(&mut s, "Panel"), 300.0, "0.0125 is under 0.0128");
set_right(&mut s, 300.0131);
let r = right(&mut s, "Panel");
assert!((r - 300.0131).abs() < 1e-4, "0.0131 applies: {r}");
assert_eq!(s.eval::<i64>("return fires").unwrap(), 2, "ApplyRect ran");
assert!((right(&mut s, "Pin") - 310.0131).abs() < 1e-4);
assert_eq!(
s.hit_test_name(300.0128, 120.0).as_deref(),
Some("Panel"),
"the applied rect's hit rect"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The comparison is against the rect last stored, not the last one computed, so a slow drift
/// accumulates until it crosses the threshold and then lands whole.
#[test]
fn a_slow_drift_accumulates_and_applies_once_it_crosses() {
let mut s = panel();
for x in [300.004, 300.008, 300.012] {
set_right(&mut s, x);
assert_eq!(right(&mut s, "Panel"), 300.0, "{x}: under the threshold");
}
set_right(&mut s, 300.016);
let r = right(&mut s, "Panel");
assert!(
(r - 300.016).abs() < 1e-4,
"0.016 from the cached edge: {r}"
);
assert_eq!(s.eval::<i64>("return fires").unwrap(), 2);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// Regions resolve through the same `0x768d20`, so a texture keeps its rect too.
#[test]
fn a_region_keeps_its_rect_under_the_threshold() {
let mut s = script();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
Holder = CreateFrame("Frame", "Holder")
Holder:SetAllPoints(nil)
Tex = Holder:CreateTexture("Tex", "ARTWORK")
Tex:SetPoint("BOTTOMLEFT", Holder, "BOTTOMLEFT", 100, 100)
Tex:SetPoint("TOPRIGHT", Holder, "BOTTOMLEFT", 300, 150)
"#,
)
.unwrap();
s.resolve();
assert_eq!(right(&mut s, "Tex"), 300.0);
s.run(r#"Tex:SetPoint("TOPRIGHT", Holder, "BOTTOMLEFT", 300.005, 150)"#)
.unwrap();
s.resolve();
assert_eq!(right(&mut s, "Tex"), 300.0, "kept");
s.run(r#"Tex:SetPoint("TOPRIGHT", Holder, "BOTTOMLEFT", 300.02, 150)"#)
.unwrap();
s.resolve();
let r = right(&mut s, "Tex");
assert!((r - 300.02).abs() < 1e-4, "applied: {r}");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// An effective-scale move clears the cached rect's resolved bit (`0x768360`, reached from the
/// scale cascade `0x76acd5`): the next resolve stores the rect without the keep test, so a frame
/// holding a kept rect lands on its exact edge, and so do its regions, which the cascade reaches
/// (`0x76ad06`).
#[test]
fn a_scale_move_stores_the_rect_without_the_keep_test() {
let mut s = panel();
s.run(
r#"
Mark = Panel:CreateTexture("Mark", "ARTWORK")
Mark:SetPoint("BOTTOMLEFT", Panel, "BOTTOMLEFT", 0, 0)
Mark:SetWidth(10); Mark:SetHeight(10)
Panel:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100.004, 100)
"#,
)
.unwrap();
s.resolve();
assert_eq!(
s.eval::<f64>("return Mark:GetLeft()").unwrap(),
100.0,
"the region reads the kept rect"
);
assert_eq!(
s.eval::<f64>("return Panel:GetLeft()").unwrap(),
100.0,
"kept"
);
// 1 + 2⁻²⁰: past the cascade's 2⁻²² gate, and moving the left edge far less than the keep's.
s.run("Panel:SetScale(1.00000095367431640625)").unwrap();
s.resolve();
let left = s.eval::<f64>("return Panel:GetLeft()").unwrap();
assert!(
(left - 100.004).abs() < 1e-4,
"the scale move re-stores the computed rect: {left}"
);
let left = s.eval::<f64>("return Mark:GetLeft()").unwrap();
assert!((left - 100.004).abs() < 1e-4, "and the region's: {left}");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A FontString's new measure is benilla's moment for the reference's `SetText`/`SetFont`, which
/// clear the resolved bit (`0x771ea4`, `0x7715e0`): its rect is stored without the keep test.
#[test]
fn a_new_measure_stores_the_fontstring_rect_without_the_keep_test() {
let mut s = script();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
Holder = CreateFrame("Frame", "Holder")
Holder:SetAllPoints(nil)
Label = Holder:CreateFontString("Label", "ARTWORK")
Label:SetText("abc")
Label:SetPoint("BOTTOMLEFT", Holder, "BOTTOMLEFT", 100, 100)
"#,
)
.unwrap();
s.resolve();
let r = s.fontstrings_needing_measure()[0].clone();
s.set_measured_text_unwrapped(&[(r.id, 50.0, 12.0, r.key)]);
s.resolve();
assert_eq!(right(&mut s, "Label"), 150.0);
s.run(r#"Label:SetPoint("BOTTOMLEFT", Holder, "BOTTOMLEFT", 100.004, 100)"#)
.unwrap();
s.resolve();
assert_eq!(right(&mut s, "Label"), 150.0, "kept");
s.run(r#"Label:SetText("abd")"#).unwrap();
s.resolve();
let r = s.fontstrings_needing_measure()[0].clone();
s.set_measured_text_unwrapped(&[(r.id, 50.001, 12.0, r.key)]);
s.resolve();
let right = right(&mut s, "Label");
assert!(
(right - 150.005).abs() < 1e-4,
"the new measure re-stores the computed rect: {right}"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
@@ -25,6 +25,7 @@ mod gm_ticket;
mod handler_prof;
mod host_state;
mod input;
mod keep_rect;
mod keyboard;
mod layout_gate;
mod layout_structure;