Files
benilla-octo/crates/benilla-ui/src/loader/widgets.rs
T
SamandClaude Fable 5 850f7df3b0 the chat dock exists, and alt-arrow keys are declined not swallowed
- the chat dock is a list, not a hardcoded pair: DOCKED_CHAT_FRAMES, SELECTED_DOCK_FRAME and the FCF verbs — DockFrame, UnDockFrame, DockUpdate, SelectDockFrame, SaveDock, SetTabPosition, Close and the button-side trio — are transcribed from FloatingChatFrame.lua, and every "for i = 1, 2" moved to what it meant, the dock or every window; the dock seeds at load from the window record's own docked byte handed to FCF_DockFrame, whose first line IS the reference's early return — transcribing the function that answers the way the reference answers, where an empty stub "because we have no dock" would have been the bandaid; its unconditional re-lock cannot clobber an unlock you saved, because the stored look is applied last; pfUI's RefreshChat now runs end to end
- ignoreArrows is alt-arrow mode, and the arrow is DECLINED, not swallowed: the reference's edit-box handler returns unconsumed on an arrow key, so the walk carries it past the box and it runs TURNLEFT — the whole feature is steering while the chat box holds focus, where the old reading left the arrows dead; holding Alt resumes the caret; SetAltArrowKeyMode and its getter land with the reference's own quirk that a missing argument enables while nil disables (the getter answers 1 or nil, never a boolean), SetIgnoreArrows turns out not to exist in 1.12 and is unpublished, and the binding pass gets a named four-key exemption rather than a hole, because the handler consumes every other path
- pfUI's square minimap is actually square: Minimap:SetMaskTexture makes the disc's mask state instead of a load-time constant — write-only from Lua, as shipped, no invented getter — where an empty path restores the round default and a failed load keeps the current mask and warns, because either mistake would otherwise hand a mistyped path a silently square map that looks like a feature; and MiniMapBattlefieldFrame grows the Border and Shine regions beside its icon, on the reference's own layers — icon on BACKGROUND, ring on ARTWORK above it, the only order a ring around an icon can draw in — which pfUI hides unguarded to sit the icon flush

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 19:51:11 +07:00

733 lines
39 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.
use mlua::{ObjectLike, Table};
use crate::framexml::{self, Element};
use super::{
abs_dim, abs_value, children_named, children_named_any, color_of, tex_coords_of, Loader,
};
impl Loader<'_> {
/// `<StatusBar>` LoadXML extras (RF-28, byte-verified table `0x782ef0`): `minValue`/`maxValue`
/// (a reversed pair is swapped — SetMinMaxValues normalizes identically), `defaultValue` →
/// SetValue, `orientation`, and the `<BarTexture>`/`<BarColor>` children; the element's
/// `drawLayer` names the bar texture's layer (widget default ARTWORK). The Slider's parallel
/// `<ThumbTexture>` path lives in [`Self::apply_slider`].
pub(super) fn apply_statusbar(&mut self, el: &Element, wrapper: &Table, dbg: &str) {
if !el.tag.eq_ignore_ascii_case("StatusBar") {
return;
}
let parse = |attr: &str| el.attr(attr).and_then(|v| v.trim().parse::<f32>().ok());
let (min, max) = (parse("minValue"), parse("maxValue"));
if min.is_some() || max.is_some() {
self.call(
wrapper,
"SetMinMaxValues",
(min.unwrap_or(0.0), max.unwrap_or(0.0)),
dbg,
);
}
if let Some(o) = el.attr("orientation") {
self.call(wrapper, "SetOrientation", o.to_string(), dbg);
}
let layer = el.attr("drawLayer").map(str::to_string);
for bar in children_named(el, "BarTexture") {
let color = children_named(bar, "Color").next().map(color_of);
if let Some(file) = bar.attr("file") {
self.call(
wrapper,
"SetStatusBarTexture",
(file.to_string(), layer.clone()),
dbg,
);
// File + `<Color>` DISCARDS the colour, it does not tint — the same
// `CSimpleTexture::LoadXML` ordering `regions.rs`'s `<Texture>` arm cites
// (`0x76fe20`: the child loop runs first, then `file=` overwrites the same `+0xcc`).
// `<BarColor>` below is this widget's real tint.
} else if let Some(c) = color {
// No file: a solid-color bar (the SetStatusBarTexture(r,g,b,a) form).
self.call(
wrapper,
"SetStatusBarTexture",
(c[0], c[1], c[2], c[3]),
dbg,
);
}
}
for bc in children_named(el, "BarColor") {
let c = color_of(bc);
self.call(wrapper, "SetStatusBarColor", (c[0], c[1], c[2], c[3]), dbg);
}
if let Some(v) = parse("defaultValue") {
self.call(wrapper, "SetValue", v, dbg);
}
}
/// `<Button>`/`<CheckButton>` extras (RF-28 `0x7788c0`/`0x785170` — the checkbox loader runs
/// the button one first, which this shared body mirrors): the four state textures, CheckButton's
/// two checked textures + `checked` attr, `<ButtonText text=>`, and the `text` attribute. Each
/// texture child takes a `file` or a `<Color>` (the same two forms as the Set*Texture methods)
/// plus the generic region layout — `<Size>`/`<Anchors>`/`setAllPoints` — so a state texture can
/// cover less than its button (the merchant row's icon-scoped highlight); `$parent` in a state
/// texture's anchors resolves against this button's own name, like a `<Layers>` region's.
/// Not modeled (stated): per-state fonts/colors, `<PushedTextOffset>`.
pub(super) fn apply_button(
&mut self,
el: &Element,
wrapper: &Table,
self_name: &str,
dbg: &str,
) {
let is_check = el.tag.eq_ignore_ascii_case("CheckButton");
if !is_check && !el.tag.eq_ignore_ascii_case("Button") {
return;
}
let tex = |tag: &str, method: &str, this: &mut Self| {
for raw in children_named(el, tag) {
// A state texture may `inherits=` a virtual `<Texture>` — `<NormalTexture
// inherits="UIPanelButtonUpTexture"/>` is how the reference's whole shared button
// kit carries its art, and it is the ONLY form those templates use. Expanding here
// is the same call a `<Layers>` region gets (`expand_region`, which passes a
// non-template `inherits=` through untouched), and without it the reads below found
// no `file=`, no `<TexCoords>` and no `<Size>`: a button with no art and NO ERROR.
let expanded = this.expand_region(raw);
let t = &expanded;
if let Some(file) = t.attr("file") {
this.call(wrapper, method, file.to_string(), dbg);
} else if let Some(c) = children_named(t, "Color").next().map(color_of) {
this.call(wrapper, method, (c[0], c[1], c[2], c[3]), dbg);
} else if t.name().is_some() {
// A NAMED state texture with no art of its own — `<NormalTexture
// name="$parentIcon">` on the ref's own MacroFrameButtonTemplate, whose art
// arrives later through `SetTexture`. The setter is what MATERIALIZES the
// slot's region, so without this call the getter below finds nothing and the
// name never publishes: `getglobal("MacroButton1Icon")` would be nil and the
// window's whole Update would die on it. `""` is the live API's own blank form
// (`set_slot_texture`'s empty-string arm), so this creates without painting.
this.call(wrapper, method, String::new(), dbg);
}
// alphaMode / <Size> / <Anchors> / <TexCoords> apply to the region the setter just
// created — fetch it back through the matching getter and use the region methods.
let getter = method.replacen("Set", "Get", 1);
if let Ok(region) = wrapper.call_method::<Table>(getter.as_str(), ()) {
if let Some(mode) = t.attr("alphaMode") {
this.call_region(&region, "SetBlendMode", mode.to_string(), dbg);
}
// The setter above MATERIALIZED the region with the runtime path's implicit
// SetAllPoints (decision 1310) — but this is the XML path, where the real
// engine routes state textures through the region adder (`0x778903`…: authored
// `<Anchors>` first, the implicit step after). Reproduce that order: clear,
// apply the authored layout, then re-run the conditional step — a same-point
// SetPoint over a live implicit corner would otherwise leave the OTHER corner
// standing (the slot law) and weld an anchored state texture to the button.
this.call_region(&region, "ClearAllPoints", (), dbg);
this.apply_region_layout(t, &region, self_name, dbg);
if let Err(e) = crate::script::implicit_creation_anchor_lua(this.lua, &region) {
this.report
.errors
.push(format!("{dbg}: implicit anchor: {e}"));
}
if let Some(tc) = tex_coords_of(t) {
this.call_region(&region, "SetTexCoord", tc, dbg);
}
// Publish a NAMED state texture (`<HighlightTexture
// name="$parentHighlightTexture">`) — the ref kit addresses these by global
// (the tab template's own OnShow does `getglobal(name.."HighlightTexture")`),
// exactly like a named `<Layers>` region or the ButtonText.
if let Some(rname) = t.name().map(|raw| framexml::resolve_name(raw, self_name))
{
if let Err(e) = this.lua().globals().set(rname.clone(), region) {
this.report
.warnings
.push(format!("{dbg}: state-texture global '{rname}': {e}"));
}
}
}
}
};
// The LABEL is published before the state textures, and the order is load-bearing: a
// state texture may anchor to `$parentText` (the reference's own sort-header template
// hangs its arrow off the label's RIGHT), and this loader resolves `relativeTo` eagerly by
// name at SetPoint time. Applied the other way round, every such anchor missed and fell
// back to the owner — 14 of them in one window, each a sort arrow sitting in the wrong
// place. The real client is order-free here because it resolves anchors later; we are not,
// so we order it ourselves rather than making each author work around it.
// **Both spellings of the label** (`ButtonText` | `NormalText`), in document order.
//
// `<NormalText>` is not a synonym bolted on here — it is the binary's own second name for
// this slot, and it does strictly more: `CSimpleButton::LoadXML 0x7788c0` constructs the
// label `CSimpleFontString` on the `<NormalText>` leg (`0x778b6c call 0x770d30`), clears
// its `ownsFontAttrs` (`0x778b7b` — the ONE site image-wide that clears it), hands it to
// the shared adopter `0x778d20`, and runs its `LoadXML 0x770f40` with the whole
// font-attribute surface skipped, because the SAME node is fed separately to the button's
// persistent Normal-state `CSimpleFont` at `+0x33c` (`0x778ba9`/`0x778baf call 0x783c30`).
// One owner per attribute, no double application — which is why this pass reads the
// element's geometry and name and the state-font pass below reads its `inherits=`/`font=`,
// and neither reads the other's. (wow-re `scratch/fontstring-loadxml-font-attrs.md` §3/§7,
// `scratch/resize-bounds-and-button-fontstring.md` §5.3 — all VERIFIED off the bytes.)
//
// The reference FrameXML writes only `<ButtonText>` (31 sites, 16 of them
// `name="$parentText"`) and never `<NormalText>`, so nothing we ship could notice the
// second spelling was missing. A third-party template is where it shows: KLHThreatMeter's
// `<Button name="KLHTM_SelfHeaderStringTemplate" virtual="true"><NormalText
// name="$parentText" …>` left `KLHTM_SelfFrameHeaderNameText` nil, and the addon died in
// its `PLAYER_LOGIN` handler on exactly that global.
for bt in children_named_any(el, &["ButtonText", "NormalText"]) {
// Create the label slot even with no text yet (the geometry below must land on a real
// region; SetText is the slot's lazy constructor), then apply the element's own
// `<Size>/<Anchors>`/justify to it — the ref anchors ButtonText all over (the quest
// greeting rows hang theirs at TOPLEFT+20 beside the bullet; without this every
// labelled Button centered its text over the whole face).
// `<ButtonText text=>` is a FontString's own attribute — the same global-string lookup
// rf28 l.115 gives every `<FontString text=>`. See `Loader::resolve_text`.
let label = match bt.attr("text") {
Some(raw) => self.resolve_text(raw, dbg),
None => String::new(),
};
// A NAMED `<ButtonText name="$parentText">` is created as a named region and bound,
// rather than left to `SetText`'s lazy constructor and aliased afterwards.
//
// Publishing a Lua global is not the same thing as naming the region, and the gap was
// invisible until something anchored to it: `relativeTo="$parentText"` resolves through
// the engine's own named-region lookup, which a global alias never reaches, so every
// such anchor missed and fell back to the owner. `GetFontString():GetName()` also
// answered nil, which is wrong for any addon that asks.
let bt_name = bt.name().map(|raw| framexml::resolve_name(raw, self_name));
if let Some(rname) = bt_name.clone() {
match wrapper
.call_method::<Table>("CreateFontString", (rname, Option::<String>::None))
{
Ok(fs) => self.call(wrapper, "SetFontString", fs, dbg),
Err(e) => self
.report
.warnings
.push(format!("{dbg}: ButtonText CreateFontString: {e}")),
}
}
self.call(wrapper, "SetText", label, dbg);
if let Ok(region) = wrapper.call_method::<Table>("GetFontString", ()) {
// Same clear→layout→implicit order as the state textures above (decision 1310):
// SetText materialized the label with the runtime path's implicit CENTER anchor;
// the XML path re-derives it AFTER the element's own `<Anchors>`/justify apply
// (so a `<ButtonText justifyH="LEFT">` with no anchors seats LEFT, as the real
// FontString post-step `0x771480` would).
self.call_region(&region, "ClearAllPoints", (), dbg);
self.apply_region_layout(bt, &region, self_name, dbg);
if let Err(e) = crate::script::implicit_creation_anchor_lua(self.lua, &region) {
self.report
.errors
.push(format!("{dbg}: implicit anchor: {e}"));
}
// Publish the label under its resolved name (`<ButtonText name="$parentText">`) —
// the ref kit addresses tab/button labels by exactly this global
// (PanelTemplates_TabResize's `getglobal(tabName.."Text")`).
if let Some(rname) = bt_name {
if let Err(e) = self.lua().globals().set(rname.clone(), region) {
self.report
.warnings
.push(format!("{dbg}: ButtonText global '{rname}': {e}"));
}
}
}
}
tex("NormalTexture", "SetNormalTexture", self);
tex("PushedTexture", "SetPushedTexture", self);
tex("DisabledTexture", "SetDisabledTexture", self);
tex("HighlightTexture", "SetHighlightTexture", self);
if is_check {
tex("CheckedTexture", "SetCheckedTexture", self);
tex("DisabledCheckedTexture", "SetDisabledCheckedTexture", self);
if let Some(c) = el.attr("checked") {
let checked = c.eq_ignore_ascii_case("true") || c == "1";
self.call(wrapper, "SetChecked", checked, dbg);
}
}
if let Some(text) = el.attr("text") {
// `<Button text=>` → the ButtonText fontstring, global-string resolved (rf28 l.36,
// `0x703bf0`). This is what makes the reference's `text="DELETE"` read "Delete".
let text = self.resolve_text(text, dbg);
self.call(wrapper, "SetText", text, dbg);
}
// The per-state label fonts (`<NormalFont inherits=>` etc. — UIPanelButtonTemplate's
// gold/white/gray label trio) → the 1.12 setter trio; every occurrence applies in
// document order (same last-wins rule as `apply_size`).
for (children, method) in [
(["NormalFont", "NormalText"], "SetTextFontObject"),
(["HighlightFont", "HighlightText"], "SetHighlightFontObject"),
(["DisabledFont", "DisabledText"], "SetDisabledFontObject"),
] {
for f in children_named_any(el, &children) {
// These three are `<Font>`-TYPED elements — `CSimpleButton::LoadXML 0x7788c0`
// routes them at `0x778bf4` into the SAME `0x783c30` a top-level `<Font>` uses — so
// they take `inherits=` and `font=` alike, and `font=` wins: both land in one slot
// and `0x770c60` unlinks the previous parent (wow-re
// `fontstring-loadxml-font-attrs.md` C5; `font=`'s registry-first path is
// `0x783d15` → `0x783d22 call 0x770c60` → `0x783d27 jmp 0x783ee0`).
//
// Reading only `inherits=` here left every corpus button that writes `font=` on its
// own default font — Bagnon writes it at seven sites, which is why its character
// list's names and its "Show Bags" label were not the Large/normal faces they ask
// for. Real FrameXML always writes `inherits=`, so nothing we ship noticed.
//
// `style=` is deliberately NOT read: it does not exist in 1.12.1. An isolated-token
// scan for it returns zero against nine controls that each return one — it is a
// later-client idiom, and the reference writes `<NormalFont inherits="GameFontNormal"/>`
// (`UIPanelTemplates.xml:20-22`).
for attr in ["inherits", "font"] {
if let Some(name) = f.attr(attr) {
self.call(wrapper, method, name.to_string(), dbg);
}
}
// An element-level justify (`<NormalFont inherits="QuestFont" justifyH="LEFT"/>`)
// lands on the label region itself — v1: one region-level set (the ref declares
// the same justify on all three state elements; a per-state justify would need
// per-state font-object clones).
if let Some(j) = f.attr("justifyH") {
// Ensure the slot exists WITHOUT clobbering a label the `<ButtonText>`/`text=`
// handling above already set: only a missing slot gets the creating SetText("").
if wrapper.call_method::<Table>("GetFontString", ()).is_err() {
self.call(wrapper, "SetText", String::new(), dbg);
}
if let Ok(region) = wrapper.call_method::<Table>("GetFontString", ()) {
self.call_region(&region, "SetJustifyH", j.to_string(), dbg);
}
}
}
}
}
/// `<EditBox>` extras (RF-0082 §"THE STRUCTURAL KEY"/§3): the `letters` cap → SetMaxLetters,
/// `historyLines` → SetHistoryLines (the submitted-line recall ring), and the config flags
/// `autoFocus`/`numeric`/`password`/`multiLine`/`ignoreArrows` → their setters.
/// A flag absent from the XML stays at its ctor default, and the ctor's own value is
/// `flags = 1` — **`autoFocus` defaults ON**, every other flag off (`0x779a29 mov eax,1` /
/// `0x779a2e mov [esi+0x318],eax`; LoadXML's `autoFocus` leg writes nothing for an absent or
/// empty attribute, `0x77a0b3`/`0x77a0b8`). So the UI.xsd's `true` default is the client's too,
/// and the divergence documented here — benilla applying `flags = 0` uniformly — is retired
/// (decision 1686). Its stated justification, "autoFocus never focuses on show", was wow-re's
/// own `call`-only-census error, corrected 2026-08-29.
///
/// **The flags are read presence-aware.** This loop used to call the setter only when an
/// attribute parsed as `true`, so `autoFocus="false"` was a no-op — harmless while the default
/// was off, and precisely backwards once it is on: the ten boxes in the shipped chain that opt
/// OUT (MailFrame ×3, MoneyInputFrame ×3, FriendsFrame ×3, AddonList ×1) are the only places
/// the attribute appears at all.
/// `<TextInsets>` maps to `SetTextInsets`; `blinkSpeed` → SetBlinkSpeed (the caret
/// half-period, `E+0x370`). A declared `<FontString>` is ASSIGNED as the box's text region by
/// the special-fontstring pass (`adopt_text_region` — the engine's LoadXML slot, never a
/// search).
pub(super) fn apply_editbox(&mut self, el: &Element, wrapper: &Table, dbg: &str) {
if !el.tag.eq_ignore_ascii_case("EditBox") {
return;
}
if let Some(n) = el
.attr("letters")
.and_then(|v| v.trim().parse::<i64>().ok())
{
self.call(wrapper, "SetMaxLetters", n, dbg);
}
if let Some(n) = el
.attr("historyLines")
.and_then(|v| v.trim().parse::<i64>().ok())
{
self.call(wrapper, "SetHistoryLines", n, dbg);
}
if let Some(s) = el
.attr("blinkSpeed")
.and_then(|v| v.trim().parse::<f32>().ok())
{
self.call(wrapper, "SetBlinkSpeed", s, dbg);
}
// <TextInsets><AbsInset left= right= top= bottom=/></TextInsets> → SetTextInsets(l,r,t,b)
// (the ref chat box drives these from ChatEdit_UpdateHeader at runtime; the XML form seeds
// the static case).
if let Some(ins) = children_named(el, "TextInsets").next() {
let src = children_named(ins, "AbsInset").next().unwrap_or(ins);
let get = |k: &str| {
src.attr(k)
.and_then(|v| v.trim().parse::<f32>().ok())
.unwrap_or(0.0)
};
let (l, r, t, b) = (get("left"), get("right"), get("top"), get("bottom"));
self.call(wrapper, "SetTextInsets", (l, r, t, b), dbg);
}
for (attr, method) in [
("autoFocus", "SetAutoFocus"),
("numeric", "SetNumeric"),
("password", "SetPassword"),
("multiLine", "SetMultiLine"),
// The XML spelling of the alt-arrow flag; the Lua spelling is `SetAltArrowKeyMode`,
// and there is one flag behind both (wow-re `ignorearrows-alt-arrow-gate.md`).
("ignoreArrows", "SetAltArrowKeyMode"),
] {
if let Some(on) = el.attr_bool_opt(attr) {
self.call(wrapper, method, on, dbg);
}
}
}
/// The two message-frame classes' LoadXML extras — **`0x787b20` and `0x785910`, two separate
/// tables** (msgframe-runtime.md's XML-attribute section), which is why the shared attrs are
/// applied for either tag and the two divergent ones are gated on the tag that has them:
///
/// - both: `displayDuration`/`fadeDuration` (float, applied **iff `> 0`** — the client's own
/// gate) → SetTimeVisible/SetFadeDuration, and the `fade` bool → SetFading.
/// - `<ScrollingMessageFrame>` only: `maxLines` (int, `> 0`, destructive `SetMaxLines`).
/// - `<MessageFrame>` only: `insertMode` → SetInsertMode. The scrolling class has no such
/// attribute and no such binding.
///
/// A missing attr keeps the ctor default (fading on, 10s/3s; 8 lines / insertMode BOTTOM). The
/// `<FontString>` child renders through the generic `<Layers>` path; its resolved font **and its
/// `justifyH`** are what the frame's lines bake, stack and align at (read at extract,
/// `crate::script::UiScript::extract`) — that child is how `UIErrorsFrame.xml` centres its
/// toasts and how a chat frame keeps its lines flush left.
/// `<Minimap>`'s LoadXML extras (`CMinimap::LoadXML 0x4ee2b0`): the two model-file attributes,
/// which name the nine `Model` children the arena's ctor already built
/// (`crate::widget::MINIMAP_ENGINE_CHILDREN`). Both have engine defaults, so an attribute-less
/// `<Minimap>` still gets the stock arrows — the reference reads the default string when the
/// attribute is absent rather than leaving the children file-less.
///
/// Gated on the element's own tag like every other step here, so a plain `<Frame>` wearing a
/// Minimap template simply skips it.
pub(super) fn apply_minimap(&mut self, el: &Element, wrapper: &Table, dbg: &str) {
if !el.tag.eq_ignore_ascii_case("Minimap") {
return;
}
let arrow = el
.attr("minimapArrowModel")
.filter(|s| !s.is_empty())
.unwrap_or(crate::widget::MINIMAP_DEFAULT_ARROW_MODEL);
let player = el
.attr("minimapPlayerModel")
.filter(|s| !s.is_empty())
.unwrap_or(crate::widget::MINIMAP_DEFAULT_PLAYER_MODEL);
if let Err(e) = crate::script::apply_minimap_model_attrs(self.lua, wrapper, arrow, player) {
self.warn_once(
"minimap:models",
format!("{dbg}: <Minimap> model attributes: {e}"),
);
}
}
pub(super) fn apply_messageframe(&mut self, el: &Element, wrapper: &Table, dbg: &str) {
let scrolling = el.tag.eq_ignore_ascii_case("ScrollingMessageFrame");
let plain = el.tag.eq_ignore_ascii_case("MessageFrame");
if !scrolling && !plain {
return;
}
if scrolling {
if let Some(n) = el
.attr("maxLines")
.and_then(|v| v.trim().parse::<i64>().ok())
{
if n > 0 {
self.call(wrapper, "SetMaxLines", n, dbg);
}
}
}
if plain {
if let Some(mode) = el.attr("insertMode") {
self.call(wrapper, "SetInsertMode", mode.trim().to_string(), dbg);
}
}
if let Some(s) = el
.attr("displayDuration")
.and_then(|v| v.trim().parse::<f32>().ok())
{
if s > 0.0 {
self.call(wrapper, "SetTimeVisible", s, dbg);
}
}
if let Some(s) = el
.attr("fadeDuration")
.and_then(|v| v.trim().parse::<f32>().ok())
{
if s > 0.0 {
self.call(wrapper, "SetFadeDuration", s, dbg);
}
}
if let Some(fade) = el.attr("fade") {
let on = fade.eq_ignore_ascii_case("true") || fade == "1";
self.call(wrapper, "SetFading", on, dbg);
}
}
/// `<Slider>` LoadXML extras (RF-28, byte-verified table `0x789580`): `orientation` →
/// SetOrientation (the ctor default is VERTICAL — decision 0250 — so an omitted attr leaves a
/// vertical scrollbar); `minValue`/`maxValue` → SetMinMaxValues, with `valueStep` → SetValueStep
/// and `defaultValue` → SetValue — the latter three **gated on BOTH minValue AND maxValue
/// present** (RF-28). Unlike StatusBar, a reversed `min > max` pair is NOT swapped. The
/// `<ThumbTexture>` child → SetThumbTexture (file or `<Color>`) plus the generic region layout
/// (`<Size>`/`<Anchors>`/`<TexCoords>`/alphaMode); the element's `drawLayer` names the thumb's
/// layer (widget default OVERLAY). The scrollbar template declares only the thumb + orientation;
/// its range/value are set at runtime by `FauxScrollFrame_Update`.
pub(super) fn apply_slider(
&mut self,
el: &Element,
wrapper: &Table,
self_name: &str,
dbg: &str,
) {
if !el.tag.eq_ignore_ascii_case("Slider") {
return;
}
if let Some(o) = el.attr("orientation") {
self.call(wrapper, "SetOrientation", o.to_string(), dbg);
}
let parse = |attr: &str| el.attr(attr).and_then(|v| v.trim().parse::<f32>().ok());
if let (Some(min), Some(max)) = (parse("minValue"), parse("maxValue")) {
self.call(wrapper, "SetMinMaxValues", (min, max), dbg);
if let Some(step) = parse("valueStep") {
self.call(wrapper, "SetValueStep", step, dbg);
}
if let Some(v) = parse("defaultValue") {
self.call(wrapper, "SetValue", v, dbg);
}
}
let layer = el.attr("drawLayer").map(str::to_string);
for raw in children_named(el, "ThumbTexture") {
// Same two rules as a Button's state textures, for the same reasons: `inherits=` on a
// virtual `<Texture>` is expanded here (see `apply_button`), and a NAMED thumb is
// published as a global — `UIPanelScrollBarTemplate`'s own
// `ScrollFrame_OnScrollRangeChanged` reaches it with
// `getglobal(bar:GetName().."ThumbTexture")`.
let expanded = self.expand_region(raw);
let tt = &expanded;
if let Some(file) = tt.attr("file") {
self.call(
wrapper,
"SetThumbTexture",
(file.to_string(), layer.clone()),
dbg,
);
} else if let Some(c) = children_named(tt, "Color").next().map(color_of) {
self.call(wrapper, "SetThumbTexture", (c[0], c[1], c[2], c[3]), dbg);
}
// alphaMode / <Size> / <Anchors> / <TexCoords> apply to the thumb region the setter just
// created — fetch it back through the getter and use the region methods (same shape as a
// Button's state textures).
if let Ok(region) = wrapper.call_method::<Table>("GetThumbTexture", ()) {
if let Some(mode) = tt.attr("alphaMode") {
self.call_region(&region, "SetBlendMode", mode.to_string(), dbg);
}
self.apply_region_layout(tt, &region, self_name, dbg);
if let Some(tc) = tex_coords_of(tt) {
self.call_region(&region, "SetTexCoord", tc, dbg);
}
if let Some(rname) = tt.name().map(|raw| framexml::resolve_name(raw, self_name)) {
crate::script::region::publish_region_name(self.lua(), &rname, &region);
if let Err(e) = self.lua().globals().set(rname.clone(), region) {
self.report
.warnings
.push(format!("{dbg}: thumb-texture global '{rname}': {e}"));
}
}
}
}
}
/// `<ColorSelect>`'s four texture sub-elements (RF-28 loader hooks `0x78b580`, `0x78b850`,
/// `0x78b8a0`, `0x78ba90`) — the hue wheel, the brightness strip, and a marker for each.
/// Structurally `apply_slider`'s `<ThumbTexture>` loop, four times over.
///
/// The one thing that is NOT like a thumb: **two of the four carry no `file=`, and that is not
/// an omission.** The client generates the wheel and the strip; there is no BLP in the chain
/// that is a colour wheel. So the setter is still called — with nothing — because the *region*
/// must exist regardless: it is what layout resolves, what the press handler hit-tests, and
/// what the app renderer paints into. An element that skipped the setter would leave the picker
/// with no wheel to click.
pub(super) fn apply_colorselect(
&mut self,
el: &Element,
wrapper: &Table,
self_name: &str,
dbg: &str,
) {
if !el.tag.eq_ignore_ascii_case("ColorSelect") {
return;
}
let layer = el.attr("drawLayer").map(str::to_string);
for (tag, setter, getter) in [
(
"ColorWheelTexture",
"SetColorWheelTexture",
"GetColorWheelTexture",
),
(
"ColorWheelThumbTexture",
"SetColorWheelThumbTexture",
"GetColorWheelThumbTexture",
),
(
"ColorValueTexture",
"SetColorValueTexture",
"GetColorValueTexture",
),
(
"ColorValueThumbTexture",
"SetColorValueThumbTexture",
"GetColorValueThumbTexture",
),
] {
for raw in children_named(el, tag) {
let expanded = self.expand_region(raw);
let tt = &expanded;
if let Some(file) = tt.attr("file") {
self.call(wrapper, setter, (file.to_string(), layer.clone()), dbg);
} else if let Some(c) = children_named(tt, "Color").next().map(color_of) {
self.call(wrapper, setter, (c[0], c[1], c[2], c[3]), dbg);
} else {
// The file-less form — create the region and leave it for the renderer.
self.call(wrapper, setter, (), dbg);
}
if let Ok(region) = wrapper.call_method::<Table>(getter, ()) {
if let Some(mode) = tt.attr("alphaMode") {
self.call_region(&region, "SetBlendMode", mode.to_string(), dbg);
}
self.apply_region_layout(tt, &region, self_name, dbg);
if let Some(tc) = tex_coords_of(tt) {
self.call_region(&region, "SetTexCoord", tc, dbg);
}
if let Some(rname) = tt.name().map(|raw| framexml::resolve_name(raw, self_name))
{
// Into the region-name registry as well as `_G`: `<ColorValueTexture>`
// anchors to `ColorPickerWheel` BY NAME, and a name that only reaches the
// globals resolves to nothing there.
crate::script::region::publish_region_name(self.lua(), &rname, &region);
if let Err(e) = self.lua().globals().set(rname.clone(), region) {
self.report
.warnings
.push(format!("{dbg}: {tag} global '{rname}': {e}"));
}
}
}
}
}
}
/// `<SimpleHTML>` LoadXML extras — `CSimpleHTML::LoadXML 0x78a130`, whose whole job past the
/// base `CSimpleFrame::LoadXML 0x769820` is to fill the four **element fonts** and the
/// hyperlink format (wow-re `simplehtml-markup-engine.md` §5.5):
///
/// - attribute **`font="NAME"`** (`0x78a152`) — looked up as a font object and `SetFontObject`ed
/// onto **all four** elements at once (the `edi = 4` loop at `0x78a17a`), with
/// `"Couldn't find font object named %s"` on a miss.
/// - child **`<FontString>`** → `elementFont[0]` (`P`), **`<FontStringHeader1|2|3>`** →
/// `[1]`/`[2]`/`[3]` (`0x78a1fe`…`0x78a26e`), each through `CSimpleFont::LoadXML 0x783c30`.
/// - attribute **`hyperlinkFormat`** (`0x87a87c` → `0x78a540`) and attribute **`file`**
/// (`0x8710b8`), the latter a localized-string lookup fed straight to `SetText`.
///
/// **These `<FontString>` children are font DECLARATIONS, not regions**, which is why
/// [`Self::apply_special_fontstrings`] skips a `<SimpleHTML>`: creating a real FontString for
/// one would put an extra, unanchored string on the frame and leave the element font empty.
///
/// Stock `ItemTextFrame.xml` declares exactly one `<FontString inherits="ItemTextFontNormal"/>`,
/// which is the whole reason an `<H1>` in a `page_text` body renders at the `<P>` size: nothing
/// declares a header font, so `0x78ae30`'s empty-path test sends every element back to `P`'s.
pub(super) fn apply_simplehtml(&mut self, el: &Element, wrapper: &Table, dbg: &str) {
if !el.tag.eq_ignore_ascii_case("SimpleHTML") {
return;
}
if let Some(fmt) = el.attr("hyperlinkFormat") {
self.call(wrapper, "SetHyperlinkFormat", fmt.to_string(), dbg);
}
// `font=` on the widget itself paints all four elements; a per-element child below can
// still override any of them, exactly as the reference's ordering allows.
if let Some(name) = el.attr("font").filter(|n| !n.is_empty()) {
if self.is_font_object(name) {
for elem in ["P", "H1", "H2", "H3"] {
self.call(wrapper, "SetFontObject", (elem, name.to_string()), dbg);
}
} else {
self.warn_once(
&format!("shtmlfont:{name}"),
format!("{dbg}: <SimpleHTML font=\"{name}\">: couldn't find font object"),
);
}
}
for child in &el.children {
let Some(elem) = crate::script::simplehtml_element_of_xml_tag(&child.tag) else {
continue; // <Size>/<Anchors>/<Scripts>/<Layers>/... have their own passes
};
let child = &self.expand_region(child);
self.apply_element_font(child, wrapper, elem, dbg);
}
if let Some(raw) = el.attr("file") {
let text = self.resolve_text(raw, dbg);
self.call(wrapper, "SetText", text, dbg);
}
}
/// One `<FontString>`/`<FontStringHeaderN>` child of a `<SimpleHTML>` → one element font.
///
/// The `inherits=` → `font=` gate is the same three-outcome law a `<Layers>` `<FontString>`
/// takes ([`Self::apply_fontstring_font`], `0x7710e1`-`0x771254`): a `font=` naming a registered
/// object is a `SetFontObject` and **skips** `<FontHeight>`/`outline=` entirely; a `font=`
/// naming anything else is a file with those two as its companions; no `font=` at all means
/// neither is ever parsed. Only `<Color>`/`<Shadow>`/`justifyH`/`justifyV`/`spacing` sit past
/// that join and are genuine post-link overrides.
fn apply_element_font(&mut self, el: &Element, wrapper: &Table, elem: usize, dbg: &str) {
let name = ["P", "H1", "H2", "H3"][elem];
if let Some(inherits) = el.attr("inherits").filter(|n| !n.is_empty()) {
let resolved = self
.font_object_through_templates(inherits)
.unwrap_or_else(|| inherits.to_string());
self.call(wrapper, "SetFontObject", (name, resolved), dbg);
}
match el.attr("font") {
Some(f) if self.is_font_object(f) => {
self.call(wrapper, "SetFontObject", (name, f.to_string()), dbg);
}
Some(path) => {
let height = children_named(el, "FontHeight").last().and_then(abs_value);
let outline = el.attr("outline").map(str::to_string);
if let Err(e) = crate::script::apply_simplehtml_font_parts(
self.lua,
wrapper,
elem,
Some(path.to_string()),
height,
outline,
) {
self.report
.errors
.push(format!("{dbg}: <SimpleHTML> {name} font attrs: {e}"));
}
}
None => {}
}
if let Some(c) = children_named(el, "Color").last().map(color_of) {
self.call(wrapper, "SetTextColor", (name, c[0], c[1], c[2], c[3]), dbg);
}
if let Some(sh) = children_named(el, "Shadow").last() {
if let Some(c) = children_named(sh, "Color").next().map(color_of) {
self.call(
wrapper,
"SetShadowColor",
(name, c[0], c[1], c[2], c[3]),
dbg,
);
}
if let Some((x, y)) = children_named(sh, "Offset").next().map(abs_dim) {
self.call(
wrapper,
"SetShadowOffset",
(name, x.unwrap_or(0.0), y.unwrap_or(0.0)),
dbg,
);
}
}
if let Some(j) = el.attr("justifyH") {
self.call(wrapper, "SetJustifyH", (name, j.to_string()), dbg);
}
if let Some(j) = el.attr("justifyV") {
self.call(wrapper, "SetJustifyV", (name, j.to_string()), dbg);
}
if let Some(sp) = el
.attr("spacing")
.and_then(|v| v.trim().parse::<f32>().ok())
{
self.call(wrapper, "SetSpacing", (name, sp), dbg);
}
}
}