From 7158c58575799112edd6140b1adafdbaede2ab8d Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:06:04 +0700 Subject: [PATCH] Hidden frames defer their layout; GetWidth answers the set size (#973) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hidden frame resolved and fired `OnSizeChanged` at once, a getter's resize fired at the drain, and `GetWidth`/`GetHeight` answered the rect over scale. In 1.12.1 hiding sets the deferral flag `0x2` (`0x76ad50`); while set, an invalidate marks the frame pending and passes it to its dependents without a resolve (`0x768178`). Show resolves it first (`0x76ae58`), a getter resolves a pending one and fires `OnSizeChanged` inside the read (`0x7a1980`, `0x76b580`), and an anchor read pulls it for good (`0x7a2fc0`). `GetWidth` answers the set size when non-zero, else the rect span, unscaled (`0x7a1e93`, `0x7a1ee6`). The deferral is modelled for frames and regions, getters fire inside the read, layout ids mint in handle order (deterministic), and the options window measures its scroll by its edges. Tests bite on the parent; the verifier is green; stock quads match. Fixes #948 Fixes #971 ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/assets/ui/OptionsFrame.xml | 14 +- .../benilla-app/src/ui_script/chat_tests.rs | 82 +++ .../src/ui_script/options_tests.rs | 23 +- .../src/ui_script/resolve_bench.rs | 97 +++ .../src/ui_script/ui_scale_tests.rs | 5 + crates/benilla-ui/src/layout.rs | 250 +++++++- crates/benilla-ui/src/loader/geometry.rs | 2 +- crates/benilla-ui/src/script/event.rs | 27 +- crates/benilla-ui/src/script/hyperlink.rs | 2 +- crates/benilla-ui/src/script/layout.rs | 549 +++++++++++++---- crates/benilla-ui/src/script/measure.rs | 2 +- crates/benilla-ui/src/script/mod.rs | 4 +- crates/benilla-ui/src/script/model.rs | 472 ++++++++++++++- crates/benilla-ui/src/script/nameplate.rs | 22 +- crates/benilla-ui/src/script/object.rs | 6 +- .../src/script/object/frame_state.rs | 2 +- .../src/script/object/layout_methods.rs | 79 ++- crates/benilla-ui/src/script/region.rs | 40 +- crates/benilla-ui/src/script/region/layout.rs | 35 +- crates/benilla-ui/src/script/region/paint.rs | 4 +- crates/benilla-ui/src/script/tests/anchors.rs | 95 +++ .../src/script/tests/hidden_defer.rs | 568 ++++++++++++++++++ crates/benilla-ui/src/script/tests/mod.rs | 1 + crates/benilla-ui/src/script/tests/tooltip.rs | 2 +- crates/benilla-ui/src/script/tooltip/mod.rs | 14 +- crates/benilla-ui/src/script/tooltip/verbs.rs | 3 + crates/benilla-ui/src/script/visibility.rs | 72 ++- crates/benilla-ui/src/widget/mod.rs | 27 +- crates/benilla-ui/src/widget/propagation.rs | 26 +- 29 files changed, 2304 insertions(+), 221 deletions(-) create mode 100644 crates/benilla-ui/src/script/tests/hidden_defer.rs diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index a5a03e77..f35b0f60 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -263,10 +263,20 @@ return bottom end + -- The scroll frame's size in its own units, from its edges: its anchors size it, and + -- GetWidth answers such a frame's span before the window's scale divides it. + local function BenillaOptionsScroll_Size(sf) + local l, r, t, b = sf:GetLeft(), sf:GetRight(), sf:GetTop(), sf:GetBottom() + if not l or not r or not t or not b then return nil end + return r - l, t - b + end + -- The page's range is the body's overhang, the content Fit measures: the engine's range -- also counts the Keybindings list's faux scroll child, which is sized to every row. function BenillaOptionsScroll_Range(sf) - local range = BenillaOptionsFrameContainerBody:GetHeight() - sf:GetHeight() + local _, fh = BenillaOptionsScroll_Size(sf) + if not fh then return 0 end + local range = BenillaOptionsFrameContainerBody:GetHeight() - fh if range > 0 then return range end return 0 end @@ -275,7 +285,7 @@ function BenillaOptionsScroll_Fit() local sf = BenillaOptionsFrameContainerScroll local body = BenillaOptionsFrameContainerBody - local fw, fh = sf:GetWidth(), sf:GetHeight() + local fw, fh = BenillaOptionsScroll_Size(sf) if not fw or fw <= 0 or not fh or fh <= 0 then return end if math.abs(body:GetWidth() - fw) >= 0.5 then body:SetWidth(fw) end local h = fh diff --git a/crates/benilla-app/src/ui_script/chat_tests.rs b/crates/benilla-app/src/ui_script/chat_tests.rs index 6da1b88b..394f910c 100644 --- a/crates/benilla-app/src/ui_script/chat_tests.rs +++ b/crates/benilla-app/src/ui_script/chat_tests.rs @@ -1259,3 +1259,85 @@ fn the_stock_afk_and_dnd_commands_reach_the_wire() { "a real ChatTypeInfo key that is nonetheless not sendable stays None" ); } + +/// A session's dock on the whole shipped UI: the login's events, the Combat Log selected and +/// deselected, and the dock moved by the bars `UIParent_ManageFramePositions` places, with +/// `FCF_OnUpdate` running every frame. The docked tabs sit on one row on ChatFrame1's background +/// throughout. The hidden Combat Log's layout waits while it is hidden, and the dock's own reads +/// (`MouseIsOver`'s getters in `FCF_OnUpdate`) resolve it each frame (`0x7a1980`), so its tab +/// follows the dock. +#[test] +fn the_docked_tabs_follow_the_dock_through_a_session() { + benilla_formats::wow_data_or_skip!(); + let mut s = UiScript::new().unwrap(); + s.set_screen_size(768.0 * 16.0 / 9.0, 768.0); + s.set_texture_size_probe(Box::new(|_| Some((32, 32)))); + s.set_text_measurer(Box::new(super::FixedWidthFont(6.0))); + let failures = super::load_default_ui(&s); + assert!(failures.is_empty(), "{failures:?}"); + super::lifecycle::finish_ui_load_with(&mut s, |_| {}, super::fire_chat_login); + s.fire_world_enter(); + let frames = |s: &mut UiScript, n: usize| { + for _ in 0..n { + s.tick(0.016); + s.resolve(); + } + }; + let check = |s: &mut UiScript, step: &str| { + // Hover the dock, so the tabs show (`FCF_OnUpdate`). + let (x, y): (f32, f32) = s + .eval( + "return (ChatFrame1:GetLeft() + ChatFrame1:GetRight()) / 2, \ + (ChatFrame1:GetBottom() + ChatFrame1:GetTop()) / 2", + ) + .unwrap(); + s.mouse_move(x, y); + frames(s, 45); + let row: f32 = s.eval("return ChatFrame1Background:GetTop()").unwrap(); + let quads = s.extract(); + for tab in ["ChatFrame1Tab", "ChatFrame2Tab"] { + let q = quads + .iter() + .find(|q| { + matches!(q.content, QuadContent::Frame) + && s.target_owner_name(q.target).as_deref() == Some(tab) + }) + .unwrap_or_else(|| panic!("{step}: {tab} draws")); + let bottom = q.rect.expect("a placed tab").bottom; + assert!( + (bottom - row).abs() < 0.01, + "{step}: {tab} sits at {bottom}, the dock's row is {row}" + ); + } + }; + let click = |s: &mut UiScript, tab: &str| { + s.run(&format!( + "this = {tab} FCF_Tab_OnClick('LeftButton') this = nil" + )) + .unwrap(); + frames(s, 5); + }; + frames(&mut s, 30); + check(&mut s, "login"); + click(&mut s, "ChatFrame2Tab"); + check(&mut s, "Combat Log selected"); + click(&mut s, "ChatFrame1Tab"); + check(&mut s, "General selected"); + s.run("SHOW_MULTI_ACTIONBAR_1 = 1 MultiActionBar_Update() UIParent_ManageFramePositions()") + .unwrap(); + frames(&mut s, 5); + check(&mut s, "bottom-left bar"); + s.run("PetActionBarFrame:Show() UIParent_ManageFramePositions()") + .unwrap(); + frames(&mut s, 5); + check(&mut s, "pet bar"); + click(&mut s, "ChatFrame2Tab"); + check(&mut s, "Combat Log selected over the pet bar"); + s.run("PetActionBarFrame:Hide() UIParent_ManageFramePositions()") + .unwrap(); + frames(&mut s, 5); + check(&mut s, "pet bar gone"); + click(&mut s, "ChatFrame1Tab"); + check(&mut s, "General selected again"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index 4d0cd37b..c8f9458e 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -1907,16 +1907,23 @@ fn a_broad_search_scrolls_the_page_instead_of_overflowing_it() { "{f} stays away while the page fits" ); } - let page_h: f32 = s - .eval("return BenillaOptionsFrameContainerScroll:GetHeight()") + // The page rect in the window's units, from its edges: the scroll is sized by its anchors, + // so its `GetWidth`/`GetHeight` answer the span before the window's scale divides it. + let (page_w, page_h): (f32, f32) = s + .eval( + "local sf = BenillaOptionsFrameContainerScroll \ + return sf:GetRight() - sf:GetLeft(), sf:GetTop() - sf:GetBottom()", + ) + .unwrap(); + let (body_w, body_h): (f32, f32) = s + .eval( + "return BenillaOptionsFrameContainerBody:GetWidth(), \ + BenillaOptionsFrameContainerBody:GetHeight()", + ) .unwrap(); assert!( - (s.eval::("return BenillaOptionsFrameContainerBody:GetHeight()") - .unwrap() - - page_h) - .abs() - < 0.5, - "the body is the page rect when nothing overflows" + (body_w - page_w).abs() < 0.5 && (body_h - page_h).abs() < 0.5, + "the body is the page rect when nothing overflows: {body_w}x{body_h} vs {page_w}x{page_h}" ); s.run("BenillaOptionsFrameSearchBox:SetText(\"e\")") diff --git a/crates/benilla-app/src/ui_script/resolve_bench.rs b/crates/benilla-app/src/ui_script/resolve_bench.rs index acc60ff1..42c31391 100644 --- a/crates/benilla-app/src/ui_script/resolve_bench.rs +++ b/crates/benilla-app/src/ui_script/resolve_bench.rs @@ -140,6 +140,103 @@ fn tooltip_change_costs_a_whole_ui_solve() { ); } +/// What hidden frames cost on the shipped UI, which keeps most of its frames hidden: the load and +/// settle, a stock panel's first and later shows, and layout writes to hidden frames every frame. +#[test] +#[ignore = "bench, run explicitly"] +fn hidden_frames_cost() { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1600.0, 900.0); + s.set_unit( + "player", + Some(benilla_ui::script::UnitState { + exists: true, + name: Some("Probefour".into()), + level: 60, + ..Default::default() + }), + ); + let t = Instant::now(); + super::load_default_ui(&s); + let load = ms(t); + let t = Instant::now(); + for _ in 0..12 { + s.resolve(); + if answer_measures(&mut s) == 0 { + break; + } + } + println!( + "BOOT load {load:.1} ms settle {:.2} ms solves={} rounds={}", + ms(t), + s.layout_solves(), + s.layout_rounds() + ); + + let quiet = |s: &mut UiScript| { + for _ in 0..4 { + app_frame(s); + } + }; + quiet(&mut s); + for panel in [ + "CharacterFrame", + "SpellBookFrame", + "QuestLogFrame", + "FriendsFrame", + "GameMenuFrame", + "OptionsFrame", + ] { + let t = Instant::now(); + s.run(&format!("{panel}:Show()")).unwrap(); + app_frame(&mut s); + let first = ms(t); + s.run(&format!("{panel}:Hide()")).unwrap(); + app_frame(&mut s); + quiet(&mut s); + let n = 20u32; + let t = Instant::now(); + for _ in 0..n { + s.run(&format!("{panel}:Show()")).unwrap(); + app_frame(&mut s); + s.run(&format!("{panel}:Hide()")).unwrap(); + app_frame(&mut s); + } + println!( + "SHOW {panel:<15} first {first:.3} ms later {:.3} ms/show+hide", + ms(t) / f64::from(n) + ); + } + + // A layout write to each of the spell book's hidden buttons, every frame. + s.run( + r#" + bench_hidden_n = 0 + function bench_hidden_frame() + bench_hidden_n = bench_hidden_n + 1 + for i = 1, 12 do + getglobal("SpellButton" .. i):SetWidth(30 + math.mod(bench_hidden_n, 2)) + end + end + "#, + ) + .unwrap(); + quiet(&mut s); + let (s0, r0) = (s.layout_solves(), s.layout_rounds()); + let n = 100u32; + let t = Instant::now(); + for _ in 0..n { + s.run("bench_hidden_frame()").unwrap(); + app_frame(&mut s); + } + println!( + "HIDDEN {:.3} ms/frame solves/frame={:.2} rounds/frame={:.2}", + ms(t) / f64::from(n), + (s.layout_solves() - s0) as f64 / f64::from(n), + (s.layout_rounds() - r0) as f64 / f64::from(n), + ); +} + /// Measuring before resolving makes a tooltip content change cost one layout solve; measured /// after, the answers would force a second whole-UI solve. #[test] diff --git a/crates/benilla-app/src/ui_script/ui_scale_tests.rs b/crates/benilla-app/src/ui_script/ui_scale_tests.rs index bb8358cf..79cfd095 100644 --- a/crates/benilla-app/src/ui_script/ui_scale_tests.rs +++ b/crates/benilla-app/src/ui_script/ui_scale_tests.rs @@ -191,6 +191,11 @@ fn the_stock_interface_inside_uiparent_is_unmoved_by_where_the_scale_lives() { "#, ) .unwrap(); + // A frame of the session: a hidden frame pending its resolve passes nothing on (`0x76814b`), + // and the stock `FCF_OnUpdate`'s `MouseIsOver` getters are what resolve the hidden Combat Log + // every frame (`0x7a1980`), which the tabs anchored through it read. + script.tick(0.016); + script.resolve(); let _ = script.take_errors(); // The old home: a root `1/S` larger, `UIParent` at scale 1. diff --git a/crates/benilla-ui/src/layout.rs b/crates/benilla-ui/src/layout.rs index 90284ccb..df7c623d 100644 --- a/crates/benilla-ui/src/layout.rs +++ b/crates/benilla-ui/src/layout.rs @@ -284,6 +284,16 @@ impl Anchors { self.iter().next() } + /// The targets of the anchors in `reads`, a bit per point id, in point order. + #[inline] + pub fn read_targets(&self, reads: u16) -> impl Iterator + '_ { + self.0 + .iter() + .enumerate() + .filter(move |(id, _)| reads & (1 << id) != 0) + .filter_map(|(_, a)| a.map(|a| a.relative_to)) + } + /// Slot-for-slot [`Anchor::bits_eq`]. pub fn bits_eq(&self, other: &Anchors) -> bool { self.0.iter().zip(&other.0).all(|(a, b)| match (a, b) { @@ -322,7 +332,7 @@ impl<'a> IntoIterator for &'a Anchors { /// A frame's layout input, the geometry fields the client's resolver reads: `anchorPoints[9]` /// (`G+0x4`), `width` (`G+0x50`), `height` (`G+0x54`), `layoutScale` (`G+0x58`) and the clamp flag -/// (bit 4 of `G+0x3c`). +/// (mask `0x10` of `G+0x3c`, `0x768ccd`). #[derive(Clone, Debug, PartialEq)] pub struct LayoutInput { pub anchors: Anchors, @@ -481,7 +491,7 @@ pub struct AssembleRect { pub rect: [f32; 4], } -/// `assemble` (`0x767a20`): fails if any edge is `+Inf`, else with `clamp` (flags bit 4) shifts +/// `assemble` (`0x767a20`): fails if any edge is `+Inf`, else with `clamp` (flags `0x10`) shifts /// the rect into `[0, extent]` on each axis, low edge first, keeping its size. #[allow(clippy::float_cmp)] pub fn assemble_rect(edges: [f32; 4], clamp: bool, extent_x: f32, extent_y: f32) -> AssembleRect { @@ -561,6 +571,9 @@ struct EdgeInput { clamp: bool, extent_x: f32, extent_y: f32, + /// The point ids whose anchor a scan read, a bit each: the anchors whose `ResolveX` or + /// `ResolveY` ran, each of which resolves a deferred target first (`0x7a2fb8`, `0x7a3098`). + reads: std::cell::Cell, } // The recursion-guard bits of the client's `G+0x28`. @@ -584,6 +597,7 @@ const IDS_YC: [usize; 3] = [3, 4, 5]; fn anchor_scan_x(ids: &[usize; 3], f: &EdgeInput) -> f64 { for &id in ids { if let Some(a) = f.anchors[id] { + f.reads.set(f.reads.get() | 1 << id); let v = anchor_resolve_x(f.scale, a.x_off, a.rel_point, a.rel_rect, a.mirror); if v != UNSET { return v; @@ -598,6 +612,7 @@ fn anchor_scan_x(ids: &[usize; 3], f: &EdgeInput) -> f64 { fn anchor_scan_y(ids: &[usize; 3], f: &EdgeInput) -> f64 { for &id in ids { if let Some(a) = f.anchors[id] { + f.reads.set(f.reads.get() | 1 << id); let v = anchor_resolve_y(f.scale, a.y_off, a.rel_point, a.rel_rect, a.mirror); if v != UNSET { return v; @@ -766,6 +781,18 @@ pub fn resolve_rect( resolve_edges(&build_edge_input(frame, target_rect)) } +/// [`resolve_rect`] with the anchors its scans read, a bit per point id ([`Anchors::read_targets`]). +/// A scan stops at the first anchor that resolves (`0x7671a0`), so an anchor behind it is read only +/// if another edge's scan reaches it. +pub fn resolve_rect_reads( + frame: &LayoutInput, + target_rect: impl FnMut(Handle) -> Option, +) -> (ResolveOutcome, u16) { + let input = build_edge_input(frame, target_rect); + let outcome = resolve_edges(&input); + (outcome, input.reads.get()) +} + fn build_edge_input( frame: &LayoutInput, mut target_rect: impl FnMut(Handle) -> Option, @@ -789,16 +816,25 @@ fn build_edge_input( clamp: frame.clamp, extent_x: frame.extent_x, extent_y: frame.extent_y, + reads: std::cell::Cell::new(0), } } /// Resolve one frame's edges, `[bottom, left, top, right]`, each `None` where it stayed unset; /// `assemble` and its clamp are not applied. The region pass reads it axis by axis. -#[allow(clippy::float_cmp)] pub fn resolve_rect_edges( frame: &LayoutInput, target_rect: impl FnMut(Handle) -> Option, ) -> [Option; 4] { + resolve_rect_edges_reads(frame, target_rect).0 +} + +/// [`resolve_rect_edges`] with the anchors its scans read, as [`resolve_rect_reads`]. +#[allow(clippy::float_cmp)] +pub fn resolve_rect_edges_reads( + frame: &LayoutInput, + target_rect: impl FnMut(Handle) -> Option, +) -> ([Option; 4], u16) { let input = build_edge_input(frame, target_rect); let mut guard = 0u32; let left = resolve_left(&input, &mut guard); @@ -806,7 +842,10 @@ pub fn resolve_rect_edges( let right = resolve_right(&input, &mut guard); let top = resolve_top(&input, &mut guard); let cvt = |v: f64| if v == UNSET { None } else { Some(v as f32) }; - [cvt(bottom), cvt(left), cvt(top), cvt(right)] + ( + [cvt(bottom), cvt(left), cvt(top), cvt(right)], + input.reads.get(), + ) } // โ”€โ”€ Graph driver โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -831,6 +870,23 @@ pub struct LayoutSolver { has_entry: Vec, /// Per frame, whether it was invalidated (`0x7680e0`) since its entry state. pending: Vec, + /// Per node, its deferral flag for this solve (`0x2` of `G+0x3c`, [`set_deferral`]): it keeps + /// its entry state, and an invalidate that reaches it passes on instead. + /// + /// [`set_deferral`]: Self::set_deferral + deferred: Vec, + /// Per node, pending under its deferral at entry (`0x4` with `0x2`): an invalidate reaching it + /// only writes the drain's retry counter, `+0x38 = 6` (`0x76814b`โ€“`0x768166`). + held: Vec, + /// Per node, whether its invalidate passes to what anchors to it in this solve, each of which + /// is then reached (`0x768178`โ€“`0x76819c`). + passes: Vec, + /// The deferred nodes this solve reached, now pending and passing on. + newly_held: Vec, + /// `(reader, target)` for each anchor a resolving node read whose target is deferred: the read + /// resolves the target first (`0x7a2fb8` โ†’ `0x768060(0)`), which the caller does in its next + /// round. + deferred_reads: Vec<(Handle, Handle)>, /// The keep test's threshold in the rects' units; 0 keeps nothing. keep_eps: f64, /// Kahn in-degree and reverse edges; each inner `Vec` is cleared, never freed. @@ -862,6 +918,9 @@ impl LayoutSolver { self.cached.resize(need, None); self.has_entry.resize(need, false); self.pending.resize(need, false); + self.deferred.resize(need, false); + self.held.resize(need, false); + self.passes.resize(need, false); self.indeg.resize(need, 0); self.dependents.resize_with(need, Vec::new); self.marked.resize(need, false); @@ -887,6 +946,9 @@ impl LayoutSolver { self.cached[i] = None; self.has_entry[i] = false; self.pending[i] = false; + self.deferred[i] = false; + self.held[i] = false; + self.passes[i] = false; self.indeg[i] = 0; self.dependents[i].clear(); self.marked[i] = false; @@ -898,6 +960,8 @@ impl LayoutSolver { self.order.clear(); self.cycle.clear(); self.unresolvable.clear(); + self.newly_held.clear(); + self.deferred_reads.clear(); } /// Seed a rect this solve does not compute, such as the screen root (`CSimpleTop`); also @@ -937,6 +1001,61 @@ impl LayoutSolver { self.pending[i] = invalidated; } + /// Node `h`'s deferral bit for this solve and whether it is pending under it, for a node this + /// solve resolves, keeps or seeds alike: a seeded one is only read, and a read of it is + /// reported in [`deferred_reads`](Self::deferred_reads). + pub fn set_deferral(&mut self, h: Handle, deferred: bool, held: bool) { + self.ensure(h); + self.touch(h); + self.deferred[h as usize] = deferred; + self.held[h as usize] = held; + } + + /// Node `h`'s invalidate passes to what anchors to it in this solve: it was invalidated under + /// its deferral (`0x768178`). + pub fn set_passes(&mut self, h: Handle) { + self.ensure(h); + self.touch(h); + self.passes[h as usize] = true; + } + + /// Whether `h` is deferred in this solve. + #[inline] + pub fn deferred(&self, h: Handle) -> bool { + self.deferred.get(h as usize).copied().unwrap_or(false) + } + + /// A deferred node `h` the resolve reached: an invalidate marks it pending and passes on to its + /// dependents, once, and queues no resolve (`0x76814b`, `0x768178`โ€“`0x76819c`). It keeps its + /// entry state. + pub fn hold_reached(&mut self, h: Handle) { + let i = h as usize; + if !self.held[i] && !self.passes[i] { + self.passes[i] = true; + self.newly_held.push(h); + } + } + + /// Report each anchor of `reader` its resolve read, `reads` a bit per point id, whose target is + /// deferred. + pub fn note_reads(&mut self, reader: Handle, anchors: &Anchors, reads: u16) { + for t in anchors.read_targets(reads) { + if self.deferred(t) { + self.deferred_reads.push((reader, t)); + } + } + } + + /// The deferred nodes this solve reached ([`hold_reached`](Self::hold_reached)). + pub fn newly_held(&self) -> &[Handle] { + &self.newly_held + } + + /// The reads of deferred targets ([`note_reads`](Self::note_reads)). + pub fn deferred_reads(&self) -> &[(Handle, Handle)] { + &self.deferred_reads + } + /// Whether `h` now holds a rect it did not hold at entry: a resolve that stored a new rect, /// which invalidates what anchors to `h`. A frame given no entry counts any rect as new; an /// external given none never changes. @@ -954,17 +1073,18 @@ impl LayoutSolver { } /// Whether the resolve reaches node `h`, by the rule [`set_entry`] documents: it has no entry - /// state, it was invalidated, or an anchor target stored a new rect. Only then does it take - /// the rect its inputs give it, or fail. `reads` lists its anchor targets. Not modelled yet: - /// invalidating a suspended (hidden) object reaches every dependent at once (`0x768178`โ€“ - /// `0x76819c`). + /// state, it was invalidated, or an anchor target stored a new rect or passed on an invalidate + /// it took while deferred (`0x768178`โ€“`0x76819c`). Only then does it take the rect its inputs + /// give it, or fail, unless it is deferred itself ([`hold_reached`]). `reads` lists its anchor + /// targets. /// /// [`set_entry`]: Self::set_entry + /// [`hold_reached`]: Self::hold_reached pub fn reached(&self, h: Handle, mut reads: impl Iterator) -> bool { let i = h as usize; !self.has_entry.get(i).copied().unwrap_or(false) || self.pending[i] - || reads.any(|t| self.changed(t)) + || reads.any(|t| self.changed(t) || self.passes.get(t as usize) == Some(&true)) } /// The entry rect of node `h`, which it keeps when the resolve does not reach it. @@ -1051,19 +1171,24 @@ impl LayoutSolver { fn resolve_one(&mut self, h: Handle) { let i = h as usize; let anchors = self.input[i].anchors; - if !self.reached(h, anchors.iter().map(|a| a.relative_to)) { - // Not reached: the entry state stands, rect or none. + let reached = self.reached(h, anchors.iter().map(|a| a.relative_to)); + if reached && self.deferred[i] { + self.hold_reached(h); + } + if !reached || self.deferred[i] { + // Not reached, or deferred: the entry state stands, rect or none. self.rect[i] = self.cached[i]; if self.rect[i].is_none() { self.unresolvable.push(h); } return; } - let outcome = { + let (outcome, reads) = { let input = &self.input[i]; let rects = &self.rect; - resolve_rect(input, |t| rects.get(t as usize).copied().flatten()) + resolve_rect_reads(input, |t| rects.get(t as usize).copied().flatten()) }; + self.note_reads(h, &anchors, reads); match outcome { ResolveOutcome::Resolved(r) => { let kept = self.cached[i].filter(|&c| keeps_rect(c, r, self.keep_eps)); @@ -1654,6 +1779,105 @@ mod tests { assert_eq!(s.rect(B), Some(Rect::new(0.0, 30.0, 5.0, 35.0))); } + /// A deferred node a changed target reaches keeps its entry rect, is held, and passes the + /// invalidate on, so what anchors to it is reached and reports reading it (`0x768178`, + /// `0x7a2fb8`). One held at entry passes nothing. + #[test] + fn a_deferred_node_reached_holds_and_passes_on() { + const A: Handle = 1; + const B: Handle = 2; + const C: Handle = 3; + let solve = |b_held: bool| { + let mut s = LayoutSolver::new(); + s.begin(); + s.set_external(SCREEN, screen_rect()); + let anchored = |to: Handle| { + LayoutInput::sized( + vec![Anchor::new( + Point::BottomLeft, + to, + Point::BottomRight, + 0.0, + 0.0, + )], + 5.0, + 5.0, + ) + }; + s.set_frame( + A, + &LayoutInput::sized( + vec![Anchor::new( + Point::BottomLeft, + SCREEN, + Point::BottomLeft, + 10.0, + 0.0, + )], + 20.0, + 20.0, + ), + ); + s.set_entry(A, Some(Rect::new(0.0, 0.0, 20.0, 20.0)), true); + s.set_frame(B, &anchored(A)); + s.set_entry(B, Some(Rect::new(0.0, 20.0, 5.0, 25.0)), false); + s.set_deferral(B, true, b_held); + s.set_frame(C, &anchored(B)); + s.set_entry(C, Some(Rect::new(0.0, 25.0, 5.0, 30.0)), false); + s.solve(); + ( + s.rect(B), + s.newly_held().to_vec(), + s.deferred_reads().to_vec(), + s.rect(C), + ) + }; + let (b, held, reads, c) = solve(false); + assert_eq!( + b, + Some(Rect::new(0.0, 20.0, 5.0, 25.0)), + "B keeps its entry" + ); + assert_eq!(held, vec![B]); + assert_eq!(reads, vec![(C, B)], "C was reached and read B"); + assert_eq!(c, Some(Rect::new(0.0, 25.0, 5.0, 30.0))); + let (_, held, reads, _) = solve(true); + assert!( + held.is_empty() && reads.is_empty(), + "held already: nothing passes" + ); + } + + /// The reads a resolve reports are the anchors its edge scans reached: a scan stops at the + /// first anchor that resolves (`0x7671a0`), so a later anchor on the same edges is not read. + #[test] + fn the_resolve_reports_the_anchors_its_scans_read() { + let f = LayoutInput::sized( + vec![ + Anchor::new(Point::TopLeft, 5, Point::TopLeft, 0.0, 0.0), + Anchor::new(Point::Left, 6, Point::Left, 0.0, 0.0), + Anchor::new(Point::BottomRight, 7, Point::BottomRight, 0.0, 0.0), + ], + 0.0, + 0.0, + ); + let (out, reads) = resolve_rect_reads(&f, |_| Some(screen_rect())); + assert!(matches!(out, ResolveOutcome::Resolved(_))); + let read: Vec = f.anchors.read_targets(reads).collect(); + assert_eq!( + read, + vec![5, 7], + "LEFT is behind TOPLEFT and no centre is derived" + ); + let (_, reads) = resolve_rect_reads(&f, |t| (t != 5).then(screen_rect)); + let read: Vec = f.anchors.read_targets(reads).collect(); + assert_eq!( + read, + vec![5, 6, 7], + "TOPLEFT unresolved: the scan reaches LEFT" + ); + } + #[test] fn size_changed_epsilon() { let r = Rect::new(0.0, 0.0, 40.0, 100.0); diff --git a/crates/benilla-ui/src/loader/geometry.rs b/crates/benilla-ui/src/loader/geometry.rs index 94925791..7a1b00a1 100644 --- a/crates/benilla-ui/src/loader/geometry.rs +++ b/crates/benilla-ui/src/loader/geometry.rs @@ -84,7 +84,7 @@ impl Loader<'_> { self.call(wrapper, "SetID", n, dbg); } } - // `clampedToScreen`: the layout's screen clamp, geometry flag bit 4 (`0x768cc0`). + // `clampedToScreen`: the layout's screen clamp, geometry flag `0x10` (`0x768cc0`). if el.attr_bool("clampedToScreen") { self.call(wrapper, "SetClampedToScreen", true, dbg); } diff --git a/crates/benilla-ui/src/script/event.rs b/crates/benilla-ui/src/script/event.rs index a362fe9a..48a5fa20 100644 --- a/crates/benilla-ui/src/script/event.rs +++ b/crates/benilla-ui/src/script/event.rs @@ -93,17 +93,22 @@ pub(super) fn fire_size_changes(lua: &Lua) { .pending_size_changed, ); for (id, w, h) in pending { - if let Err(e) = fire( - lua, - id, - "OnSizeChanged", - None, - vec![Value::Number(f64::from(w)), Value::Number(f64::from(h))], - ) { - lua.app_data_mut::() - .expect("model") - .record_lua_error(&e); - } + fire_size_changed(lua, id, w, h); + } +} + +/// `OnSizeChanged(width, height)` on frame `id`, its error recorded. +pub(super) fn fire_size_changed(lua: &Lua, id: u32, w: f32, h: f32) { + if let Err(e) = fire( + lua, + id, + "OnSizeChanged", + None, + vec![Value::Number(f64::from(w)), Value::Number(f64::from(h))], + ) { + lua.app_data_mut::() + .expect("model") + .record_lua_error(&e); } } diff --git a/crates/benilla-ui/src/script/hyperlink.rs b/crates/benilla-ui/src/script/hyperlink.rs index 697d9a0f..a8e8eb3f 100644 --- a/crates/benilla-ui/src/script/hyperlink.rs +++ b/crates/benilla-ui/src/script/hyperlink.rs @@ -149,7 +149,7 @@ fn build(model: &mut Model, owner: FrameHandle) -> FrameHandle { model.arena.set_frame_strata(h, strata); model.arena.set_frame_level(h, level + 1, false); } - model.arena.set_shown(h, false); + model.set_shown_silent(h, false); if let Some(KindState::Button(bs)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) { bs.registered_clicks = ["LeftButtonUp", "RightButtonUp"] .into_iter() diff --git a/crates/benilla-ui/src/script/layout.rs b/crates/benilla-ui/src/script/layout.rs index d5a15f6d..93affd40 100644 --- a/crates/benilla-ui/src/script/layout.rs +++ b/crates/benilla-ui/src/script/layout.rs @@ -3,9 +3,48 @@ use std::collections::HashMap; use crate::layout::{self, Anchor, LayoutInput, Rect}; use crate::widget::{FrameHandle, RegionHandle}; +use super::model::{node_deferred, IdMap, HOLD_FORCE, HOLD_HELD, HOLD_QUEUED, HOLD_QUEUED_FORCE}; use super::types::RegionData; use super::{MeasureRequest, Model, UiScript, SCREEN}; +/// Node `id`'s [`Model::layout_hold`] bits, for a caller holding the model destructured. +#[inline] +fn hold_get(hold: &[u8], id: u32) -> u8 { + hold.get(id as usize).copied().unwrap_or(0) +} + +fn hold_set(hold: &mut Vec, id: u32, bits: u8) { + let i = id as usize; + if hold.len() <= i { + hold.resize(i + 1, 0); + } + hold[i] |= bits; +} + +fn hold_clear(hold: &mut [u8], id: u32, bits: u8) { + if let Some(b) = hold.get_mut(id as usize) { + *b &= !bits; + } +} + +/// Node `id`'s anchors: a frame's layout input's, a region's own. +fn node_anchors( + layout_inputs: &HashMap, + region_data: &HashMap, + id_to_frame: &IdMap, + id_to_region: &IdMap, + id: u32, +) -> layout::Anchors { + if let Some(h) = id_to_frame.get(&id) { + return layout_inputs.get(h).map(|i| i.anchors).unwrap_or_default(); + } + id_to_region + .get(&id) + .and_then(|rh| region_data.get(rh)) + .map(|d| d.anchors) + .unwrap_or_default() +} + /// A frame's `effective_alpha` and `effective_scale`, worn by the quads it emits itself. #[derive(Clone, Copy)] pub(super) struct FramePaint { @@ -88,6 +127,20 @@ impl InputFingerprint { /// The sentinel in [`LayoutScope::last`] and `now` for an id not in that pass's graph. const NO_NODE: u64 = 0; +// A resolve's per-id marks ([`LayoutScope::marks`]). +/// Invalidated: solved from its inputs (`0x7680e0`). +const MARK_INVALID: u8 = 1; +/// Its resolved bit cleared: stored without the keep test. +const MARK_FORCED: u8 = 2; +/// Undeferred for this resolve: a show, a load's end, a read or an anchor read pulled it. +const MARK_PULLED: u8 = 4; +/// Its invalidate passes to what anchors to it (`0x768178`). +const MARK_PASSING: u8 = 8; +/// Resolved for the show that started this resolve ([`Model::layout_chain`]). +const MARK_CHAIN: u8 = 16; +/// Collected as a seed already. +const MARK_SEEN: u8 = 32; + /// A frame's per-node hash; the full derive and the incremental pass must agree bit for bit. #[inline] fn frame_node_hash(input: &LayoutInput) -> u64 { @@ -192,8 +245,13 @@ pub(crate) struct LayoutScope { out: Vec>, /// Per id: in the dirty closure. dirty: Vec, + /// The dirty ids in the order they were marked. + dirty_list: Vec, /// The closure's worklist. stack: Vec, + /// Per id, this resolve's `MARK_*` bits, cleared through `marked`. + marks: Vec, + marked: Vec, /// The cached frame roster, `(handle, id)`, kept across resolves. plan: Vec<(FrameHandle, u32)>, /// The cached roster of live, anchored regions. @@ -234,6 +292,7 @@ impl LayoutScope { self.out.resize_with(n, Vec::new); self.dirty.clear(); self.dirty.resize(n, false); + self.dirty_list.clear(); self.dep_to.clear(); self.dep_next.clear(); self.dep_free.clear(); @@ -256,10 +315,45 @@ impl LayoutScope { } self.dirty.clear(); self.dirty.resize(self.node_of.len(), false); + self.dirty_list.clear(); self.stack.clear(); self.staged.clear(); } + /// Clear the last resolve's marks and size them for `n` ids. + fn begin_marks(&mut self, n: usize) { + for &id in &self.marked { + self.marks[id as usize] = 0; + } + self.marked.clear(); + if self.marks.len() < n { + self.marks.resize(n, 0); + } + } + + /// Add `bits` to id `id`'s marks. + #[inline] + fn mark(&mut self, id: u32, bits: u8) { + let i = id as usize; + if self.marks.len() <= i { + self.marks.resize(i + 1, 0); + } + if self.marks[i] == 0 { + self.marked.push(id); + } + self.marks[i] |= bits; + } + + #[inline] + fn has(&self, id: u32, bits: u8) -> bool { + self.marks.get(id as usize).is_some_and(|m| m & bits != 0) + } + + #[inline] + fn is_dirty(&self, id: u32) -> bool { + self.dirty.get(id as usize).copied().unwrap_or(false) + } + /// Record that node `from` reads `to`'s rect, so a change at `to` reaches `from`. #[inline] fn edge(&mut self, from: u32, to: u32) { @@ -344,18 +438,22 @@ impl LayoutScope { if !*d { *d = true; self.stack.push(id); + self.dirty_list.push(id); } } } /// 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. + /// against a hash that stops covering one. A node `held` under its deferral is staged but not + /// seeded: its resolve waits, so its rect stays and nothing reading it moves. #[inline] - fn note(&mut self, id: u32, hash: u64, relinked: bool) { + fn note(&mut self, id: u32, hash: u64, relinked: bool, held: bool) { if relinked || hash != self.last.get(id as usize).copied().unwrap_or(NO_NODE) { self.staged.push((id, hash)); - self.seed(id); + if !held { + self.seed(id); + } } } @@ -416,8 +514,10 @@ impl LayoutScope { /// Mark every node dirty, for what a per-node diff cannot see (no memory, a screen move). fn dirty_all(&mut self) { for (id, &n) in self.node_of.iter().enumerate() { - if n != u32::MAX { + if n != u32::MAX && !self.dirty[id] { self.dirty[id] = true; + #[allow(clippy::cast_possible_truncation)] + self.dirty_list.push(id as u32); } } } @@ -536,6 +636,25 @@ impl LayoutScope { } } +/// Every frame's and region's deferral bit, for `WOW_LAYOUT_VERIFY`'s re-run to hold against. +fn deferral_state(model: &Model) -> (Vec, Vec) { + // In arena order, which the re-run does not change. + let frames: Vec = model + .arena + .iter_frames() + .filter(|(_, f)| f.layout_deferred) + .map(|(h, _)| h) + .collect(); + let mut regions: Vec = model + .region_to_id + .keys() + .copied() + .filter(|&rh| model.arena.region(rh).is_some_and(|r| r.layout_deferred)) + .collect(); + regions.sort_unstable_by_key(|rh| rh.fingerprint_bits()); + (frames, regions) +} + /// 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( @@ -753,12 +872,18 @@ impl UiScript { model.layout_derives, ); let (queued, warned) = (model.pending_size_changed.len(), model.warnings.len()); + // The deferral state the pass left, which a re-run of its converged model must keep: it + // reaches nothing, so it holds and pulls nothing. + let chain = model.layout_chain.take(); + let deferral_before = (deferral_state(model), model.layout_hold.clone()); model.layout_touched = None; // no ledger: derive the graph model.layout_epoch_resolved = None; // past tier 1 model.layout_fingerprint = None; // past tier 2 Self::resolve_layout_inner(model); // a fresh scope: full scope let graph = cached.diverges_from(&model.layout_scope); model.layout_scope = cached; + model.layout_chain = chain; + let deferral_after = (deferral_state(model), model.layout_hold.clone()); model.pending_size_changed.truncate(queued); model.warnings.truncate(warned); ( @@ -791,6 +916,13 @@ impl UiScript { graph.as_deref().unwrap_or("same as fresh"), )); } + if deferral_before != deferral_after { + return Some(format!( + "an INCREMENTAL layout pass left deferral state a full derivation of the same \ + model moved: a node it should have held or pulled. Graph: {}", + graph.as_deref().unwrap_or("same as fresh"), + )); + } if kept_before != model.region_kept { return Some(format!( "an INCREMENTAL layout pass kept failed textures' quads {kept_before:?}, a full \ @@ -846,6 +978,8 @@ impl UiScript { let size_eps = model.size_eps(); let resolve_eps = model.resolve_eps(); pre.watched = pre.lap(); + // The graph's own clears of a resolved bit (a moved effective scale) are writes made now. + let forced_mark = model.layout_force.len(); // โ”€โ”€ 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. @@ -883,12 +1017,91 @@ impl UiScript { layout_force, layout_screen, layout_unsettled, + layout_hold, + layout_pass, + layout_pull, + layout_chain, + next_id, + region_to_id, .. } = model; let mut full_scope = false; // The nodes this resolve stores without the keep test; a write that cleared the resolved // bit of no node (an unanchored region's measure) leaves nothing to store. let mut forced = std::mem::take(layout_force); + let mut pending = pending; + + // โ”€โ”€ Deferral (`0x2` of `G+0x3c`, `0x768060`) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // A deferred node's resolve waits, pending, until its show, a read, or an anchor read of + // a node that resolves pulls it; the pulled ones are this resolve's to solve. + scope.begin_marks((*next_id as usize).max(scope.node_of.len())); + let mut passing = std::mem::take(layout_pass); + for id in std::mem::take(layout_pull) { + scope.mark(id, MARK_PULLED); + let f = hold_get(layout_hold, id); + if f & HOLD_HELD != 0 { + if f & HOLD_FORCE != 0 { + forced.push(id); + } else { + pending.push(id); + } + hold_clear(layout_hold, id, HOLD_HELD | HOLD_FORCE); + } + } + // Queued and deferred since: off the queue, pending (`0x768074`โ€“`0x7680b3`). A resolved bit + // the derivation cleared is a write now, so a deferred one passes it on. + pending.retain(|&id| { + hold_clear(layout_hold, id, HOLD_QUEUED); + if scope.has(id, MARK_PULLED) || !node_deferred(arena, id_to_frame, id_to_region, id) { + return true; + } + hold_set(layout_hold, id, HOLD_HELD); + false + }); + // A moved effective scale reaches each of the frame's regions itself (`0x76ad06`), which + // clears its resolved bit and invalidates it under its own deferral, whatever the frame's. + let scaled: Vec = forced[forced_mark.min(forced.len())..] + .iter() + .copied() + .filter(|id| id_to_frame.contains_key(id)) + .collect(); + for &id in &scaled { + let Some(f) = id_to_frame.get(&id).and_then(|&h| arena.frame(h)) else { + continue; + }; + for rh in &f.regions { + if let Some(&rid) = region_to_id.get(rh) { + forced.push(rid); + } + } + } + let mut at = 0usize; + forced.retain(|&id| { + let derived = at >= forced_mark; + at += 1; + hold_clear(layout_hold, id, HOLD_QUEUED_FORCE); + if scope.has(id, MARK_PULLED) || !node_deferred(arena, id_to_frame, id_to_region, id) { + return true; + } + if derived && hold_get(layout_hold, id) & HOLD_HELD == 0 { + passing.push(id); + } + hold_set(layout_hold, id, HOLD_HELD | HOLD_FORCE); + false + }); + passing.sort_unstable(); + passing.dedup(); + for &id in &passing { + scope.mark(id, MARK_PASSING); + } + // A show's resolve: its roots, and what their reads pull, in `ApplyRect` order. + let mut chain = layout_chain.take(); + if let Some(roots) = &chain { + for &id in roots { + scope.mark(id, MARK_CHAIN); + } + } + // Each is reached now, so it holds what its inputs give again. for id in forced.iter().chain(&pending) { layout_unsettled.remove(id); @@ -897,6 +1110,7 @@ impl UiScript { // (`0x768d55`): its valid bit clears, a Texture keeps its last quad. So does one whose // owner's scale moved, which the cascade invalidates (`0x76ad06`). let fail_loose = |rh: RegionHandle, + arena: &crate::widget::WidgetArena, region_resolved: &mut HashMap, region_kept: &mut HashMap| { let Some(r) = arena.region(rh) else { return }; @@ -911,15 +1125,19 @@ impl UiScript { }; for &id in &pending { if let Some(&rh) = id_to_region.get(&id) { - fail_loose(rh, region_resolved, region_kept); + fail_loose(rh, arena, region_resolved, region_kept); } } for &id in &forced { if let Some(&rh) = id_to_region.get(&id) { - fail_loose(rh, region_resolved, region_kept); - } else if let Some(f) = id_to_frame.get(&id).and_then(|&h| arena.frame(h)) { + fail_loose(rh, arena, region_resolved, region_kept); + } + } + for &id in &scaled { + if let Some(f) = id_to_frame.get(&id).and_then(|&h| arena.frame(h)) { + // Whatever each region's deferral (`0x7683a2`โ€“`0x7683c3`). for &rh in &f.regions { - fail_loose(rh, region_resolved, region_kept); + fail_loose(rh, arena, region_resolved, region_kept); } } } @@ -929,7 +1147,6 @@ impl UiScript { .get(id as usize) .is_some_and(|&n| n != u32::MAX) }); - let mut pending = pending; pending.retain(|&id| { scope .node_of @@ -937,9 +1154,11 @@ impl UiScript { .is_some_and(|&n| n != u32::MAX) }); // An incremental pass has no fingerprint; its verdict is `staged`, exactly the seed set. - // A forced or invalidated node is solved even with its inputs unmoved. + // A forced or invalidated node is solved even with its inputs unmoved, and an invalidate + // passed on reaches what anchors to its node. let gate_skips = forced.is_empty() && pending.is_empty() + && passing.is_empty() && match fp { Some(_) => *layout_fingerprint == fp, None => scope.staged.is_empty(), @@ -959,6 +1178,7 @@ impl UiScript { *layout_epoch_resolved = Some(epoch_at_entry); // Re-arm the ledger: the graph is as it was, so the cache is still trustworthy. *layout_touched = Some(Vec::new()); + *layout_chain = chain.clone(); if !verify { return; } @@ -994,97 +1214,106 @@ impl UiScript { if full_scope { scope.dirty_all(); } else { - for (id, &n) in scope.node_of.iter().enumerate() { - if n != u32::MAX && scope.now[id] != scope.last[id] { - scope.stack.push(id as u32); - scope.dirty[id] = true; + // A node held under its deferral waits whatever moved its inputs. + for id in 0..scope.node_of.len() { + #[allow(clippy::cast_possible_truncation)] + let id32 = id as u32; + if scope.node_of[id] != u32::MAX + && scope.now[id] != scope.last[id] + && !(hold_get(layout_hold, id32) & HOLD_HELD != 0 + && node_deferred(arena, id_to_frame, id_to_region, id32)) + { + scope.seed(id32); } } // A node that died has no hash left to differ, so its id is seeded instead: the // derivation recorded the edges of whatever still anchors to it, and the closure // re-resolves those against its missing rect, as a full scope would. for id in 0..scope.node_of.len() { - if scope.last[id] != NO_NODE - && scope.node_of[id] == u32::MAX - && !scope.dirty[id] - { - scope.stack.push(id as u32); - scope.dirty[id] = true; + if scope.last[id] != NO_NODE && scope.node_of[id] == u32::MAX { + #[allow(clippy::cast_possible_truncation)] + scope.seed(id as u32); } } scope.close(); } } // A forced or invalidated node, and whatever reads it, is solved: the store it owes moves - // its rect. - for &id in forced.iter().chain(&pending) { + // its rect. So is what an invalidate passed on reaches. + for &id in &forced { + scope.mark(id, MARK_INVALID | MARK_FORCED); + scope.seed(id); + } + for &id in &pending { + scope.mark(id, MARK_INVALID); + scope.seed(id); + } + for &id in &passing { scope.seed(id); } scope.close(); - let mut force_mask: Vec = Vec::new(); - let mut invalid_mask: Vec = Vec::new(); - if !forced.is_empty() || !pending.is_empty() { - force_mask.resize(scope.node_of.len(), false); - invalid_mask.resize(scope.node_of.len(), false); - for &id in &forced { - force_mask[id as usize] = true; - invalid_mask[id as usize] = true; - } - for &id in &pending { - invalid_mask[id as usize] = true; + // A held node an anchor read may pull joins the solve, with what reads it: a node that + // resolves reads its targets, and a pulled target resolves first (`0x7a2fc0`). + let mut k = 0; + while k < scope.dirty_list.len() { + let id = scope.dirty_list[k]; + k += 1; + let anchors = node_anchors(layout_inputs, region_data, id_to_frame, id_to_region, id); + for t in anchors.iter().map(|a| a.relative_to) { + if hold_get(layout_hold, t) & HOLD_HELD != 0 && !scope.is_dirty(t) { + scope.seed(t); + scope.close(); + } } } - let is_forced = |id: u32| force_mask.get(id as usize).copied().unwrap_or(false); - // Invalidated since the last resolve (`0x7680e0`): solved from its inputs, never kept. - let is_invalid = |id: u32| invalid_mask.get(id as usize).copied().unwrap_or(false); // A dirty frame is solved, a dirty region swept, and whatever they read is seeded at its // cached rect. A dirty region is seeded too, since the frame pass runs before the sweep. let mut solve_frames: Vec = Vec::new(); let mut sweep_regions: Vec = Vec::new(); let mut seed_frames: Vec = Vec::new(); let mut seed_regions: Vec = Vec::new(); - // Whether a node in the closure reads `id`; a rect nothing dirty reads is not seeded. - let read_by_dirty = |scope: &LayoutScope, id: u32| -> bool { - let mut e = scope.dep_head[id as usize]; - while e != u32::MAX { - if scope.dirty[scope.dep_to[e as usize] as usize] { - return true; - } - e = scope.dep_next[e as usize]; - } - false - }; // A cleared region is no node but keeps its rect, which what anchors to it still reads. let mut seed_loose: Vec<(u32, Rect)> = Vec::new(); - for id in 0..scope.node_of.len() { - let n = scope.node_of[id]; + scope.dirty_list.sort_unstable(); + for k in 0..scope.dirty_list.len() { + let id = scope.dirty_list[k]; + let Some(&n) = scope.node_of.get(id as usize) else { + continue; + }; if n == u32::MAX { - #[allow(clippy::cast_possible_truncation)] - let id32 = id as u32; - if scope.dep_head[id] != u32::MAX && read_by_dirty(scope, id32) { - if let Some(r) = id_to_region - .get(&id32) - .and_then(|rh| region_resolved.get(rh)) - { - seed_loose.push((id32, *r)); - } - } continue; } - let (idx, is_region, dirty) = (n & !REGION_TAG, n & REGION_TAG != 0, scope.dirty[id]); - #[allow(clippy::cast_possible_truncation)] - let id32 = id as u32; - if is_region { - if dirty { - sweep_regions.push(idx); - seed_regions.push(idx); - } else if read_by_dirty(scope, id32) { - seed_regions.push(idx); - } - } else if dirty { + let idx = n & !REGION_TAG; + if n & REGION_TAG != 0 { + sweep_regions.push(idx); + seed_regions.push(idx); + } else { solve_frames.push(idx); - } else if read_by_dirty(scope, id32) { - seed_frames.push(idx); + } + } + // What a dirty node reads that is not dirty itself, each once. + for k in 0..scope.dirty_list.len() { + let id = scope.dirty_list[k] as usize; + for j in 0..scope.out.get(id).map_or(0, Vec::len) { + let t = scope.out[id][j]; + if scope.is_dirty(t) || scope.has(t, MARK_SEEN) { + continue; + } + scope.mark(t, MARK_SEEN); + match scope.node_of.get(t as usize).copied() { + None => {} + Some(u32::MAX) => { + if scope.dep_head.get(t as usize).copied().unwrap_or(u32::MAX) != u32::MAX { + if let Some(r) = + id_to_region.get(&t).and_then(|rh| region_resolved.get(rh)) + { + seed_loose.push((t, *r)); + } + } + } + Some(n) if n & REGION_TAG != 0 => seed_regions.push(n & !REGION_TAG), + Some(n) => seed_frames.push(n), + } } } // This solve's width, beside `layout_solves` (how often) and `layout_rounds` (how deep). @@ -1094,26 +1323,15 @@ impl UiScript { // and keeps that one when every edge moved less than 1e-5 layout units, so a slow drift // builds until it crosses. The cache is the rect at this resolve's entry: a mid-pass round // is our fixpoint's, not a store. A forced node, a frame with no rect, a region whose - // owner's scale moved (the cascade reaches its regions, `0x76ad06`), has no cache. + // owner's scale moved (the cascade reaches its regions, `0x76ad06`), has no cache; a + // node pulled mid-resolve is forced from then on, so each round applies the marks. let entry_frames: Vec> = solve_frames .iter() - .map(|&i| { - let (h, id) = scope.plan[i as usize]; - (!is_forced(id)) - .then(|| resolved.get(&h).copied()) - .flatten() - }) + .map(|&i| resolved.get(&scope.plan[i as usize].0).copied()) .collect(); - let entry_regions: Vec<(Option, bool)> = sweep_regions + let entry_regions: Vec> = sweep_regions .iter() - .map(|&i| { - let row = scope.regions[i as usize]; - let owner_forced = frame_to_id.get(&row.owner).is_some_and(|&o| is_forced(o)); - let cached = (!is_forced(row.id) && !owner_forced) - .then(|| region_resolved.get(&row.rh).copied()) - .flatten(); - (cached, is_invalid(row.id) || owner_forced) - }) + .map(|&i| region_resolved.get(&scope.regions[i as usize].rh).copied()) .collect(); solver.set_keep_eps(resolve_eps); let round_cap = scope.plan.len() + region_data.len() + 2; @@ -1122,6 +1340,16 @@ impl UiScript { let mut t_frames = std::time::Duration::ZERO; let mut t_regions = std::time::Duration::ZERO; let mut n_regions_swept = 0u64; + // Whether node `id` is deferred in this resolve: its bit set and nothing pulled it. + let deferral = |scope: &LayoutScope, + arena: &crate::widget::WidgetArena, + layout_hold: &[u8], + id: u32| + -> (bool, bool) { + let deferred = + !scope.has(id, MARK_PULLED) && node_deferred(arena, id_to_frame, id_to_region, id); + (deferred, hold_get(layout_hold, id) & HOLD_HELD != 0) + }; for round in 0..round_cap { *layout_rounds += 1; let t_round = prof.then(std::time::Instant::now); @@ -1137,26 +1365,49 @@ impl UiScript { if let Some(r) = region_resolved.get(&n.rh) { solver.set_external(n.id, *r); } + let (d, held) = deferral(scope, arena, layout_hold, n.id); + solver.set_deferral(n.id, d, held); } for &(id, r) in &seed_loose { solver.set_external(id, r); + let (d, held) = deferral(scope, arena, layout_hold, id); + solver.set_deferral(id, d, held); } - for (&i, &(cached, invalid)) in sweep_regions.iter().zip(&entry_regions) { - solver.set_entry(scope.regions[i as usize].id, cached, invalid); + for (&i, &raw) in sweep_regions.iter().zip(&entry_regions) { + let row = scope.regions[i as usize]; + let owner_forced = frame_to_id + .get(&row.owner) + .is_some_and(|&o| scope.has(o, MARK_FORCED)); + let cached = (!scope.has(row.id, MARK_FORCED) && !owner_forced) + .then_some(raw) + .flatten(); + solver.set_entry( + row.id, + cached, + scope.has(row.id, MARK_INVALID) || owner_forced, + ); } for &i in &seed_frames { let (h, id) = scope.plan[i as usize]; if let Some(r) = resolved.get(&h) { solver.set_external(id, *r); } + let (d, held) = deferral(scope, arena, layout_hold, id); + solver.set_deferral(id, d, held); } - for (&i, &cached) in solve_frames.iter().zip(&entry_frames) { + for (&i, &raw) in solve_frames.iter().zip(&entry_frames) { let (h, id) = scope.plan[i as usize]; let Some(input) = layout_inputs.get(&h) else { continue; }; solver.set_frame(id, input); - solver.set_entry(id, cached, is_invalid(id)); + let cached = (!scope.has(id, MARK_FORCED)).then_some(raw).flatten(); + solver.set_entry(id, cached, scope.has(id, MARK_INVALID)); + let (d, held) = deferral(scope, arena, layout_hold, id); + solver.set_deferral(id, d, held); + } + for &id in &passing { + solver.set_passes(id); } solver.solve(); for &i in &solve_frames { @@ -1184,7 +1435,7 @@ impl UiScript { } let t_reg = prof.then(std::time::Instant::now); let mut scratch = LayoutInput::default(); - for (&ri, &(cached, _)) in sweep_regions.iter().zip(&entry_regions) { + for &ri in &sweep_regions { let RegionRow { rh, id: region_id, @@ -1198,9 +1449,15 @@ impl UiScript { if prof { n_regions_swept += 1; } - // Not reached since its entry: it keeps its state, rect or none (see + let (deferred, held) = deferral(scope, arena, layout_hold, region_id); + solver.set_deferral(region_id, deferred, held); + let reached = solver.reached(region_id, data.anchors.iter().map(|a| a.relative_to)); + if reached && deferred { + solver.hold_reached(region_id); + } + // Not reached since its entry, or deferred: it keeps its state, rect or none (see // `LayoutSolver::set_entry`). - if !solver.reached(region_id, data.anchors.iter().map(|a| a.relative_to)) { + if !reached || deferred { match solver.entry(region_id) { Some(kept) => { solver.set_external(region_id, kept); @@ -1258,11 +1515,12 @@ impl UiScript { scratch.width = width; scratch.height = height; scratch.scale = scale; - let edges = layout::resolve_rect_edges(&scratch, |id| { + let (edges, reads) = layout::resolve_rect_edges_reads(&scratch, |id| { // The screen root is a frame-pass external only: a region anchor to id 0 finds // no rect here, though the solver holds one. (id != SCREEN).then(|| solver.rect(id)).flatten() }); + solver.note_reads(region_id, &data.anchors, reads); // An axis resolves, or the region does not. The reference resolver core // `[0x7671a0, 0x76761f)` reads no parent or owner pointer, only the region's own // anchors and `combineEdge`/`combineCenter` over its own size; `assemble 0x767a20` @@ -1292,7 +1550,8 @@ impl UiScript { }; let rect = Rect::new(bottom, left, top, right); // The keep test, as for a frame: the same `0x768d20` resolves regions. - let rect = cached + let rect = solver + .entry(region_id) .filter(|&c| layout::keeps_rect(c, rect, resolve_eps)) .unwrap_or(rect); // Into the solver too: a later region in this sweep that anchors here needs it. @@ -1307,6 +1566,58 @@ impl UiScript { if let Some(t) = t_reg { t_regions += t.elapsed(); } + + // โ”€โ”€ The round's deferral: what it reached deferred, and what its reads pulled โ”€โ”€โ”€โ”€ + // A deferred node reached is pending and passes its invalidate on, once. + for k in 0..solver.newly_held().len() { + let id = solver.newly_held()[k]; + hold_set(layout_hold, id, HOLD_HELD); + if !scope.has(id, MARK_PASSING) { + scope.mark(id, MARK_PASSING); + passing.push(id); + changed = true; + } + } + // A read of a deferred target clears its bit for good and resolves it first, if it + // is pending (`0x7a2fb8` โ†’ `0x768060(0)` โ†’ `0x7680e0(1)`); here, in the next round, + // after which its readers resolve again against it. + for k in 0..solver.deferred_reads().len() { + let (reader, t) = solver.deferred_reads()[k]; + if scope.has(t, MARK_PULLED) { + continue; + } + scope.mark(t, MARK_PULLED); + if let Some(f) = id_to_frame.get(&t).and_then(|&h| arena.frame_mut(h)) { + f.layout_deferred = false; + } else if let Some(r) = id_to_region.get(&t).and_then(|&rh| arena.region_mut(rh)) { + r.layout_deferred = false; + } + if scope.has(reader, MARK_CHAIN) { + scope.mark(t, MARK_CHAIN); + if let Some(order) = chain.as_mut() { + let at = order + .iter() + .position(|&c| c == reader) + .unwrap_or(order.len()); + order.insert(at, t); + } + } + let f = hold_get(layout_hold, t); + if f & HOLD_HELD == 0 { + continue; + } + hold_clear(layout_hold, t, HOLD_HELD | HOLD_FORCE); + layout_unsettled.remove(&t); + scope.mark( + t, + MARK_INVALID | if f & HOLD_FORCE != 0 { MARK_FORCED } else { 0 }, + ); + if let Some(&rh) = id_to_region.get(&t) { + fail_loose(rh, arena, region_resolved, region_kept); + } + changed = true; + } + if !changed { if prof { // `anchored` is the sweep's roster, `solved` and `swept` this pass's scope. @@ -1355,6 +1666,7 @@ impl UiScript { // 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. *layout_epoch_resolved = Some(epoch_at_entry); + *layout_chain = chain; queue_size_changes( &watched, resolved, @@ -1378,6 +1690,7 @@ impl UiScript { // The cycle bail: its rects are this frame's, so their size changes still fire, and the // scope forgets a graph that never settled. scope.invalidate(); + *layout_chain = chain; queue_size_changes( &watched, resolved, @@ -1414,6 +1727,9 @@ impl UiScript { ids.push((h, id)); } } + // In id order, not the map's: the roster, and every id minted below, are the same each + // run. + ids.sort_unstable_by_key(|&(_, id)| id); for &(h, id) in &ids { let (scale, clamp) = arena .frame(h) @@ -1425,7 +1741,7 @@ impl UiScript { layout_force.push(id); } input.scale = scale; - // Clamp-to-screen is a frame property (geometry flags bit 4), so every placement path + // Clamp-to-screen is a frame property (geometry flags `0x10`), so every placement path // clamps alike, against the live window size. input.clamp = clamp; if clamp { @@ -1485,7 +1801,11 @@ impl UiScript { let mut fed_regions = 0u64; // The region roster, built in the same walk: live, anchored regions only. scope.regions.reserve(region_data.len()); - for (&rh, data) in region_data.iter() { + // In handle order, not the map's, so an id minted here is the same each run. + let mut rows: Vec = region_data.keys().copied().collect(); + rows.sort_unstable_by_key(|rh| rh.fingerprint_bits()); + for rh in rows { + let data = ®ion_data[&rh]; // Anchor-less entries are not layout inputs: hashing them would make a paint-only // setter that creates one (`SetTexture`'s `or_default()`) read as a layout change. if data.anchors.is_empty() { @@ -1562,8 +1882,14 @@ impl UiScript { screen, layout_scope: scope, layout_force, + layout_hold, .. } = model; + // Pending under its deferral: its resolve waits, whatever moved its inputs. + let held = |layout_hold: &[u8], arena: &crate::widget::WidgetArena, id: u32| { + hold_get(layout_hold, id) & HOLD_HELD != 0 + && node_deferred(arena, id_to_frame, id_to_region, id) + }; scope.begin_incremental(*next_id as usize); named.sort_unstable(); named.dedup(); @@ -1616,7 +1942,8 @@ impl UiScript { } else { scope.relink(id, &targets) }; - scope.note(id, hash, relinked); + let held = held(layout_hold, arena, id); + scope.note(id, hash, relinked, held); continue; } // A region: a node while it lives and has an anchor. @@ -1666,7 +1993,8 @@ impl UiScript { scope.regions[(n & !REGION_TAG) as usize] = row; scope.relink(id, &targets) }; - scope.note(id, hash, relinked); + let held = held(layout_hold, arena, id); + scope.note(id, hash, relinked, held); } scope.targets = targets; scope.close(); @@ -1711,13 +2039,18 @@ impl UiScript { } dirty } - None => model - .region_data - .iter() - .filter(|(_, d)| d.text.as_deref().is_some_and(|t| !t.is_empty())) - .map(|(&rh, _)| rh) - .filter(|&rh| stale_measure_key(&model, rh).is_some()) - .collect(), + None => { + let mut all: Vec = model + .region_data + .iter() + .filter(|(_, d)| d.text.as_deref().is_some_and(|t| !t.is_empty())) + .map(|(&rh, _)| rh) + .filter(|&rh| stale_measure_key(&model, rh).is_some()) + .collect(); + // In handle order, not the map's: a request mints its region's id. + all.sort_unstable_by_key(|rh| rh.fingerprint_bits()); + all + } }; // Re-enroll every stale region: a request is not an answer, and nothing else asks again. if let Some(list) = &mut model.measure_dirty { diff --git a/crates/benilla-ui/src/script/measure.rs b/crates/benilla-ui/src/script/measure.rs index 0bc4cf6e..b6e88b02 100644 --- a/crates/benilla-ui/src/script/measure.rs +++ b/crates/benilla-ui/src/script/measure.rs @@ -68,7 +68,7 @@ pub(super) fn ensure_measured_in(model: &mut Model, rh: RegionHandle) { // Only a box that moved touches the layout, as in `set_measured_text`: a same-width // re-measure (a countdown tick) must not cost a whole-roster hash. Stored whole, as there. model.touch_layout_region(rh); - model.layout_force.push(req.id); + model.queue_force(req.id); } } diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 0ffe0327..efd9978c 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -905,7 +905,7 @@ impl UiScript { ) { return false; } - model.arena.set_shown(h, true); + model.set_shown_silent(h, true); model.focused_editbox = Some(h); true } @@ -999,7 +999,7 @@ impl UiScript { // Touched per region, so the layout gate need not re-walk the whole roster; stored // whole, as the reference's `SetText` clears the resolved bit (`0x771ea4`). model.touch_layout_region(rh); - model.layout_force.push(id); + model.queue_force(id); } } } diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 168d23be..1ef56e46 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -58,6 +58,31 @@ impl IdMap { } } +/// [`Model::layout_hold`]: the node is in [`Model::layout_pending`]. +pub(crate) const HOLD_QUEUED: u8 = 1; +/// The node is in [`Model::layout_force`]. +pub(crate) const HOLD_QUEUED_FORCE: u8 = 2; +/// The node is pending under its deferral bit, its resolve waiting. +pub(crate) const HOLD_HELD: u8 = 4; +/// The held node's resolved bit was cleared, so its resolve stores without the keep test. +pub(crate) const HOLD_FORCE: u8 = 8; + +/// Whether layout node `id`'s deferral bit is set, on its frame or region. +pub(crate) fn node_deferred( + arena: &WidgetArena, + id_to_frame: &IdMap, + id_to_region: &IdMap, + id: u32, +) -> bool { + match id_to_frame.get(&id) { + Some(&h) => arena.frame(h).is_some_and(|f| f.layout_deferred), + None => id_to_region + .get(&id) + .and_then(|&rh| arena.region(rh)) + .is_some_and(|r| r.layout_deferred), + } +} + /// The Rust-side model behind the Lua VM, in `lua.app_data`. It holds no mlua handles, whose /// persistent count the vendored `LUAI_MAXCSTACK` caps. pub(crate) struct Model { @@ -156,6 +181,24 @@ pub(crate) struct Model { /// re-resolve need not give back, so a write the reference invalidates for is not skipped as /// a no-op on it ([`Model::invalidate_frame`]). pub(crate) layout_unsettled: HashSet, + /// Per layout id, what its resolve owes beside the deferral bit itself (which the frame and + /// region carry, [`crate::widget::Frame::layout_deferred`]): `HOLD_*` bits. A node in + /// [`Self::layout_pending`] or [`Self::layout_force`] is queued; a deferred node pending is + /// held, the reference's `0x4` under `0x2`, whose resolve waits until its show, a read or a + /// reader's anchor read. + pub(crate) layout_hold: Vec, + /// Layout ids invalidated while deferred since the last resolve: each passes the invalidate to + /// what anchors to it, as the reference does at the write (`0x768178`โ€“`0x76819c`), which the + /// next resolve applies. + pub(crate) layout_pass: Vec, + /// Layout ids the next resolve takes as undeferred: a show or a load's end cleared the bit of + /// a pending one, which resolves at once (`0x7680c4` โ†’ `0x7680e0(1)`), or a getter reads a + /// pending one, resolving it without clearing the bit (`0x7a1a1d`). + pub(crate) layout_pull: Vec, + /// A show's resolve: the ids it resolves as it shows them going in, and the ids resolved for + /// them coming out, each pulled by an anchor read of one before it was, in the order their + /// `ApplyRect` runs, so the show fires their `OnSizeChanged` ([`super::visibility`]). + pub(crate) layout_chain: Option>, /// The screen the last converged resolve laid out against: a frame anchored to it is reached /// when it moves, as by any target's new rect. pub(crate) layout_screen: Option, @@ -1182,6 +1225,10 @@ impl Model { layout_force: Vec::new(), layout_pending: Vec::new(), layout_unsettled: HashSet::new(), + layout_hold: Vec::new(), + layout_pass: Vec::new(), + layout_pull: Vec::new(), + layout_chain: None, layout_screen: None, errors: Vec::new(), pending_error_dispatch: Vec::new(), @@ -1703,7 +1750,7 @@ impl Model { } self.touch_layout_frame(h); if let Some(&id) = self.frame_to_id.get(&h) { - self.layout_pending.push(id); + self.queue_invalidate(id); } } @@ -1715,7 +1762,7 @@ impl Model { // A region with no id was never anchored, so it holds no rect to lose. self.touch_layout_region(rh); if let Some(&id) = self.region_to_id.get(&rh) { - self.layout_pending.push(id); + self.queue_invalidate(id); } } @@ -1726,7 +1773,428 @@ impl Model { // A region with no id was never anchored, so it holds no rect. self.touch_layout_region(rh); if let Some(&id) = self.region_to_id.get(&rh) { + self.queue_force(id); + } + } + + /// Invalidate node `id` (`0x7680e0(0)`): queued for the next resolve, or, while it is deferred, + /// held pending, its invalidate passing to what anchors to it once (`0x768169`โ€“`0x76819c`). + pub(crate) fn queue_invalidate(&mut self, id: u32) { + if self.node_deferred(id) { + self.hold_invalidated(id, false); + } else { + self.layout_pending.push(id); + self.hold_set(id, HOLD_QUEUED); + } + } + + /// [`Self::queue_invalidate`] for a write that also clears the node's resolved bit, whose + /// resolve then stores without the keep test ([`Self::layout_force`]). + pub(crate) fn queue_force(&mut self, id: u32) { + if self.node_deferred(id) { + self.hold_invalidated(id, true); + } else { self.layout_force.push(id); + self.hold_set(id, HOLD_QUEUED_FORCE); + } + } + + /// A deferred node's invalidate: it sets `0x4` and passes on (`0x768169`โ€“`0x76819c`); one + /// already pending only writes the drain's retry counter, `+0x38 = 6` (`0x76814b`โ€“`0x768166`). + pub(super) fn hold_invalidated(&mut self, id: u32, force: bool) { + if self.hold_flags(id) & HOLD_HELD == 0 { + self.layout_pass.push(id); + } + self.hold_set(id, HOLD_HELD | if force { HOLD_FORCE } else { 0 }); + } + + /// Node `id`'s `HOLD_*` bits. + #[inline] + pub(crate) fn hold_flags(&self, id: u32) -> u8 { + self.layout_hold.get(id as usize).copied().unwrap_or(0) + } + + pub(super) fn hold_set(&mut self, id: u32, bits: u8) { + let i = id as usize; + if self.layout_hold.len() <= i { + self.layout_hold.resize(i + 1, 0); + } + self.layout_hold[i] |= bits; + } + + /// Whether node `id`'s deferral bit is set. + pub(crate) fn node_deferred(&self, id: u32) -> bool { + node_deferred(&self.arena, &self.id_to_frame, &self.id_to_region, id) + } + + /// The queued resolve of node `id`, just deferred, leaves the queue and waits with it, pending + /// (`0x768074`โ€“`0x7680b3`). + fn hold_queued(&mut self, id: u32) { + let f = self.hold_flags(id); + if f & (HOLD_QUEUED | HOLD_QUEUED_FORCE) != 0 { + let force = if f & HOLD_QUEUED_FORCE != 0 { + HOLD_FORCE + } else { + 0 + }; + self.hold_set(id, HOLD_HELD | force); + } + } + + /// `0x768060(1)` on frame `h`'s layout object. + pub(crate) fn defer_frame(&mut self, h: FrameHandle) { + let Some(f) = self.arena.frame_mut(h) else { + return; + }; + f.layout_deferred = true; + if let Some(&id) = self.frame_to_id.get(&h) { + self.hold_queued(id); + } + } + + /// `0x768060(1)` on region `rh`'s layout object. + pub(crate) fn defer_region(&mut self, rh: RegionHandle) { + let Some(r) = self.arena.region_mut(rh) else { + return; + }; + r.layout_deferred = true; + if let Some(&id) = self.region_to_id.get(&rh) { + self.hold_queued(id); + } + } + + /// `0x768060(0)` on frame `h`'s layout object: clear the bit, and a pending resolve runs now + /// (`0x7680c4` โ†’ `0x7680e0(1)`), here at the next resolve. Whether one is owed. + pub(crate) fn undefer_frame(&mut self, h: FrameHandle) -> bool { + let Some(f) = self.arena.frame_mut(h) else { + return false; + }; + f.layout_deferred = false; + match self.frame_to_id.get(&h) { + Some(&id) => self.pull_pending(id), + None => false, + } + } + + /// `0x768060(0)` on region `rh`'s layout object. + pub(crate) fn undefer_region(&mut self, rh: RegionHandle) -> bool { + let Some(r) = self.arena.region_mut(rh) else { + return false; + }; + r.layout_deferred = false; + match self.region_to_id.get(&rh) { + Some(&id) => self.pull_pending(id), + None => false, + } + } + + /// Node `id` resolves at the next resolve if it is pending, held or queued; whether it is. + pub(crate) fn pull_pending(&mut self, id: u32) -> bool { + let f = self.hold_flags(id); + if f & HOLD_HELD != 0 { + self.layout_pull.push(id); + self.name_layout_node(id); + } + f & (HOLD_HELD | HOLD_QUEUED | HOLD_QUEUED_FORCE) != 0 + } + + /// A getter's read of node `id` (`0x7a1a0b` โ†’ `0x7680e0(1)` while pending): a held node + /// resolves in the read's resolve, and its bit stays set. + pub(crate) fn pull_read(&mut self, id: u32) { + if self.hold_flags(id) & HOLD_HELD != 0 && self.node_deferred(id) { + self.layout_pull.push(id); + self.name_layout_node(id); + } + } + + /// The hide transition's layout step (`0x76ad50`): out of its load window, the frame and its + /// title region defer (`0x76ad70`, `0x76ad84`), and each region it drew stops drawing and defers + /// (`0x76adbf` โ†’ `0x77fc82`). + pub(crate) fn layout_on_hide(&mut self, h: FrameHandle) { + let Some(f) = self.arena.frame(h) else { + return; + }; + if f.load_window { + return; + } + let title = f.title_region; + let regions: Vec = f.regions.clone(); + self.defer_frame(h); + if let Some(t) = title { + self.defer_region(t); + } + // The frame was visible until this transition, so each shown region drew. + for rh in regions { + if Some(rh) != title && self.region_shown_bit(rh) { + self.defer_region(rh); + } + } + } + + /// The show transition's layout step for the frame (`0x76ae10`): out of its load window, the + /// frame and its title region undefer, resolving first (`0x76ae58`, `0x76ae6c`). Whether + /// either owes a resolve now, or an invalidate passed while deferred is yet to reach what it + /// may. + pub(crate) fn layout_on_show_frame(&mut self, h: FrameHandle) -> bool { + let Some(f) = self.arena.frame(h) else { + return false; + }; + if f.load_window { + return false; + } + let title = f.title_region; + let mut owed = self.undefer_frame(h); + if let Some(t) = title { + owed |= self.undefer_region(t); + } + owed || !self.layout_pass.is_empty() + } + + /// The show transition's region step, after the frame is marked visible (`0x76aeac` โ†’ + /// `0x77fcb0`): out of the frame's load window, each shown region starts drawing and + /// undefers (`0x77fce6`). Whether any owes a resolve now, as [`Self::layout_on_show_frame`]. + pub(crate) fn layout_on_show_regions(&mut self, h: FrameHandle) -> bool { + let Some(f) = self.arena.frame(h) else { + return false; + }; + if f.load_window { + return false; + } + let title = f.title_region; + let regions: Vec = f.regions.clone(); + let mut owed = false; + for rh in regions { + if Some(rh) != title && self.region_shown_bit(rh) { + owed |= self.undefer_region(rh); + } + } + owed || !self.layout_pass.is_empty() + } + + /// Both show steps at once, for a show with no handler to run between them. + pub(crate) fn layout_on_show(&mut self, h: FrameHandle) -> bool { + let frame = self.layout_on_show_frame(h); + self.layout_on_show_regions(h) || frame + } + + /// Whether a resolve of `roots` now can fire `OnSizeChanged`: a root frame has the handler, or + /// a deferred, pending frame an anchor read on the way would resolve (`0x7a2fc0`) does. With no + /// such handler the resolve shows nothing in the binding, and the next resolve, a getter's or + /// the drain's, gives the same rects. + pub(crate) fn resolve_fires(&self, roots: &[u32]) -> bool { + let fires = |f: FrameHandle| { + self.scripts + .get(&f) + .is_some_and(|s| s.contains("OnSizeChanged")) + }; + let anchors = |id: u32| -> Option { + match self.id_to_frame.get(&id) { + Some(h) => self.layout_inputs.get(h).map(|i| i.anchors), + None => self + .id_to_region + .get(&id) + .and_then(|rh| self.region_data.get(rh)) + .map(|d| d.anchors), + } + }; + let mut stack: Vec = Vec::new(); + for &id in roots { + if self.id_to_frame.get(&id).is_some_and(|&h| fires(h)) { + return true; + } + stack.extend( + anchors(id) + .iter() + .flat_map(|a| a.iter().map(|a| a.relative_to)), + ); + } + let mut seen: Vec = Vec::new(); + while let Some(t) = stack.pop() { + if seen.contains(&t) || self.hold_flags(t) & HOLD_HELD == 0 || !self.node_deferred(t) { + continue; + } + seen.push(t); + if self.id_to_frame.get(&t).is_some_and(|&th| fires(th)) { + return true; + } + stack.extend( + anchors(t) + .iter() + .flat_map(|a| a.iter().map(|a| a.relative_to)), + ); + } + false + } + + /// The layout ids [`Self::layout_on_show_frame`] undeferred: the frame and its title region. + pub(crate) fn show_frame_roots(&self, h: FrameHandle) -> Vec { + let mut out: Vec = self.frame_to_id.get(&h).copied().into_iter().collect(); + if let Some(t) = self.arena.frame(h).and_then(|f| f.title_region) { + out.extend(self.region_to_id.get(&t).copied()); + } + out + } + + /// The layout ids [`Self::layout_on_show_regions`] undeferred: the shown regions. + pub(crate) fn show_region_roots(&self, h: FrameHandle) -> Vec { + let Some(f) = self.arena.frame(h) else { + return Vec::new(); + }; + f.regions + .iter() + .filter(|&&rh| Some(rh) != f.title_region && self.region_shown_bit(rh)) + .filter_map(|rh| self.region_to_id.get(rh).copied()) + .collect() + } + + /// Whether node `id` is pending (`0x4`, [`Self::layout_hold`]): queued, or held under its + /// deferral. + pub(crate) fn node_pending(&self, id: u32) -> bool { + self.hold_flags(id) & (HOLD_HELD | HOLD_QUEUED | HOLD_QUEUED_FORCE) != 0 + } + + /// The arena's handler-free `Show`/`Hide` ([`WidgetArena::set_shown`]) with each transition's + /// layout step; a resolve the shows owe runs at the next resolve. + pub(crate) fn set_shown_silent(&mut self, h: FrameHandle, shown: bool) { + for f in self.arena.set_shown(h, shown) { + if shown { + self.layout_on_show(f); + } else { + self.layout_on_hide(f); + } + } + } + + /// The pre-load hook's step (`0x769770`): the frame, its title region and every region defer + /// (`0x7697cc`, `0x7697e0`, `0x769807`). + pub(crate) fn layout_load_begin(&mut self, h: FrameHandle) { + let Some(f) = self.arena.frame(h) else { + return; + }; + let regions: Vec = f.regions.clone(); + self.defer_frame(h); + for rh in regions { + if self.arena.region(rh).is_some_and(|r| !r.detached) { + self.defer_region(rh); + } + } + } + + /// The post-load hook's step (`0x76a2f0`): a visible frame undefers, with its title region and + /// each region it draws (`0x76a310`, `0x76a324`, `0x76a35b`). A hidden one stays deferred. + pub(crate) fn layout_load_end(&mut self, h: FrameHandle) { + let Some(f) = self.arena.frame(h) else { + return; + }; + if !f.effective_visible { + return; + } + let title = f.title_region; + let regions: Vec = f.regions.clone(); + self.undefer_frame(h); + if let Some(t) = title { + self.undefer_region(t); + } + for rh in regions { + if Some(rh) != title && self.region_draws(rh) { + self.undefer_region(rh); + } + } + } + + /// The region's own shown bit (`+0xc4`): not hidden, and linked to its owner. + fn region_shown_bit(&self, rh: RegionHandle) -> bool { + self.arena.region(rh).is_some_and(|r| !r.detached) + && self.region_data.get(&rh).is_none_or(|d| !d.hidden) + } + + /// Whether the region draws (`+0xc8`): shown, on a visible owner. + pub(crate) fn region_draws(&self, rh: RegionHandle) -> bool { + self.region_shown_bit(rh) + && self + .arena + .region(rh) + .and_then(|r| self.arena.frame(r.owner)) + .is_some_and(|f| f.effective_visible) + } + + /// Write region `rh`'s shown bit (`Show`/`Hide` and the engine's writes of `+0xc4`), with the + /// layout step of its start or stop drawing: out of the owner's load window it undefers + /// (`0x77fcb0` โ†’ `0x77fce6`) or defers (`0x77fc60` โ†’ `0x77fc82`). + pub(crate) fn set_region_hidden(&mut self, rh: RegionHandle, hidden: bool) { + let was = self.region_draws(rh); + self.region_data.entry(rh).or_default().hidden = hidden; + self.note_region_draw(rh, was); + } + + /// The layout step of a write to region `rh`'s shown bit that `set_region_hidden` could not + /// wrap, given whether it drew before. + pub(crate) fn note_region_draw(&mut self, rh: RegionHandle, drew: bool) { + let now = self.region_draws(rh); + if drew == now || self.region_owner_loading(rh) { + return; + } + if now { + self.undefer_region(rh); + } else { + self.defer_region(rh); + } + } + + pub(crate) fn region_owner_loading(&self, rh: RegionHandle) -> bool { + self.arena + .region(rh) + .and_then(|r| self.arena.frame(r.owner)) + .is_some_and(|f| f.load_window) + } + + /// The layout step of region `rh`'s re-link (`0x77fd10`), given whether it drew under its old + /// owner, whether that owner was in its load window and whether the owner changed: the new + /// owner's bit is copied (`0x77fd1e`โ€“`0x77fd28`), a stop on the old owner defers (`0x77fd47` โ†’ + /// `0x77fc60`), and a start on the new one undefers (`0x77fd8f` โ†’ `0x77fcb0`). A detach copies + /// nothing and only stops. + /// + /// `shown` is the link's shown argument, written here; `None` keeps the bit, as + /// `Region:SetParent` passes the region's own (`0x7733d0`). + pub(crate) fn relink_region_layout( + &mut self, + rh: RegionHandle, + drew: bool, + old_loading: bool, + moved: bool, + shown: Option, + ) { + let Some(r) = self.arena.region(rh) else { + return; + }; + if r.detached { + if let Some(shown) = shown { + self.region_data.entry(rh).or_default().hidden = !shown; + } + if drew && !old_loading { + self.defer_region(rh); + } + return; + } + let owner_bit = self.arena.frame(r.owner).is_some_and(|f| f.layout_deferred); + if owner_bit { + self.defer_region(rh); + } else { + self.undefer_region(rh); + } + if moved && drew && !old_loading { + self.defer_region(rh); + } + if let Some(shown) = shown { + self.region_data.entry(rh).or_default().hidden = !shown; + } + let loading = self.region_owner_loading(rh); + if self.region_draws(rh) { + if !loading { + self.undefer_region(rh); + } + } else if !moved && drew && !loading { + self.defer_region(rh); } } diff --git a/crates/benilla-ui/src/script/nameplate.rs b/crates/benilla-ui/src/script/nameplate.rs index ac71bcad..ae8c26b6 100644 --- a/crates/benilla-ui/src/script/nameplate.rs +++ b/crates/benilla-ui/src/script/nameplate.rs @@ -492,12 +492,12 @@ impl Plate { } for rh in [glow, skull, raid] { - model.region_data.entry(rh).or_default().hidden = true; + model.set_region_hidden(rh, true); } // Born retired and hidden: addons tell a live plate from a pooled one by `IsShown()`. // Silent, since nothing can have set a handler on a frame not yet handed out. - model.arena.set_shown(frame, false); + model.set_shown_silent(frame, false); model .nameplates @@ -683,7 +683,7 @@ impl Plate { }; model.region_data.entry(name).or_default().vertex_color = Some(c); // The glow really shows, as addons read mouseover from it; it only paints nothing. - model.region_data.entry(glow).or_default().hidden = !state.hovered; + model.set_region_hidden(glow, !state.hovered); } if last.as_ref().is_none_or(|l| { @@ -692,23 +692,23 @@ impl Plate { match (state.level, state.skull) { // The skull flag wins the seat, whatever the level. (_, true) => { - model.region_data.entry(level).or_default().hidden = true; - model.region_data.entry(skull).or_default().hidden = false; + model.set_region_hidden(level, true); + model.set_region_hidden(skull, false); } (Some(n), false) => { let c = state.level_colour; let data = model.region_data.entry(level).or_default(); data.text = Some(n.to_string()); data.vertex_color = Some([c[0], c[1], c[2], 1.0]); - data.hidden = false; - model.region_data.entry(skull).or_default().hidden = true; + model.set_region_hidden(level, false); + model.set_region_hidden(skull, true); model.touch_measure(level); model.clear_region_valid(level); } // No level yet and no skull: the seat stays empty. (None, false) => { - model.region_data.entry(level).or_default().hidden = true; - model.region_data.entry(skull).or_default().hidden = true; + model.set_region_hidden(level, true); + model.set_region_hidden(skull, true); } } } @@ -719,9 +719,9 @@ impl Plate { let (u0, v0) = (f32::from(idx & 3) * 0.25, f32::from(idx >> 2) * 0.25); let data = model.region_data.entry(raid).or_default(); data.tex_coords = Some(TexCoords::Rect([u0, u0 + 0.25, v0, v0 + 0.25])); - data.hidden = false; + model.set_region_hidden(raid, false); } - None => model.region_data.entry(raid).or_default().hidden = true, + None => model.set_region_hidden(raid, true), } } diff --git a/crates/benilla-ui/src/script/object.rs b/crates/benilla-ui/src/script/object.rs index 850d79c0..c979b432 100644 --- a/crates/benilla-ui/src/script/object.rs +++ b/crates/benilla-ui/src/script/object.rs @@ -23,7 +23,7 @@ mod events_regions; mod frame_state; mod layout_methods; pub(crate) use layout_methods::eff_scale; -pub(in crate::script) use layout_methods::settle; +pub(in crate::script) use layout_methods::settle_region; pub(crate) mod movable; pub(crate) mod toplevel; pub(crate) use movable::{cancel_on_hide, pump_move_or_size, FrameMove}; @@ -637,6 +637,10 @@ pub(super) fn create_frame( } let id = model.frame_id(h); model.layout_inputs.entry(h).or_default(); + // The builder's pre-load and post-load hooks (`0x6ee4d6`, `0x6ee4ea`): a frame born hidden + // is deferred, as a hidden parent leaves it. A template's load opens its own window. + model.layout_load_begin(h); + model.layout_load_end(h); id }; diff --git a/crates/benilla-ui/src/script/object/frame_state.rs b/crates/benilla-ui/src/script/object/frame_state.rs index c79722de..7c44713d 100644 --- a/crates/benilla-ui/src/script/object/frame_state.rs +++ b/crates/benilla-ui/src/script/object/frame_state.rs @@ -666,7 +666,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { )) })?, )?; - // Clamp-to-screen (`0x776c00`/`0x776cb0`, geometry flags bit 4): the layout resolve keeps the + // Clamp-to-screen (`0x776c00`/`0x776cb0`, geometry flags `0x10`): the layout resolve keeps the // frame's rect inside the window, size preserved. m.set( "SetClampedToScreen", diff --git a/crates/benilla-ui/src/script/object/layout_methods.rs b/crates/benilla-ui/src/script/object/layout_methods.rs index e46740f2..d1f4ce89 100644 --- a/crates/benilla-ui/src/script/object/layout_methods.rs +++ b/crates/benilla-ui/src/script/object/layout_methods.rs @@ -155,22 +155,18 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { // No `SetSize`: neither 1.12 method table has it. set_shared(lua, m, Side::Frame, "GetWidth", |lua, this: Table| { let h = frame_handle_of(lua, &this)?; - settle(lua); - let model = lua.app_data_ref::().expect("model"); - Ok(size_read(&model, h, true)) + Ok(size_read(lua, h, true)) })?; set_shared(lua, m, Side::Frame, "GetHeight", |lua, this: Table| { let h = frame_handle_of(lua, &this)?; - settle(lua); - let model = lua.app_data_ref::().expect("model"); - Ok(size_read(&model, h, false)) + Ok(size_read(lua, h, false)) })?; // GetCenter(): the resolved rect's centre in the frame's own units (screen รท effective scale, // y-up), as the reference's coordinate getters report; nil before the first resolve. set_shared(lua, m, Side::Frame, "GetCenter", |lua, this: Table| { let h = frame_handle_of(lua, &this)?; - settle(lua); + settle_frame(lua, h); let model = lua.app_data_ref::().expect("model"); let inv = 1.0 / eff_scale(&model, h); Ok(match model.resolved.get(&h) { @@ -204,7 +200,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { ] { set_shared(lua, m, Side::Frame, name, move |lua, this: Table| { let h = frame_handle_of(lua, &this)?; - settle(lua); + settle_frame(lua, h); let model = lua.app_data_mut::().expect("model"); let inv = 1.0 / eff_scale(&model, h); Ok(model.resolved.get(&h).map(|r| { @@ -221,13 +217,40 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { } /// Resolve the layout now if anything moved, as the reference answers a geometry query against -/// current layout; a settled tree costs one epoch compare. Deviation: `OnSizeChanged` fires at the -/// next layout drain (`UiScript::resolve`), not inside the getter as the reference's forced -/// `ApplyRect 0x76b580` does, because running Lua handlers inside a binding risks a borrow panic -/// or unbounded recursion. -pub(in crate::script) fn settle(lua: &Lua) { +/// current layout; a settled tree costs one epoch compare. The read frame `h`, if pending, resolves +/// inside the read, its deferral bit left set (`0x7a1a0b` โ†’ `0x7680e0(1)`), and its `OnSizeChanged` +/// fires here, as its `ApplyRect` (`0x76b580` โ†’ vt+0x48) runs inside the getter. +pub(in crate::script) fn settle_frame(lua: &Lua, h: FrameHandle) { + let id = lua + .app_data_ref::() + .expect("model") + .frame_to_id + .get(&h) + .copied(); + settle_node(lua, id); +} + +/// [`settle_frame`] for a region's rect, through the same getters (`0x7a1980`..). +pub(in crate::script) fn settle_region(lua: &Lua, rh: crate::widget::RegionHandle) { + let id = lua + .app_data_ref::() + .expect("model") + .region_to_id + .get(&rh) + .copied(); + settle_node(lua, id); +} + +fn settle_node(lua: &Lua, id: Option) { let mut model = lua.app_data_mut::().expect("model"); - crate::script::UiScript::resolve_layout(&mut model); + match id { + Some(id) if model.node_pending(id) => { + model.pull_read(id); + drop(model); + crate::script::visibility::resolve_and_fire(lua, vec![id]); + } + _ => crate::script::UiScript::resolve_layout(&mut model), + } } pub(crate) fn eff_scale(model: &Model, h: FrameHandle) -> f32 { @@ -243,18 +266,28 @@ pub(crate) fn eff_scale(model: &Model, h: FrameHandle) -> f32 { } } -/// `GetWidth`/`GetHeight`: the resolved span in the frame's own units (screen รท effective scale), -/// else the size it was given, where 0 means derived, as in the reference. -fn size_read(model: &Model, h: FrameHandle, width: bool) -> f32 { - if let Some(r) = model.resolved.get(&h) { - let span = if width { r.width() } else { r.height() }; - return span / eff_scale(model, h); +/// `GetWidth`/`GetHeight` (`0x7a1e00`, `0x7a2030`): the size the frame was given (`+0x1c`/`+0x20` +/// โ†’ `0x768420`/`0x768410`), with no resolve, whatever its anchors span (`0x7a1e99`โ€“`0x7a1ea5`). +/// Only a zero size resolves a pending frame (`0x7a1eb6`) and answers its rect's span, else 0. The +/// span is not divided by the effective scale, though the rect carries it: the answer is only +/// converted (`0x41ad70`, `0x41ae40`), where `GetLeft` divides (`0x7a1a63`). +fn size_read(lua: &Lua, h: FrameHandle, width: bool) -> f32 { + let set = { + let model = lua.app_data_ref::().expect("model"); + model + .layout_inputs + .get(&h) + .map_or(0.0, |i| if width { i.width } else { i.height }) + }; + if set != 0.0 { + return set; } + settle_frame(lua, h); + let model = lua.app_data_ref::().expect("model"); model - .layout_inputs + .resolved .get(&h) - .map(|i| if width { i.width } else { i.height }) - .unwrap_or(0.0) + .map_or(0.0, |r| if width { r.width() } else { r.height() }) } /// `SetPoint` (`0x7a2540`) for a frame: the argument ladder is [`super::anchor_args`], shared with diff --git a/crates/benilla-ui/src/script/region.rs b/crates/benilla-ui/src/script/region.rs index 7ed7d0c3..335aa6f0 100644 --- a/crates/benilla-ui/src/script/region.rs +++ b/crates/benilla-ui/src/script/region.rs @@ -77,7 +77,10 @@ pub(super) fn link_region( layer: crate::order::DrawLayer, shown: bool, ) { - if model.arena.set_region_owner(rh, Some(owner)) { + let drew = model.region_draws(rh); + let old_loading = model.region_owner_loading(rh); + let moved = model.arena.set_region_owner(rh, Some(owner)); + if moved { // The owner supplies the region's scale to layout; the re-link invalidates nothing, so // the region keeps its rect until the resolve reaches it. model.unsettle_region(rh); @@ -85,7 +88,7 @@ pub(super) fn link_region( if let Some(r) = model.arena.region_mut(rh) { r.draw_layer = layer; } - model.region_data.entry(rh).or_default().hidden = !shown; + model.relink_region_layout(rh, drew, old_loading, moved, Some(shown)); } /// Apply a Button state machine's writes to the regions' shown bit, the one `Show`/`Hide` write. @@ -93,7 +96,7 @@ pub(super) fn link_region( pub(super) fn apply_shown_writes(model: &mut Model, writes: crate::widget::ShownWrites) { for (rh, shown) in writes.0 { if model.arena.region(rh).is_some() { - model.region_data.entry(rh).or_default().hidden = !shown; + model.set_region_hidden(rh, !shown); } } } @@ -527,11 +530,15 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> { } _ => return Err(wrong_type()), }; - if model.arena.set_region_owner(rh, new_owner) { + let drew = model.region_draws(rh); + let old_loading = model.region_owner_loading(rh); + let moved = model.arena.set_region_owner(rh, new_owner); + if moved { // The owner supplies the region's scale to layout; the re-link invalidates nothing // (`0x77fd10`), so the region keeps its rect until the resolve reaches it. model.unsettle_region(rh); } + model.relink_region_layout(rh, drew, old_loading, moved, None); Ok(()) }, )?; @@ -542,7 +549,7 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> { lua.create_function(|lua, this: Table| { let rh = region_handle_of(lua, &this)?; let mut model = lua.app_data_mut::().expect("model"); - model.region_data.entry(rh).or_default().hidden = false; + model.set_region_hidden(rh, false); Ok(()) })?, )?; @@ -552,7 +559,7 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> { lua.create_function(|lua, this: Table| { let rh = region_handle_of(lua, &this)?; let mut model = lua.app_data_mut::().expect("model"); - model.region_data.entry(rh).or_default().hidden = true; + model.set_region_hidden(rh, true); Ok(()) })?, )?; @@ -774,7 +781,12 @@ pub(crate) fn free_regions(model: &mut Model, dying: &[RegionHandle]) { graph's readers of it or the nodes named since" ); } - // Each dependent re-derives its edges and keeps the state it has. + // Each dependent re-derives its edges and keeps the state it has, in handle order, as a naming + // can mint an id. + regions.sort_unstable_by_key(|rh| rh.fingerprint_bits()); + regions.dedup(); + frames.sort_unstable_by_key(|h| h.fingerprint_bits()); + frames.dedup(); for rh in regions { model.unsettle_region(rh); } @@ -912,13 +924,13 @@ pub(super) fn region_set_point(lua: &Lua, this: &Table, args: &MultiValue) -> ml Ok(()) } -/// `Region:GetWidth()`/`GetHeight()` (`0x7a1e00`, `0x7a2030`): the geometry-vtable call the rect -/// resolver also makes (`0x767579`), so the Lua size is the rect's. Per axis, a Texture answers -/// its authored size unless exactly 0, else its texel extent (`0x770720`, `0x770790`). A -/// FontString answers its authored size unless 0 (`0x77294a`), else its measure, either floored -/// at one unit (`0x772930`, `0x772a60`); the width is unwrapped, as `GetStringWidth` gives it -/// (`0x772890`, `0x79e510`), the height wrapped (`0x7729b0`). A title region answers its field -/// (`0x768420`, `0x768410`). +/// A region's own size, the geometry-vtable call (`+0x1c`/`+0x20`) the rect resolver also makes +/// (`0x767579`), which `GetWidth`/`GetHeight` answer when non-zero (`0x7a1e00`, `0x7a2030`). Per +/// axis, a Texture answers its authored size unless exactly 0, else its texel extent (`0x770720`, +/// `0x770790`). A FontString answers its authored size unless 0 (`0x77294a`), else its measure, +/// either floored at one unit (`0x772930`, `0x772a60`); the width is unwrapped, as +/// `GetStringWidth` gives it (`0x772890`, `0x79e510`), the height wrapped (`0x7729b0`). A title +/// region answers its field (`0x768420`, `0x768410`). pub(super) fn measured_wh(lua: &Lua, this: &Table) -> mlua::Result<(f32, f32)> { let rh = region_handle_of(lua, this)?; // Measures this tick when a host font engine is installed; a no-op for a Texture or a diff --git a/crates/benilla-ui/src/script/region/layout.rs b/crates/benilla-ui/src/script/region/layout.rs index e77d8eb6..5ddb7443 100644 --- a/crates/benilla-ui/src/script/region/layout.rs +++ b/crates/benilla-ui/src/script/region/layout.rs @@ -74,12 +74,15 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { // No `SetSize`: not a 1.12 verb. + // The frame's `GetWidth`/`GetHeight` (`0x7a1e00`, `0x7a2030`): the region's own size + // ([`measured_wh`]) when non-zero, with no resolve; else, resolving a pending region, its + // rect's span, not divided by the owner's effective scale, or 0 with no rect. set_shared(lua, m, Side::Region, "GetWidth", |lua, this: Table| { - Ok(measured_wh(lua, &this)?.0) + region_size_read(lua, &this, true) })?; set_shared(lua, m, Side::Region, "GetHeight", |lua, this: Table| { - Ok(measured_wh(lua, &this)?.1) + region_size_read(lua, &this, false) })?; // GetLeft/GetRight/GetTop/GetBottom: the resolved edges in the owner's units (y up, screen @@ -93,7 +96,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { ] { set_shared(lua, m, Side::Region, name, move |lua, this: Table| { let rh = region_handle_of(lua, &this)?; - crate::script::object::settle(lua); + crate::script::object::settle_region(lua, rh); let model = lua.app_data_ref::().expect("model"); let inv = 1.0 / owner_scale(&model, rh); Ok(model.region_resolved.get(&rh).map(|r| { @@ -177,7 +180,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { // two nils while the valid bit is clear; it resolves first, as the edge readers do. set_shared(lua, m, Side::Region, "GetCenter", |lua, this: Table| { let rh = region_handle_of(lua, &this)?; - crate::script::object::settle(lua); + crate::script::object::settle_region(lua, rh); let model = lua.app_data_ref::().expect("model"); let inv = 1.0 / owner_scale(&model, rh); Ok(match model.region_resolved.get(&rh) { @@ -244,3 +247,27 @@ fn owner_scale(model: &Model, rh: crate::widget::RegionHandle) -> f32 { .map(|r| crate::script::object::eff_scale(model, r.owner)) .unwrap_or(1.0) } + +/// The region side of `GetWidth`/`GetHeight`; see the bindings. +fn region_size_read(lua: &Lua, this: &Table, width: bool) -> mlua::Result { + let (w, h) = measured_wh(lua, this)?; + let set = if width { w } else { h }; + let rh = region_handle_of(lua, this)?; + // A FontString's size is floored at one unit (`0x772930`, `0x772a60`), never 0, so it never + // reads its rect; its 0 is a host measure still in flight, which answers 0. + let text = lua + .app_data_ref::() + .expect("model") + .arena + .region(rh) + .is_some_and(|r| matches!(r.kind, crate::widget::RegionKind::FontString)); + if set != 0.0 || text { + return Ok(set); + } + crate::script::object::settle_region(lua, rh); + let model = lua.app_data_ref::().expect("model"); + Ok(model + .region_resolved + .get(&rh) + .map_or(0.0, |r| if width { r.width() } else { r.height() })) +} diff --git a/crates/benilla-ui/src/script/region/paint.rs b/crates/benilla-ui/src/script/region/paint.rs index 6804014a..13c09d8b 100644 --- a/crates/benilla-ui/src/script/region/paint.rs +++ b/crates/benilla-ui/src/script/region/paint.rs @@ -493,7 +493,9 @@ impl crate::script::UiScript { }) .filter_map(|(rh, _)| model.region_to_id.get(rh).copied()) .collect(); - model.layout_pending.extend(art_sized); + for id in art_sized { + model.queue_invalidate(id); + } // 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/tests/anchors.rs b/crates/benilla-ui/src/script/tests/anchors.rs index 6139bcd6..afda25f1 100644 --- a/crates/benilla-ui/src/script/tests/anchors.rs +++ b/crates/benilla-ui/src/script/tests/anchors.rs @@ -350,3 +350,98 @@ fn geometry_answers_within_the_call_stack_that_moved_it() { (Some(250.0), Some(85.0)) ); } + +/// `GetWidth`/`GetHeight` (`0x7a1e00`, `0x7a2030`) answer the size the object was given, whatever +/// its anchors span; only a zero size answers the rect's span, which is not divided by the +/// effective scale (`0x41ad70`/`0x41ae40` only, where `GetLeft` divides at `0x7a1a63`), else 0. +#[test] +fn get_width_answers_the_set_size_else_the_rect_span_unscaled() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + SizeBase = CreateFrame("Frame", "SizeBase") + SizeBase:SetPoint("BOTTOMLEFT", 0, 0) + SizeBase:SetWidth(400); SizeBase:SetHeight(200) + SizeSet = CreateFrame("Frame", "SizeSet") + SizeSet:SetAllPoints(SizeBase) + SizeSet:SetWidth(100) + SizeTex = SizeSet:CreateTexture("SizeTex") + SizeTex:SetAllPoints(SizeSet) + SizeHalf = CreateFrame("Frame", "SizeHalf") + SizeHalf:SetScale(0.5) + SizeHalf:SetAllPoints(SizeBase) + SizeLoose = CreateFrame("Frame", "SizeLoose") + SizeLooseTex = SizeLoose:CreateTexture("SizeLooseTex") + "#, + ) + .unwrap(); + s.resolve(); + let num = |e: &str| s.eval::(&format!("return {e}")).unwrap(); + assert_eq!( + num("SizeSet:GetWidth()"), + 100.0, + "the set width, not the span" + ); + assert_eq!(num("SizeSet:GetRight() - SizeSet:GetLeft()"), 400.0); + assert_eq!(num("SizeSet:GetHeight()"), 200.0, "no set height: the span"); + assert_eq!( + num("SizeTex:GetWidth()"), + 400.0, + "a size-less texture: its rect" + ); + assert_eq!(num("SizeTex:GetHeight()"), 200.0); + assert_eq!( + num("SizeHalf:GetWidth()"), + 400.0, + "the span, not over the scale" + ); + assert_eq!(num("SizeHalf:GetRight() - SizeHalf:GetLeft()"), 800.0); + assert_eq!(num("SizeLoose:GetWidth()"), 0.0, "no size and no rect"); + assert_eq!(num("SizeLooseTex:GetHeight()"), 0.0); +} + +/// A set size answers without a resolve (`0x7a1e99`โ€“`0x7a1ea5`), so a pending resolve stays +/// pending and its `OnSizeChanged` waits for the drain; a zero size resolves it inside the read. +#[test] +fn get_width_resolves_only_at_a_zero_size() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + log = {} + SizeAnchor = CreateFrame("Frame", "SizeAnchor") + SizeAnchor:SetPoint("BOTTOMLEFT", 0, 0) + SizeAnchor:SetWidth(300); SizeAnchor:SetHeight(100) + SizeFixed = CreateFrame("Frame", "SizeFixed") + SizeFixed:SetPoint("BOTTOMLEFT", 0, 0) + SizeFixed:SetWidth(10); SizeFixed:SetHeight(10) + SizeSpan = CreateFrame("Frame", "SizeSpan") + SizeSpan:SetAllPoints(SizeAnchor) + "#, + ) + .unwrap(); + s.resolve(); + s.run( + r#" + SizeFixed:SetScript("OnSizeChanged", function() table.insert(log, "fixed") end) + SizeSpan:SetScript("OnSizeChanged", function() table.insert(log, "span") end) + SizeFixed:SetWidth(20) + table.insert(log, "w " .. SizeFixed:GetWidth()) + SizeSpan:ClearAllPoints() + SizeSpan:SetPoint("BOTTOMLEFT", SizeAnchor, "BOTTOMLEFT", 0, 0) + SizeSpan:SetPoint("TOPRIGHT", SizeAnchor, "TOPRIGHT", -100, 0) + table.insert(log, "w " .. SizeSpan:GetWidth()) + "#, + ) + .unwrap(); + assert_eq!( + s.eval::("return table.concat(log, ',')").unwrap(), + "w 20,span,w 200" + ); + s.resolve(); + assert_eq!( + s.eval::("return table.concat(log, ',')").unwrap(), + "w 20,span,w 200,fixed" + ); +} diff --git a/crates/benilla-ui/src/script/tests/hidden_defer.rs b/crates/benilla-ui/src/script/tests/hidden_defer.rs new file mode 100644 index 00000000..8c299e31 --- /dev/null +++ b/crates/benilla-ui/src/script/tests/hidden_defer.rs @@ -0,0 +1,568 @@ +//! A hidden frame's layout waits. Hiding sets the layout object's deferral bit (`0x2` of +//! `G+0x3c`), on the frame and its regions (`0x76ad50` โ†’ `0x768060(1)`; regions `0x77fc60`), and a +//! load sets it too (`0x769770`), which the load's end clears only for a visible frame +//! (`0x76a2f0`). While it is set an invalidate marks the object pending (`0x4`) and passes the +//! invalidate to what anchors to it, but queues no resolve (`0x768169`โ€“`0x76819c`), so `ApplyRect` +//! and its `OnSizeChanged` (`0x76b580`) wait for one of three things: the show, which resolves the +//! frame before its regions and children show (`0x76ae58` โ†’ `0x768060(0)` โ†’ `0x7680e0(1)`); a +//! getter, which resolves it inside the read and leaves the bit set (`0x7a1980`; `0x7a1e00` only +//! at a zero size); or an anchor read by an object that resolves, which clears the bit for good +//! (`0x7a2fb8`โ€“`0x7a2fc0`). + +use super::common::script; +use crate::script::UiScript; + +fn xml(s: &UiScript, text: &str) { + let doc = crate::framexml::parse(text).expect("valid FrameXML"); + let report = crate::loader::load(s, &doc, &|_: &str| None); + assert!(report.errors.is_empty(), "errors: {:?}", report.errors); +} + +fn int(s: &UiScript, expr: &str) -> i64 { + s.eval::(&format!("return {expr}")).unwrap() +} + +fn num(s: &UiScript, expr: &str) -> f64 { + s.eval::(&format!("return {expr}")).unwrap() +} + +/// Size changes made while hidden fire nothing at the drain; the show fires once, with the last +/// size, inside `Show` and ahead of the frame's own `OnShow`. +#[test] +fn a_size_change_while_hidden_fires_once_inside_show() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + fires, log = 0, {} + DeferPanel = CreateFrame("Frame", "DeferPanel") + DeferPanel:SetPoint("BOTTOMLEFT", 10, 10) + DeferPanel:SetWidth(100); DeferPanel:SetHeight(50) + "#, + ) + .unwrap(); + s.resolve(); + s.run( + r#" + DeferPanel:Hide() + DeferPanel:SetScript("OnSizeChanged", function() + fires = fires + 1; last_w = arg1; table.insert(log, "size") + end) + DeferPanel:SetScript("OnShow", function() table.insert(log, "show") end) + DeferPanel:SetWidth(20) + "#, + ) + .unwrap(); + s.resolve(); + s.run("DeferPanel:SetWidth(30)").unwrap(); + s.resolve(); + s.resolve(); + assert_eq!(int(&s, "fires"), 0, "a hidden frame's resolve waits"); + + s.run("DeferPanel:Show(); fires_in_show = fires").unwrap(); + assert_eq!( + int(&s, "fires_in_show"), + 1, + "the show resolves it, inside Show" + ); + assert_eq!(num(&s, "last_w"), 30.0, "once, with the last size"); + assert_eq!( + s.eval::("return table.concat(log, ',')").unwrap(), + "size,show", + "the show resolves the frame before it is shown and notified" + ); + s.resolve(); + assert_eq!(int(&s, "fires"), 1, "and the drain owes nothing more"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// A `hidden="true"` frame is deferred from its load on, so its `OnSizeChanged` waits for its +/// show; a shown one fires at its first resolve. +#[test] +fn a_hidden_xml_frame_does_not_fire_on_size_changed_until_shown() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + xml( + &s, + r#" + + + + hidden_fires = (hidden_fires or 0) + 1 + + + + + shown_fires = (shown_fires or 0) + 1 + + "#, + ); + for _ in 0..3 { + s.resolve(); + } + assert_eq!(int(&s, "shown_fires or 0"), 1, "the shown frame fires"); + assert_eq!(int(&s, "hidden_fires or 0"), 0, "the hidden one does not"); + + s.run("DeferXmlHidden:Show(); in_show = hidden_fires or 0") + .unwrap(); + assert_eq!(int(&s, "in_show"), 1, "its show resolves it"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// A getter resolves a hidden frame on read and answers the new size, but leaves it deferred: a +/// later write it does not read waits again. +#[test] +fn a_getter_on_a_hidden_frame_answers_the_new_size_and_leaves_it_deferred() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + fires = 0 + DeferRead = CreateFrame("Frame", "DeferRead") + DeferRead:SetPoint("BOTTOMLEFT", 10, 10) + DeferRead:SetWidth(100); DeferRead:SetHeight(50) + DeferRead:SetScript("OnSizeChanged", function() fires = fires + 1 end) + "#, + ) + .unwrap(); + s.resolve(); + assert_eq!(int(&s, "fires"), 1); + + s.run("DeferRead:Hide(); DeferRead:SetWidth(20)").unwrap(); + assert_eq!(num(&s, "DeferRead:GetRight() - DeferRead:GetLeft()"), 20.0); + assert_eq!(num(&s, "DeferRead:GetWidth()"), 20.0); + s.resolve(); + assert_eq!( + int(&s, "fires"), + 2, + "the read resolved it, so its size change fires" + ); + + s.run("DeferRead:SetWidth(40)").unwrap(); + s.resolve(); + s.resolve(); + assert_eq!( + int(&s, "fires"), + 2, + "unread, the next write waits: the read left it deferred" + ); + s.run("DeferRead:Show()").unwrap(); + assert_eq!(int(&s, "fires"), 3); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// A visible frame anchored to a hidden one is invalidated through it, and its resolve reads the +/// hidden frame's anchor, which resolves that frame and clears its deferral. A hidden frame no +/// visible one reads still waits, and a chain of hidden frames resolves through to the reader. +#[test] +fn a_visible_frame_anchored_to_a_hidden_one_resolves_through_it() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + read_fires, unread_fires, link_fires = 0, 0, 0 + DeferTarget = CreateFrame("Frame", "DeferTarget") + DeferTarget:SetPoint("BOTTOMLEFT", 10, 10) + DeferTarget:SetWidth(100); DeferTarget:SetHeight(50) + DeferReader = CreateFrame("Frame", "DeferReader") + DeferReader:SetPoint("BOTTOMLEFT", DeferTarget, "BOTTOMRIGHT", 5, 0) + DeferReader:SetWidth(10); DeferReader:SetHeight(10) + + DeferUnread = CreateFrame("Frame", "DeferUnread") + DeferUnread:SetPoint("TOPLEFT", 10, -10) + DeferUnread:SetWidth(100); DeferUnread:SetHeight(50) + + -- A chain: a visible reader of a hidden link that reads a hidden root. + DeferRoot = CreateFrame("Frame", "DeferRoot") + DeferRoot:SetPoint("TOPRIGHT", -10, -10) + DeferRoot:SetWidth(100); DeferRoot:SetHeight(50) + DeferLink = CreateFrame("Frame", "DeferLink") + DeferLink:SetPoint("TOPRIGHT", DeferRoot, "TOPLEFT", 0, 0) + DeferLink:SetWidth(20); DeferLink:SetHeight(20) + DeferTail = CreateFrame("Frame", "DeferTail") + DeferTail:SetPoint("TOPRIGHT", DeferLink, "TOPLEFT", 0, 0) + DeferTail:SetWidth(10); DeferTail:SetHeight(10) + "#, + ) + .unwrap(); + s.resolve(); + s.run( + r#" + DeferTarget:Hide(); DeferUnread:Hide(); DeferRoot:Hide(); DeferLink:Hide() + DeferTarget:SetScript("OnSizeChanged", function() read_fires = read_fires + 1 end) + DeferUnread:SetScript("OnSizeChanged", function() unread_fires = unread_fires + 1 end) + DeferLink:SetScript("OnSizeChanged", function() link_fires = link_fires + 1 end) + DeferTarget:SetWidth(200) + DeferUnread:SetWidth(200) + DeferRoot:SetWidth(300) + "#, + ) + .unwrap(); + s.resolve(); + assert_eq!( + int(&s, "read_fires"), + 1, + "the reader's anchor read resolved it" + ); + assert_eq!(int(&s, "unread_fires"), 0, "nothing read this one"); + assert_eq!(int(&s, "link_fires"), 0, "the link only moved"); + + s.run( + r#" + reader_left = DeferReader:GetLeft(); target_right = DeferTarget:GetRight() + tail_right = DeferTail:GetRight(); root_left = DeferRoot:GetLeft() + "#, + ) + .unwrap(); + assert_eq!(num(&s, "reader_left"), num(&s, "target_right") + 5.0); + assert_eq!(num(&s, "root_left - DeferRoot:GetRight()"), -300.0); + assert_eq!( + num(&s, "tail_right"), + num(&s, "root_left") - 20.0, + "the tail reads through the hidden link to the hidden root" + ); + + // The read cleared the target's deferral for good: hidden, it now resolves at the drain. + s.run("DeferTarget:SetWidth(250)").unwrap(); + s.resolve(); + assert_eq!(int(&s, "read_fires"), 2); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// A hidden frame's regions are deferred with it: a region read resolves the region and, through +/// its anchor, the frame; and the show resolves both. +#[test] +fn a_region_of_a_hidden_frame_waits_with_it() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + fires = 0 + DeferOwner = CreateFrame("Frame", "DeferOwner") + DeferOwner:SetPoint("BOTTOMLEFT", 10, 10) + DeferOwner:SetWidth(100); DeferOwner:SetHeight(50) + DeferArt = DeferOwner:CreateTexture(nil, "ARTWORK") + DeferArt:SetPoint("TOPLEFT", DeferOwner, "TOPLEFT", 0, 0) + DeferArt:SetPoint("BOTTOMRIGHT", DeferOwner, "BOTTOMRIGHT", 0, 0) + DeferOwner:SetScript("OnSizeChanged", function() fires = fires + 1 end) + "#, + ) + .unwrap(); + s.resolve(); + assert_eq!(int(&s, "fires"), 1); + assert_eq!(num(&s, "DeferArt:GetRight() - DeferArt:GetLeft()"), 100.0); + + s.run("DeferOwner:Hide(); DeferOwner:SetWidth(60)").unwrap(); + s.resolve(); + assert_eq!(int(&s, "fires"), 1, "the frame waits"); + assert_eq!(num(&s, "DeferArt:GetRight() - DeferArt:GetLeft()"), 60.0); + s.resolve(); + assert_eq!( + int(&s, "fires"), + 2, + "the region's read resolved its anchor target, the frame" + ); + + s.run("DeferOwner:SetWidth(80)").unwrap(); + s.resolve(); + assert_eq!( + int(&s, "fires"), + 3, + "that anchor read cleared the frame's deferral for good" + ); + s.run("DeferOwner:Show()").unwrap(); + s.resolve(); + assert_eq!(num(&s, "DeferArt:GetRight() - DeferArt:GetLeft()"), 80.0); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// The show resolves the frame before its children show, and an anchor read on the way resolves +/// the hidden frame it reads first: each `OnSizeChanged` fires where its `ApplyRect` runs. +#[test] +fn a_show_fires_each_size_change_where_its_resolve_runs() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + log = {} + function note(tag) return function() table.insert(log, tag) end end + DeferHost = CreateFrame("Frame", "DeferHost") + DeferHost:SetPoint("BOTTOMLEFT", 10, 10) + DeferHost:SetWidth(100); DeferHost:SetHeight(50) + DeferKid = CreateFrame("Frame", "DeferKid", DeferHost) + DeferKid:SetPoint("TOPLEFT", DeferHost, "TOPLEFT", 0, 0) + DeferKid:SetWidth(10); DeferKid:SetHeight(10) + DeferSide = CreateFrame("Frame", "DeferSide") + DeferSide:SetPoint("TOPLEFT", 300, -10) + DeferSide:SetWidth(40); DeferSide:SetHeight(40) + DeferNext = CreateFrame("Frame", "DeferNext") + DeferNext:SetPoint("TOPLEFT", DeferSide, "TOPRIGHT", 0, 0) + DeferNext:SetWidth(10); DeferNext:SetHeight(10) + "#, + ) + .unwrap(); + s.resolve(); + s.run( + r#" + DeferHost:Hide(); DeferSide:Hide(); DeferNext:Hide() + DeferHost:SetScript("OnSizeChanged", note("host size")) + DeferHost:SetScript("OnShow", note("host show")) + DeferKid:SetScript("OnSizeChanged", note("kid size")) + DeferKid:SetScript("OnShow", note("kid show")) + DeferSide:SetScript("OnSizeChanged", note("side size")) + DeferNext:SetScript("OnSizeChanged", note("next size")) + DeferHost:SetWidth(120); DeferKid:SetWidth(12) + DeferSide:SetWidth(44); DeferNext:SetWidth(11) + "#, + ) + .unwrap(); + s.resolve(); + assert_eq!( + s.eval::("return table.getn(log)").unwrap(), + 0, + "all of them wait" + ); + s.run("DeferHost:Show(); DeferNext:Show()").unwrap(); + assert_eq!( + s.eval::("return table.concat(log, ',')").unwrap(), + "host size,kid size,kid show,host show,side size,next size", + "the frame before its child and notifies, and a read target before its reader" + ); + s.resolve(); + assert_eq!(s.eval::("return table.getn(log)").unwrap(), 6); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// A frame created under a hidden parent is deferred from its creation, as the builder's load +/// leaves a hidden frame; under a visible one it resolves at the drain. +#[test] +fn a_frame_created_under_a_hidden_parent_waits() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + fires = {} + local function count(tag) return function() fires[tag] = (fires[tag] or 0) + 1 end end + DeferShut = CreateFrame("Frame", "DeferShut") + DeferOpen = CreateFrame("Frame", "DeferOpen") + for _, p in ipairs({ DeferShut, DeferOpen }) do + p:SetPoint("CENTER", 0, 0); p:SetWidth(100); p:SetHeight(100) + end + DeferShut:Hide() + for _, p in ipairs({ DeferShut, DeferOpen }) do + local f = CreateFrame("Frame", nil, p) + f:SetPoint("CENTER", 0, 0); f:SetWidth(30); f:SetHeight(30) + f:SetScript("OnSizeChanged", count(p:GetName())) + end + "#, + ) + .unwrap(); + s.resolve(); + assert_eq!(s.eval::("return fires.DeferOpen or 0").unwrap(), 1); + assert_eq!(s.eval::("return fires.DeferShut or 0").unwrap(), 0); + s.run("DeferShut:Show()").unwrap(); + assert_eq!(s.eval::("return fires.DeferShut or 0").unwrap(), 1); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// A region shown on a visible owner undefers and resolves; one hidden on it defers and waits. +#[test] +fn a_region_hidden_on_a_visible_frame_waits_and_its_show_resolves_it() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + DeferBase = CreateFrame("Frame", "DeferBase") + DeferBase:SetPoint("BOTTOMLEFT", 10, 10) + DeferBase:SetWidth(100); DeferBase:SetHeight(50) + DeferMark = DeferBase:CreateTexture(nil, "ARTWORK") + DeferMark:SetPoint("BOTTOMLEFT", DeferBase, "BOTTOMLEFT", 0, 0) + DeferMark:SetWidth(10); DeferMark:SetHeight(10) + "#, + ) + .unwrap(); + s.resolve(); + s.run("DeferMark:Hide(); DeferMark:SetPoint('BOTTOMLEFT', DeferBase, 'BOTTOMLEFT', 7, 0)") + .unwrap(); + s.resolve(); + let held = s.extract().len(); + s.run("DeferMark:Show()").unwrap(); + s.resolve(); + assert_eq!(num(&s, "DeferMark:GetLeft() - DeferBase:GetLeft()"), 7.0); + assert!(s.extract().len() > held, "it draws again"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// A getter's resolve fires `OnSizeChanged` inside the getter, as its `ApplyRect` runs there +/// (`0x7a1a1d` โ†’ `0x76b580` โ†’ vt+0x48); the show fires the next size inside `Show`, and the drain +/// fires neither again. +#[test] +fn a_getter_fires_the_size_change_it_resolves_inside_the_read() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + log = {} + DeferGet = CreateFrame("Frame", "DeferGet") + DeferGet:SetPoint("BOTTOMLEFT", 10, 10) + DeferGet:SetWidth(10); DeferGet:SetHeight(10) + "#, + ) + .unwrap(); + s.resolve(); + s.run( + r#" + DeferGet:SetScript("OnSizeChanged", function() + table.insert(log, "size " .. arg1) + end) + DeferGet:Hide(); DeferGet:SetWidth(20) + "#, + ) + .unwrap(); + s.resolve(); + s.run( + r#" + table.insert(log, "read") + DeferGet:GetLeft() + table.insert(log, "read done") + DeferGet:SetWidth(30) + table.insert(log, "show") + DeferGet:Show() + table.insert(log, "show done") + "#, + ) + .unwrap(); + s.resolve(); + assert_eq!( + s.eval::("return table.concat(log, ',')").unwrap(), + "read,size 20,read done,show,size 30,show done", + "each size fires inside the call that resolves it, and the drain fires no stale one" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// A visible frame's getter on a queued resolve fires it inside the read too: the getter resolves +/// whatever is pending (`0x7a1a0b` โ†’ `0x7680e0(1)`). +#[test] +fn a_getter_on_a_queued_visible_frame_fires_inside_the_read() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + log = {} + QueuedGet = CreateFrame("Frame", "QueuedGet") + QueuedGet:SetPoint("BOTTOMLEFT", 10, 10) + QueuedGet:SetWidth(10); QueuedGet:SetHeight(10) + "#, + ) + .unwrap(); + s.resolve(); + s.run( + r#" + QueuedGet:SetScript("OnSizeChanged", function() + table.insert(log, "size " .. arg1) + end) + QueuedGet:SetWidth(25) + table.insert(log, "read") + QueuedGet:GetRight() + table.insert(log, "read done") + "#, + ) + .unwrap(); + s.resolve(); + assert_eq!( + s.eval::("return table.concat(log, ',')").unwrap(), + "read,size 25,read done" + ); +} + +/// The show resolves the frame before marking it visible (`0x76ae58`, then `0x76ae7b`), and its +/// regions after (`0x76aeac` โ†’ `0x77fcb0`): a deferred target the frame's anchor read pulls fires +/// with the frame not yet visible, one its region's read pulls with it visible. +#[test] +fn a_target_pulled_by_a_shown_frame_fires_before_its_mark_and_by_its_region_after() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + log = {} + PullHost = CreateFrame("Frame", "PullHost") + PullHost:SetPoint("BOTTOMLEFT", 10, 10) + PullHost:SetWidth(100); PullHost:SetHeight(50) + PullTex = PullHost:CreateTexture("PullTex") + PullFrameTarget = CreateFrame("Frame", "PullFrameTarget") + PullFrameTarget:SetPoint("TOPLEFT", 300, -10) + PullFrameTarget:SetWidth(40); PullFrameTarget:SetHeight(40) + PullRegionTarget = CreateFrame("Frame", "PullRegionTarget") + PullRegionTarget:SetPoint("TOPLEFT", 400, -10) + PullRegionTarget:SetWidth(40); PullRegionTarget:SetHeight(40) + "#, + ) + .unwrap(); + s.resolve(); + s.run( + r#" + PullHost:Hide(); PullFrameTarget:Hide(); PullRegionTarget:Hide() + local function note(tag) + return function() table.insert(log, tag .. " " .. tostring(PullHost:IsVisible())) end + end + PullFrameTarget:SetScript("OnSizeChanged", note("frame read")) + PullRegionTarget:SetScript("OnSizeChanged", note("region read")) + PullFrameTarget:SetWidth(44); PullRegionTarget:SetWidth(46) + PullHost:ClearAllPoints() + PullHost:SetPoint("BOTTOMLEFT", PullFrameTarget, "BOTTOMRIGHT", 0, 0) + PullTex:SetAllPoints(PullRegionTarget) + "#, + ) + .unwrap(); + s.resolve(); + assert_eq!(s.eval::("return table.getn(log)").unwrap(), 0); + s.run("PullHost:Show()").unwrap(); + assert_eq!( + s.eval::("return table.concat(log, ',')").unwrap(), + "frame read nil,region read 1" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// A moved effective scale clears every region's rect, deferred or not (`0x768360`, +/// `0x7683a2`โ€“`0x7683c3`, through `0x76ad06`): a hidden frame's anchorless region holds no rect +/// after the resolve, before any read or show pulls it. +#[test] +fn a_scale_change_clears_a_deferred_anchorless_regions_rect() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + ScaleHost = CreateFrame("Frame", "ScaleHost") + ScaleHost:SetPoint("BOTTOMLEFT", 10, 10) + ScaleHost:SetWidth(100); ScaleHost:SetHeight(50) + ScaleTex = ScaleHost:CreateTexture("ScaleTex") + ScaleTex:SetAllPoints(ScaleHost) + "#, + ) + .unwrap(); + s.resolve(); + let rect = |s: &UiScript| { + let m = s.model_ref(); + let host = m.arena.lookup("ScaleHost").expect("host"); + let rh = m.arena.frame(host).expect("frame").regions[0]; + m.region_resolved.get(&rh).copied() + }; + assert!(rect(&s).is_some()); + s.run("ScaleTex:ClearAllPoints(); ScaleHost:Hide()") + .unwrap(); + s.resolve(); + assert!(rect(&s).is_some(), "a clear keeps the rect"); + s.run("ScaleHost:SetScale(0.5)").unwrap(); + s.resolve(); + assert_eq!( + rect(&s), + None, + "the cascade cleared the deferred region's rect" + ); + assert!( + s.eval::("return ScaleTex:GetLeft() == nil").unwrap(), + "and a read finds none" + ); +} diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs index 33a0171b..07a242e1 100644 --- a/crates/benilla-ui/src/script/tests/mod.rs +++ b/crates/benilla-ui/src/script/tests/mod.rs @@ -29,6 +29,7 @@ mod generic_for; mod glue_surface; mod gm_ticket; mod handler_prof; +mod hidden_defer; mod host_state; mod hover_publish; mod hover_walk; diff --git a/crates/benilla-ui/src/script/tests/tooltip.rs b/crates/benilla-ui/src/script/tests/tooltip.rs index 22194987..4a10d02c 100644 --- a/crates/benilla-ui/src/script/tests/tooltip.rs +++ b/crates/benilla-ui/src/script/tests/tooltip.rs @@ -449,7 +449,7 @@ fn fadeout_ramps_then_hides() { assert!(s.take_errors().is_empty()); } -/// Every GameTooltip clamps to the screen (the geometry-flags bit 4 clamp, `assemble 0x767a20`), so +/// Every GameTooltip clamps to the screen (the geometry `0x10` clamp, `assemble 0x767a20`), so /// the zone text's `ANCHOR_LEFT` (`Minimap.lua:39`) hangs down from the screen top, not above it. #[test] fn owner_anchored_tooltip_clamps_to_screen() { diff --git a/crates/benilla-ui/src/script/tooltip/mod.rs b/crates/benilla-ui/src/script/tooltip/mod.rs index ac5f5fee..c3da0944 100644 --- a/crates/benilla-ui/src/script/tooltip/mod.rs +++ b/crates/benilla-ui/src/script/tooltip/mod.rs @@ -153,6 +153,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> { .create_region(h, RegionKind::FontString, DrawLayer::Artwork, 0) .ok_or_else(|| mlua::Error::runtime("dead tooltip frame"))?, }; + let left_drew = model.region_draws(left); { let mut d = if adopted_left.is_some() { // Keep the declared faces; the engine owns justify/visibility/anchors. @@ -188,6 +189,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> { } }]); model.region_data.insert(left, d); + model.note_region_draw(left, left_drew); model.touch_measure(left); // an adopted cell can arrive text-in-hand // A line row's points are set with the invalidate flag (`0x530197`). model.invalidate_region(left, true); @@ -202,6 +204,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> { .create_region(h, RegionKind::FontString, DrawLayer::Artwork, 0) .ok_or_else(|| mlua::Error::runtime("dead tooltip frame"))?, }; + let right_drew = model.region_draws(right); { let mut d = if adopted_right.is_some() { model.region_data.remove(&right).unwrap_or_default() @@ -220,6 +223,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> { d.anchors = Anchors::from([Anchor::new(Point::Right, left_id, Point::Left, 0.0, 0.0)]); model.region_data.insert(right, d); + model.note_region_draw(right, right_drew); model.touch_measure(right); // an adopted cell can arrive text-in-hand model.invalidate_region(right, true); } @@ -268,7 +272,7 @@ pub(super) fn write_cell( // The line's own colour, as if set by `SetTextColor`: a later // `GameTooltipText:SetTextColor` must not repaint it. d.font_explicit.color = true; - d.hidden = false; + model.set_region_hidden(rh, false); model.touch_measure(rh); model.touch_layout_text(rh, was_shown); } @@ -281,10 +285,12 @@ fn clear_content(model: &mut Model, h: FrameHandle) { _ => return, }; for rh in lefts.into_iter().chain(rights) { + let drew = model.region_draws(rh); if let Some(d) = model.region_data.get_mut(&rh) { let was_shown = d.text.as_deref().is_some_and(|t| !t.is_empty()); d.text = None; d.hidden = true; + model.note_region_draw(rh, drew); // `measured` and the wrap pin (`size`) stay: the measure cache is keyed by content // ([`RegionData::measure_key`]) and `append_line` rewrites the pin, so the hover // loop's per-frame clear and rebuild (`ContainerFrameItemButton_OnUpdate`) re-measures @@ -755,7 +761,7 @@ pub(super) fn layout_tooltips(model: &mut Model) { if owed && model.frame_holds_stale(h) { model.note_layout_frame_write(h); if let Some(&id) = model.frame_to_id.get(&h) { - model.layout_pending.push(id); + model.queue_invalidate(id); } } let (num, lefts, rights, min_w, pad_w) = match model.arena.frame(h).map(|f| &f.kind_state) { @@ -812,7 +818,7 @@ pub(super) fn layout_tooltips(model: &mut Model) { // the line write, where the reference's relayout measured in the call. if moved { if let Some(&id) = model.frame_to_id.get(&h) { - model.layout_pending.push(id); + model.queue_invalidate(id); } } // Right-flush each shown right cell: one `SetPoint(RIGHT, leftCell, LEFT, width, 0)` @@ -833,7 +839,7 @@ pub(super) fn layout_tooltips(model: &mut Model) { if stored { model.note_layout_region_write(rights[i]); if let Some(&id) = model.region_to_id.get(&rights[i]) { - model.layout_pending.push(id); + model.queue_invalidate(id); } } } diff --git a/crates/benilla-ui/src/script/tooltip/verbs.rs b/crates/benilla-ui/src/script/tooltip/verbs.rs index 20c21238..39ec58d7 100644 --- a/crates/benilla-ui/src/script/tooltip/verbs.rs +++ b/crates/benilla-ui/src/script/tooltip/verbs.rs @@ -230,6 +230,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { } }; let left_id = model.region_id(lh); + let (left_drew, right_drew) = (model.region_draws(lh), model.region_draws(rh)); { let d = model.region_data.entry(lh).or_default(); d.hidden = true; @@ -243,6 +244,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { d.anchors = Anchors::from([Anchor::new(Point::Right, left_id, Point::Right, 0.0, 0.0)]); } + model.note_region_draw(lh, left_drew); + model.note_region_draw(rh, right_drew); // Two line rows, their points set with the invalidate flag. model.invalidate_region(lh, true); model.invalidate_region(rh, true); diff --git a/crates/benilla-ui/src/script/visibility.rs b/crates/benilla-ui/src/script/visibility.rs index 36b5d2c0..33f973b6 100644 --- a/crates/benilla-ui/src/script/visibility.rs +++ b/crates/benilla-ui/src/script/visibility.rs @@ -64,13 +64,39 @@ pub(super) fn set_shown_bit(lua: &Lua, h: FrameHandle, shown: bool) { } /// The base show transition (`0x76ae10`): the frame's own step, its children's transitions in -/// link order, the toplevel raise (`0x76aeec`), then its own OnShow (`0x76aef5`). +/// link order, the toplevel raise (`0x76aeec`), then its own OnShow (`0x76aef5`). Past the gate, +/// and before it is marked visible, the frame's layout undefers and resolves what it owes +/// (`0x76ae58`), so its `OnSizeChanged` fires here, ahead of its regions, children and notify; +/// its regions follow the mark. pub(super) fn base_show(lua: &Lua, h: FrameHandle) { - { + // The frame and its title region resolve before it is marked visible (`0x76ae58`, + // `0x76ae6c`); a show with no handler to fire leaves its resolve to the next one. + let roots = { let mut model = model_mut(lua); - if !model.arena.show_step(h) { + if !model.arena.show_gate(h) { return; } + let owed = model.layout_on_show_frame(h); + let roots = model.show_frame_roots(h); + (owed && model.resolve_fires(&roots)).then_some(roots) + }; + if let Some(roots) = roots { + resolve_and_fire(lua, roots); + } + let roots = { + let mut model = model_mut(lua); + model.arena.show_mark(h); + // Then each shown region starts drawing (`0x76aeac` โ†’ `0x77fcb0`), its own resolve after + // the mark (`0x76ae7b`). + let owed = model.layout_on_show_regions(h); + let roots = model.show_region_roots(h); + (owed && model.resolve_fires(&roots)).then_some(roots) + }; + if let Some(roots) = roots { + resolve_and_fire(lua, roots); + } + { + let mut model = model_mut(lua); // Only a frame entering the mouse index schedules a hover re-pick: the show's insert // (`0x764780` โ†’ `0x76afa0`, flag 0) reaches `0x764aa0` for a kind whose bit is set, and // its tail arms `[root+0x1100]` for the mouse kind alone (`0x764b84`, `0x764b8d`). A @@ -93,6 +119,7 @@ pub(super) fn base_hide(lua: &Lua, h: FrameHandle) { if !model.arena.hide_step(h) { return; } + model.layout_on_hide(h); // The de-registration ends a move or size on this frame before its index removal // (`0x76493a`, then `0x764942`). super::object::cancel_on_hide(&mut model, h); @@ -108,6 +135,35 @@ pub(super) fn base_hide(lua: &Lua, h: FrameHandle) { notify(lua, h, false); } +/// A resolve run inside a binding, as the reference's resolve-now (`0x7680e0(1)`) runs inside +/// the show (`0x76ae58`) and the getters (`0x7a1a1d`): what `roots` owe resolves now, with each +/// deferred object an anchor read reaches on the way (`0x7a2fc0`), and their `OnSizeChanged` fire +/// here, each pulled object's before its reader's, as each one's `ApplyRect` (`0x76b580`) runs +/// inside the read. Size changes the resolve made elsewhere stay queued for the drain, as the +/// reference resolves those there. +pub(in crate::script) fn resolve_and_fire(lua: &Lua, roots: Vec) { + let fire: Vec<(u32, f32, f32)> = { + let mut model = model_mut(lua); + let mark = model.pending_size_changed.len(); + model.layout_chain = Some(roots); + super::UiScript::resolve_layout(&mut model); + let chain = model.layout_chain.take().unwrap_or_default(); + let mut fire = Vec::new(); + for id in chain { + if let Some(at) = model.pending_size_changed[mark..] + .iter() + .position(|e| e.0 == id) + { + fire.push(model.pending_size_changed.remove(mark + at)); + } + } + fire + }; + for (id, w, h) in fire { + event::fire_size_changed(lua, id, w, h); + } +} + /// Run `step` on each child of `parent` as a live walk reaches it /// ([`crate::widget::WidgetArena::next_child`]), with no borrow held across the step. fn walk_children(lua: &Lua, parent: FrameHandle, step: fn(&Lua, FrameHandle)) { @@ -226,9 +282,17 @@ pub(crate) fn set_load_window(lua: &Lua, wrapper: &Table, open: bool) { let Ok(h) = super::object::frame_handle_of(lua, wrapper) else { return; }; - if let Some(f) = model_mut(lua).arena.frame_mut(h) { + let mut model = model_mut(lua); + if let Some(f) = model.arena.frame_mut(h) { f.load_window = open; } + // The pre-load hook defers the frame's layout (`0x7697c2`โ€“`0x769807`); the post-load hook, + // once the window is down, undefers a visible one (`0x76a2ff`โ€“`0x76a35b`). + if open { + model.layout_load_begin(h); + } else { + model.layout_load_end(h); + } } /// The post-load hook's OnShow (`0x76a38c`โ€“`0x76a3b3`), after the children and the OnLoad: the diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index b0e67ef1..d355d648 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -19,6 +19,14 @@ pub struct FrameHandle { generation: u32, } +impl FrameHandle { + /// Index and generation in one integer, for a deterministic order over a hashed set. + #[inline] + pub(crate) fn fingerprint_bits(self) -> u64 { + (u64::from(self.generation) << 32) | u64::from(self.index) + } +} + /// A generational handle to a [`Region`] (a texture or fontstring leaf). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct RegionHandle { @@ -167,6 +175,11 @@ pub struct Frame { /// the post-load hook starts (`0x76a2ff`). While it is set the frame's own OnShow and OnHide /// fire nothing (`0x76b270`/`0x76b2a0`); it still transitions and walks its children. pub load_window: bool, + /// The layout object's deferral flag (`0x2` of `G+0x3c`, written only by `0x768060`): set by a + /// hide (`0x76ad70`) and a load's start (`0x7697cc`), cleared by a show (`0x76ae58`), a load's + /// end while visible (`0x76a310`) and an anchor read (`0x7a2fc0`). While it is set the frame's + /// resolve waits; [`crate::script::Model`] keeps what it owes. + pub layout_deferred: bool, /// `EnableMouse`: the mouse bit (bit 2) of the input mask `[frame+0xcc]`. pub mouse_enabled: bool, /// `EnableMouseWheel`, or an `` script at XML load: the mask's wheel bit (3). @@ -176,7 +189,7 @@ pub struct Frame { /// mask's char and key bits (`0x76af00`), which key delivery walks /// ([`crate::script::keyboard`]); a script-less frame there declines. pub keyboard_enabled: bool, - /// `SetClampedToScreen`, geometry flags bit 4: `0x767a20` shifts the rect back on screen. + /// `SetClampedToScreen`, geometry flags `0x10`: `0x767a20` shifts the rect back on screen. /// Every GameTooltip is born with it here; the stock tooltips get it from their template, /// `GameTooltipTemplate.xml:3`. pub clamped_to_screen: bool, @@ -238,6 +251,10 @@ pub struct Region { /// while anonymous. Only the `_G` entry is first-come (`0x701bd0`), so two regions can hold /// one name, and `GetName` answers each its own. pub name: Option, + /// The layout object's deferral bit, as [`Frame::layout_deferred`]: set when the region stops + /// drawing (`0x77fc82`) and at its owner's load (`0x769807`), cleared when it starts + /// (`0x77fce6`) and by an anchor read. + pub layout_deferred: bool, } /// A cursor over a frame's child list for [`WidgetArena::next_child`]: the child last returned and @@ -513,6 +530,8 @@ impl WidgetArena { shown, effective_visible: shown && parent_visible, load_window: false, + // The ctor zeroes the layout flags (`0x767680`); the load sets the bit. + layout_deferred: false, mouse_enabled: mouse_enabled_by_ctor(kind), // The WorldFrame ctor takes the wheel (`0x481b1f`); the reference's ScrollFrame and // ScrollingMessageFrame ctors do not, and a stock one takes it from ``. @@ -685,6 +704,11 @@ impl WidgetArena { sub_level: i8, ) -> Option { let decl_seq = self.take_decl(owner)?; + // The link takes the owner's bit (`0x77fd1e`), and the shown region's start clears it on a + // visible owner out of its load (`0x77fcb0`). + let layout_deferred = self + .frame(owner) + .is_some_and(|f| f.layout_deferred && !(f.effective_visible && !f.load_window)); let (index, generation) = self.regions.insert(Region { kind, owner, @@ -693,6 +717,7 @@ impl WidgetArena { decl_seq, detached: false, name: None, + layout_deferred, }); let handle = RegionHandle { index, generation }; self.frame_mut(owner) diff --git a/crates/benilla-ui/src/widget/propagation.rs b/crates/benilla-ui/src/widget/propagation.rs index 31900f14..a0de33d8 100644 --- a/crates/benilla-ui/src/widget/propagation.rs +++ b/crates/benilla-ui/src/widget/propagation.rs @@ -12,18 +12,30 @@ impl WidgetArena { /// yet (`0x76ae43`); otherwise it marks itself visible (`0x76ae7b`), before any child or /// handler runs, and relinks at its bucket's tail (`0x764780`). Whether it transitioned. pub fn show_step(&mut self, h: FrameHandle) -> bool { - match self.frame(h) { - Some(f) if f.shown && !f.effective_visible => {} - _ => return false, - } - if !self.parent_visible(h) { + if !self.show_gate(h) { return false; } - self.frame_mut(h).unwrap().effective_visible = true; - self.resequence_to_tail(h); + self.show_mark(h); true } + /// [`Self::show_step`]'s gate (`0x76ae1d`, `0x76ae35`, `0x76ae43`): shown, on a visible or + /// absent parent, and not visible yet. + pub fn show_gate(&self, h: FrameHandle) -> bool { + matches!(self.frame(h), Some(f) if f.shown && !f.effective_visible) + && self.parent_visible(h) + } + + /// [`Self::show_step`]'s mark (`0x76ae7b`, `0x764780`), which follows the gate whatever ran + /// between them. + pub fn show_mark(&mut self, h: FrameHandle) { + let Some(f) = self.frame_mut(h) else { + return; + }; + f.effective_visible = true; + self.resequence_to_tail(h); + } + /// The hide transition's own step (`0x76ad50` up to its child walk): nothing unless the frame /// is visible (`0x76ad5b`); otherwise it marks itself invisible (`0x76ad93`). The shown bit is /// not consulted. Whether it transitioned.