One anchor slot per point; SetPoint skips an unchanged anchor (#934)

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 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-12 03:19:11 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent af281a05f7
commit 377542daeb
23 changed files with 752 additions and 229 deletions
+236 -15
View File
@@ -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<Anchor>; 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<Anchor> {
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<std::slice::Iter<'_, Option<Anchor>>> {
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<Item = (&mut f32, &mut f32)> {
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<Anchor> for Anchors {
fn from_iter<I: IntoIterator<Item = Anchor>>(iter: I) -> Anchors {
let mut out = Anchors::new();
for a in iter {
out.insert(a);
}
out
}
}
impl<const N: usize> 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<std::slice::Iter<'a, Option<Anchor>>>;
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<Anchor>,
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<Anchor>, width: f32, height: f32) -> LayoutInput {
pub fn sized(
anchors: impl IntoIterator<Item = Anchor>,
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<LayoutInput>,
is_frame: Vec<bool>,
/// 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<_>>(), 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() {
+2 -2
View File
@@ -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<Backdrop>
/// 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<Anchor>, Option<(f32, f32)>) {
fn piece_geometry(owner: u32, (anchors, size): PieceLayout) -> (Anchors, Option<(f32, f32)>) {
let anchors = anchors
.into_iter()
.flatten()
+7 -7
View File
@@ -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);
}
@@ -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);
+7 -5
View File
@@ -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<Item = &'a Anchor>) {
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<Anchor>, b: Option<Anchor>| 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;
+3 -3
View File
@@ -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<Anchor> = 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;
@@ -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 {
+15 -14
View File
@@ -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);
}
-1
View File
@@ -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};
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -195,3 +195,33 @@ pub(crate) fn resolve_xml_relative_to(lua: &Lua, name: &str) -> Option<Table> {
// 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<usize> {
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<crate::layout::Anchor>,
rel: impl FnOnce(u32) -> mlua::Result<Value>,
) -> mlua::Result<MultiValue> {
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)),
]))
}
@@ -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::<Model>().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<i64>)| {
|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::<Model>().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) {
@@ -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);
+12 -16
View File
@@ -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(())
+24 -40
View File
@@ -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<i64>)| {
|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::<Model>().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::<Model>().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(())
@@ -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,
@@ -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::<Model>()
.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");
}
@@ -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)
};
@@ -4,6 +4,7 @@
mod addon_argument_abi;
mod addon_index_space;
mod adopt;
mod anchor_slots;
mod anchors;
mod backdrop;
mod button;
@@ -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::<bool>("return RRPlate:GetPoint(7) == nil").unwrap(),
"…and the first of them is nil"
0,
"an out-of-range index pushes no value (`0x7a240d`)"
);
}
@@ -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::<i64>("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::<i64>("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]
+31 -34
View File
@@ -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]);
}
}
@@ -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);
+2 -2
View File
@@ -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<Anchor>,
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: