Message fades and scroll ranges run in the walk; OnUpdateModel gates (#913)
Hidden chat frames kept fading their lines, a scroll frame recomputed its range on every read and fired `OnScrollRangeChanged` on every `UpdateScrollChildRect`, hidden or not, and `OnUpdateModel` fired for panes with no size or off screen. In the reference, each of these runs in the frame's own update slot, only while shown: message frames trim and fade there; a scroll frame recomputes a range its writers marked, stores it and notifies only on a real move. The model paint fires only for a resolved rect whose screen-clamped viewport has size. Message frames and scroll frames are now walk kinds, the range is stored and marked, and the paint applies the rect and viewport gates. benilla's options scroll measures its body's own overhang. Tests pin the hidden fades, the stored range and its notify, and the paint gates. Fixes #182 Fixes #175 Fixes #892 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
016f08b56f
commit
55729baff9
@@ -263,6 +263,14 @@
|
||||
return bottom
|
||||
end
|
||||
|
||||
-- The page's range is the body's overhang, the content Fit measures: the engine's range
|
||||
-- also counts the Keybindings list's faux scroll child, which is sized to every row.
|
||||
function BenillaOptionsScroll_Range(sf)
|
||||
local range = BenillaOptionsFrameContainerBody:GetHeight() - sf:GetHeight()
|
||||
if range > 0 then return range end
|
||||
return 0
|
||||
end
|
||||
|
||||
-- From OnUpdate, off the previous frame's rects; SetWidth and SetHeight invalidate layout.
|
||||
function BenillaOptionsScroll_Fit()
|
||||
local sf = BenillaOptionsFrameContainerScroll
|
||||
@@ -279,6 +287,9 @@
|
||||
if math.abs(body:GetHeight() - h) >= 0.5 then
|
||||
body:SetHeight(h)
|
||||
sf:UpdateScrollChildRect()
|
||||
-- The bar follows the body, which the range notify misses when the engine's range,
|
||||
-- a whole-subtree union, does not move.
|
||||
BenillaScroll_SyncBar(sf)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1498,7 +1509,10 @@
|
||||
</Anchors>
|
||||
<Scripts>
|
||||
<!-- The loader fires OnLoad bottom-up, so the body already exists. -->
|
||||
<OnLoad>this:SetScrollChild(BenillaOptionsFrameContainerBody)</OnLoad>
|
||||
<OnLoad>
|
||||
this:SetScrollChild(BenillaOptionsFrameContainerBody)
|
||||
this.contentRange = BenillaOptionsScroll_Range
|
||||
</OnLoad>
|
||||
<OnUpdate>BenillaOptionsScroll_Fit()</OnUpdate>
|
||||
<!-- An armed Keybindings capsule binds the wheel, search rows included. -->
|
||||
<OnMouseWheel>
|
||||
|
||||
@@ -96,11 +96,18 @@
|
||||
BenillaScrollBar_UpdateArrows(bar)
|
||||
end
|
||||
|
||||
-- The range the bar follows: the engine's, the reference's union of the child's whole
|
||||
-- subtree, unless the frame measures its own content (`sf.contentRange`).
|
||||
function BenillaScroll_Range(sf)
|
||||
if sf.contentRange then return sf.contentRange(sf) end
|
||||
return sf:GetVerticalScrollRange()
|
||||
end
|
||||
|
||||
-- Hides the bar when the content fits (reference: ScrollFrame_OnScrollRangeChanged).
|
||||
function BenillaScroll_SyncBar(sf)
|
||||
local bar = getglobal(sf:GetName() .. "Bar")
|
||||
if not bar then return end
|
||||
local range = sf:GetVerticalScrollRange()
|
||||
local range = BenillaScroll_Range(sf)
|
||||
bar:SetValueStep(BENILLA_SCROLL_STEP)
|
||||
bar:SetMinMaxValues(0, range)
|
||||
bar:SetValue(sf:GetVerticalScroll())
|
||||
@@ -111,7 +118,7 @@
|
||||
-- Clamped here: SetVerticalScroll stores any value, as the reference's does.
|
||||
function BenillaScroll_Step(sf, delta)
|
||||
local target = sf:GetVerticalScroll() + delta
|
||||
local range = sf:GetVerticalScrollRange()
|
||||
local range = BenillaScroll_Range(sf)
|
||||
if target > range then target = range end
|
||||
if target < 0 then target = 0 end
|
||||
sf:SetVerticalScroll(target)
|
||||
|
||||
@@ -337,6 +337,7 @@ fn the_reference_on_vertical_scroll_path_runs_once_a_scroll_child_exists() {
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s.run("AddonScroll:UpdateScrollChildRect()").unwrap();
|
||||
s.tick(0.016);
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return AddonScroll:GetVerticalScrollRange()")
|
||||
.unwrap(),
|
||||
|
||||
@@ -749,8 +749,11 @@ fn the_who_list_reaches_its_last_row_at_the_bars_end() {
|
||||
.eval::<(f64, f64)>("return WhoListScrollFrameScrollBar:GetMinMaxValues()")
|
||||
.unwrap();
|
||||
assert_eq!(bar_max, 512.0, "(49 − 17) × 16");
|
||||
// The overflow as geometry: the stored range is not re-measured for a child's resize.
|
||||
let overflow = s
|
||||
.eval::<f64>("return WhoListScrollFrame:GetVerticalScrollRange()")
|
||||
.eval::<f64>(
|
||||
"return WhoListScrollFrameScrollChildFrame:GetHeight() - WhoListScrollFrame:GetHeight()",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
overflow, 497.0,
|
||||
|
||||
@@ -675,9 +675,10 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
|
||||
s.tick(0.016);
|
||||
s.resolve();
|
||||
}
|
||||
// Only the list's bar shows in the shared gutter. The outer page's range is not 0, as the
|
||||
// reference would measure it: the stock faux list sizes its scroll child to
|
||||
// `numItems * valueStep`, and the range unions the child's whole subtree (`0x786f80`).
|
||||
// Only the list's bar shows in the shared gutter. The outer page's engine range is not 0, as
|
||||
// the reference would measure it: the stock faux list sizes its scroll child to
|
||||
// `numItems * valueStep`, and the range unions the child's whole subtree (`0x786f80`). The
|
||||
// layer's bar follows the body's own overhang, which is 0.
|
||||
assert!(
|
||||
!s.eval::<bool>("return BenillaOptionsFrameContainerScrollBar:IsVisible()")
|
||||
.unwrap(),
|
||||
|
||||
@@ -2114,7 +2114,8 @@ fn hover_label(s: &mut UiScript, frame: &str) {
|
||||
|
||||
/// Scroll `frame` into the page's rect: past the fold a row is clipped and cannot be hovered.
|
||||
fn scroll_into_view(s: &mut UiScript, frame: &str) {
|
||||
s.resolve();
|
||||
// The page's fit and the range it marks land in the walk.
|
||||
settle(s);
|
||||
s.run(&format!(
|
||||
"local sf = BenillaOptionsFrameContainerScroll \
|
||||
local range = sf:GetVerticalScrollRange() \
|
||||
|
||||
@@ -301,6 +301,7 @@ fn a_scroll_frame_from_the_template_wires_its_bar_two_parents_deep() {
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s.run("MyScroll:UpdateScrollChildRect()").unwrap();
|
||||
s.tick(0.016);
|
||||
assert!(
|
||||
s.errors().is_empty(),
|
||||
"ScrollFrame_OnScrollRangeChanged ran clean: {:?}",
|
||||
|
||||
@@ -1052,6 +1052,7 @@ fn wheel_over_the_detail_pane_changes_vertical_scroll() {
|
||||
s.resolve();
|
||||
answer_measures(&mut s);
|
||||
s.resolve(); // the measures reach the scroll range and the bar
|
||||
s.tick(0.016); // the marked range is recomputed in the frame's update
|
||||
|
||||
s.resolve();
|
||||
let quads = s.extract();
|
||||
@@ -1109,7 +1110,7 @@ fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not
|
||||
s.resolve();
|
||||
answer_measures(&mut s);
|
||||
s.resolve(); // the measures reach the scroll range and the bar
|
||||
s.resolve(); // the range reads resolved rects
|
||||
s.tick(0.016); // the marked range is recomputed in the frame's update
|
||||
|
||||
// Scroll through the bar, as the wheel does: a reselect snaps back with the bar's
|
||||
// `SetValue(0)`, which changes nothing on a bar already at 0.
|
||||
|
||||
@@ -539,8 +539,12 @@ fn the_list_reaches_its_last_row_at_the_bars_end() {
|
||||
.eval::<(f64, f64)>("return SkillListScrollFrameScrollBar:GetMinMaxValues()")
|
||||
.unwrap();
|
||||
assert_eq!(bar_max, f64::from((n as i32 - 12) * 15));
|
||||
// The overflow as geometry: the stored range is not re-measured for a child's resize.
|
||||
let overflow = s
|
||||
.eval::<f64>("return SkillListScrollFrame:GetVerticalScrollRange()")
|
||||
.eval::<f64>(
|
||||
"return SkillListScrollFrameScrollChildFrame:GetHeight() \
|
||||
- SkillListScrollFrame:GetHeight()",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(overflow, f64::from(n as i32 * 15 - 220));
|
||||
assert!(
|
||||
|
||||
@@ -318,8 +318,10 @@ fn a_wheel_spin_over_the_grid_scrolls_the_tree() {
|
||||
script.set_unit("player", Some(probe_player()));
|
||||
script.set_talents(tall_fixture());
|
||||
script.run("ToggleTalentFrame()").expect("toggle");
|
||||
// Rects must be resolved before the wheel's hit-test can land on the grid.
|
||||
// Rects must be resolved before the wheel's hit-test can land on the grid, and the range
|
||||
// `UpdateScrollChildRect` marked is measured in the frame's update.
|
||||
script.resolve();
|
||||
script.tick(0.016);
|
||||
let (x, y): (f32, f32) = script
|
||||
.eval("return TalentFrameTalent1:GetCenter()")
|
||||
.expect("talent 1 center");
|
||||
|
||||
@@ -12,7 +12,7 @@ impl Loader<'_> {
|
||||
/// `<ScrollFrame>`'s `<ScrollChild>` (`LoadXML 0x786bc0`): after the base load (`0x769820`) it
|
||||
/// builds the node's one child through `Instantiate 0x6ee280` (`0x786bf4`), a whole frame
|
||||
/// lifecycle with the child's own `<Frames>` and OnLoad, and only then stores it with
|
||||
/// `SetScrollChild` (`0x786ce0`), which gives the frame its scroll range. So the child's OnLoad
|
||||
/// `SetScrollChild` (`0x786ce0`), which marks the frame's scroll range. So the child's OnLoad
|
||||
/// sees no scroll child yet. A template's `<ScrollChild>` was built by its own `LoadXML` level
|
||||
/// (`0x76985c`), ahead of the instance's, which therefore wins. An empty one errors in the
|
||||
/// reference (`0x786c0c`). Other frame kinds do not read the element.
|
||||
|
||||
@@ -493,7 +493,7 @@ fn queue_size_changes(
|
||||
|
||||
impl UiScript {
|
||||
/// [`UiScript::resolve`]'s body on a bare `Model`, so a Lua binding can force a fresh resolve,
|
||||
/// as `UpdateScrollChildRect` must report a range reflecting a same-tick `SetHeight`.
|
||||
/// as a scroll frame's range recompute must reflect a same-tick `SetHeight`.
|
||||
pub(super) fn resolve_layout(model: &mut Model) {
|
||||
let solves = model.layout_solves;
|
||||
Self::resolve_layout_inner(model);
|
||||
|
||||
@@ -25,6 +25,24 @@ mod scrolling;
|
||||
pub(super) use plain::REG_MESSAGEFRAME_METHODS;
|
||||
pub(super) use scrolling::REG_SCROLLINGMESSAGEFRAME_METHODS;
|
||||
|
||||
/// A message frame's update after its `OnUpdate`, in its walk slot, so only while shown: a hidden
|
||||
/// frame's lines keep their time. The plain class (`0x786200`) first drains its queued messages
|
||||
/// against the lines that fit (`0x786247`), which [`MessageFrameState::trim_to_viewport`] stands
|
||||
/// for, then fades (`0x7862b0`); the scrolling class (`0x788460`) only fades.
|
||||
///
|
||||
/// [`MessageFrameState::trim_to_viewport`]: crate::widget::MessageFrameState::trim_to_viewport
|
||||
pub(super) fn update(model: &mut Model, h: FrameHandle, elapsed: f32) {
|
||||
let rows = UiScript::message_viewport_rows(model, h);
|
||||
match model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
|
||||
Some(KindState::Message(mf)) => {
|
||||
mf.trim_to_viewport(rows);
|
||||
mf.tick(elapsed);
|
||||
}
|
||||
Some(KindState::ScrollingMessage(smf)) => smf.tick(elapsed),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// `AddMessage`'s text; `None` means the whole call does nothing, no line and no error. Both
|
||||
/// bindings (`0x795590`, `0x792900`) jump to their epilogue (`0x79582b`, `0x792b81`) when
|
||||
/// `lua_isstring` (`0x6f3510`) fails, `lua_tostring` (`0x6f3690`) gives NULL or the string is empty
|
||||
|
||||
@@ -612,7 +612,7 @@ impl crate::script::UiScript {
|
||||
let mut model = self.model_mut();
|
||||
let panes: Vec<FrameHandle> = model
|
||||
.arena
|
||||
.ticked_kinds()
|
||||
.model_kinds()
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&h| {
|
||||
@@ -643,7 +643,7 @@ impl crate::script::UiScript {
|
||||
let model = self.model_ref();
|
||||
model
|
||||
.arena
|
||||
.ticked_kinds()
|
||||
.model_kinds()
|
||||
.iter()
|
||||
.filter_map(|&h| {
|
||||
let f = model.arena.frame(h)?;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The `ScrollFrame` methods (`CSimpleScrollFrame`): the scroll child, the two offsets, the two
|
||||
//! ranges and `UpdateScrollChildRect`, over [`ScrollFrameState`]; the layout's `resolve`,
|
||||
//! `extract` and `hit_test` place and clip the child. The axes are one mechanism: `0x786d30`
|
||||
//! ranges and `UpdateScrollChildRect`, over [`ScrollFrameState`], and the frame's own update
|
||||
//! ([`update`]), which keeps the ranges; the layout's `resolve`, `extract` and `hit_test` place
|
||||
//! and clip the child. The axes are one mechanism: `0x786d30`
|
||||
//! (horizontal) is a byte-clone of `0x786db0` (vertical) on `[+0x324]`/`[+0x32c]` for
|
||||
//! `[+0x328]`/`[+0x334]`, and `0x786e30` measures both ranges in one pass. The methods sit in
|
||||
//! their own table, so on every other frame kind `SetScrollChild` is nil, as in the client.
|
||||
@@ -42,9 +43,8 @@ enum Axis {
|
||||
Vertical,
|
||||
}
|
||||
|
||||
/// `Get{Vertical,Horizontal}ScrollRange()`: `max(0, content − frame)` from the resolved rects,
|
||||
/// computed on every call, where the reference caches both (`[+0x31c]`, `[+0x320]`) and recomputes
|
||||
/// them only once marked dirty. The content is the scroll child's whole subtree, not its own rect
|
||||
/// One range as the recompute `0x786e30` measures it: `max(0, content − frame)` from the resolved
|
||||
/// rects. The content is the scroll child's whole subtree, not its own rect
|
||||
/// (`0x786e30` calls the recursive `0x786f80`): stock `ItemTextPageScrollChild` is 10×10 around a
|
||||
/// 270×304 SimpleHTML (`ItemTextFrame.xml:198`). Each extent is divided by its frame's effective
|
||||
/// scale, since the offset is in local units; a range in screen px under-scrolls a scaled frame.
|
||||
@@ -165,9 +165,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
// Every call marks the range (`0x786d16`), the same child again included.
|
||||
let changed = with_scroll(lua, &this, |s| {
|
||||
let changed = s.child != new_child;
|
||||
s.child = new_child;
|
||||
s.range_dirty = true;
|
||||
changed
|
||||
})?;
|
||||
if changed {
|
||||
@@ -217,13 +219,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
"GetVerticalScroll",
|
||||
lua.create_function(|lua, this: Table| with_scroll(lua, &this, |s| s.vertical))?,
|
||||
)?;
|
||||
// The stored range (`0x7917d0` reads `[+0x320]` at `0x791873`), stale while the frame is
|
||||
// hidden or its range unmarked.
|
||||
m.set(
|
||||
"GetVerticalScrollRange",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(scroll_range(&model, h, Axis::Vertical))
|
||||
})?,
|
||||
lua.create_function(|lua, this: Table| with_scroll(lua, &this, |s| s.range[1]))?,
|
||||
)?;
|
||||
|
||||
// ── The horizontal axis ──
|
||||
@@ -251,63 +251,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
m.set(
|
||||
"GetHorizontalScrollRange",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(scroll_range(&model, h, Axis::Horizontal))
|
||||
})?,
|
||||
lua.create_function(|lua, this: Table| with_scroll(lua, &this, |s| s.range[0]))?,
|
||||
)?;
|
||||
// UpdateScrollChildRect(): recomputes both ranges and fires `OnScrollRangeChanged` at once,
|
||||
// where the reference only marks them dirty (`0x791943`) for its next pump, which fires on a
|
||||
// change. A script resize reaches the resolved rects only at the app's next resolve, so this
|
||||
// resolves first.
|
||||
// UpdateScrollChildRect() (`0x7918b0`) marks the range and does nothing else (`0x791943`):
|
||||
// the frame's next update while shown recomputes it.
|
||||
m.set(
|
||||
"UpdateScrollChildRect",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
// The reference measures text synchronously and ours lands a frame later, so the
|
||||
// child's FontStrings are measured first: `QuestLog_UpdateQuestDetails` calls this
|
||||
// right after its SetTexts (`QuestLogFrame.lua:458`).
|
||||
let strings: Vec<crate::widget::RegionHandle> = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
match model.arena.frame(h).map(|f| &f.kind_state) {
|
||||
Some(KindState::Scroll(sf)) => sf
|
||||
.child
|
||||
.map(|c| subtree_font_strings(&model, c))
|
||||
.unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
for rh in strings {
|
||||
super::measure::ensure_measured(lua, rh);
|
||||
}
|
||||
let (id, x_range, y_range) = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
super::UiScript::resolve_layout(&mut model);
|
||||
let x_range = scroll_range(&model, h, Axis::Horizontal);
|
||||
let y_range = scroll_range(&model, h, Axis::Vertical);
|
||||
(model.frame_id(h), x_range, y_range)
|
||||
};
|
||||
// The resolve can move sizes, and the reference fires `OnSizeChanged` during layout, so
|
||||
// those fire before the range notify.
|
||||
event::fire_size_changes(lua);
|
||||
// `OnScrollRangeChanged(self, xRange, yRange)`: horizontal first (`0x786eff`), which is
|
||||
// why stock passes `arg2` on as the vertical range (`UIPanelTemplates.xml:188`).
|
||||
if let Err(e) = event::fire_widget_handler(
|
||||
lua,
|
||||
id,
|
||||
"OnScrollRangeChanged",
|
||||
vec![
|
||||
Value::Number(f64::from(x_range)),
|
||||
Value::Number(f64::from(y_range)),
|
||||
],
|
||||
) {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.record_lua_error(&e);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
lua.create_function(|lua, this: Table| with_scroll(lua, &this, |s| s.range_dirty = true))?,
|
||||
)?;
|
||||
|
||||
lua.set_named_registry_value(REG_SCROLLFRAME_METHODS, m)?;
|
||||
@@ -331,3 +281,120 @@ fn fire_scroll(lua: &Lua, this: &Table, script: &str, value: f32) -> mlua::Resul
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The frame's update (`0x787140`, the walk's slot after its `OnUpdate`), so only while shown. A
|
||||
/// resize since the last one marks the range, as the size slot `0x7871f0` does (`0x787205`). A
|
||||
/// marked range is recomputed (`0x787152`–`0x7871a0`, `0x786e30`) unless the frame has no rect
|
||||
/// (`0x787186`) or no scroll child (`0x786e42`), and the mark is cleared either way, after the
|
||||
/// notify (`0x7871a5`). The recompute stores both ranges (`0x786eaa`, `0x786ed1`) and fires
|
||||
/// `OnScrollRangeChanged(self, xRange, yRange)` when either moved by 2⁻²² (`[0x8029d4]`) or more
|
||||
/// in the reference's units (the screen diagonal as 1) (`0x786ed7`–`0x786efd`), which is that times the
|
||||
/// layout unit in ours.
|
||||
///
|
||||
/// The reference drains its layout before the walk (`0x7656ba`) and measures text at once; ours
|
||||
/// lands both a frame later, so a marked frame measures its child's FontStrings and resolves first
|
||||
/// (`QuestLog_UpdateQuestDetails` marks right after its `SetText`s, `QuestLogFrame.lua:458`). An
|
||||
/// unmarked frame reads its size as the last resolve left it, so a resize not yet resolved marks
|
||||
/// it at the update after that resolve.
|
||||
pub(super) fn update(lua: &Lua, h: FrameHandle) {
|
||||
let marked = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let rect = model.resolved.get(&h).copied();
|
||||
let Some(s) = scroll_state(&mut model, h) else {
|
||||
return;
|
||||
};
|
||||
see_size(s, rect);
|
||||
s.range_dirty
|
||||
};
|
||||
if !marked {
|
||||
return;
|
||||
}
|
||||
let strings = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
match model.arena.frame(h).map(|f| &f.kind_state) {
|
||||
Some(KindState::Scroll(s)) => s
|
||||
.child
|
||||
.map(|c| subtree_font_strings(&model, c))
|
||||
.unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
for rh in strings {
|
||||
super::measure::ensure_measured(lua, rh);
|
||||
}
|
||||
super::UiScript::resolve_layout(&mut lua.app_data_mut::<Model>().expect("model app_data"));
|
||||
// The reference fires `OnSizeChanged` from its layout drain, ahead of the walk.
|
||||
event::fire_size_changes(lua);
|
||||
let notify = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let rect = model.resolved.get(&h).copied();
|
||||
let has_child = matches!(
|
||||
model.arena.frame(h).map(|f| &f.kind_state),
|
||||
Some(KindState::Scroll(s)) if s.child.is_some()
|
||||
);
|
||||
let measured = (rect.is_some() && has_child).then(|| {
|
||||
[
|
||||
scroll_range(&model, h, Axis::Horizontal),
|
||||
scroll_range(&model, h, Axis::Vertical),
|
||||
]
|
||||
});
|
||||
let eps = crate::layout::SIZE_EPS * f64::from(model.layout_unit());
|
||||
let Some(s) = scroll_state(&mut model, h) else {
|
||||
return;
|
||||
};
|
||||
// The resolve may have moved the frame: that is the size this update has seen.
|
||||
see_size(s, rect);
|
||||
let moved = measured.filter(|new| {
|
||||
let old = std::mem::replace(&mut s.range, *new);
|
||||
(0..2).any(|i| (f64::from(new[i]) - f64::from(old[i])).abs() >= eps)
|
||||
});
|
||||
if moved.is_none() {
|
||||
s.range_dirty = false;
|
||||
}
|
||||
moved.map(|range| (model.frame_id(h), range))
|
||||
};
|
||||
let Some((id, [x_range, y_range])) = notify else {
|
||||
return;
|
||||
};
|
||||
// Horizontal first (`0x786eff`), which is why stock passes `arg2` on as the vertical range
|
||||
// (`UIPanelTemplates.xml:188`).
|
||||
if let Err(e) = event::fire_widget_handler(
|
||||
lua,
|
||||
id,
|
||||
"OnScrollRangeChanged",
|
||||
vec![
|
||||
Value::Number(f64::from(x_range)),
|
||||
Value::Number(f64::from(y_range)),
|
||||
],
|
||||
) {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.record_lua_error(&e);
|
||||
}
|
||||
// The mark clears after the notify, so one the handler set is dropped.
|
||||
if let Some(s) = scroll_state(&mut lua.app_data_mut::<Model>().expect("model app_data"), h) {
|
||||
s.range_dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame `h`'s ScrollFrame state, if it is a live ScrollFrame.
|
||||
fn scroll_state(model: &mut Model, h: FrameHandle) -> Option<&mut ScrollFrameState> {
|
||||
match model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
|
||||
Some(KindState::Scroll(s)) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Note the frame's resolved size; a change since the last note marks the range, by the width or
|
||||
/// height move that fires `OnSizeChanged` ([`crate::layout::size_changed`]). No rect, no note.
|
||||
fn see_size(s: &mut ScrollFrameState, rect: Option<crate::layout::Rect>) {
|
||||
let Some(r) = rect else { return };
|
||||
let size = [r.width(), r.height()];
|
||||
let moved = |i: usize| {
|
||||
(f64::from(size[i]) - f64::from(s.seen_size[i])).abs() >= crate::layout::SIZE_EPS
|
||||
};
|
||||
if moved(0) || moved(1) {
|
||||
s.range_dirty = true;
|
||||
}
|
||||
s.seen_size = size;
|
||||
}
|
||||
|
||||
@@ -338,6 +338,8 @@ fn a_scroll_child_element_gives_the_frame_a_real_scroll_range() {
|
||||
</Ui>"#,
|
||||
);
|
||||
s.resolve();
|
||||
// `SetScrollChild` marked the range; the frame's update measures it.
|
||||
s.tick(0.016);
|
||||
|
||||
// `$parent` names the child against the scroll frame (`0x76c5b0`).
|
||||
assert!(s.eval::<bool>("return RollerChild ~= nil").unwrap());
|
||||
@@ -387,6 +389,7 @@ fn the_scroll_range_is_the_childs_whole_subtree() {
|
||||
</Ui>"#,
|
||||
);
|
||||
s.resolve();
|
||||
s.tick(0.016);
|
||||
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return Reader:GetVerticalScrollRange()")
|
||||
@@ -401,8 +404,9 @@ fn the_scroll_range_is_the_childs_whole_subtree() {
|
||||
);
|
||||
|
||||
// A hidden branch adds nothing: the reference guards both list walks on visibility.
|
||||
s.run("Page:Hide()").unwrap();
|
||||
s.run("Page:Hide() Reader:UpdateScrollChildRect()").unwrap();
|
||||
s.resolve();
|
||||
s.tick(0.016);
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return Reader:GetVerticalScrollRange()")
|
||||
.unwrap(),
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! The engine's own per-kind updates, each run in its frame's slot of the update walk (`0x765650`,
|
||||
//! `vtable+0x38` at `0x765710`) right after that frame's `OnUpdate`: GameTooltip's fade
|
||||
//! (`0x530b20`) and a Model pane's clock and completion (`0x76d7f0`). `OnUpdateModel` fires later,
|
||||
//! from the draw pass (`0x7657d0`), in draw order.
|
||||
//! (`0x530b20`), a Model pane's clock and completion (`0x76d7f0`) and the message frames' line
|
||||
//! fades (`0x788460`, `0x786200`). `OnUpdateModel` fires later, from the draw pass (`0x7657d0`),
|
||||
//! in draw order.
|
||||
|
||||
use crate::script::UiScript;
|
||||
use crate::script::{Model, UiScript};
|
||||
use crate::widget::{ModelFileFacts, SequenceFacts};
|
||||
|
||||
use super::common::script;
|
||||
@@ -95,6 +96,7 @@ fn on_anim_finished_fires_in_the_panes_walk_slot() {
|
||||
r#"
|
||||
LOG = {{}}
|
||||
local p = CreateFrame("Model", "SlotPane")
|
||||
p:SetPoint("CENTER", 0, 0); p:SetWidth(36); p:SetHeight(36)
|
||||
p:SetFrameStrata("LOW")
|
||||
p:SetModel("{}")
|
||||
p:SetSequence(1)
|
||||
@@ -212,6 +214,7 @@ fn on_update_model_fires_in_draw_order() {
|
||||
LOG = {{}}
|
||||
function Pane(name, tag, strata, level)
|
||||
local p = CreateFrame("Model", name)
|
||||
p:SetPoint("CENTER", 0, 0); p:SetWidth(36); p:SetHeight(36)
|
||||
p:SetFrameStrata(strata)
|
||||
if level then p:SetFrameLevel(level) end
|
||||
p:SetModel("{}")
|
||||
@@ -244,3 +247,100 @@ fn on_update_model_fires_in_draw_order() {
|
||||
assert_eq!(take_log(&s), "b a low level2 level9 dialog");
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// Each line of message frame `name` as `(time_left, alpha)`, oldest first.
|
||||
fn lines(s: &UiScript, name: &str) -> Vec<(f32, f32)> {
|
||||
let model = s.lua().app_data_ref::<Model>().expect("model");
|
||||
let fh = model.arena.lookup(name).expect("message frame");
|
||||
model
|
||||
.arena
|
||||
.frame(fh)
|
||||
.and_then(|f| f.kind_state.message_lines())
|
||||
.expect("a message frame")
|
||||
.iter()
|
||||
.map(|l| (l.time_left, l.alpha))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A line's countdown is the frame's own update (`0x788460` for the scrolling class, `0x7884d4`;
|
||||
/// `0x786200` for the plain one, `0x786300`), which the walk reaches only for a shown frame: a
|
||||
/// docked chat tab hidden behind another keeps its lines' time, and they are still there when it
|
||||
/// is shown again.
|
||||
#[test]
|
||||
fn a_hidden_message_frames_lines_keep_their_time() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
for _, kind in ipairs({ "ScrollingMessageFrame", "MessageFrame" }) do
|
||||
local f = CreateFrame(kind, "Hidden" .. kind)
|
||||
f:SetPoint("CENTER", 0, 0); f:SetWidth(300); f:SetHeight(140)
|
||||
f:SetTimeVisible(10); f:SetFadeDuration(3)
|
||||
f:AddMessage("arrived while hidden")
|
||||
f:Hide()
|
||||
end
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
for _ in 0..30 {
|
||||
s.tick(1.0);
|
||||
}
|
||||
for name in ["HiddenScrollingMessageFrame", "HiddenMessageFrame"] {
|
||||
assert_eq!(lines(&s, name), vec![(10.0, 1.0)], "{name}: no time passed");
|
||||
}
|
||||
s.run("HiddenScrollingMessageFrame:Show() HiddenMessageFrame:Show()")
|
||||
.unwrap();
|
||||
s.tick(4.0);
|
||||
for name in ["HiddenScrollingMessageFrame", "HiddenMessageFrame"] {
|
||||
assert_eq!(
|
||||
lines(&s, name),
|
||||
vec![(6.0, 1.0)],
|
||||
"{name}: shown, it counts again"
|
||||
);
|
||||
}
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `OnUpdateModel` fires from the paint `0x76d240` past three gates (`0x76d5ea`): a model set
|
||||
/// (`0x76d24c`), a resolved rect (`0x768320` at `0x76d27d`), and the rect as screen fractions
|
||||
/// (`0x41ade0`), each edge clamped to 0..1 (`0x76d473`–`0x76d56d`), at least `[0x8029d4]` wide
|
||||
/// and tall (`0x76d574`–`0x76d5a0`). A pane with no size, no anchor or wholly off screen fires
|
||||
/// none; one partly on screen does.
|
||||
#[test]
|
||||
fn on_update_model_needs_a_resolved_rect_on_screen() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_model_facts(COOLDOWN_FILE, cooldown_facts());
|
||||
s.run(&format!(
|
||||
r#"
|
||||
LOG = {{}}
|
||||
function Pane(name, x, y, size, anchored)
|
||||
local p = CreateFrame("Model", name)
|
||||
if anchored then p:SetPoint("BOTTOMLEFT", x, y) end
|
||||
if size then p:SetWidth(size); p:SetHeight(size) end
|
||||
p:SetModel("{}")
|
||||
p:SetScript("OnUpdateModel", function() table.insert(LOG, name) end)
|
||||
end
|
||||
Pane("OnScreen", 100, 100, 64, true)
|
||||
Pane("PartlyOff", 780, 100, 64, true)
|
||||
Pane("NoSize", 100, 100, nil, true)
|
||||
Pane("NoAnchor", 100, 100, 64, false)
|
||||
Pane("OffRight", 900, 100, 64, true)
|
||||
Pane("OffBelow", 100, -200, 64, true)
|
||||
"#,
|
||||
lua_path(COOLDOWN_FILE)
|
||||
))
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
take_log(&s);
|
||||
s.tick(0.016);
|
||||
assert_eq!(take_log(&s), "OnScreen PartlyOff");
|
||||
// Moved on screen, the off-screen pane fires from the next paint.
|
||||
s.run(r#"OffRight:SetPoint("BOTTOMLEFT", 300, 100)"#)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s.tick(0.016);
|
||||
assert_eq!(take_log(&s), "OnScreen PartlyOff OffRight");
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -230,6 +230,7 @@ fn the_cooldown_machine_runs_on_the_two_handlers() {
|
||||
end
|
||||
end
|
||||
cd = CreateFrame("Model", "CD", UIParent)
|
||||
cd:SetPoint("CENTER", 0, 0); cd:SetWidth(36); cd:SetHeight(36)
|
||||
cd:SetModel("{}")
|
||||
cd:SetScript("OnUpdateModel", CooldownFrame_OnUpdateModel)
|
||||
cd:SetScript("OnAnimFinished", CooldownFrame_OnAnimFinished)
|
||||
@@ -292,6 +293,7 @@ fn on_update_model_fires_per_visible_paint_with_this() {
|
||||
r#"
|
||||
seen = {{}}
|
||||
m = CreateFrame("Model", "M", UIParent)
|
||||
m:SetPoint("CENTER", 0, 0); m:SetWidth(36); m:SetHeight(36)
|
||||
m:SetModel("{}")
|
||||
m:SetScript("OnUpdateModel", function() table.insert(seen, this:GetName()) end)
|
||||
m:SetScript("OnAnimFinished", function() error("must not fire") end)
|
||||
@@ -334,6 +336,7 @@ fn a_loop_completes_once_and_a_fileless_pane_paints_nothing() {
|
||||
r#"
|
||||
done = 0 painted = 0
|
||||
p = CreateFrame("Model", "Loop", UIParent)
|
||||
p:SetPoint("CENTER", 0, 0); p:SetWidth(36); p:SetHeight(36)
|
||||
p:SetScript("OnAnimFinished", function() done = done + 1 end)
|
||||
p:SetScript("OnUpdateModel", function() painted = painted + 1 end)
|
||||
"#,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! ScrollFrame: the scroll-child layout override, the live scroll ranges, the clip on `extract`
|
||||
//! ScrollFrame: the scroll-child layout override, the stored scroll ranges, the clip on `extract`
|
||||
//! and the clip-gated `hit_test`.
|
||||
|
||||
use super::common::script;
|
||||
@@ -183,8 +183,10 @@ fn set_vertical_scroll_fires_on_vertical_scroll_only_on_a_change() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The recompute measures `max(0, child − frame)`, and leaves the stored range alone when the
|
||||
/// frame has no rect (`0x787186`) or no scroll child (`0x786e42`).
|
||||
#[test]
|
||||
fn vertical_scroll_range_is_live_and_zero_when_unresolved_or_childless_or_shorter() {
|
||||
fn the_recompute_measures_the_overhang_and_skips_an_unresolved_or_childless_frame() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
@@ -198,33 +200,27 @@ fn vertical_scroll_range_is_live_and_zero_when_unresolved_or_childless_or_shorte
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return SF:GetVerticalScrollRange()").unwrap(),
|
||||
0.0,
|
||||
"unresolved -> 0"
|
||||
);
|
||||
let range =
|
||||
|s: &crate::script::UiScript| s.eval::<f32>("return SF:GetVerticalScrollRange()").unwrap();
|
||||
assert_eq!(range(&s), 0.0, "the ctor's zero");
|
||||
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return SF:GetVerticalScrollRange()").unwrap(),
|
||||
400.0,
|
||||
"600 - 200"
|
||||
);
|
||||
s.tick(0.016);
|
||||
assert_eq!(range(&s), 400.0, "600 - 200");
|
||||
|
||||
s.run("Child:SetWidth(300); Child:SetHeight(150)").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return SF:GetVerticalScrollRange()").unwrap(),
|
||||
0.0,
|
||||
"child shorter than frame -> 0, not negative"
|
||||
);
|
||||
s.run("Child:SetHeight(150) SF:UpdateScrollChildRect()")
|
||||
.unwrap();
|
||||
s.tick(0.016);
|
||||
assert_eq!(range(&s), 0.0, "a shorter child: 0, not negative");
|
||||
|
||||
s.run("Child:SetHeight(600) SF:UpdateScrollChildRect()")
|
||||
.unwrap();
|
||||
s.tick(0.016);
|
||||
assert_eq!(range(&s), 400.0);
|
||||
s.run("SF:SetScrollChild(nil)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return SF:GetVerticalScrollRange()").unwrap(),
|
||||
0.0,
|
||||
"no child -> 0"
|
||||
);
|
||||
s.tick(0.016);
|
||||
assert_eq!(range(&s), 400.0, "no child: the stored range stands");
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The range is `child:GetHeight() - self:GetHeight()` in local units, like the offset, which
|
||||
@@ -246,6 +242,7 @@ fn vertical_scroll_range_is_local_units_on_a_scaled_frame() {
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s.tick(0.016);
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return SF:GetVerticalScrollRange()").unwrap(),
|
||||
400.0,
|
||||
@@ -275,7 +272,7 @@ fn vertical_scroll_range_is_local_units_on_a_scaled_frame() {
|
||||
/// `0x786db0` fires `OnVerticalScroll` with the unclamped stored value `[+0x328]`, under the
|
||||
/// handler conventions of `0x704f10`.
|
||||
#[test]
|
||||
fn vertical_scroll_fires_as_given_and_update_rect_fires_range_changed() {
|
||||
fn vertical_scroll_fires_as_given_and_the_marked_range_fires_range_changed() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
@@ -309,15 +306,16 @@ fn vertical_scroll_fires_as_given_and_update_rect_fires_range_changed() {
|
||||
);
|
||||
|
||||
s.run("SF:UpdateScrollChildRect()").unwrap();
|
||||
s.tick(0.016);
|
||||
let (lo, hi): (f32, f32) = s.eval("return seen_lo, seen_hi").unwrap();
|
||||
assert_eq!((lo, hi), (0.0, 400.0));
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `UpdateScrollChildRect` resolves for itself, so a resize earlier in the same tick counts; the
|
||||
/// app resolves only once per frame.
|
||||
/// The recompute resolves for itself, so a resize earlier in the same tick counts; the app
|
||||
/// resolves only once per frame, after the walk.
|
||||
#[test]
|
||||
fn update_scroll_child_rect_sees_a_same_tick_resize_with_no_intervening_resolve() {
|
||||
fn the_recompute_sees_a_same_tick_resize_with_no_intervening_resolve() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
@@ -331,7 +329,8 @@ fn update_scroll_child_rect_sees_a_same_tick_resize_with_no_intervening_resolve(
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve(); // the "last frame" resolve, before this tick's resize
|
||||
s.resolve();
|
||||
s.tick(0.016); // the first recompute: 0 stays 0, nothing fires
|
||||
|
||||
s.run(
|
||||
r#"
|
||||
@@ -342,11 +341,12 @@ fn update_scroll_child_rect_sees_a_same_tick_resize_with_no_intervening_resolve(
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.tick(0.016);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return seen_hi").unwrap(),
|
||||
400.0,
|
||||
"UpdateScrollChildRect must resolve the JUST-set height itself, not report the stale \
|
||||
"the recompute must resolve the JUST-set height itself, not report the stale \
|
||||
(pre-resize) range from the last full UiScript::resolve"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -513,7 +513,7 @@ fn hit_test_denies_a_button_clipped_out_and_admits_it_once_scrolled_into_view()
|
||||
|
||||
/// The reference measures text synchronously, as stock `QuestLog_UpdateQuestDetails` relies on.
|
||||
#[test]
|
||||
fn update_scroll_child_rect_measures_the_childs_text_before_it_reads_the_range() {
|
||||
fn the_recompute_measures_the_childs_text_before_it_reads_the_range() {
|
||||
struct Rows;
|
||||
impl crate::script::TextMeasure for Rows {
|
||||
fn measure(&mut self, req: &crate::script::MeasureRequest) -> (f32, f32, f32) {
|
||||
@@ -541,11 +541,12 @@ fn update_scroll_child_rect_measures_the_childs_text_before_it_reads_the_range()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.tick(0.016);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return RANGE").unwrap(),
|
||||
6.0,
|
||||
"56px of measured text under a 50px frame: the range is known at the call, not a frame later"
|
||||
"56px of measured text under a 50px frame: the recompute measures it, not a frame later"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -577,6 +578,7 @@ fn scroll_child_left_tracks_horizontal_scroll_unclamped_and_fires_on_change() {
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s.tick(0.016);
|
||||
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return HSF:GetHorizontalScroll()").unwrap(),
|
||||
@@ -661,7 +663,7 @@ fn scroll_child_left_tracks_horizontal_scroll_unclamped_and_fires_on_change() {
|
||||
/// The vertical range leaves `0x786e30` on top of the x87 stack and is `fstp`'d first, so it is
|
||||
/// pushed deepest and lands as `arg2`.
|
||||
#[test]
|
||||
fn update_scroll_child_rect_reports_both_ranges_horizontal_first() {
|
||||
fn the_range_notify_reports_both_ranges_horizontal_first() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
@@ -679,7 +681,7 @@ fn update_scroll_child_rect_reports_both_ranges_horizontal_first() {
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s.run("BSF:UpdateScrollChildRect()").unwrap();
|
||||
s.tick(0.016);
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return seen[1]").unwrap(),
|
||||
200.0,
|
||||
@@ -692,3 +694,142 @@ fn update_scroll_child_rect_reports_both_ranges_horizontal_first() {
|
||||
);
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
// ── The stored ranges (`[+0x31c]`, `[+0x320]`), recomputed in the frame's walk slot ──────────
|
||||
|
||||
/// A shown 300×200 scroll frame `SF` and a 600-tall `Child`, not yet set; `FIRES` counts
|
||||
/// `OnScrollRangeChanged` and `RANGE` keeps its last `(xRange, yRange)`.
|
||||
const RANGED: &str = r#"
|
||||
FIRES, RANGE = 0, { -1, -1 }
|
||||
local frame = CreateFrame("ScrollFrame", "SF")
|
||||
frame:SetPoint("TOPLEFT", 0, -100)
|
||||
frame:SetWidth(300); frame:SetHeight(200)
|
||||
frame:SetScript("OnScrollRangeChanged", function()
|
||||
FIRES = FIRES + 1
|
||||
RANGE = { arg1, arg2 }
|
||||
end)
|
||||
local child = CreateFrame("Frame", "Child", frame)
|
||||
child:SetWidth(300); child:SetHeight(600)
|
||||
"#;
|
||||
|
||||
/// `(FIRES, yRange as fired, GetVerticalScrollRange())`.
|
||||
fn ranged(s: &crate::script::UiScript) -> (u32, f32, f32) {
|
||||
s.eval("return FIRES, RANGE[2], SF:GetVerticalScrollRange()")
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// `UpdateScrollChildRect` only marks the range (`0x791943`); the frame's update (`0x787140`)
|
||||
/// recomputes a marked range after its `OnUpdate` (`0x787152`–`0x7871a0`) and fires
|
||||
/// `OnScrollRangeChanged` only when a range moved by 2⁻²² or more (`0x786ed7`–`0x786efd`).
|
||||
#[test]
|
||||
fn the_range_is_recomputed_in_the_walk_and_fires_once_per_change() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(RANGED).unwrap();
|
||||
s.run("SF:SetScrollChild(Child)").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(ranged(&s), (0, -1.0, 0.0), "nothing until the walk");
|
||||
s.tick(0.016);
|
||||
assert_eq!(ranged(&s), (1, 400.0, 400.0), "SetScrollChild marked it");
|
||||
|
||||
s.run("SF:UpdateScrollChildRect()").unwrap();
|
||||
assert_eq!(ranged(&s).0, 1, "the call fires nothing");
|
||||
s.tick(0.016);
|
||||
assert_eq!(
|
||||
ranged(&s),
|
||||
(1, 400.0, 400.0),
|
||||
"an unchanged range fires nothing"
|
||||
);
|
||||
|
||||
s.run("Child:SetHeight(700) SF:UpdateScrollChildRect()")
|
||||
.unwrap();
|
||||
assert_eq!(ranged(&s), (1, 400.0, 400.0), "stored until the walk");
|
||||
s.tick(0.016);
|
||||
assert_eq!(ranged(&s), (2, 500.0, 500.0));
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The getters answer the stored value (`0x7917d0`, `[+0x320]` at `0x791873`), which the ctor
|
||||
/// zeroes (`0x786a63`) and only a marked recompute moves: a child resized with no mark leaves it.
|
||||
#[test]
|
||||
fn the_getters_answer_the_stored_range() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(RANGED).unwrap();
|
||||
s.run("SF:SetScrollChild(Child)").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
ranged(&s).2,
|
||||
0.0,
|
||||
"the ctor's zero before the first recompute"
|
||||
);
|
||||
s.tick(0.016);
|
||||
assert_eq!(ranged(&s).2, 400.0);
|
||||
s.run("Child:SetHeight(900)").unwrap();
|
||||
s.resolve();
|
||||
s.tick(0.016);
|
||||
assert_eq!(
|
||||
ranged(&s),
|
||||
(1, 400.0, 400.0),
|
||||
"a child's resize marks nothing"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The update runs only for a shown frame, so a hidden one keeps its range and fires nothing, and
|
||||
/// the mark waits for the show; a resize while hidden is marked the same way.
|
||||
#[test]
|
||||
fn a_hidden_scroll_frame_keeps_its_range_until_shown() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(RANGED).unwrap();
|
||||
s.run("SF:SetScrollChild(Child)").unwrap();
|
||||
s.resolve();
|
||||
s.tick(0.016);
|
||||
assert_eq!(ranged(&s), (1, 400.0, 400.0));
|
||||
|
||||
s.run("SF:Hide() Child:SetHeight(700) SF:UpdateScrollChildRect()")
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s.tick(0.016);
|
||||
s.tick(0.016);
|
||||
assert_eq!(ranged(&s), (1, 400.0, 400.0), "hidden: stale, and silent");
|
||||
s.run("SF:Show()").unwrap();
|
||||
s.tick(0.016);
|
||||
assert_eq!(
|
||||
ranged(&s),
|
||||
(2, 500.0, 500.0),
|
||||
"the mark held until the show"
|
||||
);
|
||||
|
||||
s.run("SF:Hide() SF:SetHeight(300)").unwrap();
|
||||
s.resolve();
|
||||
s.tick(0.016);
|
||||
assert_eq!(ranged(&s).0, 2);
|
||||
s.run("SF:Show()").unwrap();
|
||||
s.tick(0.016);
|
||||
assert_eq!(
|
||||
ranged(&s),
|
||||
(3, 400.0, 400.0),
|
||||
"the resize while hidden marked it"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// A change in the frame's own size marks the range (`0x7871f0`, `0x787205`), with no
|
||||
/// `UpdateScrollChildRect`: a frame whose code never calls it still gets its range.
|
||||
#[test]
|
||||
fn a_resize_marks_the_range() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(RANGED).unwrap();
|
||||
s.run("SF:SetScrollChild(Child)").unwrap();
|
||||
s.resolve();
|
||||
s.tick(0.016);
|
||||
assert_eq!(ranged(&s), (1, 400.0, 400.0));
|
||||
s.run("SF:SetHeight(300)").unwrap();
|
||||
s.resolve();
|
||||
s.tick(0.016);
|
||||
assert_eq!(ranged(&s), (2, 300.0, 300.0));
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -74,8 +74,8 @@ impl super::UiScript {
|
||||
/// draw pass `0x7657d0`, in that order (`0x764340`, `0x764347`). The pump is the update walk,
|
||||
/// each linked frame's update in strata, level and link order: its `OnUpdate(self, elapsed)`
|
||||
/// (`0x704f10`) and its kind's own update after it (an EditBox's flush, a tooltip's fade, a
|
||||
/// model pane's clock); then the hover re-pick at its tail (`0x7657be`). The draw pass fires
|
||||
/// `OnUpdateModel`.
|
||||
/// model pane's clock, a message frame's line fades, a scroll frame's range); then the hover
|
||||
/// re-pick at its tail (`0x7657be`). The draw pass fires `OnUpdateModel`.
|
||||
pub fn tick(&mut self, elapsed: f32) {
|
||||
self.model_mut().now += f64::from(elapsed);
|
||||
// Events queued since the last tick fire before this tick's OnUpdate.
|
||||
@@ -84,39 +84,6 @@ impl super::UiScript {
|
||||
self.fire_event(&event, args);
|
||||
}
|
||||
self.update_walk(elapsed);
|
||||
// The engine's line fades, apart from any OnUpdate script: ScrollingMessageFrame's
|
||||
// (`0x788460`) and MessageFrame's (`0x786200`), which also keeps only the lines that fit
|
||||
// its height, so its rows are read from the resolved rect before the mutable walk.
|
||||
let mut model = self.model_mut();
|
||||
let ticked: Vec<FrameHandle> = model.arena.ticked_kinds().to_vec();
|
||||
let message_frames: Vec<(FrameHandle, usize)> = ticked
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&h| {
|
||||
model
|
||||
.arena
|
||||
.frame(h)
|
||||
.is_some_and(|f| matches!(f.kind_state, crate::widget::KindState::Message(_)))
|
||||
})
|
||||
.map(|h| (h, Self::message_viewport_rows(&model, h)))
|
||||
.collect();
|
||||
for (h, viewport_rows) in message_frames {
|
||||
if let Some(crate::widget::KindState::Message(mf)) =
|
||||
model.arena.frame_mut(h).map(|f| &mut f.kind_state)
|
||||
{
|
||||
mf.tick(elapsed);
|
||||
mf.trim_to_viewport(viewport_rows);
|
||||
}
|
||||
}
|
||||
for &h in &ticked {
|
||||
let Some(frame) = model.arena.frame_mut(h) else {
|
||||
continue;
|
||||
};
|
||||
if let crate::widget::KindState::ScrollingMessage(smf) = &mut frame.kind_state {
|
||||
smf.tick(elapsed);
|
||||
}
|
||||
}
|
||||
drop(model);
|
||||
// A frame hid or showed under a still cursor: re-run the hover walk at the saved position,
|
||||
// as the reference's pump tail does (`0x765650` → `0x7660d0`). The hidden frame's OnLeave
|
||||
// already fired, so only the new winner's `OnEnter` fires.
|
||||
@@ -179,13 +146,17 @@ impl UiScript {
|
||||
/// go from 0 (`0x7657ff`); a stratum's batches are emitted before any is drained (`0x765896`,
|
||||
/// then `0x7658e2`), level 0 up, a level's frames first linked first (`0x765920`). So the
|
||||
/// panes are read per stratum when its turn comes, and a pane an earlier handler of that
|
||||
/// stratum hid still paints this frame; only the callback's own gate, a model file set
|
||||
/// (`0x76d24c`), is read at the call.
|
||||
/// stratum hid still paints this frame; only the callback's own gates ([`paints`]) are read at
|
||||
/// the call.
|
||||
///
|
||||
/// The pump drains the layout after the walk (`0x765799`), so the paint reads rects the walk's
|
||||
/// handlers moved: the pass resolves first.
|
||||
fn paint_model_panes(&mut self) {
|
||||
let mut model = self.model_mut();
|
||||
if model.on_update_model_frames.is_empty() {
|
||||
return;
|
||||
}
|
||||
Self::resolve_layout(&mut model);
|
||||
// Kept by `SetScript`; a destroyed frame's handle compacts out on its first miss.
|
||||
if model
|
||||
.on_update_model_frames
|
||||
@@ -221,7 +192,7 @@ impl UiScript {
|
||||
let id = {
|
||||
let mut model = self.model_mut();
|
||||
match model.arena.frame(h) {
|
||||
Some(f) if has_model_file(f) => model.frame_id(h),
|
||||
Some(f) if paints(&model, h, f) => model.frame_id(h),
|
||||
_ => continue,
|
||||
}
|
||||
};
|
||||
@@ -241,3 +212,28 @@ impl UiScript {
|
||||
fn has_model_file(f: &crate::widget::Frame) -> bool {
|
||||
matches!(&f.kind_state, crate::widget::KindState::Model(m) if m.path.is_some())
|
||||
}
|
||||
|
||||
/// The paint callback's gates ahead of `OnUpdateModel` (`0x76d240`, firing at `0x76d5ea`): a model
|
||||
/// set (`0x76d24c`); a resolved rect (`0x768320`, the rect-resolved bit, `0x76d27d`/`0x76d284`);
|
||||
/// and the viewport, that rect as fractions of the screen (`0x41ade0` divides by its extent) with
|
||||
/// each edge clamped to 0..1 (`0x76d473`–`0x76d56d`), at least `[0x8029d4]` = 2⁻²² wide and tall
|
||||
/// (`0x76d574`–`0x76d5a0`, else to the cleanup `0x76d63e`). So a pane with no size, or wholly off
|
||||
/// screen, paints nothing and fires nothing.
|
||||
fn paints(model: &super::Model, h: FrameHandle, f: &crate::widget::Frame) -> bool {
|
||||
if !has_model_file(f) {
|
||||
return false;
|
||||
}
|
||||
let Some(r) = model.resolved.get(&h) else {
|
||||
return false;
|
||||
};
|
||||
let screen = model.screen;
|
||||
let (sw, sh) = (screen.width(), screen.height());
|
||||
if sw <= 0.0 || sh <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let fraction = |v: f32, origin: f32, extent: f32| ((v - origin) / extent).clamp(0.0, 1.0);
|
||||
let wide = fraction(r.right, screen.left, sw) - fraction(r.left, screen.left, sw);
|
||||
let tall = fraction(r.top, screen.bottom, sh) - fraction(r.bottom, screen.bottom, sh);
|
||||
let eps = crate::layout::SIZE_EPS;
|
||||
f64::from(wide.abs()) >= eps && f64::from(tall.abs()) >= eps
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use crate::widget::{FrameHandle, KindUpdate};
|
||||
|
||||
use super::{editbox, event, tooltip, UiScript};
|
||||
use super::{editbox, event, messageframe, scrollframe, tooltip, UiScript};
|
||||
|
||||
/// A place in the walk: stratum, level, then within the level the frames linked before the walk
|
||||
/// began (0, by link stamp) ahead of those linked during it (1, by the order they linked).
|
||||
@@ -144,6 +144,12 @@ impl UiScript {
|
||||
KindUpdate::Tooltip => tooltip::update(&self.lua, h, elapsed),
|
||||
// The scene clock (`0x76d843`), whose completions fire `OnAnimFinished` here.
|
||||
KindUpdate::Model => self.advance_pane(h, elapsed),
|
||||
// The line fades (`0x7862b0`, `0x788476`), counted only while the frame is shown.
|
||||
KindUpdate::MessageFrame | KindUpdate::ScrollingMessageFrame => {
|
||||
messageframe::update(&mut self.model_mut(), h, elapsed)
|
||||
}
|
||||
// A marked range's recompute (`0x787152`), whose change fires `OnScrollRangeChanged`.
|
||||
KindUpdate::ScrollFrame => scrollframe::update(&self.lua, h),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,16 +61,25 @@ pub enum KindUpdate {
|
||||
/// `CSimpleModel`'s `0x76d7f0`, in the slot of every model kind's vtable (`0x81c608`,
|
||||
/// `0x8084f8`, `0x8083a8`, `0x808260`): the scene clock and the completions it reports.
|
||||
Model,
|
||||
/// `CSimpleMessageFrame`'s `0x786200` (slot `0x81cca0`): the cap to the lines that fit, then
|
||||
/// the line fades.
|
||||
MessageFrame,
|
||||
/// `CSimpleMessageScrollFrame`'s `0x788460` (slot `0x81ce50`): the line fades.
|
||||
ScrollingMessageFrame,
|
||||
/// `CSimpleScrollFrame`'s `0x787140` (slot `0x81cd78`): a marked range's recompute.
|
||||
ScrollFrame,
|
||||
}
|
||||
|
||||
impl FrameKind {
|
||||
/// The kind's own update, run in the walk after the frame's `OnUpdate`. The reference
|
||||
/// overrides the slot on more kinds; benilla runs the message frames' fades (`0x786200`,
|
||||
/// `0x788460`) after the walk, and models none of the rest there.
|
||||
/// overrides the slot on more kinds; benilla models these.
|
||||
pub fn kind_update(self) -> Option<KindUpdate> {
|
||||
match self {
|
||||
FrameKind::EditBox => Some(KindUpdate::EditBox),
|
||||
FrameKind::GameTooltip => Some(KindUpdate::Tooltip),
|
||||
FrameKind::MessageFrame => Some(KindUpdate::MessageFrame),
|
||||
FrameKind::ScrollingMessageFrame => Some(KindUpdate::ScrollingMessageFrame),
|
||||
FrameKind::ScrollFrame => Some(KindUpdate::ScrollFrame),
|
||||
FrameKind::Model
|
||||
| FrameKind::PlayerModel
|
||||
| FrameKind::DressUpModel
|
||||
@@ -689,11 +698,20 @@ pub const MINIMAP_DEFAULT_ARROW_MODEL: &str = "Interface\\Minimap\\Rotating-Mini
|
||||
/// The default `minimapPlayerModel` (`0x8453c0`), applied to engine child 9 alone (`0x4ee260`).
|
||||
pub const MINIMAP_DEFAULT_PLAYER_MODEL: &str = "Interface\\Minimap\\MinimapArrow.mdx";
|
||||
|
||||
/// A `CSimpleScrollFrame`'s members (`+0x318`/`+0x324`/`+0x328`); the ranges are measured live.
|
||||
/// A `CSimpleScrollFrame`'s members (`+0x314`..`+0x328`), all zero from the ctor (`0x786a51`).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ScrollFrameState {
|
||||
/// `SetScrollChild`'s frame, which pans within this frame's rect.
|
||||
pub child: Option<FrameHandle>,
|
||||
/// `[+0x31c]`, `[+0x320]`: the horizontal and vertical ranges in the frame's own units, as the
|
||||
/// last recompute stored them (`0x786eaa`, `0x786ed1`); the getters answer these.
|
||||
pub range: [f32; 2],
|
||||
/// `[+0x314]`: the ranges are out of date. `UpdateScrollChildRect`, `SetScrollChild` and a
|
||||
/// change in the frame's size set it; the frame's update recomputes and clears it.
|
||||
pub range_dirty: bool,
|
||||
/// The frame's width and height in px when its update last ran, from which that update sees a
|
||||
/// resize (`0x7871f0`'s mark); the ctor's empty rect until then.
|
||||
pub seen_size: [f32; 2],
|
||||
/// `SetHorizontalScroll`'s offset in px, unclamped; `0x787100` anchors the child at
|
||||
/// `(+horizontal, +vertical)`, so scrolling right takes a negative offset, down a positive.
|
||||
pub horizontal: f32,
|
||||
|
||||
@@ -270,10 +270,10 @@ pub struct WidgetArena {
|
||||
pub(crate) order_cache: crate::order::OrderCache,
|
||||
/// Minimap frames ever created, so the state feed notices a new one without a walk.
|
||||
minimap_created: u64,
|
||||
/// The live message frames and model panes, so the host's passes over them (message fades,
|
||||
/// model paint) skip the rest of the arena. Added at creation, as kinds never change;
|
||||
/// `destroy` removes its own.
|
||||
ticked_kinds: Vec<FrameHandle>,
|
||||
/// The live model panes, so the host's passes over them (the paint list, the implicit rects)
|
||||
/// skip the rest of the arena. Added at creation, as kinds never change; `destroy` removes its
|
||||
/// own.
|
||||
model_kinds: Vec<FrameHandle>,
|
||||
/// The live frames whose kind has a [`KindUpdate`], kept the same way: the update walk reaches
|
||||
/// each one shown, with an `OnUpdate` or without.
|
||||
walked_kinds: Vec<FrameHandle>,
|
||||
@@ -326,7 +326,7 @@ impl WidgetArena {
|
||||
link_log: None,
|
||||
minimap_created: 0,
|
||||
order_cache: Default::default(),
|
||||
ticked_kinds: Vec::new(),
|
||||
model_kinds: Vec::new(),
|
||||
walked_kinds: Vec::new(),
|
||||
tooltip_kinds: Vec::new(),
|
||||
minimap_kinds: Vec::new(),
|
||||
@@ -339,9 +339,9 @@ impl WidgetArena {
|
||||
self.minimap_created
|
||||
}
|
||||
|
||||
/// The frames the host advances each tick.
|
||||
pub fn ticked_kinds(&self) -> &[FrameHandle] {
|
||||
&self.ticked_kinds
|
||||
/// The live model panes.
|
||||
pub fn model_kinds(&self) -> &[FrameHandle] {
|
||||
&self.model_kinds
|
||||
}
|
||||
|
||||
/// The frames whose kind has an update of its own in the walk ([`FrameKind::kind_update`]).
|
||||
@@ -566,11 +566,9 @@ impl WidgetArena {
|
||||
if matches!(kind, FrameKind::Minimap) {
|
||||
self.minimap_created += 1;
|
||||
}
|
||||
let ticked = matches!(
|
||||
let model_kind = matches!(
|
||||
kind,
|
||||
FrameKind::ScrollingMessageFrame
|
||||
| FrameKind::MessageFrame
|
||||
| FrameKind::Model
|
||||
FrameKind::Model
|
||||
| FrameKind::PlayerModel
|
||||
| FrameKind::DressUpModel
|
||||
| FrameKind::TabardModel
|
||||
@@ -585,8 +583,8 @@ impl WidgetArena {
|
||||
log.push(handle);
|
||||
}
|
||||
}
|
||||
if ticked {
|
||||
self.ticked_kinds.push(handle);
|
||||
if model_kind {
|
||||
self.model_kinds.push(handle);
|
||||
}
|
||||
if kind.kind_update().is_some() {
|
||||
self.walked_kinds.push(handle);
|
||||
@@ -659,7 +657,7 @@ impl WidgetArena {
|
||||
let parent = frame.parent;
|
||||
let name = frame.name.clone();
|
||||
// Each recursive call removes its own handle, so the registries hold no dead frame.
|
||||
self.ticked_kinds.retain(|&t| t != h);
|
||||
self.model_kinds.retain(|&t| t != h);
|
||||
self.walked_kinds.retain(|&t| t != h);
|
||||
self.tooltip_kinds.retain(|&t| t != h);
|
||||
self.minimap_kinds.retain(|&t| t != h);
|
||||
|
||||
Reference in New Issue
Block a user