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