Layout file saves the rect; bubbles re-sort on change; resize Deviation (#981)
- **The layout file** now saves what the reference's writer does (`0x490e60`): `X`, `Y`, `W`, `H` and `FrameLevel` from the frame's held rect, rows in creation order, and only frames that are user-placed and movable or resizable. The restore (`0x4905e0`) pins TOPLEFT to the screen, sets the size and level, and resolves each frame as it goes (`0x490710`). Old anchor-form files still load and are rewritten once. - **Chat bubbles** re-rank only on a camera move or a new line (`0x4b11ff`), and a repeat speaker moves to the tail, as `0x4b1870` links it. - **The live re-layout on a window resize** gets its `Deviation:` line. 1.12.1 reloads the UI on an aspect change of 0.001 or more (`0x492f26`). Tests cover parent/child level restore, the held rect, the hidden-frame resolve, the junk lines, the migration and the bubble tie order. Fixes #954 Fixes #955 Fixes #847 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8ccaa7b411
commit
0c58d85cf9
@@ -304,6 +304,9 @@ struct Bubble {
|
||||
/// Stand-box height × model scale, latched at spawn as the reference does (`bubble+0x354`).
|
||||
lift: f32,
|
||||
fade: Fade,
|
||||
/// The squared camera distance the bubble was last stacked by (`[bubble+0x350]`); `None` for
|
||||
/// a new line, which the next pass stacks (the spawn flag, `[bubble+0x320] & 0x8`).
|
||||
depth: Option<f32>,
|
||||
}
|
||||
|
||||
/// Whether the speaker was drawn in the last render: the reference's scene-node test `0x6704c0`
|
||||
@@ -322,13 +325,67 @@ fn speaker_drawn(
|
||||
.is_ok_and(|kids| kids.iter().any(|c| meshes.get(c).is_ok_and(|v| v.get())))
|
||||
}
|
||||
|
||||
/// The live bubbles by speaker guid, at most one per unit, and the next line's number.
|
||||
/// The live bubbles by speaker guid, at most one per unit, their stacking, and the next line's
|
||||
/// number.
|
||||
#[derive(Resource, Default)]
|
||||
struct Bubbles {
|
||||
live: HashMap<u64, Bubble>,
|
||||
/// The stacking, farthest first (the list at `0xb6f038`); the index is the frame level.
|
||||
stack: Vec<u64>,
|
||||
/// The camera position the stack was last sorted from (`0xb6eff0`, held once `0xb6effc` is
|
||||
/// set).
|
||||
eye: Option<Vec3>,
|
||||
next_line: u64,
|
||||
}
|
||||
|
||||
impl Bubbles {
|
||||
/// A new line from `guid`: it replaces the speaker's old bubble, which goes back to the pool
|
||||
/// (`0x608bb4` → `0x608c00`), and the new one is linked at the stack's tail (`0x608bbf` →
|
||||
/// `0x4b1870`, `0x4b18f0`), unstacked until the next pass.
|
||||
fn spawn(&mut self, guid: u64, bubble: Bubble) {
|
||||
self.stack.retain(|g| *g != guid);
|
||||
self.stack.push(guid);
|
||||
self.live.insert(guid, bubble);
|
||||
}
|
||||
|
||||
/// The stacking step of the bubble pass (`0x4b1060`). The camera moved when any coordinate
|
||||
/// differs from the held one, compared exactly (`0x4b10a5`–`0x4b10d3`); then every bubble is
|
||||
/// re-stacked, else only a new line (`0x4b11c7`), each from the held position to its speaker
|
||||
/// (`pos`), into the stack before the first one nearer (`0x4b1360`). The rest keep their
|
||||
/// place, so a speaker walking under a still camera keeps its stacking.
|
||||
fn restack(&mut self, camera: Option<Vec3>, pos: impl Fn(u64) -> Option<Vec3>) {
|
||||
let moved = camera.is_some() && camera != self.eye;
|
||||
if moved {
|
||||
self.eye = camera;
|
||||
}
|
||||
let Self {
|
||||
live, stack, eye, ..
|
||||
} = self;
|
||||
stack.retain(|g| live.contains_key(g));
|
||||
// Pulled out in stack order, then each re-inserted (`0x4b11d7`, `0x4b1220`).
|
||||
let dirty: Vec<u64> = stack
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|g| moved || live[g].depth.is_none())
|
||||
.collect();
|
||||
stack.retain(|g| !dirty.contains(g));
|
||||
for g in dirty {
|
||||
let Some(at) = pos(g) else {
|
||||
continue;
|
||||
};
|
||||
let d = eye.map_or(0.0, |e| e.distance_squared(at));
|
||||
if let Some(b) = live.get_mut(&g) {
|
||||
b.depth = Some(d);
|
||||
}
|
||||
let slot = stack
|
||||
.iter()
|
||||
.position(|o| live[o].depth.is_some_and(|od| od < d))
|
||||
.unwrap_or(stack.len());
|
||||
stack.insert(slot, g);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This frame's live bubbles for the widget layer, farthest from the camera first, and the sizes
|
||||
/// the window gives them; `None` with no viewport to lay out against. [`crate::ui_chat::bubbles`]
|
||||
/// hands them to the VM.
|
||||
@@ -411,7 +468,7 @@ fn drive_bubbles(
|
||||
let line = bubbles.next_line;
|
||||
bubbles.next_line += 1;
|
||||
// Replace, never queue (`0x608bb4`): the old line's frame goes back to the pool.
|
||||
bubbles.live.insert(
|
||||
bubbles.spawn(
|
||||
guid,
|
||||
Bubble {
|
||||
line,
|
||||
@@ -425,6 +482,7 @@ fn drive_bubbles(
|
||||
born: now,
|
||||
lift,
|
||||
fade: Fade::spawn(now),
|
||||
depth: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -444,23 +502,16 @@ fn drive_bubbles(
|
||||
|
||||
let cam = camera.single().ok();
|
||||
let window = window.single().ok();
|
||||
// The order is the frame levels (`0x4b1360`, stamped `0x4b12d5`-`0x4b1312`): the camera's
|
||||
// distance to the speaker, farthest first. The reference re-sorts only when the camera moved
|
||||
// or a line spawned (`0x4b10a5`-`0x4b10d3`, `0x4b11c7`); this sorts every frame, so a speaker
|
||||
// moving under a still camera can re-stack here and not there.
|
||||
let mut order: Vec<(u64, f32)> = bubbles
|
||||
.live
|
||||
.keys()
|
||||
.filter_map(|guid| {
|
||||
let (entity, _, tf) = find(*guid)?;
|
||||
// The order is the frame levels (`0x4b1360`, stamped `0x4b12d5`-`0x4b1312`), farthest from
|
||||
// the camera first.
|
||||
bubbles.restack(cam.map(|(_, pose)| pose.translation), |guid| {
|
||||
find(guid).map(|(_, _, tf)| tf.translation)
|
||||
});
|
||||
for guid in &bubbles.stack {
|
||||
if let Some((entity, _, _)) = find(*guid) {
|
||||
active.0.insert(entity);
|
||||
let d2 = cam.map_or(0.0, |(_, pose)| {
|
||||
pose.translation.distance_squared(tf.translation)
|
||||
});
|
||||
Some((*guid, d2))
|
||||
})
|
||||
.collect();
|
||||
stack_sort(&mut order);
|
||||
}
|
||||
}
|
||||
|
||||
// Sizes from the camera's viewport, else the window's; with neither there is nothing to lay
|
||||
// out against.
|
||||
@@ -481,9 +532,10 @@ fn drive_bubbles(
|
||||
text_height: text_px(TEXT_H, basis) / seam,
|
||||
device_px: seam * scale,
|
||||
});
|
||||
feed.states = order
|
||||
feed.states = bubbles
|
||||
.stack
|
||||
.iter()
|
||||
.filter_map(|(guid, _)| {
|
||||
.filter_map(|guid| {
|
||||
let b = bubbles.live.get(guid)?;
|
||||
let (entity, _, tf) = find(*guid)?;
|
||||
// The anchor (`0x4b0c30`), from this frame's `Transform` and the latched lift.
|
||||
@@ -558,12 +610,6 @@ fn trace_seat(
|
||||
);
|
||||
}
|
||||
|
||||
/// Farthest camera distance first, so the index is the frame level. Ties break on guid, as the
|
||||
/// reference keeps its order across an exact tie and a `HashMap` walk cannot.
|
||||
fn stack_sort(order: &mut [(u64, f32)]) {
|
||||
order.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
|
||||
}
|
||||
|
||||
/// The bubble stage; [`crate::nameplates`] orders after it to read [`BubblesActive`].
|
||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct BubbleSet;
|
||||
@@ -859,18 +905,82 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Farthest camera distance first, guid breaking an exact tie.
|
||||
#[test]
|
||||
fn the_stack_is_sorted_farthest_first() {
|
||||
let mut p = vec![(7, 25.0), (3, 400.0), (9, 1.0), (2, 400.0)];
|
||||
stack_sort(&mut p);
|
||||
assert_eq!(
|
||||
p.iter().map(|q| q.0).collect::<Vec<_>>(),
|
||||
vec![2, 3, 7, 9],
|
||||
"farthest first; the 400-yd² pair ordered by guid"
|
||||
/// A line spoken by `guid` into `b`, as the spawn gate leaves it.
|
||||
fn speak(b: &mut Bubbles, guid: u64) {
|
||||
let line = b.next_line;
|
||||
b.next_line += 1;
|
||||
b.spawn(
|
||||
guid,
|
||||
Bubble {
|
||||
line,
|
||||
text: String::new(),
|
||||
color: [1.0; 3],
|
||||
born: 0,
|
||||
duration: 0,
|
||||
lift: 0.0,
|
||||
fade: Fade::spawn(0),
|
||||
depth: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Farthest from the camera first, an exact tie keeping the earlier line in front of the
|
||||
/// later; the stack stands while the camera does, whoever walks, and a camera move re-sorts.
|
||||
#[test]
|
||||
fn the_stack_resorts_on_a_camera_move_or_a_new_line_only() {
|
||||
let mut at: HashMap<u64, Vec3> = HashMap::from([
|
||||
(1, Vec3::new(5.0, 0.0, 0.0)),
|
||||
(2, Vec3::new(20.0, 0.0, 0.0)),
|
||||
(3, Vec3::new(20.0, 0.0, 0.0)),
|
||||
]);
|
||||
let mut b = Bubbles::default();
|
||||
for g in [1, 2, 3] {
|
||||
speak(&mut b, g);
|
||||
}
|
||||
let mut camera = Vec3::ZERO;
|
||||
b.restack(Some(camera), |g| at.get(&g).copied());
|
||||
assert_eq!(b.stack, [2, 3, 1], "farthest first; the tie in line order");
|
||||
|
||||
// Speaker 1 walks behind the others under a still camera.
|
||||
at.insert(1, Vec3::new(40.0, 0.0, 0.0));
|
||||
b.restack(Some(camera), |g| at.get(&g).copied());
|
||||
assert_eq!(b.stack, [2, 3, 1], "a still camera keeps the stacking");
|
||||
|
||||
// A new line stacks at its own distance; the others keep their stale ones.
|
||||
speak(&mut b, 4);
|
||||
at.insert(4, Vec3::new(10.0, 0.0, 0.0));
|
||||
b.restack(Some(camera), |g| at.get(&g).copied());
|
||||
assert_eq!(b.stack, [2, 3, 4, 1]);
|
||||
|
||||
// The camera moves, by any amount: every bubble re-sorts.
|
||||
camera.y = 1e-3;
|
||||
b.restack(Some(camera), |g| at.get(&g).copied());
|
||||
assert_eq!(b.stack, [1, 2, 3, 4]);
|
||||
|
||||
// A bubble that ends leaves the stack.
|
||||
b.live.remove(&2);
|
||||
b.restack(Some(camera), |g| at.get(&g).copied());
|
||||
assert_eq!(b.stack, [1, 3, 4]);
|
||||
}
|
||||
|
||||
/// A speaker who speaks again goes to the tail before the pass re-stacks the new lines in list
|
||||
/// order (`0x4b1870`, `0x4b11d7`), so in a tie the later line lands in front.
|
||||
#[test]
|
||||
fn a_repeat_line_moves_its_speaker_to_the_tail() {
|
||||
let at = |_: u64| Some(Vec3::new(10.0, 0.0, 0.0));
|
||||
let mut b = Bubbles::default();
|
||||
for g in [1, 2] {
|
||||
speak(&mut b, g);
|
||||
}
|
||||
b.restack(Some(Vec3::ZERO), at);
|
||||
assert_eq!(b.stack, [1, 2]);
|
||||
// In one frame, 3 speaks, then 1 again, all at one distance.
|
||||
speak(&mut b, 3);
|
||||
speak(&mut b, 1);
|
||||
b.restack(Some(Vec3::ZERO), at);
|
||||
assert_eq!(b.stack, [2, 3, 1], "1's new line came after 3's");
|
||||
}
|
||||
|
||||
/// 16 px at 1024-wide 4:3 (width/64), damped past the plate knee.
|
||||
#[test]
|
||||
fn border_unit_is_a_64th_of_the_width() {
|
||||
|
||||
@@ -2,16 +2,11 @@
|
||||
//! (`frame+0xb4 & 0x1000`), restored at the next login. The engine half (snapshot, restore, dirty
|
||||
//! bit) is `benilla_ui::script`'s `layout_cache`; this is the file and when it is written.
|
||||
//!
|
||||
//! The reference writes `WTF/Account/<ACC>/<REALM>/<CHAR>/layout-cache.txt` with `Frame:`,
|
||||
//! `FrameLevel:`, `X:`, `Y:`, `W:` and `H:` lines; ours is
|
||||
//! `benilla-config/layout/<realm>-<character>.txt`, keeping `Frame:`, `W:` and `H:`.
|
||||
//! Deviation: one `Point: <point> <relativeTo> <relativePoint> <x> <y>` line per anchor, `-` for
|
||||
//! the screen root, instead of `X:`/`Y:`. A drag leaves one TOPLEFT against the screen root
|
||||
//! (`0x768430`), whose line holds the reference's X and Y unrounded, where the reference saves
|
||||
//! integers (`0x490fc4`–`0x490fdf`); a frame user-placed without a drag keeps its authored
|
||||
//! anchors here, where the reference saves its rect's top-left. There is no
|
||||
//! `FrameLevel:`: the reference saves an undocked window's raise, and benilla's raise does not
|
||||
//! persist.
|
||||
//! The reference writes `WTF/Account/<ACC>/<REALM>/<CHAR>/layout-cache.txt`; ours is
|
||||
//! `benilla-config/layout/<realm>-<character>.txt`, in its form: per frame a `Frame:` line, then
|
||||
//! `FrameLevel:`, `X:`, `Y:`, `W:` and `H:` (`0x490e60`), each `Name: value` and an integer, the
|
||||
//! records back to back. A file of benilla's earlier anchor form, one `Point:` line per anchor, is
|
||||
//! read once and rewritten in this form at the next save.
|
||||
//!
|
||||
//! The reference writes the file only in its UI shutdown tail (`0x490bd0`), at `0x490c79`,
|
||||
//! after `PLAYER_LOGOUT` (`0x490c2a`) and before the flat saved file (`0x490c7e`), on logout,
|
||||
@@ -23,22 +18,19 @@ use std::path::PathBuf;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_ui::script::{FrameLayout, LayoutPoint, UiScript};
|
||||
use benilla_ui::script::{AnchorLayout, FrameLayout, LayoutPoint, UiScript};
|
||||
|
||||
use crate::ui_script::VmMemo;
|
||||
|
||||
/// How long a moved window sits before the autosave: one gesture coalesced, at most one lost.
|
||||
const SAVE_QUIET: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
|
||||
/// The screen root in the file, where `GetPoint` answers `nil`; not `UIParent`, a real frame.
|
||||
/// The screen root as a target in the anchor form, where `GetPoint` answers `nil`.
|
||||
const SCREEN_TOKEN: &str = "-";
|
||||
|
||||
/// The file's header, a `#` block the parser skips.
|
||||
/// The file's header, a `#` line the parser skips.
|
||||
const HEADER: &str = "\
|
||||
# benilla window layout — every frame the player has moved or resized (the client's userPlaced
|
||||
# bit). A relative of the reference's layout-cache.txt: same scope, same Frame:/W:/H: keys, but
|
||||
# anchors instead of its X:/Y: pair; a dragged window has one, TOPLEFT on the screen's TOPLEFT.
|
||||
# `-` as a Point: target means the screen root.
|
||||
# benilla window layout: every frame the player has moved or resized, as layout-cache.txt has it.
|
||||
";
|
||||
|
||||
/// The character's file: its path, the VM it was restored into, and whether a write is owed.
|
||||
@@ -54,54 +46,134 @@ pub(crate) struct LayoutFile {
|
||||
last_change: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
/// Render the cache as [`parse`] reads it, frames name-sorted as the engine hands them.
|
||||
/// Render the cache as [`parse`] reads it, frames in the order the engine hands them; a field
|
||||
/// the row does not carry has no line, as the writer's flag gates leave it (`0x490f99`,
|
||||
/// `0x49109f`).
|
||||
pub(crate) fn render(frames: &[FrameLayout]) -> String {
|
||||
let mut out = String::from(HEADER);
|
||||
for f in frames {
|
||||
out.push_str(&format!("Frame: {}\n", f.name));
|
||||
out.push_str(&format!("W: {}\n", f.width));
|
||||
out.push_str(&format!("H: {}\n", f.height));
|
||||
for p in &f.points {
|
||||
out.push_str(&format!(
|
||||
"Point: {} {} {} {} {}\n",
|
||||
p.point,
|
||||
p.relative_to.as_deref().unwrap_or(SCREEN_TOKEN),
|
||||
p.relative_point,
|
||||
p.x,
|
||||
p.y
|
||||
));
|
||||
let fields = [
|
||||
("FrameLevel", f.level.map(i32::from)),
|
||||
("X", f.x),
|
||||
("Y", f.y),
|
||||
("W", f.width),
|
||||
("H", f.height),
|
||||
];
|
||||
for (key, value) in fields {
|
||||
if let Some(v) = value {
|
||||
out.push_str(&format!("{key}: {v}\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse the cache: keys match case-insensitively, and an unknown key, a malformed `Point:` or a
|
||||
/// value line before any `Frame:` costs only its own line.
|
||||
pub(crate) fn parse(text: &str) -> Vec<FrameLayout> {
|
||||
/// What a layout file holds.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(crate) enum Layout {
|
||||
/// The reference's rect form.
|
||||
Rect(Vec<FrameLayout>),
|
||||
/// benilla's earlier anchor form, which carries `Point:` lines.
|
||||
Anchors(Vec<AnchorLayout>),
|
||||
}
|
||||
|
||||
/// Parse the cache: keys match case-insensitively, and an unknown key costs only its own line. A
|
||||
/// value reads as `atoi` reads it (`0x64ac60`). The value lines before any `Frame:`, or after one
|
||||
/// with no name, belong to no frame and are dropped, as the reference applies them to a null frame
|
||||
/// (`0x4905e6`); a name no frame has drops its lines at the restore the same way.
|
||||
///
|
||||
/// Deviation: a record's fields start empty. The reference's parser clears X, Y, W and the level
|
||||
/// at each `Frame:` (`ebx`, `edi`, `esi`, `[ebp-0x4]` at `0x49045b`–`0x490469`) but not H
|
||||
/// (`[ebp-0xc]`), so a record with no `H:` takes the one before's; benilla writes `H:` whenever it
|
||||
/// writes `W:`, so only a hand-edited file reaches the slip, which would size a window from
|
||||
/// another window's row.
|
||||
pub(crate) fn parse(text: &str) -> Layout {
|
||||
if lines(text).any(|(key, _)| key.eq_ignore_ascii_case("Point")) {
|
||||
return Layout::Anchors(parse_anchor_form(text));
|
||||
}
|
||||
let mut out: Vec<FrameLayout> = Vec::new();
|
||||
for line in text.lines() {
|
||||
let mut current = false;
|
||||
for (key, rest) in lines(text) {
|
||||
if key.eq_ignore_ascii_case("Frame") {
|
||||
current = !rest.is_empty();
|
||||
if current {
|
||||
out.push(FrameLayout {
|
||||
name: rest.to_owned(),
|
||||
..FrameLayout::default()
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(frame) = out.last_mut().filter(|_| current) else {
|
||||
continue; // no frame owns the line
|
||||
};
|
||||
let v = atoi(rest);
|
||||
if key.eq_ignore_ascii_case("FrameLevel") {
|
||||
// Below zero is the parser's "none" (`0x490462`), which the apply skips (`0x4905f0`).
|
||||
frame.level = (v >= 0).then(|| u16::try_from(v).unwrap_or(u16::MAX));
|
||||
} else if key.eq_ignore_ascii_case("X") {
|
||||
frame.x = Some(v);
|
||||
} else if key.eq_ignore_ascii_case("Y") {
|
||||
frame.y = Some(v);
|
||||
} else if key.eq_ignore_ascii_case("W") {
|
||||
frame.width = Some(v);
|
||||
} else if key.eq_ignore_ascii_case("H") {
|
||||
frame.height = Some(v);
|
||||
}
|
||||
}
|
||||
Layout::Rect(out)
|
||||
}
|
||||
|
||||
/// The file's `Key: value` lines, trimmed, with blanks and `#` lines skipped.
|
||||
fn lines(text: &str) -> impl Iterator<Item = (&str, &str)> {
|
||||
text.lines().filter_map(|line| {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
return None;
|
||||
}
|
||||
let Some((key, rest)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let (key, rest) = (key.trim(), rest.trim());
|
||||
let (key, rest) = line.split_once(':')?;
|
||||
Some((key.trim(), rest.trim()))
|
||||
})
|
||||
}
|
||||
|
||||
/// C's `atoi`: an optional sign and the leading digits, zero when there are none.
|
||||
fn atoi(s: &str) -> i32 {
|
||||
let (neg, digits) = match s.as_bytes().first() {
|
||||
Some(b'-') => (true, &s[1..]),
|
||||
Some(b'+') => (false, &s[1..]),
|
||||
_ => (false, s),
|
||||
};
|
||||
let n = digits
|
||||
.bytes()
|
||||
.take_while(u8::is_ascii_digit)
|
||||
.fold(0i64, |n, d| {
|
||||
(n * 10 + i64::from(d - b'0')).min(i64::from(i32::MAX) + 1)
|
||||
});
|
||||
let n = if neg { -n } else { n };
|
||||
n.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32
|
||||
}
|
||||
|
||||
/// The anchor form: `W:`, `H:` and one `Point: <point> <relativeTo> <relativePoint> <x> <y>` line
|
||||
/// per anchor, `-` for the screen root. A malformed `Point:` costs only its own line.
|
||||
fn parse_anchor_form(text: &str) -> Vec<AnchorLayout> {
|
||||
let mut out: Vec<AnchorLayout> = Vec::new();
|
||||
let mut current = false;
|
||||
for (key, rest) in lines(text) {
|
||||
if key.eq_ignore_ascii_case("Frame") {
|
||||
if rest.is_empty() {
|
||||
continue;
|
||||
current = !rest.is_empty();
|
||||
if current {
|
||||
out.push(AnchorLayout {
|
||||
name: rest.to_owned(),
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
points: Vec::new(),
|
||||
});
|
||||
}
|
||||
out.push(FrameLayout {
|
||||
name: rest.to_owned(),
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
points: Vec::new(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let Some(frame) = out.last_mut() else {
|
||||
continue; // a value line with no `Frame:` above it owns nothing
|
||||
let Some(frame) = out.last_mut().filter(|_| current) else {
|
||||
continue;
|
||||
};
|
||||
if key.eq_ignore_ascii_case("W") {
|
||||
frame.width = rest.parse().unwrap_or(0.0);
|
||||
@@ -110,11 +182,11 @@ pub(crate) fn parse(text: &str) -> Vec<FrameLayout> {
|
||||
} else if key.eq_ignore_ascii_case("Point") {
|
||||
let f: Vec<&str> = rest.split_whitespace().collect();
|
||||
if f.len() != 5 {
|
||||
warn!("layout: malformed Point line ignored: {line}");
|
||||
warn!("layout: malformed Point line ignored: {key}: {rest}");
|
||||
continue;
|
||||
}
|
||||
let (Ok(x), Ok(y)) = (f[3].parse::<f32>(), f[4].parse::<f32>()) else {
|
||||
warn!("layout: Point line with unparsable offsets ignored: {line}");
|
||||
warn!("layout: Point line with unparsable offsets ignored: {key}: {rest}");
|
||||
continue;
|
||||
};
|
||||
frame.points.push(LayoutPoint {
|
||||
@@ -161,18 +233,30 @@ pub(crate) fn load_layout(
|
||||
return;
|
||||
}
|
||||
};
|
||||
let frames = parse(&text);
|
||||
if frames.is_empty() {
|
||||
let (count, anchor_form) = match parse(&text) {
|
||||
Layout::Rect(frames) => {
|
||||
let n = frames.len();
|
||||
script.restore_user_placed_layouts(frames);
|
||||
(n, false)
|
||||
}
|
||||
Layout::Anchors(frames) => {
|
||||
let n = frames.len();
|
||||
script.restore_anchor_layouts(frames);
|
||||
(n, true)
|
||||
}
|
||||
};
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
info!(
|
||||
"layout: {} frames restored from {}",
|
||||
frames.len(),
|
||||
path.display()
|
||||
);
|
||||
script.restore_user_placed_layouts(frames);
|
||||
info!("layout: {count} frames restored from {}", path.display());
|
||||
// Drain the dirty bit the restore set, so the first save does not rewrite what was just read.
|
||||
script.take_user_placed_change();
|
||||
if anchor_form {
|
||||
// The anchor form is read once: the autosave rewrites it in the rect form once the
|
||||
// restored windows have settled.
|
||||
*file.dirty.get(&script) = true;
|
||||
file.last_change = Some(std::time::Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the engine's user-placed-frame-moved bit into the dirty flag.
|
||||
@@ -255,13 +339,10 @@ impl Plugin for UiLayoutPlugin {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn point(p: &str, rel: Option<&str>, rp: &str, x: f32, y: f32) -> LayoutPoint {
|
||||
LayoutPoint {
|
||||
point: p.into(),
|
||||
relative_to: rel.map(str::to_owned),
|
||||
relative_point: rp.into(),
|
||||
x,
|
||||
y,
|
||||
fn rows(layout: Layout) -> Vec<FrameLayout> {
|
||||
match layout {
|
||||
Layout::Rect(rows) => rows,
|
||||
Layout::Anchors(rows) => panic!("read as the anchor form: {rows:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,53 +351,155 @@ mod tests {
|
||||
let frames = vec![
|
||||
FrameLayout {
|
||||
name: "ChatFrame1".into(),
|
||||
width: 512.0,
|
||||
height: 180.5,
|
||||
points: vec![point(
|
||||
"BOTTOMLEFT",
|
||||
Some("UIParent"),
|
||||
"BOTTOMLEFT",
|
||||
40.0,
|
||||
120.0,
|
||||
)],
|
||||
level: Some(0),
|
||||
x: Some(38),
|
||||
y: Some(-225),
|
||||
width: Some(431),
|
||||
height: Some(400),
|
||||
},
|
||||
// Movable only: no `W:`/`H:`, as the reference writes `KLHTM_Frame`.
|
||||
FrameLayout {
|
||||
name: "Floater".into(),
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
points: vec![
|
||||
point("TOPLEFT", None, "TOPLEFT", -1.5, 2.0),
|
||||
point("BOTTOMRIGHT", Some("ChatFrame1"), "TOPRIGHT", 0.0, 0.0),
|
||||
],
|
||||
level: Some(4),
|
||||
x: Some(-2),
|
||||
y: Some(0),
|
||||
width: None,
|
||||
height: None,
|
||||
},
|
||||
];
|
||||
assert_eq!(parse(&render(&frames)), frames);
|
||||
let text = render(&frames);
|
||||
assert!(
|
||||
text.ends_with(
|
||||
"Frame: ChatFrame1\nFrameLevel: 0\nX: 38\nY: -225\nW: 431\nH: 400\n\
|
||||
Frame: Floater\nFrameLevel: 4\nX: -2\nY: 0\n"
|
||||
),
|
||||
"the reference's lines, in its order:\n{text}"
|
||||
);
|
||||
assert_eq!(rows(parse(&text)), frames);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_header_is_skipped_not_parsed() {
|
||||
assert!(render(&[]).starts_with('#'));
|
||||
assert_eq!(parse(HEADER), vec![]);
|
||||
assert_eq!(parse(HEADER), Layout::Rect(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_costs_only_its_own_line() {
|
||||
let got = parse(
|
||||
let got = rows(parse(
|
||||
"W: 100\n\
|
||||
Frame: A\n\
|
||||
FrameLevel: 4\n\
|
||||
Point: TOPLEFT -\n\
|
||||
W: 200\n\
|
||||
point: bottomleft - BOTTOMLEFT 1 2\n",
|
||||
);
|
||||
Shade: 4\n\
|
||||
FrameLevel: -1\n\
|
||||
x: 12.7\n\
|
||||
Y: -3px\n\
|
||||
W: wide\n\
|
||||
H: 200\n\
|
||||
Frame:\n\
|
||||
X: 99\n\
|
||||
H: 7\n",
|
||||
));
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![FrameLayout {
|
||||
name: "A".into(),
|
||||
width: 200.0,
|
||||
height: 0.0,
|
||||
points: vec![point("bottomleft", None, "BOTTOMLEFT", 1.0, 2.0)],
|
||||
level: None,
|
||||
x: Some(12),
|
||||
y: Some(-3),
|
||||
width: Some(0),
|
||||
height: Some(200),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_point_line_marks_the_anchor_form() {
|
||||
let text = "\
|
||||
# benilla window layout — every frame the player has moved or resized (the client's userPlaced
|
||||
Frame: ChatFrame1
|
||||
W: 512
|
||||
H: 180.5
|
||||
Point: BOTTOMLEFT UIParent BOTTOMLEFT 40 120
|
||||
Frame: Floater
|
||||
W: 0
|
||||
H: 0
|
||||
Point: TOPLEFT - TOPLEFT -1.5 2
|
||||
Point: TOPLEFT -
|
||||
";
|
||||
let point = |p: &str, rel: Option<&str>, rp: &str, x: f32, y: f32| LayoutPoint {
|
||||
point: p.into(),
|
||||
relative_to: rel.map(str::to_owned),
|
||||
relative_point: rp.into(),
|
||||
x,
|
||||
y,
|
||||
};
|
||||
assert_eq!(
|
||||
parse(text),
|
||||
Layout::Anchors(vec![
|
||||
AnchorLayout {
|
||||
name: "ChatFrame1".into(),
|
||||
width: 512.0,
|
||||
height: 180.5,
|
||||
points: vec![point(
|
||||
"BOTTOMLEFT",
|
||||
Some("UIParent"),
|
||||
"BOTTOMLEFT",
|
||||
40.0,
|
||||
120.0
|
||||
)],
|
||||
},
|
||||
AnchorLayout {
|
||||
name: "Floater".into(),
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
points: vec![point("TOPLEFT", None, "TOPLEFT", -1.5, 2.0)],
|
||||
},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/// A file of the anchor form seats the windows where they were and saves back in the rect
|
||||
/// form, so a player's windows survive the change of format.
|
||||
#[test]
|
||||
fn an_anchor_form_file_migrates_to_the_rect_form() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.run(
|
||||
r#"
|
||||
local b = CreateFrame("Frame", "Base")
|
||||
b:SetPoint("BOTTOMLEFT", 0, 0) b:SetWidth(1024) b:SetHeight(768)
|
||||
local f = CreateFrame("Frame", "Moved")
|
||||
f:SetPoint("CENTER", 0, 0) f:SetWidth(100) f:SetHeight(50)
|
||||
f:SetMovable(true) f:SetResizable(true)
|
||||
local g = CreateFrame("Frame", "Glider")
|
||||
g:SetPoint("CENTER", 0, 0) g:SetWidth(30) g:SetHeight(30)
|
||||
g:SetMovable(true)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let old = "\
|
||||
Frame: Glider
|
||||
W: 30
|
||||
H: 30
|
||||
Point: TOPLEFT - TOPLEFT 10.4 -20.6
|
||||
Frame: Moved
|
||||
W: 213.6
|
||||
H: 80
|
||||
Point: BOTTOMLEFT Base BOTTOMLEFT 61 29
|
||||
";
|
||||
let Layout::Anchors(rows) = parse(old) else {
|
||||
panic!("an old file reads as the anchor form");
|
||||
};
|
||||
s.restore_anchor_layouts(rows);
|
||||
let text = render(&s.user_placed_layouts());
|
||||
assert!(
|
||||
text.contains(
|
||||
"Frame: Moved\nFrameLevel: 0\nX: 61\nY: -659\nW: 214\nH: 80\n\
|
||||
Frame: Glider\nFrameLevel: 0\nX: 10\nY: -21\n"
|
||||
),
|
||||
"the same windows, where they were, in the rect form, in creation order:\n{text}"
|
||||
);
|
||||
assert!(!text.contains("Point:"), "{text}");
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,7 +361,9 @@ fn the_geometry_round_trips_through_the_save_file() {
|
||||
assert_eq!(saved.len(), 1, "one window was placed: {saved:?}");
|
||||
assert_eq!(saved[0].name, "ChatFrame1");
|
||||
let text = crate::ui_layout::render(&saved);
|
||||
let read_back = crate::ui_layout::parse(&text);
|
||||
let crate::ui_layout::Layout::Rect(read_back) = crate::ui_layout::parse(&text) else {
|
||||
panic!("written in the rect form:\n{text}");
|
||||
};
|
||||
assert_eq!(
|
||||
read_back, saved,
|
||||
"the file expresses what the seam produced"
|
||||
|
||||
@@ -494,7 +494,12 @@ pub(super) fn tick_script(
|
||||
if script.vm_kind() == benilla_ui::script::VmKind::InGame {
|
||||
// A display change re-applies the UI scale (`0x492e90`), whose narrow-aspect cap
|
||||
// follows the window; a moved dial applies once, as its CVar callback does
|
||||
// (`0x490770`).
|
||||
// (`0x490770`). Deviation: the reference then re-reads the aspect (`0x492f13`), and on
|
||||
// a change of 0.001 or more calls `0x491380`, which sets the reload trigger
|
||||
// (`[0xb4b3f4]`) when the script-context check `0x494a50(0xa)` allows it; here a
|
||||
// resize re-lays out the live UI, the chat bubbles' window-derived `BubbleGeometry`
|
||||
// with it, because the window resizes freely and a reload on each resize would tear
|
||||
// down every addon's state mid-play.
|
||||
if resized {
|
||||
let display = (w.round() as u32, h.round() as u32);
|
||||
let mut none = None;
|
||||
|
||||
@@ -1022,8 +1022,8 @@ fn a_repeating_error_is_one_row_with_a_count_not_a_flood() {
|
||||
// A `/reload` never leaves `InWorld`, so only a saver in the shutdown tail sees every root.
|
||||
|
||||
/// A window the player has placed, made as a drag makes one: `SetUserPlaced` refuses a frame that
|
||||
/// is neither movable nor resizable. Parentless, so it anchors to the screen root (the file's `-`
|
||||
/// target). Both flags: the cache applies position only to a movable frame, size to a resizable.
|
||||
/// is neither movable nor resizable. Both flags: the cache writes `X:`/`Y:` only for a movable
|
||||
/// frame and `W:`/`H:` only for a resizable one.
|
||||
fn place_a_window(world: &mut World) {
|
||||
world
|
||||
.get_non_send_resource_mut::<benilla_ui::script::UiScript>()
|
||||
@@ -1045,12 +1045,7 @@ fn layout_cache(character: &str) -> Option<String> {
|
||||
|
||||
/// What a saved window's row has to say for the player to get it back.
|
||||
fn assert_probe_row(text: &str) {
|
||||
for want in [
|
||||
"Frame: LayoutProbe",
|
||||
"W: 413",
|
||||
"H: 147",
|
||||
"Point: BOTTOMLEFT - BOTTOMLEFT 61 29",
|
||||
] {
|
||||
for want in ["Frame: LayoutProbe", "X: 61", "W: 413", "H: 147"] {
|
||||
assert!(
|
||||
text.contains(want),
|
||||
"the saved row is missing `{want}`:\n{text}"
|
||||
@@ -1128,6 +1123,51 @@ fn each_character_gets_its_own_layout_cache() {
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
/// A file of benilla's earlier anchor form seats the window through the loader, and the next save
|
||||
/// writes it back in the reference's rect form, so a player's windows survive the change.
|
||||
#[test]
|
||||
fn an_anchor_form_file_is_restored_and_rewritten_as_the_rect() {
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let (tmp, _c, _h) = hermetic_probe("layout-anchor-form");
|
||||
let mut world = booted_world();
|
||||
world.init_resource::<crate::ui_layout::LayoutFile>();
|
||||
|
||||
log_in_as(&mut world, "Onehunter", 1);
|
||||
let path = crate::local_state::layout_character_path("Realm", "Onehunter")
|
||||
.expect("a layout path in the hermetic state folder");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(
|
||||
&path,
|
||||
"Frame: LayoutProbe\nW: 413\nH: 147\nPoint: BOTTOMLEFT - BOTTOMLEFT 61 29\n",
|
||||
)
|
||||
.unwrap();
|
||||
author_a_window(&mut world);
|
||||
world
|
||||
.run_system_once(crate::ui_layout::load_layout)
|
||||
.expect("the layout loader ran");
|
||||
let (w, h, left, _) = window_geometry(&world);
|
||||
assert_eq!(
|
||||
(w, h, left),
|
||||
(413.0, 147.0, 61.0),
|
||||
"seated from the anchor form"
|
||||
);
|
||||
|
||||
super::end_ui_session(&mut world);
|
||||
let text = layout_cache("Onehunter").expect("the logout wrote the layout cache");
|
||||
assert_probe_row(&text);
|
||||
assert!(
|
||||
!text.contains("Point:"),
|
||||
"rewritten in the rect form:\n{text}"
|
||||
);
|
||||
|
||||
drop(world);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
/// The probe window as FrameXML authors it, the fresh tree a relog meets: not user-placed, with the
|
||||
/// authored `movable`/`resizable` pair the cache's apply reads off the live frame.
|
||||
fn author_a_window(world: &mut World) {
|
||||
@@ -1143,14 +1183,14 @@ fn author_a_window(world: &mut World) {
|
||||
.expect("author the probe window");
|
||||
}
|
||||
|
||||
/// The probe window's live geometry, as the player sees it.
|
||||
fn window_geometry(world: &World) -> (f32, f32, String, f32, f32) {
|
||||
/// The probe window's live geometry, as the player sees it: its size, left and top.
|
||||
fn window_geometry(world: &World) -> (f32, f32, f32, f32) {
|
||||
world
|
||||
.get_non_send_resource::<benilla_ui::script::UiScript>()
|
||||
.expect("a VM")
|
||||
.eval::<(f32, f32, String, f32, f32)>(
|
||||
"local p, _, _, x, y = LayoutProbe:GetPoint(1) \
|
||||
return LayoutProbe:GetWidth(), LayoutProbe:GetHeight(), p, x, y",
|
||||
.eval::<(f32, f32, f32, f32)>(
|
||||
"return LayoutProbe:GetWidth(), LayoutProbe:GetHeight(), \
|
||||
LayoutProbe:GetLeft(), LayoutProbe:GetTop()",
|
||||
)
|
||||
.expect("read the probe window back")
|
||||
}
|
||||
|
||||
@@ -752,19 +752,15 @@ fn a_stale_layout_row_cannot_seat_a_stock_frame() {
|
||||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||||
s.resolve();
|
||||
|
||||
// The row as `benilla-config/layout/<realm>-<character>.txt` carries it: Cartographer's saved
|
||||
// `LookNFeel` offsets and the size it forces.
|
||||
// The row as the reference's `layout-cache.txt` carries it after Cartographer's `LookNFeel`:
|
||||
// the size it forces, centred by its drag.
|
||||
let row = || benilla_ui::script::FrameLayout {
|
||||
name: "WorldMapFrame".into(),
|
||||
width: 1024.0,
|
||||
height: 768.0,
|
||||
points: vec![benilla_ui::script::LayoutPoint {
|
||||
point: "CENTER".into(),
|
||||
relative_to: Some("UIParent".into()),
|
||||
relative_point: "CENTER".into(),
|
||||
x: -0.6369222,
|
||||
y: 53.861614,
|
||||
}],
|
||||
level: None,
|
||||
x: Some(307),
|
||||
y: Some(-128),
|
||||
width: Some(1024),
|
||||
height: Some(768),
|
||||
};
|
||||
|
||||
s.restore_user_placed_layouts([row()]);
|
||||
@@ -813,7 +809,14 @@ fn a_stale_layout_row_cannot_seat_a_stock_frame() {
|
||||
);
|
||||
let saved = s.user_placed_layouts();
|
||||
assert_eq!(saved.len(), 1, "which is what puts the row back: {saved:?}");
|
||||
assert_eq!(saved[0], row(), "unchanged through the round trip");
|
||||
assert_eq!(
|
||||
benilla_ui::script::FrameLayout {
|
||||
level: None,
|
||||
..saved[0].clone()
|
||||
},
|
||||
row(),
|
||||
"unchanged through the round trip"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,9 @@ const FIRST_LEVEL: u16 = 2;
|
||||
|
||||
/// The bubble geometry that follows from the window rather than the line, in the `WorldFrame`'s
|
||||
/// units. Written into a bubble when it changes, so an addon's own anchors survive between
|
||||
/// resizes.
|
||||
/// resizes. A resize re-sizes the live bubbles as part of the live re-layout's deviation, where
|
||||
/// the reference can reload the UI on an aspect change (`0x492f13` → `0x491380`); the `Deviation:`
|
||||
/// line is at the resize in `benilla_app`'s `extract::tick_script`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct BubbleGeometry {
|
||||
/// The border unit (`0x4b09d2`-`0x4b0a2f`): the backdrop's edge size, tile size and four
|
||||
|
||||
@@ -3,17 +3,39 @@
|
||||
//! engine half, a snapshot out, a restore in and a dirty bit; the app owns the file
|
||||
//! (`benilla_app::ui_layout`).
|
||||
//!
|
||||
//! A saved frame carries its name, size and whole anchor list. An anchor's target is the screen
|
||||
//! 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.
|
||||
//! A saved frame is its rect, as the reference's writer (`0x490e60`) takes it: the frame level,
|
||||
//! the top-left against the screen's top-left and the extent, in the frame's own units and rounded
|
||||
//! to integers. The apply (`0x4905e0`) pins TOPLEFT on the screen there and sets the size, so the
|
||||
//! frame's authored anchors do not survive it.
|
||||
|
||||
use crate::layout::{Anchor, Anchors};
|
||||
use crate::layout::{Anchor, Anchors, Point};
|
||||
use crate::widget::FrameHandle;
|
||||
|
||||
use super::object::{point_from_str, point_name};
|
||||
use super::{Model, SCREEN};
|
||||
use super::object::point_from_str;
|
||||
use super::object::{eff_scale, settle_frame};
|
||||
use super::SCREEN;
|
||||
|
||||
/// One anchor of a saved frame, in the spelling `GetPoint`/`SetPoint` use.
|
||||
/// One user-placed frame's persisted geometry, one field per line of the reference's file. A
|
||||
/// field is `None` where its line is absent.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct FrameLayout {
|
||||
/// The frame's global name, which addresses the row across sessions (`Frame:`).
|
||||
pub name: String,
|
||||
/// The frame level (`FrameLevel:`, `frame+0xc4`), written for every row (`0x490f55`).
|
||||
pub level: Option<u16>,
|
||||
/// The rect's left, against the screen's left (`X:`), written for a movable frame
|
||||
/// (`0x490f99`).
|
||||
pub x: Option<i32>,
|
||||
/// The rect's top, against the screen's top (`Y:`), so zero or below.
|
||||
pub y: Option<i32>,
|
||||
/// The rect's width (`W:`), written for a resizable frame (`0x49109f`).
|
||||
pub width: Option<i32>,
|
||||
/// The rect's height (`H:`).
|
||||
pub height: Option<i32>,
|
||||
}
|
||||
|
||||
/// One anchor of a frame saved in benilla's earlier anchor form, in the spelling
|
||||
/// `GetPoint`/`SetPoint` use.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct LayoutPoint {
|
||||
/// The point on the saved frame (`"BOTTOMLEFT"`, …).
|
||||
@@ -28,62 +50,170 @@ pub struct LayoutPoint {
|
||||
pub y: f32,
|
||||
}
|
||||
|
||||
/// One user-placed frame's persisted geometry.
|
||||
/// A row of the anchor form benilla wrote before it saved the reference's rect: the authored
|
||||
/// size and the whole anchor list. Read once, to carry a player's windows into the rect form.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FrameLayout {
|
||||
/// The frame's global name, which addresses the row across sessions.
|
||||
pub struct AnchorLayout {
|
||||
/// The frame's global name.
|
||||
pub name: String,
|
||||
/// The frame's authored width (`LayoutInput::width`), which a resize drag writes.
|
||||
/// The frame's authored width (`LayoutInput::width`).
|
||||
pub width: f32,
|
||||
/// The frame's authored height.
|
||||
pub height: f32,
|
||||
/// Every anchor the frame carries, in `GetPoint` order.
|
||||
/// Every anchor the frame carried, in `GetPoint` order.
|
||||
pub points: Vec<LayoutPoint>,
|
||||
}
|
||||
|
||||
impl super::UiScript {
|
||||
/// Snapshot, sorted by name so the file is stable, every frame the reference's writer
|
||||
/// (`0x490e60`) writes: a non-empty name (`0x490e79`, `0x490e82`), `userPlaced` (`0x490e8e`,
|
||||
/// `0x1000`) and `movable` or `resizable` (`0x490e97`, `0x100|0x200`), with every anchor
|
||||
/// nameable. Every drag stamps `userPlaced` (`0x7652b0` at `0x7652e5`), so the last test is
|
||||
/// what drops the row of a stock frame an addon once moved, once that addon is gone.
|
||||
/// Snapshot, in creation order as the writer walks the list of every frame (`0x490e24`,
|
||||
/// `[root+0xcc4]`, appended by the constructor at `0x76930d`), every frame it writes: a
|
||||
/// non-empty name (`0x490e79`, `0x490e82`), `userPlaced` (`0x490e8e`, `0x1000`) and `movable`
|
||||
/// or `resizable` (`0x490e97`, `0x100|0x200`), with a rect to read. Every drag stamps
|
||||
/// `userPlaced` (`0x7652b0` at `0x7652e5`), so the last test is what drops the row of a stock
|
||||
/// frame an addon once moved, once that addon is gone.
|
||||
///
|
||||
/// The rect is the one the frame holds, stale or not (`0x768320`, its valid bit): only a frame
|
||||
/// holding none is resolved now (`0x490eed`, `0x7680e0(1)`) and read again, and still none
|
||||
/// writes no row (`0x490f00`). The root's rect is the screen, which is always held, so the
|
||||
/// second bail (`0x490f17`) never takes here. The level and the two flags are read after the
|
||||
/// rect (`0x490f55`, `0x490f93`, `0x491099`), since a resolve can run script.
|
||||
///
|
||||
/// `X:`/`Y:` round half away from zero (`0x490fc4`–`0x490fdf`, then `0x40a2b0`'s truncating
|
||||
/// `fistp`); `W:`/`H:` add a half and truncate (`0x4910cd`–`0x4910d3`).
|
||||
pub fn user_placed_layouts(&self) -> Vec<FrameLayout> {
|
||||
let model = self.model_ref();
|
||||
let mut out: Vec<FrameLayout> = model
|
||||
let mut candidates: Vec<(u64, FrameHandle, String)> = self
|
||||
.model_ref()
|
||||
.arena
|
||||
.iter_frames()
|
||||
.filter(|(_, f)| f.user_placed && (f.movable || f.resizable))
|
||||
.filter_map(|(h, f)| {
|
||||
let name = f.name.clone()?;
|
||||
let input = model.layout_inputs.get(&h)?;
|
||||
let points = input
|
||||
.anchors
|
||||
.iter()
|
||||
.map(|a| saved_point(&model, a))
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
Some(FrameLayout {
|
||||
name,
|
||||
width: input.width,
|
||||
height: input.height,
|
||||
points,
|
||||
})
|
||||
let name = f.name.clone().filter(|n| !n.is_empty())?;
|
||||
Some((f.created(), h, name))
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
candidates.sort_unstable_by_key(|c| c.0);
|
||||
let mut out = Vec::with_capacity(candidates.len());
|
||||
for (_, h, name) in candidates {
|
||||
if !self.model_ref().resolved.contains_key(&h) {
|
||||
settle_frame(self.lua(), h);
|
||||
}
|
||||
let model = self.model_ref();
|
||||
let Some(r) = model.resolved.get(&h).copied() else {
|
||||
continue;
|
||||
};
|
||||
let Some((movable, resizable, level)) = model
|
||||
.arena
|
||||
.frame(h)
|
||||
.map(|f| (f.movable, f.resizable, f.level))
|
||||
else {
|
||||
continue; // the resolve's script destroyed it
|
||||
};
|
||||
let root = model.screen;
|
||||
let scale = f64::from(eff_scale(&model, h));
|
||||
let own = |v: f32| f64::from(v) / scale;
|
||||
let (x, y) = if movable {
|
||||
(
|
||||
Some(round_offset(own(r.left - root.left))),
|
||||
Some(round_offset(own(r.top - root.top))),
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
let (width, height) = if resizable {
|
||||
(
|
||||
Some(round_extent(own(r.right - r.left))),
|
||||
Some(round_extent(own(r.top - r.bottom))),
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
out.push(FrameLayout {
|
||||
name,
|
||||
level: Some(level),
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Seat saved geometry back onto the frames that exist, each row whole or not at all. The
|
||||
/// apply is per arm (`0x4905e0`): the position needs `movable` (`0x490600`) and anchors (the
|
||||
/// no-position bail, `0x490613`), the size needs `resizable` (`0x490689`) and a non-zero size
|
||||
/// (`0x49069a`), and each arm that applies stamps `userPlaced` (`0x49067e`, `0x490706`), which
|
||||
/// keeps `UIParent_ManageFramePositions` off the frame. Marks nothing dirty: the values came
|
||||
/// Seat saved rows back onto the frames that exist, as the apply (`0x4905e0`) does. A level of
|
||||
/// zero or more is set, carried to same-strata children (`0x4905ee`–`0x4905f5`). The position
|
||||
/// needs `movable` (`0x490600`) and a non-zero `X` or `Y` (`0x490607`–`0x490613`): it clears
|
||||
/// every anchor (`0x49061a`) and pins TOPLEFT on the screen's TOPLEFT there (`0x490670`). The
|
||||
/// size needs `resizable` (`0x490689`) and a non-zero `W` or `H` (`0x49068e`–`0x49069a`), and
|
||||
/// sets each non-zero one (`0x4906c7`, `0x4906fa`). Each arm that applies stamps `userPlaced`
|
||||
/// (`0x49067e`, `0x490706`), which keeps `UIParent_ManageFramePositions` off the frame. Each
|
||||
/// row ends in a forced resolve of its frame, shown or hidden (`0x490710`, `0x7680e0(1)`), so
|
||||
/// its `OnSizeChanged` fires before the next row applies. Marks nothing dirty: the values came
|
||||
/// from the file.
|
||||
pub fn restore_user_placed_layouts(&mut self, layouts: impl IntoIterator<Item = FrameLayout>) {
|
||||
for l in layouts {
|
||||
if let Some(h) = self.apply_layout_row(l) {
|
||||
settle_frame(self.lua(), h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of [`Self::restore_user_placed_layouts`], up to its invalidate; the frame it
|
||||
/// applied to.
|
||||
fn apply_layout_row(&mut self, l: FrameLayout) -> Option<FrameHandle> {
|
||||
let mut model = self.model_mut();
|
||||
// A window this session does not have applies nothing (`0x4905e6`).
|
||||
let h = model.arena.lookup(&l.name)?;
|
||||
if let Some(level) = l.level {
|
||||
model.arena.set_frame_level(h, level, true);
|
||||
}
|
||||
let (movable, resizable) = model.arena.frame(h).map(|f| (f.movable, f.resizable))?;
|
||||
// An absent line reads as the reference's cleared accumulator, zero.
|
||||
let (x, y) = (l.x.unwrap_or(0), l.y.unwrap_or(0));
|
||||
let (w, ht) = (l.width.unwrap_or(0), l.height.unwrap_or(0));
|
||||
let seat_position = movable && (x != 0 || y != 0);
|
||||
let seat_size = resizable && (w != 0 || ht != 0);
|
||||
let input = model.layout_inputs.entry(h).or_default();
|
||||
let mut changed = false;
|
||||
if seat_position {
|
||||
let pin = Anchors::from([Anchor::new(
|
||||
Point::TopLeft,
|
||||
SCREEN,
|
||||
Point::TopLeft,
|
||||
x as f32,
|
||||
y as f32,
|
||||
)]);
|
||||
changed |= !input.anchors.bits_eq(&pin);
|
||||
input.anchors = pin;
|
||||
}
|
||||
if seat_size && w != 0 {
|
||||
changed |= input.width.to_bits() != (w as f32).to_bits();
|
||||
input.width = w as f32;
|
||||
}
|
||||
if seat_size && ht != 0 {
|
||||
changed |= input.height.to_bits() != (ht as f32).to_bits();
|
||||
input.height = ht as f32;
|
||||
}
|
||||
if seat_size {
|
||||
model.note_authored_size(h);
|
||||
}
|
||||
if seat_position || seat_size {
|
||||
if let Some(f) = model.arena.frame_mut(h) {
|
||||
f.user_placed = true;
|
||||
}
|
||||
}
|
||||
// Every row that names a frame ends in an invalidate (`0x490710`).
|
||||
model.invalidate_frame(h, changed);
|
||||
Some(h)
|
||||
}
|
||||
|
||||
/// Seat rows of benilla's earlier anchor form, each whole or not at all, under the apply's arm
|
||||
/// gates (`0x490600`, `0x490689`) and stamps (`0x49067e`, `0x490706`). The next save writes
|
||||
/// the frames back in the rect form.
|
||||
pub fn restore_anchor_layouts(&mut self, layouts: impl IntoIterator<Item = AnchorLayout>) {
|
||||
let mut model = self.model_mut();
|
||||
for l in layouts {
|
||||
let Some(h) = model.arena.lookup(&l.name) else {
|
||||
continue; // a window this build does not have
|
||||
continue;
|
||||
};
|
||||
let mut anchors = Anchors::new();
|
||||
let mut usable = true;
|
||||
@@ -110,7 +240,6 @@ impl super::UiScript {
|
||||
if !usable {
|
||||
continue;
|
||||
}
|
||||
// The two arm gates, read before the layout input is borrowed mutably.
|
||||
let Some((movable, resizable)) = model.arena.frame(h).map(|f| (f.movable, f.resizable))
|
||||
else {
|
||||
continue;
|
||||
@@ -120,9 +249,7 @@ impl super::UiScript {
|
||||
if !seat_position && !seat_size {
|
||||
continue;
|
||||
}
|
||||
let Some(input) = model.layout_inputs.get_mut(&h) else {
|
||||
continue;
|
||||
};
|
||||
let input = model.layout_inputs.entry(h).or_default();
|
||||
let mut changed = false;
|
||||
if seat_position {
|
||||
changed |= input.anchors != anchors;
|
||||
@@ -134,13 +261,9 @@ impl super::UiScript {
|
||||
input.width = l.width;
|
||||
input.height = l.height;
|
||||
}
|
||||
// Only an arm that applied stamps, so a row neither arm took falls out of the file.
|
||||
if seat_position || seat_size {
|
||||
if let Some(f) = model.arena.frame_mut(h) {
|
||||
f.user_placed = true;
|
||||
}
|
||||
if let Some(f) = model.arena.frame_mut(h) {
|
||||
f.user_placed = true;
|
||||
}
|
||||
// A row applied invalidates the frame (`0x490710`).
|
||||
model.invalidate_frame(h, changed);
|
||||
}
|
||||
}
|
||||
@@ -151,19 +274,28 @@ impl super::UiScript {
|
||||
}
|
||||
}
|
||||
|
||||
/// An anchor's saved form, or `None` when its target is an unnamed frame or a region.
|
||||
fn saved_point(model: &Model, a: &Anchor) -> Option<LayoutPoint> {
|
||||
let relative_to = if a.relative_to == SCREEN {
|
||||
None
|
||||
} else {
|
||||
let h: FrameHandle = model.id_to_frame.get(&a.relative_to).copied()?;
|
||||
Some(model.arena.frame(h)?.name.clone()?)
|
||||
};
|
||||
Some(LayoutPoint {
|
||||
point: point_name(a.point).to_owned(),
|
||||
relative_to,
|
||||
relative_point: point_name(a.relative_point).to_owned(),
|
||||
x: a.x_off,
|
||||
y: a.y_off,
|
||||
})
|
||||
/// `X:`/`Y:`'s rounding: a half added, or taken off below zero (`fcom 0.0; test ah,0x5; jp`),
|
||||
/// then truncated toward zero (`0x40a2b0`), so half rounds away from zero.
|
||||
fn round_offset(v: f64) -> i32 {
|
||||
(if v < 0.0 { v - 0.5 } else { v + 0.5 }).trunc() as i32
|
||||
}
|
||||
|
||||
/// `W:`/`H:`'s rounding: a half added with no sign test, then truncated toward zero.
|
||||
fn round_extent(v: f64) -> i32 {
|
||||
(v + 0.5).trunc() as i32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{round_extent, round_offset};
|
||||
|
||||
#[test]
|
||||
fn offsets_round_half_away_from_zero_and_extents_add_a_half() {
|
||||
assert_eq!(
|
||||
[2.5, 2.49, -2.5, -2.49, 0.0, -0.4].map(round_offset),
|
||||
[3, 2, -3, -2, 0, 0]
|
||||
);
|
||||
// No sign test on an extent: a negative half truncates toward zero.
|
||||
assert_eq!([2.5, 2.49, 0.0, -0.7].map(round_extent), [3, 2, 0, 0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +226,7 @@ pub(crate) use button::{set_label_font_justify_h_lua, LabelFont};
|
||||
pub use inspect::{InspectView, UnitReach};
|
||||
pub use item_stats::{item_usable, ItemSetView, ItemTemplateView, PlayerReqState};
|
||||
pub use item_text::ItemTextState;
|
||||
pub use layout_cache::{FrameLayout, LayoutPoint};
|
||||
pub use layout_cache::{AnchorLayout, FrameLayout, LayoutPoint};
|
||||
pub use loot::{LootRow, LootState, LOOT_PORTRAIT_UNIT};
|
||||
pub use loot_roll::{LootRollEntry, LootRollsState};
|
||||
pub use macros::{MacroBinding, MacroState, MacroView, MAX_MACROS, MAX_MACRO_BODY, MAX_MACRO_NAME};
|
||||
|
||||
@@ -23,7 +23,7 @@ mod events_regions;
|
||||
mod frame_state;
|
||||
mod layout_methods;
|
||||
pub(crate) use layout_methods::eff_scale;
|
||||
pub(in crate::script) use layout_methods::settle_region;
|
||||
pub(in crate::script) use layout_methods::{settle_frame, settle_region};
|
||||
pub(crate) mod movable;
|
||||
pub(crate) mod toplevel;
|
||||
pub(crate) use movable::{cancel_on_hide, pump_move_or_size, FrameMove};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! anchor and size.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::{FrameLayout, LayoutPoint, UiScript};
|
||||
use crate::script::{FrameLayout, UiScript};
|
||||
|
||||
/// A 200×100 frame CENTER-anchored in a full-screen holder on an 800×600 screen: its rect is
|
||||
/// left 300, right 500, bottom 250, top 350.
|
||||
@@ -261,25 +261,23 @@ fn a_frame_with_no_rect_starts_no_drag() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// After a drag the layout cache holds the one TOPLEFT anchor and the size, the reference's `X:`,
|
||||
/// `Y:`, `W:` and `H:` (`0x490e60`), and its apply seats that back.
|
||||
/// After a drag the layout cache holds the pin as the reference's `X:`, `Y:`, `W:` and `H:`, with
|
||||
/// the frame level (`0x490e60`), and its apply seats all of that back (`0x4905e0`).
|
||||
#[test]
|
||||
fn the_layout_cache_round_trips_the_pin() {
|
||||
let mut s = centred_box();
|
||||
s.run("Box:StartMoving()").unwrap();
|
||||
s.mouse_move(430.0, 280.0);
|
||||
s.run("Box:StopMovingOrSizing()").unwrap();
|
||||
s.run("Box:StopMovingOrSizing() Box:SetFrameLevel(7)")
|
||||
.unwrap();
|
||||
s.resolve(); // drawn there before the save reads its held rect
|
||||
let want = FrameLayout {
|
||||
name: "Box".into(),
|
||||
width: 200.0,
|
||||
height: 100.0,
|
||||
points: vec![LayoutPoint {
|
||||
point: "TOPLEFT".into(),
|
||||
relative_to: None,
|
||||
relative_point: "TOPLEFT".into(),
|
||||
x: 330.0,
|
||||
y: -270.0,
|
||||
}],
|
||||
level: Some(7),
|
||||
x: Some(330),
|
||||
y: Some(-270),
|
||||
width: Some(200),
|
||||
height: Some(100),
|
||||
};
|
||||
let saved = s.user_placed_layouts();
|
||||
assert_eq!(saved, std::slice::from_ref(&want));
|
||||
@@ -288,6 +286,196 @@ fn the_layout_cache_round_trips_the_pin() {
|
||||
fresh.restore_user_placed_layouts(saved);
|
||||
assert_eq!(edges(&mut fresh), (330.0, 530.0, 230.0, 330.0));
|
||||
assert_eq!(anchor(&fresh), pinned_at(330.0, -270.0));
|
||||
assert_eq!(
|
||||
fresh.eval::<i64>("return Box:GetFrameLevel()").unwrap(),
|
||||
7,
|
||||
"the level is applied too (`0x4905f5`)"
|
||||
);
|
||||
assert_eq!(fresh.user_placed_layouts(), [want]);
|
||||
assert!(fresh.errors().is_empty(), "{:?}", fresh.errors());
|
||||
}
|
||||
|
||||
/// A drag that leaves the frame off the unit grid saves whole units: `X:`/`Y:` round half away
|
||||
/// from zero (`0x490fc4`–`0x490fdf`), and the apply seats the rounded spot.
|
||||
#[test]
|
||||
fn a_sub_unit_drag_saves_and_restores_rounded() {
|
||||
let mut s = centred_box();
|
||||
s.run("Box:StartMoving()").unwrap();
|
||||
s.mouse_move(430.25, 279.5);
|
||||
s.run("Box:StopMovingOrSizing()").unwrap();
|
||||
assert_eq!(
|
||||
anchor(&s),
|
||||
pinned_at(330.25, -270.5),
|
||||
"the live pin is exact"
|
||||
);
|
||||
s.resolve();
|
||||
let saved = s.user_placed_layouts();
|
||||
assert_eq!(
|
||||
(saved[0].x, saved[0].y),
|
||||
(Some(330), Some(-271)),
|
||||
"330.25 rounds down, -270.5 away from zero"
|
||||
);
|
||||
|
||||
let mut fresh = centred_box();
|
||||
fresh.restore_user_placed_layouts(saved);
|
||||
assert_eq!(anchor(&fresh), pinned_at(330.0, -271.0));
|
||||
}
|
||||
|
||||
/// A frame set user-placed without a drag keeps its authored anchors while it lives, but its row
|
||||
/// is its rect, and the apply clears those anchors (`0x49061a`) and pins TOPLEFT on the screen
|
||||
/// (`0x490670`): restored, it no longer follows the frame it was authored against.
|
||||
#[test]
|
||||
fn a_frame_placed_without_a_drag_restores_at_its_saved_top_left() {
|
||||
let s = centred_box();
|
||||
s.run("Box:SetUserPlaced(true)").unwrap();
|
||||
let saved = s.user_placed_layouts();
|
||||
assert_eq!(
|
||||
(saved[0].x, saved[0].y, saved[0].width, saved[0].height),
|
||||
(Some(300), Some(-250), Some(200), Some(100)),
|
||||
"the rect, not the CENTER anchor"
|
||||
);
|
||||
|
||||
let mut fresh = centred_box();
|
||||
fresh
|
||||
.run("Holder:ClearAllPoints() Holder:SetPoint('BOTTOMLEFT', 100, 0)")
|
||||
.unwrap();
|
||||
fresh.restore_user_placed_layouts(saved);
|
||||
assert_eq!(
|
||||
edges(&mut fresh),
|
||||
(300.0, 500.0, 250.0, 350.0),
|
||||
"where it was saved, though its holder moved"
|
||||
);
|
||||
assert_eq!(anchor(&fresh), pinned_at(300.0, -250.0));
|
||||
assert!(fresh.errors().is_empty(), "{:?}", fresh.errors());
|
||||
}
|
||||
|
||||
/// The rows are written in creation order, the writer's walk of every frame (`0x490e24`), and
|
||||
/// applied in file order, each level carried to same-strata children (`0x76a4f0`): the parent's
|
||||
/// row lands before its child's, so both come back at their saved levels whatever their names.
|
||||
#[test]
|
||||
fn a_parent_and_child_restore_their_saved_levels() {
|
||||
let make = || {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
Zed = CreateFrame("Frame", "Zed")
|
||||
Zed:SetPoint("TOPLEFT", 100, -100); Zed:SetWidth(200); Zed:SetHeight(200)
|
||||
Zed:SetMovable(true); Zed:SetUserPlaced(true)
|
||||
Alpha = CreateFrame("Frame", "Alpha", Zed)
|
||||
Alpha:SetPoint("TOPLEFT", 10, -10); Alpha:SetWidth(50); Alpha:SetHeight(50)
|
||||
Alpha:SetMovable(true); Alpha:SetUserPlaced(true)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s
|
||||
};
|
||||
let s = make();
|
||||
s.run("Zed:SetFrameLevel(5) Alpha:SetFrameLevel(9)")
|
||||
.unwrap();
|
||||
let saved = s.user_placed_layouts();
|
||||
assert_eq!(
|
||||
saved.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(),
|
||||
["Zed", "Alpha"],
|
||||
"creation order, not name order"
|
||||
);
|
||||
|
||||
let mut fresh = make();
|
||||
fresh.restore_user_placed_layouts(saved);
|
||||
assert_eq!(
|
||||
fresh
|
||||
.eval::<(i64, i64)>("return Zed:GetFrameLevel(), Alpha:GetFrameLevel()")
|
||||
.unwrap(),
|
||||
(5, 9)
|
||||
);
|
||||
assert!(fresh.errors().is_empty(), "{:?}", fresh.errors());
|
||||
}
|
||||
|
||||
/// Each row ends in a forced resolve of its frame (`0x490710`, `0x7680e0(1)`), hidden or not, so
|
||||
/// its `OnSizeChanged` runs inside the restore.
|
||||
#[test]
|
||||
fn the_restore_resolves_each_frame_at_once_even_hidden() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
fires = 0
|
||||
Panel = CreateFrame("Frame", "Panel")
|
||||
Panel:SetPoint("TOPLEFT", 10, -10); Panel:SetWidth(50); Panel:SetHeight(50)
|
||||
Panel:SetResizable(true)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s.run(
|
||||
r#"
|
||||
Panel:Hide()
|
||||
Panel:SetScript("OnSizeChanged", function() fires = fires + 1; last_w = arg1 end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.restore_user_placed_layouts([FrameLayout {
|
||||
name: "Panel".into(),
|
||||
width: Some(120),
|
||||
height: Some(80),
|
||||
..FrameLayout::default()
|
||||
}]);
|
||||
assert_eq!(
|
||||
s.eval::<(i64, f64)>("return fires, last_w").unwrap(),
|
||||
(1, 120.0),
|
||||
"fired inside the restore, though the frame is hidden"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The writer reads the rect the frame holds, stale or not (`0x768320`, its valid bit), and
|
||||
/// resolves only a frame that holds none (`0x490eed`): a size set since the last resolve is not
|
||||
/// saved, and the save runs no script.
|
||||
#[test]
|
||||
fn the_save_reads_the_held_rect_and_runs_no_script() {
|
||||
let mut s = centred_box();
|
||||
s.run(
|
||||
r#"
|
||||
fires = 0
|
||||
Box:SetUserPlaced(true)
|
||||
Box:SetScript("OnSizeChanged", function() fires = fires + 1 end)
|
||||
Box:SetWidth(300)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let saved = s.user_placed_layouts();
|
||||
assert_eq!(
|
||||
(saved[0].x, saved[0].width),
|
||||
(Some(300), Some(200)),
|
||||
"the held rect, from before the width moved"
|
||||
);
|
||||
assert_eq!(s.eval::<i64>("return fires").unwrap(), 0, "nothing fired");
|
||||
s.resolve();
|
||||
assert_eq!(s.user_placed_layouts()[0].width, Some(300));
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// A frame with no rect is resolved inside the save (`0x490eed`), which can run its script; the
|
||||
/// flags are read after that (`0x490f93`, `0x491099`), so what the script set is what is written.
|
||||
#[test]
|
||||
fn the_save_reads_the_flags_after_the_rect() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
Late = CreateFrame("Frame", "Late")
|
||||
Late:SetMovable(true); Late:SetUserPlaced(true)
|
||||
Late:SetScript("OnSizeChanged", function() this:SetResizable(true) end)
|
||||
Late:SetPoint("TOPLEFT", 40, -60); Late:SetWidth(70); Late:SetHeight(30)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let saved = s.user_placed_layouts();
|
||||
assert_eq!(
|
||||
(saved[0].x, saved[0].y, saved[0].width, saved[0].height),
|
||||
(Some(40), Some(-60), Some(70), Some(30)),
|
||||
"resized by its own OnSizeChanged inside the save, so W and H are written"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -288,7 +288,6 @@ fn a_drag_cut_short_by_the_pointer_leaving_keeps_the_last_real_position() {
|
||||
.find(|l| l.name == "Here")
|
||||
.expect("Here is saved");
|
||||
// Saved as the drag's TOPLEFT pin (`0x768430`): top 210 on a 768 screen.
|
||||
assert_eq!(here.points[0].point, "TOPLEFT");
|
||||
assert_eq!((here.points[0].x, here.points[0].y), (130.0, -558.0));
|
||||
assert_eq!((here.x, here.y), (Some(130), Some(-558)));
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -608,19 +608,15 @@ fn the_write_filter_is_user_placed_and_movable_or_resizable() {
|
||||
/// neither flag is left alone.
|
||||
#[test]
|
||||
fn the_apply_seats_position_behind_movable_and_size_behind_resizable() {
|
||||
use crate::script::{FrameLayout, LayoutPoint};
|
||||
use crate::script::FrameLayout;
|
||||
|
||||
let row = |name: &str| FrameLayout {
|
||||
name: name.to_owned(),
|
||||
width: 200.0,
|
||||
height: 80.0,
|
||||
points: vec![LayoutPoint {
|
||||
point: "BOTTOMLEFT".into(),
|
||||
relative_to: None,
|
||||
relative_point: "BOTTOMLEFT".into(),
|
||||
x: 300.0,
|
||||
y: 200.0,
|
||||
}],
|
||||
level: None,
|
||||
x: Some(300),
|
||||
y: Some(-200),
|
||||
width: Some(200),
|
||||
height: Some(80),
|
||||
};
|
||||
// Three frames, one per flag state, all authored identically.
|
||||
let mut s = script();
|
||||
@@ -672,6 +668,42 @@ fn the_apply_seats_position_behind_movable_and_size_behind_resizable() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The apply sets a saved level of zero or more whatever the flags (`0x4905ee`–`0x4905f5`), and
|
||||
/// takes a zero `X` and `Y` for no position (`0x490607`–`0x490613`), so the frame keeps its anchors.
|
||||
#[test]
|
||||
fn the_apply_sets_the_level_and_reads_a_zero_position_as_none() {
|
||||
use crate::script::FrameLayout;
|
||||
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
F = CreateFrame("Frame", "Corner")
|
||||
F:SetPoint("BOTTOMLEFT", 10, 10); F:SetWidth(50); F:SetHeight(50)
|
||||
F:SetMovable(true)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.restore_user_placed_layouts([FrameLayout {
|
||||
name: "Corner".into(),
|
||||
level: Some(6),
|
||||
x: Some(0),
|
||||
y: Some(0),
|
||||
width: None,
|
||||
height: None,
|
||||
}]);
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
s.eval::<(f64, i64, bool)>(
|
||||
"return Corner:GetLeft(), Corner:GetFrameLevel(), Corner:IsUserPlaced()"
|
||||
)
|
||||
.unwrap(),
|
||||
(10.0, 6, false),
|
||||
"levelled, but left on its anchors and unstamped"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
// ── The drag slot is taken, never refused (`0x7652b0`) ──────────────────────────────────────────
|
||||
|
||||
/// A title-region frame `A` at (100, 100) 200×80 and a movable `B` at (500, 100), on 800×600.
|
||||
|
||||
@@ -233,6 +233,13 @@ pub struct Frame {
|
||||
next_decl: u32,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
/// Its place on the list of every live frame (`root+0xcc4`), creation order.
|
||||
pub(crate) fn created(&self) -> u64 {
|
||||
self.created
|
||||
}
|
||||
}
|
||||
|
||||
/// A region leaf: a texture or font string owned by one frame.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Region {
|
||||
|
||||
Reference in New Issue
Block a user