scrollframe: the child's re-anchor is a SetPoint at the setter, not a per-frame override
`SetVerticalScroll`'s own note here already said it: `0x786db0` stores the offset,
**re-anchors the child (0x787100)** and fires its event, "all only on a change".
0x787100 passes the offsets to `SetPoint` — so the engine writes the child's
TOPLEFT once, when the scroll state changes. This applied it instead as a layout
override, recomputed every frame, which is a different thing: it made the
engine's anchor permanent and unoverridable.
That is why AI_VoiceOver's options could be opened and read but not scrolled.
AceGUI-3.0 scrolls by moving the child **itself** — `SetScroll` is
`content:ClearAllPoints()` then `content:SetPoint("TOPLEFT", 0, offset)`
(AceGUIContainer-ScrollFrame.lua:62-77) and it never calls `SetVerticalScroll` at
all — so the override put the content back at the top on every frame. Measured
through the harness: `SetScroll(1000)` computes offset 170 (content 549 − view
379) and the content's top now moves 563 → 733 where before it did not move.
`SetScrollChild` and both offset setters now write the merged TOPLEFT through
`layout_methods::write_anchor`, which is the commit `SetPoint` itself uses, so
the merge rule is shared rather than reimplemented and a child authored
TOPLEFT/TOPRIGHT keeps the TOPRIGHT that gives it its width. The whole override
path goes away with it: no third element on the layout plan, no override in the
node hash, no special case in the edge walk or in `retarget`, and
`LayoutSolver::set_frame_anchored` is gone.
One behaviour genuinely changes and the test says so now: `SetScrollChild(nil)`
no longer restores the child's authored anchor, because the authored TOPLEFT was
replaced the first time the frame took the child. Nothing was ever saved to
restore — that expectation was an artefact of the override model, not a trace.
The child keeps where the last scroll left it, and its own `SetPoint` wins from
there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -708,33 +708,6 @@ impl LayoutSolver {
|
||||
self.live.push(h);
|
||||
}
|
||||
|
||||
/// [`set_frame`](Self::set_frame) with `anchor` **merged into** its anchors the way a
|
||||
/// `SetPoint` call merges: the entry at that same point is replaced, every other point kept.
|
||||
/// A ScrollFrame child's re-anchor is the one caller, and the reference does it by calling
|
||||
/// `SetPoint` on the child (`0x787100`) — which in 1.12 adds a point rather than clearing the
|
||||
/// list, so the child keeps whatever else it was anchored with.
|
||||
///
|
||||
/// This replaced the list outright before, and the difference is not academic: AceGUI's
|
||||
/// scroll child is authored `TOPLEFT`/`TOPRIGHT` to its parent with an explicit height, so
|
||||
/// dropping `TOPRIGHT` left it with one corner and no width — **no rect at all**, and with it
|
||||
/// no rect for anything inside. That is what drew AI_VoiceOver's options window as chrome and
|
||||
/// tabs over an empty content area: the whole widget tree was built, sized and shown, and
|
||||
/// unresolved from the scroll child down.
|
||||
pub fn set_frame_anchored(&mut self, h: Handle, src: &LayoutInput, anchor: Anchor) {
|
||||
self.ensure(h);
|
||||
self.touch(h);
|
||||
let dst = &mut self.input[h as usize];
|
||||
dst.anchors.clear();
|
||||
dst.anchors.extend_from_slice(&src.anchors);
|
||||
match dst.anchors.iter_mut().find(|a| a.point == anchor.point) {
|
||||
Some(slot) => *slot = anchor,
|
||||
None => dst.anchors.push(anchor),
|
||||
}
|
||||
Self::copy_fields(dst, src);
|
||||
self.is_frame[h as usize] = true;
|
||||
self.live.push(h);
|
||||
}
|
||||
|
||||
fn copy_fields(dst: &mut LayoutInput, src: &LayoutInput) {
|
||||
dst.width = src.width;
|
||||
dst.height = src.height;
|
||||
@@ -1299,83 +1272,4 @@ mod tests {
|
||||
);
|
||||
assert!(s.unresolvable().is_empty());
|
||||
}
|
||||
|
||||
/// The override keeps the size and scale and **merges** into the anchors the way `SetPoint`
|
||||
/// does — its own point replaced, every other kept — and the reused anchors `Vec` must not
|
||||
/// leak the last round's.
|
||||
#[test]
|
||||
fn set_frame_anchored_merges_the_override_into_the_authored_points() {
|
||||
const A: Handle = 1;
|
||||
let mut s = LayoutSolver::new();
|
||||
let input = LayoutInput::sized(
|
||||
vec![
|
||||
Anchor::new(Point::TopLeft, SCREEN, Point::TopLeft, 0.0, 0.0),
|
||||
Anchor::new(Point::BottomRight, SCREEN, Point::BottomRight, 0.0, 0.0),
|
||||
],
|
||||
100.0,
|
||||
40.0,
|
||||
);
|
||||
|
||||
// Plain: both anchors pin all four edges, so the size is ignored.
|
||||
s.begin();
|
||||
s.set_external(SCREEN, screen_rect());
|
||||
s.set_frame(A, &input);
|
||||
s.solve();
|
||||
assert_eq!(
|
||||
bits(s.rect(A).expect("resolved")),
|
||||
bits(Rect::new(0.0, 0.0, 600.0, 800.0))
|
||||
);
|
||||
|
||||
// Overridden: TOPLEFT moves to (10, -5) and the authored BOTTOMRIGHT **stands**, so both
|
||||
// corners are still pinned and the size decides nothing. This is what gives a scroll child
|
||||
// authored as a corner pair its width; replacing the list left it with one corner.
|
||||
s.begin();
|
||||
s.set_external(SCREEN, screen_rect());
|
||||
s.set_frame_anchored(
|
||||
A,
|
||||
&input,
|
||||
Anchor::new(Point::TopLeft, SCREEN, Point::TopLeft, 10.0, -5.0),
|
||||
);
|
||||
s.solve();
|
||||
assert_eq!(
|
||||
bits(s.rect(A).expect("resolved")),
|
||||
bits(Rect::new(0.0, 10.0, 595.0, 800.0))
|
||||
);
|
||||
|
||||
// A point the frame does not author is added rather than substituted: with BOTTOMRIGHT
|
||||
// alone the override still completes the pair, to the same rect.
|
||||
let bottom_right_only = LayoutInput::sized(
|
||||
vec![Anchor::new(
|
||||
Point::BottomRight,
|
||||
SCREEN,
|
||||
Point::BottomRight,
|
||||
0.0,
|
||||
0.0,
|
||||
)],
|
||||
100.0,
|
||||
40.0,
|
||||
);
|
||||
s.begin();
|
||||
s.set_external(SCREEN, screen_rect());
|
||||
s.set_frame_anchored(
|
||||
A,
|
||||
&bottom_right_only,
|
||||
Anchor::new(Point::TopLeft, SCREEN, Point::TopLeft, 10.0, -5.0),
|
||||
);
|
||||
s.solve();
|
||||
assert_eq!(
|
||||
bits(s.rect(A).expect("resolved")),
|
||||
bits(Rect::new(0.0, 10.0, 595.0, 800.0))
|
||||
);
|
||||
|
||||
// And the next plain round carries none of it: the `Vec` is cleared, not appended to.
|
||||
s.begin();
|
||||
s.set_external(SCREEN, screen_rect());
|
||||
s.set_frame(A, &input);
|
||||
s.solve();
|
||||
assert_eq!(
|
||||
bits(s.rect(A).expect("resolved")),
|
||||
bits(Rect::new(0.0, 0.0, 600.0, 800.0))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::layout::{self, Anchor, LayoutInput, Point, Rect};
|
||||
use crate::layout::{self, Anchor, LayoutInput, Rect};
|
||||
use crate::order::ZTarget;
|
||||
use crate::widget::{FrameHandle, FrameKind, KindState, RegionHandle};
|
||||
use crate::widget::{FrameHandle, RegionHandle};
|
||||
|
||||
use super::backdrop;
|
||||
use super::types::RegionData;
|
||||
@@ -89,13 +89,9 @@ const NO_NODE: u64 = 0;
|
||||
|
||||
/// A frame's per-node hash; the full derive and the incremental pass must agree bit for bit.
|
||||
#[inline]
|
||||
fn frame_node_hash(input: &LayoutInput, over: Option<Anchor>) -> u64 {
|
||||
fn frame_node_hash(input: &LayoutInput) -> u64 {
|
||||
let mut node = InputFingerprint::default();
|
||||
node.input(input);
|
||||
match over {
|
||||
Some(a) => node.anchors(std::slice::from_ref(&a)),
|
||||
None => node.feed(u64::MAX),
|
||||
}
|
||||
node.finish()
|
||||
}
|
||||
|
||||
@@ -192,8 +188,8 @@ pub(crate) struct LayoutScope {
|
||||
dirty: Vec<bool>,
|
||||
/// The closure's worklist.
|
||||
stack: Vec<u32>,
|
||||
/// The cached frame roster, `(handle, id, ScrollFrame anchor override)`, kept across resolves.
|
||||
plan: Vec<(FrameHandle, u32, Option<Anchor>)>,
|
||||
/// The cached frame roster, `(handle, id)`, kept across resolves.
|
||||
plan: Vec<(FrameHandle, u32)>,
|
||||
/// The cached roster of live, anchored regions.
|
||||
regions: Vec<RegionRow>,
|
||||
/// An incremental pass's `(id, hash)` writes to [`Self::last`], applied only on convergence.
|
||||
@@ -271,8 +267,7 @@ impl LayoutScope {
|
||||
|
||||
/// Re-point node `id`'s outgoing edges after an anchor retarget. `old` and `new` are its full
|
||||
/// anchor-target lists, so duplicate anchors stay exact. `false` means a conservative touch:
|
||||
/// `id` has no node, or it is a ScrollFrame child, whose edges are its anchors *plus* its
|
||||
/// override's target and so are re-derived conservatively rather than patched here.
|
||||
/// `id` has no node.
|
||||
pub(crate) fn retarget(&mut self, id: u32, old: &[u32], new: &[u32]) -> bool {
|
||||
let Some(&n) = self.node_of.get(id as usize) else {
|
||||
return false;
|
||||
@@ -280,9 +275,6 @@ impl LayoutScope {
|
||||
if n == u32::MAX {
|
||||
return false;
|
||||
}
|
||||
if n & REGION_TAG == 0 && self.plan[n as usize].2.is_some() {
|
||||
return false; // a ScrollFrame override owns this frame's edges, not its anchors
|
||||
}
|
||||
for &t in old {
|
||||
self.unlink_edge(t, id);
|
||||
}
|
||||
@@ -400,8 +392,6 @@ struct PreambleProf {
|
||||
watched: u128,
|
||||
/// The ids rebuild and the scale and clamp sync into `layout_inputs`.
|
||||
ids: u128,
|
||||
/// The ScrollFrame override map.
|
||||
scroll: u128,
|
||||
/// `region_resolved`'s liveness retain.
|
||||
retain: u128,
|
||||
/// `LayoutScope::begin_full`.
|
||||
@@ -444,7 +434,6 @@ impl PreambleProf {
|
||||
let total = self.tooltip
|
||||
+ self.watched
|
||||
+ self.ids
|
||||
+ self.scroll
|
||||
+ self.retain
|
||||
+ self.begin
|
||||
+ self.fp_frames
|
||||
@@ -452,14 +441,13 @@ impl PreambleProf {
|
||||
+ self.seed;
|
||||
eprintln!(
|
||||
"[layout-pre] skips={} incr={} frames={frames} anchored={regions} total_us={total} \
|
||||
tooltip={} watched={} ids={} scroll={} retain={} begin={} \
|
||||
tooltip={} watched={} ids={} retain={} begin={} \
|
||||
fp_frames={} fp_regions={} seed={}",
|
||||
u8::from(skips),
|
||||
u8::from(self.incremental),
|
||||
self.tooltip,
|
||||
self.watched,
|
||||
self.ids,
|
||||
self.scroll,
|
||||
self.retain,
|
||||
self.begin,
|
||||
self.fp_frames,
|
||||
@@ -621,9 +609,9 @@ impl UiScript {
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
let (h, _, over) = scope.plan[n as usize];
|
||||
let (h, _) = scope.plan[n as usize];
|
||||
match layout_inputs.get(&h) {
|
||||
Some(i) => frame_node_hash(i, over),
|
||||
Some(i) => frame_node_hash(i),
|
||||
None => continue,
|
||||
}
|
||||
};
|
||||
@@ -765,24 +753,21 @@ impl UiScript {
|
||||
}
|
||||
}
|
||||
for &i in &seed_frames {
|
||||
let (h, id, _) = scope.plan[i as usize];
|
||||
let (h, id) = scope.plan[i as usize];
|
||||
if let Some(r) = resolved.get(&h) {
|
||||
solver.set_external(id, *r);
|
||||
}
|
||||
}
|
||||
for &i in &solve_frames {
|
||||
let (h, id, over) = scope.plan[i as usize];
|
||||
let (h, id) = scope.plan[i as usize];
|
||||
let Some(input) = layout_inputs.get(&h) else {
|
||||
continue;
|
||||
};
|
||||
match over {
|
||||
Some(a) => solver.set_frame_anchored(id, input, a),
|
||||
None => solver.set_frame(id, input),
|
||||
}
|
||||
solver.set_frame(id, input);
|
||||
}
|
||||
solver.solve();
|
||||
for &i in &solve_frames {
|
||||
let (h, id, _) = scope.plan[i as usize];
|
||||
let (h, id) = scope.plan[i as usize];
|
||||
match solver.rect(id) {
|
||||
Some(r) => {
|
||||
if resolved.get(&h) != Some(&r) {
|
||||
@@ -1011,40 +996,6 @@ impl UiScript {
|
||||
}
|
||||
pre.ids = pre.lap();
|
||||
|
||||
// A live ScrollFrame's live child has its TOPLEFT re-pointed, for this solve only, to the
|
||||
// ScrollFrame's TOPLEFT at the raw, unnegated `(horizontal, vertical)` offsets, as the
|
||||
// reference's re-anchor `0x787100` passes them to `SetPoint`: y up, so a positive vertical
|
||||
// lifts the child. One point, merged — the child's other authored points stand, which is
|
||||
// what `SetPoint` does and what gives a `TOPLEFT`/`TOPRIGHT`-authored child its width. Its
|
||||
// stored anchors and size are untouched, so `SetScrollChild(nil)` needs no restore.
|
||||
let mut scroll_child_anchor: HashMap<FrameHandle, Anchor> = HashMap::new();
|
||||
for &(h, id) in &ids {
|
||||
let Some(frame) = arena.frame(h) else {
|
||||
continue;
|
||||
};
|
||||
if frame.kind != FrameKind::ScrollFrame {
|
||||
continue;
|
||||
}
|
||||
let KindState::Scroll(state) = &frame.kind_state else {
|
||||
continue;
|
||||
};
|
||||
let Some(child) = state.child else { continue };
|
||||
if arena.frame(child).is_none() {
|
||||
continue; // a stale/destroyed child contributes no override
|
||||
}
|
||||
scroll_child_anchor.insert(
|
||||
child,
|
||||
Anchor::new(
|
||||
Point::TopLeft,
|
||||
id,
|
||||
Point::TopLeft,
|
||||
state.horizontal,
|
||||
state.vertical,
|
||||
),
|
||||
);
|
||||
}
|
||||
pre.scroll = pre.lap();
|
||||
|
||||
// The reference resolves one graph in which frames and regions anchor each other; ours
|
||||
// alternates a frame solve and a region sweep until a round changes no rect, compared
|
||||
// exactly. The round cap is the node count, so only a cycle exhausts it, warned like the
|
||||
@@ -1071,29 +1022,20 @@ impl UiScript {
|
||||
let Some(input) = layout_inputs.get(&h) else {
|
||||
continue;
|
||||
};
|
||||
let over = scroll_child_anchor.get(&h).copied();
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let i = scope.plan.len() as u32;
|
||||
fp.feed(u64::from(id));
|
||||
fp.input(input);
|
||||
match over {
|
||||
Some(a) => fp.anchors(std::slice::from_ref(&a)),
|
||||
None => fp.feed(u64::MAX),
|
||||
}
|
||||
if let Some(slot) = scope.now.get_mut(id as usize) {
|
||||
*slot = frame_node_hash(input, over);
|
||||
*slot = frame_node_hash(input);
|
||||
scope.node_of[id as usize] = i;
|
||||
}
|
||||
scope.plan.push((h, id, over));
|
||||
// A frame reads its anchor targets' rects — and a ScrollFrame child reads its
|
||||
// override's target as well, since `set_frame_anchored` merges the override into the
|
||||
// authored anchors rather than replacing them.
|
||||
scope.plan.push((h, id));
|
||||
// A frame reads its anchor targets' rects. A ScrollFrame child is no special case:
|
||||
// its re-anchor was written into those anchors at the setter.
|
||||
for a in &input.anchors {
|
||||
scope.edge(id, a.relative_to);
|
||||
}
|
||||
if let Some(a) = over {
|
||||
scope.edge(id, a.relative_to);
|
||||
}
|
||||
}
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
{
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::widget::{FrameHandle, FrameKind};
|
||||
pub(crate) mod anchor_args;
|
||||
mod events_regions;
|
||||
mod frame_state;
|
||||
mod layout_methods;
|
||||
pub(crate) mod layout_methods;
|
||||
pub(crate) use layout_methods::eff_scale;
|
||||
pub(crate) mod movable;
|
||||
pub(crate) mod toplevel;
|
||||
|
||||
@@ -288,8 +288,23 @@ 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);
|
||||
|
||||
write_anchor(
|
||||
&mut model,
|
||||
h,
|
||||
Anchor::new(point, rel_to_id, rel_point, x, y),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Merge one anchor into a frame's stored list, which is all `SetPoint` does: the entry at that
|
||||
/// same point is replaced, every other point kept, and the layout is told whether the change
|
||||
/// moved a target.
|
||||
///
|
||||
/// Shared with the ScrollFrame setters, because the reference's re-anchor of a scroll child
|
||||
/// (`0x787100`) **is** a `SetPoint` call — see `script/scrollframe.rs`.
|
||||
pub(crate) fn write_anchor(model: &mut Model, h: FrameHandle, new: Anchor) {
|
||||
let point = new.point;
|
||||
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
|
||||
@@ -299,27 +314,27 @@ fn set_point(lua: &Lua, this: &Table, args: &MultiValue) -> mlua::Result<()> {
|
||||
&& !input.anchors[..input.anchors.len() - 1]
|
||||
.iter()
|
||||
.any(|a| a.point == point);
|
||||
if !same_at_tail {
|
||||
// Target lists are collected only for a retarget: the value-only change is the per-frame
|
||||
// idiom (a dragged window) and must stay allocation-free.
|
||||
let structural = anchor_retarget_is_structural(&input.anchors, &new);
|
||||
let old_targets: Option<Vec<u32>> =
|
||||
structural.then(|| input.anchors.iter().map(|a| a.relative_to).collect());
|
||||
input.anchors.retain(|a| a.point != point);
|
||||
input.anchors.push(new);
|
||||
match old_targets {
|
||||
None => model.touch_layout_frame(h),
|
||||
Some(old) => {
|
||||
let new_targets: Vec<u32> = model.layout_inputs[&h]
|
||||
.anchors
|
||||
.iter()
|
||||
.map(|a| a.relative_to)
|
||||
.collect();
|
||||
model.touch_layout_retarget_frame(h, &old, &new_targets);
|
||||
}
|
||||
if same_at_tail {
|
||||
return;
|
||||
}
|
||||
// Target lists are collected only for a retarget: the value-only change is the per-frame
|
||||
// idiom (a dragged window) and must stay allocation-free.
|
||||
let structural = anchor_retarget_is_structural(&input.anchors, &new);
|
||||
let old_targets: Option<Vec<u32>> =
|
||||
structural.then(|| input.anchors.iter().map(|a| a.relative_to).collect());
|
||||
input.anchors.retain(|a| a.point != point);
|
||||
input.anchors.push(new);
|
||||
match old_targets {
|
||||
None => model.touch_layout_frame(h),
|
||||
Some(old) => {
|
||||
let new_targets: Vec<u32> = model.layout_inputs[&h]
|
||||
.anchors
|
||||
.iter()
|
||||
.map(|a| a.relative_to)
|
||||
.collect();
|
||||
model.touch_layout_retarget_frame(h, &old, &new_targets);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether this `SetPoint` changes the node's anchor targets, mirroring the setters' retain and
|
||||
|
||||
@@ -9,6 +9,7 @@ use mlua::{Lua, Table, Value};
|
||||
|
||||
use super::object::{decode_id, frame_handle_of, frame_wrapper};
|
||||
use super::{event, Model};
|
||||
use crate::layout::{Anchor, Point};
|
||||
use crate::widget::{FrameHandle, KindState, ScrollFrameState};
|
||||
|
||||
/// Registry key of the ScrollFrame method table.
|
||||
@@ -136,6 +137,43 @@ fn subtree_font_strings(model: &Model, f: FrameHandle) -> Vec<crate::widget::Reg
|
||||
out
|
||||
}
|
||||
|
||||
/// The reference's re-anchor of the scroll child, `0x787100`: its `TOPLEFT` to the ScrollFrame's
|
||||
/// `TOPLEFT` at the raw, unnegated `(horizontal, vertical)` — y up, so a positive vertical lifts
|
||||
/// the child and a positive horizontal moves it right.
|
||||
///
|
||||
/// **Written through to the child's own anchors, at the setter, on a change** — not applied as a
|
||||
/// per-frame layout override. That is what `0x787100` being a `SetPoint` call means, and what the
|
||||
/// three setters' own notes here already said; the timing is the whole behaviour. An addon that
|
||||
/// scrolls by moving the child *itself* has to be able to win, and AceGUI-3.0 is exactly that
|
||||
/// addon: its `SetScroll` is `content:ClearAllPoints()` then `content:SetPoint("TOPLEFT", 0,
|
||||
/// offset)` (`AceGUIContainer-ScrollFrame.lua:62-77`), and it never calls `SetVerticalScroll` at
|
||||
/// all. Re-applying the engine's anchor every frame put the content back at the top every frame,
|
||||
/// so an Ace options panel could be opened and read but not scrolled.
|
||||
///
|
||||
/// One point, merged, because `SetPoint` merges: a child authored `TOPLEFT`/`TOPRIGHT` keeps the
|
||||
/// `TOPRIGHT` that gives it its width.
|
||||
fn reanchor_child(lua: &Lua, this: &Table) -> mlua::Result<()> {
|
||||
let h = frame_handle_of(lua, this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let Some(frame) = model.arena.frame(h) else {
|
||||
return Ok(());
|
||||
};
|
||||
let KindState::Scroll(s) = &frame.kind_state else {
|
||||
return Ok(());
|
||||
};
|
||||
let (child, horizontal, vertical) = (s.child, s.horizontal, s.vertical);
|
||||
let Some(child) = child.filter(|&c| model.arena.frame(c).is_some()) else {
|
||||
return Ok(()); // no child, or one that has been destroyed: nothing to place
|
||||
};
|
||||
let id = model.frame_id(h);
|
||||
crate::script::object::layout_methods::write_anchor(
|
||||
&mut model,
|
||||
child,
|
||||
Anchor::new(Point::TopLeft, id, Point::TopLeft, horizontal, vertical),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let m = lua.create_table()?;
|
||||
|
||||
@@ -173,6 +211,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
if changed {
|
||||
// The child is an input of the layout resolve, so a real change dirties it.
|
||||
lua.app_data_mut::<Model>().expect("model").touch_layout();
|
||||
// And the new child is placed now, as `0x790fa0` does, not at the next resolve:
|
||||
// a caller sets its own points straight after this and must win.
|
||||
reanchor_child(lua, &this)?;
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
@@ -210,6 +251,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
lua.app_data_mut::<Model>().expect("model").touch_layout();
|
||||
reanchor_child(lua, &this)?;
|
||||
fire_scroll(lua, &this, "OnVerticalScroll", px)
|
||||
})?,
|
||||
)?;
|
||||
@@ -242,6 +284,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
lua.app_data_mut::<Model>().expect("model").touch_layout();
|
||||
reanchor_child(lua, &this)?;
|
||||
fire_scroll(lua, &this, "OnHorizontalScroll", px)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -131,7 +131,7 @@ fn get_scroll_child_roundtrips_wrapper_and_name_and_clears_on_nil() {
|
||||
/// A positive offset lifts the child; offsets past the range or below zero are stored and applied
|
||||
/// as given, since `0x786db0` never reads the range.
|
||||
#[test]
|
||||
fn scroll_child_top_tracks_vertical_scroll_unclamped_then_restores_on_clear() {
|
||||
fn scroll_child_top_tracks_vertical_scroll_unclamped_and_keeps_it_on_clear() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0); // screen rect: bottom 0, left 0, top 600, right 800
|
||||
|
||||
@@ -199,15 +199,97 @@ fn scroll_child_top_tracks_vertical_scroll_unclamped_then_restores_on_clear() {
|
||||
"a negative offset lowers the child: frame top 500 - 30"
|
||||
);
|
||||
|
||||
// Clearing the child stops the override, and its own untouched anchor applies again.
|
||||
// Clearing the child does **not** put its authored anchor back: the re-anchor was a
|
||||
// `SetPoint` on the child, so the authored TOPLEFT was replaced the first time the frame took
|
||||
// it as a scroll child, and there is nothing saved to restore. The child simply stops being
|
||||
// moved, keeping the last position the scroll put it in.
|
||||
s.run("SF:SetScrollChild(nil)").unwrap();
|
||||
s.resolve();
|
||||
let quads = s.extract();
|
||||
assert_eq!(
|
||||
marker_rect(&quads, "marker:child").map(|r| r.top),
|
||||
Some(580.0),
|
||||
"authored anchor survives: screen top 600 - 20"
|
||||
Some(470.0),
|
||||
"it stays where the last scroll left it; nothing restores the authored anchor"
|
||||
);
|
||||
|
||||
// And the child's own `SetPoint` wins from here, because the engine is no longer writing it.
|
||||
s.run(r#"Child:SetPoint("TOPLEFT", 20, -20)"#).unwrap();
|
||||
s.resolve();
|
||||
let quads = s.extract();
|
||||
assert_eq!(
|
||||
marker_rect(&quads, "marker:child").map(|r| r.top),
|
||||
Some(580.0),
|
||||
"screen top 600 - 20, set by the caller rather than inherited"
|
||||
);
|
||||
}
|
||||
|
||||
/// An addon that scrolls by moving the child **itself** has to win, and that is the whole reason
|
||||
/// the re-anchor is a write at the setter and not a per-frame override.
|
||||
///
|
||||
/// AceGUI-3.0 is that addon: its `SetScroll` is `content:ClearAllPoints()` followed by
|
||||
/// `content:SetPoint("TOPLEFT", 0, offset)` (`AceGUIContainer-ScrollFrame.lua:62-77`), and it
|
||||
/// never calls `SetVerticalScroll` at all. Re-applying the engine's anchor every frame put the
|
||||
/// content back at the top on every frame, so an Ace options panel opened, drew, and could not be
|
||||
/// scrolled — which is exactly what AI_VoiceOver's did.
|
||||
#[test]
|
||||
fn a_scroll_child_the_addon_re_anchors_itself_stays_where_the_addon_put_it() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
local frame = CreateFrame("ScrollFrame", "AceScroll")
|
||||
frame:SetPoint("TOPLEFT", 0, -100) -- screen top 600 -> 500
|
||||
frame:SetWidth(300) frame:SetHeight(200)
|
||||
|
||||
local content = CreateFrame("Frame", "AceContent", frame)
|
||||
content:SetPoint("TOPLEFT", 0, 0)
|
||||
content:SetPoint("TOPRIGHT", 0, 0)
|
||||
content:SetHeight(600)
|
||||
frame:SetScrollChild(content)
|
||||
|
||||
local marker = content:CreateTexture(nil, "ARTWORK")
|
||||
marker:SetTexture("marker:content")
|
||||
marker:SetAllPoints()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
marker_rect(&s.extract(), "marker:content").map(|r| r.top),
|
||||
Some(500.0),
|
||||
"SetScrollChild places it at the viewport's top, as the reference's does"
|
||||
);
|
||||
|
||||
// The library's own scroll: clear the points and re-anchor lower, with no scroll verb at all.
|
||||
s.run(
|
||||
r#"
|
||||
AceContent:ClearAllPoints()
|
||||
AceContent:SetPoint("TOPLEFT", 0, 120)
|
||||
AceContent:SetPoint("TOPRIGHT", 0, 120)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
marker_rect(&s.extract(), "marker:content").map(|r| r.top),
|
||||
Some(620.0),
|
||||
"the addon's own offset stands: nothing re-applies the engine's anchor behind it"
|
||||
);
|
||||
|
||||
// A real scroll verb still takes it back, which is the other half of the contract.
|
||||
s.run("AceScroll:SetVerticalScroll(40)").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
marker_rect(&s.extract(), "marker:content").map(|r| r.top),
|
||||
Some(540.0),
|
||||
"frame top 500 + 40, and the addon's TOPRIGHT still gives it its width"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return AceContent:GetRight()").unwrap(),
|
||||
300.0,
|
||||
"the TOPRIGHT the merge must not drop"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `0x786db0` acts only when the offset differs from the stored one; a value past the range is
|
||||
|
||||
Reference in New Issue
Block a user