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.