diff --git a/crates/benilla-app/src/ui_script/resolve_bench.rs b/crates/benilla-app/src/ui_script/resolve_bench.rs index ad71ca70..acc60ff1 100644 --- a/crates/benilla-app/src/ui_script/resolve_bench.rs +++ b/crates/benilla-app/src/ui_script/resolve_bench.rs @@ -677,6 +677,11 @@ fn structural_change_costs() { r#" BenchHost = CreateFrame("Frame", "BenchHost", UIParent) BenchHost:SetPoint("CENTER", 0, 0); BenchHost:SetWidth(200); BenchHost:SetHeight(100) + BenchText = BenchHost:CreateFontString(nil, "ARTWORK", "GameFontNormal") + BenchText:SetPoint("TOPLEFT", BenchHost, "TOPLEFT", 4, -4); BenchText:SetText("bench") + BenchArt = BenchHost:CreateTexture(nil, "ARTWORK") + BenchArt:SetPoint("BOTTOMLEFT", BenchHost, "BOTTOMLEFT", 4, 4) + BenchArt:SetTexture("Interface\\Buttons\\UI-CheckBox-Check") bench_n = 0 bench_spec = { bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", tile = true, tileSize = 16, @@ -687,8 +692,28 @@ fn structural_change_costs() { for _ in 0..4 { app_frame(&mut s); } - let cases: [(&str, &str); 5] = [ + let cases: [(&str, &str); 9] = [ ("no-op", "bench_n = bench_n + 1"), + // Setters an OnUpdate calls every frame with the value already held: each is a write the + // reference invalidates for, and must not re-resolve the world. + ( + "SetWidth same", + "bench_n = bench_n + 1; BenchHost:SetWidth(200)", + ), + ( + "SetPoint same", + "bench_n = bench_n + 1; BenchHost:SetPoint('CENTER', 0, 0)", + ), + ( + "SetText same", + "bench_n = bench_n + 1; BenchText:SetText('bench')", + ), + ( + "SetTexture swap", + "bench_n = bench_n + 1; BenchArt:SetTexture(math.mod(bench_n, 2) == 0 and \ + 'Interface\\\\Buttons\\\\UI-CheckBox-Check' or \ + 'Interface\\\\Buttons\\\\UI-CheckBox-Up')", + ), ( "SetWidth", "bench_n = bench_n + 1; BenchHost:SetWidth(200 + math.mod(bench_n, 2))", @@ -712,17 +737,23 @@ fn structural_change_costs() { ]; const N: u32 = 200; for (name, lua) in cases { - let d0 = s.layout_derivations(); + let (d0, s0, r0) = (s.layout_derivations(), s.layout_solves(), s.layout_rounds()); + let mut widest = (0, 0); let t = Instant::now(); for _ in 0..N { s.run(lua).unwrap(); app_frame(&mut s); std::hint::black_box(s.extract()); + let (f, r) = s.layout_last_scope(); + widest = (widest.0.max(f), widest.1.max(r)); } println!( - "STRUCT {name:<14} {:.3} ms/iter derivations/iter={:.2}", + "STRUCT {name:<15} {:.3} ms/iter derivations/iter={:.2} solves/iter={:.2} \ + rounds/iter={:.2} widest scope={widest:?}", ms(t) / f64::from(N), (s.layout_derivations() - d0) as f64 / f64::from(N), + (s.layout_solves() - s0) as f64 / f64::from(N), + (s.layout_rounds() - r0) as f64 / f64::from(N), ); } } diff --git a/crates/benilla-ui/src/layout.rs b/crates/benilla-ui/src/layout.rs index d71fd251..90284ccb 100644 --- a/crates/benilla-ui/src/layout.rs +++ b/crates/benilla-ui/src/layout.rs @@ -232,6 +232,20 @@ impl Anchors { held } + /// Empty every slot naming a target in `dying`, returning whether any did: the unhook a dying + /// layout object runs on each dependent (`0x767e70`, from `0x768e20`). + #[inline] + pub fn release_targets(&mut self, dying: &[u32]) -> bool { + let mut freed = false; + for slot in &mut self.0 { + if slot.is_some_and(|a| dying.contains(&a.relative_to)) { + *slot = None; + freed = true; + } + } + freed + } + #[inline] pub fn is_empty(&self) -> bool { self.0.iter().all(Option::is_none) @@ -905,27 +919,15 @@ impl LayoutSolver { self.live.push(h); } - /// [`set_frame`](Self::set_frame) with `anchor` as the only anchor: a ScrollFrame child's. - pub fn set_frame_anchored(&mut self, h: Handle, src: &LayoutInput, anchor: Anchor) { - self.ensure(h); - self.touch(h); - let dst = &mut self.input[h as usize]; - dst.anchors = Anchors::from([anchor]); - Self::copy_fields(dst, src); - self.is_frame[h as usize] = true; - self.live.push(h); - } - /// 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. + /// resolve that stored a new rect (`0x768cf0`). A node the resolve does not reach keeps this + /// state, whatever its inputs now give, as a write that invalidates nothing (`ClearAllPoints` + /// `0x767620`, a dying target's unhook `0x767e70`, `SetTexture` `0x770200`) leaves it. 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); @@ -951,30 +953,18 @@ impl LayoutSolver { } } - /// 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. + /// Whether the resolve reaches node `h`, by the rule [`set_entry`] documents: it has no entry + /// state, it was invalidated, or an anchor target stored a new rect. Only then does it take + /// the rect its inputs give it, or fail. `reads` lists its anchor targets. Not modelled yet: + /// invalidating a suspended (hidden) object reaches every dependent at once (`0x768178`– + /// `0x76819c`). /// /// [`set_entry`]: Self::set_entry - pub fn takes_resolve( - &self, - h: Handle, - resolved: bool, - reads: impl Iterator, - ) -> bool { + pub fn reached(&self, h: Handle, mut 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 + !self.has_entry.get(i).copied().unwrap_or(false) + || self.pending[i] + || reads.any(|t| self.changed(t)) } /// The entry rect of node `h`, which it keeps when the resolve does not reach it. @@ -1060,14 +1050,8 @@ impl LayoutSolver { fn resolve_one(&mut self, h: Handle) { let i = h as usize; - let outcome = { - 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)) { + if !self.reached(h, 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() { @@ -1075,6 +1059,11 @@ impl LayoutSolver { } return; } + let outcome = { + let input = &self.input[i]; + let rects = &self.rect; + resolve_rect(input, |t| rects.get(t as usize).copied().flatten()) + }; match outcome { ResolveOutcome::Resolved(r) => { let kept = self.cached[i].filter(|&c| keeps_rect(c, r, self.keep_eps)); @@ -1770,44 +1759,4 @@ mod tests { ); assert!(s.unresolvable().is_empty()); } - - /// The override keeps the size and scale but replaces the anchors, and the reused input slot - /// must not leak the last round's. - #[test] - fn set_frame_anchored_replaces_only_the_anchors() { - const A: Handle = 1; - let mut s = LayoutSolver::new(); - let input = LayoutInput::sized( - vec![ - Anchor::new(Point::TopLeft, SCREEN, Point::TopLeft, 0.0, 0.0), - Anchor::new(Point::BottomRight, SCREEN, Point::BottomRight, 0.0, 0.0), - ], - 100.0, - 40.0, - ); - - // Plain: both anchors pin all four edges, so the size is ignored. - s.begin(); - s.set_external(SCREEN, screen_rect()); - s.set_frame(A, &input); - s.solve(); - assert_eq!( - bits(s.rect(A).expect("resolved")), - bits(Rect::new(0.0, 0.0, 600.0, 800.0)) - ); - - // Overridden: one TOPLEFT pin at (10, -5), so the 100x40 size decides the other edges. - s.begin(); - s.set_external(SCREEN, screen_rect()); - s.set_frame_anchored( - A, - &input, - Anchor::new(Point::TopLeft, SCREEN, Point::TopLeft, 10.0, -5.0), - ); - s.solve(); - assert_eq!( - bits(s.rect(A).expect("resolved")), - bits(Rect::new(555.0, 10.0, 595.0, 110.0)) - ); - } } diff --git a/crates/benilla-ui/src/script/backdrop.rs b/crates/benilla-ui/src/script/backdrop.rs index 58e152d4..65a96d93 100644 --- a/crates/benilla-ui/src/script/backdrop.rs +++ b/crates/benilla-ui/src/script/backdrop.rs @@ -307,10 +307,14 @@ impl Installed { pub(crate) fn install(model: &mut Model, fh: FrameHandle, spec: Option) { // Each freed piece names its node, and each built one below, so the next resolve takes the // swap through the cached graph. - let old = model.backdrops.remove(&fh); - for rh in old.iter().flat_map(|o| o.pieces).flatten() { - super::region::free_region(model, rh); - } + let old: Vec = model + .backdrops + .remove(&fh) + .iter() + .flat_map(|o| o.pieces) + .flatten() + .collect(); + super::region::free_regions(model, &old); let Some(spec) = spec.filter(|_| model.arena.frame(fh).is_some()) else { return; }; @@ -365,8 +369,9 @@ pub(crate) fn install(model: &mut Model, fh: FrameHandle, spec: Option ); } } + // Each piece is invalidated after its points are set (`0x77e9ca`..`0x77ec77`, `0x77ed05`..). for rh in pieces.iter().flatten() { - model.touch_layout_region(*rh); + model.invalidate_region(*rh, true); } model.backdrops.insert( fh, @@ -425,8 +430,9 @@ pub(crate) fn set_metrics( }; (d.anchors, d.size) = piece_geometry(owner, layout); } + // Set as `0x77e8d0` sets them, so invalidated as there. for rh in pieces.iter().flatten() { - model.touch_layout_region(*rh); + model.invalidate_region(*rh, true); } if let Some(rect) = model.resolved.get(&fh).copied() { run_texcoords(model, fh, rect); diff --git a/crates/benilla-ui/src/script/button.rs b/crates/benilla-ui/src/script/button.rs index 1fc78140..e3602cbe 100644 --- a/crates/benilla-ui/src/script/button.rs +++ b/crates/benilla-ui/src/script/button.rs @@ -481,7 +481,7 @@ fn set_slot_texture( if let Some(blend) = blend { data.blend = blend; } - model.touch_layout_art(rh); + model.note_art(rh); Ok(()) } diff --git a/crates/benilla-ui/src/script/chat_bubble.rs b/crates/benilla-ui/src/script/chat_bubble.rs index 88d06aea..8b70f958 100644 --- a/crates/benilla-ui/src/script/chat_bubble.rs +++ b/crates/benilla-ui/src/script/chat_bubble.rs @@ -341,10 +341,11 @@ fn lay_out(model: &mut Model, i: usize, g: BubbleGeometry) { -g.margin, ), ]); - // Once per geometry change: each node it wrote re-derives. - model.touch_layout_frame(frame); - model.touch_layout_region(tail); - model.touch_layout_region(text); + // Once per geometry change: each node it wrote is invalidated, as the bubble build's + // `SetPoint`s with the invalidate flag do (`0x4b0b6b`, `0x4b0b94`). + model.invalidate_frame(frame, true); + model.invalidate_region(tail, true); + model.invalidate_region(text, true); model.chat_bubbles.bubbles[i].laid_out = Some(g); } @@ -393,7 +394,8 @@ fn fit_width(model: &mut Model, i: usize, g: BubbleGeometry) { d.size = Some((width, 0.0)); } model.touch_measure(text); - model.touch_layout_region(text); + // `SetWidth`, which invalidates (`0x7683d0`). + model.invalidate_region(text, true); } /// The per-frame write, each property only when it moved. @@ -457,7 +459,8 @@ fn drive( seat.0, seat.1, )]); - model.touch_layout_region(text); + // `SetPoint` with the invalidate flag (`0x4b157a`). + model.invalidate_region(text, true); model.chat_bubbles.bubbles[i].seat = Some(seat); } } diff --git a/crates/benilla-ui/src/script/container.rs b/crates/benilla-ui/src/script/container.rs index 2d662d1f..f77bdc0f 100644 --- a/crates/benilla-ui/src/script/container.rs +++ b/crates/benilla-ui/src/script/container.rs @@ -474,7 +474,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { data.texture = path; data.circular = true; data.portrait_unit = None; - model.touch_layout_art(rh); + model.note_art(rh); Ok(()) })?, )?; diff --git a/crates/benilla-ui/src/script/editbox/relayout.rs b/crates/benilla-ui/src/script/editbox/relayout.rs index 2470b82c..ed0895a1 100644 --- a/crates/benilla-ui/src/script/editbox/relayout.rs +++ b/crates/benilla-ui/src/script/editbox/relayout.rs @@ -53,9 +53,9 @@ pub(super) fn seat_text_region(model: &mut Model, h: FrameHandle) { data.anchors = anchors; } data.size = size; - if !same_anchors || size_moved { - model.touch_layout_region(rh); - } + // The seat's points and size, each set with an invalidate (`0x77b905`, `0x77b91e`/`0x77b940`, + // `0x77b96b`). + model.invalidate_region(rh, !same_anchors || size_moved); if size_moved { // The width is the wrap width, which keys the measure. model.touch_measure(rh); @@ -137,10 +137,10 @@ pub(super) fn relayout(lua: &Lua, h: FrameHandle) -> bool { // `fld top; fadd bottom; fadd st, st(1)`. let height = (top + bottom) + text_h; let input = model.layout_inputs.entry(h).or_default(); - if input.height.to_bits() != height.to_bits() { - input.height = height; - model.touch_layout_frame(h); - } + let moved = input.height.to_bits() != height.to_bits(); + input.height = height; + // `SetHeight`, which invalidates (`0x7683f0`). + model.invalidate_frame(h, moved); true } diff --git a/crates/benilla-ui/src/script/hyperlink.rs b/crates/benilla-ui/src/script/hyperlink.rs index c0dea21d..697d9a0f 100644 --- a/crates/benilla-ui/src/script/hyperlink.rs +++ b/crates/benilla-ui/src/script/hyperlink.rs @@ -189,8 +189,10 @@ fn place(model: &mut Model, h: FrameHandle, rect: Rect, link: &str, text: &str) input.anchors = pin; input.width = w; input.height = ht; - model.touch_layout_frame(h); model.note_authored_size(h); } + // The points are cleared (`0x7a3415`), one set with the invalidate flag (`0x7a3441`), and the + // size set through the setters, which invalidate (`0x7a3457`, `0x7a346b`). + model.invalidate_frame(h, changed); changed } diff --git a/crates/benilla-ui/src/script/layout.rs b/crates/benilla-ui/src/script/layout.rs index 9684b3fc..d5a15f6d 100644 --- a/crates/benilla-ui/src/script/layout.rs +++ b/crates/benilla-ui/src/script/layout.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; -use crate::layout::{self, Anchor, LayoutInput, Point, Rect}; -use crate::widget::{FrameHandle, FrameKind, KindState, RegionHandle}; +use crate::layout::{self, Anchor, LayoutInput, Rect}; +use crate::widget::{FrameHandle, RegionHandle}; use super::types::RegionData; use super::{MeasureRequest, Model, UiScript, SCREEN}; @@ -90,13 +90,9 @@ const NO_NODE: u64 = 0; /// A frame's per-node hash; the full derive and the incremental pass must agree bit for bit. #[inline] -fn frame_node_hash(input: &LayoutInput, over: Option) -> u64 { +fn frame_node_hash(input: &LayoutInput) -> u64 { let mut node = InputFingerprint::default(); node.input(input); - match over { - Some(a) => node.anchors(std::slice::from_ref(&a)), - None => node.feed(u64::MAX), - } node.finish() } @@ -111,7 +107,8 @@ pub(super) fn effective_measure(data: &RegionData) -> Option) -> u64 { let mut node = InputFingerprint::default(); @@ -132,20 +129,6 @@ pub(super) fn region_node_hash(data: &RegionData, owner: Option) -> u64 { } None => node.feed(u64::MAX), } - // The art (`content_span`): a new texture moves such a region, while a sized region's art - // cannot move its rect. - if !data.anchors.is_empty() && unsized_axis { - node.feed(data.fill.is_some() as u64); - match &data.texture { - Some(path) => { - node.feed(path.len() as u64); - for b in path.as_bytes() { - node.feed(u64::from(*b)); - } - } - None => node.feed(u64::MAX), - } - } node.finish() } @@ -211,16 +194,10 @@ pub(crate) struct LayoutScope { dirty: Vec, /// The closure's worklist. stack: Vec, - /// The cached frame roster, `(handle, id, ScrollFrame anchor override)`, kept across resolves. - plan: Vec<(FrameHandle, u32, Option)>, + /// The cached frame roster, `(handle, id)`, kept across resolves. + plan: Vec<(FrameHandle, u32)>, /// The cached roster of live, anchored regions. regions: Vec, - /// Per ScrollFrame id, the live child it held when last derived: the one frame whose anchors - /// its override replaces. - scroll_child: HashMap, - /// Per child, the ScrollFrames holding it, `(handle, id)`: [`Self::scroll_child`] inverted, so - /// a re-derived frame finds its override without a scan. - claims: HashMap>, /// An incremental pass's `(id, hash)` writes to [`Self::last`], applied only on convergence. staged: Vec<(u32, u64)>, /// A re-derive's target list, reused. @@ -263,8 +240,6 @@ impl LayoutScope { self.stack.clear(); self.plan.clear(); self.regions.clear(); - self.scroll_child.clear(); - self.claims.clear(); self.staged.clear(); } @@ -384,33 +359,18 @@ impl LayoutScope { } } - /// Record that ScrollFrame `sid` (`sh`) now holds `child`; the child it held before, if it - /// changed, so the caller re-derives both. - fn hold( - &mut self, - sh: FrameHandle, - sid: u32, - child: Option, - ) -> Option { - let before = match child { - Some(c) => self.scroll_child.insert(sid, c), - None => self.scroll_child.remove(&sid), - }; - if before == child { - return None; - } - if let Some(old) = before { - if let Some(v) = self.claims.get_mut(&old) { - v.retain(|&(_, s)| s != sid); - if v.is_empty() { - self.claims.remove(&old); - } + /// The nodes this graph records reading `id`'s rect. It describes the model while the ledger + /// is armed (`Model::layout_touched` is `Some`), but for the nodes named since. + pub(crate) fn readers_of(&self, id: u32) -> impl Iterator + '_ { + let mut e = self.dep_head.get(id as usize).copied().unwrap_or(u32::MAX); + std::iter::from_fn(move || { + if e == u32::MAX { + return None; } - } - if let Some(c) = child { - self.claims.entry(c).or_default().push((sh, sid)); - } - before + let from = self.dep_to[e as usize]; + e = self.dep_next[e as usize]; + Some(from) + }) } /// Whether `id` is a node whose converged hash is `hash`: a write that leaves it there moved @@ -507,11 +467,6 @@ impl LayoutScope { v.sort_unstable(); v }; - let over_eq = |a: Option, b: Option| match (a, b) { - (None, None) => true, - (Some(a), Some(b)) => a.bits_eq(&b), - _ => false, - }; let n = self .node_of .len() @@ -527,10 +482,7 @@ impl LayoutScope { let r = s.regions[(n & !REGION_TAG) as usize]; format!("region {:?} owner {:?}", r.rh, r.owner) } - n => format!( - "frame {:?} override {:?}", - s.plan[n as usize].0, s.plan[n as usize].2 - ), + n => format!("frame {:?}", s.plan[n as usize].0), } }; let same_node = match (a, b) { @@ -541,10 +493,7 @@ impl LayoutScope { self.regions[(a & !REGION_TAG) as usize] == fresh.regions[(b & !REGION_TAG) as usize] } - (a, b) => { - let (x, y) = (self.plan[a as usize], fresh.plan[b as usize]); - x.0 == y.0 && x.1 == y.1 && over_eq(x.2, y.2) - } + (a, b) => self.plan[a as usize] == fresh.plan[b as usize], }; if !same_node { return Some(( @@ -583,30 +532,7 @@ impl LayoutScope { )); } } - let held = |s: &LayoutScope| { - let mut v: Vec<(u32, FrameHandle)> = - s.scroll_child.iter().map(|(&id, &c)| (id, c)).collect(); - v.sort_unstable_by_key(|e| e.0); - v - }; - let (sa, sb) = (held(self), held(fresh)); - if sa != sb { - return Some((0, format!("ScrollFrames hold {sa:?} cached, {sb:?} fresh"))); - } - // The inverse must agree with its source, entry for entry. - let claimed: usize = self.claims.values().map(Vec::len).sum(); - let inverse_ok = claimed == sa.len() - && sa.iter().all(|&(id, c)| { - self.claims - .get(&c) - .is_some_and(|v| v.iter().any(|&(_, s)| s == id)) - }); - (!inverse_ok).then(|| { - ( - 0, - format!("ScrollFrame claims {:?} disagree with {sa:?}", self.claims), - ) - }) + None } } @@ -646,11 +572,12 @@ fn node_inputs(model: &Model, id: u32) -> String { if let Some(h) = model.id_to_frame.get(&id) { let f = model.arena.frame(*h); return format!( - "frame {:?} {:?}, effective scale {:?}, clamped {:?}", + "frame {:?} {:?}, effective scale {:?}, clamped {:?}, unsettled {}", f.and_then(|f| f.name.clone()), model.layout_inputs.get(h), f.map(|f| f.effective_scale), f.map(|f| f.clamped_to_screen), + model.layout_unsettled.contains(&id), ); } let Some(rh) = model.id_to_region.get(&id) else { @@ -661,13 +588,14 @@ fn node_inputs(model: &Model, id: u32) -> String { match model.region_data.get(rh) { Some(d) => format!( "region owner {owner:?} (id {owner_id:?}), anchors {:?}, size {:?}, measured {:?} \ - (text {} bytes), texture {:?}, fill {}", + (text {} bytes), texture {:?}, fill {}, unsettled {}", d.anchors, d.size, d.measured.map(|m| (m.w, m.h)), d.text.as_deref().map_or(0, str::len), d.texture, - d.fill.is_some() + d.fill.is_some(), + model.layout_unsettled.contains(&id), ), None => format!("region owner {owner:?} with no data"), } @@ -681,7 +609,7 @@ fn layout_prof_enabled() -> bool { /// `WOW_LAYOUT_VERIFY=1`, on in this crate's tests: every shortcut of the resolve (a gate skip, a /// tier-1-quiet frame, an incremental pass, the measure ledger) is checked against the full answer. -fn layout_verify_enabled() -> bool { +pub(super) fn layout_verify_enabled() -> bool { static ON: std::sync::OnceLock = std::sync::OnceLock::new(); *ON.get_or_init(|| cfg!(test) || std::env::var("WOW_LAYOUT_VERIFY").as_deref() == Ok("1")) } @@ -697,8 +625,6 @@ struct PreambleProf { watched: u128, /// The ids rebuild and the scale and clamp sync into `layout_inputs`. ids: u128, - /// The ScrollFrame override map. - scroll: u128, /// `region_resolved`'s liveness retain. retain: u128, /// `LayoutScope::begin_full`. @@ -741,7 +667,6 @@ impl PreambleProf { let total = self.tooltip + self.watched + self.ids - + self.scroll + self.retain + self.begin + self.fp_frames @@ -749,14 +674,13 @@ impl PreambleProf { + self.seed; eprintln!( "[layout-pre] skips={} incr={} frames={frames} anchored={regions} total_us={total} \ - tooltip={} watched={} ids={} scroll={} retain={} begin={} \ + tooltip={} watched={} ids={} retain={} begin={} \ fp_frames={} fp_regions={} seed={}", u8::from(skips), u8::from(self.incremental), self.tooltip, self.watched, self.ids, - self.scroll, self.retain, self.begin, self.fp_frames, @@ -958,12 +882,17 @@ impl UiScript { texture_size_probe, layout_force, layout_screen, + layout_unsettled, .. } = 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); + // Each is reached now, so it holds what its inputs give again. + for id in forced.iter().chain(&pending) { + layout_unsettled.remove(id); + } // 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`). @@ -1169,7 +1098,7 @@ impl UiScript { let entry_frames: Vec> = solve_frames .iter() .map(|&i| { - let (h, id, _) = scope.plan[i as usize]; + let (h, id) = scope.plan[i as usize]; (!is_forced(id)) .then(|| resolved.get(&h).copied()) .flatten() @@ -1216,25 +1145,22 @@ impl UiScript { solver.set_entry(scope.regions[i as usize].id, cached, invalid); } for &i in &seed_frames { - let (h, id, _) = scope.plan[i as usize]; + let (h, id) = scope.plan[i as usize]; if let Some(r) = resolved.get(&h) { solver.set_external(id, *r); } } for (&i, &cached) in solve_frames.iter().zip(&entry_frames) { - let (h, id, over) = scope.plan[i as usize]; + let (h, id) = scope.plan[i as usize]; let Some(input) = layout_inputs.get(&h) else { continue; }; - match over { - Some(a) => solver.set_frame_anchored(id, input, a), - None => solver.set_frame(id, input), - } + solver.set_frame(id, input); solver.set_entry(id, cached, is_invalid(id)); } solver.solve(); for &i in &solve_frames { - let (h, id, _) = scope.plan[i as usize]; + let (h, id) = scope.plan[i as usize]; match solver.rect(id) { Some(r) => { if resolved.get(&h) != Some(&r) { @@ -1272,6 +1198,25 @@ impl UiScript { if prof { n_regions_swept += 1; } + // Not reached since its entry: it keeps its state, rect or none (see + // `LayoutSolver::set_entry`). + if !solver.reached(region_id, data.anchors.iter().map(|a| a.relative_to)) { + 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; + } // The owner supplies the region's scale and nothing else: no fallback edges. let scale = arena.frame(owner).map(|f| f.effective_scale).unwrap_or(1.0); // An axis authored 0, width or height, takes the host-measured extent: the @@ -1297,6 +1242,8 @@ impl UiScript { // A texture's unset axis takes its texel extent (`content_span`): the reference's // size getter is virtual for both region classes. It must apply before the // resolve, as `combine_edge` reads a zero span as no size and leaves an edge unset. + // The getter runs in the resolve (`0x767579`, `0x7675e9`), loading the art first if + // it must (`0x44b3b0`), so only a resolve that reaches the node reads it. if !is_fontstring && (width == 0.0 || height == 0.0) { if let Some((cw, ch)) = content_span(data, texture_size_probe.as_ref()) { if width == 0.0 { @@ -1331,26 +1278,6 @@ impl UiScript { let vertical = axis(edges[0], edges[2]); let horizontal = axis(edges[1], edges[3]); 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 @@ -1508,40 +1435,6 @@ impl UiScript { } pre.ids = pre.lap(); - // A live ScrollFrame's live child is anchored, for this solve only, TOPLEFT to its TOPLEFT - // at the raw, unnegated `(horizontal, vertical)` offsets, as the reference's re-anchor - // `0x787100` passes them to `SetPoint`: y up, so a positive vertical lifts the child. Its - // authored anchors and size are untouched, so `SetScrollChild(nil)` needs no restore. - let mut scroll_child_anchor: HashMap = HashMap::new(); - let mut scroll_holds: Vec<(FrameHandle, u32, FrameHandle)> = Vec::new(); - for &(h, id) in &ids { - let Some(frame) = arena.frame(h) else { - continue; - }; - if frame.kind != FrameKind::ScrollFrame { - continue; - } - let KindState::Scroll(state) = &frame.kind_state else { - continue; - }; - let Some(child) = state.child else { continue }; - if arena.frame(child).is_none() { - continue; // a stale/destroyed child contributes no override - } - scroll_holds.push((h, id, child)); - scroll_child_anchor.insert( - child, - Anchor::new( - Point::TopLeft, - id, - Point::TopLeft, - state.horizontal, - state.vertical, - ), - ); - } - pre.scroll = pre.lap(); - // 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 @@ -1554,17 +1447,15 @@ impl UiScript { // ── The change gate ─────────────────────────────────────────────────────────────────── // 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 + // converged resolve: the screen; each live frame's id and synced `LayoutInput`; each + // anchored region's node hash (anchors, explicit size, measured + // extent, owner) and liveness. A Texture's art is no input: the sweep reads it afresh only + // for a node a write named or the resolve reaches. 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); - for (h, id, child) in scroll_holds { - scope.hold(h, id, Some(child)); - } pre.begin = pre.lap(); let mut fp = InputFingerprint::default(); fp.rect(*screen); @@ -1572,29 +1463,18 @@ impl UiScript { let Some(input) = layout_inputs.get(&h) else { continue; }; - let over = scroll_child_anchor.get(&h).copied(); #[allow(clippy::cast_possible_truncation)] let i = scope.plan.len() as u32; fp.feed(u64::from(id)); fp.input(input); - match over { - Some(a) => fp.anchors(std::slice::from_ref(&a)), - None => fp.feed(u64::MAX), - } if let Some(slot) = scope.now.get_mut(id as usize) { - *slot = frame_node_hash(input, over); + *slot = frame_node_hash(input); scope.node_of[id as usize] = i; } - scope.plan.push((h, id, over)); - // A frame reads its anchor targets' rects, or only its ScrollFrame override's target, - // as the round loop's `set_frame_anchored` does. - match over { - Some(a) => scope.link(id, a.relative_to), - None => { - for a in &input.anchors { - scope.link(id, a.relative_to); - } - } + scope.plan.push((h, id)); + // A frame reads its anchor targets' rects. + for a in &input.anchors { + scope.link(id, a.relative_to); } } #[allow(clippy::cast_possible_truncation)] @@ -1614,8 +1494,8 @@ impl UiScript { fed_regions += 1; let live = arena.region(rh); let owner_id = live.and_then(|r| frame_to_id.get(&r.owner).copied()); - // The node hash covers every input the sweep reads (anchors, size, measure, the art - // of an unsized axis, the owner), so the two tiers read one set. + // The node hash covers every input the sweep reads (anchors, size, measure, the + // owner), so the two tiers read one set. let node = region_node_hash(data, owner_id); fp.feed(rh.fingerprint_bits()); fp.feed(node); @@ -1667,8 +1547,7 @@ impl UiScript { /// unlinked (a region freed or cleared), or keeps its slot with its edges re-pointed and its /// inputs re-synced, exactly as [`Self::derive_layout_graph`] would build it. Each node whose /// hash or edges moved is seeded, and the closure taken. `false` gives up for the derivation: - /// a frame leaving the roster, or a frame two ScrollFrames claim, whose override the - /// derivation picks by map order. + /// a frame leaving the roster. fn rederive_named(model: &mut Model, mut named: Vec, pre: &mut PreambleProf) -> bool { let Model { arena, @@ -1686,14 +1565,7 @@ impl UiScript { .. } = model; scope.begin_incremental(*next_id as usize); - // ScrollFrames first, so a child re-derived after them reads the claims they recorded. - let is_scroller = |id: u32| { - id_to_frame - .get(&id) - .and_then(|&h| arena.frame(h)) - .is_some_and(|f| f.kind == FrameKind::ScrollFrame) - }; - named.sort_unstable_by_key(|&id| (!is_scroller(id), id)); + named.sort_unstable(); named.dedup(); let mut targets = std::mem::take(&mut scope.targets); let mut at = 0; @@ -1718,39 +1590,6 @@ impl UiScript { scope.targets = targets; return false; } - // A ScrollFrame records the live child it holds, and that child re-derives after - // it, as the override is the child's; so does a child it let go. - if frame.kind == FrameKind::ScrollFrame { - let child = match &frame.kind_state { - KindState::Scroll(state) => { - state.child.filter(|&c| arena.frame(c).is_some()) - } - _ => None, - }; - let let_go = scope.hold(h, id, child); - for c in child.into_iter().chain(let_go) { - named.extend(frame_to_id.get(&c)); - } - } - // The override of the one ScrollFrame holding this frame; a child two hold gives - // up, as the derivation picks between them by map order. - let over = match scope.claims.get(&h).map(Vec::as_slice) { - None | Some([]) => None, - Some(&[(sh, sid)]) => match arena.frame(sh).map(|f| &f.kind_state) { - Some(KindState::Scroll(state)) => Some(Anchor::new( - Point::TopLeft, - sid, - Point::TopLeft, - state.horizontal, - state.vertical, - )), - _ => None, - }, - Some(_) => { - scope.targets = targets; - return false; - } - }; // 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(); @@ -1764,21 +1603,17 @@ impl UiScript { input.extent_y = screen.top; } targets.clear(); - match over { - Some(a) => targets.push(a.relative_to), - None => targets.extend(input.anchors.iter().map(|a| a.relative_to)), - } - let hash = frame_node_hash(input, over); + targets.extend(input.anchors.iter().map(|a| a.relative_to)); + let hash = frame_node_hash(input); let relinked = if n == u32::MAX { #[allow(clippy::cast_possible_truncation)] { scope.node_of[id as usize] = scope.plan.len() as u32; } - scope.plan.push((h, id, over)); + scope.plan.push((h, id)); scope.relink(id, &targets); true } else { - scope.plan[n as usize].2 = over; scope.relink(id, &targets) }; scope.note(id, hash, relinked); diff --git a/crates/benilla-ui/src/script/layout_cache.rs b/crates/benilla-ui/src/script/layout_cache.rs index cd9f31f7..2b9dcc27 100644 --- a/crates/benilla-ui/src/script/layout_cache.rs +++ b/crates/benilla-ui/src/script/layout_cache.rs @@ -140,9 +140,8 @@ impl super::UiScript { f.user_placed = true; } } - if changed { - model.touch_layout_frame(h); - } + // A row applied invalidates the frame (`0x490710`). + model.invalidate_frame(h, changed); } } diff --git a/crates/benilla-ui/src/script/loot.rs b/crates/benilla-ui/src/script/loot.rs index 3a6d7881..26422394 100644 --- a/crates/benilla-ui/src/script/loot.rs +++ b/crates/benilla-ui/src/script/loot.rs @@ -134,7 +134,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { data.texture = None; data.fill = None; data.circular = true; - model.touch_layout_art(rh); + model.note_art(rh); Ok(Value::Integer(1)) })?, )?; diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 6e03caf4..38c441b0 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -150,6 +150,12 @@ pub(crate) struct Model { /// node nothing invalidated keeps a state a recompute could change /// ([`Model::invalidate_frame`]). pub(crate) layout_pending: Vec, + /// Layout ids whose inputs a write moved without invalidating them since they were last + /// invalidated: a dying target's unhook (`0x768e20` → `0x767e70` with a zero flag), new art + /// (`SetTexture` `0x770200`), a re-link into another frame (`0x77fd10`). Each holds a state a + /// re-resolve need not give back, so a write the reference invalidates for is not skipped as + /// a no-op on it ([`Model::invalidate_frame`]). + pub(crate) layout_unsettled: HashSet, /// 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, @@ -1173,6 +1179,7 @@ impl Model { text_flips: Vec::new(), layout_force: Vec::new(), layout_pending: Vec::new(), + layout_unsettled: HashSet::new(), layout_screen: None, errors: Vec::new(), pending_error_dispatch: Vec::new(), @@ -1613,7 +1620,9 @@ impl Model { /// A write to region `rh`'s layout inputs or liveness: its anchors, size, measured extent, /// art or owner. Its node re-derives at the next resolve, joining or leaving the graph as it /// gained or lost its anchors. An id is minted only for an anchored region, the only kind that - /// can be a node; an anchorless one with no id has nothing in the graph to move. + /// can be a node; an anchorless one with no id has nothing in the graph to move. Naming + /// invalidates nothing: a write the reference invalidates for goes through + /// [`Self::invalidate_region`]. pub(crate) fn touch_layout_region(&mut self, rh: RegionHandle) { match self.region_to_id.get(&rh) { Some(&id) => self.name_layout_node(id), @@ -1628,20 +1637,6 @@ impl Model { } } - /// A write to region `rh`'s art, its texture or solid fill: a layout input only where an - /// anchored region takes an axis authored 0 from it ([`super::layout::content_span`]), and - /// named only when it moved the node's hash, so a repaint of the same art leaves the gate shut. - pub(crate) fn touch_layout_art(&mut self, rh: RegionHandle) { - if self - .region_data - .get(&rh) - .is_some_and(|d| d.size.is_some_and(|(w, h)| w != 0.0 && h != 0.0)) - { - return; - } - self.touch_layout_content(rh); - } - /// A write to region `rh`'s text, which was non-empty iff `was_shown`: the sweep reads a /// FontString's measure only while its text is non-empty /// ([`super::layout::effective_measure`]), so a flip between empty and not is noted, and the @@ -1658,7 +1653,9 @@ impl Model { } } - /// Names anchored region `rh` unless its node still holds its converged hash. + /// Text that emptied or filled on anchored region `rh`, which moves the measure its resolve + /// reads: a new string clears the valid bit and invalidates (`0x771ea4`), unless the node + /// still holds its converged hash, as when a clear and rebuild restored the same text. pub(crate) fn touch_layout_content(&mut self, rh: RegionHandle) { let Some(d) = self.region_data.get(&rh) else { return; @@ -1678,10 +1675,10 @@ impl Model { return; } } - self.touch_layout_region(rh); + self.clear_region_valid(rh); } - /// The same for frame `h`: its anchors, size, scale, clamp or scroll override. + /// The same for frame `h`: its anchors, size, scale or clamp. pub(crate) fn touch_layout_frame(&mut self, h: FrameHandle) { match self.frame_to_id.get(&h) { Some(&id) => self.name_layout_node(id), @@ -1695,7 +1692,8 @@ 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`]). + /// the frame holds one a re-resolve would not give it ([`Self::frame_holds_stale`]): only an + /// invalidated node, or one whose target stored a new rect, takes a new rect. pub(crate) fn invalidate_frame(&mut self, h: FrameHandle, changed: bool) { if !changed && !self.frame_holds_stale(h) { return; @@ -1729,9 +1727,50 @@ impl Model { } } - /// 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. + /// A write that moved region `rh`'s layout inputs and invalidated nothing (a re-link into + /// another frame, `0x77fd10`; a dying target's unhook, `0x767e70`): its node re-derives, and + /// it keeps its state until the resolve reaches it. + pub(crate) fn unsettle_region(&mut self, rh: RegionHandle) { + self.touch_layout_region(rh); + if let Some(&id) = self.region_to_id.get(&rh) { + self.layout_unsettled.insert(id); + } + } + + /// [`Self::unsettle_region`] for frame `h`. + pub(crate) fn unsettle_frame(&mut self, h: FrameHandle) { + self.touch_layout_frame(h); + if let Some(&id) = self.frame_to_id.get(&h) { + self.layout_unsettled.insert(id); + } + } + + /// New art on region `rh` (`SetTexture` `0x770200`, the colour form `0x770360`): it marks + /// only the draw dirty (`0x77fdf0`), so the layout is not touched. An anchored texture with an + /// axis sized 0 reads its art in its next reaching resolve, so until then it holds a state + /// that resolve need not give back. + pub(crate) fn note_art(&mut self, rh: RegionHandle) { + let Some(&id) = self.region_to_id.get(&rh) else { + return; + }; + if self.region_data.get(&rh).is_some_and(|d| { + !d.anchors.is_empty() && d.size.is_none_or(|(w, h)| w == 0.0 || h == 0.0) + }) { + self.layout_unsettled.insert(id); + } + } + + /// Whether frame `h` holds a state its inputs would not resolve to now: a write moved them + /// without invalidating it ([`Self::layout_unsettled`]), or it holds a rect with no anchors + /// or reading a target with no rect, which stands only because nothing invalidated it since. pub(crate) fn frame_holds_stale(&self, h: FrameHandle) -> bool { + if self + .frame_to_id + .get(&h) + .is_some_and(|id| self.layout_unsettled.contains(id)) + { + return true; + } if !self.resolved.contains_key(&h) { return false; } @@ -1742,6 +1781,13 @@ impl Model { /// [`Self::frame_holds_stale`] for region `rh`. pub(crate) fn region_holds_stale(&self, rh: RegionHandle) -> bool { + if self + .region_to_id + .get(&rh) + .is_some_and(|id| self.layout_unsettled.contains(id)) + { + return true; + } if !self.region_resolved.contains_key(&rh) { return false; } diff --git a/crates/benilla-ui/src/script/modelframe.rs b/crates/benilla-ui/src/script/modelframe.rs index ae653d15..65a80355 100644 --- a/crates/benilla-ui/src/script/modelframe.rs +++ b/crates/benilla-ui/src/script/modelframe.rs @@ -86,9 +86,8 @@ impl Model { input.width.to_bits() != w.to_bits() || input.height.to_bits() != ht.to_bits(); input.width = w; input.height = ht; - if changed { - self.touch_layout_frame(h); - } + // The model-ready size write invalidates the frame (`0x76ce70`). + self.invalidate_frame(h, changed); if let Some(KindState::Model(m)) = self.arena.frame_mut(h).map(|f| &mut f.kind_state) { m.implicit_size = true; } diff --git a/crates/benilla-ui/src/script/nameplate.rs b/crates/benilla-ui/src/script/nameplate.rs index 4ad555d1..ac71bcad 100644 --- a/crates/benilla-ui/src/script/nameplate.rs +++ b/crates/benilla-ui/src/script/nameplate.rs @@ -596,11 +596,12 @@ impl Plate { Anchors::from([Anchor::new(Point::Right, plate_id, Point::Left, 0.0, 0.0)]); raid_data.size = Some((g.raid_size, g.raid_size)); - // Once per geometry change: each node it wrote re-derives. - model.touch_layout_frame(frame); - model.touch_layout_frame(bar); + // Once per geometry change: each node it wrote is invalidated, as the plate setup's + // `SetPoint`/`SetAllPoints` with the invalidate flag do (`0x7cb456`..`0x7cb806`). + model.invalidate_frame(frame, true); + model.invalidate_frame(bar, true); for rh in [border, glow, name, level, skull, raid] { - model.touch_layout_region(rh); + model.invalidate_region(rh, true); } model.nameplates.plates[i].laid_out = Some(g); } @@ -633,7 +634,8 @@ impl Plate { state.top_centre.0, state.top_centre.1, )]); - model.touch_layout_frame(frame); + // `SetPoint` with the invalidate flag (`0x50a044`). + model.invalidate_frame(frame, true); } if last.is_none() { @@ -670,7 +672,8 @@ impl Plate { if last.as_ref().is_none_or(|l| l.name != state.name) { model.region_data.entry(name).or_default().text = Some(state.name.clone()); model.touch_measure(name); - model.touch_layout_region(name); + // A new string clears the valid bit and invalidates (`0x771ea4`). + model.clear_region_valid(name); } if last.as_ref().is_none_or(|l| l.hovered != state.hovered) { let c = if state.hovered { @@ -700,7 +703,7 @@ impl Plate { data.hidden = false; model.region_data.entry(skull).or_default().hidden = true; model.touch_measure(level); - model.touch_layout_region(level); + model.clear_region_valid(level); } // No level yet and no skull: the seat stays empty. (None, false) => { diff --git a/crates/benilla-ui/src/script/object/movable.rs b/crates/benilla-ui/src/script/object/movable.rs index a700e9e1..975fca9c 100644 --- a/crates/benilla-ui/src/script/object/movable.rs +++ b/crates/benilla-ui/src/script/object/movable.rs @@ -410,9 +410,8 @@ fn pin_top_left( input.anchors = pin; input.width = w; input.height = ht; - if changed { - model.touch_layout_frame(h); - } + // The size setters and a closing `0x7680e0(1)` invalidate (`0x768533`). + model.invalidate_frame(h, changed); // The size it now carries is explicit, as a `SetWidth`/`SetHeight` would leave it. model.note_authored_size(h); Some(changed) @@ -494,7 +493,8 @@ fn drag_step(model: &mut Model, mv: FrameMove, dx: f32, dy: f32) { }) .is_some(); if moved { - model.touch_layout_frame(h); + // The offsets move and the frame is invalidated (`0x768c31`). + model.invalidate_frame(h, true); mark_user_placed_change(model, h); return; } diff --git a/crates/benilla-ui/src/script/region.rs b/crates/benilla-ui/src/script/region.rs index dbbba05f..7ed7d0c3 100644 --- a/crates/benilla-ui/src/script/region.rs +++ b/crates/benilla-ui/src/script/region.rs @@ -78,8 +78,9 @@ pub(super) fn link_region( shown: bool, ) { if model.arena.set_region_owner(rh, Some(owner)) { - // The owner supplies the region's scale to layout, so a re-link is a layout change. - model.touch_layout_region(rh); + // The owner supplies the region's scale to layout; the re-link invalidates nothing, so + // the region keeps its rect until the resolve reaches it. + model.unsettle_region(rh); } if let Some(r) = model.arena.region_mut(rh) { r.draw_layer = layer; @@ -207,7 +208,7 @@ pub(super) fn set_texture_path(model: &mut Model, rh: RegionHandle, path: &str) let data = model.region_data.entry(rh).or_default(); data.texture = (!path.is_empty()).then(|| path.to_string()); data.fill = None; - model.touch_layout_art(rh); + model.note_art(rh); } /// The loader's install of the texture a ``, ``, Button state-texture or @@ -274,7 +275,7 @@ pub(super) fn clear_texture(model: &mut Model, rh: RegionHandle) { data.texture = None; data.fill = None; data.portrait_unit = None; - model.touch_layout_art(rh); + model.note_art(rh); } /// Apply the font parts an XML element supplies (`font=`, ``, `outline=`), any of @@ -379,7 +380,7 @@ pub(super) fn install_in_game(lua: &Lua) -> mlua::Result<()> { data.texture = None; data.fill = None; data.circular = true; - model.touch_layout_art(rh); + model.note_art(rh); Ok(()) })?, )?; @@ -402,7 +403,7 @@ pub(super) fn install_in_game(lua: &Lua) -> mlua::Result<()> { data.texture = Some(path); data.circular = true; data.portrait_unit = None; - model.touch_layout_art(rh); + model.note_art(rh); Ok(()) })?, )?; @@ -527,8 +528,9 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> { _ => return Err(wrong_type()), }; if model.arena.set_region_owner(rh, new_owner) { - // The owner supplies the region's scale to layout, so a re-link is a layout change. - model.touch_layout_region(rh); + // The owner supplies the region's scale to layout; the re-link invalidates nothing + // (`0x77fd10`), so the region keeps its rect until the resolve reaches it. + model.unsettle_region(rh); } Ok(()) }, @@ -677,29 +679,108 @@ pub(super) fn region_type_name(model: &Model, rh: RegionHandle) -> &'static str } } -/// Free a region and every trace of its identity (arena slot, paint, rect, name, id), so a held -/// wrapper raises as stale; the reference frees too, as `CSimpleButton::SetFontString 0x778d20` -/// deletes the label it replaces. +/// Free a region; see [`free_regions`]. 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); - // The lookup entry goes only if this region is the one it holds; a later holder of the - // same name was never in it. - if let Some(name) = name { - if model.region_by_name.get(&name) == Some(&id) { - model.region_by_name.remove(&name); + free_regions(model, &[rh]); +} + +/// Free regions and every trace of their identity (arena slot, paint, rect, name, id), so a held +/// wrapper raises as stale. The reference destroys them: `CSimpleButton::SetFontString` the label +/// it replaces (`0x778d3c`), a state-texture setter the texture it displaces (`0x77900a`), a +/// backdrop teardown its pieces (`0x77f4d0`), a SimpleHTML parse its old blocks (`0x78a5aa`). The +/// destructor (`0x767720` → `0x768e20`) unhooks every object anchored to a dying one with +/// `0x767e70(dependent, dying, 0)`: each anchor naming it is freed, and the zero flag queues no +/// resolve, so the dependent keeps its state until the resolve reaches it. +pub(crate) fn free_regions(model: &mut Model, dying: &[RegionHandle]) { + let mut ids = Vec::with_capacity(dying.len()); + for &rh in dying { + 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); + model.layout_unsettled.remove(&id); + // The lookup entry goes only if this region is the one it holds; a later holder of the + // same name was never in it. + if let Some(name) = name { + if model.region_by_name.get(&name) == Some(&id) { + model.region_by_name.remove(&name); + } + } + // Its node leaves the cached graph with its edges at the next resolve. + model.name_layout_node(id); + ids.push(id); + } + model.arena.destroy_region(rh); + } + // Only a region with an id can be an anchor target. + if ids.is_empty() { + return; + } + let mut regions: Vec = Vec::new(); + let mut frames: Vec = Vec::new(); + match &model.layout_touched { + // The cached graph's readers of each dying id, and every node a write named since it was + // built, are all that can anchor to one. + Some(named) => { + let mut maybe = named.clone(); + for &id in &ids { + maybe.extend(model.layout_scope.readers_of(id)); + } + maybe.sort_unstable(); + maybe.dedup(); + for id in maybe { + if let Some(&rh) = model.id_to_region.get(&id) { + if let Some(d) = model.region_data.get_mut(&rh) { + if d.anchors.release_targets(&ids) { + regions.push(rh); + } + } + } else if let Some(&h) = model.id_to_frame.get(&id) { + if let Some(i) = model.layout_inputs.get_mut(&h) { + if i.anchors.release_targets(&ids) { + frames.push(h); + } + } + } + } + } + // No graph to vouch for: every object. + None => { + for (&rh, d) in model.region_data.iter_mut() { + if d.anchors.release_targets(&ids) { + regions.push(rh); + } + } + for (&h, i) in model.layout_inputs.iter_mut() { + if i.anchors.release_targets(&ids) { + frames.push(h); + } } } - // 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); + // Under `WOW_LAYOUT_VERIFY` the graph's answer is checked against every object. + if model.layout_touched.is_some() && super::layout::layout_verify_enabled() { + let missed = model + .region_data + .values() + .map(|d| &d.anchors) + .chain(model.layout_inputs.values().map(|i| &i.anchors)) + .any(|a| a.iter().any(|a| ids.contains(&a.relative_to))); + assert!( + !missed, + "WOW_LAYOUT_VERIFY: an object anchored to a freed region was not among the cached \ + graph's readers of it or the nodes named since" + ); + } + // Each dependent re-derives its edges and keeps the state it has. + for rh in regions { + model.unsettle_region(rh); + } + for h in frames { + model.unsettle_frame(h); + } } pub(super) fn region_owner_id(model: &mut Model, rh: RegionHandle) -> u32 { @@ -738,7 +819,8 @@ pub(crate) fn implicit_creation_anchor(model: &mut Model, rh: RegionHandle) { } RegionKind::Title => return, } - model.touch_layout_region(rh); + // Both set their points with the invalidate flag (`0x7701ec`, `0x7714bc`). + model.invalidate_region(rh, true); } /// The middle-row point a justify word selects, as the FontString post-step (`0x771480`) and the @@ -767,7 +849,8 @@ pub(crate) fn anchor_unanchored_at( return; } data.anchors = Anchors::from([Anchor::new(point, owner_id, point, 0.0, 0.0)]); - model.touch_layout_region(rh); + // The adopter's `SetPoint` invalidates (`0x778d96`, flag 1). + model.invalidate_region(rh, true); } /// [`implicit_creation_anchor`] for the loader, which holds region wrappers. diff --git a/crates/benilla-ui/src/script/region/paint.rs b/crates/benilla-ui/src/script/region/paint.rs index d15f671a..6804014a 100644 --- a/crates/benilla-ui/src/script/region/paint.rs +++ b/crates/benilla-ui/src/script/region/paint.rs @@ -199,11 +199,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { }; loaded }; - // Only the art-sized shape can move, and only a new art: touching the layout on - // every repaint would reopen the resolve's change gate each frame. - lua.app_data_mut::() - .expect("model") - .touch_layout_art(rh); + // The draw only (`0x77fdf0`); an art-sized texture now holds a state its next + // reaching resolve need not give back. + lua.app_data_mut::().expect("model").note_art(rh); Ok(if loaded { Value::Number(1.0) } else { @@ -485,8 +483,18 @@ impl crate::script::UiScript { pub fn set_texture_size_probe(&mut self, probe: crate::script::TextureSizeProbe) { let mut model = self.model_mut(); model.texture_size_probe = Some(probe); - // Every art-sized region's span may move, and no per-node hash holds a probe's answer: a - // full-scope rebuild, as for a screen move. + // The reference has every art's size at hand (`0x44b3b0` loads it), so a probe installed + // late re-resolves each texture that takes a span from its art, as if it had been there. + let art_sized: Vec = model + .region_data + .iter() + .filter(|(_, d)| { + !d.anchors.is_empty() && d.size.is_none_or(|(w, h)| w == 0.0 || h == 0.0) + }) + .filter_map(|(rh, _)| model.region_to_id.get(rh).copied()) + .collect(); + model.layout_pending.extend(art_sized); + // No per-node hash holds a probe's answer: a full-scope rebuild, as for a screen move. model.layout_scope.invalidate(); model.touch_layout(); } diff --git a/crates/benilla-ui/src/script/scrollframe.rs b/crates/benilla-ui/src/script/scrollframe.rs index 4e4ee3c2..d3ea4302 100644 --- a/crates/benilla-ui/src/script/scrollframe.rs +++ b/crates/benilla-ui/src/script/scrollframe.rs @@ -143,20 +143,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { } }; // Every call marks the range (`0x786d16`), the same child again included. - let old_child = with_scroll(lua, &this, |s| { + with_scroll(lua, &this, |s| { s.range_dirty = true; - std::mem::replace(&mut s.child, new_child) + s.child = new_child; })?; - if old_child != new_child { - // The child takes this frame's override anchor and the old one drops it; the - // ScrollFrame's node re-derives its new child with it. - let h = frame_handle_of(lua, &this)?; - let mut model = lua.app_data_mut::().expect("model"); - model.touch_layout_frame(h); - if let Some(old) = old_child { - model.touch_layout_frame(old); - } - } + // The old child keeps its point and is not invalidated (`0x786ce6`–`0x786cf4` only + // flags it); the new one is re-anchored on every call (`0x786d11`). + reanchor_child(lua, &this)?; Ok(()) })?, )?; @@ -192,11 +185,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { if !changed { return Ok(()); } - // The child's override offset: the ScrollFrame's node re-derives its child. - let h = frame_handle_of(lua, &this)?; - lua.app_data_mut::() - .expect("model") - .touch_layout_frame(h); + // The re-anchor at the new offset (`0x786dd8` → `0x787100`). + reanchor_child(lua, &this)?; fire_scroll(lua, &this, "OnVerticalScroll", px) })?, )?; @@ -226,10 +216,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { if !changed { return Ok(()); } - let h = frame_handle_of(lua, &this)?; - lua.app_data_mut::() - .expect("model") - .touch_layout_frame(h); + reanchor_child(lua, &this)?; fire_scroll(lua, &this, "OnHorizontalScroll", px) })?, )?; @@ -357,3 +344,32 @@ fn see_size(s: &mut ScrollFrameState, rect: Option, eps: f6 } s.seen_size = size; } + +/// The re-anchor (`0x787100`): when the frame holds a child, clear its points (`0x787110` → +/// `0x767620`) and set one TOPLEFT on the frame's TOPLEFT at the raw `(horizontal, vertical)` +/// offsets, y up, with the invalidate flag (`0x787136`). A real write on the child: its own +/// points are gone, and it keeps the new one when the frame lets it go. +fn reanchor_child(lua: &Lua, this: &Table) -> mlua::Result<()> { + let (child, x, y) = with_scroll(lua, this, |s| (s.child, s.horizontal, s.vertical))?; + let Some(child) = child else { + return Ok(()); + }; + let h = frame_handle_of(lua, this)?; + let mut model = lua.app_data_mut::().expect("model"); + if model.arena.frame(child).is_none() { + return Ok(()); + } + let sid = model.frame_id(h); + let pin = crate::layout::Anchors::from([crate::layout::Anchor::new( + crate::layout::Point::TopLeft, + sid, + crate::layout::Point::TopLeft, + x, + y, + )]); + let input = model.layout_inputs.entry(child).or_default(); + let changed = !input.anchors.bits_eq(&pin); + input.anchors = pin; + model.invalidate_frame(child, changed); + Ok(()) +} diff --git a/crates/benilla-ui/src/script/simplehtml/mod.rs b/crates/benilla-ui/src/script/simplehtml/mod.rs index 000bc2b7..0f0c4fa1 100644 --- a/crates/benilla-ui/src/script/simplehtml/mod.rs +++ b/crates/benilla-ui/src/script/simplehtml/mod.rs @@ -166,16 +166,14 @@ fn resolve_font(model: &Model, ef: &ElementFont) -> BlockPaint { /// `CSimpleHTML::SetText` (`0x78a3a0`): free the old blocks, parse, build. Returns `usedMarkup` /// (`0x78a519`), which the Lua shim drops. pub(crate) fn set_text(model: &mut Model, fh: FrameHandle, raw: &str) -> bool { - // Free the previous parse's regions first, as the reference does, or a book's next page - // draws over the last. + // Free the previous parse's regions first, as the reference does (`0x78a580`), or a book's + // next page draws over the last. let old = model .simple_html .get_mut(&fh) .map(|s| std::mem::take(&mut s.blocks)) .unwrap_or_default(); - for rh in old { - free_block(model, rh); - } + crate::script::region::free_regions(model, &old); let (frame_name, hyperlink_format) = { let name = model @@ -289,7 +287,7 @@ fn build(model: &mut Model, fh: FrameHandle, blocks: &[Block]) { else { break; // unreachable, as above }; - // Minted though nothing anchors to an image; the resolve and `free_block` use it. + // Minted though nothing anchors to an image; the resolve and the free use it. model.region_id(rh); // `align` picks the corner; 8, 16 and 32 get no anchor at all (`0x78ac61`, a jump // to `0x78ace8`), so the image draws nothing, here as there. @@ -332,9 +330,10 @@ fn build(model: &mut Model, fh: FrameHandle, blocks: &[Block]) { } } - // Each new block joins the graph; the old ones' frees named theirs. + // Each new block's `SetPoint` invalidates it (`0x78ae0c`, flag 1); the old ones' frees named + // theirs. for &rh in &made { - model.touch_layout_region(rh); + model.invalidate_region(rh, true); } model.simple_html.entry(fh).or_default().blocks = made; } @@ -356,10 +355,6 @@ fn anchor_for(prev_block: Option, frame_id: u32, point: Point, next_y: f32) } } -fn free_block(model: &mut Model, rh: RegionHandle) { - crate::script::region::free_region(model, rh); -} - // ── The loader's seam ──────────────────────────────────────────────────────────────────────── /// Apply the `font=`, `` and `outline=` an XML `` or `` diff --git a/crates/benilla-ui/src/script/tabard.rs b/crates/benilla-ui/src/script/tabard.rs index fa8f39a3..1a7b9ac8 100644 --- a/crates/benilla-ui/src/script/tabard.rs +++ b/crates/benilla-ui/src/script/tabard.rs @@ -195,7 +195,7 @@ fn emblem_texture(lua: &Lua, this: Value, texture: Value, upper: bool) -> mlua:: data.fill = None; data.portrait_unit = None; data.circular = false; - model.touch_layout_art(rh); + model.note_art(rh); Ok(()) } diff --git a/crates/benilla-ui/src/script/taxi.rs b/crates/benilla-ui/src/script/taxi.rs index 7a372095..76bd6f27 100644 --- a/crates/benilla-ui/src/script/taxi.rs +++ b/crates/benilla-ui/src/script/taxi.rs @@ -198,7 +198,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let art = model.taxi.as_ref().map(|t| t.art.clone()); if let Some(art) = art { model.region_data.entry(rh).or_default().texture = Some(art); - model.touch_layout_art(rh); + model.note_art(rh); } Ok(()) })?, diff --git a/crates/benilla-ui/src/script/tests/dying_target.rs b/crates/benilla-ui/src/script/tests/dying_target.rs new file mode 100644 index 00000000..80ba6e80 --- /dev/null +++ b/crates/benilla-ui/src/script/tests/dying_target.rs @@ -0,0 +1,211 @@ +//! A layout object's destructor (`0x767720` → `0x768e20`) unhooks each object anchored to it with +//! `0x767e70(dependent, dying, 0)`: every anchor slot naming the dying object is freed, and the +//! zero flag skips the invalidate (`0x7680e0`). So `GetNumPoints` (`0x7a2260`) drops and `GetPoint` +//! no longer answers those points, while the dependent keeps its rect until its own next +//! invalidate. A region dies to `Button:SetFontString` (`0x778d3c`), a state-texture setter +//! (`0x77900a`), a backdrop teardown (`0x77f4d0`) and a SimpleHTML re-parse (`0x78a5aa`). + +use super::common::script; +use crate::script::UiScript; + +/// Resolve, then require the cached graph and its rects to equal a from-scratch solve. +fn resolve_matches_scratch(s: &mut UiScript, what: &str) { + s.resolve(); + let mut model = s.model_mut(); + if let Some(why) = UiScript::scratch_divergence(&mut model) { + panic!("{what}: {why}"); + } +} + +fn num(s: &mut UiScript, expr: &str) -> Option { + s.eval::>(&format!("return {expr}")).unwrap() +} + +/// The issue's sequence: a texture anchored to a button's label and to the button; the label +/// replaced. The anchor to the destroyed label is gone, the other stands, and the texture keeps +/// its rect, which the anchor that is left would move, until its own invalidate. +#[test] +fn a_replaced_label_leaves_its_dependents_anchors() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + b = CreateFrame("Button", "B") + b:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100) + b:SetWidth(200); b:SetHeight(40) + b:SetFontString(b:CreateFontString("Old", "ARTWORK")) + Old:SetWidth(50); Old:SetHeight(10) + t = b:CreateTexture("T", "OVERLAY") + t:SetTexture(1, 1, 1); t:SetWidth(10); t:SetHeight(10) + t:SetPoint("LEFT", b:GetFontString(), "RIGHT") + t:SetPoint("TOP", b, "TOP") + "#, + ) + .unwrap(); + s.resolve(); + assert_eq!(s.eval::("return t:GetNumPoints()").unwrap(), 2); + assert_eq!( + num(&mut s, "t:GetLeft()"), + Some(225.0), + "right of the label" + ); + + s.run(r#"b:SetFontString(b:CreateFontString("New", "ARTWORK"))"#) + .unwrap(); + assert_eq!( + s.eval::("return t:GetNumPoints()").unwrap(), + 1, + "the anchor to the destroyed label is freed" + ); + assert_eq!( + s.eval::("return table.getn({ t:GetPoint(2) })") + .unwrap(), + 0, + "and no second point answers" + ); + assert!(s + .eval::( + r#"local p, r, rp = t:GetPoint(1); return p == "TOP" and r == b and rp == "TOP""# + ) + .unwrap()); + + resolve_matches_scratch(&mut s, "the label replaced"); + assert_eq!( + num(&mut s, "t:GetLeft()"), + Some(225.0), + "the unhook queued no resolve" + ); + // Its own invalidate resolves it from the anchor left: centred under the button's top. + s.run("t:SetWidth(12)").unwrap(); + resolve_matches_scratch(&mut s, "the texture's own invalidate"); + assert_eq!(num(&mut s, "t:GetLeft()"), Some(194.0)); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// A texture and a frame each anchored to a backdrop piece and to the frame: the teardown frees +/// the piece's anchors only, and both keep their rects. +#[test] +fn a_backdrop_teardown_frees_the_anchors_to_its_pieces() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + f = CreateFrame("Frame") + f:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100) + f:SetWidth(100); f:SetHeight(100) + f:SetBackdrop({ bgFile = "bg", edgeFile = "edge" }) + local piece = ({ f:GetRegions() })[1] + local g = CreateFrame("Frame") + dep = g:CreateTexture(nil, "ARTWORK") + dep:SetTexture("dep"); dep:SetWidth(10); dep:SetHeight(10) + dep:SetPoint("TOPLEFT", piece, "TOPLEFT", 5, -5) + dep:SetPoint("BOTTOM", f, "BOTTOM", 0, 20) + h = CreateFrame("Frame") + h:SetWidth(10); h:SetHeight(10) + h:SetPoint("TOPLEFT", piece, "TOPLEFT", 3, -3) + h:SetPoint("BOTTOM", f, "BOTTOM", 0, 30) + "#, + ) + .unwrap(); + s.resolve(); + let before = ( + num(&mut s, "dep:GetLeft()"), + num(&mut s, "dep:GetTop()"), + num(&mut s, "h:GetLeft()"), + num(&mut s, "h:GetTop()"), + ); + assert!(before.0.is_some() && before.2.is_some(), "{before:?}"); + + s.run("f:SetBackdrop(nil)").unwrap(); + assert_eq!(s.eval::("return dep:GetNumPoints()").unwrap(), 1); + assert_eq!(s.eval::("return h:GetNumPoints()").unwrap(), 1); + assert!(s + .eval::(r#"local p, r = dep:GetPoint(1); return p == "BOTTOM" and r == f"#) + .unwrap()); + + resolve_matches_scratch(&mut s, "the backdrop torn down"); + let after = ( + num(&mut s, "dep:GetLeft()"), + num(&mut s, "dep:GetTop()"), + num(&mut s, "h:GetLeft()"), + num(&mut s, "h:GetTop()"), + ); + assert_eq!(after, before, "no resolve was queued for either"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// A ScrollFrame's re-anchor clears its child's points (`0x787110`) and sets one on the frame, so +/// the child holds no anchor to a label it was placed on, and the label's destruction leaves it be. +#[test] +fn a_scroll_child_holds_no_anchor_to_a_label_it_was_placed_on() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + b = CreateFrame("Button", "B") + b:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100) + b:SetWidth(200); b:SetHeight(40) + b:SetFontString(b:CreateFontString("Old", "ARTWORK")) + Old:SetWidth(50); Old:SetHeight(10) + sf = CreateFrame("ScrollFrame", "SF") + sf:SetPoint("TOPLEFT", nil, "TOPLEFT", 10, -10) + sf:SetWidth(50); sf:SetHeight(50) + c = CreateFrame("Frame", "Child") + c:SetWidth(5); c:SetHeight(5) + c:SetPoint("TOPLEFT", Old, "TOPLEFT") + sf:SetScrollChild(c) + "#, + ) + .unwrap(); + s.resolve(); + let left = num(&mut s, "c:GetLeft()"); + assert_eq!(left, Some(10.0), "placed by the override"); + + assert_eq!(s.eval::("return c:GetNumPoints()").unwrap(), 1); + s.run(r#"b:SetFontString(b:CreateFontString("New", "ARTWORK"))"#) + .unwrap(); + assert!(s + .eval::(r#"local p, r = c:GetPoint(1); return c:GetNumPoints() == 1 and r == sf"#) + .unwrap()); + resolve_matches_scratch(&mut s, "the label replaced"); + assert_eq!(num(&mut s, "c:GetLeft()"), left); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// The re-link into another frame (`0x77fd10`) invalidates nothing: a region moved under a frame of +/// another scale keeps its rect until its own invalidate, which then reads the new owner's scale. +#[test] +fn a_region_reowned_keeps_its_rect_until_its_own_invalidate() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + a = CreateFrame("Frame", "A"); a:SetWidth(10); a:SetHeight(10) + a:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 0, 0) + b = CreateFrame("Frame", "B"); b:SetWidth(10); b:SetHeight(10) + b:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 0, 0); b:SetScale(2) + x = CreateFrame("Frame", "X"); x:SetWidth(100); x:SetHeight(100) + x:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100) + t = a:CreateTexture("T", "ARTWORK"); t:SetTexture(1, 1, 1) + t:SetWidth(8); t:SetHeight(8); t:SetPoint("BOTTOMLEFT", x, "BOTTOMLEFT", 0, 0) + "#, + ) + .unwrap(); + s.resolve(); + assert_eq!(num(&mut s, "T:GetRight()"), Some(108.0)); + s.run("t:SetParent(b)").unwrap(); + resolve_matches_scratch(&mut s, "re-owned"); + assert_eq!( + num(&mut s, "T:GetRight()"), + Some(54.0), + "the rect it had, read in the new owner's units" + ); + s.run("t:SetWidth(8)").unwrap(); + resolve_matches_scratch(&mut s, "its own invalidate"); + assert_eq!( + num(&mut s, "T:GetRight()"), + Some(58.0), + "8 units at scale 2, from 100" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} diff --git a/crates/benilla-ui/src/script/tests/layout_structure.rs b/crates/benilla-ui/src/script/tests/layout_structure.rs index e231dbc8..9fc2b10e 100644 --- a/crates/benilla-ui/src/script/tests/layout_structure.rs +++ b/crates/benilla-ui/src/script/tests/layout_structure.rs @@ -86,10 +86,10 @@ const POINTS: [&str; 9] = [ /// The Lua state the steps act on: `N` every node in creation order, `F` the frames, `R` the /// regions, `B` the buttons, `S` the ScrollFrames, `owned[child]` the ScrollFrame that holds it. -/// `deps[o][point]` mirrors each anchor's target and `held[c]` each scroll override, so `target` +/// `deps[o][point]` mirrors each anchor's target and `held[c]` each scroll claim, so `target` /// picks any node, older or newer, whose rect does not already read `o`'s: the graph stays acyclic. const SETUP: &str = r#" - N, F, R, B, S, at, owned, deps, held = {}, {}, {}, {}, {}, {}, {}, {}, {} + N, F, R, B, S, at, owned, deps, held, pieced = {}, {}, {}, {}, {}, {}, {}, {}, {}, {} function add(o) table.insert(N, o); at[o] = table.getn(N); deps[o] = {} end -- Whether `a`'s rect reads `b`'s, through anchors and scroll overrides. function reaches(a, b) @@ -181,15 +181,25 @@ fn step(rng: &mut Rng) -> Write { "local f = F[math.mod({k}, table.getn(F)) + 1] if f:GetBackdrop() then f:SetBackdrop(nil) else f:SetBackdrop(spec) end" ), - // A node anchored to a backdrop piece, so a teardown frees what something reads. + // A node, a texture or a frame, anchored to a fresh backdrop piece (the last region the + // build links) and, at another point, to any node, so a later teardown frees one of its + // anchors and it stays a node. 5 => format!( "local f = F[math.mod({k}, table.getn(F)) + 1] - local piece = ({{ f:GetRegions() }})[1] - if piece and at[f] and not at[piece] then - deps[piece] = {{ f }} - local t = f:CreateTexture(nil, 'OVERLAY'); add(t); table.insert(R, t) - t:SetWidth(5); t:SetHeight(5); point(t, '{p}', piece, '{rp}', {x}, {y}) - end" + f:SetBackdrop(spec) + local rs = {{ f:GetRegions() }} + local piece = rs[table.getn(rs)] + deps[piece] = {{ f }} + local o + if {k2} < 600 then + o = f:CreateTexture(nil, 'OVERLAY'); add(o); table.insert(R, o) + else + o = CreateFrame('Frame', nil, f); add(o); table.insert(F, o) + end + o:SetWidth(5); o:SetHeight(5) + point(o, '{p}', piece, '{rp}', {x}, {y}) + if '{p}' ~= '{rp}' then point(o, '{rp}', target(o, {k2}), '{p}', {y}, {x}) end + table.insert(pieced, {{ o, o:GetNumPoints() }})" ), // A button's slot region set, or freed by a nil; or two freed in one pass. 6 => format!( @@ -260,6 +270,7 @@ fn step(rng: &mut Rng) -> Write { local old = sf:GetScrollChild() if old then owned[old] = nil; held[old] = nil end sf:SetScrollChild(c); owned[c] = sf; held[c] = sf + deps[c] = {{ TOPLEFT = sf }} break end end @@ -348,6 +359,22 @@ fn random_structural_writes_resolve_as_a_scratch_solve_would() { "return table.getn(N), raised, table.getn(F), table.getn(R), newer", ) .expect("count"); + // Dependents of a backdrop piece that a later teardown or rebuild freed: each lost the + // anchor to it, and the scratch solve above agreed with every pass in between. + let unhooked = s + .eval::( + "local n = 0 + for _, e in pieced do + local ok, now = pcall(e[1].GetNumPoints, e[1]) + if ok and now < e[2] then n = n + 1 end + end + return n", + ) + .expect("unhooked"); + assert!( + unhooked > 10, + "seed {seed:#x}: only {unhooked} dependents of a backdrop piece saw it freed" + ); eprintln!( "seed {seed:#x}: {writes} writes, {nodes} nodes ({frames} frames, {regions} \ regions), {raised} raised, {newer} anchors to a newer node" @@ -372,10 +399,10 @@ fn random_structural_writes_resolve_as_a_scratch_solve_would() { } } -/// A frame two ScrollFrames claim takes the derivation, whose override the cached graph cannot -/// name; the result still equals a scratch solve, and a lone claim stays incremental. +/// A frame two ScrollFrames claim holds the last one's re-anchor (`0x787100`), one real point, +/// through the cached graph: no derivation. #[test] -fn a_child_two_scrollframes_claim_falls_back_to_the_derivation() { +fn a_child_two_scrollframes_claim_holds_the_last_re_anchor() { let mut s = script(); s.set_screen_size(800.0, 600.0); s.run( @@ -394,14 +421,13 @@ fn a_child_two_scrollframes_claim_falls_back_to_the_derivation() { s.run("a:SetScrollChild(c); a:SetVerticalScroll(5)") .expect("one claim"); resolve_matches_scratch(&mut s, "one claim"); - assert_eq!(s.layout_derivations(), derived, "one claim is named"); s.run("b:SetScrollChild(c)").expect("two claims"); resolve_matches_scratch(&mut s, "two claims"); - assert_eq!( - s.layout_derivations(), - derived + 1, - "two claims give up for the derivation" - ); + assert_eq!(s.layout_derivations(), derived, "every claim is named"); + assert!(s + .eval::("local p, r = c:GetPoint(1); return c:GetNumPoints() == 1 and r == b") + .unwrap()); + assert_eq!(s.eval::("return c:GetLeft()").unwrap(), 200.0); } /// A frame whose id is minted after a region on it joined the graph (the engine builds some diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs index 9eae7b2d..33a0171b 100644 --- a/crates/benilla-ui/src/script/tests/mod.rs +++ b/crates/benilla-ui/src/script/tests/mod.rs @@ -19,6 +19,7 @@ mod cvar_bindings; mod dispatch_bench; mod drag_pin; mod drag_threshold; +mod dying_target; mod end_to_end; mod engine_updates; mod events; @@ -65,6 +66,7 @@ mod structure_queries; mod talent; mod taxi; mod texcoord_font; +mod texture_art; mod tooltip; mod tooltip_item; mod tooltip_spell; diff --git a/crates/benilla-ui/src/script/tests/scrollframe.rs b/crates/benilla-ui/src/script/tests/scrollframe.rs index ee18d452..c0c53d80 100644 --- a/crates/benilla-ui/src/script/tests/scrollframe.rs +++ b/crates/benilla-ui/src/script/tests/scrollframe.rs @@ -3,7 +3,7 @@ use super::common::script; use crate::layout::Rect; -use crate::script::QuadContent; +use crate::script::{QuadContent, UiScript}; /// The rect of the first Texture quad whose path is `marker`. fn marker_rect(quads: &[crate::script::ExtractedQuad], marker: &str) -> Option { @@ -140,15 +140,64 @@ fn scroll_child_top_tracks_vertical_scroll_unclamped_then_restores_on_clear() { "a negative offset lowers the child: frame top 500 - 30" ); - // Clearing the child stops the override, and its own untouched anchor applies again. + // Clearing the child leaves its one point alone and invalidates nothing (`0x786ce6`): it + // stays at the scroll frame's corner, and follows the frame when the frame moves. s.run("SF:SetScrollChild(nil)").unwrap(); s.resolve(); let quads = s.extract(); assert_eq!( marker_rect(&quads, "marker:child").map(|r| r.top), - Some(580.0), - "authored anchor survives: screen top 600 - 20" + Some(470.0), + "the re-anchor's point stands: frame top 500 - 30" ); + s.run(r#"SF:SetPoint("TOPLEFT", 0, -150)"#).unwrap(); + s.resolve(); + assert_eq!( + marker_rect(&s.extract(), "marker:child").map(|r| r.top), + Some(420.0), + "still on the frame's corner: frame top 450 - 30" + ); +} + +/// The re-anchor (`0x787100`) is a real write on the child: its points are cleared (`0x787110`) +/// and one TOPLEFT on the scroll frame's TOPLEFT is set at the offsets (`0x787136`), at +/// `SetScrollChild` and at each offset change; the authored points are gone. +#[test] +fn the_scroll_child_holds_one_topleft_on_the_scroll_frame() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + sf = CreateFrame("ScrollFrame", "SF") + sf:SetPoint("TOPLEFT", 0, -100); sf:SetWidth(300); sf:SetHeight(200) + c = CreateFrame("Frame", "Child") + c:SetPoint("CENTER", UIParent); c:SetPoint("BOTTOM", 0, 10) + c:SetWidth(100); c:SetHeight(400) + sf:SetScrollChild(c) + "#, + ) + .unwrap(); + let point = |s: &mut UiScript| { + s.eval::<(i64, String, bool, String, f64, f64)>( + "local p, r, rp, x, y = c:GetPoint(1); return c:GetNumPoints(), p, r == sf, rp, x, y", + ) + .unwrap() + }; + assert_eq!( + point(&mut s), + (1, "TOPLEFT".into(), true, "TOPLEFT".into(), 0.0, 0.0) + ); + s.run("sf:SetVerticalScroll(30); sf:SetHorizontalScroll(-12)") + .unwrap(); + assert_eq!( + point(&mut s), + (1, "TOPLEFT".into(), true, "TOPLEFT".into(), -12.0, 30.0), + "scrolling moves the one point" + ); + s.resolve(); + assert_eq!(s.eval::("return c:GetTop()").unwrap(), 530.0); + assert_eq!(s.eval::("return c:GetLeft()").unwrap(), -12.0); + assert!(s.errors().is_empty(), "{:?}", s.errors()); } /// `0x786db0` acts only when the offset differs from the stored one; a value past the range is diff --git a/crates/benilla-ui/src/script/tests/texture_art.rs b/crates/benilla-ui/src/script/tests/texture_art.rs new file mode 100644 index 00000000..e5c421d6 --- /dev/null +++ b/crates/benilla-ui/src/script/tests/texture_art.rs @@ -0,0 +1,162 @@ +//! `SetTexture` (`0x770200`, the colour form `0x770360`) writes the texture slot and marks the +//! draw batch dirty (`0x77fdf0`); it never invalidates the layout (`0x7680e0`). A texture's axis +//! sized 0 takes its texel extent only when the texture resolves: the resolver reads the size +//! through the geometry vtable (`0x767579`, `0x7675e9`), which a Texture answers from its art +//! (`0x770720`, loading it first if it must, `0x44b3b0`). New art moves nothing until the +//! texture's own next invalidate, or a target's new rect. + +use super::common::script; +use crate::layout::Rect; +use crate::script::{UiScript, ZTarget}; + +/// Resolve, then require the cached graph and its rects to equal a from-scratch solve. +fn resolve_matches_scratch(s: &mut UiScript, what: &str) { + s.resolve(); + let mut model = s.model_mut(); + if let Some(why) = UiScript::scratch_divergence(&mut model) { + panic!("{what}: {why}"); + } +} + +fn num(s: &mut UiScript, expr: &str) -> Option { + s.eval::>(&format!("return {expr}")).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) +} + +/// `Owner` at `(100, 100)`–`(300, 200)`, with art `Art` 64×32 and `Big` 128×64. +fn owner() -> UiScript { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.set_texture_size_probe(Box::new(|p| match p { + "Art" => Some((64, 32)), + "Big" => Some((128, 64)), + _ => None, + })); + s.run( + r#" + f = CreateFrame("Frame", "Owner") + f:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100) + f:SetWidth(200); f:SetHeight(100) + "#, + ) + .unwrap(); + s +} + +/// One anchor and no size: with no art the resolve fails for want of a span. Art set after +/// leaves it failed, the path form and the colour form alike, until its own invalidate. +#[test] +fn a_failed_texture_stays_failed_when_its_art_arrives() { + let mut s = owner(); + s.run( + r#" + T = f:CreateTexture("T", "ARTWORK"); T:SetPoint("TOPLEFT", f, "TOPLEFT", 0, 0) + C = f:CreateTexture("C", "ARTWORK"); C:SetPoint("TOPLEFT", f, "TOPLEFT", 0, 0) + "#, + ) + .unwrap(); + s.resolve(); + assert_eq!(num(&mut s, "T:GetLeft()"), None, "no art, no span"); + assert_eq!(num(&mut s, "C:GetLeft()"), None); + + s.run(r#"T:SetTexture("Art"); C:SetTexture(1, 1, 1)"#) + .unwrap(); + resolve_matches_scratch(&mut s, "art on failed textures"); + assert_eq!( + num(&mut s, "T:GetLeft()"), + None, + "SetTexture queued no resolve" + ); + assert_eq!(num(&mut s, "C:GetLeft()"), None); + assert_eq!(drawn(&s, "T"), None, "and it draws nothing"); + assert_eq!(drawn(&s, "C"), None); + // The size getter reads the art live (`0x770720`), resolved or not. + assert_eq!(num(&mut s, "T:GetWidth()"), Some(64.0)); + + s.run( + r#"T:SetPoint("TOPLEFT", f, "TOPLEFT", 1, 0); C:SetPoint("TOPLEFT", f, "TOPLEFT", 2, 0)"#, + ) + .unwrap(); + resolve_matches_scratch(&mut s, "their own invalidates"); + assert_eq!(num(&mut s, "T:GetRight()"), Some(165.0), "the art's 64"); + assert_eq!(num(&mut s, "T:GetBottom()"), Some(168.0), "and its 32"); + assert_eq!(num(&mut s, "C:GetRight()"), Some(110.0), "the solid's 8"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// A texture resolved at its art's size keeps that rect when the art changes, and takes the new +/// art's size when a target stores a new rect (`0x768cf0`). +#[test] +fn a_resolved_texture_keeps_its_rect_when_its_art_changes() { + let mut s = owner(); + s.run( + r#"T = f:CreateTexture("T", "ARTWORK"); T:SetTexture("Art") + T:SetPoint("TOPLEFT", f, "TOPLEFT", 0, 0)"#, + ) + .unwrap(); + s.resolve(); + assert_eq!(num(&mut s, "T:GetRight()"), Some(164.0)); + let before = drawn(&s, "T").expect("drawn"); + + s.run(r#"T:SetTexture("Big")"#).unwrap(); + resolve_matches_scratch(&mut s, "new art"); + assert_eq!(num(&mut s, "T:GetRight()"), Some(164.0), "the rect stands"); + assert_eq!(drawn(&s, "T"), Some(before), "and draws there"); + assert_eq!( + num(&mut s, "T:GetWidth()"), + Some(128.0), + "the getter reads the art" + ); + + // The owner's new rect reaches the texture, whose resolve reads the art it holds now. + s.run("f:SetHeight(120)").unwrap(); + resolve_matches_scratch(&mut s, "the target moved"); + assert_eq!( + num(&mut s, "T:GetRight()"), + Some(228.0), + "the new art's 128" + ); + assert_eq!(num(&mut s, "T:GetTop()"), Some(220.0)); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// A write the reference invalidates for, though it moves no input, re-resolves a texture whose +/// art changed since: `SetWidth` always invalidates (`0x7683d0`), so the new art's span lands. +#[test] +fn a_same_value_invalidate_after_new_art_takes_the_art() { + let mut s = owner(); + s.run( + r#"T = f:CreateTexture("T", "ARTWORK"); T:SetTexture("Art") + T:SetPoint("TOPLEFT", f, "TOPLEFT", 0, 0); T:SetWidth(0)"#, + ) + .unwrap(); + s.resolve(); + assert_eq!(num(&mut s, "T:GetRight()"), Some(164.0)); + s.run(r#"T:SetTexture("Big")"#).unwrap(); + resolve_matches_scratch(&mut s, "new art"); + assert_eq!( + num(&mut s, "T:GetRight()"), + Some(164.0), + "nothing reached it" + ); + s.run("T:SetWidth(0)").unwrap(); + resolve_matches_scratch(&mut s, "the same width"); + assert_eq!( + num(&mut s, "T:GetRight()"), + Some(228.0), + "the invalidate read the new art" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} diff --git a/crates/benilla-ui/src/script/tests/tooltip.rs b/crates/benilla-ui/src/script/tests/tooltip.rs index ec37cdf0..22194987 100644 --- a/crates/benilla-ui/src/script/tests/tooltip.rs +++ b/crates/benilla-ui/src/script/tests/tooltip.rs @@ -1174,3 +1174,44 @@ fn the_fade_end_restores_full_alpha_before_its_hide() { .unwrap(); assert!(s.take_errors().is_empty()); } + +/// The tooltip is invalidated by its relayout (`0x530640`, ending in `0x7680e0(1)` at `0x530a52`), +/// which only Show and the line writers run (`0x530aad`, `0x52ad59`, ..): a cleared plate keeps its +/// rect through an unrelated resolve, and the next line's relayout resolves it without points. +#[test] +fn only_the_relayout_invalidates_the_tooltip() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + local owner = CreateFrame("Button", "Slot") + owner:SetPoint("TOPLEFT", 100, -100); owner:SetWidth(40); owner:SetHeight(40) + local tt = CreateFrame("GameTooltip", "TT") + tt:SetOwner(owner, "ANCHOR_RIGHT") + tt:AddLine("Tough Jerky", 1, 1, 1) + tt:Show() + "#, + ) + .unwrap(); + s.resolve(); + measure_all(&mut s, &[("Tough Jerky", 80.0, 14.0)]); + let left = |s: &mut UiScript| s.eval::>("return TT:GetLeft()").unwrap(); + assert_eq!(left(&mut s), Some(140.0)); + s.run( + r#"TT:ClearAllPoints() + local other = CreateFrame("Frame"); other:SetWidth(5); other:SetHeight(5) + other:SetPoint("CENTER", 0, 0)"#, + ) + .unwrap(); + s.resolve(); + s.resolve(); + assert_eq!(left(&mut s), Some(140.0), "nothing ran the relayout"); + s.run(r#"TT:AddLine("Soulbound")"#).unwrap(); + s.resolve(); + assert_eq!( + left(&mut s), + None, + "the relayout invalidated a plate with no points" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} diff --git a/crates/benilla-ui/src/script/tooltip/mod.rs b/crates/benilla-ui/src/script/tooltip/mod.rs index f4e95d75..ac5f5fee 100644 --- a/crates/benilla-ui/src/script/tooltip/mod.rs +++ b/crates/benilla-ui/src/script/tooltip/mod.rs @@ -189,7 +189,8 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> { }]); model.region_data.insert(left, d); model.touch_measure(left); // an adopted cell can arrive text-in-hand - model.touch_layout_region(left); // a line row entered the layout graph + // A line row's points are set with the invalidate flag (`0x530197`). + model.invalidate_region(left, true); } let left_id = model.region_id(left); @@ -220,7 +221,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> { Anchors::from([Anchor::new(Point::Right, left_id, Point::Left, 0.0, 0.0)]); model.region_data.insert(right, d); model.touch_measure(right); // an adopted cell can arrive text-in-hand - model.touch_layout_region(right); // a line row entered the layout graph + model.invalidate_region(right, true); } let right_id = model.region_id(right); @@ -377,6 +378,7 @@ pub(in crate::script) fn show_override(lua: &Lua, h: FrameHandle) { let live = tip_mut(&mut model, h).is_ok_and(|t| t.owner.is_some() && t.num_lines > 0); if live { full_alpha(&mut model, h); + owe_relayout(&mut model, h); } live }; @@ -465,11 +467,11 @@ fn apply_anchor( let owner_id = model.frame_id(owner); let new = Anchors::from([Anchor::new(own, owner_id, rel, x, y)]); let input = model.layout_inputs.entry(h).or_default(); - // No-op when unchanged, so the bag hover's per-frame SetOwner does not dirty the layout. - if !input.anchors.bits_eq(&new) { - input.anchors = new; - model.touch_layout_frame(h); - } + // No-op when unchanged, so the bag hover's per-frame SetOwner does not dirty the layout; + // a real one is `SetPoint` with the invalidate flag (`0x52ff12`..`0x52ffb2`). + let changed = !input.anchors.bits_eq(&new); + input.anchors = new; + model.invalidate_frame(h, changed); } } @@ -649,9 +651,9 @@ pub(super) fn append_line( if let Some(d) = model.region_data.get_mut(&lh) { if d.size != pin { d.size = pin; - // A size-only write takes the named touch: a conservative one would re-derive the - // whole layout graph on every hover that turns a line between wrapped and plain. - model.touch_layout_region(lh); + // `SetWidth`, which invalidates (`0x7683d0`), named: a conservative touch would + // re-derive the whole layout graph on every hover that turns a line wrapped or plain. + model.invalidate_region(lh, true); // The wrap pin is the measure key's wrap-width input. model.touch_measure(lh); } @@ -662,9 +664,21 @@ pub(super) fn append_line( if let Ok(t) = tip_mut(&mut model, h) { t.num_lines = n; } + owe_relayout(&mut model, h); Ok(()) } +/// A line writer or the show ran the relayout (`0x530640`, from `0x52ad59`, `0x52b029`, +/// `0x52e5ae`, `0x52f869`, `0x530615`, `0x530aad`), which does nothing without an owner +/// (`0x530651`) and else ends in an unconditional invalidate (`0x530a52`). +pub(super) fn owe_relayout(model: &mut Model, h: FrameHandle) { + if let Ok(t) = tip_mut(model, h) { + if t.owner.is_some() { + t.relayout_owed = true; + } + } +} + mod verbs; pub(super) use verbs::install; @@ -708,9 +722,10 @@ fn cursor_anchor(model: &mut Model, h: FrameHandle) { ); let units = model.layout_units(); let input = model.layout_inputs.entry(h).or_default(); - // `0x767c70`'s change detect: the BOTTOM slot is replaced only on a real change. + // `0x767c70`'s change detect: the BOTTOM slot is replaced, and the plate invalidated + // (`0x530b97`, flag 1), only on a real change. if input.anchors.set(new, units) { - model.touch_layout_frame(h); + model.invalidate_frame(h, true); } } @@ -733,6 +748,16 @@ pub(super) fn layout_tooltips(model: &mut Model) { if cursor_mode { cursor_anchor(model, h); } + // The relayout's closing invalidate (`0x530a52`), whatever it computed. Its own writes + // below push when they move the plate; past those, a re-resolve gives back the rect the + // plate holds unless that rect is stale, so only then is the invalidate paid here. + let owed = tip_mut(model, h).is_ok_and(|t| std::mem::take(&mut t.relayout_owed)); + if owed && model.frame_holds_stale(h) { + model.note_layout_frame_write(h); + if let Some(&id) = model.frame_to_id.get(&h) { + model.layout_pending.push(id); + } + } let (num, lefts, rights, min_w, pad_w) = match model.arena.frame(h).map(|f| &f.kind_state) { Some(KindState::Tooltip(t)) => ( t.num_lines, @@ -783,6 +808,8 @@ pub(super) fn layout_tooltips(model: &mut Model) { // that these inputs changed. The size goes through `SetWidth`/`SetHeight` (`0x530a2e`, // `0x530a39`), which invalidate the tooltip. model.note_layout_frame_write(h); + // A size the measures moved since the relayout ran: benilla's measures land a tick after + // the line write, where the reference's relayout measured in the call. if moved { if let Some(&id) = model.frame_to_id.get(&h) { model.layout_pending.push(id); @@ -805,6 +832,9 @@ pub(super) fn layout_tooltips(model: &mut Model) { .is_some_and(|d| d.anchors.set(new, units)); if stored { model.note_layout_region_write(rights[i]); + if let Some(&id) = model.region_to_id.get(&rights[i]) { + model.layout_pending.push(id); + } } } } diff --git a/crates/benilla-ui/src/script/tooltip/verbs.rs b/crates/benilla-ui/src/script/tooltip/verbs.rs index d8a47b70..20c21238 100644 --- a/crates/benilla-ui/src/script/tooltip/verbs.rs +++ b/crates/benilla-ui/src/script/tooltip/verbs.rs @@ -151,6 +151,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { None => (String::new(), [1.0, 1.0, 1.0, 1.0]), }; write_cell(&mut model, lh, &(current + &extra), color); + super::owe_relayout(&mut model, h); Ok(()) })?, )?; @@ -242,8 +243,9 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { d.anchors = Anchors::from([Anchor::new(Point::Right, left_id, Point::Right, 0.0, 0.0)]); } - model.touch_layout_region(lh); // two line rows entered the layout graph - model.touch_layout_region(rh); + // Two line rows, their points set with the invalidate flag. + model.invalidate_region(lh, true); + model.invalidate_region(rh, true); let t = tip_mut(&mut model, h)?; t.left_lines.push(lh); t.right_lines.push(rh); diff --git a/crates/benilla-ui/src/widget/kinds/mod.rs b/crates/benilla-ui/src/widget/kinds/mod.rs index dbae78e8..4f3f7bd1 100644 --- a/crates/benilla-ui/src/widget/kinds/mod.rs +++ b/crates/benilla-ui/src/widget/kinds/mod.rs @@ -281,6 +281,9 @@ pub struct TooltipState { pub padding: f32, /// Line 1 came from XML (`ShoppingTooltipTemplate`), so later lines clone the previous fonts. pub xml_declared_lines: bool, + /// A relayout (`0x530640`) is owed: Show and the line writers run it with an owner, and it + /// ends by invalidating the plate (`0x530a52`). The layout pre-pass pays it. + pub relayout_owed: bool, } /// The plate's text inset: the stock template seats `TextLeft1` at TOPLEFT (10, −10). diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index cfbe82a4..b0e67ef1 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -724,7 +724,7 @@ impl WidgetArena { Some(d) } - /// Free one region, as `CSimpleHTML::SetText` pool-frees the last parse's blocks (`0x78adc6`) + /// Free one region, as `CSimpleHTML::SetText` deletes the last parse's blocks (`0x78a580`) /// and a backdrop's teardown its nine pieces (`0x77f4d0`). The caller drops the region's /// script-model state too, and never frees a region some state holds by handle. pub fn destroy_region(&mut self, h: RegionHandle) -> bool {