diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index 76fc1595..24d50716 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -512,7 +512,8 @@ pub(super) fn tick_script( let us_tick = lap(); // ── The FontString measure round-trip, before the resolve ──────────────────────────────── // A request reads only region data, never a resolved rect, and `resolve` writes no region's - // size, so measuring first lets the frame's one resolve see the answers. + // size, so measuring first lets the resolve below see the answers. The tick drained the layout + // after its walk; this resolve takes the measures and what the tick's tail moved. let mut measured_any = false; if let Some(atlas) = font_atlas.as_deref_mut() { measured_any = measure_fontstrings(&mut script, atlas, s, &mut ui_cost, ui_cost_wanted.0); diff --git a/crates/benilla-ui/src/layout.rs b/crates/benilla-ui/src/layout.rs index 8c68f058..eae78518 100644 --- a/crates/benilla-ui/src/layout.rs +++ b/crates/benilla-ui/src/layout.rs @@ -16,7 +16,9 @@ use std::collections::VecDeque; const UNSET: f64 = f64::INFINITY; /// The least width or height change that fires `OnSizeChanged`: 2^-22, bits `0x34800000` at -/// `0x8029d4`, tested in `ApplyRect` (`0x76b580`). +/// `0x8029d4`, tested in `ApplyRect` (`0x76b5e7`, `0x76b602`) in the reference's layout units, the +/// screen diagonal as 1; in benilla's units that is this times the layout unit +/// (`Model::size_eps`). pub const SIZE_EPS: f64 = 2.384_185_791_015_625e-7; /// The least edge move against the cached rect for the client to re-store it and `ApplyRect`, 1e-5 @@ -336,14 +338,14 @@ pub fn assemble_rect(edges: [f32; 4], clamp: bool, extent_x: f32, extent_y: f32) } } -/// `ApplyRect`'s `OnSizeChanged` test (`0x76b580`): width or height moved by at least -/// [`SIZE_EPS`], the deltas taken in `f64` from the `f32` edges. -pub fn size_changed(old: Rect, new: Rect) -> bool { +/// `ApplyRect`'s `OnSizeChanged` test (`0x76b580`): width or height moved by at least `eps`, +/// [`SIZE_EPS`] in the units the rects are in, the deltas taken in `f64` from the `f32` edges. +pub fn size_changed(old: Rect, new: Rect, eps: f64) -> bool { let dw = (f64::from(new.right) - f64::from(new.left)) - (f64::from(old.right) - f64::from(old.left)); let dh = (f64::from(new.top) - f64::from(new.bottom)) - (f64::from(old.top) - f64::from(old.bottom)); - dw.abs() >= SIZE_EPS || dh.abs() >= SIZE_EPS + dw.abs() >= eps || dh.abs() >= eps } // ── The composed resolver: `assemble` over six recursion-guarded edge resolvers ────────────── @@ -1187,9 +1189,12 @@ mod tests { let r = Rect::new(0.0, 0.0, 40.0, 100.0); // 1e-7 is under SIZE_EPS. let tiny = Rect::new(0.0, 0.0, 40.0, 100.0 + 1e-7); - assert!(!size_changed(r, tiny)); + assert!(!size_changed(r, tiny, SIZE_EPS)); let big = Rect::new(0.0, 0.0, 40.0, 100.001); - assert!(size_changed(r, big)); + assert!(size_changed(r, big, SIZE_EPS)); + // In units of 1280 to the reference's one, 1e-4 is under the threshold. + let jitter = Rect::new(0.0, 0.0, 40.0, 100.0001); + assert!(!size_changed(r, jitter, SIZE_EPS * 1280.0)); } /// The buffers live across rounds, so `begin` must leave nothing behind: round 2 anchors to a diff --git a/crates/benilla-ui/src/script/backdrop.rs b/crates/benilla-ui/src/script/backdrop.rs index aff9f12c..2e7b2f9e 100644 --- a/crates/benilla-ui/src/script/backdrop.rs +++ b/crates/benilla-ui/src/script/backdrop.rs @@ -490,6 +490,7 @@ pub(crate) fn set_color(model: &mut Model, fh: FrameHandle, border: bool, color: /// After a resolve: re-run the texcoords of every backdrop whose frame rect changed size, as the /// rect application `0x76b580` calls `0x77f0c0` (at `0x76b61e`); a move alone re-runs nothing. pub(crate) fn after_resolve(model: &mut Model) { + let eps = model.size_eps(); let Model { backdrops, resolved, @@ -503,7 +504,7 @@ pub(crate) fn after_resolve(model: &mut Model) { }; if inst .ran_for - .is_some_and(|prev| !layout::size_changed(prev, rect)) + .is_some_and(|prev| !layout::size_changed(prev, rect, eps)) { continue; } diff --git a/crates/benilla-ui/src/script/button.rs b/crates/benilla-ui/src/script/button.rs index f0df458a..7c2a7566 100644 --- a/crates/benilla-ui/src/script/button.rs +++ b/crates/benilla-ui/src/script/button.rs @@ -506,8 +506,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let id = ensure_slot(lua, &this, Slot::Text)?; let mut model = lua.app_data_mut::().expect("model app_data"); let rh = *model.id_to_region.get(&id).expect("text region id"); - model.region_data.entry(rh).or_default().text = Some(text); + let data = model.region_data.entry(rh).or_default(); + let was_shown = data.text.as_deref().is_some_and(|t| !t.is_empty()); + data.text = Some(text); model.touch_measure(rh); + model.touch_layout_text(rh, was_shown); Ok(()) })?, )?; diff --git a/crates/benilla-ui/src/script/editbox.rs b/crates/benilla-ui/src/script/editbox.rs index 27b0ea4b..bd84e0b7 100644 --- a/crates/benilla-ui/src/script/editbox.rs +++ b/crates/benilla-ui/src/script/editbox.rs @@ -376,8 +376,11 @@ fn sync_text_region(lua: &Lua, h: FrameHandle) { }); if let Some(display) = display { let mut model = lua.app_data_mut::().expect("model app_data"); - model.region_data.entry(rh).or_default().text = Some(display); + let data = model.region_data.entry(rh).or_default(); + let was_shown = data.text.as_deref().is_some_and(|t| !t.is_empty()); + data.text = Some(display); model.touch_measure(rh); + model.touch_layout_text(rh, was_shown); } } diff --git a/crates/benilla-ui/src/script/editbox/flush_tests.rs b/crates/benilla-ui/src/script/editbox/flush_tests.rs index 2862277a..3a4d6ec6 100644 --- a/crates/benilla-ui/src/script/editbox/flush_tests.rs +++ b/crates/benilla-ui/src/script/editbox/flush_tests.rs @@ -471,6 +471,9 @@ fn up_and_down_move_a_multi_line_boxs_caret_by_rows() { "#, ) .unwrap(); + // The flush sizes the box to its two rows; the drain after the walk resizes it, and the + // re-seat that runs fires once more at the next flush (`0x77a8d0` → `0x77b8c0`). + s.tick(0.016); s.tick(0.016); take_log(&s); let row = |back| EditAction::Move { @@ -568,3 +571,30 @@ fn on_cursor_changed_answers_in_the_boxs_own_units() { ); assert!(s.errors().is_empty(), "{:?}", s.errors()); } + +/// A box created and anchored between frames, as an event handler does: the pump's drain before +/// the walk (`0x7656ba`) gives it its first rect, whose resize runs the box's `OnSizeChanged` +/// override (`0x76b63e` → `0x77a8d0`); the re-seat ORs its bits into the ctor's (`0x77ba7f`), so the +/// walk's flush (`0x77a7a1`) fires `OnTextChanged` once, and the frames after it fire nothing. +#[test] +fn a_box_anchored_between_frames_fires_on_text_changed_once() { + let mut s = script(); + s.run( + r#" + N = 0 + local e = CreateFrame("EditBox", "Fresh") + e:SetAutoFocus(false) + e:SetPoint("BOTTOMLEFT", 100, 50) + e:SetWidth(200); e:SetHeight(32) + e:SetScript("OnTextChanged", function() N = N + 1 end) + "#, + ) + .unwrap(); + // The host's frame: the tick, then its own resolve for the draw. + for _ in 0..3 { + s.tick(0.016); + s.resolve(); + } + assert_eq!(s.eval::("return N").unwrap(), 1); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} diff --git a/crates/benilla-ui/src/script/editbox/relayout.rs b/crates/benilla-ui/src/script/editbox/relayout.rs index 1c867e53..b5a80c4f 100644 --- a/crates/benilla-ui/src/script/editbox/relayout.rs +++ b/crates/benilla-ui/src/script/editbox/relayout.rs @@ -74,6 +74,7 @@ pub(super) fn seat_text_region(model: &mut Model, h: FrameHandle) { /// a layout input, for the caller to resolve it in the same pass, as the reference's drain does. pub(in crate::script) fn reseat_resized(model: &mut Model) -> bool { let epoch = model.layout_epoch; + let eps = model.size_eps(); for i in 0..model.arena.editbox_kinds().len() { let h = model.arena.editbox_kinds()[i]; let Some(&now) = model.resolved.get(&h) else { @@ -87,7 +88,7 @@ pub(in crate::script) fn reseat_resized(model: &mut Model) -> bool { .notified_rect .replace(now) .unwrap_or(Rect::new(0.0, 0.0, 0.0, 0.0)); - if !crate::layout::size_changed(before, now) { + if !crate::layout::size_changed(before, now, eps) { continue; } // The ctor built the text region; its data is seeded here if nothing has touched it. diff --git a/crates/benilla-ui/src/script/layout.rs b/crates/benilla-ui/src/script/layout.rs index c3fa04bd..fc0d6e71 100644 --- a/crates/benilla-ui/src/script/layout.rs +++ b/crates/benilla-ui/src/script/layout.rs @@ -97,8 +97,18 @@ fn frame_node_hash(input: &LayoutInput, over: Option) -> u64 { node.finish() } -/// A region's per-node input hash: its anchors, explicit size, measured text extent and owner, -/// whose effective scale the sweep reads (`None` for an owner with no layout id). +/// The measured extent the region sweep sizes a FontString by: the stored measure while the text +/// is non-empty, used without a key check, so changed text keeps its last box while the +/// re-measure is in flight; empty text is never measured, so it has none, never a stale box. +#[inline] +pub(super) fn effective_measure(data: &RegionData) -> Option { + data.measured + .filter(|_| data.text.as_deref().is_some_and(|t| !t.is_empty())) +} + +/// A region's per-node input hash: its anchors, explicit size, owner, whose effective scale the +/// sweep reads (`None` for an owner with no layout id), and, where an axis authored 0 takes its +/// span from them, its effective measure and its art: exactly what the sweep reads. #[inline] pub(super) fn region_node_hash(data: &RegionData, owner: Option) -> u64 { let mut node = InputFingerprint::default(); @@ -111,16 +121,17 @@ pub(super) fn region_node_hash(data: &RegionData, owner: Option) -> u64 { } None => node.feed(u64::MAX), } - match data.measured { + let unsized_axis = data.size.is_none_or(|(w, h)| w == 0.0 || h == 0.0); + match effective_measure(data).filter(|_| unsized_axis) { Some(m) => { node.f32(m.w); node.f32(m.h); } None => node.feed(u64::MAX), } - // The art, only where an axis authored 0 takes its span from it (`content_span`): a new - // texture moves such a region, while a sized region's art cannot move its rect. - if !data.anchors.is_empty() && data.size.is_none_or(|(w, h)| w == 0.0 || h == 0.0) { + // The art (`content_span`): a new texture moves such a region, while a sized region's art + // cannot move its rect. + if !data.anchors.is_empty() && unsized_axis { node.feed(data.fill.is_some() as u64); match &data.texture { Some(path) => { @@ -752,13 +763,14 @@ impl PreambleProf { } /// `OnSizeChanged`'s "after": queue `(id, width, height)` for each watched frame whose size moved -/// by the `ApplyRect 0x76b580` test ([`crate::layout::size_changed`], `ε = _DAT_008029d4`), so a +/// by the `ApplyRect 0x76b580` test ([`crate::layout::size_changed`] at [`Model::size_eps`]), so a /// pure move never fires. A frame with no rect at entry compares against the zero rect /// `CSimpleFrame`'s ctor leaves; one that loses its rect had no `ApplyRect` and fires nothing. fn queue_size_changes( watched: &[(FrameHandle, Option)], resolved: &HashMap, frame_to_id: &HashMap, + eps: f64, out: &mut Vec<(u32, f32, f32)>, ) { for &(h, before) in watched { @@ -766,7 +778,7 @@ fn queue_size_changes( continue; }; let before = before.unwrap_or(Rect::new(0.0, 0.0, 0.0, 0.0)); - if !layout::size_changed(before, now) { + if !layout::size_changed(before, now, eps) { continue; } let Some(&id) = frame_to_id.get(&h) else { @@ -860,6 +872,13 @@ impl UiScript { } fn resolve_layout_inner(model: &mut Model) { + // Text that emptied or filled since the last resolve: named now where its node's hash + // moved, ahead of the epoch's read, as if named at the write. + if !model.text_flips.is_empty() { + for rh in std::mem::take(&mut model.text_flips) { + model.touch_layout_content(rh); + } + } // ── Tier 1: the mutation epoch ──────────────────────────────────────────────────────── // No layout touch since the last converged resolve: nothing the fingerprint reads moved, // so skip everything. Under verify, fall through so tier 2 checks the claim. @@ -884,6 +903,7 @@ impl UiScript { .iter() .map(|&h| (h, model.resolved.get(&h).copied())) .collect(); + let size_eps = model.size_eps(); pre.watched = pre.lap(); // ── The graph: the ledger's cache, or a fresh derivation ───────────────────────────── // A `Some` ledger claims every write since the graph was built named its node and kept @@ -1113,12 +1133,8 @@ impl UiScript { // The owner supplies the region's scale and nothing else: no fallback edges. let scale = arena.frame(owner).map(|f| f.effective_scale).unwrap_or(1.0); // An axis authored 0, width or height, takes the host-measured extent: the - // reference sizes a zero width to the unwrapped line. The measure is used without - // a key check, so changed text keeps its last box while the re-measure is in - // flight; empty text is never measured, so it counts as zero, never a stale box. - let measured = data - .measured - .filter(|_| data.text.as_deref().is_some_and(|t| !t.is_empty())); + // reference sizes a zero width to the unwrapped line. + let measured = effective_measure(data); let mut height = data.size.map_or(0.0, |s| s.1); let mut width = data.size.map_or(0.0, |s| s.0); if let Some(m) = &measured { @@ -1239,7 +1255,13 @@ 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); - queue_size_changes(&watched, resolved, frame_to_id, pending_size_changed); + queue_size_changes( + &watched, + resolved, + frame_to_id, + size_eps, + pending_size_changed, + ); return; } if round + 1 == round_cap { @@ -1256,7 +1278,13 @@ 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(); - queue_size_changes(&watched, resolved, frame_to_id, pending_size_changed); + queue_size_changes( + &watched, + resolved, + frame_to_id, + size_eps, + pending_size_changed, + ); } /// Derive the layout graph from scratch: the roster, the reverse edges, the per-node hashes and diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 451203bd..f8ce7279 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -886,7 +886,8 @@ impl UiScript { .map(|(_, f)| f.effective_alpha) } - /// Resolve and cache every frame's rect, which `GetWidth`/`GetHeight`/`extract` read, then fire + /// The layout drain (`0x768ed0`), which [`Self::tick`] runs before and after its walk: resolve + /// and cache every frame's rect, which `GetWidth`/`GetHeight`/`extract` read, then fire /// `OnSizeChanged` for frames whose size moved ([`event::fire_size_changes`]) and re-seat the /// text of every resized EditBox. pub fn resolve(&mut self) { diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 762bae1f..7fa11087 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -227,6 +227,9 @@ pub(crate) struct Model { /// Frames the last resolve resized, `(id, width, height)`, owed `OnSizeChanged` outside it. pub(crate) pending_size_changed: Vec<(u32, f32, f32)>, + /// Regions whose text emptied or filled since the last resolve, which names each one whose + /// node hash moved ([`Model::touch_layout_text`]). + pub(crate) text_flips: Vec, /// Script errors caught from `pcall`'d handlers, drained by the host each frame. pub(crate) errors: Vec, @@ -1122,6 +1125,7 @@ impl Model { mouse_capture: None, last_click: HashMap::new(), pending_size_changed: Vec::new(), + text_flips: Vec::new(), errors: Vec::new(), pending_error_dispatch: Vec::new(), diagnostics: Default::default(), @@ -1579,10 +1583,38 @@ impl Model { /// anchored region takes an axis authored 0 from it ([`super::layout::content_span`]), and /// named only when it moved the node's hash, so a repaint of the same art leaves the gate shut. pub(crate) fn touch_layout_art(&mut self, rh: RegionHandle) { + if self + .region_data + .get(&rh) + .is_some_and(|d| d.size.is_some_and(|(w, h)| w != 0.0 && h != 0.0)) + { + return; + } + self.touch_layout_content(rh); + } + + /// A write to region `rh`'s text, which was non-empty iff `was_shown`: the sweep reads a + /// FontString's measure only while its text is non-empty + /// ([`super::layout::effective_measure`]), so a flip between empty and not is noted, and the + /// next resolve names it if it moved the node's hash. Checked there, not here, so a clear and + /// rebuild of the same text (the hover tooltip's, every frame) leaves the epoch alone. A + /// same-side write leaves the layout to the measure ledger. + pub(crate) fn touch_layout_text(&mut self, rh: RegionHandle, was_shown: bool) { + let shown = self + .region_data + .get(&rh) + .is_some_and(|d| d.text.as_deref().is_some_and(|t| !t.is_empty())); + if shown != was_shown { + self.text_flips.push(rh); + } + } + + /// Names anchored region `rh` unless its node still holds its converged hash. + pub(crate) fn touch_layout_content(&mut self, rh: RegionHandle) { let Some(d) = self.region_data.get(&rh) else { return; }; - if d.anchors.is_empty() || d.size.is_some_and(|(w, h)| w != 0.0 && h != 0.0) { + if d.anchors.is_empty() { return; } if let Some(&id) = self.region_to_id.get(&rh) { diff --git a/crates/benilla-ui/src/script/modelframe.rs b/crates/benilla-ui/src/script/modelframe.rs index 043bf4d9..6443661e 100644 --- a/crates/benilla-ui/src/script/modelframe.rs +++ b/crates/benilla-ui/src/script/modelframe.rs @@ -35,6 +35,12 @@ impl Model { 768.0 * (a * a + 1.0).sqrt() } + /// `ApplyRect`'s size threshold, 2⁻²² in the reference's layout units (`0x76b5e7`, + /// `0x76b602`, `[0x8029d4]`), in ours: [`crate::layout::SIZE_EPS`] times the layout unit. + pub(crate) fn size_eps(&self) -> f64 { + crate::layout::SIZE_EPS * f64::from(self.layout_unit()) + } + /// The implicit rect: a pane with no authored size takes its file's bounding-box extent, in /// layout units, as the reference's geometry overrides answer it (`0x76d080`, `0x76d0d0`). pub(crate) fn apply_implicit_rect(&mut self, h: FrameHandle) { diff --git a/crates/benilla-ui/src/script/object/layout_methods.rs b/crates/benilla-ui/src/script/object/layout_methods.rs index c930547b..481a22a3 100644 --- a/crates/benilla-ui/src/script/object/layout_methods.rs +++ b/crates/benilla-ui/src/script/object/layout_methods.rs @@ -236,8 +236,9 @@ 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 `UiScript::resolve`, a tick after the reference's (`ApplyRect 0x76b580`), because running -/// Lua handlers inside a binding risks a borrow panic or unbounded recursion. +/// 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. fn settle(lua: &Lua) { let mut model = lua.app_data_mut::().expect("model"); crate::script::UiScript::resolve_layout(&mut model); diff --git a/crates/benilla-ui/src/script/region/text.rs b/crates/benilla-ui/src/script/region/text.rs index fcf553ea..52a3bfff 100644 --- a/crates/benilla-ui/src/script/region/text.rs +++ b/crates/benilla-ui/src/script/region/text.rs @@ -18,12 +18,14 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { let rh = region_handle_of(lua, &this)?; let mut model = lua.app_data_mut::().expect("model"); let data = model.region_data.entry(rh).or_default(); + let was_shown = data.text.as_deref().is_some_and(|t| !t.is_empty()); data.text = text; // Fresh text draws whole: an armed write-on gradient belongs to the old string. data.alpha_gradient = None; - // SetText must not touch the layout, since the extent has not moved yet; it names - // itself on the measure ledger. + // New text names itself on the measure ledger, and on the layout only when it empties + // or fills, which drops or takes up the measure the sweep reads. model.touch_measure(rh); + model.touch_layout_text(rh, was_shown); Ok(()) })?, )?; diff --git a/crates/benilla-ui/src/script/scrollframe.rs b/crates/benilla-ui/src/script/scrollframe.rs index 06a28093..4e4ee3c2 100644 --- a/crates/benilla-ui/src/script/scrollframe.rs +++ b/crates/benilla-ui/src/script/scrollframe.rs @@ -113,29 +113,6 @@ fn subtree_span(model: &Model, f: FrameHandle, axis: Axis) -> Option<(f32, f32)> span } -/// Every FontString under `f` (its own regions, then its visible children's), in tree order. -fn subtree_font_strings(model: &Model, f: FrameHandle) -> Vec { - let mut out = Vec::new(); - let Some(frame) = model.arena.frame(f) else { - return out; - }; - for &rh in &frame.regions { - if model - .arena - .region(rh) - .is_some_and(|r| matches!(r.kind, crate::widget::RegionKind::FontString)) - { - out.push(rh); - } - } - for &ch in &frame.children { - if model.arena.frame(ch).is_some_and(|c| c.effective_visible) { - out.extend(subtree_font_strings(model, ch)); - } - } - out -} - pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let m = lua.create_table()?; @@ -302,59 +279,31 @@ fn fire_scroll(lua: &Lua, this: &Table, script: &str, value: f32) -> mlua::Resul /// in the reference's units (the screen diagonal as 1) (`0x786ed7`–`0x786efd`), which is that times the /// layout unit in ours. /// -/// The reference drains its layout before the walk (`0x7656ba`) and measures text at once; ours -/// lands both a frame later, so a marked frame measures its child's FontStrings and resolves first -/// (`QuestLog_UpdateQuestDetails` marks right after its `SetText`s, `QuestLogFrame.lua:458`). An -/// unmarked frame reads its size as the last resolve left it, so a resize not yet resolved marks -/// it at the update after that resolve. +/// The rects it reads are the ones the pump's drain before the walk (`0x7656ba`) resolved: the +/// recompute reads them through `0x768320`, which never resolves, so a resize made earlier in the +/// walk counts from the next tick, and the size it notes is the one that drain applied. pub(super) fn update(lua: &Lua, h: FrameHandle) { - let marked = { - let mut model = lua.app_data_mut::().expect("model app_data"); - let rect = model.resolved.get(&h).copied(); - let Some(s) = scroll_state(&mut model, h) else { - return; - }; - see_size(s, rect); - s.range_dirty - }; - if !marked { - return; - } - let strings = { - let model = lua.app_data_ref::().expect("model app_data"); - match model.arena.frame(h).map(|f| &f.kind_state) { - Some(KindState::Scroll(s)) => s - .child - .map(|c| subtree_font_strings(&model, c)) - .unwrap_or_default(), - _ => Vec::new(), - } - }; - for rh in strings { - super::measure::ensure_measured(lua, rh); - } - super::UiScript::resolve_layout(&mut lua.app_data_mut::().expect("model app_data")); - // The reference fires `OnSizeChanged` from its layout drain, ahead of the walk. - event::fire_size_changes(lua); let notify = { let mut model = lua.app_data_mut::().expect("model app_data"); let rect = model.resolved.get(&h).copied(); - let has_child = matches!( - model.arena.frame(h).map(|f| &f.kind_state), - Some(KindState::Scroll(s)) if s.child.is_some() - ); + let eps = model.size_eps(); + let Some(s) = scroll_state(&mut model, h) else { + return; + }; + see_size(s, rect, eps); + if !s.range_dirty { + return; + } + let has_child = s.child.is_some(); let measured = (rect.is_some() && has_child).then(|| { [ scroll_range(&model, h, Axis::Horizontal), scroll_range(&model, h, Axis::Vertical), ] }); - let eps = crate::layout::SIZE_EPS * f64::from(model.layout_unit()); let Some(s) = scroll_state(&mut model, h) else { return; }; - // The resolve may have moved the frame: that is the size this update has seen. - see_size(s, rect); let moved = measured.filter(|new| { let old = std::mem::replace(&mut s.range, *new); (0..2).any(|i| (f64::from(new[i]) - f64::from(old[i])).abs() >= eps) @@ -397,13 +346,12 @@ fn scroll_state(model: &mut Model, h: FrameHandle) -> Option<&mut ScrollFrameSta } /// Note the frame's resolved size; a change since the last note marks the range, by the width or -/// height move that fires `OnSizeChanged` ([`crate::layout::size_changed`]). No rect, no note. -fn see_size(s: &mut ScrollFrameState, rect: Option) { +/// height move that fires `OnSizeChanged` ([`crate::layout::size_changed`] at `eps`, +/// [`Model::size_eps`]). No rect, no note. +fn see_size(s: &mut ScrollFrameState, rect: Option, eps: f64) { let Some(r) = rect else { return }; let size = [r.width(), r.height()]; - let moved = |i: usize| { - (f64::from(size[i]) - f64::from(s.seen_size[i])).abs() >= crate::layout::SIZE_EPS - }; + let moved = |i: usize| (f64::from(size[i]) - f64::from(s.seen_size[i])).abs() >= eps; if moved(0) || moved(1) { s.range_dirty = true; } diff --git a/crates/benilla-ui/src/script/tests/layout_structure.rs b/crates/benilla-ui/src/script/tests/layout_structure.rs index 05c835e8..d8834a0b 100644 --- a/crates/benilla-ui/src/script/tests/layout_structure.rs +++ b/crates/benilla-ui/src/script/tests/layout_structure.rs @@ -2,7 +2,17 @@ //! every resolve must equal a from-scratch solve of the same model, rects and graph alike. use super::common::script; -use crate::script::UiScript; +use crate::script::{MeasureRequest, TextMeasure, UiScript}; + +/// A stand-in font engine, so a text-sized FontString takes a box: 6 wide per character, 12 tall. +struct Block; + +impl TextMeasure for Block { + fn measure(&mut self, req: &MeasureRequest) -> (f32, f32, f32) { + let w = req.text.chars().count() as f32 * 6.0; + (w, 12.0, w) + } +} /// Resolve, then require the cached graph and its rects to equal a from-scratch solve. fn resolve_matches_scratch(s: &mut UiScript, what: &str) { @@ -133,7 +143,7 @@ fn step(rng: &mut Rng) -> Write { let k2 = rng.next() % 1000; let w = if rng.coin(4) { 0 } else { rng.pick(60) }; let h = if rng.coin(4) { 0 } else { rng.pick(60) }; - let lua = match rng.next() % 20 { + let lua = match rng.next() % 21 { // A frame born under any frame, anchored to any node it may read. 0 | 1 => { let kind = if rng.coin(3) { "Button" } else { "Frame" }; @@ -271,6 +281,15 @@ fn step(rng: &mut Rng) -> Write { ), // The host's UI scale, `UIParent`'s whole subtree. 18 => return Write::UiScale([0.64, 0.8, 1.0][(k % 3) as usize]), + // A font string's text set, emptied or restored: its measured box comes and goes. + 20 => format!( + "local n = table.getn(R) + if n > 0 then + local t = R[math.mod({k}, n) + 1] + if t.SetText then t:SetText(({{ '', 'hello', 'a longer line', nil }})[{}]) end + end", + k2 % 4 + 1 + ), // Two backdrops torn down or built in one pass. _ => format!( "local n = table.getn(F) @@ -282,9 +301,9 @@ fn step(rng: &mut Rng) -> Write { Write::Lua(lua) } -/// A random run of births, frees, re-parents, re-owns, retargets, scrolls and scale changes, in -/// batches of one to four writes per resolve: after every resolve the cached graph and its rects -/// equal a from-scratch solve, and none of them derived the graph. +/// A random run of births, frees, re-parents, re-owns, retargets, scrolls, scale changes and text +/// set, emptied and restored, in batches of one to four writes per resolve: after every resolve +/// the cached graph and its rects equal a from-scratch solve, and none of them derived the graph. #[test] fn random_structural_writes_resolve_as_a_scratch_solve_would() { for seed in [ @@ -295,6 +314,7 @@ fn random_structural_writes_resolve_as_a_scratch_solve_would() { ] { let mut s = script(); s.set_screen_size(800.0, 600.0); + s.set_text_measurer(Box::new(Block)); s.run(SETUP).expect("setup"); s.run("raised = 0").expect("counter"); s.resolve(); diff --git a/crates/benilla-ui/src/script/tests/measure.rs b/crates/benilla-ui/src/script/tests/measure.rs index b280da10..4df22166 100644 --- a/crates/benilla-ui/src/script/tests/measure.rs +++ b/crates/benilla-ui/src/script/tests/measure.rs @@ -514,3 +514,59 @@ fn a_settled_region_is_refound_after_each_measure_input_write() { assert_eq!(reqs[0].text, "Go"); assert!(s.errors().is_empty(), "{:?}", s.errors()); } + +/// A text-sized FontString spans its measure while it has text and the one-unit floor once its +/// text is empty (`0x772930`); emptying and restoring the text moves its box both ways, and each +/// resolve equals a from-scratch solve. +#[test] +fn an_emptied_fontstring_drops_to_the_floor_and_a_restored_one_regains_its_box() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.set_text_measurer(Box::new(BlockFont)); + s.run( + r#" + local f = CreateFrame("Frame", "Host") + f:SetPoint("BOTTOMLEFT", 0, 0); f:SetWidth(400); f:SetHeight(300) + Label = f:CreateFontString("Label", "ARTWORK") + Label:SetPoint("BOTTOMLEFT", 10, 10) + Tail = CreateFrame("Frame", "Tail") + Tail:SetPoint("LEFT", Label, "RIGHT", 0, 0); Tail:SetWidth(5); Tail:SetHeight(5) + "#, + ) + .unwrap(); + let step = |s: &mut UiScript, lua: &str| -> f32 { + s.run(lua).unwrap(); + s.resolve(); + let mut model = s.model_mut(); + if let Some(why) = UiScript::scratch_divergence(&mut model) { + panic!("after {lua}: {why}"); + } + drop(model); + s.eval::("return Tail:GetLeft()").unwrap() + }; + assert_eq!( + step(&mut s, r#"Label:SetText("hello")"#), + 10.0 + 5.0 * PER_CHAR + ); + assert_eq!(step(&mut s, r#"Label:SetText("")"#), 11.0, "the floor"); + // Swept for another reason while empty: still the floor. + assert_eq!( + step(&mut s, r#"Label:SetPoint("BOTTOMLEFT", 20, 10)"#), + 21.0 + ); + assert_eq!( + step(&mut s, r#"Label:SetText("hello")"#), + 20.0 + 5.0 * PER_CHAR, + "the same text again, under its old measure key" + ); + assert_eq!( + step(&mut s, r#"Label:SetText(nil)"#), + 21.0, + "nil empties too" + ); + assert_eq!( + step(&mut s, r#"Label:SetText("hello")"#), + 20.0 + 5.0 * PER_CHAR + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} diff --git a/crates/benilla-ui/src/script/tests/scrollframe.rs b/crates/benilla-ui/src/script/tests/scrollframe.rs index 2b0f35ce..ee18d452 100644 --- a/crates/benilla-ui/src/script/tests/scrollframe.rs +++ b/crates/benilla-ui/src/script/tests/scrollframe.rs @@ -312,8 +312,8 @@ fn vertical_scroll_fires_as_given_and_the_marked_range_fires_range_changed() { assert!(s.errors().is_empty(), "{:?}", s.errors()); } -/// The recompute resolves for itself, so a resize earlier in the same tick counts; the app -/// resolves only once per frame, after the walk. +/// The pump drains the layout before its walk (`0x7656ba`), so a resize made since the last tick +/// counts in this tick's recompute, with no resolve of the host's in between. #[test] fn the_recompute_sees_a_same_tick_resize_with_no_intervening_resolve() { let mut s = script(); @@ -346,8 +346,8 @@ fn the_recompute_sees_a_same_tick_resize_with_no_intervening_resolve() { assert_eq!( s.eval::("return seen_hi").unwrap(), 400.0, - "the recompute must resolve the JUST-set height itself, not report the stale \ - (pre-resize) range from the last full UiScript::resolve" + "the drain before the walk resolves the JUST-set height, so the recompute does not \ + report the stale (pre-resize) range from the last full UiScript::resolve" ); assert_eq!( s.eval::("return SF:GetVerticalScrollRange()").unwrap(), @@ -511,7 +511,8 @@ fn hit_test_denies_a_button_clipped_out_and_admits_it_once_scrolled_into_view() ); } -/// The reference measures text synchronously, as stock `QuestLog_UpdateQuestDetails` relies on. +/// The reference measures text synchronously and drains the layout before the walk (`0x7656ba`), +/// as stock `QuestLog_UpdateQuestDetails` relies on: the text set before the tick is in the range. #[test] fn the_recompute_measures_the_childs_text_before_it_reads_the_range() { struct Rows; @@ -833,3 +834,27 @@ fn a_resize_marks_the_range() { assert_eq!(ranged(&s), (2, 300.0, 300.0)); assert!(s.errors().is_empty(), "{:?}", s.errors()); } + +/// The mark rides the frame's `OnSizeChanged` override, behind `ApplyRect`'s gate (`0x76b5e7`, +/// `0x76b602`), which is 2⁻²² in the reference's units, the screen diagonal as 1: a jitter under it +/// marks nothing, so a child's unmarked resize stays unreported until a real resize. +#[test] +fn a_sub_threshold_jitter_does_not_mark_the_range() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run(RANGED).unwrap(); + s.run("SF:SetScrollChild(Child)").unwrap(); + s.resolve(); + s.tick(0.016); + assert_eq!(ranged(&s), (1, 400.0, 400.0)); + s.run("Child:SetHeight(700) SF:SetHeight(200.0001)") + .unwrap(); + s.resolve(); + s.tick(0.016); + assert_eq!(ranged(&s), (1, 400.0, 400.0), "a jitter marks nothing"); + s.run("SF:SetHeight(201)").unwrap(); + s.resolve(); + s.tick(0.016); + assert_eq!(ranged(&s), (2, 499.0, 499.0)); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} diff --git a/crates/benilla-ui/src/script/tests/size_changed.rs b/crates/benilla-ui/src/script/tests/size_changed.rs index 58985bf9..1e2a63c9 100644 --- a/crates/benilla-ui/src/script/tests/size_changed.rs +++ b/crates/benilla-ui/src/script/tests/size_changed.rs @@ -184,3 +184,95 @@ fn the_on_size_changed_watch_list_is_exactly_the_frames_carrying_the_script() { s.run(r#"Watched:SetScript("OnSizeChanged", nil)"#).unwrap(); assert_eq!(watched(&s), 0, "clearing the script un-watches the frame"); } + +/// The pump drains the layout before its walk (`0x7656ba` → `0x768ed0`), and the drain's +/// `ApplyRect` fires `OnSizeChanged`: a resize made in an event handler fires it ahead of that +/// tick's `OnUpdate`. +#[test] +fn a_resize_from_an_event_fires_on_size_changed_before_the_walk() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + LOG = {} + P = CreateFrame("Frame", "DrainPanel") + P:SetPoint("BOTTOMLEFT", 0, 0) + P:SetWidth(10); P:SetHeight(10) + P:RegisterEvent("PLAYER_ENTERING_WORLD") + P:SetScript("OnEvent", function() this:SetWidth(50) end) + P:SetScript("OnSizeChanged", function() table.insert(LOG, "size" .. arg1) end) + P:SetScript("OnUpdate", function() table.insert(LOG, "update") end) + "#, + ) + .unwrap(); + s.resolve(); + s.tick(0.016); + s.run("LOG = {}").unwrap(); + s.queue_event("PLAYER_ENTERING_WORLD", Vec::new()); + s.tick(0.016); + assert_eq!( + s.eval::("return table.concat(LOG, ' ')").unwrap(), + "size50 update" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// The pump drains again after its walk (`0x765799`), so a resize made in an `OnUpdate` fires +/// `OnSizeChanged` inside the same tick, before the host reads the frame. +#[test] +fn a_resize_from_on_update_fires_on_size_changed_in_the_same_tick() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + LOG = {} + P = CreateFrame("Frame", "DrainPanel") + P:SetPoint("BOTTOMLEFT", 0, 0) + P:SetWidth(10); P:SetHeight(10) + P:SetScript("OnSizeChanged", function() table.insert(LOG, "size" .. arg1) end) + P:SetScript("OnUpdate", function() + table.insert(LOG, "update") + if GROW then GROW = nil; this:SetWidth(70) end + end) + "#, + ) + .unwrap(); + s.resolve(); + s.tick(0.016); + s.run("LOG = {} GROW = true").unwrap(); + s.tick(0.016); + assert_eq!( + s.eval::("return table.concat(LOG, ' ')").unwrap(), + "update size70" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// `ApplyRect` compares the size move against 2⁻²² (`0x76b5e7`, `0x76b602`, `[0x8029d4]`) in the +/// reference's layout units, where the screen diagonal is 1: in ours, 2⁻²² × the layout unit, +/// about 3.05e-4 at 4:3. A jitter under it fires nothing; a real move fires. +#[test] +fn a_sub_threshold_jitter_fires_nothing_and_a_real_move_fires() { + let mut s = script(); + s.set_screen_size(1024.0, 768.0); + s.run( + r#" + fires = 0 + P = CreateFrame("Frame", "JitterPanel") + P:SetPoint("BOTTOMLEFT", 0, 0) + P:SetWidth(100); P:SetHeight(40) + P:SetScript("OnSizeChanged", function() fires = fires + 1 end) + "#, + ) + .unwrap(); + s.resolve(); + assert_eq!(s.eval::("return fires").unwrap(), 1, "the birth"); + // 1e-4 is a thousand times 2⁻²² but a third of the reference's threshold here. + s.run("P:SetWidth(100.0001); P:SetHeight(40.0001)").unwrap(); + s.resolve(); + assert_eq!(s.eval::("return fires").unwrap(), 1, "a jitter"); + s.run("P:SetWidth(101)").unwrap(); + s.resolve(); + assert_eq!(s.eval::("return fires").unwrap(), 2, "a real move"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} diff --git a/crates/benilla-ui/src/script/tests/update_walk.rs b/crates/benilla-ui/src/script/tests/update_walk.rs index 916166dc..5877c2b9 100644 --- a/crates/benilla-ui/src/script/tests/update_walk.rs +++ b/crates/benilla-ui/src/script/tests/update_walk.rs @@ -257,3 +257,42 @@ fn a_mid_walk_raise_moves_a_frame_ahead_into_a_walked_level_so_it_waits_a_tick() assert_eq!(take_log(&s), "r w"); assert!(s.errors().is_empty(), "{:?}", s.errors()); } + +/// The hover re-pick at the pump's tail (`0x7657be`) follows the drain after the walk +/// (`0x765799`), so it hits against the rects the walk's handlers just moved: a frame moved under +/// the still cursor as the hovered one hides gets the `OnEnter`. +#[test] +fn the_hover_re_pick_reads_the_rects_the_walk_moved() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + LOG = {} + A = CreateFrame("Frame", "PickA") + A:SetPoint("BOTTOMLEFT", 0, 0); A:SetWidth(50); A:SetHeight(50) + A:EnableMouse(true) + A:SetScript("OnEnter", function() table.insert(LOG, "enterA") end) + A:SetScript("OnLeave", function() table.insert(LOG, "leaveA") end) + B = CreateFrame("Frame", "PickB") + B:SetPoint("BOTTOMLEFT", 300, 300); B:SetWidth(50); B:SetHeight(50) + B:EnableMouse(true) + B:SetScript("OnEnter", function() table.insert(LOG, "enterB") end) + Mover = CreateFrame("Frame", "PickMover") + Mover:SetScript("OnUpdate", function() + if GO then + GO = nil + A:Hide() + B:SetPoint("BOTTOMLEFT", 0, 0) + end + end) + "#, + ) + .unwrap(); + s.resolve(); + s.mouse_move(25.0, 25.0); + assert_eq!(take_log(&s), "enterA"); + s.run("GO = true").unwrap(); + s.tick(0.016); + assert_eq!(take_log(&s), "leaveA enterB"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} diff --git a/crates/benilla-ui/src/script/tick.rs b/crates/benilla-ui/src/script/tick.rs index 01dccce9..a97f1d40 100644 --- a/crates/benilla-ui/src/script/tick.rs +++ b/crates/benilla-ui/src/script/tick.rs @@ -71,11 +71,13 @@ pub(crate) fn fire_event_into(lua: &Lua, event: &str, args: Vec) { impl super::UiScript { /// Advance a frame: the `GetTime()` clock and queued events, then the pump `0x765650` and the - /// draw pass `0x7657d0`, in that order (`0x764340`, `0x764347`). The pump is the update walk, - /// each linked frame's update in strata, level and link order: its `OnUpdate(self, elapsed)` - /// (`0x704f10`) and its kind's own update after it (an EditBox's flush, a tooltip's fade, a - /// model pane's clock, a message frame's line fades, a scroll frame's range); then the hover - /// re-pick at its tail (`0x7657be`). The draw pass fires `OnUpdateModel`. + /// draw pass `0x7657d0`, in that order (`0x764340`, `0x764347`). The pump drains the layout + /// queue (`0x768ed0`, at `0x7656ba`), whose rect applications fire `OnSizeChanged`; runs the + /// update walk, each linked frame's update in strata, level and link order: its + /// `OnUpdate(self, elapsed)` (`0x704f10`) and its kind's own update after it (an EditBox's + /// flush, a tooltip's fade, a model pane's clock, a message frame's line fades, a scroll frame's + /// range); drains the layout again (`0x765799`); then re-picks the hover against those rects + /// (`0x7657be`). The draw pass fires `OnUpdateModel`. pub fn tick(&mut self, elapsed: f32) { self.model_mut().now += f64::from(elapsed); // Events queued since the last tick fire before this tick's OnUpdate. @@ -83,7 +85,9 @@ impl super::UiScript { for (event, args) in pending { self.fire_event(&event, args); } + self.resolve(); self.update_walk(elapsed); + self.resolve(); // A frame hid or showed under a still cursor: re-run the hover walk at the saved position, // as the reference's pump tail does (`0x765650` → `0x7660d0`). The hidden frame's OnLeave // already fired, so only the new winner's `OnEnter` fires. @@ -147,16 +151,13 @@ impl UiScript { /// then `0x7658e2`), level 0 up, a level's frames first linked first (`0x765920`). So the /// panes are read per stratum when its turn comes, and a pane an earlier handler of that /// stratum hid still paints this frame; only the callback's own gates ([`paints`]) are read at - /// the call. - /// - /// The pump drains the layout after the walk (`0x765799`), so the paint reads rects the walk's - /// handlers moved: the pass resolves first. + /// the call. The rects are the ones the pump's last drain (`0x765799`) left: the paint reads + /// them through `0x768320`, which never resolves. fn paint_model_panes(&mut self) { let mut model = self.model_mut(); if model.on_update_model_frames.is_empty() { return; } - Self::resolve_layout(&mut model); // Kept by `SetScript`; a destroyed frame's handle compacts out on its first miss. if model .on_update_model_frames diff --git a/crates/benilla-ui/src/script/tooltip/mod.rs b/crates/benilla-ui/src/script/tooltip/mod.rs index 1fcce159..e96a7cca 100644 --- a/crates/benilla-ui/src/script/tooltip/mod.rs +++ b/crates/benilla-ui/src/script/tooltip/mod.rs @@ -258,6 +258,7 @@ pub(super) fn write_cell( color: [f32; 4], ) { let d = model.region_data.entry(rh).or_default(); + let was_shown = d.text.as_deref().is_some_and(|t| !t.is_empty()); d.text = Some(text.to_string()); d.vertex_color = Some(color); // The line's own colour, as if set by `SetTextColor`: a later @@ -265,6 +266,7 @@ pub(super) fn write_cell( d.font_explicit.color = true; d.hidden = false; model.touch_measure(rh); + model.touch_layout_text(rh, was_shown); } /// Blanks every line cell and resets the counters and the subject: the model half of the clear @@ -276,12 +278,14 @@ fn clear_content(model: &mut Model, h: FrameHandle) { }; for rh in lefts.into_iter().chain(rights) { 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; // `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 - // and re-lays out nothing. + // nothing, and its lines' layout re-hashes to the converged node and solves nothing. + model.touch_layout_text(rh, was_shown); } } if let Ok(t) = tip_mut(model, h) {