From 3cc71f4c7d0d42488896601c4a02a2941fd66e9c Mon Sep 17 00:00:00 2001 From: grom Date: Sat, 17 Oct 2026 08:49:01 -0600 Subject: [PATCH] scrollframe: the child's re-anchor is a SetPoint at the setter, not a per-frame override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-ui/src/layout.rs | 106 ------------------ crates/benilla-ui/src/script/layout.rs | 92 +++------------ crates/benilla-ui/src/script/object.rs | 2 +- .../src/script/object/layout_methods.rs | 55 +++++---- crates/benilla-ui/src/script/scrollframe.rs | 43 +++++++ .../src/script/tests/scrollframe.rs | 90 ++++++++++++++- 6 files changed, 182 insertions(+), 206 deletions(-) diff --git a/crates/benilla-ui/src/layout.rs b/crates/benilla-ui/src/layout.rs index 30f296a4..2183555d 100644 --- a/crates/benilla-ui/src/layout.rs +++ b/crates/benilla-ui/src/layout.rs @@ -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)) - ); - } } diff --git a/crates/benilla-ui/src/script/layout.rs b/crates/benilla-ui/src/script/layout.rs index bc0f4ad8..12986224 100644 --- a/crates/benilla-ui/src/script/layout.rs +++ b/crates/benilla-ui/src/script/layout.rs @@ -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) -> 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, /// The closure's worklist. stack: Vec, - /// The cached frame roster, `(handle, id, ScrollFrame anchor override)`, kept across resolves. - plan: Vec<(FrameHandle, u32, Option)>, + /// The cached frame roster, `(handle, id)`, kept across resolves. + plan: Vec<(FrameHandle, u32)>, /// The cached roster of live, anchored regions. regions: Vec, /// 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 = 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)] { diff --git a/crates/benilla-ui/src/script/object.rs b/crates/benilla-ui/src/script/object.rs index a0c5ca95..68e0c3e6 100644 --- a/crates/benilla-ui/src/script/object.rs +++ b/crates/benilla-ui/src/script/object.rs @@ -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; diff --git a/crates/benilla-ui/src/script/object/layout_methods.rs b/crates/benilla-ui/src/script/object/layout_methods.rs index b646c2fe..f4df294b 100644 --- a/crates/benilla-ui/src/script/object/layout_methods.rs +++ b/crates/benilla-ui/src/script/object/layout_methods.rs @@ -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> = - 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 = 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> = + 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 = 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 diff --git a/crates/benilla-ui/src/script/scrollframe.rs b/crates/benilla-ui/src/script/scrollframe.rs index c4470781..6a408dae 100644 --- a/crates/benilla-ui/src/script/scrollframe.rs +++ b/crates/benilla-ui/src/script/scrollframe.rs @@ -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 mlua::Result<()> { + let h = frame_handle_of(lua, this)?; + let mut model = lua.app_data_mut::().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::().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::().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::().expect("model").touch_layout(); + reanchor_child(lua, &this)?; fire_scroll(lua, &this, "OnHorizontalScroll", px) })?, )?; diff --git a/crates/benilla-ui/src/script/tests/scrollframe.rs b/crates/benilla-ui/src/script/tests/scrollframe.rs index 97dbeb58..638d434b 100644 --- a/crates/benilla-ui/src/script/tests/scrollframe.rs +++ b/crates/benilla-ui/src/script/tests/scrollframe.rs @@ -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::("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