diff --git a/crates/benilla-app/src/ui_script/resolve_bench.rs b/crates/benilla-app/src/ui_script/resolve_bench.rs index c8dce186..ad71ca70 100644 --- a/crates/benilla-app/src/ui_script/resolve_bench.rs +++ b/crates/benilla-app/src/ui_script/resolve_bench.rs @@ -73,6 +73,19 @@ fn app_frame_ticked(s: &mut UiScript, dt: f32) { s.resolve(); } +/// A forced rebuild counts a derivation: the positive control behind every zero asserted on +/// `layout_derivations`, now that births derive nothing. +fn derivations_move(s: &mut UiScript) { + let before = s.layout_derivations(); + s.force_full_layout_resolve(); + s.resolve(); + assert!( + s.layout_derivations() > before, + "a forced rebuild must count a derivation — reading zero means `layout_derivations` never \ + moves and every zero asserted on it is vacuous" + ); +} + /// A tooltip whose lines change width every call, as in a hover sweep across a bag grid. fn install_changing_tooltip(s: &UiScript, owner: &str, func: &str) { s.run(&format!( @@ -267,8 +280,7 @@ fn a_region_moving_every_frame_costs_one_gate_walk_on_the_shipped_ui() { /// Those walks cost zero derivations of the layout graph: a moving region names its node, so the /// resolve re-hashes one node, not the roster; a derivation per frame means a write site fell back -/// to the conservative `touch_layout`. The VM ticks, so the shipped handlers are held to it too, -/// and a birth must derive, which proves the counter moves. +/// to the conservative `touch_layout`. The VM ticks, so the shipped handlers are held to it too. #[test] fn a_region_moving_every_frame_costs_no_graph_derivation_on_the_shipped_ui() { benilla_formats::wow_data_or_skip!(); @@ -290,18 +302,18 @@ fn a_region_moving_every_frame_costs_no_graph_derivation_on_the_shipped_ui() { ) .unwrap(); - // Positive control: a birth moves the roster, so it must derive. + // Births join the cached graph without a derivation. let born_at = s.layout_derivations(); for _ in 0..6 { s.run("bench_spark2_frame()").unwrap(); app_frame_ticked(&mut s, 1.0 / 144.0); } - assert!( - s.layout_derivations() > born_at, - "creating a frame and a texture must derive the graph — a birth moves the roster, which \ - is the one thing the per-node ledger cannot describe. Reading zero here means \ - `layout_derivations` never moves and the assertion below is vacuous." + assert_eq!( + s.layout_derivations() - born_at, + 0, + "creating a frame and a texture derived the layout graph: a birth names its nodes, which join the cached graph" ); + derivations_move(&mut s); let derives_before = s.layout_derivations(); let walks_before = s.layout_gate_walks(); @@ -435,17 +447,18 @@ fn a_tooltip_line_flipping_wrapped_to_plain_costs_no_graph_derivation() { ) .unwrap(); - // Positive control: the owner's birth and the line pool's growth move the roster and derive. + // Births join the cached graph without a derivation. let born_at = s.layout_derivations(); for _ in 0..8 { s.run("flip_change()").unwrap(); app_frame(&mut s); } - assert!( - s.layout_derivations() > born_at, - "creating the owner and growing the tooltip's line pool must derive the graph — reading \ - zero here means `layout_derivations` never moves and the assertion below is vacuous." + assert_eq!( + s.layout_derivations() - born_at, + 0, + "creating the owner and growing the line pool derived the layout graph: a birth names its nodes, which join the cached graph" ); + derivations_move(&mut s); let derives_before = s.layout_derivations(); for _ in 0..10 { @@ -493,17 +506,18 @@ fn a_hover_sweep_across_owners_costs_no_graph_derivation() { ) .unwrap(); - // Positive control: twelve births and the line pool's growth move the roster and derive. + // Births join the cached graph without a derivation. let born_at = s.layout_derivations(); for _ in 0..40 { s.run("sweep_change()").unwrap(); app_frame(&mut s); } - assert!( - s.layout_derivations() > born_at, - "creating twelve owners and growing the line pool must derive the graph — reading zero \ - here means `layout_derivations` never moves and the assertion below proves nothing." + assert_eq!( + s.layout_derivations() - born_at, + 0, + "creating twelve owners and growing the line pool derived the layout graph: a birth names its nodes, which join the cached graph" ); + derivations_move(&mut s); // Two laps of the twelve owners: every step re-hovers a button the graph already knows. let derives_before = s.layout_derivations(); @@ -515,9 +529,9 @@ fn a_hover_sweep_across_owners_costs_no_graph_derivation() { assert_eq!( derives, 0, "24 hovers across 12 owners derived the layout graph {derives} times — one per slot \ - crossed. `SetOwner` re-points ONE node's anchor and must patch that node's edges \ - (`Model::touch_layout_retarget_frame`); the conservative touch re-derives the whole \ - roster on every slot the cursor passes over." + crossed. `SetOwner` re-points ONE node's anchor and must name that node \ + (`Model::touch_layout_frame`), whose edges re-derive in the cached graph; the \ + conservative touch re-derives the whole roster on every slot the cursor passes over." ); } @@ -556,16 +570,18 @@ fn an_action_bar_hover_sweep_costs_no_graph_derivation() { ) .unwrap(); - // Positive control: the births and the line pool's growth are structural. + // Births join the cached graph without a derivation. let born_at = s.layout_derivations(); for _ in 0..40 { s.run("bar_hover()").unwrap(); app_frame(&mut s); } - assert!( - s.layout_derivations() > born_at, - "creating twelve buttons must derive the graph — zero here makes the assertion vacuous." + assert_eq!( + s.layout_derivations() - born_at, + 0, + "creating twelve buttons derived the layout graph: a birth names its nodes, which join the cached graph" ); + derivations_move(&mut s); let derives_before = s.layout_derivations(); for _ in 0..24 { @@ -623,16 +639,18 @@ fn a_bag_addon_hover_sweep_costs_no_graph_derivation() { ) .unwrap(); - // Positive control: the births and the line pool's growth are structural. + // Births join the cached graph without a derivation. let born_at = s.layout_derivations(); for _ in 0..40 { s.run("bag_hover()").unwrap(); app_frame(&mut s); } - assert!( - s.layout_derivations() > born_at, - "creating twelve slots must derive the graph — zero here makes the assertion vacuous." + assert_eq!( + s.layout_derivations() - born_at, + 0, + "creating twelve slots derived the layout graph: a birth names its nodes, which join the cached graph" ); + derivations_move(&mut s); let derives_before = s.layout_derivations(); for _ in 0..24 { @@ -647,3 +665,64 @@ fn a_bag_addon_hover_sweep_costs_no_graph_derivation() { EMPTY set and names its node like any other." ); } + +/// What one structural change costs on the full shipped UI, per run, resolve and extract: a no-op, +/// a `SetWidth`, a `CreateTexture`, a `CreateFrame` and a backdrop rebuild, each as the mean of +/// 200, with the graph derivations it paid beside it. +#[test] +#[ignore = "bench, run explicitly"] +fn structural_change_costs() { + let mut s = settled_default_ui(); + s.run( + r#" + BenchHost = CreateFrame("Frame", "BenchHost", UIParent) + BenchHost:SetPoint("CENTER", 0, 0); BenchHost:SetWidth(200); BenchHost:SetHeight(100) + bench_n = 0 + bench_spec = { bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", tile = true, tileSize = 16, + edgeSize = 16, insets = { left = 4, right = 4, top = 4, bottom = 4 } } + "#, + ) + .unwrap(); + for _ in 0..4 { + app_frame(&mut s); + } + let cases: [(&str, &str); 5] = [ + ("no-op", "bench_n = bench_n + 1"), + ( + "SetWidth", + "bench_n = bench_n + 1; BenchHost:SetWidth(200 + math.mod(bench_n, 2))", + ), + ( + "CreateTexture", + "bench_n = bench_n + 1; local t = BenchHost:CreateTexture(nil, 'ARTWORK'); \ + t:SetWidth(4); t:SetHeight(4); t:SetPoint('TOPLEFT', BenchHost, 'TOPLEFT', \ + math.mod(bench_n, 50), 0)", + ), + ( + "CreateFrame", + "bench_n = bench_n + 1; local f = CreateFrame('Frame', nil, BenchHost); \ + f:SetWidth(4); f:SetHeight(4); f:SetPoint('TOPLEFT', BenchHost, 'TOPLEFT', \ + math.mod(bench_n, 50), 0)", + ), + ( + "SetBackdrop", + "bench_n = bench_n + 1; BenchHost:SetBackdrop(bench_spec)", + ), + ]; + const N: u32 = 200; + for (name, lua) in cases { + let d0 = s.layout_derivations(); + let t = Instant::now(); + for _ in 0..N { + s.run(lua).unwrap(); + app_frame(&mut s); + std::hint::black_box(s.extract()); + } + println!( + "STRUCT {name:<14} {:.3} ms/iter derivations/iter={:.2}", + ms(t) / f64::from(N), + (s.layout_derivations() - d0) as f64 / f64::from(N), + ); + } +} diff --git a/crates/benilla-ui/src/script/backdrop.rs b/crates/benilla-ui/src/script/backdrop.rs index 189b57c8..aff9f12c 100644 --- a/crates/benilla-ui/src/script/backdrop.rs +++ b/crates/benilla-ui/src/script/backdrop.rs @@ -331,16 +331,13 @@ impl Installed { /// the next resolve runs them. Each piece is a fresh Texture linked at the tail of the region /// list, so a second backdrop's pieces come after any region made in between. pub(crate) fn install(model: &mut Model, fh: FrameHandle, spec: Option) { - // One layout touch for the whole swap, the pieces it frees and the ones it builds: the next - // resolve re-derives the graph once either way. + // 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::release_region(model, rh); + super::region::free_region(model, rh); } let Some(spec) = spec.filter(|_| model.arena.frame(fh).is_some()) else { - if old.is_some() { - model.touch_layout(); - } return; }; let owner = model.frame_id(fh); @@ -399,6 +396,9 @@ pub(crate) fn install(model: &mut Model, fh: FrameHandle, spec: Option ); } } + for rh in pieces.iter().flatten() { + model.touch_layout_region(*rh); + } model.backdrops.insert( fh, Installed { @@ -407,7 +407,6 @@ pub(crate) fn install(model: &mut Model, fh: FrameHandle, spec: Option ran_for: None, }, ); - model.touch_layout(); 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 8d7479bf..f3746935 100644 --- a/crates/benilla-ui/src/script/button.rs +++ b/crates/benilla-ui/src/script/button.rs @@ -284,7 +284,7 @@ fn ensure_slot(lua: &Lua, this: &Table, slot: Slot) -> mlua::Result { .create_region(h, kind, layer, 0) .ok_or_else(|| mlua::Error::runtime("stale frame handle"))?; model.region_data.insert(rh, RegionData::default()); - model.touch_layout(); // a region entered the layout gate's read set + model.touch_layout_region(rh); if let Some(frame) = model.arena.frame_mut(h) { if let KindState::Button(bs) = &mut frame.kind_state { slot.set(bs, Some(rh)); @@ -418,6 +418,7 @@ fn set_slot_texture( if let Some(blend) = blend { data.blend = blend; } + model.touch_layout_art(rh); Ok(()) } @@ -571,8 +572,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { } super::region::link_region(&mut model, rh, owner, DrawLayer::Artwork); adopt_label(&mut model, owner, rh); - // The layout's read set moved, and `GetTextWidth` reads the new label's extents. - model.touch_layout(); + // A new owner, so a new scale for its node, and `GetTextWidth` reads its extents. + model.touch_layout_region(rh); model.touch_measure(rh); Ok(()) })?, diff --git a/crates/benilla-ui/src/script/colorselect.rs b/crates/benilla-ui/src/script/colorselect.rs index 7d3ef164..4d3dfa74 100644 --- a/crates/benilla-ui/src/script/colorselect.rs +++ b/crates/benilla-ui/src/script/colorselect.rs @@ -115,7 +115,7 @@ fn ensure_slot(lua: &Lua, this: &Table, slot: Slot, layer: Option) -> .create_region(h, RegionKind::Texture, layer.unwrap_or(slot.layer()), 0) .ok_or_else(|| mlua::Error::runtime("stale frame handle"))?; model.region_data.insert(rh, RegionData::default()); - model.touch_layout(); // a region entered the layout gate's read set + model.touch_layout_region(rh); if let Some(frame) = model.arena.frame_mut(h) { if let KindState::ColorSelect(s) = &mut frame.kind_state { slot.set(s, rh); @@ -225,6 +225,7 @@ fn install_slot_texture( // The empty form: the region now exists and stays file-less. _ => {} } + model.touch_layout_art(rh); Ok(()) })?, )?; diff --git a/crates/benilla-ui/src/script/container.rs b/crates/benilla-ui/src/script/container.rs index 944e07ba..2d662d1f 100644 --- a/crates/benilla-ui/src/script/container.rs +++ b/crates/benilla-ui/src/script/container.rs @@ -474,6 +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); Ok(()) })?, )?; diff --git a/crates/benilla-ui/src/script/editbox.rs b/crates/benilla-ui/src/script/editbox.rs index 2b2c5708..27b0ea4b 100644 --- a/crates/benilla-ui/src/script/editbox.rs +++ b/crates/benilla-ui/src/script/editbox.rs @@ -461,7 +461,7 @@ fn ensure_text_region_in(model: &mut Model, h: FrameHandle) -> Option) -> u64 { node.finish() } -/// A region's per-node input hash: its anchors, explicit size and measured text extent. +/// A region's per-node input hash: its anchors, explicit size, measured text extent and owner, +/// whose effective scale the sweep reads (`None` for an owner with no layout id). #[inline] -fn region_node_hash(data: &RegionData) -> u64 { +pub(super) fn region_node_hash(data: &RegionData, owner: Option) -> u64 { let mut node = InputFingerprint::default(); node.anchors(&data.anchors); + node.feed(owner.map_or(u64::MAX, u64::from)); match data.size { Some((w, h)) => { node.f32(w); @@ -168,6 +170,11 @@ const REGION_TAG: u32 = 0x8000_0000; /// Per-node layout scope, so a resolve touches only what moved. Indexed densely by layout id, as /// frames and regions share one counter (`Model::next_id`); nothing allocates on a steady frame. +/// +/// The graph is derived in full once, then kept: a write names the ids it touched +/// ([`Model::touch_layout_frame`]), and the next resolve re-derives just those nodes against the +/// model ([`UiScript::rederive_named`]), joining, leaving or re-pointing them in place, the way a +/// retained layout tree marks a node dirty rather than rebuilding itself. #[derive(Default)] pub(crate) struct LayoutScope { /// Per id: the hash the last converged resolve saw, or [`NO_NODE`]; a mismatch seeds dirty. @@ -182,8 +189,10 @@ pub(crate) struct LayoutScope { dep_head: Vec, dep_to: Vec, dep_next: Vec, - /// Edge slots [`Self::retarget`] freed, for [`Self::edge`] to reuse rather than grow `dep_to`. + /// Edge slots [`Self::unlink_edge`] freed, for [`Self::edge`] to reuse rather than grow `dep_to`. dep_free: Vec, + /// Per id: the targets of the edges it recorded, in order, so a re-derive unlinks exactly those. + out: Vec>, /// Per id: in the dirty closure. dirty: Vec, /// The closure's worklist. @@ -192,12 +201,20 @@ pub(crate) struct LayoutScope { plan: Vec<(FrameHandle, u32, Option)>, /// 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. + targets: Vec, } -/// One live, anchored region as the round loop consumes it, built once per derivation. -#[derive(Clone, Copy)] +/// One live, anchored region as the round loop consumes it, a row of the cached roster. +#[derive(Clone, Copy, PartialEq)] struct RegionRow { rh: RegionHandle, /// The layout id: the solver's array index and the scope's node key. @@ -221,6 +238,9 @@ impl LayoutScope { self.node_of.resize(n, u32::MAX); self.dep_head.clear(); self.dep_head.resize(n, u32::MAX); + // Cleared in place, so a re-derivation reuses each list's allocation. + self.out.iter_mut().for_each(Vec::clear); + self.out.resize_with(n, Vec::new); self.dirty.clear(); self.dirty.resize(n, false); self.dep_to.clear(); @@ -229,24 +249,28 @@ impl LayoutScope { self.stack.clear(); self.plan.clear(); self.regions.clear(); + self.scroll_child.clear(); + self.claims.clear(); self.staged.clear(); } - /// The incremental entry: the cached graph survives, and only the dirty marks reset. - fn begin_incremental(&mut self) { + /// The incremental entry: the cached graph survives, grown to `n` ids, and only the dirty + /// marks reset. + fn begin_incremental(&mut self, n: usize) { + if self.node_of.len() < n { + self.node_of.resize(n, u32::MAX); + self.dep_head.resize(n, u32::MAX); + self.out.resize_with(n, Vec::new); + } + if self.last.len() < self.node_of.len() { + self.last.resize(self.node_of.len(), NO_NODE); + } self.dirty.clear(); self.dirty.resize(self.node_of.len(), false); self.stack.clear(); self.staged.clear(); } - /// Whether `id` is a node of the cached graph, the only kind a precise touch may name. - pub(crate) fn has_node(&self, id: u32) -> bool { - self.node_of - .get(id as usize) - .is_some_and(|&n| n != u32::MAX) - } - /// Record that node `from` reads `to`'s rect, so a change at `to` reaches `from`. #[inline] fn edge(&mut self, from: u32, to: u32) { @@ -269,23 +293,33 @@ impl LayoutScope { *head = e; } - /// Re-point node `id`'s outgoing edges after an anchor retarget. `old` and `new` are its full - /// anchor-target lists, so duplicate anchors stay exact. `false` means a conservative touch: - /// `id` has no node, or a ScrollFrame override, not its anchors, supplies its edges. - pub(crate) fn retarget(&mut self, id: u32, old: &[u32], new: &[u32]) -> bool { - let Some(&n) = self.node_of.get(id as usize) else { + /// [`Self::edge`], remembered as one of `from`'s own, for a later [`Self::relink`]. + #[inline] + fn link(&mut self, from: u32, to: u32) { + self.edge(from, to); + if let Some(out) = self.out.get_mut(from as usize) { + out.push(to); + } + } + + /// Point node `id`'s edges at `targets`, unlinking the ones it recorded; `true` when they + /// differed, which the caller seeds, as the node now reads other rects. + fn relink(&mut self, id: u32, targets: &[u32]) -> bool { + let Some(out) = self.out.get_mut(id as usize) else { return false; }; - if n == u32::MAX { + if out.as_slice() == targets { return false; } - if n & REGION_TAG == 0 && self.plan[n as usize].2.is_some() { - return false; // a ScrollFrame override owns this frame's edges, not its anchors - } - for &t in old { + let old = std::mem::take(out); + for &t in &old { self.unlink_edge(t, id); } - for &t in new { + let mut old = old; + old.clear(); + old.extend_from_slice(targets); + self.out[id as usize] = old; + for &t in targets { self.edge(id, t); } true @@ -325,6 +359,73 @@ impl LayoutScope { } } + /// A re-derived node's verdict: stage and seed it when its hash or its edges moved. Every edge + /// source (anchor targets, the owner, the override) is in the hash, so `relinked` only guards + /// against a hash that stops covering one. + #[inline] + fn note(&mut self, id: u32, hash: u64, relinked: bool) { + if relinked || hash != self.last.get(id as usize).copied().unwrap_or(NO_NODE) { + self.staged.push((id, hash)); + self.seed(id); + } + } + + /// 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); + } + } + } + if let Some(c) = child { + self.claims.entry(c).or_default().push((sh, sid)); + } + before + } + + /// Whether `id` is a node whose converged hash is `hash`: a write that leaves it there moved + /// nothing the resolve reads. + pub(crate) fn holds(&self, id: u32, hash: u64) -> bool { + self.node_of + .get(id as usize) + .is_some_and(|&n| n != u32::MAX) + && self.last.get(id as usize) == Some(&hash) + } + + /// Drop region node `id` from the roster with its edges, seeding it so what read its rect + /// re-resolves without it. Returns the row it held. + fn remove_region(&mut self, id: u32, n: u32) -> RegionRow { + let idx = (n & !REGION_TAG) as usize; + let row = self.regions.swap_remove(idx); + if let Some(moved) = self.regions.get(idx) { + #[allow(clippy::cast_possible_truncation)] + { + self.node_of[moved.id as usize] = idx as u32 | REGION_TAG; + } + } + self.node_of[id as usize] = u32::MAX; + self.relink(id, &[]); + self.staged.push((id, NO_NODE)); + self.seed(id); + row + } + /// Close the dirty set under "depends on": whatever reads a dirty rect, transitively. fn close(&mut self) { while let Some(id) = self.stack.pop() { @@ -353,7 +454,7 @@ impl LayoutScope { } /// [`Self::commit`] for an incremental pass: only the re-hashed slots. `last_screen` stays, - /// since a structural change always takes the derivation. + /// since a screen move always takes the derivation. fn commit_incremental(&mut self) { for &(id, h) in &self.staged { self.last[id as usize] = h; @@ -365,12 +466,196 @@ impl LayoutScope { pub(crate) fn invalidate(&mut self) { self.last_screen = None; self.last.clear(); - // The roster and `node_of` too: `has_node` must not vouch for a node the memory forgot. self.plan.clear(); self.regions.clear(); self.node_of.clear(); self.staged.clear(); } + + /// `WOW_LAYOUT_VERIFY`'s graph check: where this cached graph differs from `fresh`, derived + /// from scratch on the same model, in roster, per-node hash, edges or ScrollFrames. + fn diverges_from(&self, fresh: &LayoutScope) -> Option<(u32, String)> { + let at = |v: &[u32], id: usize| v.get(id).copied().unwrap_or(u32::MAX); + let hash = |s: &LayoutScope, id: usize| s.last.get(id).copied().unwrap_or(NO_NODE); + let sorted = |v: Option<&Vec>| { + let mut v = v.cloned().unwrap_or_default(); + v.sort_unstable(); + v + }; + let incoming = |s: &LayoutScope, id: usize| { + let mut v = Vec::new(); + let mut e = at(&s.dep_head, id); + while e != u32::MAX { + v.push(s.dep_to[e as usize]); + e = s.dep_next[e as usize]; + } + v.sort_unstable(); + v + }; + let over_eq = |a: Option, b: Option| match (a, b) { + (None, None) => true, + (Some(a), Some(b)) => super::object::anchor_bits_eq(&a, &b), + _ => false, + }; + let n = self + .node_of + .len() + .max(fresh.node_of.len()) + .max(self.last.len()) + .max(fresh.last.len()); + for id in 0..n { + let (a, b) = (at(&self.node_of, id), at(&fresh.node_of, id)); + let row = |s: &LayoutScope, n: u32| -> String { + match n { + u32::MAX => "absent".into(), + n if n & REGION_TAG != 0 => { + 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 + ), + } + }; + let same_node = match (a, b) { + (u32::MAX, u32::MAX) => true, + (u32::MAX, _) | (_, u32::MAX) => false, + (a, b) if (a ^ b) & REGION_TAG != 0 => false, + (a, b) if a & REGION_TAG != 0 => { + 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) + } + }; + if !same_node { + return Some(( + id as u32, + format!( + "id {id}: the cached graph holds {}, a fresh derivation {}", + row(self, a), + row(fresh, b) + ), + )); + } + if hash(self, id) != hash(fresh, id) { + return Some(( + id as u32, + format!( + "id {id} ({}): the cached hash is stale against a fresh derivation", + row(self, a) + ), + )); + } + let (oa, ob) = (sorted(self.out.get(id)), sorted(fresh.out.get(id))); + if a != u32::MAX && oa != ob { + return Some(( + id as u32, + format!( + "id {id} ({}): reads {oa:?} in the cached graph, {ob:?} fresh", + row(self, a) + ), + )); + } + let (ia, ib) = (incoming(self, id), incoming(fresh, id)); + if ia != ib { + return Some(( + id as u32, + format!("id {id}: read by {ia:?} in the cached graph, {ib:?} fresh"), + )); + } + } + 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), + ) + }) + } +} + +/// The layout id of the first frame or region whose rect differs between the model and `frames` +/// and `regions`, in id order. +fn first_rect_diff( + model: &Model, + frames: &HashMap, + regions: &HashMap, +) -> Option { + let mut ids: Vec = Vec::new(); + for (h, r) in &model.resolved { + if frames.get(h) != Some(r) { + ids.extend(model.frame_to_id.get(h)); + } + } + for h in frames.keys().filter(|h| !model.resolved.contains_key(h)) { + ids.extend(model.frame_to_id.get(h)); + } + for (h, r) in &model.region_resolved { + if regions.get(h) != Some(r) { + ids.extend(model.region_to_id.get(h)); + } + } + for h in regions + .keys() + .filter(|h| !model.region_resolved.contains_key(h)) + { + ids.extend(model.region_to_id.get(h)); + } + let differs = frames != &model.resolved || regions != &model.region_resolved; + ids.into_iter().min().or(differs.then_some(u32::MAX)) +} + +/// A node's layout inputs as the model holds them, for `WOW_LAYOUT_VERIFY`'s graph report. +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 {:?}", + 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), + ); + } + let Some(rh) = model.id_to_region.get(&id) else { + return "no live frame or region".into(); + }; + let owner = model.arena.region(*rh).map(|r| r.owner); + let owner_id = owner.and_then(|o| model.frame_to_id.get(&o)); + match model.region_data.get(rh) { + Some(d) => format!( + "region owner {owner:?} (id {owner_id:?}), anchors {:?}, size {:?}, measured {:?} \ + (text {} bytes), texture {:?}, fill {}", + 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() + ), + None => format!("region owner {owner:?} with no data"), + } } /// `WOW_LAYOUT_PROF=1`: the per-solve `[layout-prof]` and `[layout-pre]` lines on stderr. @@ -505,11 +790,20 @@ impl UiScript { return; } // ── `WOW_LAYOUT_VERIFY`: the incremental pass's falsifier ───────────── - // Re-run the frame from scratch and require identical rects: this catches a write that - // moved a node without naming it, or changed an anchor's target as if value-only. model.layout_verify_recheck = false; + if let Some(why) = Self::scratch_divergence(model) { + panic!("WOW_LAYOUT_VERIFY: {why}"); + } + } + + /// Re-solve the model from scratch on a fresh graph and compare it with the converged cached + /// one: the rects, then the graph itself (roster, per-node hashes, edges). This catches a write + /// that moved a node without naming it, and a re-derive that joined, dropped or re-pointed one + /// wrong. The cached graph is put back after, so a run of incremental passes is checked step + /// by step, not reset at each; the meters keep the incremental pass's values. + pub(super) fn scratch_divergence(model: &mut Model) -> Option { + let cached = std::mem::take(&mut model.layout_scope); let before = (model.resolved.clone(), model.region_resolved.clone()); - // The meters keep the incremental pass's values, which tests assert on, not the re-run's. let meters = ( model.layout_solves, model.layout_rounds, @@ -521,8 +815,9 @@ impl UiScript { model.layout_touched = None; // no ledger: derive the graph model.layout_epoch_resolved = None; // past tier 1 model.layout_fingerprint = None; // past tier 2 - model.layout_scope.invalidate(); // full scope - Self::resolve_layout_inner(model); + Self::resolve_layout_inner(model); // a fresh scope: full scope + let graph = cached.diverges_from(&model.layout_scope); + model.layout_scope = cached; model.pending_size_changed.truncate(queued); model.warnings.truncate(warned); ( @@ -532,17 +827,36 @@ impl UiScript { model.layout_gate_walks, model.layout_derives, ) = meters; - assert!( - before.0 == model.resolved && before.1 == model.region_resolved, - "WOW_LAYOUT_VERIFY: an INCREMENTAL layout pass disagreed with a full derivation of \ - the same frame — a write site named the wrong node, named one while changing an \ - anchor's target, or moved layout inputs without naming any. \ - frames {} -> {}, regions {} -> {}", - before.0.len(), - model.resolved.len(), - before.1.len(), - model.region_resolved.len(), - ); + // The first node whose rect differs, with its inputs; the graph's divergence, if any, + // usually names the cause. + let report = |id: u32| format!("id {id}, inputs now: {}", node_inputs(model, id)); + let graph = graph.map(|(id, why)| format!("{why} ({})", report(id))); + if let Some(id) = first_rect_diff(model, &before.0, &before.1) { + let rect = |frames: &HashMap, + regions: &HashMap| { + match (model.id_to_frame.get(&id), model.id_to_region.get(&id)) { + (Some(h), _) => frames.get(h).copied(), + (_, Some(rh)) => regions.get(rh).copied(), + _ => None, + } + }; + return Some(format!( + "an INCREMENTAL layout pass disagreed with a full derivation of the same frame — \ + a write site named the wrong node or moved layout inputs without naming any. \ + First rect to differ: {}, incremental {:?}, fresh {:?}. Graph: {}", + report(id), + rect(&before.0, &before.1), + rect(&model.resolved, &model.region_resolved), + graph.as_deref().unwrap_or("same as fresh"), + )); + } + graph.map(|why| { + format!( + "the cached layout graph diverged from a fresh derivation after an INCREMENTAL \ + pass — a write changed a node's roster, edges or inputs without naming it, or \ + the re-derive mis-applied one. {why}" + ) + }) } fn resolve_layout_inner(model: &mut Model) { @@ -574,10 +888,12 @@ impl UiScript { // ── The graph: the ledger's cache, or a fresh derivation ───────────────────────────── // A `Some` ledger claims every write since the graph was built named its node and kept // the graph's shape, so the cached roster, edges and hashes stand; `None` derives. - let touched = model.layout_touched.take(); - let fp = touched - .is_none() - .then(|| Self::derive_layout_graph(model, &mut pre)); + // The named nodes re-derive in place; a change the cached graph cannot take derives. + let incremental = match model.layout_touched.take() { + Some(named) => Self::rederive_named(model, named, &mut pre), + None => false, + }; + let fp = (!incremental).then(|| Self::derive_layout_graph(model, &mut pre)); let Model { arena, @@ -602,42 +918,7 @@ impl UiScript { texture_size_probe, .. } = model; - // ── The incremental seed ───────────────────────────────────────────── - // Re-hash only the named nodes and seed those whose hash moved. It runs before the gate - // because on this path it is the verdict: an empty seed set means nothing moved. let mut full_scope = false; - if let Some(touched) = &touched { - scope.begin_incremental(); - for &id in touched { - let Some(&n) = scope.node_of.get(id as usize) else { - continue; - }; - if n == u32::MAX { - continue; - } - let now = if n & REGION_TAG != 0 { - let rh = scope.regions[(n & !REGION_TAG) as usize].rh; - match region_data.get(&rh) { - Some(d) => region_node_hash(d), - None => continue, - } - } else { - let (h, _, over) = scope.plan[n as usize]; - match layout_inputs.get(&h) { - Some(i) => frame_node_hash(i, over), - None => continue, - } - }; - // A named write that did not move the hash (a same-value `SetPoint`) seeds nothing. - if now != scope.last.get(id as usize).copied().unwrap_or(NO_NODE) { - scope.staged.push((id, now)); - scope.seed(id); - } - } - scope.close(); - pre.seed = pre.lap(); - pre.incremental = true; - } // An incremental pass has no fingerprint; its verdict is `staged`, exactly the seed set. let gate_skips = match fp { Some(_) => *layout_fingerprint == fp, @@ -675,9 +956,9 @@ impl UiScript { // ── The scope: which nodes this solve may touch ──── // A node with unchanged inputs and no moved dependency recomputes to the rect it holds, so // the solve runs over the dirty closure and feeds other rects in as externals. Full scope - // when there is no memory yet, the screen moved, or under verify. The incremental pass - // seeded its closure above. - if touched.is_none() { + // when there is no memory yet, the screen moved, or under verify. An incremental pass + // seeded its closure in `rederive_named`. + if !incremental { // A node that died past the new id count has no edges recorded (the derivation sized // them to the count), so only a full scope reaches what anchored to it. let died_past_end = scope @@ -943,7 +1224,7 @@ impl UiScript { } *layout_fingerprint = fp; // Adopted only on convergence; an incremental pass adopts only what it re-hashed. - if touched.is_some() { + if incremental { scope.commit_incremental(); } else { scope.commit(*screen); @@ -953,7 +1234,7 @@ impl UiScript { // Only here: the cycle bail below leaves it `None`. *layout_touched = Some(Vec::new()); // Under verify, `resolve_layout` re-derives this frame from scratch and compares. - *layout_verify_recheck = touched.is_some() && verify; + *layout_verify_recheck = incremental && verify; // A converged solve closes tier 1, at `epoch_at_entry` rather than the live epoch, // so a touch during this pass (the tooltip pre-pass, a minted id) re-opens it. The // fingerprint hashes inputs only, so the next unmutated resolve would match it. @@ -1025,6 +1306,7 @@ impl UiScript { // `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; @@ -1039,6 +1321,7 @@ impl UiScript { 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( @@ -1066,11 +1349,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 anchors, explicit size, measured extent - // and liveness. Seeded rects are outputs, a pure function of these, so they are not hashed. + // ScrollFrame override; each anchored region's node hash (anchors, explicit size, measured + // extent, the art of an unsized axis, owner) and liveness. Seeded rects are outputs, a pure + // function of these, so they are not hashed. // 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); @@ -1095,10 +1382,10 @@ impl UiScript { // 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.edge(id, a.relative_to), + Some(a) => scope.link(id, a.relative_to), None => { for a in &input.anchors { - scope.edge(id, a.relative_to); + scope.link(id, a.relative_to); } } } @@ -1119,22 +1406,12 @@ 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. + let node = region_node_hash(data, owner_id); fp.feed(rh.fingerprint_bits()); - fp.anchors(&data.anchors); - match data.size { - Some((w, h)) => { - fp.f32(w); - fp.f32(h); - } - None => fp.feed(u64::MAX), - } - match data.measured { - Some(m) => { - fp.f32(m.w); - fp.f32(m.h); - } - None => fp.feed(u64::MAX), - } + fp.feed(node); if let Some(r) = live { // Mint an id for a region that never needed one, without `Model::region_id`'s // epoch bump, which would re-dirty the running resolve. @@ -1151,15 +1428,15 @@ impl UiScript { #[allow(clippy::cast_possible_truncation)] let idx = scope.regions.len() as u32 | REGION_TAG; if let Some(slot) = scope.now.get_mut(id as usize) { - *slot = region_node_hash(data); + *slot = node; scope.node_of[id as usize] = idx; } // A region reads its anchor targets' rects and its owner's scale. for a in &data.anchors { - scope.edge(id, a.relative_to); + scope.link(id, a.relative_to); } - if let Some(&owner_id) = frame_to_id.get(&r.owner) { - scope.edge(id, owner_id); + if let Some(owner_id) = owner_id { + scope.link(id, owner_id); } scope.regions.push(RegionRow { rh, @@ -1178,6 +1455,175 @@ impl UiScript { fp } + /// Re-derive each named node against the model, in the cached graph: a node joins the roster + /// with its edges (a frame or region born, a region anchored), leaves it with its edges + /// 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. + fn rederive_named(model: &mut Model, mut named: Vec, pre: &mut PreambleProf) -> bool { + let Model { + arena, + layout_inputs, + region_data, + region_resolved, + frame_to_id, + id_to_frame, + id_to_region, + next_id, + screen, + layout_scope: scope, + .. + } = 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.dedup(); + let mut targets = std::mem::take(&mut scope.targets); + let mut at = 0; + while at < named.len() { + let id = named[at]; + at += 1; + let Some(&n) = scope.node_of.get(id as usize) else { + continue; // past every minted id: never a node + }; + let is_region_node = n != u32::MAX && n & REGION_TAG != 0; + // A frame: every live frame with an id is a node. + if let Some(&h) = id_to_frame.get(&id) { + let Some(frame) = arena.frame(h).filter(|_| frame_to_id.get(&h) == Some(&id)) + else { + if n != u32::MAX { + scope.targets = targets; + return false; + } + continue; + }; + if is_region_node { + 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(); + input.scale = scale; + input.clamp = clamp; + if clamp { + input.extent_x = screen.right; + 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); + 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.relink(id, &targets); + true + } else { + scope.plan[n as usize].2 = over; + scope.relink(id, &targets) + }; + scope.note(id, hash, relinked); + continue; + } + // A region: a node while it lives and has an anchor. + let live = id_to_region.get(&id).and_then(|&rh| { + let r = arena.region(rh)?; + let data = region_data.get(&rh).filter(|d| !d.anchors.is_empty())?; + Some((rh, r, data)) + }); + let Some((rh, r, data)) = live else { + if is_region_node { + let row = scope.remove_region(id, n); + region_resolved.remove(&row.rh); + } else if n != u32::MAX { + scope.targets = targets; + return false; + } + continue; + }; + if n != u32::MAX && !is_region_node { + scope.targets = targets; + return false; + } + let owner_id = frame_to_id.get(&r.owner).copied(); + targets.clear(); + targets.extend(data.anchors.iter().map(|a| a.relative_to)); + targets.extend(owner_id); + let hash = region_node_hash(data, owner_id); + let row = RegionRow { + rh, + id, + owner: r.owner, + is_fontstring: matches!(r.kind, crate::widget::RegionKind::FontString), + }; + let relinked = if n == u32::MAX { + #[allow(clippy::cast_possible_truncation)] + { + scope.node_of[id as usize] = scope.regions.len() as u32 | REGION_TAG; + } + scope.regions.push(row); + scope.relink(id, &targets); + true + } else { + scope.regions[(n & !REGION_TAG) as usize] = row; + scope.relink(id, &targets) + }; + scope.note(id, hash, relinked); + } + scope.targets = targets; + scope.close(); + pre.seed = pre.lap(); + pre.incremental = true; + true + } + /// FontStrings with text whose stored measure is stale, for the host to measure: call after /// [`UiScript::resolve`], answer with [`UiScript::set_measured_text`], and resolve again. pub fn fontstrings_needing_measure(&mut self) -> Vec { diff --git a/crates/benilla-ui/src/script/layout_cache.rs b/crates/benilla-ui/src/script/layout_cache.rs index 44516c4b..9361064c 100644 --- a/crates/benilla-ui/src/script/layout_cache.rs +++ b/crates/benilla-ui/src/script/layout_cache.rs @@ -141,8 +141,7 @@ impl super::UiScript { } } if changed { - // The whole graph, not the frame: a restored row may repoint an anchor. - model.touch_layout(); + model.touch_layout_frame(h); } } } diff --git a/crates/benilla-ui/src/script/loot.rs b/crates/benilla-ui/src/script/loot.rs index 82086792..9938fec4 100644 --- a/crates/benilla-ui/src/script/loot.rs +++ b/crates/benilla-ui/src/script/loot.rs @@ -134,6 +134,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { data.texture = None; data.fill = None; data.circular = true; + model.touch_layout_art(rh); Ok(Value::Integer(1)) })?, )?; diff --git a/crates/benilla-ui/src/script/messageframe/mod.rs b/crates/benilla-ui/src/script/messageframe/mod.rs index 29e575a7..3ef32b0c 100644 --- a/crates/benilla-ui/src/script/messageframe/mod.rs +++ b/crates/benilla-ui/src/script/messageframe/mod.rs @@ -193,7 +193,7 @@ pub(super) fn ensure_font_region( let mut data = crate::script::RegionData::default(); data.justify.set_h(JustifyH::Left); model.region_data.insert(rh, data); - model.touch_layout(); + model.touch_layout_region(rh); Some(rh) } diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 3692fe04..762bae1f 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -119,9 +119,10 @@ pub(crate) struct Model { /// Tier 1 of the resolve gate: every layout write bumps it, and a resolve returns at once while /// it equals `layout_epoch_resolved`. Never bump it per frame: tier 2 hashes the whole UI. pub(crate) layout_epoch: u64, - /// Layout ids named by writes since the last converged resolve, each write claiming it left the - /// cached graph's shape alone, so a resolve seeds its dirty closure from them. `None` derives - /// the graph in full; `WOW_LAYOUT_VERIFY` checks every incremental pass against a full one. + /// Layout ids named by writes since the last converged resolve: the resolve re-derives each + /// in the cached graph (born, freed, re-pointed or re-valued alike) and seeds its dirty + /// closure from them. `None` derives the graph in full; `WOW_LAYOUT_VERIFY` checks every + /// incremental pass's rects and graph against a fresh derivation. pub(crate) layout_touched: Option>, /// Regions a write may have re-keyed for text measure (text, font, outline, wrap, scale) since /// the last sweep, which asks only these; `None` walks all. A site that forgets to name its @@ -144,8 +145,9 @@ pub(crate) struct Model { /// cost, which `layout_solves` misses. Verify mode walks anyway, so assert on this only from a /// crate that depends on `benilla-ui`; inside this crate, assert on `layout_solves`. pub(crate) layout_gate_walks: u64, - /// Full layout-graph derivations: 0 on a steady UI, so a rise means a write fell back to - /// `touch_layout`. The `WOW_LAYOUT_VERIFY` re-run's derivation is not counted. + /// Full layout-graph derivations: 0 on a steady UI, births and frees included, so a rise means + /// a write named no node (`touch_layout`) or a re-derive gave up. The `WOW_LAYOUT_VERIFY` + /// re-run's derivation is not counted. pub(crate) layout_derives: u64, /// The last solve's width, `(frames solved, regions swept)`. pub(crate) layout_last_scope: (usize, usize), @@ -1500,8 +1502,22 @@ impl Model { self.next_id += 1; self.frame_to_id.insert(h, id); self.id_to_frame.insert(id, h); - // Every creation path mints here, so a new frame always opens the layout gate. - self.touch_layout(); + // Every creation path mints here, so a new frame always joins the graph. A region already + // a node on it gains its owner edge and the owner's id in its hash, so it re-derives too. + self.name_layout_node(id); + let owned: Vec = self + .arena + .frame(h) + .map(|f| { + f.regions + .iter() + .filter_map(|rh| self.region_to_id.get(rh).copied()) + .collect() + }) + .unwrap_or_default(); + for rid in owned { + self.name_layout_node(rid); + } id } @@ -1514,8 +1530,8 @@ impl Model { self.next_id += 1; self.region_to_id.insert(h, id); self.id_to_region.insert(id, h); - // A new region can join the resolve's external set, so it opens the gate too. - self.touch_layout(); + // Named, so an anchored region joins the graph; an unanchored one re-derives to nothing. + self.name_layout_node(id); id } @@ -1527,39 +1543,78 @@ impl Model { let changed = self.arena.frame(h).is_some_and(|f| f.scale != scale); self.arena.set_scale(h, scale); if changed { - self.touch_layout(); + // The effective scale cascades down the subtree, an input of each frame's node. + self.touch_layout_subtree(h); self.touch_measure_all(); } } - /// A layout write that names no node. The resolve's tooltip pre-pass must not call this, or - /// the gate never closes; a missed call is caught by `WOW_LAYOUT_VERIFY`. + /// A layout write that names no node, for what moves every node at once (the screen, every + /// measure): the next resolve derives the graph in full. The resolve's tooltip pre-pass must not + /// call this, or the gate never closes; a missed call is caught by `WOW_LAYOUT_VERIFY`. pub(crate) fn touch_layout(&mut self) { - // Naming nothing, the next resolve derives the graph in full: slow, never wrong. self.give_up_naming(); self.bump_layout_epoch(); } - /// A write that moved one region's offsets, size or measured extent, not its targets or - /// liveness; a region missing from the cached graph falls back to `touch_layout`. + /// 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. pub(crate) fn touch_layout_region(&mut self, rh: RegionHandle) { match self.region_to_id.get(&rh) { - Some(&id) => self.touch_layout_node(id), - None => self.touch_layout(), + Some(&id) => self.name_layout_node(id), + None if self + .region_data + .get(&rh) + .is_some_and(|d| !d.anchors.is_empty()) => + { + self.region_id(rh); // mints and names + } + None => self.bump_layout_epoch(), } } - /// The same for a frame: an anchor offset or size, nothing that moves an edge or the roster. + /// 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) { + let Some(d) = self.region_data.get(&rh) else { + return; + }; + if d.anchors.is_empty() || d.size.is_some_and(|(w, h)| w != 0.0 && h != 0.0) { + return; + } + if let Some(&id) = self.region_to_id.get(&rh) { + let owner = self + .arena + .region(rh) + .and_then(|r| self.frame_to_id.get(&r.owner).copied()); + if self + .layout_scope + .holds(id, super::layout::region_node_hash(d, owner)) + { + return; + } + } + self.touch_layout_region(rh); + } + + /// The same for frame `h`: its anchors, size, scale, clamp or scroll override. pub(crate) fn touch_layout_frame(&mut self, h: FrameHandle) { match self.frame_to_id.get(&h) { - Some(&id) => self.touch_layout_node(id), - None => self.touch_layout(), + Some(&id) => self.name_layout_node(id), + None if self.arena.frame(h).is_some() => { + self.frame_id(h); // mints and names + } + None => self.bump_layout_epoch(), } } - /// A `SetParent` under `root`: only the subtree's scale moves, as anchor targets are ids fixed - /// at `SetPoint`. Addon pools reparent hundreds of frames a redraw, so this stays precise. - pub(crate) fn touch_layout_reparent(&mut self, root: FrameHandle) { + /// Every frame under `root`, inclusive, for a write that moves the effective scale down a + /// subtree (`SetParent`, `SetScale`); anchor targets are ids fixed at `SetPoint`, so only the + /// scale moves. Addon pools reparent hundreds of frames a redraw, so this stays precise. + pub(crate) fn touch_layout_subtree(&mut self, root: FrameHandle) { let mut subtree = vec![root]; let mut at = 0; while at < subtree.len() { @@ -1569,53 +1624,11 @@ impl Model { subtree.extend(f.children.iter().copied()); } } - // Write the new scale here: only a full derivation syncs it, and a named node skips that. - let Model { - arena, - layout_inputs, - .. - } = self; - for &h in &subtree { - if let Some(f) = arena.frame(h) { - layout_inputs.entry(h).or_default().scale = f.effective_scale; - } - } for h in subtree { self.touch_layout_frame(h); } } - /// A write changed frame `h`'s anchor targets; `old` and `new` are its full target lists, so - /// the cached edges are re-pointed. Falls back to `touch_layout` when the scope refuses. - pub(crate) fn touch_layout_retarget_frame(&mut self, h: FrameHandle, old: &[u32], new: &[u32]) { - match self.frame_to_id.get(&h).copied() { - // Already conservative: the next resolve rebuilds every edge, so patching is wasted. - Some(id) - if self.layout_touched.is_some() && self.layout_scope.retarget(id, old, new) => - { - self.touch_layout_node(id); - } - _ => self.touch_layout(), - } - } - - /// `touch_layout_retarget_frame` for a region. - pub(crate) fn touch_layout_retarget_region( - &mut self, - rh: RegionHandle, - old: &[u32], - new: &[u32], - ) { - match self.region_to_id.get(&rh).copied() { - Some(id) - if self.layout_touched.is_some() && self.layout_scope.retarget(id, old, new) => - { - self.touch_layout_node(id); - } - _ => self.touch_layout(), - } - } - /// Names `rh` on the measure ledger for a write that may move its measure key. pub(crate) fn touch_measure(&mut self, rh: RegionHandle) { if let Some(list) = &mut self.measure_dirty { @@ -1633,7 +1646,7 @@ impl Model { pub(crate) fn note_layout_frame_write(&mut self, h: FrameHandle) { if self.layout_touched.is_some() { match self.frame_to_id.get(&h) { - Some(&id) if self.layout_scope.has_node(id) => { + Some(&id) => { self.layout_touched .as_mut() .expect("checked above") @@ -1648,7 +1661,7 @@ impl Model { pub(crate) fn note_layout_region_write(&mut self, rh: RegionHandle) { if self.layout_touched.is_some() { match self.region_to_id.get(&rh) { - Some(&id) if self.layout_scope.has_node(id) => { + Some(&id) => { self.layout_touched .as_mut() .expect("checked above") @@ -1659,14 +1672,12 @@ impl Model { } } - /// Names `id` as what this write moved, if the cached graph has it and naming is still on. - fn touch_layout_node(&mut self, id: u32) { - let in_graph = self.layout_scope.has_node(id); - match &mut self.layout_touched { - // Already conservative: a precise touch cannot un-say an imprecise one. - None => {} - Some(_) if !in_graph => self.give_up_naming(), - Some(list) => list.push(id), + /// Names layout id `id` as what this write moved, while naming is still on, whether or not + /// the cached graph holds it yet: the resolve re-derives it either way. + pub(crate) fn name_layout_node(&mut self, id: u32) { + // Already conservative: a precise touch cannot un-say an imprecise one. + if let Some(list) = &mut self.layout_touched { + list.push(id); } self.bump_layout_epoch(); } diff --git a/crates/benilla-ui/src/script/nameplate.rs b/crates/benilla-ui/src/script/nameplate.rs index c1c77bd1..1b3b2816 100644 --- a/crates/benilla-ui/src/script/nameplate.rs +++ b/crates/benilla-ui/src/script/nameplate.rs @@ -586,8 +586,12 @@ impl Plate { raid_data.anchors = vec![Anchor::new(Point::Right, plate_id, Point::Left, 0.0, 0.0)]; raid_data.size = Some((g.raid_size, g.raid_size)); - // The first lay-out adds anchor targets, so the full touch; once per geometry change. - model.touch_layout(); + // Once per geometry change: each node it wrote re-derives. + model.touch_layout_frame(frame); + model.touch_layout_frame(bar); + for rh in [border, glow, name, level, skull, raid] { + model.touch_layout_region(rh); + } model.nameplates.plates[i].laid_out = Some(g); } @@ -606,16 +610,11 @@ impl Plate { let last = p.last.clone(); let world_id = model.frame_id(world); - // Re-seated whenever it moves, as the reference does (`0x509ec0`). A moved offset keeps - // the graph's edges, so the precise touch; the first seat adds an edge, so the retarget. + // Re-seated whenever it moves, as the reference does (`0x509ec0`). if last .as_ref() .is_none_or(|l| l.top_centre != state.top_centre) { - let fresh = model - .layout_inputs - .get(&frame) - .is_none_or(|i| i.anchors.is_empty()); let input = model.layout_inputs.entry(frame).or_default(); input.anchors = vec![Anchor::new( Point::Top, @@ -624,11 +623,7 @@ impl Plate { state.top_centre.0, state.top_centre.1, )]; - if fresh { - model.touch_layout_retarget_frame(frame, &[], &[world_id]); - } else { - model.touch_layout_frame(frame); - } + model.touch_layout_frame(frame); } if last.is_none() { diff --git a/crates/benilla-ui/src/script/object.rs b/crates/benilla-ui/src/script/object.rs index a08bc94c..6e1e9a3e 100644 --- a/crates/benilla-ui/src/script/object.rs +++ b/crates/benilla-ui/src/script/object.rs @@ -24,7 +24,7 @@ mod layout_methods; pub(crate) use layout_methods::eff_scale; pub(crate) mod movable; pub(crate) mod toplevel; -pub(crate) use layout_methods::{anchor_bits_eq, anchor_retarget_is_structural}; +pub(crate) use layout_methods::anchor_bits_eq; pub(crate) use movable::{advance_move, advance_size, FrameMove, FrameSizing}; // ───────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/benilla-ui/src/script/object/events_regions.rs b/crates/benilla-ui/src/script/object/events_regions.rs index ecf96af4..37849150 100644 --- a/crates/benilla-ui/src/script/object/events_regions.rs +++ b/crates/benilla-ui/src/script/object/events_regions.rs @@ -152,7 +152,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { let changed = !d.anchors.is_empty(); d.anchors.clear(); if changed { - model.touch_layout(); + model.touch_layout_region(rh); } model.region_id(rh) }; diff --git a/crates/benilla-ui/src/script/object/frame_state.rs b/crates/benilla-ui/src/script/object/frame_state.rs index 598d7d52..951b394a 100644 --- a/crates/benilla-ui/src/script/object/frame_state.rs +++ b/crates/benilla-ui/src/script/object/frame_state.rs @@ -346,7 +346,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { model.arena.reparent_finish(h, new_parent); // A reparent moves the subtree's effective scale, a layout input and part of every // descendant FontString's measure key. - model.touch_layout_reparent(h); + model.touch_layout_subtree(h); model.touch_measure_all(); } if was_visible { @@ -676,7 +676,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { let changed = model.arena.is_clamped_to_screen(h) != clamp; model.arena.set_clamped_to_screen(h, clamp); if changed { - model.touch_layout(); + model.touch_layout_frame(h); } Ok(()) })?, diff --git a/crates/benilla-ui/src/script/object/layout_methods.rs b/crates/benilla-ui/src/script/object/layout_methods.rs index 89525a82..c930547b 100644 --- a/crates/benilla-ui/src/script/object/layout_methods.rs +++ b/crates/benilla-ui/src/script/object/layout_methods.rs @@ -36,17 +36,16 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { let h = frame_handle_of(lua, &this)?; let mut model = lua.app_data_mut::().expect("model"); // Every layout setter mutates only on a real change, so a per-frame caller setting the - // same value never reopens the layout gate. Clearing is a retarget to no targets. - let old: Option> = match model.layout_inputs.get_mut(&h) { + // same value never reopens the layout gate. + let cleared = match model.layout_inputs.get_mut(&h) { Some(input) if !input.anchors.is_empty() => { - let old = input.anchors.iter().map(|a| a.relative_to).collect(); input.anchors.clear(); - Some(old) + true } - _ => None, + _ => false, }; - if let Some(old) = old { - model.touch_layout_retarget_frame(h, &old, &[]); + if cleared { + model.touch_layout_frame(h); } Ok(()) })?; @@ -125,7 +124,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { if !same { input.anchors.clear(); input.anchors.extend_from_slice(&pair); - model.touch_layout(); + model.touch_layout_frame(h); } Ok(()) }, @@ -298,38 +297,13 @@ fn set_point(lua: &Lua, this: &Table, args: &MultiValue) -> mlua::Result<()> { .iter() .any(|a| a.point == point); if !same_at_tail { - // Target lists are collected only for a retarget: the value-only change is the per-frame - // idiom (a dragged window) and must stay allocation-free. - let structural = anchor_retarget_is_structural(&input.anchors, &new); - let old_targets: Option> = - structural.then(|| input.anchors.iter().map(|a| a.relative_to).collect()); input.anchors.retain(|a| a.point != point); input.anchors.push(new); - match old_targets { - None => model.touch_layout_frame(h), - Some(old) => { - let new_targets: Vec = model.layout_inputs[&h] - .anchors - .iter() - .map(|a| a.relative_to) - .collect(); - model.touch_layout_retarget_frame(h, &old, &new_targets); - } - } + model.touch_layout_frame(h); } Ok(()) } -/// Whether this `SetPoint` changes the node's anchor targets, mirroring the setters' retain and -/// push: value-only exactly when one existing anchor has this point and keeps its target. -pub(crate) fn anchor_retarget_is_structural(anchors: &[Anchor], new: &Anchor) -> bool { - let mut same_point = anchors.iter().filter(|a| a.point == new.point); - match (same_point.next(), same_point.next()) { - (Some(old), None) => old.relative_to != new.relative_to, - _ => true, - } -} - /// Bit-exact anchor equality, as the layout gate's fingerprint compares, so the two always agree. pub(crate) fn anchor_bits_eq(a: &Anchor, b: &Anchor) -> bool { a.point == b.point diff --git a/crates/benilla-ui/src/script/region.rs b/crates/benilla-ui/src/script/region.rs index 41ec4343..0b502e4f 100644 --- a/crates/benilla-ui/src/script/region.rs +++ b/crates/benilla-ui/src/script/region.rs @@ -4,9 +4,7 @@ use mlua::{Lua, MultiValue, Table, Value}; use super::object::anchor_args::{parse_set_point, resolve_rel_target, UNNAMED}; -use super::object::{ - anchor_bits_eq, anchor_retarget_is_structural, decode_id, id_to_lud, NamedTarget, -}; +use super::object::{anchor_bits_eq, decode_id, id_to_lud, NamedTarget}; use super::region_map::{set_shared, Side}; use super::{ Model, REG_FONTSTRING_META, REG_FONTSTRING_METHODS, REG_REGION_META, REG_REGION_METHODS, @@ -80,7 +78,7 @@ pub(super) fn link_region( ) { 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(); + model.touch_layout_region(rh); } if let Some(r) = model.arena.region_mut(rh) { r.draw_layer = layer; @@ -123,7 +121,11 @@ pub(super) fn install_into_slot( if let Some(f) = model.arena.frame_mut(owner) { set(&mut f.kind_state, incoming); } - model.touch_layout(); + // The displaced region's free named its node; the incoming one re-derives with its new owner + // and points. + if let Some(rh) = incoming { + model.touch_layout_region(rh); + } Ok(true) } @@ -175,6 +177,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); } /// Apply the font parts an XML element supplies (`font=`, ``, `outline=`), any of @@ -265,6 +268,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { data.texture = None; data.fill = None; data.circular = true; + model.touch_layout_art(rh); Ok(()) })?, )?; @@ -287,6 +291,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { data.texture = Some(path); data.circular = true; data.portrait_unit = None; + model.touch_layout_art(rh); Ok(()) })?, )?; @@ -421,7 +426,7 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> { }; 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(); + model.touch_layout_region(rh); } Ok(()) }, @@ -566,18 +571,14 @@ pub(super) fn region_type_name(model: &Model, rh: RegionHandle) -> &'static str /// wrapper raises as stale; the reference frees too, as `CSimpleButton::SetFontString 0x778d20` /// deletes the label it replaces. pub(crate) fn free_region(model: &mut Model, rh: RegionHandle) { - release_region(model, rh); - // A removed node takes its edges with it: a structural change, so the full touch. - model.touch_layout(); -} - -/// [`free_region`] without its layout touch, for a caller that frees several and touches once. -pub(crate) fn release_region(model: &mut Model, rh: RegionHandle) { model.region_data.remove(&rh); model.region_resolved.remove(&rh); if let Some(id) = model.region_to_id.remove(&rh) { model.id_to_region.remove(&id); model.region_names.retain(|_, v| *v != id); + // Its node leaves the cached graph with its edges at the next resolve, and whatever + // anchored to it re-resolves without it. + model.name_layout_node(id); } model.arena.destroy_region(rh); } @@ -618,7 +619,7 @@ pub(crate) fn implicit_creation_anchor(model: &mut Model, rh: RegionHandle) { } RegionKind::Title => return, } - model.touch_layout(); + model.touch_layout_region(rh); } /// The middle-row point a justify word selects, as the FontString post-step (`0x771480`) and the @@ -647,7 +648,7 @@ pub(crate) fn anchor_unanchored_at( return; } data.anchors = vec![Anchor::new(point, owner_id, point, 0.0, 0.0)]; - model.touch_layout(); + model.touch_layout_region(rh); } /// [`implicit_creation_anchor`] for the loader, which holds region wrappers. @@ -708,23 +709,9 @@ pub(super) fn region_set_point(lua: &Lua, this: &Table, args: &MultiValue) -> ml .iter() .any(|a| a.point == point); if !same_at_tail { - // The same targets are a value change on this node; a moved target set is a retarget. - let structural = anchor_retarget_is_structural(&data.anchors, &new); - let old_targets: Option> = - structural.then(|| data.anchors.iter().map(|a| a.relative_to).collect()); data.anchors.retain(|a| a.point != point); data.anchors.push(new); - match old_targets { - None => model.touch_layout_region(rh), - Some(old) => { - let new_targets: Vec = model.region_data[&rh] - .anchors - .iter() - .map(|a| a.relative_to) - .collect(); - model.touch_layout_retarget_region(rh, &old, &new_targets); - } - } + model.touch_layout_region(rh); } Ok(()) } diff --git a/crates/benilla-ui/src/script/region/layout.rs b/crates/benilla-ui/src/script/region/layout.rs index c9e5292a..4f1dc2a2 100644 --- a/crates/benilla-ui/src/script/region/layout.rs +++ b/crates/benilla-ui/src/script/region/layout.rs @@ -14,15 +14,14 @@ use super::{ }; /// Install the layout methods into `m`. -/// `ClearAllPoints` on a region (`0x767ed0`): a retarget onto no targets, so the cached graph -/// unlinks the edges with no re-derive. +/// `ClearAllPoints` on a region (`0x767ed0`): the region leaves the cached graph with its edges. pub(in crate::script) fn clear_points(model: &mut Model, rh: crate::widget::RegionHandle) { let d = model.region_data.entry(rh).or_default(); - let old: Option> = - (!d.anchors.is_empty()).then(|| d.anchors.iter().map(|a| a.relative_to).collect()); + // Mutates only on a real change; an unanchored region leaves the graph. + let cleared = !d.anchors.is_empty(); d.anchors.clear(); - if let Some(old) = old { - model.touch_layout_retarget_region(rh, &old, &[]); + if cleared { + model.touch_layout_region(rh); } } @@ -155,7 +154,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { if !same { data.anchors.clear(); data.anchors.extend_from_slice(&pair); - model.touch_layout(); + model.touch_layout_region(rh); } Ok(()) }, diff --git a/crates/benilla-ui/src/script/region/paint.rs b/crates/benilla-ui/src/script/region/paint.rs index 79bfa8e9..bbaadf4c 100644 --- a/crates/benilla-ui/src/script/region/paint.rs +++ b/crates/benilla-ui/src/script/region/paint.rs @@ -123,7 +123,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { let rh = region_handle_of(lua, &this)?; // Scoped so the model borrow ends before the layout touch; the path arm drops it // mid-way to call the host probe. - let (loaded, derived) = { + let loaded = { let mut model = lua.app_data_mut::().expect("model"); // SetTexture drops any portrait mask and live-unit binding. let data = model.region_data.entry(rh).or_default(); @@ -139,11 +139,6 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { if !same_path && !colour_form { data.desaturated = false; } - // An anchored region with an axis sized 0 takes that span from its art, so a - // new texture moves it; an anchorless region never resolves, so for it this - // is only a paint. - let derived = !data.anchors.is_empty() - && data.size.is_none_or(|(w, h)| w == 0.0 || h == 0.0); // Both forms write the one texture slot (`+0xcc`), a file or an 8×8 solid, so // each clears the other; neither touches the vertex colour (`+0xb8`), so a // tint outlives its art. @@ -202,15 +197,13 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { } _ => false, }; - (loaded, derived) + loaded }; - // Only the art-sized shape can move; touching the layout on every repaint would - // reopen the resolve's change gate each frame. - if derived { - lua.app_data_mut::() - .expect("model") - .touch_layout_region(rh); - } + // 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); Ok(if loaded { Value::Number(1.0) } else { @@ -490,6 +483,11 @@ impl crate::script::UiScript { /// as `CSimpleTexture::GetWidth` (`0x770720`) falls back to the texels; without one such a /// region stays where it was. pub fn set_texture_size_probe(&mut self, probe: crate::script::TextureSizeProbe) { - self.model_mut().texture_size_probe = Some(probe); + 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. + 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 031bcecc..06a28093 100644 --- a/crates/benilla-ui/src/script/scrollframe.rs +++ b/crates/benilla-ui/src/script/scrollframe.rs @@ -166,15 +166,19 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { } }; // Every call marks the range (`0x786d16`), the same child again included. - let changed = with_scroll(lua, &this, |s| { - let changed = s.child != new_child; - s.child = new_child; + let old_child = with_scroll(lua, &this, |s| { s.range_dirty = true; - changed + std::mem::replace(&mut s.child, new_child) })?; - if changed { - // The child is an input of the layout resolve, so a real change dirties it. - lua.app_data_mut::().expect("model").touch_layout(); + 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); + } } Ok(()) })?, @@ -211,7 +215,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { if !changed { return Ok(()); } - lua.app_data_mut::().expect("model").touch_layout(); + // 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); fire_scroll(lua, &this, "OnVerticalScroll", px) })?, )?; @@ -241,7 +249,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { if !changed { return Ok(()); } - lua.app_data_mut::().expect("model").touch_layout(); + let h = frame_handle_of(lua, &this)?; + lua.app_data_mut::() + .expect("model") + .touch_layout_frame(h); fire_scroll(lua, &this, "OnHorizontalScroll", px) })?, )?; diff --git a/crates/benilla-ui/src/script/simplehtml/mod.rs b/crates/benilla-ui/src/script/simplehtml/mod.rs index af8870f6..81830568 100644 --- a/crates/benilla-ui/src/script/simplehtml/mod.rs +++ b/crates/benilla-ui/src/script/simplehtml/mod.rs @@ -318,8 +318,11 @@ fn build(model: &mut Model, fh: FrameHandle, blocks: &[Block]) { } } + // Each new block joins the graph; the old ones' frees named theirs. + for &rh in &made { + model.touch_layout_region(rh); + } model.simple_html.entry(fh).or_default().blocks = made; - model.touch_layout(); } /// A block's anchor (`SetPoint` `0x767c70`): block 0 to the frame, block N to the previous text diff --git a/crates/benilla-ui/src/script/slider.rs b/crates/benilla-ui/src/script/slider.rs index d7365785..d42b2848 100644 --- a/crates/benilla-ui/src/script/slider.rs +++ b/crates/benilla-ui/src/script/slider.rs @@ -73,7 +73,7 @@ fn ensure_thumb(lua: &Lua, this: &Table, layer: Option) -> mlua::Resu ) .ok_or_else(|| mlua::Error::runtime("stale frame handle"))?; model.region_data.insert(rh, RegionData::default()); - model.touch_layout(); // a region entered the layout gate's read set + model.touch_layout_region(rh); if let Some(frame) = model.arena.frame_mut(h) { if let KindState::Slider(s) = &mut frame.kind_state { s.thumb = Some(rh); @@ -229,6 +229,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { } _ => {} } + model.touch_layout_art(rh); Ok(()) })?, )?; diff --git a/crates/benilla-ui/src/script/statusbar.rs b/crates/benilla-ui/src/script/statusbar.rs index c31a1659..247d1500 100644 --- a/crates/benilla-ui/src/script/statusbar.rs +++ b/crates/benilla-ui/src/script/statusbar.rs @@ -78,7 +78,7 @@ fn ensure_bar(lua: &Lua, this: &Table, layer: Option) -> mlua::Result ) .ok_or_else(|| mlua::Error::runtime("stale frame handle"))?; model.region_data.insert(rh, RegionData::default()); - model.touch_layout(); // a region entered the layout gate's read set + model.touch_layout_region(rh); if let Some(frame) = model.arena.frame_mut(h) { if let KindState::StatusBar(sb) = &mut frame.kind_state { sb.bar = Some(rh); @@ -228,6 +228,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { } _ => {} } + model.touch_layout_art(rh); Ok(()) })?, )?; diff --git a/crates/benilla-ui/src/script/tabard.rs b/crates/benilla-ui/src/script/tabard.rs index 9c9a3116..fa8f39a3 100644 --- a/crates/benilla-ui/src/script/tabard.rs +++ b/crates/benilla-ui/src/script/tabard.rs @@ -195,6 +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); Ok(()) } diff --git a/crates/benilla-ui/src/script/taxi.rs b/crates/benilla-ui/src/script/taxi.rs index 32f5b55a..7a372095 100644 --- a/crates/benilla-ui/src/script/taxi.rs +++ b/crates/benilla-ui/src/script/taxi.rs @@ -198,6 +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); } Ok(()) })?, diff --git a/crates/benilla-ui/src/script/tests/backdrop.rs b/crates/benilla-ui/src/script/tests/backdrop.rs index 576e4fb6..c10c04e7 100644 --- a/crates/benilla-ui/src/script/tests/backdrop.rs +++ b/crates/benilla-ui/src/script/tests/backdrop.rs @@ -839,17 +839,5 @@ fn a_teardown_re_resolves_what_anchored_to_a_piece() { incremental == full, "the dirty-scope solve matches a full one" ); - eprintln!( - "DBG {:?}", - s.eval::<(Option, Option, i64)>( - "return dep:GetLeft(), dep:GetTop(), dep:GetNumPoints()" - ) - ); - eprintln!( - "DBG2 {:?}", - s.eval::<(Option, Option)>( - "local p, r = dep:GetPoint(1) return p, r and r:GetObjectType()" - ) - ); assert!(!drawn(&s), "its anchor target is gone"); } diff --git a/crates/benilla-ui/src/script/tests/layout_gate.rs b/crates/benilla-ui/src/script/tests/layout_gate.rs index 08878298..467c3961 100644 --- a/crates/benilla-ui/src/script/tests/layout_gate.rs +++ b/crates/benilla-ui/src/script/tests/layout_gate.rs @@ -438,8 +438,8 @@ fn a_paint_only_write_leaves_tier_one_closed() { assert_eq!(solves(&s), before); } -/// A retarget patches one node's edges in the cached graph instead of re-deriving it, so the new -/// edge must land: moving the new target moves the node, and moving the old one does not. +/// A retarget re-derives one node's edges in the cached graph instead of the whole graph, so the +/// new edge must land: moving the new target moves the node, and moving the old one does not. #[test] fn a_retargeted_anchor_follows_its_new_target() { let mut s = script(); diff --git a/crates/benilla-ui/src/script/tests/layout_structure.rs b/crates/benilla-ui/src/script/tests/layout_structure.rs new file mode 100644 index 00000000..05c835e8 --- /dev/null +++ b/crates/benilla-ui/src/script/tests/layout_structure.rs @@ -0,0 +1,449 @@ +//! Structural layout writes (a node born, freed, re-owned or re-pointed) through the cached graph: +//! every resolve must equal a from-scratch solve of the same model, rects and graph alike. + +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}"); + } +} + +/// A region's owner supplies its scale, so re-owning it under a scaled frame moves its rect. +#[test] +fn a_region_reowned_under_a_scaled_frame_takes_the_new_scale() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + x = CreateFrame("Frame", "X", nil); x:SetWidth(100); x:SetHeight(100) + x:SetPoint("CENTER", nil, "CENTER", 0, 0) + a = CreateFrame("Frame", "A", nil); a:SetWidth(10); a:SetHeight(10) + a:SetPoint("TOPLEFT", nil, "TOPLEFT", 0, 0) + b = CreateFrame("Frame", "B", nil); b:SetWidth(10); b:SetHeight(10) + b:SetPoint("TOPLEFT", nil, "TOPLEFT", 0, 0); b:SetScale(2) + t = a:CreateTexture(nil, "ARTWORK"); t:SetTexture(1, 1, 1) + t:SetWidth(8); t:SetHeight(8); t:SetPoint("TOPLEFT", x, "TOPLEFT", 5, -5) + "#, + ) + .expect("setup"); + s.resolve(); + s.resolve(); + s.run("t:SetParent(b)").expect("re-own"); + resolve_matches_scratch(&mut s, "re-owned region"); +} + +/// xorshift64*, so a failing seed replays. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + self.0.wrapping_mul(0x2545_f491_4f6c_dd1d) + } + + /// In `1..=n`, as Lua indexes. + fn pick(&mut self, n: usize) -> usize { + (self.next() % n as u64) as usize + 1 + } + + fn coin(&mut self, in_n: u64) -> bool { + self.next().is_multiple_of(in_n) + } + + fn off(&mut self) -> i64 { + (self.next() % 41) as i64 - 20 + } +} + +const POINTS: [&str; 9] = [ + "TOPLEFT", + "TOP", + "TOPRIGHT", + "LEFT", + "CENTER", + "RIGHT", + "BOTTOMLEFT", + "BOTTOM", + "BOTTOMRIGHT", +]; + +/// 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` +/// 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 = {}, {}, {}, {}, {}, {}, {}, {}, {} + 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) + local seen, stack = {}, { a } + while table.getn(stack) > 0 do + local x = table.remove(stack) + if x == b then return true end + if not seen[x] then + seen[x] = true + for _, t in (deps[x] or {}) do table.insert(stack, t) end + if held[x] then table.insert(stack, held[x]) end + end + end + return false + end + -- A node `o` may anchor to: one of a few picks over the whole roster, else the root. + function target(o, k) + local n = table.getn(N) + for i = 0, 7 do + local t = N[math.mod(k * 7 + i * 13, n) + 1] + if t ~= o and not reaches(t, o) then return t end + end + return root + end + newer = 0 + function point(o, p, t, rp, x, y) + o:SetPoint(p, t, rp, x, y); deps[o][p] = t + if at[t] and at[t] > at[o] then newer = newer + 1 end + end + function clear(o) o:ClearAllPoints(); deps[o] = {} end + function all(o, t) o:SetAllPoints(t); deps[o] = { TOPLEFT = t, BOTTOMRIGHT = t } end + root = CreateFrame("Frame", "UIParent"); root:SetWidth(400); root:SetHeight(300) + root:SetPoint("CENTER", nil, "CENTER", 0, 0) + add(root); table.insert(F, root) + spec = { bgFile = "bg", edgeFile = "edge", edgeSize = 8, insets = { left = 2, right = 2, top = 2, bottom = 2 } } +"#; + +/// One write in a batch: a Lua chunk, run under `pcall` (a step may name a region another step +/// freed, which raises, as it would in the client), or the host's UI scale. +enum Write { + Lua(String), + UiScale(f32), +} + +/// One random structural or value write. +fn step(rng: &mut Rng) -> Write { + let p = POINTS[rng.pick(9) - 1]; + let rp = POINTS[rng.pick(9) - 1]; + let (x, y) = (rng.off(), rng.off()); + let k = rng.next() % 1000; + let k2 = rng.next() % 1000; + let w = if rng.coin(4) { 0 } else { rng.pick(60) }; + let h = if rng.coin(4) { 0 } else { rng.pick(60) }; + let lua = match rng.next() % 20 { + // A frame born under any frame, anchored to any node it may read. + 0 | 1 => { + let kind = if rng.coin(3) { "Button" } else { "Frame" }; + format!( + "local par = F[math.mod({k}, table.getn(F)) + 1] + local f = CreateFrame('{kind}', nil, par); add(f); table.insert(F, f) + if '{kind}' == 'Button' then table.insert(B, f) end + f:SetWidth({w}); f:SetHeight({h}) + point(f, '{p}', target(f, {k2}), '{rp}', {x}, {y})" + ) + } + // A texture or font string born on any frame, anchored once or twice. + 2 | 3 => { + let (make, art) = if rng.coin(3) { + ("CreateFontString", "") + } else if rng.coin(2) { + ("CreateTexture", "t:SetTexture(1, 0, 0)") + } else { + ("CreateTexture", "") + }; + let second = if rng.coin(2) { + format!("point(t, '{rp}', target(t, {}), '{p}', {y}, {x})", k2 + 7) + } else { + String::new() + }; + format!( + "local t = F[math.mod({k}, table.getn(F)) + 1]:{make}(nil, 'ARTWORK') + add(t); table.insert(R, t); {art} + if {w} > 0 then t:SetWidth({w}) end; if {h} > 0 then t:SetHeight({h}) end + point(t, '{p}', target(t, {k2}), '{rp}', {x}, {y}); {second}" + ) + } + // A backdrop built or torn down: nine pieces born or freed at once. + 4 => format!( + "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. + 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" + ), + // A button's slot region set, or freed by a nil; or two freed in one pass. + 6 => format!( + "local n = table.getn(B) + if n > 0 then + local b = B[math.mod({k}, n) + 1] + if b:GetNormalTexture() and {k2} < 400 then b:SetNormalTexture(nil) + elseif {k2} < 700 then + b:SetNormalTexture(nil); B[math.mod({k2}, n) + 1]:SetNormalTexture(nil) + else b:SetNormalTexture('slot' .. {k2}) end + end" + ), + // A frame re-parented, its subtree's scale with it. + 7 => format!( + "local f = F[math.mod({k}, table.getn(F)) + 1] + local par = F[math.mod({k2}, table.getn(F)) + 1] + if f ~= root and par ~= f then pcall(f.SetParent, f, par) end" + ), + // A frame's own scale, the root's included: a whole-UI subtree. + 8 => format!( + "F[math.mod({k}, table.getn(F)) + 1]:SetScale({})", + [0.5, 0.75, 1.0, 1.25, 2.0][(k2 % 5) as usize] + ), + // A region re-owned, and re-anchored in the same pass. + 9 => format!( + "if table.getn(R) > 0 then + local t = R[math.mod({k}, table.getn(R)) + 1] + t:SetParent(F[math.mod({k2}, table.getn(F)) + 1]) + if {k} < 500 then point(t, '{p}', target(t, {k2}), '{rp}', {x}, {y}) end + end" + ), + // An anchor re-pointed, cleared or set to all points, on a frame or a region. + 10 | 11 => { + let verb = match k % 4 { + 0 => "clear(o)".to_string(), + 1 => format!("all(o, target(o, {k2}))"), + _ => format!("point(o, '{p}', target(o, {k2}), '{rp}', {x}, {y})"), + }; + format!( + "local o = N[math.mod({k2}, table.getn(N)) + 1] + if o ~= root then {verb} end" + ) + } + // A node leaving the graph and rejoining it in one pass. + 12 => format!( + "local o = N[math.mod({k}, table.getn(N)) + 1] + if o ~= root then clear(o); point(o, '{p}', target(o, {k2}), '{rp}', {x}, {y}) end" + ), + // A size. + 13 => format!( + "local o = N[math.mod({k}, table.getn(N)) + 1] + if o ~= root then o:SetWidth({w}); o:SetHeight({h}) end" + ), + // A ScrollFrame born, or one taking a frame it does not read as its child, or scrolling. + 14 | 15 => format!( + "if {k} < 300 or table.getn(S) == 0 then + local par = F[math.mod({k2}, table.getn(F)) + 1] + local sf = CreateFrame('ScrollFrame', nil, par); add(sf); table.insert(F, sf) + table.insert(S, sf); sf:SetWidth(50); sf:SetHeight(50) + point(sf, '{p}', target(sf, {k}), '{rp}', {x}, {y}) + else + local sf = S[math.mod({k2}, table.getn(S)) + 1] + if {k} < 650 then + local n = table.getn(F) + for i = 0, n - 1 do + local c = F[math.mod({k} + i, n) + 1] + if c ~= root and c ~= sf and not owned[c] and not reaches(sf, c) then + local old = sf:GetScrollChild() + if old then owned[old] = nil; held[old] = nil end + sf:SetScrollChild(c); owned[c] = sf; held[c] = sf + break + end + end + elseif {k} < 800 then sf:SetVerticalScroll({x}) + else sf:SetHorizontalScroll({y}) end + end" + ), + // Clamped to the screen. + 16 => format!( + "F[math.mod({k}, table.getn(F)) + 1]:SetClampedToScreen({})", + k2.is_multiple_of(2) + ), + // Art on a region whose size may take it. + 17 => format!( + "if table.getn(R) > 0 then + local t = R[math.mod({k}, table.getn(R)) + 1] + if t.SetTexture then if {k2} < 500 then t:SetTexture(0, 1, 0) else t:SetTexture(nil) end end + end" + ), + // The host's UI scale, `UIParent`'s whole subtree. + 18 => return Write::UiScale([0.64, 0.8, 1.0][(k % 3) as usize]), + // Two backdrops torn down or built in one pass. + _ => format!( + "local n = table.getn(F) + for _, f in {{ F[math.mod({k}, n) + 1], F[math.mod({k2}, n) + 1] }} do + if f:GetBackdrop() then f:SetBackdrop(nil) else f:SetBackdrop(spec) end + end" + ), + }; + Write::Lua(lua) +} + +/// A random run of births, frees, re-parents, re-owns, retargets, scrolls and scale changes, in +/// batches of one to four writes per resolve: after every resolve the cached graph and its rects +/// equal a from-scratch solve, and none of them derived the graph. +#[test] +fn random_structural_writes_resolve_as_a_scratch_solve_would() { + for seed in [ + 0x9e37_79b9_7f4a_7c15u64, + 0xdead_beef_cafe_f00d, + 0x0123_4567_89ab_cdef, + 0x5555_aaaa_1234_9876, + ] { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run(SETUP).expect("setup"); + s.run("raised = 0").expect("counter"); + s.resolve(); + let derived_at = s.layout_derivations(); + let mut rng = Rng(seed); + let mut writes = 0; + for n in 0..300 { + let batch = rng.pick(4); + let mut log = String::new(); + for _ in 0..batch { + writes += 1; + match step(&mut rng) { + Write::Lua(chunk) => { + s.run(&format!( + "if not pcall(function() {chunk} end) then raised = raised + 1 end" + )) + .unwrap_or_else(|e| panic!("seed {seed:#x} batch {n}: {e}\n{chunk}")); + log.push_str(&chunk); + } + Write::UiScale(v) => { + s.set_ui_scale(v); + log.push_str(&format!("set_ui_scale({v})")); + } + } + log.push('\n'); + } + resolve_matches_scratch(&mut s, &format!("seed {seed:#x} batch {n}:\n{log}")); + } + let (nodes, raised, frames, regions, newer) = s + .eval::<(i64, i64, i64, i64, i64)>( + "return table.getn(N), raised, table.getn(F), table.getn(R), newer", + ) + .expect("count"); + eprintln!( + "seed {seed:#x}: {writes} writes, {nodes} nodes ({frames} frames, {regions} \ + regions), {raised} raised, {newer} anchors to a newer node" + ); + assert!( + nodes > 100, + "seed {seed:#x}: the run built only {nodes} nodes" + ); + assert!( + newer > 20, + "seed {seed:#x}: only {newer} anchors pointed at a newer node" + ); + assert!( + raised * 10 < writes, + "seed {seed:#x}: {raised} of {writes} writes raised" + ); + assert_eq!( + s.layout_derivations(), + derived_at, + "seed {seed:#x}: a structural write derived the graph instead of naming its nodes" + ); + } +} + +/// 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. +#[test] +fn a_child_two_scrollframes_claim_falls_back_to_the_derivation() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + a = CreateFrame("ScrollFrame", "SA"); a:SetWidth(50); a:SetHeight(50) + a:SetPoint("TOPLEFT", nil, "TOPLEFT", 10, -10) + b = CreateFrame("ScrollFrame", "SB"); b:SetWidth(50); b:SetHeight(50) + b:SetPoint("TOPLEFT", nil, "TOPLEFT", 200, -10) + c = CreateFrame("Frame", "C"); c:SetWidth(20); c:SetHeight(20) + c:SetPoint("CENTER", nil, "CENTER", 0, 0) + "#, + ) + .expect("setup"); + s.resolve(); + let derived = s.layout_derivations(); + 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" + ); +} + +/// A frame whose id is minted after a region on it joined the graph (the engine builds some +/// frames before any id, as the nameplate pool does): the mint re-derives the region, which then +/// reads the owner's scale. +#[test] +fn an_owner_minted_after_its_region_joined_takes_the_owner_edge() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#"root = CreateFrame("Frame", "Root"); root:SetWidth(100); root:SetHeight(100) + root:SetPoint("CENTER", nil, "CENTER", 0, 0)"#, + ) + .expect("setup"); + s.resolve(); + let (host, rh) = { + let mut m = s.model_mut(); + let root = m.arena.lookup("Root").expect("root"); + let root_id = m.frame_id(root); + let host = m + .arena + .create(crate::widget::FrameKind::Frame, None, Some(root)); + let rh = m + .arena + .create_region( + host, + crate::widget::RegionKind::Texture, + crate::order::DrawLayer::Artwork, + 0, + ) + .expect("region"); + let d = m.region_data.entry(rh).or_default(); + d.size = Some((8.0, 8.0)); + d.anchors = vec![crate::layout::Anchor::new( + crate::layout::Point::TopLeft, + root_id, + crate::layout::Point::TopLeft, + 5.0, + -5.0, + )]; + m.touch_layout_region(rh); + (host, rh) + }; + resolve_matches_scratch(&mut s, "a region on an id-less owner"); + { + let mut m = s.model_mut(); + assert!( + !m.frame_to_id.contains_key(&host), + "the owner has no id yet" + ); + m.set_frame_scale(host, 2.0); + m.frame_id(host); + } + resolve_matches_scratch(&mut s, "the owner's id minted"); + let r = s + .model_ref() + .region_resolved + .get(&rh) + .copied() + .expect("rect"); + assert!( + ((r.right - r.left) - 16.0).abs() < 1e-3, + "the region takes its now-known owner's scale: {r:?}" + ); +} diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs index b5d0915b..83c6bd0b 100644 --- a/crates/benilla-ui/src/script/tests/mod.rs +++ b/crates/benilla-ui/src/script/tests/mod.rs @@ -26,6 +26,7 @@ mod host_state; mod input; mod keyboard; mod layout_gate; +mod layout_structure; mod measure; mod minimap; mod model_clock; diff --git a/crates/benilla-ui/src/script/tooltip/mod.rs b/crates/benilla-ui/src/script/tooltip/mod.rs index ba430a57..1fcce159 100644 --- a/crates/benilla-ui/src/script/tooltip/mod.rs +++ b/crates/benilla-ui/src/script/tooltip/mod.rs @@ -189,7 +189,7 @@ 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(); // a line row entered the layout graph + model.touch_layout_region(left); // a line row entered the layout graph } let left_id = model.region_id(left); @@ -217,7 +217,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> { d.anchors = vec![Anchor::new(Point::Right, left_id, Point::Right, 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(); // a line row entered the layout graph + model.touch_layout_region(right); // a line row entered the layout graph } let right_id = model.region_id(right); @@ -447,16 +447,16 @@ fn apply_anchor( TooltipAnchor::BottomLeft => Some((Point::TopRight, Point::BottomLeft)), TooltipAnchor::Cursor | TooltipAnchor::None | TooltipAnchor::Preserve => None, }; - // The drop is a named retarget to no targets, not a conservative touch. if matches!(anchor, TooltipAnchor::Cursor | TooltipAnchor::None) { - let dropped: Option> = match model.layout_inputs.get_mut(&h) { + let dropped = match model.layout_inputs.get_mut(&h) { Some(input) if !input.anchors.is_empty() => { - Some(input.anchors.drain(..).map(|a| a.relative_to).collect()) + input.anchors.clear(); + true } - _ => None, + _ => false, }; - if let Some(old_targets) = dropped { - model.touch_layout_retarget_frame(h, &old_targets, &[]); + if dropped { + model.touch_layout_frame(h); } } if let Some((own, rel)) = pts { @@ -467,11 +467,8 @@ fn apply_anchor( let same = input.anchors.len() == 1 && crate::script::object::anchor_bits_eq(&input.anchors[0], &new); if !same { - // A named retarget: the cached layout graph re-points this edge instead of being - // rebuilt. - let old_targets: Vec = input.anchors.iter().map(|a| a.relative_to).collect(); input.anchors = vec![new]; - model.touch_layout_retarget_frame(h, &old_targets, &[owner_id]); + model.touch_layout_frame(h); } } } @@ -716,9 +713,8 @@ fn cursor_anchor(model: &mut Model, h: FrameHandle) { if same { return; } - let old_targets: Vec = input.anchors.iter().map(|a| a.relative_to).collect(); input.anchors = vec![new]; - model.touch_layout_retarget_frame(h, &old_targets, &[super::SCREEN]); + model.touch_layout_frame(h); } pub(super) fn layout_tooltips(model: &mut Model) { diff --git a/crates/benilla-ui/src/script/tooltip/verbs.rs b/crates/benilla-ui/src/script/tooltip/verbs.rs index 1432935e..59fccabd 100644 --- a/crates/benilla-ui/src/script/tooltip/verbs.rs +++ b/crates/benilla-ui/src/script/tooltip/verbs.rs @@ -241,7 +241,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { d.justify.set_h(crate::script::JustifyH::Right); d.anchors = vec![Anchor::new(Point::Right, left_id, Point::Right, 0.0, 0.0)]; } - model.touch_layout(); // two line rows entered the layout graph + model.touch_layout_region(lh); // two line rows entered the layout graph + model.touch_layout_region(rh); let t = tip_mut(&mut model, h)?; t.left_lines.push(lh); t.right_lines.push(rh);