ClearAllPoints keeps the rect; a failed resolve reaches nothing (#950)

`ClearAllPoints` invalidated the node, so an anchorless frame lost its rect at once, with everything anchored to it, and any region whose resolve failed stopped drawing.

In 1.12.1 `ClearAllPoints` (`0x7a2b70` → `0x767620`) only frees the anchors, queuing no resolve, so the rect stands. Only the node's own invalidate re-resolves it; a failure (`0x768d20` at `0x768d55`) clears the valid bit and skips slot 0, so its dependents keep their rects. A Texture keeps drawing its last vertices (`0x7706e0` checks only its texture); a FontString stops (`0x773080`, `0x7730b7`). A new string, font or spacing forces a store (`0x771ea4`).

Each node now carries an entry state, and takes a recomputed result only when invalidated or a target stored a new rect; a failed Texture's last quad is kept, and the full derive and the incremental pass read the same state.

Tests bite on the parent; the verifier is green.

Fixes #942
Fixes #943

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-12 05:36:21 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 75edfd7ca5
commit aff59fd55b
16 changed files with 775 additions and 85 deletions
@@ -170,6 +170,10 @@ fn the_stock_interface_inside_uiparent_is_unmoved_by_where_the_scale_lives() {
let (w, h) = (768.0 * 16.0 / 9.0, 768.0);
let mut script = UiScript::new().expect("VM");
script.set_screen_size(w, h);
// Art with an extent, as the app's probe gives it, so a texture sized 0 on an axis (a tab's
// middle, sized by its unmeasured label) resolves instead of failing: a failed texture keeps
// the quad its last good resolve wrote, which no later scale moves.
script.set_texture_size_probe(Box::new(|_| Some((32, 32))));
let failures = super::load_default_ui(&script);
assert!(
failures.is_empty(),
+166 -10
View File
@@ -809,9 +809,15 @@ 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.
/// Per node, the rect it held when this resolve began, its valid bit set, else `None`
/// ([`set_entry`](Self::set_entry)): what [`keeps_rect`] holds a new rect against, what a node
/// the resolve need not revisit keeps, and what a target's change is measured from.
cached: Vec<Option<Rect>>,
/// Per node, whether [`set_entry`](Self::set_entry) gave its entry state. A frame without one
/// is solved from its inputs alone, as if invalidated.
has_entry: Vec<bool>,
/// Per frame, whether it was invalidated (`0x7680e0`) since its entry state.
pending: Vec<bool>,
/// 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.
@@ -841,6 +847,8 @@ impl LayoutSolver {
self.is_frame.resize(need, false);
self.rect.resize(need, None);
self.cached.resize(need, None);
self.has_entry.resize(need, false);
self.pending.resize(need, false);
self.indeg.resize(need, 0);
self.dependents.resize_with(need, Vec::new);
self.marked.resize(need, false);
@@ -864,6 +872,8 @@ impl LayoutSolver {
self.is_frame[i] = false;
self.rect[i] = None;
self.cached[i] = None;
self.has_entry[i] = false;
self.pending[i] = false;
self.indeg[i] = 0;
self.dependents[i].clear();
self.marked[i] = false;
@@ -907,12 +917,71 @@ 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) {
/// The state node `h` was in when this resolve began, its rect while its valid bit is set,
/// and whether it was invalidated since (`0x7680e0`: its own write, a cleared valid bit).
///
/// The reference resolves only what is invalidated: by its own write, or by a target's
/// resolve that stored a new rect (`0x768cf0`). A failed resolve stores nothing and skips that
/// slot (`0x768d55`), and `ClearAllPoints` invalidates nothing (`0x767620`). So a node not
/// reached keeps this state when its resolve fails or reads a target with no rect. Any other
/// node not reached is recomputed, which gives back its state only if every write that moved
/// its inputs since also invalidated it; the solver cannot tell one that did not. The rect is
/// also what the keep test ([`set_keep_eps`](Self::set_keep_eps)) holds a new one against.
pub fn set_entry(&mut self, h: Handle, rect: Option<Rect>, invalidated: bool) {
self.ensure(h);
self.touch(h);
self.cached[h as usize] = Some(rect);
let i = h as usize;
self.cached[i] = rect;
self.has_entry[i] = true;
self.pending[i] = invalidated;
}
/// Whether `h` now holds a rect it did not hold at entry: a resolve that stored a new rect,
/// which invalidates what anchors to `h`. A frame given no entry counts any rect as new; an
/// external given none never changes.
#[inline]
pub fn changed(&self, h: Handle) -> bool {
let i = h as usize;
let Some(now) = self.rect.get(i).copied().flatten() else {
return false;
};
if self.has_entry[i] {
self.cached[i] != Some(now)
} else {
self.is_frame[i]
}
}
/// Whether node `h` takes the rect its inputs give it, by the rule [`set_entry`]
/// documents: when it was invalidated, when an anchor target stored a new rect, or when the
/// rect resolved against targets that all hold one. `reads` lists its anchor targets.
///
/// [`set_entry`]: Self::set_entry
pub fn takes_resolve(
&self,
h: Handle,
resolved: bool,
reads: impl Iterator<Item = Handle>,
) -> bool {
let i = h as usize;
if !self.has_entry.get(i).copied().unwrap_or(false) || self.pending[i] {
return true;
}
let mut reads_failed = false;
for t in reads {
if self.rect(t).is_none() {
reads_failed = true;
} else if self.changed(t) {
return true;
}
}
resolved && !reads_failed
}
/// The entry rect of node `h`, which it keeps when the resolve does not reach it.
#[inline]
pub fn entry(&self, h: Handle) -> Option<Rect> {
self.cached.get(h as usize).copied().flatten()
}
/// The keep test's threshold, [`RESOLVE_EPS`] in the rects' units; kept across rounds.
@@ -991,15 +1060,26 @@ impl LayoutSolver {
}
fn resolve_one(&mut self, h: Handle) {
let i = h as usize;
let outcome = {
let input = &self.input[h as usize];
let input = &self.input[i];
let rects = &self.rect;
resolve_rect(input, |t| rects.get(t as usize).copied().flatten())
};
let anchors = self.input[i].anchors;
let resolved = matches!(outcome, ResolveOutcome::Resolved(_));
if !self.takes_resolve(h, resolved, anchors.iter().map(|a| a.relative_to)) {
// Not reached: the entry state stands, rect or none.
self.rect[i] = self.cached[i];
if self.rect[i].is_none() {
self.unresolvable.push(h);
}
return;
}
match outcome {
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));
let kept = self.cached[i].filter(|&c| keeps_rect(c, r, self.keep_eps));
self.rect[i] = Some(kept.unwrap_or(r));
}
ResolveOutcome::Unresolvable => self.unresolvable.push(h),
}
@@ -1484,7 +1564,7 @@ mod tests {
20.0,
),
);
s.set_cached(A, Rect::new(0.0, 10.0, 20.0, 30.0));
s.set_entry(A, Some(Rect::new(0.0, 10.0, 20.0, 30.0)), true);
s.set_frame(
B,
&LayoutInput::sized(
@@ -1510,6 +1590,82 @@ mod tests {
assert_eq!(b.left, a.right);
}
/// A frame with no anchors keeps the rect it entered with until it is invalidated, and what
/// anchors to a frame whose resolve fails keeps its own: a failed resolve reaches nothing.
#[test]
fn a_node_not_reached_keeps_its_entry_state() {
const A: Handle = 1;
const B: Handle = 2;
let held = Rect::new(0.0, 10.0, 20.0, 30.0);
let below = Rect::new(-5.0, 30.0, 0.0, 35.0);
let solve = |a_invalidated: bool| {
let mut s = LayoutSolver::new();
s.begin();
s.set_external(SCREEN, screen_rect());
s.set_frame(A, &LayoutInput::sized(vec![], 20.0, 20.0));
s.set_entry(A, Some(held), a_invalidated);
s.set_frame(
B,
&LayoutInput::sized(
vec![Anchor::new(Point::TopLeft, A, Point::BottomRight, 0.0, 0.0)],
5.0,
5.0,
),
);
s.set_entry(B, Some(below), false);
s.solve();
(s.rect(A), s.rect(B))
};
assert_eq!(solve(false), (Some(held), Some(below)), "nothing reached A");
assert_eq!(
solve(true),
(None, Some(below)),
"A fails, and B is not reached"
);
}
/// A target that stores a new rect reaches what anchors to it, which resolves again.
#[test]
fn a_target_with_a_new_rect_reaches_its_dependents() {
const A: Handle = 1;
const B: Handle = 2;
let mut s = LayoutSolver::new();
s.begin();
s.set_external(SCREEN, screen_rect());
s.set_frame(
A,
&LayoutInput::sized(
vec![Anchor::new(
Point::BottomLeft,
SCREEN,
Point::BottomLeft,
10.0,
0.0,
)],
20.0,
20.0,
),
);
s.set_entry(A, None, true);
s.set_frame(
B,
&LayoutInput::sized(
vec![Anchor::new(
Point::BottomLeft,
A,
Point::BottomRight,
0.0,
0.0,
)],
5.0,
5.0,
),
);
s.set_entry(B, Some(Rect::new(0.0, 0.0, 1.0, 1.0)), false);
s.solve();
assert_eq!(s.rect(B), Some(Rect::new(0.0, 30.0, 5.0, 35.0)));
}
#[test]
fn size_changed_epsilon() {
let r = Rect::new(0.0, 0.0, 40.0, 100.0);
+13 -7
View File
@@ -302,20 +302,26 @@ impl UiScript {
if let Some(c) = state_color {
data.vertex_color = Some(c);
}
// A region draws at its resolved rect only: drawable regions carry anchors
// (authored, or `region::implicit_creation_anchor`) and a failed resolve
// latches unresolvable (`0x768d55`), so an unanchored Lua region draws nowhere,
// as in the reference. The bar fill and thumbs keep their fraction geometry.
// A region draws at its resolved rect: drawable regions carry anchors
// (authored, or `region::implicit_creation_anchor`), so a region that never
// resolved draws nowhere, as in the reference. After a failed resolve
// (`0x768d55`) a FontString stops (`0x7730b7` needs the valid bit) and a
// Texture keeps the quad its last good resolve wrote (`0x7706e0`). The bar
// fill and thumbs keep their fraction geometry.
if let Some(sb) = bar_fill {
data.tex_coords = Some(bar_fill_uv(data.tex_coords, sb));
}
if bar_fill.is_none() && !thumb_fill && !color_thumb {
rect = model.region_resolved.get(&rh).copied();
}
let is_text = matches!(
region.map(|r| r.kind),
Some(crate::widget::RegionKind::FontString)
);
if bar_fill.is_none() && !thumb_fill && !color_thumb {
rect = model.region_resolved.get(&rh).copied().or_else(|| {
(!is_text)
.then(|| model.region_kept.get(&rh).copied())
.flatten()
});
}
// Generated art fills only an empty slot (`ColorPickerFrame.xml` sets none).
let content = if let Some(art) =
color_art.filter(|_| data.texture.is_none() && data.fill.is_none())
@@ -264,6 +264,15 @@ pub(super) fn install(
d.font_explicit.height = true;
let after = (d.font_path.clone(), d.font_height, d.outline);
model.touch_measure(rh);
// A FontString's load of another face, height or flags clears its valid bit and
// invalidates (`0x7715e0`); the same font returns first (`0x771541`), and a freed
// one skips it (`0x7715b9`).
if kind == FontKind::FontString
&& load == Load::Loaded
&& before.as_ref() != Some(&after)
{
model.clear_region_valid(rh);
}
drop(model);
// A load that changed the path, height or flags notifies (`0x79f32f` sets the
// change bits, `0x79f342` calls the notify).
@@ -423,6 +432,11 @@ pub(super) fn install(
// Clears inherit bit `0x10` (`0x79e37b`, `0x79fbb1`).
d.font_explicit.spacing = true;
model.touch_measure(rh);
// A FontString's new spacing clears its valid bit and invalidates (`0x771666`);
// the same one returns first (`0x77161c`).
if changed && kind == FontKind::FontString {
model.clear_region_valid(rh);
}
changed
};
if changed {
+138 -23
View File
@@ -422,8 +422,9 @@ impl LayoutScope {
&& 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.
/// Drop region node `id` from the roster with its edges, seeding it so the closure revisits
/// what read its rect; nothing invalidates those, so they keep their state. 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);
@@ -819,6 +820,7 @@ impl UiScript {
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());
let kept_before = model.region_kept.clone();
let meters = (
model.layout_solves,
model.layout_rounds,
@@ -865,6 +867,14 @@ impl UiScript {
graph.as_deref().unwrap_or("same as fresh"),
));
}
if kept_before != model.region_kept {
return Some(format!(
"an INCREMENTAL layout pass kept failed textures' quads {kept_before:?}, a full \
derivation {:?}. Graph: {}",
model.region_kept,
graph.as_deref().unwrap_or("same as fresh"),
));
}
graph.map(|why| {
format!(
"the cached layout graph diverged from a fresh derivation after an INCREMENTAL \
@@ -897,6 +907,9 @@ impl UiScript {
// The GameTooltip pre-pass writes tooltip sizes and offsets from cached measures.
super::tooltip::layout_tooltips(model);
pre.tooltip = pre.lap();
// The invalidations since the last resolve, the pre-pass's included, each answered by this
// one, as the reference drains its whole queue (`0x768ed0`).
let pending = std::mem::take(&mut model.layout_pending);
// ── `OnSizeChanged`'s "before" ───────────────────────────────────────────────────────
// The reference fires it per rect application (`ApplyRect 0x76b580`); our batch fixpoint
// fires on the entry-vs-convergence diff, never on a half-solved round. Only frames with
@@ -925,7 +938,10 @@ impl UiScript {
resolved,
region_data,
region_resolved,
region_kept,
frame_to_id,
id_to_frame,
id_to_region,
screen,
warnings,
diagnostics,
@@ -941,21 +957,60 @@ impl UiScript {
pending_size_changed,
texture_size_probe,
layout_force,
layout_screen,
..
} = 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);
// An invalidated region with no anchors is no node, and its resolve can only fail
// (`0x768d55`): its valid bit clears, a Texture keeps its last quad. So does one whose
// owner's scale moved, which the cascade invalidates (`0x76ad06`).
let fail_loose = |rh: RegionHandle,
region_resolved: &mut HashMap<RegionHandle, Rect>,
region_kept: &mut HashMap<RegionHandle, Rect>| {
let Some(r) = arena.region(rh) else { return };
if region_data.get(&rh).is_some_and(|d| !d.anchors.is_empty()) {
return;
}
if let Some(rect) = region_resolved.remove(&rh) {
if !matches!(r.kind, crate::widget::RegionKind::FontString) {
region_kept.insert(rh, rect);
}
}
};
for &id in &pending {
if let Some(&rh) = id_to_region.get(&id) {
fail_loose(rh, region_resolved, region_kept);
}
}
for &id in &forced {
if let Some(&rh) = id_to_region.get(&id) {
fail_loose(rh, region_resolved, region_kept);
} else if let Some(f) = id_to_frame.get(&id).and_then(|&h| arena.frame(h)) {
for &rh in &f.regions {
fail_loose(rh, region_resolved, region_kept);
}
}
}
forced.retain(|&id| {
scope
.node_of
.get(id as usize)
.is_some_and(|&n| n != u32::MAX)
});
let mut pending = pending;
pending.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.
// A forced node is solved even with its inputs unmoved.
// A forced or invalidated node is solved even with its inputs unmoved.
let gate_skips = forced.is_empty()
&& pending.is_empty()
&& match fp {
Some(_) => *layout_fingerprint == fp,
None => scope.staged.is_empty(),
@@ -1031,19 +1086,28 @@ impl UiScript {
scope.close();
}
}
// A forced node, and whatever reads it, is solved: the store it owes moves its rect.
for &id in &forced {
// A forced or invalidated node, and whatever reads it, is solved: the store it owes moves
// its rect.
for &id in forced.iter().chain(&pending) {
scope.seed(id);
}
scope.close();
let mut force_mask: Vec<bool> = Vec::new();
if !forced.is_empty() {
let mut invalid_mask: Vec<bool> = Vec::new();
if !forced.is_empty() || !pending.is_empty() {
force_mask.resize(scope.node_of.len(), false);
invalid_mask.resize(scope.node_of.len(), false);
for &id in &forced {
force_mask[id as usize] = true;
invalid_mask[id as usize] = true;
}
for &id in &pending {
invalid_mask[id as usize] = true;
}
}
let is_forced = |id: u32| force_mask.get(id as usize).copied().unwrap_or(false);
// Invalidated since the last resolve (`0x7680e0`): solved from its inputs, never kept.
let is_invalid = |id: u32| invalid_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();
@@ -1061,9 +1125,21 @@ impl UiScript {
}
false
};
// A cleared region is no node but keeps its rect, which what anchors to it still reads.
let mut seed_loose: Vec<(u32, Rect)> = Vec::new();
for id in 0..scope.node_of.len() {
let n = scope.node_of[id];
if n == u32::MAX {
#[allow(clippy::cast_possible_truncation)]
let id32 = id as u32;
if scope.dep_head[id] != u32::MAX && read_by_dirty(scope, id32) {
if let Some(r) = id_to_region
.get(&id32)
.and_then(|rh| region_resolved.get(rh))
{
seed_loose.push((id32, *r));
}
}
continue;
}
let (idx, is_region, dirty) = (n & !REGION_TAG, n & REGION_TAG != 0, scope.dirty[id]);
@@ -1099,14 +1175,15 @@ impl UiScript {
.flatten()
})
.collect();
let entry_regions: Vec<Option<Rect>> = sweep_regions
let entry_regions: Vec<(Option<Rect>, bool)> = 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)
let cached = (!is_forced(row.id) && !owner_forced)
.then(|| region_resolved.get(&row.rh).copied())
.flatten()
.flatten();
(cached, is_invalid(row.id) || owner_forced)
})
.collect();
solver.set_keep_eps(resolve_eps);
@@ -1124,12 +1201,20 @@ impl UiScript {
// Seed the screen root and every read rect this pass does not recompute.
solver.begin();
solver.set_external(SCREEN, *screen);
// The screen the last converged resolve read: a move reaches what anchors to it.
solver.set_entry(SCREEN, *layout_screen, false);
for &i in &seed_regions {
let n = scope.regions[i as usize];
if let Some(r) = region_resolved.get(&n.rh) {
solver.set_external(n.id, *r);
}
}
for &(id, r) in &seed_loose {
solver.set_external(id, r);
}
for (&i, &(cached, invalid)) in sweep_regions.iter().zip(&entry_regions) {
solver.set_entry(scope.regions[i as usize].id, cached, invalid);
}
for &i in &seed_frames {
let (h, id, _) = scope.plan[i as usize];
if let Some(r) = resolved.get(&h) {
@@ -1145,9 +1230,7 @@ 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.set_entry(id, cached, is_invalid(id));
}
solver.solve();
for &i in &solve_frames {
@@ -1175,7 +1258,7 @@ impl UiScript {
}
let t_reg = prof.then(std::time::Instant::now);
let mut scratch = LayoutInput::default();
for (&ri, &cached) in sweep_regions.iter().zip(&entry_regions) {
for (&ri, &(cached, _)) in sweep_regions.iter().zip(&entry_regions) {
let RegionRow {
rh,
id: region_id,
@@ -1247,10 +1330,36 @@ impl UiScript {
};
let vertical = axis(edges[0], edges[2]);
let horizontal = axis(edges[1], edges[3]);
let (Some((bottom, top)), Some((left, right))) = (vertical, horizontal) else {
// Unresolvable: drop its rect, so it stops drawing instead of going stale.
if region_resolved.remove(&rh).is_some() {
let resolved_edges = vertical.zip(horizontal);
// Not reached since its entry: it keeps its state, rect or none (see
// `LayoutSolver::set_entry`).
let reads = data.anchors.iter().map(|a| a.relative_to);
if !solver.takes_resolve(region_id, resolved_edges.is_some(), reads) {
match solver.entry(region_id) {
Some(kept) => {
solver.set_external(region_id, kept);
if region_resolved.get(&rh) != Some(&kept) {
region_resolved.insert(rh, kept);
changed = true;
}
}
None => {
if region_resolved.remove(&rh).is_some() {
changed = true;
}
}
}
continue;
}
let Some(((bottom, top), (left, right))) = resolved_edges else {
// The failed resolve (`0x768d55`) clears the valid bit, so the getters and a
// FontString's draw lose the rect; a Texture keeps the quad its last good
// resolve wrote (`0x770670`), as its emit checks only its texture (`0x7706e0`).
if let Some(last) = region_resolved.remove(&rh) {
changed = true;
if !is_fontstring {
region_kept.insert(rh, last);
}
}
continue;
};
@@ -1265,6 +1374,7 @@ impl UiScript {
region_resolved.insert(rh, rect);
changed = true;
}
region_kept.remove(&rh);
}
if let Some(t) = t_reg {
@@ -1301,6 +1411,7 @@ impl UiScript {
);
}
*layout_fingerprint = fp;
*layout_screen = Some(*screen);
// Adopted only on convergence; an incremental pass adopts only what it re-hashed.
if incremental {
scope.commit_incremental();
@@ -1358,6 +1469,7 @@ impl UiScript {
layout_inputs,
region_data,
region_resolved,
region_kept,
frame_to_id,
id_to_region,
region_to_id,
@@ -1434,12 +1546,10 @@ impl UiScript {
// 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 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())
});
// held against its own cached rect, so last frame's rects are a legal seed: only dead
// regions drop. A cleared one keeps its rect until its own next invalidate.
region_resolved.retain(|rh, _| arena.region(*rh).is_some() && region_data.contains_key(rh));
region_kept.retain(|rh, _| arena.region(*rh).is_some() && region_data.contains_key(rh));
pre.retain = pre.lap();
// ── The change gate ───────────────────────────────────────────────────────────────────
@@ -1565,6 +1675,7 @@ impl UiScript {
layout_inputs,
region_data,
region_resolved,
region_kept,
frame_to_id,
id_to_frame,
id_to_region,
@@ -1681,8 +1792,12 @@ impl UiScript {
});
let Some((rh, r, data)) = live else {
if is_region_node {
// A cleared region leaves the graph but keeps its rect; a dead one drops it.
let row = scope.remove_region(id, n);
region_resolved.remove(&row.rh);
if arena.region(row.rh).is_none() || !region_data.contains_key(&row.rh) {
region_resolved.remove(&row.rh);
region_kept.remove(&row.rh);
}
} else if n != u32::MAX {
scope.targets = targets;
return false;
+93 -1
View File
@@ -142,6 +142,15 @@ pub(crate) struct Model {
/// 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>,
/// Layout ids invalidated since the last resolve (`0x7680e0` on the node): a write the
/// reference queues a resolve for, `SetPoint`, `SetWidth` and the like, but not
/// `ClearAllPoints` (`0x767620`). The next resolve solves each one from its inputs, where a
/// node nothing invalidated keeps a state a recompute could change
/// ([`Model::invalidate_frame`]).
pub(crate) layout_pending: Vec<u32>,
/// The screen the last converged resolve laid out against: a frame anchored to it is reached
/// when it moves, as by any target's new rect.
pub(crate) layout_screen: Option<Rect>,
/// 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.
@@ -174,8 +183,14 @@ pub(crate) struct Model {
/// Named `<Font>` objects by lowercased name, as 1.12 matches them with `SStrCmpI` (`0x783870`,
/// `0x7838c7`); read them only through `font_object`, where the fold lives.
pub(crate) font_objects_by_lower: HashMap<String, FontObject>,
/// Anchored regions' owner-relative rects from the last resolve; anchor-less ones are absent.
/// Each region's rect while its valid bit is set: what its getters and a FontString's draw
/// read (`GetRect 0x768320`, `0x7730b7`). A cleared region keeps its rect until its own next
/// invalidate, as `ClearAllPoints` queues no resolve (`0x767620`).
pub(crate) region_resolved: HashMap<RegionHandle, Rect>,
/// A Texture whose resolve failed: the rect its last good resolve placed it at, which it keeps
/// drawing, as the failed resolve (`0x768d55`) leaves the quad `0x770670` wrote and its emit
/// checks only its texture (`0x7706e0`). Dropped when it resolves again.
pub(crate) region_kept: HashMap<RegionHandle, Rect>,
/// The next object id to mint; ids start at 1, since 0 is `SCREEN`.
pub(crate) next_id: u32,
@@ -1123,6 +1138,7 @@ impl Model {
simple_html: simplehtml::SimpleHtmlStates::new(),
font_objects_by_lower: HashMap::new(),
region_resolved: HashMap::new(),
region_kept: HashMap::new(),
next_id: 1,
id_to_frame: IdMap::default(),
frame_to_id: HashMap::new(),
@@ -1150,6 +1166,8 @@ impl Model {
pending_size_changed: Vec::new(),
text_flips: Vec::new(),
layout_force: Vec::new(),
layout_pending: Vec::new(),
layout_screen: None,
errors: Vec::new(),
pending_error_dispatch: Vec::new(),
diagnostics: Default::default(),
@@ -1668,6 +1686,80 @@ impl Model {
}
}
/// A write the reference invalidates frame `h` for (`0x7680e0`): `SetPoint`, `SetWidth`,
/// `SetClampedToScreen` and the like, which pass `changed` for whether it moved an input. One
/// that moved nothing is skipped, as a re-resolve gives back the rect the frame holds, unless
/// the frame holds one a re-resolve would not give it ([`Self::frame_holds_stale`]).
pub(crate) fn invalidate_frame(&mut self, h: FrameHandle, changed: bool) {
if !changed && !self.frame_holds_stale(h) {
return;
}
self.touch_layout_frame(h);
if let Some(&id) = self.frame_to_id.get(&h) {
self.layout_pending.push(id);
}
}
/// [`Self::invalidate_frame`] for region `rh`.
pub(crate) fn invalidate_region(&mut self, rh: RegionHandle, changed: bool) {
if !changed && !self.region_holds_stale(rh) {
return;
}
// A region with no id was never anchored, so it holds no rect to lose.
self.touch_layout_region(rh);
if let Some(&id) = self.region_to_id.get(&rh) {
self.layout_pending.push(id);
}
}
/// A write that clears region `rh`'s valid bit and invalidates it, as a FontString's new
/// string (`0x771ea4`), font (`0x7715e0`) or spacing (`0x771666`) does: the next resolve stores
/// its rect whole, and that store reaches what anchors to it (`0x768cf0`).
pub(crate) fn clear_region_valid(&mut self, rh: RegionHandle) {
// A region with no id was never anchored, so it holds no rect.
self.touch_layout_region(rh);
if let Some(&id) = self.region_to_id.get(&rh) {
self.layout_force.push(id);
}
}
/// Whether frame `h` holds a rect its inputs would not resolve to now: it has no anchors, or
/// reads a target with no rect, so the rect stands only because nothing invalidated it since.
pub(crate) fn frame_holds_stale(&self, h: FrameHandle) -> bool {
if !self.resolved.contains_key(&h) {
return false;
}
self.layout_inputs
.get(&h)
.is_none_or(|i| self.anchors_hold_stale(&i.anchors, false))
}
/// [`Self::frame_holds_stale`] for region `rh`.
pub(crate) fn region_holds_stale(&self, rh: RegionHandle) -> bool {
if !self.region_resolved.contains_key(&rh) {
return false;
}
self.region_data
.get(&rh)
.is_none_or(|d| self.anchors_hold_stale(&d.anchors, true))
}
/// No anchors, or one whose target has no rect; a region reads none at the screen root.
fn anchors_hold_stale(&self, anchors: &crate::layout::Anchors, region: bool) -> bool {
anchors.is_empty()
|| anchors.iter().any(|a| {
let t = a.relative_to;
if t == super::SCREEN {
return region;
}
match (self.id_to_frame.get(&t), self.id_to_region.get(&t)) {
(Some(f), _) => !self.resolved.contains_key(f),
(_, Some(r)) => !self.region_resolved.contains_key(r),
_ => true,
}
})
}
/// 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.
+1
View File
@@ -22,6 +22,7 @@ mod events_regions;
mod frame_state;
mod layout_methods;
pub(crate) use layout_methods::eff_scale;
pub(in crate::script) use layout_methods::settle;
pub(crate) mod movable;
pub(crate) mod toplevel;
pub(crate) use movable::{cancel_on_hide, pump_move_or_size, FrameMove, FrameSizing};
@@ -675,9 +675,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let mut model = lua.app_data_mut::<Model>().expect("model");
let changed = model.arena.is_clamped_to_screen(h) != clamp;
model.arena.set_clamped_to_screen(h, clamp);
if changed {
model.touch_layout_frame(h);
}
// `0x768cc0` invalidates whether or not the flag moved.
model.invalidate_frame(h, changed);
Ok(())
})?,
)?;
@@ -38,8 +38,10 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
set_shared(lua, m, Side::Frame, "ClearAllPoints", |lua, this: Table| {
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.
// `0x767620` frees the anchors and unhooks each from its target, and queues no resolve:
// the frame keeps its rect until its own next invalidate. Named, as the graph loses the
// edges. Every layout setter mutates only on a real change, so a per-frame caller setting
// the same value never reopens the layout gate.
let cleared = model
.layout_inputs
.get_mut(&h)
@@ -107,12 +109,12 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
Anchor::new(Point::BottomRight, rel_id, Point::BottomRight, 0.0, 0.0),
];
let input = model.layout_inputs.entry(h).or_default();
// The reference clears and sets both (`0x767db0`); only a bit-identical pair skips.
// The reference clears, sets both and invalidates (`0x767db0`); a bit-identical pair
// moves no input.
let pair = Anchors::from(pair);
if !input.anchors.bits_eq(&pair) {
input.anchors = pair;
model.touch_layout_frame(h);
}
let changed = !input.anchors.bits_eq(&pair);
input.anchors = pair;
model.invalidate_frame(h, changed);
Ok(())
},
)?;
@@ -127,10 +129,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let input = model.layout_inputs.entry(h).or_default();
let changed = input.width.to_bits() != w.to_bits();
input.width = w;
if changed {
// A size write moves no edge and no roster membership.
model.touch_layout_frame(h);
}
// A size write moves no edge and no roster membership. It invalidates whether or not
// the width moved (`0x7683d0`).
model.invalidate_frame(h, changed);
model.note_authored_size(h);
Ok(())
},
@@ -146,9 +147,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let input = model.layout_inputs.entry(h).or_default();
let changed = input.height.to_bits() != ht.to_bits();
input.height = ht;
if changed {
model.touch_layout_frame(h);
}
model.invalidate_frame(h, changed);
model.note_authored_size(h);
Ok(())
},
@@ -226,7 +225,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
/// next layout drain (`UiScript::resolve`), not inside the getter as the reference's forced
/// `ApplyRect 0x76b580` does, because running Lua handlers inside a binding risks a borrow panic
/// or unbounded recursion.
fn settle(lua: &Lua) {
pub(in crate::script) fn settle(lua: &Lua) {
let mut model = lua.app_data_mut::<Model>().expect("model");
crate::script::UiScript::resolve_layout(&mut model);
}
@@ -275,11 +274,12 @@ fn set_point(lua: &Lua, this: &Table, args: &MultiValue) -> mlua::Result<()> {
let units = model.layout_units();
let input = model.layout_inputs.entry(h).or_default();
// An unchanged anchor stores nothing and queues no resolve (`0x767cde` → `0x767d5e`).
if input
.anchors
.set(Anchor::new(point, rel_to_id, rel_point, x, y), units)
{
model.touch_layout_frame(h);
model.invalidate_frame(h, true);
}
Ok(())
}
+6 -4
View File
@@ -678,6 +678,7 @@ pub(super) fn region_type_name(model: &Model, rh: RegionHandle) -> &'static str
pub(crate) fn free_region(model: &mut Model, rh: RegionHandle) {
model.region_data.remove(&rh);
model.region_resolved.remove(&rh);
model.region_kept.remove(&rh);
let name = model.arena.region(rh).and_then(|r| r.name.clone());
if let Some(id) = model.region_to_id.remove(&rh) {
model.id_to_region.remove(&id);
@@ -688,8 +689,9 @@ pub(crate) fn free_region(model: &mut Model, rh: RegionHandle) {
model.region_by_name.remove(&name);
}
}
// Its node leaves the cached graph with its edges at the next resolve, and whatever
// anchored to it re-resolves without it.
// Its node leaves the cached graph with its edges at the next resolve. What anchored to
// it is not invalidated (`0x768e20` unhooks each with `0x767e70`, flag 0), so it keeps
// its state.
model.name_layout_node(id);
}
model.arena.destroy_region(rh);
@@ -810,14 +812,14 @@ pub(super) fn region_set_point(lua: &Lua, this: &Table, args: &MultiValue) -> ml
let rel_to_id = resolve_rel_target(&model, &p.target, &who, "SetPoint", me, owner)?;
let (point, rel_point, x, y) = (p.point, p.rel_point, p.x, p.y);
// The frame's store and its unchanged-anchor test (`0x767c70`).
// The frame's store and its unchanged-anchor test (`0x767c70`), which queues no resolve.
let units = model.layout_units();
let data = model.region_data.entry(rh).or_default();
if data
.anchors
.set(Anchor::new(point, rel_to_id, rel_point, x, y), units)
{
model.touch_layout_region(rh);
model.invalidate_region(rh, true);
}
Ok(())
}
+20 -14
View File
@@ -15,15 +15,16 @@ use super::{
size_bits_eq,
};
/// Install the layout methods into `m`.
/// `ClearAllPoints` on a region (`0x767ed0`): the region leaves the cached graph with its edges.
/// `ClearAllPoints` on a region (`0x767ed0` → `0x767620`): the region leaves the cached graph with
/// its edges, and keeps its rect, as the clear queues no resolve, until its own next invalidate.
pub(in crate::script) fn clear_points(model: &mut Model, rh: crate::widget::RegionHandle) {
// Mutates only on a real change; an unanchored region leaves the graph.
// Mutates only on a real change.
if model.region_data.entry(rh).or_default().anchors.clear() {
model.touch_layout_region(rh);
}
}
/// Install the layout methods into `m`.
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// The explicit size fills the axes the anchors do not pin, so SetAllPoints's two corners
// leave it unread.
@@ -39,11 +40,12 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let new = Some((w, d.size.map_or(0.0, |s| s.1)));
let changed = !size_bits_eq(d.size, new);
d.size = new;
// No edge or roster change; the width, as wrap width, keys a FontString's measure. It
// invalidates whether or not the size moved (`0x7683d0`, `0x7683f0`).
if changed {
// No edge or roster change; the width, as wrap width, keys a FontString's measure.
model.touch_layout_region(rh);
model.touch_measure(rh);
}
model.invalidate_region(rh, changed);
Ok(())
},
)?;
@@ -60,11 +62,12 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let new = Some((d.size.map_or(0.0, |s| s.0), h));
let changed = !size_bits_eq(d.size, new);
d.size = new;
// No edge or roster change; the width, as wrap width, keys a FontString's measure. It
// invalidates whether or not the size moved (`0x7683d0`, `0x7683f0`).
if changed {
// No edge or roster change; the width, as wrap width, keys a FontString's measure.
model.touch_layout_region(rh);
model.touch_measure(rh);
}
model.invalidate_region(rh, changed);
Ok(())
},
)?;
@@ -80,7 +83,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
})?;
// GetLeft/GetRight/GetTop/GetBottom: the resolved edges in the owner's units (y up, screen
// over the owner's effective scale, which a region shares); nil for a region never resolved.
// over the owner's effective scale, which a region shares); nil while the valid bit is clear.
// The frame's getters (`0x7a1980`..), so they resolve first, as a frame's do.
for (name, pick) in [
("GetLeft", 0u8),
("GetRight", 1u8),
@@ -89,6 +93,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
] {
set_shared(lua, m, Side::Region, name, move |lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
crate::script::object::settle(lua);
let model = lua.app_data_ref::<Model>().expect("model");
let inv = 1.0 / owner_scale(&model, rh);
Ok(model.region_resolved.get(&rh).map(|r| {
@@ -144,12 +149,12 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
Anchor::new(Point::BottomRight, rel_id, Point::BottomRight, 0.0, 0.0),
];
let data = model.region_data.entry(rh).or_default();
// The reference clears and sets both (`0x767db0`); only a bit-identical pair skips.
// The reference clears, sets both and invalidates (`0x767db0`); a bit-identical pair
// moves no input.
let pair = Anchors::from(pair);
if !data.anchors.bits_eq(&pair) {
data.anchors = pair;
model.touch_layout_region(rh);
}
let changed = !data.anchors.bits_eq(&pair);
data.anchors = pair;
model.invalidate_region(rh, changed);
Ok(())
},
)?;
@@ -169,9 +174,10 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
})?;
// GetCenter: the resolved rect's midpoint in the owner's units, as the edge readers answer, or
// two nils before the first resolve.
// two nils while the valid bit is clear; it resolves first, as the edge readers do.
set_shared(lua, m, Side::Region, "GetCenter", |lua, this: Table| {
let rh = region_handle_of(lua, &this)?;
crate::script::object::settle(lua);
let model = lua.app_data_ref::<Model>().expect("model");
let inv = 1.0 / owner_scale(&model, rh);
Ok(match model.region_resolved.get(&rh) {
@@ -19,6 +19,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let mut model = lua.app_data_mut::<Model>().expect("model");
let data = model.region_data.entry(rh).or_default();
let was_shown = data.text.as_deref().is_some_and(|t| !t.is_empty());
let same = data.text == text;
data.text = text;
// Fresh text draws whole: an armed write-on gradient belongs to the old string.
data.alpha_gradient = None;
@@ -26,6 +27,11 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// or fills, which drops or takes up the measure the sweep reads.
model.touch_measure(rh);
model.touch_layout_text(rh, was_shown);
// A new string clears the valid bit and invalidates (`0x771ea4`); the same one returns
// first (`0x771df1`).
if !same {
model.clear_region_valid(rh);
}
Ok(())
})?,
)?;
@@ -792,10 +792,12 @@ fn the_bg_bottomright_anchor_answers_the_top_inset() {
.unwrap();
}
/// A teardown kills the pieces' layout nodes; whatever anchored to one re-resolves against the
/// missing rect in the dirty-scope solve, exactly as a from-scratch solve of the same model does.
/// A teardown kills the pieces' layout nodes. What anchored to one is not invalidated: the
/// piece's teardown (`0x767720` → `0x768e20`) unhooks it with no resolve queued (`0x767e70` with
/// a zero flag), so it keeps its rect and draws in place, in the dirty-scope solve exactly as in
/// a from-scratch solve of the same model.
#[test]
fn a_teardown_re_resolves_what_anchored_to_a_piece() {
fn a_teardown_leaves_what_anchored_to_a_piece_in_place() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
@@ -840,5 +842,8 @@ fn a_teardown_re_resolves_what_anchored_to_a_piece() {
incremental == full,
"the dirty-scope solve matches a full one"
);
assert!(!drawn(&s), "its anchor target is gone");
assert!(
drawn(&s),
"its anchor target is gone, and nothing re-resolved it"
);
}
@@ -0,0 +1,275 @@
//! `ClearAllPoints` (`0x7a2b70` → `0x767620`) only unhooks each anchor from its target
//! (`0x767fa0`) and frees it: no resolve is queued (`0x7680e0`) and no flag is written, so the
//! cached rect and its valid bit stand until the object's own next invalidate. That resolve
//! (`0x768d20`) then fails at `0x768d55`: the valid bit clears and the rect-changed slot is
//! skipped, so nothing anchored to it re-resolves. A Texture keeps the quad its last good resolve
//! wrote (`0x770670` → `0x7705b0`), as its emit checks only its texture (`0x7706e0`); a FontString's
//! emit needs the valid bit (`0x7730b7`), and so do the getters (`GetRect 0x768320`).
use super::common::script;
use crate::layout::Rect;
use crate::script::{UiScript, ZTarget};
/// `Host` at `(100, 100)`–`(300, 200)` with `Child`, a frame pinned to its top right, and `Tex`, a
/// solid texture on its bottom left, 20 square.
fn host() -> UiScript {
let mut s = script();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
Host = CreateFrame("Frame", "Host")
Host:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100)
Host:SetWidth(200); Host:SetHeight(100)
Child = CreateFrame("Frame", "Child")
Child:SetPoint("BOTTOMLEFT", Host, "TOPRIGHT", 0, 0)
Child:SetWidth(10); Child:SetHeight(10)
Tex = Host:CreateTexture("Tex", "ARTWORK")
Tex:SetTexture(1, 1, 1)
Tex:SetPoint("BOTTOMLEFT", Host, "BOTTOMLEFT", 10, 10)
Tex:SetWidth(20); Tex:SetHeight(20)
"#,
)
.unwrap();
s.resolve();
s
}
fn left(s: &mut UiScript, name: &str) -> Option<f64> {
s.eval::<Option<f64>>(&format!("return {name}:GetLeft()"))
.unwrap()
}
/// The rect the render list draws region `name` at, if it draws.
fn drawn(s: &UiScript, name: &str) -> Option<Rect> {
let rh = {
let m = s.model_ref();
let id = *m.region_by_name.get(name).expect("a named region");
*m.id_to_region.get(&id).expect("a live region")
};
s.extract()
.into_iter()
.find(|q| q.target == ZTarget::Region(rh))
.and_then(|q| q.rect)
}
/// The getter answers the old edge, before and after the next resolve: the clear queued nothing.
#[test]
fn a_cleared_frame_answers_its_old_left() {
let mut s = host();
s.run("Host:ClearAllPoints()").unwrap();
assert_eq!(left(&mut s, "Host"), Some(100.0), "the cached rect stands");
s.resolve();
assert_eq!(left(&mut s, "Host"), Some(100.0), "and outlasts a resolve");
assert_eq!(s.eval::<i64>("return Host:GetNumPoints()").unwrap(), 0);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A cleared texture not re-anchored keeps drawing in place and keeps its edges.
#[test]
fn a_cleared_texture_still_draws_and_keeps_its_left() {
let mut s = host();
let before = drawn(&s, "Tex").expect("drawn before the clear");
s.run("Tex:ClearAllPoints()").unwrap();
s.resolve();
assert_eq!(drawn(&s, "Tex"), Some(before), "the same quad");
assert_eq!(left(&mut s, "Tex"), Some(110.0));
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// What anchors to a cleared frame keeps its rect, and the cleared frame no longer follows the
/// frame it was anchored to: `0x767fa0` cut that edge.
#[test]
fn a_child_of_a_cleared_frame_keeps_its_rect() {
let mut s = host();
s.run(
r#"
Outer = CreateFrame("Frame", "Outer")
Outer:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 0, 0)
Outer:SetWidth(50); Outer:SetHeight(50)
Host:SetPoint("BOTTOMLEFT", Outer, "TOPRIGHT", 50, 50)
"#,
)
.unwrap();
s.resolve();
assert_eq!(left(&mut s, "Host"), Some(100.0));
s.run("Host:ClearAllPoints()").unwrap();
s.resolve();
assert_eq!(left(&mut s, "Child"), Some(300.0), "the child stands");
assert_eq!(left(&mut s, "Tex"), Some(110.0), "and so does the region");
s.run(r#"Outer:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 40, 0)"#)
.unwrap();
s.resolve();
assert_eq!(left(&mut s, "Outer"), Some(40.0));
assert_eq!(left(&mut s, "Host"), Some(100.0), "the old target's move");
assert_eq!(left(&mut s, "Child"), Some(300.0));
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The cleared frame's own next invalidate resolves it, and the resolve fails: its getters
/// answer nil, while everything anchored to it keeps its rect, since a failed resolve skips the
/// rect-changed slot (`0x768e0e`).
#[test]
fn a_cleared_frame_fails_on_its_own_invalidate_and_its_dependents_stand() {
let mut s = host();
let tex = drawn(&s, "Tex").expect("drawn");
s.run("Host:ClearAllPoints(); Host:SetWidth(100)").unwrap();
assert_eq!(left(&mut s, "Host"), None, "the failed resolve");
s.resolve();
assert_eq!(left(&mut s, "Host"), None);
assert_eq!(left(&mut s, "Child"), Some(300.0), "the child stands");
assert_eq!(left(&mut s, "Tex"), Some(110.0), "the region stands");
assert_eq!(drawn(&s, "Tex"), Some(tex), "and draws");
// A re-anchor resolves it again, and its rect change reaches what anchors to it.
s.run(r#"Host:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 0, 0)"#)
.unwrap();
s.resolve();
assert_eq!(left(&mut s, "Host"), Some(0.0));
assert_eq!(left(&mut s, "Child"), Some(100.0), "the child follows");
assert_eq!(left(&mut s, "Tex"), Some(10.0));
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `SetWidth` invalidates whether or not the width moved (`0x7a1feb` → `0x7683d0` → `0x7680e0`).
#[test]
fn a_same_width_still_invalidates_a_cleared_frame() {
let mut s = host();
s.run("Host:ClearAllPoints()").unwrap();
s.resolve();
assert_eq!(left(&mut s, "Host"), Some(100.0));
s.run("Host:SetWidth(200)").unwrap();
assert_eq!(left(&mut s, "Host"), None);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A clear behind an invalidate the resolve has not taken yet leaves that resolve to fail: the
/// frame is still queued (`0x7680e0`), and the clear does not dequeue it.
#[test]
fn a_clear_behind_a_queued_resolve_fails_it() {
let mut s = host();
s.run(
r#"
Host:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 120, 100)
Host:ClearAllPoints()
"#,
)
.unwrap();
assert_eq!(left(&mut s, "Host"), None);
assert_eq!(left(&mut s, "Child"), Some(300.0), "nothing moved under it");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A region's own invalidate after a clear fails it at once: its getter resolves first, as a
/// frame's does (`0x7a1980` serves both).
#[test]
fn a_cleared_texture_fails_on_its_own_invalidate() {
let mut s = host();
let tex = drawn(&s, "Tex").expect("drawn");
s.run("Tex:ClearAllPoints(); Tex:SetWidth(30)").unwrap();
assert_eq!(left(&mut s, "Tex"), None, "the getter resolves");
s.resolve();
assert_eq!(left(&mut s, "Tex"), None);
assert_eq!(drawn(&s, "Tex"), Some(tex), "the last good quad");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A texture re-anchored onto a frame that never resolved fails, and keeps drawing the quad its
/// last good resolve wrote; a FontString in the same spot stops drawing. Both getters answer nil.
#[test]
fn a_failed_texture_keeps_its_quad_and_a_failed_fontstring_does_not() {
let mut s = host();
s.run(
r#"
Label = Host:CreateFontString("Label", "ARTWORK")
Label:SetText("x")
Label:SetPoint("BOTTOMLEFT", Host, "BOTTOMLEFT", 10, 10)
Label:SetWidth(20); Label:SetHeight(20)
"#,
)
.unwrap();
s.resolve();
let tex = drawn(&s, "Tex").expect("the texture draws");
assert!(drawn(&s, "Label").is_some(), "the label draws");
s.run(
r#"
Ghost = CreateFrame("Frame", "Ghost")
Tex:SetPoint("BOTTOMLEFT", Ghost, "BOTTOMLEFT", 0, 0)
Label:SetPoint("BOTTOMLEFT", Ghost, "BOTTOMLEFT", 0, 0)
"#,
)
.unwrap();
s.resolve();
assert_eq!(left(&mut s, "Tex"), None);
assert_eq!(left(&mut s, "Label"), None);
assert_eq!(drawn(&s, "Tex"), Some(tex), "the texture's last good quad");
assert_eq!(drawn(&s, "Label"), None, "the label stops");
// Anchored again where it resolves, it draws there.
s.run(r#"Tex:SetPoint("BOTTOMLEFT", Host, "BOTTOMLEFT", 30, 30)"#)
.unwrap();
s.resolve();
assert_eq!(left(&mut s, "Tex"), Some(130.0));
assert_eq!(drawn(&s, "Tex").map(|r| r.left), Some(130.0));
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `T` spans from `FS`'s top left to the bottom right of `X`; `X` is then cleared and invalidated,
/// so it fails, and `T`, not reached, keeps its rect. `FS` is sized, so its text never moves it,
/// and `T`'s art has no extent here, so it needs both anchors.
fn stale_reader_of_a_label() -> UiScript {
let mut s = host();
s.run(
r#"
FS = Host:CreateFontString("FS", "ARTWORK")
FS:SetText("AAAA")
FS:SetPoint("BOTTOMLEFT", Host, "BOTTOMLEFT", 10, 10)
FS:SetWidth(40); FS:SetHeight(20)
X = CreateFrame("Frame", "X")
X:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 400, 300)
X:SetWidth(20); X:SetHeight(20)
T = Host:CreateTexture("T", "ARTWORK")
T:SetTexture("Interface\Buttons\WHITE8X8")
T:SetPoint("TOPLEFT", FS, "TOPLEFT", 0, 0)
T:SetPoint("BOTTOMRIGHT", X, "BOTTOMRIGHT", 0, 0)
"#,
)
.unwrap();
s.resolve();
assert_eq!(left(&mut s, "T"), Some(110.0));
s.run("X:ClearAllPoints(); X:SetWidth(10)").unwrap();
s.resolve();
assert_eq!(left(&mut s, "X"), None);
assert_eq!(left(&mut s, "T"), Some(110.0), "not reached");
s
}
/// A FontString's new string, font or spacing clears its valid bit before it invalidates
/// (`0x771ea4`, `0x7715e0`, `0x771666`), so its next resolve stores whole and its rect-changed
/// slot reaches `T`, which fails on `X` and keeps only its quad. The same value reaches nothing.
#[test]
fn a_labels_new_text_font_or_spacing_reaches_what_anchors_to_it() {
let mut wrong = Vec::new();
for (write, reaches) in [
(r#"FS:SetText("BBBB")"#, true),
(r#"FS:SetText("AAAA")"#, false),
(r#"FS:SetFont("Fonts\\ARIALN.TTF", 14)"#, true),
("FS:SetSpacing(2)", true),
("FS:SetSpacing(0)", false),
] {
let mut s = stale_reader_of_a_label();
let quad = drawn(&s, "T").expect("drawn");
s.run(write).unwrap();
s.resolve();
let want = if reaches { None } else { Some(110.0) };
let got = left(&mut s, "T");
if got != want {
wrong.push(format!("{write}: T:GetLeft() {got:?}, want {want:?}"));
}
assert_eq!(
left(&mut s, "FS"),
Some(110.0),
"{write}: the label resolves"
);
assert_eq!(drawn(&s, "T"), Some(quad), "{write}: the quad stays");
assert!(s.errors().is_empty(), "{write}: {:?}", s.errors());
}
assert!(wrong.is_empty(), "{wrong:#?}");
}
@@ -11,6 +11,7 @@ mod button;
mod capture;
mod channel;
mod chat_bubble;
mod clear_points;
mod common;
mod cooldown;
mod create_frame_template;
+11 -3
View File
@@ -775,11 +775,19 @@ pub(super) fn layout_tooltips(model: &mut Model) {
}
let input = model.layout_inputs.entry(h).or_default();
// `pad_w` is `SetPadding`'s extra width (ItemRefTooltip's room for its close button).
input.width = maxw + 2.0 * TOOLTIP_PAD + pad_w;
input.height = totalh + 2.0 * TOOLTIP_PAD;
let (w, ht) = (maxw + 2.0 * TOOLTIP_PAD + pad_w, totalh + 2.0 * TOOLTIP_PAD);
let moved = input.width.to_bits() != w.to_bits() || input.height.to_bits() != ht.to_bits();
input.width = w;
input.height = ht;
// Inside the resolve, so a note and not a touch; the incremental pass still has to hear
// that these inputs changed.
// that these inputs changed. The size goes through `SetWidth`/`SetHeight` (`0x530a2e`,
// `0x530a39`), which invalidate the tooltip.
model.note_layout_frame_write(h);
if moved {
if let Some(&id) = model.frame_to_id.get(&h) {
model.layout_pending.push(id);
}
}
// Right-flush each shown right cell: one `SetPoint(RIGHT, leftCell, LEFT, width, 0)`
// (`0x530910`..`0x53094f`), which leaves any other point the cell holds alone.
let units = model.layout_units();