diff --git a/crates/benilla-ui/src/layout.rs b/crates/benilla-ui/src/layout.rs index 8c68f058..30f296a4 100644 --- a/crates/benilla-ui/src/layout.rs +++ b/crates/benilla-ui/src/layout.rs @@ -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)) ); } } diff --git a/crates/benilla-ui/src/script/layout.rs b/crates/benilla-ui/src/script/layout.rs index cd35cfff..bc0f4ad8 100644 --- a/crates/benilla-ui/src/script/layout.rs +++ b/crates/benilla-ui/src/script/layout.rs @@ -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 = 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)] diff --git a/crates/benilla-ui/src/script/tests/scrollframe.rs b/crates/benilla-ui/src/script/tests/scrollframe.rs index 1e4a912c..97dbeb58 100644 --- a/crates/benilla-ui/src/script/tests/scrollframe.rs +++ b/crates/benilla-ui/src/script/tests/scrollframe.rs @@ -21,6 +21,65 @@ fn marker_clip(quads: &[crate::script::ExtractedQuad], marker: &str) -> Option 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::( + 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::( + 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();