diff --git a/crates/benilla-app/src/ui_script/ui_scale_tests.rs b/crates/benilla-app/src/ui_script/ui_scale_tests.rs index f38e9942..bb8358cf 100644 --- a/crates/benilla-app/src/ui_script/ui_scale_tests.rs +++ b/crates/benilla-app/src/ui_script/ui_scale_tests.rs @@ -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(), diff --git a/crates/benilla-ui/src/layout.rs b/crates/benilla-ui/src/layout.rs index 158c09ea..6f20c213 100644 --- a/crates/benilla-ui/src/layout.rs +++ b/crates/benilla-ui/src/layout.rs @@ -809,9 +809,15 @@ pub struct LayoutSolver { is_frame: Vec, /// Externals as seeded, frames as they resolve. rect: Vec>, - /// 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>, + /// 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, + /// Per frame, whether it was invalidated (`0x7680e0`) since its entry state. + pending: Vec, /// 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, 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, + ) -> 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 { + 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); diff --git a/crates/benilla-ui/src/script/extract.rs b/crates/benilla-ui/src/script/extract.rs index 5a88c99f..c3d8dc87 100644 --- a/crates/benilla-ui/src/script/extract.rs +++ b/crates/benilla-ui/src/script/extract.rs @@ -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()) diff --git a/crates/benilla-ui/src/script/font_block.rs b/crates/benilla-ui/src/script/font_block.rs index b7f04675..ef345c68 100644 --- a/crates/benilla-ui/src/script/font_block.rs +++ b/crates/benilla-ui/src/script/font_block.rs @@ -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 { diff --git a/crates/benilla-ui/src/script/layout.rs b/crates/benilla-ui/src/script/layout.rs index d87270e0..688acc92 100644 --- a/crates/benilla-ui/src/script/layout.rs +++ b/crates/benilla-ui/src/script/layout.rs @@ -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 { 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, + region_kept: &mut HashMap| { + 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 = Vec::new(); - if !forced.is_empty() { + let mut invalid_mask: Vec = 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 = 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> = sweep_regions + let entry_regions: Vec<(Option, 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; diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 35eac102..8b95202b 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -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, + /// 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, + /// 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, /// 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 `` 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, - /// 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, + /// 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, /// 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. diff --git a/crates/benilla-ui/src/script/object.rs b/crates/benilla-ui/src/script/object.rs index 5daedb04..b88c016f 100644 --- a/crates/benilla-ui/src/script/object.rs +++ b/crates/benilla-ui/src/script/object.rs @@ -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}; diff --git a/crates/benilla-ui/src/script/object/frame_state.rs b/crates/benilla-ui/src/script/object/frame_state.rs index 951b394a..c79722de 100644 --- a/crates/benilla-ui/src/script/object/frame_state.rs +++ b/crates/benilla-ui/src/script/object/frame_state.rs @@ -675,9 +675,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { let mut model = lua.app_data_mut::().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(()) })?, )?; diff --git a/crates/benilla-ui/src/script/object/layout_methods.rs b/crates/benilla-ui/src/script/object/layout_methods.rs index b3f66682..e46740f2 100644 --- a/crates/benilla-ui/src/script/object/layout_methods.rs +++ b/crates/benilla-ui/src/script/object/layout_methods.rs @@ -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::().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::().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(()) } diff --git a/crates/benilla-ui/src/script/region.rs b/crates/benilla-ui/src/script/region.rs index a2807880..d1c22ee1 100644 --- a/crates/benilla-ui/src/script/region.rs +++ b/crates/benilla-ui/src/script/region.rs @@ -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(()) } diff --git a/crates/benilla-ui/src/script/region/layout.rs b/crates/benilla-ui/src/script/region/layout.rs index afbb3d6b..e77d8eb6 100644 --- a/crates/benilla-ui/src/script/region/layout.rs +++ b/crates/benilla-ui/src/script/region/layout.rs @@ -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::().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::().expect("model"); let inv = 1.0 / owner_scale(&model, rh); Ok(match model.region_resolved.get(&rh) { diff --git a/crates/benilla-ui/src/script/region/text.rs b/crates/benilla-ui/src/script/region/text.rs index fda339b1..a4702874 100644 --- a/crates/benilla-ui/src/script/region/text.rs +++ b/crates/benilla-ui/src/script/region/text.rs @@ -19,6 +19,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { let mut model = lua.app_data_mut::().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(()) })?, )?; diff --git a/crates/benilla-ui/src/script/tests/backdrop.rs b/crates/benilla-ui/src/script/tests/backdrop.rs index 08a398a3..49defb30 100644 --- a/crates/benilla-ui/src/script/tests/backdrop.rs +++ b/crates/benilla-ui/src/script/tests/backdrop.rs @@ -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" + ); } diff --git a/crates/benilla-ui/src/script/tests/clear_points.rs b/crates/benilla-ui/src/script/tests/clear_points.rs new file mode 100644 index 00000000..7477ddcb --- /dev/null +++ b/crates/benilla-ui/src/script/tests/clear_points.rs @@ -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 { + s.eval::>(&format!("return {name}:GetLeft()")) + .unwrap() +} + +/// The rect the render list draws region `name` at, if it draws. +fn drawn(s: &UiScript, name: &str) -> Option { + 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::("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:#?}"); +} diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs index 47ff00e6..d88b02ad 100644 --- a/crates/benilla-ui/src/script/tests/mod.rs +++ b/crates/benilla-ui/src/script/tests/mod.rs @@ -11,6 +11,7 @@ mod button; mod capture; mod channel; mod chat_bubble; +mod clear_points; mod common; mod cooldown; mod create_frame_template; diff --git a/crates/benilla-ui/src/script/tooltip/mod.rs b/crates/benilla-ui/src/script/tooltip/mod.rs index e7611348..f4e95d75 100644 --- a/crates/benilla-ui/src/script/tooltip/mod.rs +++ b/crates/benilla-ui/src/script/tooltip/mod.rs @@ -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();