layout: a scroll child's re-anchor merges one point, as SetPoint does
A live ScrollFrame re-points its child's TOPLEFT to the frame's own TOPLEFT at the scroll offsets, which the reference does by calling SetPoint on the child (0x787100). This replaced the child's whole anchor list instead — and 1.12's SetPoint merges a point into the list, it does not clear it. The difference is not academic. AceGUI's scroll child is authored TOPLEFT *and* TOPRIGHT to its parent with an explicit height and no width (AceGUIContainer-ScrollFrame.lua:192-195), so the corner pair is where its width comes from. Dropping TOPRIGHT left it with one corner and no width, so it resolved to no rect at all — and with no rect for the child, nothing below it resolved either. That is what drew AI_VoiceOver's options window as chrome and tabs over an empty content area. The whole widget tree was there the entire time: probed through addon_harness, the panel builds TabGroup → ScrollFrame → four InlineGroups of CheckBoxes, Dropdowns, Sliders and Buttons, every one sized and IsVisible() true — and every one from the scroll child down answering GetLeft() == nil. After this, the same probe reads the scroll child at L=190 T=563 and its first checkbox at L=200 T=530 B=506, inside the viewport. The override's edges now include the authored anchors' targets as well, since the merge means the child reads both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -708,13 +708,28 @@ impl LayoutSolver {
|
||||
self.live.push(h);
|
||||
}
|
||||
|
||||
/// [`set_frame`](Self::set_frame) with `anchor` as the only anchor: a ScrollFrame child's.
|
||||
/// [`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.push(anchor);
|
||||
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);
|
||||
@@ -1285,10 +1300,11 @@ mod tests {
|
||||
assert!(s.unresolvable().is_empty());
|
||||
}
|
||||
|
||||
/// The override keeps the size and scale but replaces the anchors, and the reused anchors `Vec`
|
||||
/// must not leak the last round's.
|
||||
/// 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_replaces_only_the_anchors() {
|
||||
fn set_frame_anchored_merges_the_override_into_the_authored_points() {
|
||||
const A: Handle = 1;
|
||||
let mut s = LayoutSolver::new();
|
||||
let input = LayoutInput::sized(
|
||||
@@ -1310,7 +1326,9 @@ mod tests {
|
||||
bits(Rect::new(0.0, 0.0, 600.0, 800.0))
|
||||
);
|
||||
|
||||
// Overridden: one TOPLEFT pin at (10, -5), so the 100x40 size decides the other edges.
|
||||
// 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(
|
||||
@@ -1321,7 +1339,43 @@ mod tests {
|
||||
s.solve();
|
||||
assert_eq!(
|
||||
bits(s.rect(A).expect("resolved")),
|
||||
bits(Rect::new(555.0, 10.0, 595.0, 110.0))
|
||||
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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,7 +271,8 @@ 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 a ScrollFrame override, not its anchors, supplies its edges.
|
||||
/// `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.
|
||||
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;
|
||||
@@ -1010,10 +1011,12 @@ impl UiScript {
|
||||
}
|
||||
pre.ids = pre.lap();
|
||||
|
||||
// A live ScrollFrame's live child is anchored, for this solve only, TOPLEFT to its 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. Its
|
||||
// authored anchors and size are untouched, so `SetScrollChild(nil)` needs no restore.
|
||||
// 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 {
|
||||
@@ -1082,15 +1085,14 @@ impl UiScript {
|
||||
scope.node_of[id as usize] = i;
|
||||
}
|
||||
scope.plan.push((h, id, over));
|
||||
// A frame reads its anchor targets' rects, or only its ScrollFrame override's target,
|
||||
// as the round loop's `set_frame_anchored` does.
|
||||
match over {
|
||||
Some(a) => scope.edge(id, a.relative_to),
|
||||
None => {
|
||||
for a in &input.anchors {
|
||||
scope.edge(id, a.relative_to);
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
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)]
|
||||
|
||||
@@ -21,6 +21,65 @@ fn marker_clip(quads: &[crate::script::ExtractedQuad], marker: &str) -> Option<O
|
||||
})
|
||||
}
|
||||
|
||||
/// AceGUI's scroll child in its own shape: anchored `TOPLEFT` **and** `TOPRIGHT` to its parent
|
||||
/// with an explicit height and no width, so the corner pair is where its width comes from
|
||||
/// (`AceGUIContainer-ScrollFrame.lua:192-195`). The re-anchor re-points `TOPLEFT` only, so the
|
||||
/// pair survives and the child has a rect — and so does everything inside it.
|
||||
///
|
||||
/// Replacing the whole anchor list instead left the child with one corner and **no rect at all**,
|
||||
/// and with it no rect for any descendant: that is what drew AI_VoiceOver's options window as
|
||||
/// chrome and tabs over an empty content area, with the whole widget tree built, sized and shown
|
||||
/// but unresolved from the scroll child down.
|
||||
#[test]
|
||||
fn a_scroll_child_anchored_as_a_corner_pair_keeps_its_width() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
host = CreateFrame("Frame", "Host")
|
||||
Host:SetPoint("TOPLEFT", 100, -100) -- screen top 600 -> 500
|
||||
Host:SetWidth(400) Host:SetHeight(300)
|
||||
|
||||
sf = CreateFrame("ScrollFrame", "AceSF", Host)
|
||||
AceSF:SetPoint("TOPLEFT", 0, 0)
|
||||
AceSF:SetPoint("BOTTOMRIGHT", 0, 0)
|
||||
|
||||
kid = CreateFrame("Frame", "AceKid", AceSF)
|
||||
AceKid:SetPoint("TOPLEFT", 0, 0)
|
||||
AceKid:SetPoint("TOPRIGHT", 0, 0)
|
||||
AceKid:SetHeight(400)
|
||||
|
||||
inner = CreateFrame("Frame", "AceInner", AceKid)
|
||||
AceInner:SetPoint("TOPLEFT", 0, 0)
|
||||
AceInner:SetWidth(170) AceInner:SetHeight(24)
|
||||
|
||||
AceSF:SetScrollChild(AceKid)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
r#"return string.format("%.0f %.0f %.0f %.0f",
|
||||
AceKid:GetLeft(), AceKid:GetRight(), AceKid:GetTop(), AceKid:GetBottom())"#
|
||||
)
|
||||
.unwrap(),
|
||||
"100 500 500 100",
|
||||
"the TOPRIGHT the re-anchor must not drop is the child's whole width"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
r#"return string.format("%.0f %.0f",
|
||||
AceInner:GetLeft(), AceInner:GetTop())"#
|
||||
)
|
||||
.unwrap(),
|
||||
"100 500",
|
||||
"and the widgets inside resolve, which is the symptom this was found through"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrollframe_methods_exist_only_on_scrollframes() {
|
||||
let s = script();
|
||||
|
||||
Reference in New Issue
Block a user