diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index 9b425483..a5a03e77 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -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 @@ - this:SetScrollChild(BenillaOptionsFrameContainerBody) + + this:SetScrollChild(BenillaOptionsFrameContainerBody) + this.contentRange = BenillaOptionsScroll_Range + BenillaOptionsScroll_Fit() diff --git a/crates/benilla-app/assets/ui/ScrollTemplates.xml b/crates/benilla-app/assets/ui/ScrollTemplates.xml index 19a3f3e8..d8fdd234 100644 --- a/crates/benilla-app/assets/ui/ScrollTemplates.xml +++ b/crates/benilla-app/assets/ui/ScrollTemplates.xml @@ -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) diff --git a/crates/benilla-app/src/ui_script/faux_scroll_tests.rs b/crates/benilla-app/src/ui_script/faux_scroll_tests.rs index 20774d74..042171a0 100644 --- a/crates/benilla-app/src/ui_script/faux_scroll_tests.rs +++ b/crates/benilla-app/src/ui_script/faux_scroll_tests.rs @@ -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::("return AddonScroll:GetVerticalScrollRange()") .unwrap(), diff --git a/crates/benilla-app/src/ui_script/friends_tests.rs b/crates/benilla-app/src/ui_script/friends_tests.rs index 8c39f41d..e865b2d0 100644 --- a/crates/benilla-app/src/ui_script/friends_tests.rs +++ b/crates/benilla-app/src/ui_script/friends_tests.rs @@ -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::("return WhoListScrollFrame:GetVerticalScrollRange()") + .eval::( + "return WhoListScrollFrameScrollChildFrame:GetHeight() - WhoListScrollFrame:GetHeight()", + ) .unwrap(); assert_eq!( overflow, 497.0, diff --git a/crates/benilla-app/src/ui_script/keybindings_tests.rs b/crates/benilla-app/src/ui_script/keybindings_tests.rs index 54fe2744..c44413b7 100644 --- a/crates/benilla-app/src/ui_script/keybindings_tests.rs +++ b/crates/benilla-app/src/ui_script/keybindings_tests.rs @@ -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::("return BenillaOptionsFrameContainerScrollBar:IsVisible()") .unwrap(), diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index c72f8c74..4d0cd37b 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -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() \ diff --git a/crates/benilla-app/src/ui_script/panel_template_tests.rs b/crates/benilla-app/src/ui_script/panel_template_tests.rs index 84ab0e32..8806af74 100644 --- a/crates/benilla-app/src/ui_script/panel_template_tests.rs +++ b/crates/benilla-app/src/ui_script/panel_template_tests.rs @@ -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: {:?}", diff --git a/crates/benilla-app/src/ui_script/questlog_tests.rs b/crates/benilla-app/src/ui_script/questlog_tests.rs index 0b55c519..80ba405a 100644 --- a/crates/benilla-app/src/ui_script/questlog_tests.rs +++ b/crates/benilla-app/src/ui_script/questlog_tests.rs @@ -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. diff --git a/crates/benilla-app/src/ui_script/skills_frame_tests.rs b/crates/benilla-app/src/ui_script/skills_frame_tests.rs index dd2ebea5..3965e222 100644 --- a/crates/benilla-app/src/ui_script/skills_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/skills_frame_tests.rs @@ -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::("return SkillListScrollFrame:GetVerticalScrollRange()") + .eval::( + "return SkillListScrollFrameScrollChildFrame:GetHeight() \ + - SkillListScrollFrame:GetHeight()", + ) .unwrap(); assert_eq!(overflow, f64::from(n as i32 * 15 - 220)); assert!( diff --git a/crates/benilla-app/tests/talent_frame.rs b/crates/benilla-app/tests/talent_frame.rs index 0735c205..d64a106a 100644 --- a/crates/benilla-app/tests/talent_frame.rs +++ b/crates/benilla-app/tests/talent_frame.rs @@ -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"); diff --git a/crates/benilla-ui/src/loader/widgets.rs b/crates/benilla-ui/src/loader/widgets.rs index 58b2b52b..58db6e81 100644 --- a/crates/benilla-ui/src/loader/widgets.rs +++ b/crates/benilla-ui/src/loader/widgets.rs @@ -12,7 +12,7 @@ impl Loader<'_> { /// ``'s `` (`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 `` 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 `` 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. diff --git a/crates/benilla-ui/src/script/layout.rs b/crates/benilla-ui/src/script/layout.rs index f738b5a2..76ea42c0 100644 --- a/crates/benilla-ui/src/script/layout.rs +++ b/crates/benilla-ui/src/script/layout.rs @@ -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); diff --git a/crates/benilla-ui/src/script/messageframe/mod.rs b/crates/benilla-ui/src/script/messageframe/mod.rs index 76d610e0..29e575a7 100644 --- a/crates/benilla-ui/src/script/messageframe/mod.rs +++ b/crates/benilla-ui/src/script/messageframe/mod.rs @@ -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 diff --git a/crates/benilla-ui/src/script/modelframe.rs b/crates/benilla-ui/src/script/modelframe.rs index d834accb..043bf4d9 100644 --- a/crates/benilla-ui/src/script/modelframe.rs +++ b/crates/benilla-ui/src/script/modelframe.rs @@ -612,7 +612,7 @@ impl crate::script::UiScript { let mut model = self.model_mut(); let panes: Vec = 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)?; diff --git a/crates/benilla-ui/src/script/scrollframe.rs b/crates/benilla-ui/src/script/scrollframe.rs index 6e80c0d7..031bcecc 100644 --- a/crates/benilla-ui/src/script/scrollframe.rs +++ b/crates/benilla-ui/src/script/scrollframe.rs @@ -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::().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::().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 = { - let model = lua.app_data_ref::().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::().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::() - .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::().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::().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::().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::().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::() + .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::().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) { + 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; +} diff --git a/crates/benilla-ui/src/script/tests/create_frame_template.rs b/crates/benilla-ui/src/script/tests/create_frame_template.rs index d22c4a69..639af328 100644 --- a/crates/benilla-ui/src/script/tests/create_frame_template.rs +++ b/crates/benilla-ui/src/script/tests/create_frame_template.rs @@ -338,6 +338,8 @@ fn a_scroll_child_element_gives_the_frame_a_real_scroll_range() { "#, ); 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::("return RollerChild ~= nil").unwrap()); @@ -387,6 +389,7 @@ fn the_scroll_range_is_the_childs_whole_subtree() { "#, ); s.resolve(); + s.tick(0.016); assert_eq!( s.eval::("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::("return Reader:GetVerticalScrollRange()") .unwrap(), diff --git a/crates/benilla-ui/src/script/tests/engine_updates.rs b/crates/benilla-ui/src/script/tests/engine_updates.rs index 33e28260..05f08bc3 100644 --- a/crates/benilla-ui/src/script/tests/engine_updates.rs +++ b/crates/benilla-ui/src/script/tests/engine_updates.rs @@ -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::().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()); +} diff --git a/crates/benilla-ui/src/script/tests/model_clock.rs b/crates/benilla-ui/src/script/tests/model_clock.rs index 78494ca5..21221086 100644 --- a/crates/benilla-ui/src/script/tests/model_clock.rs +++ b/crates/benilla-ui/src/script/tests/model_clock.rs @@ -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) "#, diff --git a/crates/benilla-ui/src/script/tests/scrollframe.rs b/crates/benilla-ui/src/script/tests/scrollframe.rs index 689a6cf9..2b0f35ce 100644 --- a/crates/benilla-ui/src/script/tests/scrollframe.rs +++ b/crates/benilla-ui/src/script/tests/scrollframe.rs @@ -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::("return SF:GetVerticalScrollRange()").unwrap(), - 0.0, - "unresolved -> 0" - ); + let range = + |s: &crate::script::UiScript| s.eval::("return SF:GetVerticalScrollRange()").unwrap(); + assert_eq!(range(&s), 0.0, "the ctor's zero"); s.resolve(); - assert_eq!( - s.eval::("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::("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::("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::("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::("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::("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::("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::("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()); +} diff --git a/crates/benilla-ui/src/script/tick.rs b/crates/benilla-ui/src/script/tick.rs index 809c0690..01dccce9 100644 --- a/crates/benilla-ui/src/script/tick.rs +++ b/crates/benilla-ui/src/script/tick.rs @@ -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 = 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 +} diff --git a/crates/benilla-ui/src/script/update_walk.rs b/crates/benilla-ui/src/script/update_walk.rs index 48588a15..13ea0cc6 100644 --- a/crates/benilla-ui/src/script/update_walk.rs +++ b/crates/benilla-ui/src/script/update_walk.rs @@ -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), } } } diff --git a/crates/benilla-ui/src/widget/kinds/mod.rs b/crates/benilla-ui/src/widget/kinds/mod.rs index 67cb406e..146c8ba4 100644 --- a/crates/benilla-ui/src/widget/kinds/mod.rs +++ b/crates/benilla-ui/src/widget/kinds/mod.rs @@ -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 { 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, + /// `[+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, diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index f7d368ab..64209741 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -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, + /// 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, /// 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, @@ -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);