From 377542daeb1b80982a8218fb38c52ee7f8440c84 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:19:11 +0700 Subject: [PATCH] One anchor slot per point; SetPoint skips an unchanged anchor (#934) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anchors were a list: SetPoint re-applied an unchanged anchor, GetPoint counted in the order points were set and answered five nils past the last, and the cursor tooltip replaced every anchor each frame. In 1.12.1 a frame holds one anchor per point. SetPoint (`0x767c70`) returns at `0x767d5e` with no store and no layout queue when that point's anchor has the same target and relative point and both offsets differ by under 2⁻²² layout units (`[0x8029d4]`). GetPoint (`0x7a2340`) walks the nine slots in point order and pushes nothing past the last (`0x7a240d`). The `ANCHOR_CURSOR` update sets BOTTOM alone (`0x530b20`). Anchors are now nine slots, and SetPoint, GetPoint and the cursor update follow those rules. Tests: an unchanged re-set and an in-threshold offset touch no layout, GetPoint order and indexes, a cursor tooltip keeps an addon's second point. Fixes #926 Fixes #131 Fixes #879 πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 --- crates/benilla-ui/src/layout.rs | 251 ++++++++++++++++-- crates/benilla-ui/src/script/backdrop.rs | 4 +- crates/benilla-ui/src/script/chat_bubble.rs | 14 +- .../benilla-ui/src/script/editbox/relayout.rs | 13 +- crates/benilla-ui/src/script/layout.rs | 12 +- crates/benilla-ui/src/script/layout_cache.rs | 6 +- crates/benilla-ui/src/script/modelframe.rs | 8 + crates/benilla-ui/src/script/nameplate.rs | 29 +- crates/benilla-ui/src/script/object.rs | 1 - .../src/script/object/anchor_args.rs | 30 +++ .../src/script/object/layout_methods.rs | 92 +++---- .../benilla-ui/src/script/object/movable.rs | 12 +- crates/benilla-ui/src/script/region.rs | 28 +- crates/benilla-ui/src/script/region/layout.rs | 64 ++--- .../benilla-ui/src/script/simplehtml/mod.rs | 11 +- .../src/script/tests/anchor_slots.rs | 213 +++++++++++++++ .../src/script/tests/layout_structure.rs | 4 +- crates/benilla-ui/src/script/tests/mod.rs | 1 + .../src/script/tests/reference_surface.rs | 8 +- crates/benilla-ui/src/script/tests/tooltip.rs | 104 ++++++++ crates/benilla-ui/src/script/tooltip/mod.rs | 65 +++-- crates/benilla-ui/src/script/tooltip/verbs.rs | 7 +- crates/benilla-ui/src/script/types.rs | 4 +- 23 files changed, 752 insertions(+), 229 deletions(-) create mode 100644 crates/benilla-ui/src/script/tests/anchor_slots.rs diff --git a/crates/benilla-ui/src/layout.rs b/crates/benilla-ui/src/layout.rs index a264fc81..158c09ea 100644 --- a/crates/benilla-ui/src/layout.rs +++ b/crates/benilla-ui/src/layout.rs @@ -141,6 +141,170 @@ impl Anchor { y_off, } } + + /// Bit-exact equality, as the layout gate's fingerprint compares, so the two always agree. + pub fn bits_eq(&self, other: &Anchor) -> bool { + self.point == other.point + && self.relative_to == other.relative_to + && self.relative_point == other.relative_point + && self.x_off.to_bits() == other.x_off.to_bits() + && self.y_off.to_bits() == other.y_off.to_bits() + } +} + +/// The client's aspect globals, both written only by `0x41ad10(a)`: `[0x832a4c] = f32(a Β· 0.75)` +/// (`0x41ad24`) and `[0x832a44] = f32(a / √(aΒ² + 1))`, the quotient kept unrounded before its +/// multiply (`0x41ad3e` `fst`, `0x41ad44`, `0x41ad47`). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LayoutUnits { + g3: f32, + g44: f32, +} + +impl LayoutUnits { + /// From the screen aspect `a`, width over height. + pub fn from_aspect(a: f32) -> LayoutUnits { + let a64 = f64::from(a); + LayoutUnits { + g3: (a64 * 0.75) as f32, + g44: ((1.0 / (a64 * a64 + 1.0).sqrt()) * a64) as f32, + } + } + + /// A Lua offset as `SetPoint`'s binding stores it, in layout units (`0x7a28a3`..`0x7a28e3`): + /// `f32(f32(v / ([0x832a4c] Β· 1024)) Β· [0x832a44])`. + pub fn store(self, v: f32) -> f32 { + let q = (f64::from(v) / (f64::from(self.g3) * 1024.0)) as f32; + (f64::from(self.g44) * f64::from(q)) as f32 + } +} + +/// A region's anchors, one slot per point: the client's `anchorPoints[9]` (`G+0x4`, a frame's +/// `+0x28`), indexed by point id. Setting a point replaces its slot (`SetPoint` stores at +/// `0x767d33`), so the order anchors were set in is not kept, and every walk runs in point order, +/// TOPLEFT to BOTTOMRIGHT, as `GetPoint` (`0x7a23f5`) and `GetNumPoints` (`0x7a2311`) walk them. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Anchors([Option; 9]); + +impl Anchors { + pub const fn new() -> Anchors { + Anchors([None; 9]) + } + + /// The anchor held for `point`. + #[inline] + pub fn get(&self, point: Point) -> Option<&Anchor> { + self.0[point.id() as usize].as_ref() + } + + /// Store `a` in its point's slot, returning the anchor it replaces. + #[inline] + pub fn insert(&mut self, a: Anchor) -> Option { + self.0[a.point.id() as usize].replace(a) + } + + /// `SetPoint`'s store (`0x767c70`): the anchor held for `a.point` stands when it has the same + /// target (`0x767ca9`) and relative point (`0x767cb1`) and both offsets, as the client stores + /// them ([`LayoutUnits::store`]), differ by less than 2⁻²² (`0x767cb6`..`0x767cde`), and the + /// call then stores nothing; else `a` replaces it. Returns whether it stored. A NaN offset + /// never matches, as the client's unordered compare. + pub fn set(&mut self, a: Anchor, units: LayoutUnits) -> bool { + let near = |p: f32, q: f32| { + (f64::from(units.store(p)) - f64::from(units.store(q))).abs() < SIZE_EPS + }; + let unchanged = self.get(a.point).is_some_and(|h| { + h.relative_to == a.relative_to + && h.relative_point == a.relative_point + && near(h.x_off, a.x_off) + && near(h.y_off, a.y_off) + }); + if !unchanged { + self.insert(a); + } + !unchanged + } + + /// Empty every slot, returning whether any was held (`ClearAllPoints`, `0x767620`). + #[inline] + pub fn clear(&mut self) -> bool { + let held = !self.is_empty(); + self.0 = [None; 9]; + held + } + + #[inline] + pub fn is_empty(&self) -> bool { + self.0.iter().all(Option::is_none) + } + + /// The occupied slots, `GetNumPoints`'s count. + #[inline] + pub fn len(&self) -> usize { + self.0.iter().filter(|s| s.is_some()).count() + } + + /// The held anchors in point order. + #[inline] + pub fn iter(&self) -> std::iter::Flatten>> { + self.0.iter().flatten() + } + + /// Each held anchor's offsets in point order; the point is not writable here, so an anchor + /// never leaves its slot. + #[inline] + pub fn offsets_mut(&mut self) -> impl Iterator { + self.0 + .iter_mut() + .flatten() + .map(|a| (&mut a.x_off, &mut a.y_off)) + } + + /// The `n`-th held anchor in point order, from 0. + #[inline] + pub fn nth(&self, n: usize) -> Option<&Anchor> { + self.iter().nth(n) + } + + /// The first held anchor in point order. + #[inline] + pub fn first(&self) -> Option<&Anchor> { + self.iter().next() + } + + /// 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) { + (Some(a), Some(b)) => a.bits_eq(b), + (None, None) => true, + _ => false, + }) + } +} + +/// Each anchor into its point's slot, a later one on the same point replacing an earlier one. +impl FromIterator for Anchors { + fn from_iter>(iter: I) -> Anchors { + let mut out = Anchors::new(); + for a in iter { + out.insert(a); + } + out + } +} + +impl From<[Anchor; N]> for Anchors { + fn from(a: [Anchor; N]) -> Anchors { + a.into_iter().collect() + } +} + +impl<'a> IntoIterator for &'a Anchors { + type Item = &'a Anchor; + type IntoIter = std::iter::Flatten>>; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } } /// A frame's layout input, the geometry fields the client's resolver reads: `anchorPoints[9]` @@ -148,8 +312,7 @@ impl Anchor { /// (bit 4 of `G+0x3c`). #[derive(Clone, Debug, PartialEq)] pub struct LayoutInput { - /// Slotted by `point`, the later of two on one point winning (`SetPoint`, `0x767c70`). - pub anchors: Vec, + pub anchors: Anchors, /// `0.0` is the no-size sentinel (`0x7ffd74`): the axis derives from the anchors. pub width: f32, pub height: f32, @@ -167,7 +330,7 @@ pub struct LayoutInput { impl Default for LayoutInput { fn default() -> LayoutInput { LayoutInput { - anchors: Vec::new(), + anchors: Anchors::new(), width: 0.0, height: 0.0, scale: 1.0, @@ -180,9 +343,13 @@ impl Default for LayoutInput { impl LayoutInput { /// A frame with the given anchors and explicit size, scale `1.0`, no clamp. - pub fn sized(anchors: Vec, width: f32, height: f32) -> LayoutInput { + pub fn sized( + anchors: impl IntoIterator, + width: f32, + height: f32, + ) -> LayoutInput { LayoutInput { - anchors, + anchors: anchors.into_iter().collect(), width, height, ..LayoutInput::default() @@ -637,7 +804,7 @@ pub fn resolve_rect_edges( /// [`set_external`](Self::set_external) and [`set_frame`](Self::set_frame), [`solve`](Self::solve). #[derive(Clone, Debug, Default)] pub struct LayoutSolver { - /// Overwritten in place, so each anchors `Vec` keeps its allocation. + /// Overwritten in place, so a steady round allocates nothing. input: Vec, is_frame: Vec, /// Externals as seeded, frames as they resolve. @@ -723,8 +890,7 @@ impl LayoutSolver { self.ensure(h); self.touch(h); let dst = &mut self.input[h as usize]; - dst.anchors.clear(); - dst.anchors.extend_from_slice(&src.anchors); + dst.anchors = src.anchors; Self::copy_fields(dst, src); self.is_frame[h as usize] = true; self.live.push(h); @@ -735,8 +901,7 @@ impl LayoutSolver { self.ensure(h); self.touch(h); let dst = &mut self.input[h as usize]; - dst.anchors.clear(); - dst.anchors.push(anchor); + dst.anchors = Anchors::from([anchor]); Self::copy_fields(dst, src); self.is_frame[h as usize] = true; self.live.push(h); @@ -775,8 +940,8 @@ impl LayoutSolver { let h = self.live[idx]; let mut seen: [Handle; 9] = [0; 9]; let mut n_seen = 0usize; - for a_idx in 0..self.input[h as usize].anchors.len() { - let d = self.input[h as usize].anchors[a_idx].relative_to; + let anchors = self.input[h as usize].anchors; + for d in anchors.iter().map(|a| a.relative_to) { if !self.is_frame.get(d as usize).copied().unwrap_or(false) { continue; } @@ -866,6 +1031,15 @@ mod tests { Rect::new(0.0, 0.0, 600.0, 800.0) } + /// `0x41ad10` at 4:3: `[0x832a4c] = 1.0` and `[0x832a44] = 0.8` (as `f32`), so an offset + /// stores as `f32(f32(v / 1024) Β· 0.8)`. + #[test] + fn layout_units_at_four_thirds() { + let u = LayoutUnits::from_aspect(4.0 / 3.0); + assert_eq!(u, LayoutUnits { g3: 1.0, g44: 0.8 }); + assert_eq!(u.store(1280.0), (1.25_f64 * f64::from(0.8_f32)) as f32); + } + fn bits(r: Rect) -> [u32; 4] { [ r.bottom.to_bits(), @@ -874,6 +1048,53 @@ mod tests { r.right.to_bits(), ] } + + /// One slot per point, walked in point order whatever order the points were set in. + #[test] + fn anchors_keep_one_slot_per_point_in_point_order() { + let br = Anchor::new(Point::BottomRight, SCREEN, Point::BottomRight, -1.0, 1.0); + let tl = Anchor::new(Point::TopLeft, SCREEN, Point::TopLeft, 1.0, -1.0); + let tl2 = Anchor::new(Point::TopLeft, 7, Point::Center, 2.0, -2.0); + let a = Anchors::from([br, tl, tl2]); + assert_eq!(a.len(), 2); + assert_eq!(a.iter().copied().collect::>(), vec![tl2, br]); + assert_eq!(a.nth(1), Some(&br)); + assert_eq!(a.nth(2), None); + } + + /// `SetPoint`'s compare (`0x767cb6`..`0x767cde`): under the threshold on both axes with the + /// same target and relative point stands; past it, NaN, or another target or relative point + /// replaces. At 4:3 a layout unit is 1280 FrameXML units, so 2⁻²² is about 3.05e-4. + #[test] + fn anchors_set_keeps_an_anchor_within_the_threshold() { + let eps = LayoutUnits::from_aspect(4.0 / 3.0); + let held = Anchor::new(Point::Center, SCREEN, Point::Center, 10.0, 20.0); + let mut a = Anchors::from([held]); + let with = |x: f32, y: f32| Anchor::new(Point::Center, SCREEN, Point::Center, x, y); + assert!(!a.set(with(10.0001, 19.9999), eps)); + assert_eq!(a.get(Point::Center), Some(&held), "nothing is stored"); + for (x, y) in [(10.001, 20.0), (10.0, 19.999), (f32::NAN, 20.0)] { + let mut b = a; + assert!(b.set(with(x, y), eps), "({x}, {y})"); + assert!(b.get(Point::Center).unwrap().x_off.to_bits() == x.to_bits()); + } + let mut b = a; + assert!(b.set( + Anchor::new(Point::Center, 3, Point::Center, 10.0, 20.0), + eps + )); + let mut b = a; + assert!(b.set( + Anchor::new(Point::Center, SCREEN, Point::Top, 10.0, 20.0), + eps + )); + let mut b = a; + assert!(b.set( + Anchor::new(Point::Top, SCREEN, Point::Center, 10.0, 20.0), + eps + )); + assert_eq!(b.len(), 2); + } fn assert_rect(got: ResolveOutcome, want: Rect) { match got { ResolveOutcome::Resolved(r) => { @@ -1062,13 +1283,13 @@ mod tests { #[test] fn scale_interaction() { let f = LayoutInput { - anchors: vec![Anchor::new( + anchors: Anchors::from([Anchor::new( Point::TopLeft, SCREEN, Point::TopLeft, 10.0, -5.0, - )], + )]), width: 200.0, height: 50.0, scale: 2.0, @@ -1395,7 +1616,7 @@ mod tests { assert!(s.unresolvable().is_empty()); } - /// The override keeps the size and scale but replaces the anchors, and the reused anchors `Vec` + /// The override keeps the size and scale but replaces the anchors, and the reused input slot /// must not leak the last round's. #[test] fn set_frame_anchored_replaces_only_the_anchors() { diff --git a/crates/benilla-ui/src/script/backdrop.rs b/crates/benilla-ui/src/script/backdrop.rs index ba1c94e0..58e152d4 100644 --- a/crates/benilla-ui/src/script/backdrop.rs +++ b/crates/benilla-ui/src/script/backdrop.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; -use crate::layout::{self, Anchor, Point, Rect}; +use crate::layout::{self, Anchor, Anchors, Point, Rect}; use crate::order::DrawLayer; use crate::widget::{FrameHandle, RegionHandle, RegionKind}; @@ -383,7 +383,7 @@ pub(crate) fn install(model: &mut Model, fh: FrameHandle, spec: Option /// A piece's anchors on frame `owner` and its explicit size, `None` where the anchors span both /// axes. -fn piece_geometry(owner: u32, (anchors, size): PieceLayout) -> (Vec, Option<(f32, f32)>) { +fn piece_geometry(owner: u32, (anchors, size): PieceLayout) -> (Anchors, Option<(f32, f32)>) { let anchors = anchors .into_iter() .flatten() diff --git a/crates/benilla-ui/src/script/chat_bubble.rs b/crates/benilla-ui/src/script/chat_bubble.rs index 39c8cebe..88d06aea 100644 --- a/crates/benilla-ui/src/script/chat_bubble.rs +++ b/crates/benilla-ui/src/script/chat_bubble.rs @@ -11,7 +11,7 @@ use std::collections::{HashMap, VecDeque}; -use crate::layout::{Anchor, Point}; +use crate::layout::{Anchor, Anchors, Point}; use crate::order::DrawLayer; use crate::widget::{FrameHandle, FrameKind, RegionHandle, RegionKind}; @@ -318,20 +318,20 @@ fn lay_out(model: &mut Model, i: usize, g: BubbleGeometry) { // A border-unit square, its TOPRIGHT on the frame's BOTTOM lifted a quarter unit. let tail_data = model.region_data.entry(tail).or_default(); tail_data.size = Some((g.border, g.border)); - tail_data.anchors = vec![Anchor::new( + tail_data.anchors = Anchors::from([Anchor::new( Point::TopRight, frame_id, Point::Bottom, 0.0, g.border / 4.0, - )]; + )]); model.region_data.entry(text).or_default().font_height = Some(g.text_height); model.touch_measure(text); // The frame hugs the text: TOPLEFT and BOTTOMRIGHT on the text's, out by the margin. let input = model.layout_inputs.entry(frame).or_default(); - input.anchors = vec![ + input.anchors = Anchors::from([ Anchor::new(Point::TopLeft, text_id, Point::TopLeft, -g.margin, g.margin), Anchor::new( Point::BottomRight, @@ -340,7 +340,7 @@ fn lay_out(model: &mut Model, i: usize, g: BubbleGeometry) { g.margin, -g.margin, ), - ]; + ]); // Once per geometry change: each node it wrote re-derives. model.touch_layout_frame(frame); model.touch_layout_region(tail); @@ -450,13 +450,13 @@ fn drive( ); if last_seat != Some(seat) { let data = model.region_data.entry(text).or_default(); - data.anchors = vec![Anchor::new( + data.anchors = Anchors::from([Anchor::new( Point::Bottom, world_id, Point::BottomLeft, seat.0, seat.1, - )]; + )]); model.touch_layout_region(text); model.chat_bubbles.bubbles[i].seat = Some(seat); } diff --git a/crates/benilla-ui/src/script/editbox/relayout.rs b/crates/benilla-ui/src/script/editbox/relayout.rs index b5a80c4f..aba72a7f 100644 --- a/crates/benilla-ui/src/script/editbox/relayout.rs +++ b/crates/benilla-ui/src/script/editbox/relayout.rs @@ -3,7 +3,7 @@ use mlua::Lua; -use crate::layout::{Anchor, Point, Rect}; +use crate::layout::{Anchor, Anchors, Point, Rect}; use crate::script::{MeasureRequest, Model}; use crate::widget::{EditBoxState, FrameHandle, KindState, RegionHandle}; @@ -39,18 +39,13 @@ pub(super) fn seat_text_region(model: &mut Model, h: FrameHandle) { let top_left = Anchor::new(Point::TopLeft, owner, Point::TopLeft, l, -t); let (anchors, size) = if multi_line { let width = multi_line_text_width(model, h, [l, r]); - (vec![top_left], width.map(|w| Some((w, 0.0)))) + (Anchors::from([top_left]), width.map(|w| Some((w, 0.0)))) } else { let bottom_right = Anchor::new(Point::BottomRight, owner, Point::BottomRight, -r, b); - (vec![top_left, bottom_right], Some(None)) + (Anchors::from([top_left, bottom_right]), Some(None)) }; let data = model.region_data.entry(rh).or_default(); - let same_anchors = data.anchors.len() == anchors.len() - && data - .anchors - .iter() - .zip(&anchors) - .all(|(a, b)| crate::script::object::anchor_bits_eq(a, b)); + let same_anchors = data.anchors.bits_eq(&anchors); // `None` keeps the size: a multi-line box with no rect yet. let size = size.unwrap_or(data.size); let size_moved = !crate::script::region::size_bits_eq(data.size, size); diff --git a/crates/benilla-ui/src/script/layout.rs b/crates/benilla-ui/src/script/layout.rs index a67494d4..147ae351 100644 --- a/crates/benilla-ui/src/script/layout.rs +++ b/crates/benilla-ui/src/script/layout.rs @@ -39,16 +39,19 @@ impl InputFingerprint { self.feed(u64::from(v.to_bits())); } + /// The anchors in point order, then their count. #[inline] - fn anchors(&mut self, anchors: &[Anchor]) { - self.feed(anchors.len() as u64); + fn anchors<'a>(&mut self, anchors: impl IntoIterator) { + let mut n = 0u64; for a in anchors { self.feed(u64::from(a.point.id())); self.feed(u64::from(a.relative_to)); self.feed(u64::from(a.relative_point.id())); self.f32(a.x_off); self.f32(a.y_off); + n += 1; } + self.feed(n); } #[inline] @@ -505,7 +508,7 @@ impl LayoutScope { }; let over_eq = |a: Option, b: Option| match (a, b) { (None, None) => true, - (Some(a), Some(b)) => super::object::anchor_bits_eq(&a, &b), + (Some(a), Some(b)) => a.bits_eq(&b), _ => false, }; let n = self @@ -1221,8 +1224,7 @@ impl UiScript { } } } - scratch.anchors.clear(); - scratch.anchors.extend_from_slice(&data.anchors); + scratch.anchors = data.anchors; scratch.width = width; scratch.height = height; scratch.scale = scale; diff --git a/crates/benilla-ui/src/script/layout_cache.rs b/crates/benilla-ui/src/script/layout_cache.rs index 9361064c..cd9f31f7 100644 --- a/crates/benilla-ui/src/script/layout_cache.rs +++ b/crates/benilla-ui/src/script/layout_cache.rs @@ -7,7 +7,7 @@ //! root (saved as no target, the `nil` `GetPoint` answers there) or a named frame; a frame anchored //! to anything else is skipped whole, never half-restored. -use crate::layout::Anchor; +use crate::layout::{Anchor, Anchors}; use crate::widget::FrameHandle; use super::object::{point_from_str, point_name}; @@ -85,7 +85,7 @@ impl super::UiScript { let Some(h) = model.arena.lookup(&l.name) else { continue; // a window this build does not have }; - let mut anchors: Vec = Vec::with_capacity(l.points.len()); + let mut anchors = Anchors::new(); let mut usable = true; for p in &l.points { // `frame_id` mints the id lazily, so a target nothing has anchored to yet resolves. @@ -99,7 +99,7 @@ impl super::UiScript { point_from_str(&p.relative_point), ) { (Some(point), Some(rel), Some(rp)) => { - anchors.push(Anchor::new(point, rel, rp, p.x, p.y)); + anchors.insert(Anchor::new(point, rel, rp, p.x, p.y)); } _ => { usable = false; diff --git a/crates/benilla-ui/src/script/modelframe.rs b/crates/benilla-ui/src/script/modelframe.rs index cc4585a0..285b1d5d 100644 --- a/crates/benilla-ui/src/script/modelframe.rs +++ b/crates/benilla-ui/src/script/modelframe.rs @@ -41,6 +41,14 @@ impl Model { crate::layout::SIZE_EPS * f64::from(self.layout_unit()) } + /// The aspect globals `SetPoint`'s unchanged-anchor test quantizes offsets by + /// ([`crate::layout::Anchors::set`]), for the screen aspect [`Self::layout_unit`] reads. + pub(crate) fn layout_units(&self) -> crate::layout::LayoutUnits { + let (w, h) = (self.screen.width(), self.screen.height()); + let a = if h > 0.0 && w > 0.0 { w / h } else { 4.0 / 3.0 }; + crate::layout::LayoutUnits::from_aspect(a) + } + /// The drain's keep threshold, 1e-5 in the reference's layout units (`[0x80c5c8]`, tested in /// `0x768d20`), in ours: [`crate::layout::RESOLVE_EPS`] times the layout unit. pub(crate) fn resolve_eps(&self) -> f64 { diff --git a/crates/benilla-ui/src/script/nameplate.rs b/crates/benilla-ui/src/script/nameplate.rs index c9b44565..fd9ce8fa 100644 --- a/crates/benilla-ui/src/script/nameplate.rs +++ b/crates/benilla-ui/src/script/nameplate.rs @@ -10,7 +10,7 @@ use mlua::Lua; -use crate::layout::{Anchor, Point}; +use crate::layout::{Anchor, Anchors, Point}; use crate::order::DrawLayer; use crate::widget::{FrameHandle, FrameKind, KindState, RegionHandle, RegionKind}; @@ -525,32 +525,32 @@ impl Plate { input.height = g.height; for rh in [border, glow] { - model.region_data.entry(rh).or_default().anchors = vec![ + model.region_data.entry(rh).or_default().anchors = Anchors::from([ Anchor::new(Point::TopLeft, plate_id, Point::TopLeft, 0.0, 0.0), Anchor::new(Point::BottomRight, plate_id, Point::BottomRight, 0.0, 0.0), - ]; + ]); } let bar_input = model.layout_inputs.entry(bar).or_default(); - bar_input.anchors = vec![Anchor::new( + bar_input.anchors = Anchors::from([Anchor::new( Point::BottomLeft, plate_id, Point::BottomLeft, g.bar_off_x, g.bar_off_y, - )]; + )]); bar_input.width = g.bar_width; bar_input.height = g.bar_height; // The name's (`0x7cb456`) and level's (`0x7cb7f5`) anchors, which `_Nameplates` checks. let name_data = model.region_data.entry(name).or_default(); - name_data.anchors = vec![Anchor::new( + name_data.anchors = Anchors::from([Anchor::new( Point::Bottom, plate_id, Point::Center, 0.0, 0.0, - )]; + )]); name_data.font_height = Some(g.name_height); for rh in [name, level] { @@ -561,29 +561,30 @@ impl Plate { } let level_data = model.region_data.entry(level).or_default(); - level_data.anchors = vec![Anchor::new( + level_data.anchors = Anchors::from([Anchor::new( Point::Center, plate_id, Point::BottomRight, -g.level_off_x, g.level_off_y, - )]; + )]); level_data.font_height = Some(g.level_height); level_data.font_explicit.height = true; model.touch_measure(level); let skull_data = model.region_data.entry(skull).or_default(); - skull_data.anchors = vec![Anchor::new( + skull_data.anchors = Anchors::from([Anchor::new( Point::Center, plate_id, Point::BottomRight, -g.level_off_x, g.level_off_y, - )]; + )]); skull_data.size = Some((g.skull_size, g.skull_size)); let raid_data = model.region_data.entry(raid).or_default(); - raid_data.anchors = vec![Anchor::new(Point::Right, plate_id, Point::Left, 0.0, 0.0)]; + raid_data.anchors = + Anchors::from([Anchor::new(Point::Right, plate_id, Point::Left, 0.0, 0.0)]); raid_data.size = Some((g.raid_size, g.raid_size)); // Once per geometry change: each node it wrote re-derives. @@ -616,13 +617,13 @@ impl Plate { .is_none_or(|l| l.top_centre != state.top_centre) { let input = model.layout_inputs.entry(frame).or_default(); - input.anchors = vec![Anchor::new( + input.anchors = Anchors::from([Anchor::new( Point::Top, world_id, Point::BottomLeft, state.top_centre.0, state.top_centre.1, - )]; + )]); model.touch_layout_frame(frame); } diff --git a/crates/benilla-ui/src/script/object.rs b/crates/benilla-ui/src/script/object.rs index 6e1e9a3e..88f9960c 100644 --- a/crates/benilla-ui/src/script/object.rs +++ b/crates/benilla-ui/src/script/object.rs @@ -24,7 +24,6 @@ mod layout_methods; pub(crate) use layout_methods::eff_scale; pub(crate) mod movable; pub(crate) mod toplevel; -pub(crate) use layout_methods::anchor_bits_eq; pub(crate) use movable::{advance_move, advance_size, FrameMove, FrameSizing}; // ───────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/benilla-ui/src/script/object/anchor_args.rs b/crates/benilla-ui/src/script/object/anchor_args.rs index fade7e05..d5f277dc 100644 --- a/crates/benilla-ui/src/script/object/anchor_args.rs +++ b/crates/benilla-ui/src/script/object/anchor_args.rs @@ -195,3 +195,33 @@ pub(crate) fn resolve_xml_relative_to(lua: &Lua, name: &str) -> Option { // A wrapper, not a name, so the Lua ladder's raise can never reach a loading document. nt.wrapper.clone() } + +/// `GetPoint`'s index (`0x7a2340`), from 0: an `is-number` argument (`0x6f34d0`), truncated +/// toward zero (`0x40a2b0`), less one; any other argument, or one at or below 1, picks the first +/// anchor, since the walk takes the first held slot whose count reaches the index (`0x7a23fe`). +pub(crate) fn get_point_index(lua: &Lua, n: Value) -> mlua::Result { + let idx = match lua.coerce_number(n)? { + Some(n) => (n as i64 as i32).wrapping_sub(1), + None => 0, + }; + Ok(idx.max(0) as usize) +} + +/// `GetPoint`'s answer for `anchor` (`0x7a2416`..`0x7a24d6`): point, `relativeTo`, relative point +/// and the two offsets; no anchor at the index pushes no value at all (`0x7a240d`). +pub(crate) fn get_point_values( + lua: &Lua, + anchor: Option, + rel: impl FnOnce(u32) -> mlua::Result, +) -> mlua::Result { + let Some(a) = anchor else { + return Ok(MultiValue::new()); + }; + Ok(MultiValue::from_vec(vec![ + Value::String(lua.create_string(super::point_name(a.point))?), + rel(a.relative_to)?, + Value::String(lua.create_string(super::point_name(a.relative_point))?), + Value::Number(f64::from(a.x_off)), + Value::Number(f64::from(a.y_off)), + ])) +} diff --git a/crates/benilla-ui/src/script/object/layout_methods.rs b/crates/benilla-ui/src/script/object/layout_methods.rs index 481a22a3..b3f66682 100644 --- a/crates/benilla-ui/src/script/object/layout_methods.rs +++ b/crates/benilla-ui/src/script/object/layout_methods.rs @@ -2,13 +2,16 @@ use mlua::{Lua, MultiValue, Table, Value}; -use crate::layout::{Anchor, Point}; +use crate::layout::{Anchor, Anchors, Point}; use crate::script::region_map::{set_shared, Side}; use crate::script::{Model, SCREEN}; use crate::widget::FrameHandle; -use super::anchor_args::{parse_set_all_points, parse_set_point, resolve_rel_target, UNNAMED}; -use super::{frame_handle_of, frame_parent_token_base, frame_wrapper, point_name}; +use super::anchor_args::{ + get_point_index, get_point_values, parse_set_all_points, parse_set_point, resolve_rel_target, + UNNAMED, +}; +use super::{frame_handle_of, frame_parent_token_base, frame_wrapper}; /// The receiver's name for the error strings and its `$parent` base, read under one short `Model` /// borrow that ends before the ladder's `_G` read. @@ -37,51 +40,40 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { let mut model = lua.app_data_mut::().expect("model"); // Every layout setter mutates only on a real change, so a per-frame caller setting the // same value never reopens the layout gate. - let cleared = match model.layout_inputs.get_mut(&h) { - Some(input) if !input.anchors.is_empty() => { - input.anchors.clear(); - true - } - _ => false, - }; + let cleared = model + .layout_inputs + .get_mut(&h) + .is_some_and(|input| input.anchors.clear()); if cleared { model.touch_layout_frame(h); } Ok(()) })?; - // GetPoint([n]) β†’ point, relativeTo, relativePoint, x, y of the n-th anchor (default 1); - // relativeTo is nil for a screen-root anchor, since `SCREEN` has no wrapper. + // GetPoint([n]) β†’ point, relativeTo, relativePoint, x, y of the n-th anchor in point order + // (`0x7a2340`); relativeTo is nil for a screen-root anchor, since `SCREEN` has no wrapper. set_shared( lua, m, Side::Frame, "GetPoint", - |lua, (this, n): (Table, Option)| { + |lua, (this, n): (Table, Value)| { let h = frame_handle_of(lua, &this)?; + let idx = get_point_index(lua, n)?; let anchor = { let model = lua.app_data_ref::().expect("model"); - let idx = (n.unwrap_or(1).max(1) - 1) as usize; model .layout_inputs .get(&h) - .and_then(|i| i.anchors.get(idx)) - .cloned() + .and_then(|i| i.anchors.nth(idx)) + .copied() }; - let Some(a) = anchor else { - return Ok((Value::Nil, Value::Nil, Value::Nil, Value::Nil, Value::Nil)); - }; - let rel = if a.relative_to == SCREEN { - Value::Nil - } else { - Value::Table(frame_wrapper(lua, a.relative_to)?) - }; - Ok(( - Value::String(lua.create_string(point_name(a.point))?), - rel, - Value::String(lua.create_string(point_name(a.relative_point))?), - Value::Number(f64::from(a.x_off)), - Value::Number(f64::from(a.y_off)), - )) + get_point_values(lua, anchor, |rel| { + Ok(if rel == SCREEN { + Value::Nil + } else { + Value::Table(frame_wrapper(lua, rel)?) + }) + }) }, )?; // GetNumPoints(): on the Region method table (`0x87c9b8`), so every widget answers it. @@ -115,15 +107,10 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { Anchor::new(Point::BottomRight, rel_id, Point::BottomRight, 0.0, 0.0), ]; let input = model.layout_inputs.entry(h).or_default(); - let same = input.anchors.len() == 2 - && input - .anchors - .iter() - .zip(&pair) - .all(|(a, b)| anchor_bits_eq(a, b)); - if !same { - input.anchors.clear(); - input.anchors.extend_from_slice(&pair); + // The reference clears and sets both (`0x767db0`); only a bit-identical pair skips. + let pair = Anchors::from(pair); + if !input.anchors.bits_eq(&pair) { + input.anchors = pair; model.touch_layout_frame(h); } Ok(()) @@ -286,34 +273,17 @@ fn set_point(lua: &Lua, this: &Table, args: &MultiValue) -> mlua::Result<()> { let rel_to_id = resolve_rel_target(&model, &p.target, &who, "SetPoint", me, parent)?; let (point, rel_point, x, y) = (p.point, p.rel_point, p.x, p.y); + let units = model.layout_units(); let input = model.layout_inputs.entry(h).or_default(); - let new = Anchor::new(point, rel_to_id, rel_point, x, y); - // A no-op only when the identical anchor is already last and no earlier one has this point, - // mirroring the retain and push below; bits compared, as the fingerprint tells -0.0 from 0.0. - let same_at_tail = input + if input .anchors - .last() - .is_some_and(|a| anchor_bits_eq(a, &new)) - && !input.anchors[..input.anchors.len() - 1] - .iter() - .any(|a| a.point == point); - if !same_at_tail { - input.anchors.retain(|a| a.point != point); - input.anchors.push(new); + .set(Anchor::new(point, rel_to_id, rel_point, x, y), units) + { model.touch_layout_frame(h); } Ok(()) } -/// Bit-exact anchor equality, as the layout gate's fingerprint compares, so the two always agree. -pub(crate) fn anchor_bits_eq(a: &Anchor, b: &Anchor) -> bool { - a.point == b.point - && a.relative_to == b.relative_to - && a.relative_point == b.relative_point - && a.x_off.to_bits() == b.x_off.to_bits() - && a.y_off.to_bits() == b.y_off.to_bits() -} - /// The default `relativeTo`: the parent, or [`SCREEN`] for a top-level frame (`0x76c6e0`). fn default_parent_id(model: &mut Model, h: FrameHandle) -> u32 { match model.arena.frame(h).and_then(|f| f.parent) { diff --git a/crates/benilla-ui/src/script/object/movable.rs b/crates/benilla-ui/src/script/object/movable.rs index fbfa6ff8..3eca13d8 100644 --- a/crates/benilla-ui/src/script/object/movable.rs +++ b/crates/benilla-ui/src/script/object/movable.rs @@ -421,12 +421,12 @@ pub(crate) fn advance_size(model: &mut Model, pos: (f32, f32)) { // keeps its opposite edge. The reference instead re-anchors the frame to one TOPLEFT point at // `StartSizing` (`0x768430`). if !input.anchors.is_empty() && (sz.left || sz.bottom) { - for a in &mut input.anchors { + for (x_off, y_off) in input.anchors.offsets_mut() { if sz.left { - a.x_off += dx; + *x_off += dx; } if sz.bottom { - a.y_off += dy; + *y_off += dy; } } moved |= (sz.left && dx != 0.0) || (sz.bottom && dy != 0.0); @@ -460,9 +460,9 @@ pub(crate) fn advance_move(model: &mut Model, pos: (f32, f32)) { let (dx, dy) = (dx * inv, dy * inv); if let Some(input) = model.layout_inputs.get_mut(&mv.frame) { if !input.anchors.is_empty() { - for a in &mut input.anchors { - a.x_off += dx; - a.y_off += dy; + for (x_off, y_off) in input.anchors.offsets_mut() { + *x_off += dx; + *y_off += dy; } // No target moves, so only this node is touched; a zero delta already returned above. model.touch_layout_frame(mv.frame); diff --git a/crates/benilla-ui/src/script/region.rs b/crates/benilla-ui/src/script/region.rs index 2b3d03c5..b9303dee 100644 --- a/crates/benilla-ui/src/script/region.rs +++ b/crates/benilla-ui/src/script/region.rs @@ -4,13 +4,13 @@ use mlua::{Lua, MultiValue, Table, Value}; use super::object::anchor_args::{parse_set_point, resolve_rel_target, UNNAMED}; -use super::object::{anchor_bits_eq, decode_id, id_to_lud, NamedTarget}; +use super::object::{decode_id, id_to_lud, NamedTarget}; use super::region_map::{set_shared, Side}; use super::{ Model, REG_FONTSTRING_META, REG_FONTSTRING_METHODS, REG_REGION_META, REG_REGION_METHODS, REG_TEXTURE_META, REG_TEXTURE_METHODS, REG_TITLE_META, REG_TITLE_METHODS, REG_WRAPPERS, SCREEN, }; -use crate::layout::Anchor; +use crate::layout::{Anchor, Anchors}; use crate::widget::{RegionHandle, RegionKind}; /// Resolve `self` (a region wrapper) to its live [`RegionHandle`]. @@ -699,14 +699,14 @@ pub(crate) fn implicit_creation_anchor(model: &mut Model, rh: RegionHandle) { use crate::layout::Point; match kind { RegionKind::Texture => { - data.anchors = vec![ + data.anchors = Anchors::from([ Anchor::new(Point::TopLeft, owner_id, Point::TopLeft, 0.0, 0.0), Anchor::new(Point::BottomRight, owner_id, Point::BottomRight, 0.0, 0.0), - ]; + ]); } RegionKind::FontString => { let point = justify_anchor_point(data.justify.0); - data.anchors = vec![Anchor::new(point, owner_id, point, 0.0, 0.0)]; + data.anchors = Anchors::from([Anchor::new(point, owner_id, point, 0.0, 0.0)]); } RegionKind::Title => return, } @@ -738,7 +738,7 @@ pub(crate) fn anchor_unanchored_at( if !data.anchors.is_empty() { return; } - data.anchors = vec![Anchor::new(point, owner_id, point, 0.0, 0.0)]; + data.anchors = Anchors::from([Anchor::new(point, owner_id, point, 0.0, 0.0)]); model.touch_layout_region(rh); } @@ -791,17 +791,13 @@ pub(super) fn region_set_point(lua: &Lua, this: &Table, args: &MultiValue) -> ml let rel_to_id = resolve_rel_target(&model, &p.target, &who, "SetPoint", me, owner)?; let (point, rel_point, x, y) = (p.point, p.rel_point, p.x, p.y); + // The frame's store and its unchanged-anchor test (`0x767c70`). + let units = model.layout_units(); let data = model.region_data.entry(rh).or_default(); - let new = Anchor::new(point, rel_to_id, rel_point, x, y); - // A no-op only when the bit-identical anchor is already the tail and no earlier entry has - // this point, as for frames. - let same_at_tail = data.anchors.last().is_some_and(|a| anchor_bits_eq(a, &new)) - && !data.anchors[..data.anchors.len() - 1] - .iter() - .any(|a| a.point == point); - if !same_at_tail { - data.anchors.retain(|a| a.point != point); - data.anchors.push(new); + if data + .anchors + .set(Anchor::new(point, rel_to_id, rel_point, x, y), units) + { model.touch_layout_region(rh); } Ok(()) diff --git a/crates/benilla-ui/src/script/region/layout.rs b/crates/benilla-ui/src/script/region/layout.rs index 4f1dc2a2..afbb3d6b 100644 --- a/crates/benilla-ui/src/script/region/layout.rs +++ b/crates/benilla-ui/src/script/region/layout.rs @@ -2,9 +2,11 @@ use mlua::{Lua, MultiValue, Table, Value}; -use crate::layout::{Anchor, Point}; -use crate::script::object::anchor_args::{parse_set_all_points, resolve_rel_target}; -use crate::script::object::{anchor_bits_eq, frame_wrapper, point_name}; +use crate::layout::{Anchor, Anchors, Point}; +use crate::script::object::anchor_args::{ + get_point_index, get_point_values, parse_set_all_points, resolve_rel_target, +}; +use crate::script::object::frame_wrapper; use crate::script::region_map::{set_shared, Side}; use crate::script::{Model, SCREEN}; @@ -16,11 +18,8 @@ use super::{ /// Install the layout methods into `m`. /// `ClearAllPoints` on a region (`0x767ed0`): the region leaves the cached graph with its edges. pub(in crate::script) fn clear_points(model: &mut Model, rh: crate::widget::RegionHandle) { - let d = model.region_data.entry(rh).or_default(); // Mutates only on a real change; an unanchored region leaves the graph. - let cleared = !d.anchors.is_empty(); - d.anchors.clear(); - if cleared { + if model.region_data.entry(rh).or_default().anchors.clear() { model.touch_layout_region(rh); } } @@ -145,15 +144,10 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { Anchor::new(Point::BottomRight, rel_id, Point::BottomRight, 0.0, 0.0), ]; let data = model.region_data.entry(rh).or_default(); - let same = data.anchors.len() == 2 - && data - .anchors - .iter() - .zip(&pair) - .all(|(a, b)| anchor_bits_eq(a, b)); - if !same { - data.anchors.clear(); - data.anchors.extend_from_slice(&pair); + // The reference clears and sets both (`0x767db0`); only a bit-identical pair skips. + let pair = Anchors::from(pair); + if !data.anchors.bits_eq(&pair) { + data.anchors = pair; model.touch_layout_region(rh); } Ok(()) @@ -198,48 +192,38 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { .map_or(0, |d| d.anchors.len() as i64)) })?; - // GetPoint([n]): the n-th anchor (1-based, default 1) as point, relativeTo, relativePoint, x - // and y, or five nils. Frames and regions share one id space and a region may anchor to a - // sibling region, so relativeTo is answered with the matching wrapper kind. + // GetPoint([n]): the frame's binding (`0x7a2340`), the n-th anchor in point order. Frames and + // regions share one id space and a region may anchor to a sibling region, so relativeTo is + // answered with the matching wrapper kind. set_shared( lua, m, Side::Region, "GetPoint", - |lua, (this, n): (Table, Option)| { + |lua, (this, n): (Table, Value)| { let rh = region_handle_of(lua, &this)?; + let idx = get_point_index(lua, n)?; let anchor = { let model = lua.app_data_ref::().expect("model"); - let idx = (n.unwrap_or(1).max(1) - 1) as usize; model .region_data .get(&rh) - .and_then(|d| d.anchors.get(idx)) - .cloned() + .and_then(|d| d.anchors.nth(idx)) + .copied() }; - let Some(a) = anchor else { - return Ok((Value::Nil, Value::Nil, Value::Nil, Value::Nil, Value::Nil)); - }; - let rel = { + get_point_values(lua, anchor, |rel| { let is_frame = { let model = lua.app_data_ref::().expect("model"); - model.id_to_frame.contains_key(&a.relative_to) + model.id_to_frame.contains_key(&rel) }; - if a.relative_to == SCREEN { + Ok(if rel == SCREEN { Value::Nil } else if is_frame { - Value::Table(frame_wrapper(lua, a.relative_to)?) + Value::Table(frame_wrapper(lua, rel)?) } else { - Value::Table(super::region_wrapper(lua, a.relative_to)?) - } - }; - Ok(( - Value::String(lua.create_string(point_name(a.point))?), - rel, - Value::String(lua.create_string(point_name(a.relative_point))?), - Value::Number(f64::from(a.x_off)), - Value::Number(f64::from(a.y_off)), - )) + Value::Table(super::region_wrapper(lua, rel)?) + }) + }) }, )?; Ok(()) diff --git a/crates/benilla-ui/src/script/simplehtml/mod.rs b/crates/benilla-ui/src/script/simplehtml/mod.rs index 81830568..ad64a4e6 100644 --- a/crates/benilla-ui/src/script/simplehtml/mod.rs +++ b/crates/benilla-ui/src/script/simplehtml/mod.rs @@ -26,7 +26,7 @@ use mlua::{FromLua, Lua, MultiValue, Table, Value}; use super::object::frame_handle_of; use super::{FontObject, FontShadow, Model, Outline, RegionData}; use crate::justify::{self, Justify}; -use crate::layout::{Anchor, Point}; +use crate::layout::{Anchor, Anchors, Point}; use crate::order::DrawLayer; use crate::widget::{FrameHandle, FrameKind, RegionHandle, RegionKind}; @@ -234,7 +234,12 @@ fn build(model: &mut Model, fh: FrameHandle, blocks: &[Block]) { }; let id = model.region_id(rh); let mut d = RegionData { - anchors: vec![anchor_for(prev_block, frame_id, Point::TopLeft, next_y)], + anchors: Anchors::from([anchor_for( + prev_block, + frame_id, + Point::TopLeft, + next_y, + )]), // No height: the rect takes the wrapped text's height from the measure round // trip, as the client's font engine gives it (`0x7729b0`, `0x5c2070`). size: Some((width, 0.0)), @@ -286,7 +291,7 @@ fn build(model: &mut Model, fh: FrameHandle, blocks: &[Block]) { _ => None, }; let anchors = point - .map(|p| vec![anchor_for(prev_block, frame_id, p, next_y)]) + .map(|p| Anchors::from([anchor_for(prev_block, frame_id, p, next_y)])) .unwrap_or_default(); model.region_data.insert( rh, diff --git a/crates/benilla-ui/src/script/tests/anchor_slots.rs b/crates/benilla-ui/src/script/tests/anchor_slots.rs new file mode 100644 index 00000000..129c5e09 --- /dev/null +++ b/crates/benilla-ui/src/script/tests/anchor_slots.rs @@ -0,0 +1,213 @@ +//! One anchor slot per point, as the reference keeps them (`anchorPoints[9]`, frame `+0x28`): +//! `SetPoint`'s unchanged-anchor test (`0x767c70`) and `GetPoint`'s walk in point order +//! (`0x7a2340`). + +use super::common::script; +use crate::script::{Model, UiScript}; + +/// The mutation epoch, bumped by every write into the layout read set. +fn epoch(s: &UiScript) -> u64 { + s.lua() + .app_data_ref::() + .expect("model app_data") + .layout_epoch +} + +/// A frame and a texture on it, each with two anchors set out of point order. +fn setup(s: &mut UiScript) { + s.set_screen_size(800.0, 600.0); + s.run( + r#" + f = CreateFrame("Frame", "Slots") + f:SetPoint("BOTTOMRIGHT", nil, "BOTTOMRIGHT", -20, 30) + f:SetPoint("TOPLEFT", nil, "TOPLEFT", 10, -10) + t = f:CreateTexture(nil, "ARTWORK") + t:ClearAllPoints() + t:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -4, 4) + t:SetPoint("TOPLEFT", f, "TOPLEFT", 4, -4) + "#, + ) + .expect("setup"); + s.resolve(); +} + +/// Re-applying an anchor that is not the last one set matches the one held for its point, so the +/// call returns before the store and the queue (`0x767ca9`..`0x767cde` β†’ `0x767d5e`). +#[test] +fn re_setting_an_earlier_anchor_touches_no_layout() { + let mut s = script(); + setup(&mut s); + for (who, call) in [ + ( + "frame", + r#"f:SetPoint("BOTTOMRIGHT", nil, "BOTTOMRIGHT", -20, 30)"#, + ), + ( + "region", + r#"t:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -4, 4)"#, + ), + ] { + let before = epoch(&s); + s.run(call).expect("re-set"); + assert_eq!(epoch(&s), before, "{who}: an unchanged anchor is a no-op"); + } + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// Both offsets compare within 2⁻²² of the reference's layout unit (`[0x8029d4]`), so float noise +/// in a computed offset changes nothing and is not stored; a step past it is a change. At 800Γ—600 +/// the unit is 1280 FrameXML units, so the threshold is about 3.05e-4. +#[test] +fn an_offset_within_the_threshold_is_unchanged() { + let mut s = script(); + setup(&mut s); + for (who, noisy, getter) in [ + ( + "frame", + r#"f:SetPoint("TOPLEFT", nil, "TOPLEFT", 10.0001, -10.0001)"#, + "local _, _, _, x = f:GetPoint(1); return x", + ), + ( + "region", + r#"t:SetPoint("TOPLEFT", f, "TOPLEFT", 4.0001, -4.0001)"#, + "local _, _, _, x = t:GetPoint(1); return x", + ), + ] { + let before = epoch(&s); + let x0: f64 = s.eval(getter).unwrap(); + s.run(noisy).expect("noisy re-set"); + assert_eq!( + epoch(&s), + before, + "{who}: noise under the threshold is a no-op" + ); + let x1: f64 = s.eval(getter).unwrap(); + assert_eq!( + x1, x0, + "{who}: and the held offset is kept, not overwritten" + ); + } + let before = epoch(&s); + s.run(r#"f:SetPoint("TOPLEFT", nil, "TOPLEFT", 10.001, -10)"#) + .unwrap(); + assert_ne!(epoch(&s), before, "a step past the threshold is a change"); + let x: f64 = s + .eval("local _, _, _, x = f:GetPoint(1); return x") + .unwrap(); + assert_eq!(x as f32, 10.001_f32); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// `GetPoint(n)` answers the n-th occupied slot by point id, TOPLEFT 0 through BOTTOMRIGHT 8 (the +/// ids of the name table `0x811a38`, whose entries run alphabetically), whatever order the anchors +/// were set in (`0x7a23f5`..`0x7a240b`). +#[test] +fn get_point_counts_in_point_order() { + let mut s = script(); + setup(&mut s); + for who in ["f", "t"] { + let (p1, p2): (String, String) = s + .eval(&format!("return ({who}:GetPoint(1)), ({who}:GetPoint(2))")) + .unwrap(); + assert_eq!( + (p1.as_str(), p2.as_str()), + ("TOPLEFT", "BOTTOMRIGHT"), + "{who}" + ); + let p: String = s.eval(&format!("return ({who}:GetPoint())")).unwrap(); + assert_eq!(p, "TOPLEFT", "{who}: the default is the first"); + } + // SetAllPoints, then a TOPLEFT re-set: the reference still answers TOPLEFT first. + let (p1, p2): (String, String) = s + .eval( + r#" + local g = CreateFrame("Frame") + g:SetAllPoints(f) + g:SetPoint("TOPLEFT", f, "TOPLEFT", 1, -1) + return (g:GetPoint(1)), (g:GetPoint(2)) + "#, + ) + .unwrap(); + assert_eq!((p1.as_str(), p2.as_str()), ("TOPLEFT", "BOTTOMRIGHT")); +} + +/// `n` is `lua_isnumber`-tested and truncated (`0x6f34d0`, `0x40a2b0`); an index at or below the +/// first, or a non-number, answers the first anchor. Past the last, no value is pushed +/// (`0x7a240d`). +#[test] +fn get_point_index_and_past_the_last() { + let mut s = script(); + setup(&mut s); + for who in ["f", "t"] { + let n: i64 = s + .eval(&format!( + "local function n(...) return arg.n end; return n({who}:GetPoint(3))" + )) + .unwrap(); + assert_eq!(n, 0, "{who}: past the last anchor answers nothing"); + for arg in ["0", "-4", "{}", "'x'", "1.9"] { + let p: String = s.eval(&format!("return ({who}:GetPoint({arg}))")).unwrap(); + assert_eq!(p, "TOPLEFT", "{who}:GetPoint({arg})"); + } + for arg in ["2", "'2'", "2.5"] { + let p: String = s.eval(&format!("return ({who}:GetPoint({arg}))")).unwrap(); + assert_eq!(p, "BOTTOMRIGHT", "{who}:GetPoint({arg})"); + } + } + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// `GetNumPoints` counts the occupied slots (`0x7a2311`..`0x7a231b`): a re-set point keeps one. +#[test] +fn get_num_points_counts_occupied_slots() { + let mut s = script(); + setup(&mut s); + s.run( + r#" + f:SetPoint("TOPLEFT", nil, "TOPLEFT", 50, -50) + t:SetPoint("TOPLEFT", f, "TOPLEFT", 8, -8) + "#, + ) + .unwrap(); + let (nf, nt): (i64, i64) = s.eval("return f:GetNumPoints(), t:GetNumPoints()").unwrap(); + assert_eq!((nf, nt), (2, 2)); + let n: i64 = s + .eval("f:ClearAllPoints(); return f:GetNumPoints()") + .unwrap(); + assert_eq!(n, 0); +} + +/// The compare runs on the stored values, each offset quantized to `f32` layout units as the +/// binding stores it (`0x7a28a3`..`0x7a28e3`), not on the raw difference. At 1280Γ—1024 (aspect +/// 1.25, about 1229.4 FrameXML units per layout unit) three `f32` ulps above 650 move the stored +/// value by 2⁻²² or more, though the raw step (1.83e-4) is under the threshold's 2.93e-4. +#[test] +fn the_threshold_compares_the_quantized_stored_offsets() { + let mut s = script(); + s.set_screen_size(1280.0, 1024.0); + s.run(r#"f = CreateFrame("Frame"); f:SetPoint("TOPLEFT", nil, "TOPLEFT", 650, 0)"#) + .unwrap(); + s.resolve(); + let before = epoch(&s); + s.run(r#"f:SetPoint("TOPLEFT", nil, "TOPLEFT", 650.0001831054688, 0)"#) + .unwrap(); + assert_ne!( + epoch(&s), + before, + "the stored offsets differ by 2^-22 or more" + ); + let x: f64 = s + .eval("local _, _, _, x = f:GetPoint(1); return x") + .unwrap(); + assert_eq!(x, 650.0001831054688); + + // And the other way: five ulps above 700 (a raw 3.05e-4, past the raw threshold) store + // within 2^-22 of 700's value, so the call is a no-op. + s.run(r#"f:SetPoint("TOPLEFT", nil, "TOPLEFT", 700, 0)"#) + .unwrap(); + s.resolve(); + let before = epoch(&s); + s.run(r#"f:SetPoint("TOPLEFT", nil, "TOPLEFT", 700.0003051757812, 0)"#) + .unwrap(); + assert_eq!(epoch(&s), before, "the stored offsets are within 2^-22"); +} diff --git a/crates/benilla-ui/src/script/tests/layout_structure.rs b/crates/benilla-ui/src/script/tests/layout_structure.rs index d8834a0b..e231dbc8 100644 --- a/crates/benilla-ui/src/script/tests/layout_structure.rs +++ b/crates/benilla-ui/src/script/tests/layout_structure.rs @@ -435,13 +435,13 @@ fn an_owner_minted_after_its_region_joined_takes_the_owner_edge() { .expect("region"); let d = m.region_data.entry(rh).or_default(); d.size = Some((8.0, 8.0)); - d.anchors = vec![crate::layout::Anchor::new( + d.anchors = crate::layout::Anchors::from([crate::layout::Anchor::new( crate::layout::Point::TopLeft, root_id, crate::layout::Point::TopLeft, 5.0, -5.0, - )]; + )]); m.touch_layout_region(rh); (host, rh) }; diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs index ad0fcb60..03c00724 100644 --- a/crates/benilla-ui/src/script/tests/mod.rs +++ b/crates/benilla-ui/src/script/tests/mod.rs @@ -4,6 +4,7 @@ mod addon_argument_abi; mod addon_index_space; mod adopt; +mod anchor_slots; mod anchors; mod backdrop; mod button; diff --git a/crates/benilla-ui/src/script/tests/reference_surface.rs b/crates/benilla-ui/src/script/tests/reference_surface.rs index 0ce487f0..f1cbb5ad 100644 --- a/crates/benilla-ui/src/script/tests/reference_surface.rs +++ b/crates/benilla-ui/src/script/tests/reference_surface.rs @@ -1118,12 +1118,8 @@ fn the_region_map_readers_answer_the_way_the_edges_do() { ); assert_eq!( s.arity("RRPlate:GetPoint(7)").unwrap(), - 5, - "an out-of-range index still answers five values, all nil" - ); - assert!( - s.eval::("return RRPlate:GetPoint(7) == nil").unwrap(), - "…and the first of them is nil" + 0, + "an out-of-range index pushes no value (`0x7a240d`)" ); } diff --git a/crates/benilla-ui/src/script/tests/tooltip.rs b/crates/benilla-ui/src/script/tests/tooltip.rs index faa71207..ec37cdf0 100644 --- a/crates/benilla-ui/src/script/tests/tooltip.rs +++ b/crates/benilla-ui/src/script/tests/tooltip.rs @@ -80,6 +80,77 @@ fn line_stack_autosize_and_right_flush() { assert_eq!(l2.top, 536.0); } +/// The layout re-points each shown right cell by its RIGHT slot alone, `SetPoint(RIGHT, leftCell, +/// LEFT, width, 0)` (`0x53094f`): a point an addon added beside it keeps its offset, and after a +/// `ClearAllPoints` the RIGHT point comes back. +#[test] +fn the_right_column_write_sets_the_right_slot_only() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + local owner = CreateFrame("Button", "SlotR") + owner:SetPoint("TOPLEFT", 100, -100); owner:SetWidth(40); owner:SetHeight(40) + local tt = CreateFrame("GameTooltip", "TTR") + tt:SetOwner(owner, "ANCHOR_RIGHT") + tt:AddLine("Tough Jerky", 1, 1, 1) + tt:AddDoubleLine("One-Hand", "Sword", 1, 1, 1, 1, 1, 1) + tt:Show() + "#, + ) + .unwrap(); + s.resolve(); + measure_all( + &mut s, + &[ + ("Tough Jerky", 80.0, 14.0), + ("One-Hand", 50.0, 12.0), + ("Sword", 30.0, 12.0), + ], + ); + // maxw = max(80, 50 + 40 + 30) = 120. + let right_anchor = r#" + local p, rel, rp, x, y = TTRTextRight2:GetPoint(N) + return p, rel == TTRTextLeft2, rp, x, y + "#; + + // An addon adds LEFT without clearing; LEFT precedes RIGHT in slot order. + s.run(r#"TTRTextRight2:SetPoint("LEFT", TTRTextLeft2, "RIGHT", 7, 0)"#) + .unwrap(); + s.resolve(); + s.resolve(); + let (p, x): (String, f64) = s + .eval("local p, _, _, x = TTRTextRight2:GetPoint(1); return p, x") + .unwrap(); + assert_eq!( + (p.as_str(), x), + ("LEFT", 7.0), + "the addon's point keeps its offset" + ); + let (p, rel, rp, x, y): (String, bool, String, f64, f64) = + s.eval(&right_anchor.replace('N', "2")).unwrap(); + assert_eq!( + (p.as_str(), rel, rp.as_str(), x, y), + ("RIGHT", true, "LEFT", 120.0, 0.0) + ); + + s.run("TTRTextRight2:ClearAllPoints()").unwrap(); + s.resolve(); + s.resolve(); + assert_eq!( + s.eval::("return TTRTextRight2:GetNumPoints()") + .unwrap(), + 1, + "the layout sets RIGHT again after a ClearAllPoints" + ); + let (p, rel, rp, x, y): (String, bool, String, f64, f64) = + s.eval(&right_anchor.replace('N', "1")).unwrap(); + assert_eq!( + (p.as_str(), rel, rp.as_str(), x, y), + ("RIGHT", true, "LEFT", 120.0, 0.0) + ); +} + /// An empty line is a one-unit row plus its 2 px gap, as the reference's size getters floor a /// FontString at one FrameXML unit; the row and the plate read that floor from one constant. #[test] @@ -804,6 +875,39 @@ fn anchor_cursor_follows_the_cursor_every_frame() { ); } +/// The cursor update (`0x530b20`) makes one `SetPoint` for BOTTOM (`0x767c70`) and clears +/// nothing, so a point an addon set after `SetOwner` stays held beside it. +#[test] +fn anchor_cursor_keeps_a_point_set_after_set_owner() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + KeepTip = CreateFrame("GameTooltip", "KeepTip") + KeepHost = CreateFrame("Frame", "KeepHost") + KeepTip:SetOwner(KeepHost, "ANCHOR_CURSOR") + KeepTip:AddLine("Copper Ore") + KeepTip:SetWidth(120) KeepTip:SetHeight(40) + KeepTip:Show() + KeepTip:SetPoint("BOTTOMRIGHT", KeepHost, "BOTTOMRIGHT", 0, 0) + "#, + ) + .unwrap(); + for (x, y) in [(300.0, 200.0), (120.0, 480.0)] { + s.mouse_move(x, y); + s.resolve(); + assert_eq!( + s.eval::("return KeepTip:GetNumPoints()").unwrap(), + 2, + "BOTTOM and the addon's BOTTOMRIGHT" + ); + let (p1, p2): (String, String) = s + .eval("return (KeepTip:GetPoint(1)), (KeepTip:GetPoint(2))") + .unwrap(); + assert_eq!((p1.as_str(), p2.as_str()), ("BOTTOM", "BOTTOMRIGHT")); + } +} + /// `GetAnchorType` (`0x5313e0`, table `0x854198`) answers one string, `[+0x318]` through the name /// table `0x531530`; `_Nameplates.lua:479` compares it with what it passed to `SetOwner`. #[test] diff --git a/crates/benilla-ui/src/script/tooltip/mod.rs b/crates/benilla-ui/src/script/tooltip/mod.rs index e96a7cca..01e2da07 100644 --- a/crates/benilla-ui/src/script/tooltip/mod.rs +++ b/crates/benilla-ui/src/script/tooltip/mod.rs @@ -17,7 +17,7 @@ use super::object::{frame_handle_of, publish_global}; use super::region::region_wrapper; use super::visibility; use super::{FontObject, Model, RegionData}; -use crate::layout::{Anchor, Point}; +use crate::layout::{Anchor, Anchors, Point}; use crate::order::DrawLayer; use crate::widget::{ FrameHandle, KindState, RegionKind, TooltipAnchor, TooltipState, TOOLTIP_DOUBLE_GAP, @@ -168,7 +168,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> { d.hidden = true; // Left-justified as in the template; it shows once a wrap line pins its width. d.justify.set_h(super::JustifyH::Left); - d.anchors = vec![match prev_left { + d.anchors = Anchors::from([match prev_left { None => Anchor::new( Point::TopLeft, frame_id, @@ -186,7 +186,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> { -TOOLTIP_LINE_GAP, ) } - }]; + }]); model.region_data.insert(left, d); model.touch_measure(left); // an adopted cell can arrive text-in-hand model.touch_layout_region(left); // a line row entered the layout graph @@ -214,7 +214,10 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> { }; d.hidden = true; d.justify.set_h(super::JustifyH::Right); - d.anchors = vec![Anchor::new(Point::Right, left_id, Point::Right, 0.0, 0.0)]; + // RIGHT on the left cell's LEFT, the point the layout re-sets with the plate + // width as x (`0x53094f`), as the template's `$parentTextRight1` declares it. + d.anchors = + Anchors::from([Anchor::new(Point::Right, left_id, Point::Left, 0.0, 0.0)]); model.region_data.insert(right, d); model.touch_measure(right); // an adopted cell can arrive text-in-hand model.touch_layout_region(right); // a line row entered the layout graph @@ -452,26 +455,21 @@ fn apply_anchor( TooltipAnchor::Cursor | TooltipAnchor::None | TooltipAnchor::Preserve => None, }; if matches!(anchor, TooltipAnchor::Cursor | TooltipAnchor::None) { - let dropped = match model.layout_inputs.get_mut(&h) { - Some(input) if !input.anchors.is_empty() => { - input.anchors.clear(); - true - } - _ => false, - }; + let dropped = model + .layout_inputs + .get_mut(&h) + .is_some_and(|input| input.anchors.clear()); if dropped { model.touch_layout_frame(h); } } if let Some((own, rel)) = pts { let owner_id = model.frame_id(owner); - let new = Anchor::new(own, owner_id, rel, x, y); + let new = Anchors::from([Anchor::new(own, owner_id, rel, x, y)]); let input = model.layout_inputs.entry(h).or_default(); // No-op when unchanged, so the bag hover's per-frame SetOwner does not dirty the layout. - let same = input.anchors.len() == 1 - && crate::script::object::anchor_bits_eq(&input.anchors[0], &new); - if !same { - input.anchors = vec![new]; + if !input.anchors.bits_eq(&new) { + input.anchors = new; model.touch_layout_frame(h); } } @@ -710,15 +708,12 @@ fn cursor_anchor(model: &mut Model, h: FrameHandle) { cx / scale, cy / scale, ); + let units = model.layout_units(); let input = model.layout_inputs.entry(h).or_default(); - // `0x767c70`'s change detect: the one slot is replaced only on a real change. - let same = - input.anchors.len() == 1 && crate::script::object::anchor_bits_eq(&input.anchors[0], &new); - if same { - return; + // `0x767c70`'s change detect: the BOTTOM slot is replaced only on a real change. + if input.anchors.set(new, units) { + model.touch_layout_frame(h); } - input.anchors = vec![new]; - model.touch_layout_frame(h); } pub(super) fn layout_tooltips(model: &mut Model) { @@ -787,20 +782,22 @@ pub(super) fn layout_tooltips(model: &mut Model) { // Inside the resolve, so a note and not a touch; the incremental pass still has to hear // that these inputs changed. model.note_layout_frame_write(h); - // Right-flush each double line: left.right + (maxw - left.width) is the text inset. - for (i, (l, r)) in rows.iter().enumerate() { + // Right-flush each shown right cell: one `SetPoint(RIGHT, leftCell, LEFT, width, 0)` + // (`0x530910`..`0x53094f`), which leaves any other point the cell holds alone. + let units = model.layout_units(); + for (i, (_, r)) in rows.iter().enumerate() { if r.is_none() { continue; } - let lw = l.map_or(0.0, |(w, _)| w); - let mut wrote = false; - if let Some(d) = model.region_data.get_mut(&rights[i]) { - if let Some(a) = d.anchors.first_mut() { - a.x_off = maxw - lw; - wrote = true; - } - } - if wrote { + let Some(&left_id) = model.region_to_id.get(&lefts[i]) else { + continue; + }; + let new = Anchor::new(Point::Right, left_id, Point::Left, maxw, 0.0); + let stored = model + .region_data + .get_mut(&rights[i]) + .is_some_and(|d| d.anchors.set(new, units)); + if stored { model.note_layout_region_write(rights[i]); } } diff --git a/crates/benilla-ui/src/script/tooltip/verbs.rs b/crates/benilla-ui/src/script/tooltip/verbs.rs index 59fccabd..d8a47b70 100644 --- a/crates/benilla-ui/src/script/tooltip/verbs.rs +++ b/crates/benilla-ui/src/script/tooltip/verbs.rs @@ -2,7 +2,7 @@ use mlua::{Lua, Table, Value, Variadic}; -use crate::layout::{Anchor, Point}; +use crate::layout::{Anchor, Anchors, Point}; use crate::script::binding_abi::optional_string; use crate::script::object::frame_handle_of; use crate::script::region::region_handle_of; @@ -233,13 +233,14 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { let d = model.region_data.entry(lh).or_default(); d.hidden = true; d.justify.set_h(crate::script::JustifyH::Left); - d.anchors = vec![anchor]; + d.anchors = Anchors::from([anchor]); } { let d = model.region_data.entry(rh).or_default(); d.hidden = true; d.justify.set_h(crate::script::JustifyH::Right); - d.anchors = vec![Anchor::new(Point::Right, left_id, Point::Right, 0.0, 0.0)]; + d.anchors = + Anchors::from([Anchor::new(Point::Right, left_id, Point::Right, 0.0, 0.0)]); } model.touch_layout_region(lh); // two line rows entered the layout graph model.touch_layout_region(rh); diff --git a/crates/benilla-ui/src/script/types.rs b/crates/benilla-ui/src/script/types.rs index 041f0621..c4972f30 100644 --- a/crates/benilla-ui/src/script/types.rs +++ b/crates/benilla-ui/src/script/types.rs @@ -1,6 +1,6 @@ use mlua::{Lua, Value}; -use crate::layout::{Anchor, Rect}; +use crate::layout::{Anchors, Rect}; use crate::order::ZTarget; /// A value the host passes into Lua, such as a `fire_event` argument, without an mlua handle. @@ -370,7 +370,7 @@ pub(crate) struct RegionData { /// extent, never the owner's rect; one still unset leaves the region unresolved (`0x767a20`). /// Empty is a Lua region nobody anchored, which never draws: every other has an authored or /// creation-time anchor ([`super::region::implicit_creation_anchor`]). - pub(crate) anchors: Vec, + pub(crate) anchors: Anchors, /// `SetDesaturated` (`0x79c1e0`): the renderer greys the texel, so the binding reports support. pub(crate) desaturated: bool, /// `SetNonSpaceWrap`/`CanNonSpaceWrap` (`0x79e9f0`/`0x79ead0`), `None` on. Stored, not applied: