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:
co-authored by
Claude Opus 5.5
parent
b0ad656aaa
commit
56f7fa003a
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user