Files
benilla-octo/crates/benilla-ui/src/script/scrollframe.rs
T
SamandClaude Fable 5 dfa369127c the same-continent teleport fall is found and fixed
- a teleport between cities on one continent could release you before the destination had streamed in and drop you under the map — the residency publish now waits for the snap and names the tile its facts are about, so the loading screen provably holds until the ground is real
- nameplates stop jittering: plates of units you cannot see no longer shove the visible ones around, and the snap rides the correct pixel grid
- unit-name text takes its first fix — a string fits the width its own measure reported, and a re-bake reaches the screen
- books and plaques render through a real SimpleHTML widget inside a real ScrollFrame — strict parse, per-element fonts, block layout, and a scroll range measured from the whole subtree

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 17:43:01 +07:00

282 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The `ScrollFrame` method surface (`CSimpleScrollFrame`) — the ScrollFrame mechanism (decision
//! 0112, the engine's last structural gap): `SetScrollChild`/`GetScrollChild`, the vertical scroll
//! offset (`SetVerticalScroll`/`GetVerticalScroll`), and the live range
//! (`GetVerticalScrollRange`/`UpdateScrollChildRect`). Spec-faithful to the documented widget
//! contract (the Era `ScrollFrameTemplate` Lua drives its scrollbar off exactly these), not
//! byte-pinned — same posture as StatusBar's fill. Horizontal scroll (`SetHorizontalScroll`/…) is
//! out of scope: no 1.12 template drives it.
//!
//! The actual geometry — the scroll child's anchors pinned to the frame + the scroll offset, and
//! the clip every descendant of the child draws/hits within — lives in [`super::mod@super`]'s
//! `resolve`/`extract`/`hit_test` (the layout-graph override + the ancestor-walk clip helper); this
//! module is the thin Lua binding over the per-frame [`ScrollFrameState`], plus the two script
//! hooks (`OnVerticalScroll`/`OnScrollRangeChanged`) a scrollbar's `OnValueChanged`/`OnLoad` wires to.
//!
//! The methods live in their own registry table, consulted by the frame `__index` dispatcher only
//! for ScrollFrame frames — so duck-typing addons (`if frame.SetScrollChild then …`) see `nil` on
//! every other kind, exactly as against the client's per-class method sets.
use mlua::{Lua, Table, Value};
use super::object::{decode_id, frame_handle_of, frame_wrapper};
use super::{event, Model};
use crate::widget::{FrameHandle, KindState, ScrollFrameState};
/// Registry key of the ScrollFrame method table (the MAXCSTACK discipline: Lua-side root, named key).
pub(super) const REG_SCROLLFRAME_METHODS: &str = "__benilla_scrollframe_methods";
/// Run `f` over a frame's ScrollFrame state under one short write borrow. Errors if `this` is not a
/// live ScrollFrame (unreachable through the kind dispatcher, but the method table is a plain Lua
/// value — a caller could fish it out and misapply it).
fn with_scroll<T>(
lua: &Lua,
this: &Table,
f: impl FnOnce(&mut ScrollFrameState) -> T,
) -> mlua::Result<T> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let frame = model
.arena
.frame_mut(h)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
match &mut frame.kind_state {
KindState::Scroll(s) => Ok(f(s)),
_ => Err(mlua::Error::runtime("not a ScrollFrame")),
}
}
/// `GetVerticalScrollRange()`'s live computation: `max(0, contentExtent − frameHeight)` from the
/// **resolved** rects — `0.0` when the frame is unresolved or there is no child. Never cached: the
/// scroll offset always clamps against the current layout, not a stale snapshot.
///
/// **The content extent is the scroll child's whole SUBTREE, not the child frame's own height**
/// (wow-re `system/ui/scratch/simplehtml-markup-engine.md` §4.5, byte-identified during the
/// SimpleHTML RE; decision 1338 corrects what 0112 shipped). `0x786e30` seeds a bounding box
/// `{FLT_MAX, FLT_MAX, 0, 0}` and calls the **recursive** `0x786f80(scrollChild, &bbox)`, which
/// unions the frame's REGION list (head `+0x1b4`, link `+0x1b8`, guard `[entry+8]+0xc4`) and
/// re-enters itself for every CHILD FRAME (head `+0x2fc`, link `+0x300`, guard `[entry+8]+0xd0`);
/// the range stored at `[+0x31c]` is `max(0, bboxExtent − viewportExtent)`.
///
/// The distinction is not academic: stock `ItemTextPageScrollChild` is declared **10×10**
/// (`ItemTextFrame.xml` l.198) around a 270×304 SimpleHTML whose blocks extend well past it.
/// Measured as the child's own height the range is zero and a book cannot be scrolled at all;
/// measured as the subtree box it tracks the rendered text. Every ScrollFrame whose child is sized
/// directly — the guild-info EditBox, the options panes, every faux list — is unchanged, because
/// there its own rect already IS its subtree.
///
/// In **LOCAL units**, each height divided by the owning frame's effective scale — i.e. exactly
/// `child:GetHeight() - self:GetHeight()`, the widget contract this method states. The resolved
/// rects are screen px, and the scroll offset is NOT: `SetVerticalScroll` becomes the scroll
/// child's anchor y-offset, which the solver multiplies by that child's scale (`resolve`'s
/// override; `anchor_resolve_y`). Reporting a screen-px range against a local-unit offset makes
/// every SCALED ScrollFrame under-scroll by exactly its scale — the bar's max is the range, so it
/// stops short of the end and the tail of the content is unreachable. Invisible at scale 1.0,
/// which is every 1.12-native window; the era-scaled options window (ERA_WINDOW_SCALE 0.78) is the
/// first real-scroll customer that isn't, and it lost 22% of its travel.
fn scroll_range(model: &Model, h: FrameHandle) -> f32 {
let child = match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::Scroll(s)) => s.child,
_ => None,
};
let Some(child) = child else { return 0.0 };
let scale_of = |f: FrameHandle| {
model
.arena
.frame(f)
.map(|x| x.effective_scale)
.filter(|s| s.abs() >= 1e-6)
.unwrap_or(1.0)
};
let Some(frame_h) = model.resolved.get(&h).map(|r| r.height() / scale_of(h)) else {
return 0.0;
};
let Some((top, bottom)) = subtree_span(model, child) else {
return 0.0;
};
((top - bottom) / scale_of(child) - frame_h).max(0.0)
}
/// The vertical span `(top, bottom)` of `f`'s subtree in SCREEN px — its own resolved rect unioned
/// with every VISIBLE descendant region and child frame, recursively (`0x786f80`). `None` when
/// nothing in the subtree resolved.
///
/// Visibility is the client's own guard on both lists: a hidden block contributes no scroll range,
/// which is what stops a window's parked art from inventing travel nobody can use.
fn subtree_span(model: &Model, f: FrameHandle) -> Option<(f32, f32)> {
let frame = model.arena.frame(f)?;
let mut span: Option<(f32, f32)> = model.resolved.get(&f).map(|r| (r.top, r.bottom));
let union = |s: &mut Option<(f32, f32)>, t: f32, b: f32| {
*s = Some(match *s {
Some((ot, ob)) => (ot.max(t), ob.min(b)),
None => (t, b),
});
};
for &rh in &frame.regions {
if model.region_data.get(&rh).is_some_and(|d| d.hidden) {
continue;
}
if let Some(r) = model.region_resolved.get(&rh) {
union(&mut span, r.top, r.bottom);
}
}
for &ch in &frame.children {
if !model.arena.frame(ch).is_some_and(|c| c.effective_visible) {
continue;
}
if let Some((t, b)) = subtree_span(model, ch) {
union(&mut span, t, b);
}
}
span
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
// SetScrollChild(frame|name|nil) — a wrapper table or a name string resolves like every other
// frame-target arg (SetParent, SetPoint's relativeTo); nil (or an unresolvable target) clears.
m.set(
"SetScrollChild",
lua.create_function(|lua, (this, target): (Table, Value)| {
let new_child: Option<FrameHandle> = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
match &target {
Value::Table(t) => decode_id(t)
.ok()
.and_then(|id| model.id_to_frame.get(&id).copied()),
Value::String(s) => {
s.to_str().ok().and_then(|n| model.arena.lookup(n.as_ref()))
}
_ => None,
}
};
let changed = with_scroll(lua, &this, |s| {
let changed = s.child != new_child;
s.child = new_child;
changed
})?;
if changed {
// The child override is part of the resolve's read set (decision 0112's local
// anchor map — a fingerprint input), so a real re-target dirties tier 1.
lua.app_data_mut::<Model>().expect("model").touch_layout();
}
Ok(())
})?,
)?;
m.set(
"GetScrollChild",
lua.create_function(|lua, this: Table| {
let child = with_scroll(lua, &this, |s| s.child)?;
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
child
.filter(|&h| model.arena.frame(h).is_some())
.map(|h| model.frame_id(h))
};
match id {
Some(id) => Ok(Value::Table(frame_wrapper(lua, id)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// SetVerticalScroll(px) — clamps into [0, GetVerticalScrollRange()] (computed live), stores, and
// fires OnVerticalScroll(self, offset) — the scrollbar's OnValueChanged wiring's other half.
m.set(
"SetVerticalScroll",
lua.create_function(|lua, (this, px): (Table, f32)| {
let h = frame_handle_of(lua, &this)?;
let range = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
scroll_range(&model, h)
};
let clamped = px.clamp(0.0, range);
let changed = with_scroll(lua, &this, |s| {
let changed = s.vertical.to_bits() != clamped.to_bits();
s.vertical = clamped;
changed
})?;
if changed {
lua.app_data_mut::<Model>().expect("model").touch_layout();
}
fire_vertical_scroll(lua, &this, clamped)
})?,
)?;
m.set(
"GetVerticalScroll",
lua.create_function(|lua, this: Table| with_scroll(lua, &this, |s| s.vertical))?,
)?;
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))
})?,
)?;
// UpdateScrollChildRect() — the Era template calls this after resizing its content to re-drive
// its scrollbar. Our range is always live (never cached) against the RESOLVED rects — but a
// script resize (`child:SetHeight(...)`) lands in `LayoutInput` immediately, while `resolved`
// only catches up on the app's own next `resolve()` call (Lua runs before that each tick,
// `ui_script/mod.rs`'s drive loop). This is precisely the Era idiom this method exists to
// serve — resize the content, then immediately ask for the new range — so a stale `resolved`
// would make its very own primary caller see a one-tick-old (often wrong-by-a-lot, e.g. still
// "nothing to scroll") answer. Force a fresh resolve first: cheap on a quiet graph (the
// fixpoint's own doc — an unrelated resize elsewhere converges in one round), and correctness
// here matters more than dodging a resolve pass a caller who wants nothing stale already asked
// for by name.
m.set(
"UpdateScrollChildRect",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let (id, range) = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
super::UiScript::resolve_layout(&mut model);
let range = scroll_range(&model, h);
(model.frame_id(h), range)
};
// That forced resolve can have MOVED sizes — this method exists precisely because the
// caller just resized its content — so drain the queue before the range notify: the
// reference fires `OnSizeChanged` from `ApplyRect`, i.e. during the layout, not after.
event::fire_size_changes(lua);
if let Err(e) = event::fire_widget_handler(
lua,
id,
"OnScrollRangeChanged",
vec![Value::Number(0.0), Value::Number(f64::from(range))],
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.errors
.push(e.to_string());
}
Ok(())
})?,
)?;
lua.set_named_registry_value(REG_SCROLLFRAME_METHODS, m)?;
Ok(())
}
/// Fire `OnVerticalScroll(self, offset)` (the ScrollFrame's own script slot). Fired outside any
/// model borrow; errors go to [`Model::errors`] like every other widget handler.
fn fire_vertical_scroll(lua: &Lua, this: &Table, value: f32) -> mlua::Result<()> {
let id = {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.frame_id(h)
};
if let Err(e) = event::fire_widget_handler(
lua,
id,
"OnVerticalScroll",
vec![Value::Number(f64::from(value))],
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.errors
.push(e.to_string());
}
Ok(())
}