- `UnitHasRelicSlot` is real in 1.12 and benilla had no binding for it: `0x519e50` reads `ChrClasses.dbc` field 16 — set for Paladin, Shaman and Druid in `patch.MPQ`'s 17-column copy (the base `dbc.MPQ` copy has 16 columns and no such field, which is how the "always false in vanilla" belief survived twice in decisions and seven times in code) — and stock `PaperDollFrame.lua` calls it unconditionally, so the character sheet would have raised for EVERY class; a druid, paladin or shaman's sheet now hides the ammo slot, shows the relic plate where the bow was, tooltips "Relic", and reports ranged Attack/Damage/Power as N/A, all four branches transcribed from the reference - the loot window is the reference's own `LootFrame.xml`: `LootButton` is built as a real widget type rather than aliased (a Button plus one slot dword and one overridden OnClick — right-click loots exactly like left, no shift/ctrl/alt takes, a row whose Lua handler errored still loots, a fast double-click takes two slots), `LOOT_SLOT_CLEARED` fires per row again because the stock file hangs hide-in-place AND the emptied-page auto-advance off it, `EditBox:Insert(nil)` is a no-op as `lua_tostring` makes it, and an alias `GameTooltip.xml` had quietly become a reader of would have turned every item tooltip white, silently, had it just gone nil - the vendor cursor: `PickupMerchantItem` is two verbs behind one name, decided by what the cursor holds and never by the caller — a held bag item SELLS (index ignored, no merchant-open gate), otherwise the vendor row is grabbed as cursor mode 5, which is invisible to `CursorHasItem` (modes 1 and 9 only) so all thirteen stock gates stay closed while it is held; the drop is the buy, `CMSG_BUY_ITEM_IN_SLOT`, count 1, into a bag or an equipment slot, and every refusal is silent and clears the cursor; the four `Model_*` rotation handlers land in UIParent where the reference keeps them - the gap report reads `:Method(` sites now — it found `SetMerchantCompareItem`, which would have raised on the first vendor-row hover with every check green, and `LootButton:SetSlot`, and 28 "free" windows became 25; the shared coin kit moved out of the merchant window into `MoneyFrame.xml` so the eight windows that draw coins through it can reach it without loading a vendor Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
892 lines
49 KiB
Rust
892 lines
49 KiB
Rust
//! The widget object model — the frame arena and the show/hide/strata/level/scale/alpha
|
||
//! mutations, transcribed from wow-5875-re's binary-verified propagation cluster (decision 0068).
|
||
//!
|
||
//! This crate owns the *model*, not the runtime: the arena stores frames and their region leaves and
|
||
//! implements the propagation math, but it does **not** run Lua or fire `OnShow`/`OnHide` — the
|
||
//! mutations that cause visibility transitions *return the set of frames that changed* so the
|
||
//! Lua-embedding layer (the app) can fire the handlers in order.
|
||
//!
|
||
//! ## Ground truth (wow-5875-re `system/ui/scratch/`, binary-verified against `WoW.exe`)
|
||
//!
|
||
//! - **Effective visibility** (`effective_visible_show 0x76ae10` / `_hide 0x76ad50`,
|
||
//! `propagation.md`): `effectiveVisible = shown AND (parent == null OR parent.effectiveVisible)`.
|
||
//! A transition recurses into child frames — **a hidden mid-tree frame blocks its whole subtree** —
|
||
//! and fires `OnShow`/`OnHide`. The recursion is *transition-gated*: a node whose effective
|
||
//! visibility does not change stops the walk (its subtree cannot have changed).
|
||
//! - **Strata** (`set_frame_strata 0x76a470`): the **whole subtree is forced to one stratum** — the
|
||
//! frame's strata is set, then every child frame is recursively set to the same value.
|
||
//! - **Level** (`set_frame_level 0x76a4f0`): clamp `>= 0`; `delta = new - old`; **same-strata**
|
||
//! children shift by the same `delta` (relative offsets preserved); cross-strata children are
|
||
//! untouched.
|
||
//! - **Effective scale** (`effective_scale 0x76ac90`): `effectiveScale = parentScale * ownScale`,
|
||
//! written to `layoutScale`, **ε-gated** (skip the write + recursion if `|new - cur| < 2.384e-7`,
|
||
//! the `_DAT_008029d4` constant — [`SCALE_EPS`]); recurse to child frames.
|
||
//! - **Alpha** (`SetAlpha 0x76a690`, byte-verified — wow-re `propagation.md`, CORRECTED section):
|
||
//! an eager set-time **overwrite-cascade** — SetAlpha writes the raw value to the frame and
|
||
//! recurses over its child frames pushing the SAME value (a flatten, like the strata subtree-
|
||
//! force; never a draw-time ancestor product). At draw a region multiplies its own alpha by its
|
||
//! OWNER frame's alpha only (one hop, `0x772180`/`0x77fac0`) — which is what `effective_alpha`
|
||
//! feeds, so effective always equals own.
|
||
//! - **Named auto-publish**: a frame with a name is published to the global name table; a duplicate
|
||
//! name does **not** overwrite the first (see [`WidgetArena::lookup`]).
|
||
//!
|
||
//! ## Alpha: the settled model (the old 1.12-vs-Era tension is resolved)
|
||
//!
|
||
//! An earlier INFERRED reading ("1.12 alpha is not tree-propagated") kept a dormant Era-style
|
||
//! propagation flag here as a hedge. The §5 cross-check on `SetAlpha 0x76a690` settled it: 1.12
|
||
//! DOES reach the subtree, but by set-time overwrite (the same raw byte pushed to every
|
||
//! descendant frame's `+0xc8`), not by a live parent×child product — Era's `ignoreParentAlpha`
|
||
//! belongs to the later multiplicative model and has no 1.12 counterpart (there is no
|
||
//! `GetEffectiveAlpha` in 5875). The hedge machinery is gone; [`WidgetArena::set_alpha`] IS the
|
||
//! client's mechanism. Consequences worth knowing: a child's later own-SetAlpha diverges from its
|
||
//! parent until the parent is set again (last write wins), and a frame created under a dimmed
|
||
//! parent starts at 1.0 (creation does not re-push the parent's alpha). Scale keeps its own
|
||
//! (verified multiplicative) propagation and Era `ignore_parent_scale` opt-out, inert in 1.12.
|
||
|
||
use std::collections::HashMap;
|
||
|
||
use crate::order::{DrawLayer, Strata};
|
||
|
||
/// The scale-propagation ε — `_DAT_008029d4` = `0x34800000` ≈ 2.384e-7 (`propagation.md`,
|
||
/// `effective_scale 0x76ac90`): a recomputed effective scale within ε of the current one is skipped
|
||
/// (no write, no recursion). This is the *same* binary constant the layout resolver uses for its
|
||
/// `OnSizeChanged` gate, so we reuse it rather than restate the value.
|
||
pub const SCALE_EPS: f64 = crate::layout::SIZE_EPS;
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||
// Handles — generational, so a destroyed-then-reused slot never aliases a stale handle
|
||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||
|
||
/// A generational handle to a [`Frame`] in a [`WidgetArena`]. Copyable and comparable; a handle to a
|
||
/// destroyed frame is detected (the slot's generation moved on) and reads return `None`.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||
pub struct FrameHandle {
|
||
index: u32,
|
||
generation: u32,
|
||
}
|
||
|
||
/// A generational handle to a [`Region`] (a texture or fontstring leaf).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||
pub struct RegionHandle {
|
||
index: u32,
|
||
generation: u32,
|
||
}
|
||
|
||
impl RegionHandle {
|
||
/// The handle's identity as one integer — index and generation packed — for the layout change
|
||
/// gate's fingerprint (`script::layout::InputFingerprint`), which must notice a region being
|
||
/// swapped for a different one at the same map size.
|
||
#[inline]
|
||
pub(crate) fn fingerprint_bits(self) -> u64 {
|
||
(u64::from(self.generation) << 32) | u64::from(self.index)
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||
// A hand-rolled generational arena (no external deps — a slotmap in miniature)
|
||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||
|
||
struct Slot<T> {
|
||
generation: u32,
|
||
value: Option<T>,
|
||
}
|
||
|
||
/// Minimal generational arena: `insert` reuses freed slots, `remove` bumps the slot's generation so
|
||
/// old handles stop resolving. Indices/generations are `u32` — plenty for a UI.
|
||
struct Arena<T> {
|
||
slots: Vec<Slot<T>>,
|
||
free: Vec<u32>,
|
||
}
|
||
|
||
impl<T> Arena<T> {
|
||
fn new() -> Arena<T> {
|
||
Arena {
|
||
slots: Vec::new(),
|
||
free: Vec::new(),
|
||
}
|
||
}
|
||
|
||
fn insert(&mut self, value: T) -> (u32, u32) {
|
||
if let Some(index) = self.free.pop() {
|
||
let slot = &mut self.slots[index as usize];
|
||
slot.value = Some(value);
|
||
(index, slot.generation)
|
||
} else {
|
||
let index = self.slots.len() as u32;
|
||
self.slots.push(Slot {
|
||
generation: 0,
|
||
value: Some(value),
|
||
});
|
||
(index, 0)
|
||
}
|
||
}
|
||
|
||
fn remove(&mut self, index: u32, generation: u32) -> Option<T> {
|
||
let slot = self.slots.get_mut(index as usize)?;
|
||
if slot.generation != generation {
|
||
return None;
|
||
}
|
||
let taken = slot.value.take();
|
||
if taken.is_some() {
|
||
slot.generation = slot.generation.wrapping_add(1);
|
||
self.free.push(index);
|
||
}
|
||
taken
|
||
}
|
||
|
||
fn get(&self, index: u32, generation: u32) -> Option<&T> {
|
||
let slot = self.slots.get(index as usize)?;
|
||
if slot.generation != generation {
|
||
return None;
|
||
}
|
||
slot.value.as_ref()
|
||
}
|
||
|
||
fn get_mut(&mut self, index: u32, generation: u32) -> Option<&mut T> {
|
||
let slot = self.slots.get_mut(index as usize)?;
|
||
if slot.generation != generation {
|
||
return None;
|
||
}
|
||
slot.value.as_mut()
|
||
}
|
||
|
||
/// Iterate live entries as `(index, generation, &value)`.
|
||
fn iter(&self) -> impl Iterator<Item = (u32, u32, &T)> + '_ {
|
||
self.slots
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(i, s)| s.value.as_ref().map(|v| (i as u32, s.generation, v)))
|
||
}
|
||
|
||
/// Iterate live entries as `(index, generation, &mut value)`.
|
||
fn iter_mut(&mut self) -> impl Iterator<Item = (u32, u32, &mut T)> + '_ {
|
||
self.slots
|
||
.iter_mut()
|
||
.enumerate()
|
||
.filter_map(|(i, s)| s.value.as_mut().map(|v| (i as u32, s.generation, v)))
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||
// Widget kinds
|
||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||
|
||
mod kinds;
|
||
pub use kinds::{
|
||
slider_fraction, slider_grab, ButtonFont, ButtonState, ColorSelectState, CooldownState,
|
||
EditAction, EditBoxState, EditOutcome, EditUnit, FrameKind, InsertMode, KindState,
|
||
MessageFrameState, MessageLine, MinimapState, ModelState, RegionKind, ScrollFrameState,
|
||
ScrollingMessageState, SliderState, StatusBarState, TooltipState, COOLDOWN_FLASH_SECS,
|
||
MINIMAP_DEFAULT_ARROW_MODEL, MINIMAP_DEFAULT_MASK, MINIMAP_DEFAULT_PLAYER_MODEL,
|
||
MINIMAP_DEFAULT_ZOOM, MINIMAP_ENGINE_CHILDREN, MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP,
|
||
TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD, TOOLTIP_WRAP_WIDTH,
|
||
};
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||
// Frame + Region nodes
|
||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||
|
||
/// A frame node in the arena — the modeled subset of `CSimpleFrame` (`frame-model.md`).
|
||
///
|
||
/// The invariant-bearing fields (`effective_visible`, `effective_scale`, `effective_alpha`) and the
|
||
/// structural fields (`parent`, `children`, `regions`) are maintained by the [`WidgetArena`]
|
||
/// mutators; treat them as **read-only** and change state through
|
||
/// [`WidgetArena::set_shown`]/[`set_frame_strata`](WidgetArena::set_frame_strata)/etc. so propagation
|
||
/// stays correct. (Not to be confused with [`crate::layout::LayoutInput`], which is the *layout resolver's*
|
||
/// per-frame anchor/size input — a separate concern.)
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct Frame {
|
||
/// The widget subtype.
|
||
pub kind: FrameKind,
|
||
/// Draw layers switched OFF on this frame — bit `n` = [`DrawLayer::index`] `n`.
|
||
///
|
||
/// `Frame:EnableDrawLayer(layer)` / `DisableDrawLayer(layer)`, VERIFIED present on the Frame
|
||
/// method table `0x878ec0` at `0x7755b0` / `0x775680`, sitting between `IsToplevel` and `Show`.
|
||
/// (Read off the `{const char*, void*}` pair bytes; the same walk reproduces wow-re's recorded
|
||
/// Button table at `0x879d00` exactly.)
|
||
///
|
||
/// A disabled layer hides every region the frame owns in it, and the region's own `Show`/`Hide`
|
||
/// is untouched underneath — re-enabling the layer brings back exactly what was showing before,
|
||
/// which is why this is a frame-level mask rather than a sweep over the regions. Both corpus
|
||
/// consumers (pfUI, MoveAnything) use it to strip Blizzard artwork off a frame they are
|
||
/// reskinning without disturbing what the owner does to its own regions.
|
||
pub disabled_layers: u8,
|
||
/// The frame's name (its global identifier), if any. See [`WidgetArena::lookup`] for the
|
||
/// non-overwriting publish rule.
|
||
pub name: Option<String>,
|
||
/// The parent frame, or `None` for a top-level frame (attached to the screen root / `UIParent`).
|
||
pub parent: Option<FrameHandle>,
|
||
/// Child frames in **insertion order** (the client's `+0x300` child list order) — this order is
|
||
/// the draw-order tiebreak within a `(strata, level)` bucket for equal-insertion children.
|
||
pub children: Vec<FrameHandle>,
|
||
/// Owned region leaves (textures/fontstrings), in creation order. A region detached by
|
||
/// `SetParent(nil)` stays in this list so [`WidgetArena::destroy`] still frees it — see
|
||
/// [`Region::detached`], which is what actually takes it out of the draw.
|
||
pub regions: Vec<RegionHandle>,
|
||
/// This frame's **title region** — the drag handle at `CSimpleFrame+0xA8`, or `None`.
|
||
///
|
||
/// **One per frame**, which is what makes `CreateTitleRegion` idempotent: a second call returns
|
||
/// the SAME object after clearing its anchors, so calling it on an XML-declared title region
|
||
/// silently wipes them (wow-re `widget-api-batch-benilla.md` Q6). It is also in
|
||
/// [`Frame::regions`], so the arena still frees it.
|
||
pub title_region: Option<RegionHandle>,
|
||
/// The draw stratum (`frameStrata +0xc0`, default MEDIUM).
|
||
pub strata: Strata,
|
||
/// The level within the stratum (`frameLevel +0xc4`, default 0).
|
||
pub level: u16,
|
||
/// This frame's own alpha, 0.0..=1.0 (`alpha +0xc8`, default 1.0).
|
||
pub alpha: f32,
|
||
/// The alpha a renderer should apply — always == [`Frame::alpha`] (see the module's alpha
|
||
/// section: 1.12 cascades at set time, so there is no separate draw-time product). Kept as its
|
||
/// propagated product (see the module tension note).
|
||
pub effective_alpha: f32,
|
||
/// `shown` bit (`shown +0xd0`, default true) — the frame's own Show/Hide state.
|
||
pub shown: bool,
|
||
/// `effectiveVisible +0xd4` — `shown AND parent-chain visible`. Maintained by propagation.
|
||
pub effective_visible: bool,
|
||
/// Whether the frame accepts the mouse (`EnableMouse`). Default **false** — WoW's `enableMouse`
|
||
/// default; only mouse-enabled *and* effective-visible frames capture the cursor in
|
||
/// [`crate::order::hit_test`]. (Keyboard focus / `EnableKeyboard` is a separate flag, out of
|
||
/// scope in this crate for now.)
|
||
pub mouse_enabled: bool,
|
||
/// Whether the frame accepts the **wheel** (`EnableMouseWheel` / XML `enableMouseWheel`) — a
|
||
/// flag of its own in the reference, and separate from [`Self::mouse_enabled`] there and here
|
||
/// (decision 1198).
|
||
///
|
||
/// Default **false**, WoW's own default. The wheel dispatch bubbles to the nearest *enabled*
|
||
/// ancestor carrying an `OnMouseWheel`, which is how a scroll region hands the wheel to the
|
||
/// window behind it while keeping its handler installed — a frame with a handler it has not
|
||
/// enabled is deliberately transparent to the wheel.
|
||
pub mouse_wheel_enabled: bool,
|
||
/// Whether the frame is **keyboard-enabled** (`EnableKeyboard` `0x776f90` / XML
|
||
/// `enableKeyboard`) — the kind-0/kind-1 bucket membership `0x76af00` writes as
|
||
/// `[frame+0xcc] |= 1<<kind`.
|
||
///
|
||
/// Default **false**, the client's own default. **The flag round-trips; key DELIVERY is not
|
||
/// gated on it yet** — the same shape [`Self::mouse_wheel_enabled`] shipped in (1198), and for
|
||
/// the same reason: the machinery it gates (the strata 8→0 walk, the kind buckets, the
|
||
/// `OnKeyDown`/`OnKeyUp`/`OnChar` script kinds) does not exist here, so gating on the flag
|
||
/// would change nothing while pretending otherwise. wow-re's
|
||
/// `scratch/frame-key-script-delivery.md` §3.2 is explicit that the two are separable:
|
||
/// `EnableKeyboard(true)` on a script-less frame "puts it in the walk where it is called and
|
||
/// declines — transparent to everything downstream", so **being enabled is not being a
|
||
/// handler**, and storing the flag alone is the faithful half rather than a stub of the whole.
|
||
pub keyboard_enabled: bool,
|
||
/// Clamp-to-screen (`SetClampedToScreen` / XML `clampedToScreen` — the client's geometry
|
||
/// flags **bit4**, applied inside rect assembly `0x767a20`, wow-re `layout.md`): the layout
|
||
/// resolve shifts this frame's assembled rect back inside the screen, size preserved.
|
||
/// Default **true for GameTooltip frames**, false otherwise: the reference tooltip observably
|
||
/// clamps (the minimap zone-text hover's ANCHOR_LEFT plate hangs down from the screen top
|
||
/// instead of leaving the window) though its XML never sets the attribute — the class supplies
|
||
/// the flag — and the rule here is absolute: no tooltip ever leaves the window (decision 0352).
|
||
pub clamped_to_screen: bool,
|
||
/// `SetHitRectInsets` / XML `<HitRectInsets>` — how far the frame's **mouse** rect is inset
|
||
/// from its resolved rect, as `[left, right, top, bottom]` (default all 0, i.e. the hit rect
|
||
/// IS the frame rect). Only [`crate::order::hit_test`] reads it: drawing, anchoring, and
|
||
/// `GetLeft`/`GetWidth` all stay on the un-inset rect. The reference uses it wherever a
|
||
/// button's art fills less than its frame — the micro buttons are 29×58 frames whose art
|
||
/// occupies only the lower ~40 px, and a `top="18"` inset is what stops that empty header
|
||
/// from swallowing hover and clicks aimed past the bar.
|
||
pub hit_rect_insets: [f32; 4],
|
||
/// `SetMovable` / XML `movable` — whether `StartMoving()` may drag this frame. Default
|
||
/// **false**, and the flag is a *guard*, not a behaviour: nothing moves until a script calls
|
||
/// `StartMoving`, and `StartMoving` without this bit set raises instead of moving
|
||
/// ([`crate::script`]'s `object::movable` cluster).
|
||
pub movable: bool,
|
||
/// `SetResizable` / XML `resizable` — the sizing twin of [`Frame::movable`], guarding the
|
||
/// `StartSizing` half of the same family. Default false. benilla stores and reports the flag;
|
||
/// no resize *drag* is built (`StopMovingOrSizing` already covers the stop side of both).
|
||
pub resizable: bool,
|
||
/// `SetMinResize` / `SetMaxResize` — the interactive-resize bounds as `(width, height)`
|
||
/// (`0x776020` / `0x7762a0`, with `GetMinResize 0x775f20` / `GetMaxResize 0x7761a0`; all four
|
||
/// on the Frame method table `0x878ec0`, stored in the client at `CLayoutFrame+0x5c..+0x68`
|
||
/// as a `CRect`-shaped `{minY, minX, maxY, maxX}`).
|
||
///
|
||
/// **`0.0` is the client's own "unbounded" sentinel, on each field independently** (1505) — not a
|
||
/// flag, not a negative, not a `None`: the `CLayoutFrame` ctor `0x767680` zeroes all four, the
|
||
/// getters hand back two plain numbers (`0, 0` on a frame nobody bounded, never `nil`), and
|
||
/// the clamp's first test on every axis is `bound == 0.0 → skip`. A **negative** bound is
|
||
/// therefore live and clamps normally. Byte-verified: wow-re
|
||
/// `system/ui/scratch/resize-bounds-and-button-fontstring.md` §1–2.
|
||
///
|
||
/// The values are lengths in the same space as the explicit width/height, so they compare
|
||
/// directly against [`crate::layout::LayoutInput`]'s — the binding runs the byte-identical
|
||
/// logical→internal transform `SetWidth`'s does.
|
||
///
|
||
/// A window kit's opening lines are `SetResizable(true)` + these two; Quiver's
|
||
/// `SideEffectMakeMoveable` calls `SetMinResize` on every module frame it builds and died on
|
||
/// the nil method (part of B267).
|
||
///
|
||
/// **Only the interactive drag reads them** — VERIFIED, not an omission: the setters store raw
|
||
/// and do not even mark the layout dirty, `CLayoutFrame::SetWidth`/`SetHeight` never consult
|
||
/// them, and no layout-resolve path does either. A frame sized 100 wide with
|
||
/// `SetMinResize(400, 400)` stays 100 wide until the first drag tick snaps it into range.
|
||
pub min_resize: (f32, f32),
|
||
/// The upper twin of [`Frame::min_resize`] — see its doc, including the `0.0` sentinel.
|
||
pub max_resize: (f32, f32),
|
||
/// `SetUserPlaced` — the client's "the user placed this frame; persist its position across
|
||
/// sessions" bit. Default false. Stored and readable (`IsUserPlaced`); **nothing consumes it
|
||
/// yet** — persisting a frame's position belongs with the layout cache, not with the drag that
|
||
/// moved it, so the flag lands here and the saving lands with the cache.
|
||
pub user_placed: bool,
|
||
/// `SetToplevel` / XML `toplevel` — flag word `[frame+0xb4]` **bit `0x1`**, the same word as
|
||
/// [`Frame::movable`] (`0x100`) and [`Frame::resizable`] (`0x200`), written by the same pure
|
||
/// bit-setter `0x76a3c0` (wow-re `ui/scratch/toplevel-raise.md`). Default false.
|
||
///
|
||
/// The flag only **marks**: setting it performs no raise (`SetToplevel 0x775440` is
|
||
/// arg-marshal + `0x76a3c0` and returns). What reads it is the raise worker
|
||
/// `CSimpleTop::Raise 0x7650f0`, which resolves the nearest toplevel *self-or-ancestor* and
|
||
/// raises **that** — so a frame with this bit clear is never the subject of a raise, and a
|
||
/// frame with it set is what a click/show anywhere in its subtree lifts. The law, the gate and
|
||
/// the arithmetic live in [`crate::script::object::toplevel`].
|
||
pub toplevel: bool,
|
||
/// This frame's own scale (`ownScale +0xb8`, default 1.0).
|
||
pub scale: f32,
|
||
/// `layoutScale` = `parentEffective * ownScale`, ε-gated (`effective_scale 0x76ac90`).
|
||
pub effective_scale: f32,
|
||
/// Era `ignoreParentScale` opt-out — inert in 1.12 (no frame sets it; extension point).
|
||
pub ignore_parent_scale: bool,
|
||
/// The Lua-visible numeric id (`GetID`/`SetID`, XML `id=` — the client's `+0xb4`): an
|
||
/// app-meaning-free label frames carry for their handlers (a dropdown row's list position, a
|
||
/// tab index). Default 0. Distinct from the layout/wrapper id ([`Model`](crate::script::Model)'s
|
||
/// bijection) — this one is data, not identity.
|
||
pub wow_id: i64,
|
||
/// Monotonic creation sequence — the frame's insertion order for the draw-order key. See the
|
||
/// note on [`WidgetArena`] about how this relates to the client's live per-bucket insertion.
|
||
pub insertion_seq: u32,
|
||
/// Per-kind behavior state (StatusBar value model, …); [`KindState::None`] for plain kinds.
|
||
pub kind_state: KindState,
|
||
/// Next declaration index handed to a new region of this frame (region draw-order tiebreak).
|
||
next_decl: u32,
|
||
}
|
||
|
||
/// A region leaf — a texture or fontstring belonging to one frame (`frame-model.md`).
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct Region {
|
||
/// Texture or fontstring.
|
||
pub kind: RegionKind,
|
||
/// The frame this region belongs to.
|
||
pub owner: FrameHandle,
|
||
/// The draw layer within the owner (BACKGROUND..HIGHLIGHT).
|
||
pub draw_layer: DrawLayer,
|
||
/// `textureSubLevel` — ordering within the draw layer (extension point beyond 1.12; see
|
||
/// [`crate::order`]).
|
||
pub sub_level: i8,
|
||
/// Declaration index within the owner frame — the final within-layer draw tiebreak.
|
||
pub decl_seq: u32,
|
||
/// `Region:SetParent(nil)` — the region is **orphaned and unrendered, not destroyed** (wow-re
|
||
/// `widget-api-batch-benilla.md` Q7: the re-link virtual `0x77fd10` with a null parent unlinks
|
||
/// from the old parent's draw layer and region list and stores nothing in their place).
|
||
///
|
||
/// Modelled as a flag rather than as an owner-less region: [`Frame::regions`] keeps the entry
|
||
/// so [`WidgetArena::destroy`] still frees the slab slot, and
|
||
/// [`crate::order::traversal`] skips it, so it emits no quad. A later
|
||
/// `SetParent(frame)` clears it and re-links for real
|
||
/// ([`WidgetArena::set_region_owner`]).
|
||
pub detached: bool,
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||
// The arena
|
||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||
|
||
/// The frame arena: the store of all frames and regions, the name registry, and the propagation
|
||
/// mutators.
|
||
///
|
||
/// The **screen root** (`CSimpleTop` / `UIParent`) is *not* a node here — a frame with
|
||
/// `parent == None` is a top-level frame anchored to the always-visible, unit-scale screen root.
|
||
///
|
||
/// **Link-stamp / draw order.** Each frame gets a monotonic `insertion_seq` at creation, used as the
|
||
/// draw-order tiebreak **below the draw layer** within a `(strata, level)` bucket (decision 0884 —
|
||
/// the layer outranks the frame; see [`crate::order`]). Like the client, a frame is re-stamped to
|
||
/// its bucket's **tail** whenever it *becomes visible* (`effective_visible_show 0x76ae10` re-ADDS to
|
||
/// the intrusive level list) or a visible frame changes strata/level (both setters remove-then-add;
|
||
/// `propagation.md`). Show order IS draw order within a bucket — how the reference's late-shown
|
||
/// `MiniMapTrackingFrame` draws over the earlier-declared `MinimapBackdrop` ring despite the XML
|
||
/// declaring it first (decision 0557; the mutators live in [`propagation`]).
|
||
///
|
||
/// The client's own list is a *head* insert walked tail→head, i.e. FIFO by link time with the
|
||
/// newest-linked frame emitted last — order-equivalent to this ascending counter, which is why the
|
||
/// counter stays (0884's §5 refuted the intuition that a head insert draws *first*). A same-value
|
||
/// `SetFrameLevel` early-outs before re-stamping, matching `0x76a4f0`'s `je` at `0x76a509`.
|
||
pub struct WidgetArena {
|
||
frames: Arena<Frame>,
|
||
regions: Arena<Region>,
|
||
names: HashMap<String, FrameHandle>,
|
||
next_insertion: u32,
|
||
/// Monotonic count of Minimap-kind frames ever created — an O(1) "a new Minimap widget
|
||
/// exists" signal, so the per-frame state feed (`set_minimap_inside`'s caller) re-pushes
|
||
/// exactly when one appears instead of walking every frame to find out.
|
||
minimap_created: u64,
|
||
/// Frames whose kind carries **engine-side per-tick behavior** — ScrollingMessageFrame /
|
||
/// MessageFrame (the line fades) and Cooldown (the flash-finished hide). The tick's registry
|
||
/// (decision 1446, the `minimap_created` disposition at list size): the per-frame advance
|
||
/// walks these few dozen instead of the whole arena, which it used to do TWICE per tick — a
|
||
/// corpus UI is thousands of frames. Appended at creation (kinds never change after);
|
||
/// `destroy` removes its own handle, so the list never carries dead entries.
|
||
ticked_kinds: Vec<FrameHandle>,
|
||
/// Every live **GameTooltip**, same registry shape as [`Self::ticked_kinds`] and for the same
|
||
/// reason one layer over: three hot paths (the auto-size pre-pass at the top of every layout
|
||
/// resolve, the per-tick fade advance, and the unit-push hook) all begin "find the tooltips",
|
||
/// and all three did it by walking the resolve's whole frame roster — ~4000 entries at a
|
||
/// corpus UI, to reach the two or three that exist. Appended at creation (kinds never change);
|
||
/// `destroy` removes its own handle, so the list never carries dead entries.
|
||
tooltip_kinds: Vec<FrameHandle>,
|
||
/// Every live **Minimap**, same registry shape as [`Self::ticked_kinds`] and for the same
|
||
/// reason: three state feeds (containment, the two zoom indices, and the player-arrow facing)
|
||
/// all begin "find the Minimaps", and the facing is *per frame*. The ping's own lesson
|
||
/// (decision 1596) was that a per-widget push must not walk the ~3–4k-frame arena every frame
|
||
/// to reach the one widget that exists. Appended at creation (kinds never change); `destroy`
|
||
/// removes its own handle.
|
||
minimap_kinds: Vec<FrameHandle>,
|
||
}
|
||
|
||
impl Default for WidgetArena {
|
||
fn default() -> WidgetArena {
|
||
WidgetArena::new()
|
||
}
|
||
}
|
||
|
||
impl WidgetArena {
|
||
/// A new, empty arena in **1.12 mode** (alpha does not propagate).
|
||
pub fn new() -> WidgetArena {
|
||
WidgetArena {
|
||
frames: Arena::new(),
|
||
regions: Arena::new(),
|
||
names: HashMap::new(),
|
||
next_insertion: 0,
|
||
minimap_created: 0,
|
||
ticked_kinds: Vec::new(),
|
||
tooltip_kinds: Vec::new(),
|
||
minimap_kinds: Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// Monotonic count of Minimap widgets ever created (see the field note).
|
||
pub fn minimap_created(&self) -> u64 {
|
||
self.minimap_created
|
||
}
|
||
|
||
/// The frames whose kind the host must advance each tick (see the field note).
|
||
pub fn ticked_kinds(&self) -> &[FrameHandle] {
|
||
&self.ticked_kinds
|
||
}
|
||
|
||
/// The live GameTooltip frames (see the field note).
|
||
pub fn tooltip_kinds(&self) -> &[FrameHandle] {
|
||
&self.tooltip_kinds
|
||
}
|
||
|
||
/// The live Minimap frames (see the field note).
|
||
pub fn minimap_kinds(&self) -> &[FrameHandle] {
|
||
&self.minimap_kinds
|
||
}
|
||
|
||
// ── Read access ────────────────────────────────────────────────────────────────────────────
|
||
|
||
/// Borrow a frame, or `None` if the handle is stale/unknown.
|
||
pub fn frame(&self, h: FrameHandle) -> Option<&Frame> {
|
||
self.frames.get(h.index, h.generation)
|
||
}
|
||
|
||
/// Mutably borrow a frame. Prefer the dedicated mutators for state that propagates
|
||
/// (shown/strata/level/scale/alpha/parent) — direct edits bypass propagation.
|
||
pub fn frame_mut(&mut self, h: FrameHandle) -> Option<&mut Frame> {
|
||
self.frames.get_mut(h.index, h.generation)
|
||
}
|
||
|
||
/// Borrow a region, or `None` if the handle is stale/unknown.
|
||
pub fn region(&self, h: RegionHandle) -> Option<&Region> {
|
||
self.regions.get(h.index, h.generation)
|
||
}
|
||
|
||
/// Mutably borrow a region.
|
||
pub fn region_mut(&mut self, h: RegionHandle) -> Option<&mut Region> {
|
||
self.regions.get_mut(h.index, h.generation)
|
||
}
|
||
|
||
/// Iterate every live frame as `(handle, &Frame)` (arena order — [`crate::order::traversal`]
|
||
/// sorts into draw order).
|
||
pub fn iter_frames(&self) -> impl Iterator<Item = (FrameHandle, &Frame)> + '_ {
|
||
self.frames
|
||
.iter()
|
||
.map(|(index, generation, f)| (FrameHandle { index, generation }, f))
|
||
}
|
||
|
||
/// Iterate every live frame as `(handle, &mut Frame)` — for per-kind state the host advances each
|
||
/// tick (the ScrollingMessageFrame fade). Structural fields stay the arena's to mutate through its
|
||
/// propagation setters; this is for [`Frame::kind_state`] only.
|
||
pub fn iter_frames_mut(&mut self) -> impl Iterator<Item = (FrameHandle, &mut Frame)> + '_ {
|
||
self.frames
|
||
.iter_mut()
|
||
.map(|(index, generation, f)| (FrameHandle { index, generation }, f))
|
||
}
|
||
|
||
/// Move `h` to the **tail** of its `(strata, level)` draw bucket — the client's own live-list
|
||
/// maintenance (`propagation.md`): `effective_visible_show 0x76ae10` re-ADDS a frame to its
|
||
/// level's intrusive list on the hidden→visible transition, and `set_frame_strata 0x76a470` /
|
||
/// `set_frame_level 0x76a4f0` remove-then-add a visible frame — an intrusive-list add appends.
|
||
/// Called by the [`propagation`] mutators on exactly those transitions (module doc).
|
||
pub(crate) fn resequence_to_tail(&mut self, h: FrameHandle) {
|
||
// The seq lives in a 19-bit field of the packed `ZKey`; unbounded show-bumping could
|
||
// exhaust it over a marathon session, so at the cap renumber every frame in its current
|
||
// order (order-preserving, so no visible change) instead of tripping ZKey's assert.
|
||
if self.next_insertion >= (1 << crate::order::INSERTION_BITS) {
|
||
let mut order: Vec<FrameHandle> =
|
||
self.iter_frames().map(|(handle, _)| handle).collect();
|
||
order.sort_by_key(|&fh| self.frame(fh).map_or(0, |f| f.insertion_seq));
|
||
for (i, fh) in order.iter().enumerate() {
|
||
if let Some(f) = self.frame_mut(*fh) {
|
||
f.insertion_seq = i as u32;
|
||
}
|
||
}
|
||
self.next_insertion = order.len() as u32;
|
||
}
|
||
let seq = self.next_insertion;
|
||
if let Some(f) = self.frame_mut(h) {
|
||
f.insertion_seq = seq;
|
||
self.next_insertion += 1;
|
||
}
|
||
}
|
||
|
||
/// Resolve a name to the frame that published it, or `None`. Publishing is **non-overwriting**:
|
||
/// the first frame created with a given name owns it; a later duplicate keeps its own `name`
|
||
/// field but does not become the lookup target (matching the client's auto-publish rule,
|
||
/// `propagation.md`/decision 0068).
|
||
pub fn lookup(&self, name: &str) -> Option<FrameHandle> {
|
||
self.names.get(name).copied()
|
||
}
|
||
|
||
// ── Create / destroy ────────────────────────────────────────────────────────────────────────
|
||
|
||
/// Create a frame of `kind`, with an optional `name` and `parent`. A `parent` handle that is not
|
||
/// live is treated as `None` (top-level). The new frame starts `shown`, strata MEDIUM, level 0,
|
||
/// scale 1.0, alpha 1.0; its `effective_visible`/`effective_scale`/`effective_alpha` are computed
|
||
/// from the parent immediately. If `name` is given and not already published, it is published.
|
||
///
|
||
/// (Strata/level inheritance from the parent at create time is a *loader/CreateFrame* concern,
|
||
/// not the ctor's — the client's ctor writes MEDIUM/0 unconditionally, `frame-model.md`; we match
|
||
/// that and leave inheritance to the layer above.)
|
||
pub fn create(
|
||
&mut self,
|
||
kind: FrameKind,
|
||
name: Option<String>,
|
||
parent: Option<FrameHandle>,
|
||
) -> FrameHandle {
|
||
// Only keep a parent that is actually live.
|
||
let parent = parent.filter(|&p| self.frame(p).is_some());
|
||
let parent_visible = parent.is_none_or(|p| self.frame(p).unwrap().effective_visible);
|
||
let parent_scale = parent.map_or(1.0, |p| self.frame(p).unwrap().effective_scale);
|
||
|
||
let insertion_seq = self.next_insertion;
|
||
self.next_insertion += 1;
|
||
|
||
let shown = true;
|
||
let scale = 1.0f32;
|
||
// Alpha starts at 1.0 regardless of the parent's — creation does not re-push a dimmed
|
||
// parent's value (only SetAlpha cascades; module alpha section).
|
||
let alpha = 1.0f32;
|
||
let effective_alpha = alpha;
|
||
|
||
let frame = Frame {
|
||
disabled_layers: 0,
|
||
kind,
|
||
name: name.clone(),
|
||
parent,
|
||
children: Vec::new(),
|
||
regions: Vec::new(),
|
||
title_region: None,
|
||
strata: Strata::default(),
|
||
level: 0,
|
||
alpha,
|
||
effective_alpha,
|
||
shown,
|
||
effective_visible: shown && parent_visible,
|
||
// A button/editbox is mouse-enabled by construction (the client's CSimpleButton and
|
||
// CSimpleEditBox enable input in their ctors — an XML button needs no enableMouse attr
|
||
// to be clickable, and an editbox must take clicks to focus, RF-0082 §1).
|
||
mouse_enabled: matches!(
|
||
kind,
|
||
FrameKind::Button
|
||
| FrameKind::CheckButton
|
||
| FrameKind::EditBox
|
||
// The scroll frame takes the wheel in its ctor (msgframe-runtime.md) — an XML
|
||
// ScrollingMessageFrame is wheel-interactive with no enableMouse attr, exactly
|
||
// like a Button is clickable without one.
|
||
| FrameKind::ScrollingMessageFrame
|
||
// A plain ScrollFrame likewise takes the wheel/drag by construction (decision
|
||
// 0112) — an XML ScrollFrame is scroll-interactive with no enableMouse attr,
|
||
// the same rationale as the ScrollingMessageFrame above.
|
||
| FrameKind::ScrollFrame
|
||
// A Slider takes the mouse in its ctor too (the thumb must be draggable): every
|
||
// scrollbar is a UIPanelScrollBarTemplate Slider that declares no enableMouse yet
|
||
// is draggable in-game (decision 0250), so the enablement is by construction.
|
||
| FrameKind::Slider
|
||
),
|
||
// The two kinds whose ctor takes the WHEEL rather than the mouse generally — the same
|
||
// by-construction argument as above, narrowed to the flag it is actually about
|
||
// (decision 1198): a ScrollingMessageFrame and a ScrollFrame are wheel-interactive
|
||
// with no attribute, and nothing else is.
|
||
mouse_wheel_enabled: matches!(
|
||
kind,
|
||
FrameKind::ScrollingMessageFrame | FrameKind::ScrollFrame
|
||
),
|
||
// Nothing is keyboard-enabled by construction: `0x76af00` is only ever reached from the
|
||
// XML attribute or an explicit call, never a ctor (`scripts-auto-enable.md` §1-2).
|
||
keyboard_enabled: false,
|
||
// A tooltip clamps to the screen by construction (its XML never sets the attribute,
|
||
// yet the reference plate observably never leaves the window — the class supplies
|
||
// geometry flags bit4; decision 0352's law: no tooltip off-screen, ever).
|
||
clamped_to_screen: matches!(kind, FrameKind::GameTooltip),
|
||
// No frame is born with an inset hit rect — `<HitRectInsets>`/SetHitRectInsets is the
|
||
// only source (the client's ctor zeroes the four floats too).
|
||
hit_rect_insets: [0.0; 4],
|
||
// No kind is born movable/resizable/user-placed/toplevel: every one of the four is
|
||
// opt-in from XML or a script, and a frame nobody opted in stays where its anchors put
|
||
// it, at the level it was created with.
|
||
movable: false,
|
||
resizable: false,
|
||
// All four zeroed, which IS the client's unbounded state — `CLayoutFrame`'s ctor
|
||
// `0x767680` does exactly this (`xor esi,esi` into `+0x5c..+0x68`), and `0.0` is the
|
||
// sentinel the clamp tests rather than a separate flag.
|
||
min_resize: (0.0, 0.0),
|
||
max_resize: (0.0, 0.0),
|
||
user_placed: false,
|
||
toplevel: false,
|
||
scale,
|
||
effective_scale: parent_scale * scale,
|
||
ignore_parent_scale: false,
|
||
wow_id: 0,
|
||
insertion_seq,
|
||
kind_state: match kind {
|
||
FrameKind::StatusBar => KindState::StatusBar(StatusBarState::default()),
|
||
// `CLootButton` and `CSimpleCheckbox` both extend `CSimpleButton` and both add
|
||
// exactly one field, which `ButtonState` carries for them (`loot_slot`/`checked`).
|
||
FrameKind::Button | FrameKind::CheckButton | FrameKind::LootButton => {
|
||
KindState::Button(ButtonState::default())
|
||
}
|
||
FrameKind::EditBox => KindState::EditBox(EditBoxState::default()),
|
||
FrameKind::ScrollingMessageFrame => {
|
||
KindState::ScrollingMessage(kinds::ScrollingMessageState::default())
|
||
}
|
||
FrameKind::MessageFrame => KindState::Message(kinds::MessageFrameState::default()),
|
||
FrameKind::ScrollFrame => KindState::Scroll(kinds::ScrollFrameState::default()),
|
||
FrameKind::Slider => KindState::Slider(kinds::SliderState::default()),
|
||
FrameKind::ColorSelect => {
|
||
KindState::ColorSelect(kinds::ColorSelectState::default())
|
||
}
|
||
// Both model panes share one state: `CGCharacterModelBase` EXTENDS `CSimpleModel`,
|
||
// so a `<PlayerModel>` carries every `CSimpleModel` member and adds only the
|
||
// turn-animation pair we do not model (`ModelState`'s doc).
|
||
FrameKind::Model | FrameKind::PlayerModel => {
|
||
KindState::Model(kinds::ModelState::default())
|
||
}
|
||
FrameKind::Minimap => KindState::Minimap(kinds::MinimapState::default()),
|
||
FrameKind::Cooldown => KindState::Cooldown(kinds::CooldownState::default()),
|
||
FrameKind::GameTooltip => KindState::Tooltip(kinds::TooltipState::default()),
|
||
_ => KindState::None,
|
||
},
|
||
next_decl: 0,
|
||
};
|
||
|
||
if matches!(kind, FrameKind::Minimap) {
|
||
self.minimap_created += 1;
|
||
}
|
||
let ticked = matches!(
|
||
kind,
|
||
FrameKind::ScrollingMessageFrame | FrameKind::MessageFrame | FrameKind::Cooldown
|
||
);
|
||
let (index, generation) = self.frames.insert(frame);
|
||
let handle = FrameHandle { index, generation };
|
||
if ticked {
|
||
self.ticked_kinds.push(handle);
|
||
}
|
||
if matches!(kind, FrameKind::GameTooltip) {
|
||
self.tooltip_kinds.push(handle);
|
||
}
|
||
if let Some(p) = parent {
|
||
self.frame_mut(p)
|
||
.expect("live parent")
|
||
.children
|
||
.push(handle);
|
||
}
|
||
if let Some(n) = name {
|
||
// Non-overwriting: first writer wins.
|
||
self.names.entry(n).or_insert(handle);
|
||
}
|
||
if matches!(kind, FrameKind::EditBox) {
|
||
self.build_editbox_engine_regions(handle);
|
||
}
|
||
if matches!(kind, FrameKind::Minimap) {
|
||
self.minimap_kinds.push(handle);
|
||
// The `CMinimap` ctor's own last act, so no observer ever sees a Minimap without its
|
||
// nine (see [`MINIMAP_ENGINE_CHILDREN`]). It sits here rather than in the loader
|
||
// because the client builds them in the *element factory* — `CreateFrame("Minimap")`
|
||
// from Lua gets them too, and gets them before its OnLoad runs.
|
||
self.build_minimap_engine_children(handle);
|
||
}
|
||
handle
|
||
}
|
||
|
||
/// Build the **five engine-owned regions of a fresh EditBox**, in the ctor's own order — the
|
||
/// same posture as [`Self::build_minimap_engine_children`] below, and for the same reason: the
|
||
/// client builds them in `CSimpleEditBox`'s ctor, so a `CreateFrame("EditBox")` with no XML
|
||
/// behind it has them too, before its OnLoad runs.
|
||
///
|
||
/// wow-re `system/ui/scratch/rf85-editbox-caret.md` §1, all VERIFIED off bytes:
|
||
///
|
||
/// | # | region | ctor site | built by |
|
||
/// |---|---|---|---|
|
||
/// | 1 | text FontString `E+0x328` | `0x779bee` | `0x770d30(E, 2, 1)` |
|
||
/// | 2-4 | selection-highlight quads `E+0x350/0x354/0x358` | `0x779c41`–`0x779c72` | `0x76fc40(E, 2, 0)` |
|
||
/// | 5 | caret `E+0x368` | `0x779c86`–`0x779cac` | `0x76fc40(E, 3, 1)` |
|
||
///
|
||
/// **Why the ORDER is the whole point.** `GetRegions 0x773f60` walks `[frame+0x1b8]` — one flat
|
||
/// creation-ordered list, oldest first, with **no filter** (hidden regions are returned and
|
||
/// counted), and insertion is at the TAIL (`scratch/widget-list-bindings.md`). All five reach
|
||
/// that list through `0x77fd10`, the single linker. So on a real client the authored `<Layers>`
|
||
/// regions start at index **6**, and an addon may index them there.
|
||
///
|
||
/// pfUI's `skins/blizzard/friends.lua` l.379 is the report:
|
||
/// `local _,_,_,_,_,left,right = GuildControlPopupFrameEditBox:GetRegions()` — it skips five and
|
||
/// takes the box's two `UI-ChatInputBorder` textures. Before this, benilla returned only the
|
||
/// authored regions, so `left` was nil and the whole guild-control skin died on it.
|
||
///
|
||
/// The four quads are created blank — no texture, no fill — so they emit nothing; benilla's
|
||
/// caret and selection highlight are still painted host-side. That gap is named on the
|
||
/// [`EditBoxState`](kinds::EditBoxState) fields rather than papered over.
|
||
fn build_editbox_engine_regions(&mut self, eb: FrameHandle) {
|
||
// The text region keeps the OVERLAY/0 layer it has always shipped with; the ctor's own
|
||
// 2/1 is recorded on the field and deferred there (a draw-order change to every EditBox
|
||
// is not a rider on a region-list fix).
|
||
let text = self.create_region(eb, RegionKind::FontString, DrawLayer::Overlay, 0);
|
||
let mut selection = [None; 3];
|
||
for slot in &mut selection {
|
||
*slot = self.create_region(eb, RegionKind::Texture, DrawLayer::Artwork, 0);
|
||
}
|
||
let caret = self.create_region(eb, RegionKind::Texture, DrawLayer::Overlay, 1);
|
||
if let Some(KindState::EditBox(s)) = self.frame_mut(eb).map(|f| &mut f.kind_state) {
|
||
s.text_region = text;
|
||
s.selection_regions = selection;
|
||
s.caret_region = caret;
|
||
}
|
||
}
|
||
|
||
/// Build the nine engine-owned `Model` children of a fresh Minimap and remember the ninth as
|
||
/// its player arrow. They are created **anonymous and file-less**: the ctor only constructs
|
||
/// them, and the model paths are `CMinimap::LoadXML 0x4ee2b0`'s to assign from the two
|
||
/// attributes (the loader's minimap pass) — which is why a `CreateFrame("Minimap")` with no XML
|
||
/// behind it has nine blank Models here, exactly as the reference does.
|
||
fn build_minimap_engine_children(&mut self, minimap: FrameHandle) {
|
||
let mut ninth = None;
|
||
for _ in 0..kinds::MINIMAP_ENGINE_CHILDREN {
|
||
ninth = Some(self.create(FrameKind::Model, None, Some(minimap)));
|
||
}
|
||
if let Some(KindState::Minimap(m)) = self.frame_mut(minimap).map(|f| &mut f.kind_state) {
|
||
m.player_arrow = ninth;
|
||
}
|
||
}
|
||
|
||
/// Destroy a frame and its whole subtree (child frames and all regions), unpublishing any names
|
||
/// it owns and detaching it from its parent's child list. Stale handles are ignored.
|
||
pub fn destroy(&mut self, h: FrameHandle) {
|
||
let Some(frame) = self.frame(h) else {
|
||
return;
|
||
};
|
||
let children = frame.children.clone();
|
||
let regions = frame.regions.clone();
|
||
let parent = frame.parent;
|
||
let name = frame.name.clone();
|
||
// Each (recursive) destroy removes its own handle — the tick registry stays dead-free.
|
||
self.ticked_kinds.retain(|&t| t != h);
|
||
self.tooltip_kinds.retain(|&t| t != h);
|
||
self.minimap_kinds.retain(|&t| t != h);
|
||
|
||
for c in children {
|
||
self.destroy(c);
|
||
}
|
||
for r in regions {
|
||
self.regions.remove(r.index, r.generation);
|
||
}
|
||
if let Some(p) = parent {
|
||
if let Some(pf) = self.frame_mut(p) {
|
||
pf.children.retain(|&c| c != h);
|
||
}
|
||
}
|
||
if let Some(n) = name {
|
||
if self.names.get(&n) == Some(&h) {
|
||
self.names.remove(&n);
|
||
}
|
||
}
|
||
self.frames.remove(h.index, h.generation);
|
||
}
|
||
|
||
/// Create a region leaf owned by `owner`. Returns `None` if `owner` is stale.
|
||
pub fn create_region(
|
||
&mut self,
|
||
owner: FrameHandle,
|
||
kind: RegionKind,
|
||
draw_layer: DrawLayer,
|
||
sub_level: i8,
|
||
) -> Option<RegionHandle> {
|
||
let decl_seq = {
|
||
let f = self.frame_mut(owner)?;
|
||
let d = f.next_decl;
|
||
f.next_decl += 1;
|
||
d
|
||
};
|
||
let (index, generation) = self.regions.insert(Region {
|
||
kind,
|
||
owner,
|
||
draw_layer,
|
||
sub_level,
|
||
decl_seq,
|
||
detached: false,
|
||
});
|
||
let handle = RegionHandle { index, generation };
|
||
self.frame_mut(owner)
|
||
.expect("live owner")
|
||
.regions
|
||
.push(handle);
|
||
Some(handle)
|
||
}
|
||
|
||
/// **Free one region leaf** — unlink it from its owner's region list and release the slab slot,
|
||
/// so the handle stops resolving and the region stops drawing. Returns whether it was live.
|
||
///
|
||
/// There is no `Region:Destroy` in the widget API and none is wanted: this exists for the one
|
||
/// engine-owned lifetime in the client, `CSimpleHTML::SetText`, which pool-frees the blocks of
|
||
/// the previous parse before building the new ones (`simplehtml-markup-engine.md` §10 step 1 —
|
||
/// the pool allocate at `0x78adc6` has a matching free, and the CONTENTNODE list at `+0x340` is
|
||
/// what names the objects to free). Without it a second `SetText` leaves the first parse's
|
||
/// FontStrings on screen, stacked behind the new ones.
|
||
///
|
||
/// **The caller owns the rest of the region's identity.** The arena holds only structure;
|
||
/// paint (`RegionData`), the stable id maps, the resolved-rect cache and any name publish live
|
||
/// on the script model, and a caller that frees a region without dropping those leaves a
|
||
/// resolvable id pointing at a dead handle. [`crate::script`]'s SimpleHTML block teardown is
|
||
/// the one caller and does the whole set. For the same reason this must never be pointed at a
|
||
/// region some *state* still holds by handle (a button's texture slot, a tooltip line, an
|
||
/// EditBox's text region): freeing those would leave that state dangling.
|
||
pub fn destroy_region(&mut self, h: RegionHandle) -> bool {
|
||
let Some(region) = self.region(h) else {
|
||
return false;
|
||
};
|
||
let owner = region.owner;
|
||
if let Some(f) = self.frame_mut(owner) {
|
||
f.regions.retain(|&r| r != h);
|
||
if f.title_region == Some(h) {
|
||
f.title_region = None;
|
||
}
|
||
}
|
||
self.regions.remove(h.index, h.generation).is_some()
|
||
}
|
||
}
|
||
|
||
// The propagation mutators (visibility/strata/level/scale/alpha/reparenting) — split out for size;
|
||
// see `propagation`'s module doc.
|
||
mod propagation;
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|