- a cinematic holds the body while the camera flies: the streaming focus follows the eye — a Tauren's intro opens 1741 yards from the body — so the body's own ground unloaded under it and it fell (z=59.4 to −62 in four seconds, the server yanking it back); it now takes the same hold a teleport takes, released when the focus comes home to a resident tile, and the loading screen stops covering the fly-by, because "the player's ground is missing" is not a load while the eye is deliberately elsewhere; the letterbox measurement prints as an ordinary info line, and scripts/cine.sh plays any one cinematic and prints what the client did to the world while the camera was away
- a cinematic cuts the zone music: the reference asserts exactly what /console EnableMusic 0 asserts, through the same setter — a cut, not a fade, the music pump dead for the duration, resume at +3.000 s — and benilla asserts the runtime flag, never the CVar, or quitting during a 102-second intro would save your music off for good; ambience is deliberately untouched (it has the analogous flag and the cinematic never writes it); the narration has no category at all — its flags route it around every volume slider but not around the Enable Sound Effects gate, so unchecking that silences it — and the listener follows the cinematic camera for the duration
- addons can walk a UI they did not build: GetChildren/GetNumChildren/GetRegions/GetNumRegions land — the basis of the whole reskin genre, and the wall four of the twenty most-installed 1.12 addons died on: pfUI drew nothing before and now paints 495 quads, ShaguDPS 71 → 566, pfQuest 3 → 498 — with the reference's own quirks: the title region is NOT in the list (its creation path never reaches the region linker), hidden regions are returned and counted, oldest first; the Model widget gets its Lua surface (SetModel, SetSequence and siblings, read out of the shipped image, where SetRotation and SetFacing are two names for one slot); and the Minimap carries the nine engine-created Model children no XML declares, built before the frame descent, the ninth being the player arrow Questie indexes as ({Minimap:GetChildren()})[9]
- all seven chat windows come from one template, tabs and scroll buttons included — whether a window is shown has nothing to do with whether its globals resolve, and pfUI reads every piece of that furniture for all seven, unguarded; isDocked only on windows 1 and 2 (the chat-cache's own DOCKED byte), SELECTED_CHAT_FRAME seated beside DEFAULT_CHAT_FRAME (the window you last picked vs the one FrameXML prints to), and the four per-window fields ChatFrame_OnLoad seats; two more one-name walls fell — a Button's label answers to its second spelling NormalText, which KLHThreatMeter's column headers die without, and ABANDON_QUEST_WITH_ITEMS is declared because a dialog table is read at load whatever the client later shows, leaving Questie with zero errors of ours — and the addon harness gains --probe/--eval, one addon's VM answering a question in ~5 s instead of a 90 s corpus run
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
910 lines
42 KiB
Rust
910 lines
42 KiB
Rust
//! Frame method-table cluster: Show/Hide/visibility, identity/hierarchy (`GetName`/`GetParent`/
|
||
//! `SetParent`), strata/level/scale/alpha, the Backdrop mechanism (`SetBackdrop`/`SetBackdropColor`/
|
||
//! `SetBackdropBorderColor`), and mouse-enable. Split out of [`super`] purely for size — the shared
|
||
//! id/handle plumbing, `CreateFrame`, and the method-table wiring stay there; this module's
|
||
//! [`install`] just populates its share of the one shared method table.
|
||
|
||
use mlua::{Lua, Table, Value};
|
||
|
||
use crate::order::Strata;
|
||
use crate::script::{event, Backdrop, Insets, Model};
|
||
use crate::widget::FrameKind;
|
||
|
||
use super::{decode_id, frame_handle_of, frame_wrapper, strata_from_str};
|
||
|
||
/// Populate `m`'s visibility/hierarchy/strata/backdrop/mouse methods (see the module doc).
|
||
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||
// Show / Hide / visibility
|
||
m.set(
|
||
"Show",
|
||
lua.create_function(|lua, this: Table| set_shown(lua, &this, true))?,
|
||
)?;
|
||
m.set(
|
||
"Hide",
|
||
lua.create_function(|lua, this: Table| set_shown(lua, &this, false))?,
|
||
)?;
|
||
// SetShown(bool) — the live API's branchless Show/Hide (a consensus call across the 0068
|
||
// target addons; Lua truthiness, so SetShown(nil) hides).
|
||
m.set(
|
||
"SetShown",
|
||
lua.create_function(|lua, (this, shown): (Table, Value)| {
|
||
let show = !matches!(shown, Value::Nil | Value::Boolean(false));
|
||
set_shown(lua, &this, show)
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"IsShown",
|
||
lua.create_function(|lua, this: Table| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let model = lua.app_data_ref::<Model>().expect("model");
|
||
Ok(model.arena.frame(h).map(|f| f.shown).unwrap_or(false))
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"IsVisible",
|
||
lua.create_function(|lua, this: Table| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let model = lua.app_data_ref::<Model>().expect("model");
|
||
Ok(model
|
||
.arena
|
||
.frame(h)
|
||
.map(|f| f.effective_visible)
|
||
.unwrap_or(false))
|
||
})?,
|
||
)?;
|
||
// Identity / hierarchy
|
||
m.set(
|
||
"GetName",
|
||
lua.create_function(|lua, this: Table| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let name = {
|
||
let model = lua.app_data_ref::<Model>().expect("model");
|
||
model.arena.frame(h).and_then(|f| f.name.clone())
|
||
};
|
||
match name {
|
||
Some(n) => Ok(Value::String(lua.create_string(&n)?)),
|
||
None => Ok(Value::Nil),
|
||
}
|
||
})?,
|
||
)?;
|
||
// GetID/SetID — the app-meaning-free numeric label (XML `id=`, the client's `+0xb4`): a
|
||
// dropdown row's list position, a tab index. Default 0 (see `Frame::wow_id`).
|
||
m.set(
|
||
"GetID",
|
||
lua.create_function(|lua, this: Table| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let model = lua.app_data_ref::<Model>().expect("model");
|
||
Ok(model.arena.frame(h).map(|f| f.wow_id).unwrap_or(0))
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"SetID",
|
||
lua.create_function(|lua, (this, id): (Table, i64)| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||
if let Some(f) = model.arena.frame_mut(h) {
|
||
f.wow_id = id;
|
||
}
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
// ── GetObjectType / IsObjectType, frame side (wow-re `widget-type-identity.md` §6) ──────────
|
||
//
|
||
// The region side landed first because a FontString found it; `_Nameplates` is why this half
|
||
// exists too — `_Nameplates.lua:164` tests `Nameplate:GetObjectType() ~= "Button"` and `:191`
|
||
// tests `== "StatusBar"`, on FRAMES, in the same file whose `Region:GetObjectType()` calls the
|
||
// region verbs answer.
|
||
//
|
||
// The chains are a hardcoded straight-line list per class in the binary, not a runtime parent
|
||
// walk, so they are a table here too. Three of them are things a reasonable person invents
|
||
// wrongly:
|
||
//
|
||
// · **`ScrollingMessageFrame` derives from `Frame`, NOT from `MessageFrame`.** The name says
|
||
// otherwise and the roster is explicit (`0x787940`).
|
||
// · **`SimpleHTML` is spelled with a capitalised HTML.** Our enum variant is `SimpleHtml`, so
|
||
// anything derived from the variant name — `format!("{:?}")` and friends — would hand
|
||
// addons `"SimpleHtml"`, and `GetObjectType` is compared with `==` (`IsObjectType`'s
|
||
// case-folding would hide it; the getter's would not).
|
||
// · **`Cooldown` answers `"Model"`.** 1.12.1 has no `Cooldown` type — the census finds 23
|
||
// type-name globals and none is that. The reference builds its cooldown as a `Model` playing
|
||
// `UI-Cooldown-Indicator.mdx` (`CooldownFrameTemplate`), and OUR `FrameKind::Cooldown` is a
|
||
// deliberate Era-shaped divergence that models the mechanism first-class (0137 phase 4). So
|
||
// the faithful answer is what the reference's own cooldown widget IS. Answering `"Cooldown"`
|
||
// would announce an Era type to a Lua ecosystem that branches on presence — precisely the
|
||
// superset 1189 had to take back out.
|
||
fn type_chain(kind: FrameKind) -> &'static [&'static str] {
|
||
match kind {
|
||
FrameKind::Frame => &["Frame", "Region"],
|
||
FrameKind::Button => &["Button", "Frame", "Region"],
|
||
FrameKind::CheckButton => &["CheckButton", "Button", "Frame", "Region"],
|
||
FrameKind::EditBox => &["EditBox", "Frame", "Region"],
|
||
FrameKind::StatusBar => &["StatusBar", "Frame", "Region"],
|
||
FrameKind::Slider => &["Slider", "Frame", "Region"],
|
||
FrameKind::ScrollFrame => &["ScrollFrame", "Frame", "Region"],
|
||
FrameKind::Model => &["Model", "Frame", "Region"],
|
||
FrameKind::MessageFrame => &["MessageFrame", "Frame", "Region"],
|
||
FrameKind::ScrollingMessageFrame => &["ScrollingMessageFrame", "Frame", "Region"],
|
||
FrameKind::ColorSelect => &["ColorSelect", "Frame", "Region"],
|
||
FrameKind::SimpleHtml => &["SimpleHTML", "Frame", "Region"],
|
||
FrameKind::MovieFrame => &["MovieFrame", "Frame", "Region"],
|
||
FrameKind::GameTooltip => &["GameTooltip", "Frame", "Region"],
|
||
FrameKind::Minimap => &["Minimap", "Frame", "Region"],
|
||
FrameKind::Cooldown => &["Model", "Frame", "Region"],
|
||
}
|
||
}
|
||
fn chain_of(lua: &Lua, this: &Table) -> mlua::Result<&'static [&'static str]> {
|
||
let h = frame_handle_of(lua, this)?;
|
||
let model = lua.app_data_ref::<Model>().expect("model");
|
||
Ok(model
|
||
.arena
|
||
.frame(h)
|
||
.map_or(&["Frame", "Region"][..], |f| type_chain(f.kind)))
|
||
}
|
||
|
||
m.set(
|
||
"GetObjectType",
|
||
lua.create_function(|lua, this: Table| {
|
||
Ok(Value::String(lua.create_string(chain_of(lua, &this)?[0])?))
|
||
})?,
|
||
)?;
|
||
|
||
// Same four traps as the region twin (see `script::region`): case-insensitive whole-string,
|
||
// number 1 on a hit and nil on a miss, exactly one value either way, and a non-string
|
||
// non-number argument raises the reference's own `Usage:` text.
|
||
m.set(
|
||
"IsObjectType",
|
||
lua.create_function(|lua, (this, want): (Table, Value)| {
|
||
let chain = chain_of(lua, &this)?;
|
||
let want = match &want {
|
||
Value::String(s) => s.to_str()?.to_string(),
|
||
Value::Number(n) => n.to_string(),
|
||
Value::Integer(i) => i.to_string(),
|
||
_ => {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let model = lua.app_data_ref::<Model>().expect("model");
|
||
let who = model
|
||
.arena
|
||
.frame(h)
|
||
.and_then(|f| f.name.clone())
|
||
.unwrap_or_else(|| "<unnamed>".to_string());
|
||
return Err(mlua::Error::runtime(format!(
|
||
"Usage: {who}:IsObjectType(\"TYPE\")"
|
||
)));
|
||
}
|
||
};
|
||
Ok(if chain.iter().any(|t| want.eq_ignore_ascii_case(t)) {
|
||
Value::Number(1.0)
|
||
} else {
|
||
Value::Nil
|
||
})
|
||
})?,
|
||
)?;
|
||
|
||
m.set(
|
||
"GetParent",
|
||
lua.create_function(|lua, this: Table| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let parent_id = {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||
model
|
||
.arena
|
||
.frame(h)
|
||
.and_then(|f| f.parent)
|
||
.map(|p| model.frame_id(p))
|
||
};
|
||
match parent_id {
|
||
Some(pid) => Ok(Value::Table(frame_wrapper(lua, pid)?)),
|
||
None => Ok(Value::Nil),
|
||
}
|
||
})?,
|
||
)?;
|
||
// ── The four structure queries: GetChildren / GetNumChildren / GetRegions / GetNumRegions ──
|
||
//
|
||
// Registered bindings in 1.12 (`GetNumRegions 0x773e60`, `GetRegions 0x773f60`,
|
||
// `GetNumChildren 0x774080`, `GetChildren 0x774180` — wow-re `ui/ledger.tsv`, classified
|
||
// ORCHESTRATION: each marshals and delegates, with no fidelity math of its own).
|
||
//
|
||
// **Why these four are worth more than their size.** They are how an addon walks a frame it did
|
||
// not build, which is the whole basis of the "reskin the default UI" genre — and that genre is
|
||
// the top of the 1.12 popularity list. `pfUI.api.StripTextures` is the canonical shape:
|
||
//
|
||
// for _, v in ipairs({ frame:GetRegions() }) do
|
||
// if v.SetTexture then ... v:SetTexture(nil) end
|
||
// end
|
||
//
|
||
// With `GetRegions` absent that is `attempt to call method 'GetRegions' (a nil value)`, raised
|
||
// out of a library file every one of pfUI, pfQuest, pfQuest-turtle and ShaguDPS embeds — four
|
||
// separate top-20 addons dying on one missing verb, plus Questie on `GetChildren`.
|
||
//
|
||
// **Two lists, one walk each, in the arena's own order.** `Frame::children` is insertion order,
|
||
// which our own arena documents as the client's `+0x300` child-list order; `Frame::regions` is
|
||
// creation order. Neither is re-sorted here — a structure query reports the structure, and the
|
||
// draw order is `crate::order`'s separate concern.
|
||
//
|
||
// **A detached region is not in the list.** `Region:SetParent(nil)` sets `Region::detached`
|
||
// rather than removing the entry, because the owner's `Vec` is what `WidgetArena::destroy`
|
||
// frees from — but the client's re-link virtual `0x77fd10` with a null parent genuinely
|
||
// **unlinks from the old parent's draw layer and region list** (wow-re
|
||
// `widget-api-batch-benilla.md` Q7, VERIFIED), so a detached region must be invisible to both
|
||
// region verbs. Our flag is a representation choice; the list these report is the client's.
|
||
//
|
||
// **The count and the list come from ONE function** ([`regions_of`] / [`children_of`]), never
|
||
// two walks with the same filter written twice. `GetNumRegions` disagreeing with
|
||
// `#{GetRegions()}` is a bug an addon would hit as an off-by-one deep inside a loop it did not
|
||
// write, and the only way to make it impossible is to have one of them BE the other.
|
||
//
|
||
// **Settled by a wow-re §5 quartet** (`ui/scratch/widget-list-bindings.md`), which confirmed two
|
||
// of these choices and corrected a third:
|
||
//
|
||
// · **The order is link order, oldest first, and no reversal.** `CSimpleFrame` embeds a punned
|
||
// `TSList` header whose ctor sets `tail = &header`, `head = (&header)|1`, and BOTH linkers
|
||
// append at the TAIL (`0x76a750` for regions, `0x76aa20` for children — one caller each).
|
||
// Our insertion-order `Vec` is the same order. (wow-re's own `draw-order-law.md` said
|
||
// "tail→head"; that was a direction mislabel and was corrected in the same round.)
|
||
// · **Hidden children and regions are returned and counted** — a VERIFIED negative: the
|
||
// counting loops never load the visibility word at all.
|
||
// · **The TITLE REGION IS NOT RETURNED**, and that one corrected us. Both creation paths
|
||
// (`0x773910`, `0x769b79`) dispatch a vtable slot that is a bare `[this+0x9c] = parent` and
|
||
// never reach the linker `0x77fd10`, so a title region is not in the list this walks —
|
||
// corroborated by `Hide`/`Show` carrying an explicit extra `[frame+0xa8]` case *because* the
|
||
// list walk misses it. Ours is in `Frame::regions` (so the arena can free it), so
|
||
// [`regions_of`] filters it out.
|
||
// · **A Button's label (`+0x338`) and its four state textures ARE in the list** — they link
|
||
// through `0x77fd10` like any region, which is what ours do too.
|
||
// · **The return shape**: N separate values, never a table; a frame with none returns ZERO
|
||
// values (no `lua_newtable` in either body), and `GetNum*` returns one number — `0` when
|
||
// empty, never nil.
|
||
//
|
||
// The one thing still out of reach is not these bindings': Questie reads
|
||
// `({Minimap:GetChildren()})[9]` for the player arrow, and the §5 found why that index works —
|
||
// `CMinimap`'s ctor `0x4edbc0` builds NINE engine-owned `Model` children before the XML
|
||
// `<Frames>` descent, the ninth (`[Minimap+0x338]`) being the arrow `SetPlayerFacing 0x4eb8e0`
|
||
// writes. We create none of them, so index 9 is nil here until the minimap grows its engine
|
||
// children.
|
||
m.set(
|
||
"GetChildren",
|
||
lua.create_function(|lua, this: Table| {
|
||
let ids = children_of(lua, &this)?;
|
||
let mut out = Vec::with_capacity(ids.len());
|
||
for id in ids {
|
||
out.push(Value::Table(frame_wrapper(lua, id)?));
|
||
}
|
||
Ok(mlua::MultiValue::from_vec(out))
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"GetNumChildren",
|
||
lua.create_function(|lua, this: Table| Ok(children_of(lua, &this)?.len()))?,
|
||
)?;
|
||
m.set(
|
||
"GetRegions",
|
||
lua.create_function(|lua, this: Table| {
|
||
let ids = regions_of(lua, &this)?;
|
||
let mut out = Vec::with_capacity(ids.len());
|
||
for id in ids {
|
||
out.push(Value::Table(crate::script::region::region_wrapper(
|
||
lua, id,
|
||
)?));
|
||
}
|
||
Ok(mlua::MultiValue::from_vec(out))
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"GetNumRegions",
|
||
lua.create_function(|lua, this: Table| Ok(regions_of(lua, &this)?.len()))?,
|
||
)?;
|
||
// SetParent — the runtime reparent, per the byte-verified law (wow-re
|
||
// `ui/scratch/setparent-runtime-strata-level.md`; decision 1323). The binding half
|
||
// (`0x7a1550`): the parent argument is a frame table, a NAME string (`0x76c760`), or an
|
||
// explicit nil — an ABSENT argument is NOT the nil path (`0x6f3400` returns −1) and raises
|
||
// like an unresolvable name; the cycle guard is the binding's own inline ancestor walk and
|
||
// RAISES (`0x87cb14`), it does not silently no-op. The arena half is
|
||
// `reparent_begin`/`reparent_finish`, split so `OnHide` fires between them the way the
|
||
// reference's hide→show round-trip does — `fire_visibility_changes` reads each frame's live
|
||
// state for direction, so hide and show must be fired from their own phase, never one
|
||
// combined list.
|
||
m.set(
|
||
"SetParent",
|
||
lua.create_function(|lua, args: mlua::MultiValue| {
|
||
let mut it = args.into_iter();
|
||
let this = match it.next() {
|
||
Some(Value::Table(t)) => t,
|
||
_ => return Err(mlua::Error::runtime("expected a frame")),
|
||
};
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let who = {
|
||
let model = lua.app_data_ref::<Model>().expect("model");
|
||
model
|
||
.arena
|
||
.frame(h)
|
||
.and_then(|f| f.name.clone())
|
||
.unwrap_or_else(|| "<unnamed>".to_string())
|
||
};
|
||
let parent_arg = it.next();
|
||
let new_parent = {
|
||
let model = lua.app_data_ref::<Model>().expect("model");
|
||
match &parent_arg {
|
||
// Explicit nil reparents to the screen root (the strata/level RESET path).
|
||
Some(Value::Nil) => None,
|
||
Some(Value::Table(t)) => Some(
|
||
decode_id(t)
|
||
.ok()
|
||
.and_then(|id| model.id_to_frame.get(&id).copied())
|
||
.ok_or_else(|| {
|
||
mlua::Error::runtime(format!(
|
||
"{who}:SetParent(): Couldn't find region named ''"
|
||
))
|
||
})?,
|
||
),
|
||
Some(Value::String(s)) => {
|
||
let n = s.to_str()?.to_string();
|
||
Some(model.arena.lookup(&n).ok_or_else(|| {
|
||
mlua::Error::runtime(format!(
|
||
"{who}:SetParent(): Couldn't find region named '{n}'"
|
||
))
|
||
})?)
|
||
}
|
||
// Absent argument, or any other type: the reference never reaches the
|
||
// reparent slot (`0x87cb48` raise).
|
||
_ => {
|
||
return Err(mlua::Error::runtime(format!(
|
||
"{who}:SetParent(): Couldn't find region named ''"
|
||
)))
|
||
}
|
||
}
|
||
};
|
||
// The cycle guard raises — `"%s:SetParent(): Would create a loop parenting to %s"`
|
||
// (`0x7a177f`'s inline `+0x9c` walk, `newParent == self` included).
|
||
if let Some(np) = new_parent {
|
||
let model = lua.app_data_ref::<Model>().expect("model");
|
||
if np == h || model.arena.is_ancestor(h, np) {
|
||
let pname = model
|
||
.arena
|
||
.frame(np)
|
||
.and_then(|f| f.name.clone())
|
||
.unwrap_or_else(|| "<unnamed>".to_string());
|
||
return Err(mlua::Error::runtime(format!(
|
||
"{who}:SetParent(): Would create a loop parenting to {pname}"
|
||
)));
|
||
}
|
||
}
|
||
let hidden = {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||
model.arena.reparent_begin(h, new_parent)
|
||
};
|
||
// None = the verified total no-op (same parent, `0x76ab20`): nothing runs, not even
|
||
// the layout touch.
|
||
let Some(hidden) = hidden else { return Ok(()) };
|
||
let was_visible = !hidden.is_empty();
|
||
event::fire_visibility_changes(lua, hidden);
|
||
let shown = {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||
let shown = model.arena.reparent_finish(h, new_parent, was_visible);
|
||
// A real reparent moves the subtree's effective scale and anchors — a
|
||
// layout-gate input, and the scale is a measure-key input the subtree's
|
||
// FontStrings cannot name one by one (human-rate; the sweep walks once).
|
||
model.touch_layout();
|
||
model.touch_measure_all();
|
||
shown
|
||
};
|
||
event::fire_visibility_changes(lua, shown);
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
// Strata / level / scale / alpha
|
||
m.set(
|
||
"SetFrameStrata",
|
||
lua.create_function(|lua, (this, strata): (Table, String)| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let s = strata_from_str(&strata)
|
||
.ok_or_else(|| mlua::Error::runtime(format!("unknown frameStrata '{strata}'")))?;
|
||
lua.app_data_mut::<Model>()
|
||
.expect("model")
|
||
.arena
|
||
.set_frame_strata(h, s);
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"GetFrameStrata",
|
||
lua.create_function(|lua, this: Table| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let model = lua.app_data_ref::<Model>().expect("model");
|
||
let s = model.arena.frame(h).map(|f| f.strata).unwrap_or_default();
|
||
Ok(strata_name(s).to_string())
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"SetFrameLevel",
|
||
lua.create_function(|lua, (this, level): (Table, i64)| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let lvl = level.clamp(0, i64::from(u16::MAX)) as u16;
|
||
lua.app_data_mut::<Model>()
|
||
.expect("model")
|
||
.arena
|
||
.set_frame_level(h, lvl, true);
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"GetFrameLevel",
|
||
lua.create_function(|lua, this: Table| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let model = lua.app_data_ref::<Model>().expect("model");
|
||
Ok(i64::from(
|
||
model.arena.frame(h).map(|f| f.level).unwrap_or(0),
|
||
))
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"SetScale",
|
||
lua.create_function(|lua, (this, scale): (Table, f32)| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||
// Effective-scale changes ride the propagation's own eps gate; the own-scale compare
|
||
// is the cheap superset (same own scale => no effective change is possible).
|
||
let changed = model.arena.frame(h).is_some_and(|f| f.scale != scale);
|
||
model.arena.set_scale(h, scale);
|
||
if changed {
|
||
model.touch_layout();
|
||
// The owner's effective scale is in every descendant FontString's measure key.
|
||
model.touch_measure_all();
|
||
}
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"GetScale",
|
||
lua.create_function(|lua, this: Table| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let model = lua.app_data_ref::<Model>().expect("model");
|
||
Ok(model.arena.frame(h).map(|f| f.scale).unwrap_or(1.0))
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"SetAlpha",
|
||
lua.create_function(|lua, (this, alpha): (Table, f32)| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
// The 1.12 API clamps to 0..1 — ref Lua leans on it (fade code that passes a 0..255
|
||
// alpha stays fully opaque until the value falls below 1/255).
|
||
lua.app_data_mut::<Model>()
|
||
.expect("model")
|
||
.arena
|
||
.set_alpha(h, alpha.clamp(0.0, 1.0));
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"GetAlpha",
|
||
lua.create_function(|lua, this: Table| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let model = lua.app_data_ref::<Model>().expect("model");
|
||
Ok(model.arena.frame(h).map(|f| f.alpha).unwrap_or(1.0))
|
||
})?,
|
||
)?;
|
||
// Backdrop (backdrop-mechanism.md): SetBackdrop(table|nil) installs (or, with nil, tears down)
|
||
// the frame's tiled bg + 8-piece border plate. The two color setters tint the bg / all 8 border
|
||
// pieces (never the reverse — spec §4). The Lua-table SetBackdrop defaults both colors to WHITE
|
||
// (the ctor), so a caller must SetBackdropColor after to tint (the tooltip's OnLoad does).
|
||
m.set(
|
||
"SetBackdrop",
|
||
lua.create_function(|lua, (this, arg): (Table, Value)| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let bd = match arg {
|
||
Value::Nil => None,
|
||
Value::Table(t) => Some(backdrop_from_table(&t)?),
|
||
other => {
|
||
return Err(mlua::Error::runtime(format!(
|
||
"SetBackdrop: expected a table or nil, got {}",
|
||
other.type_name()
|
||
)))
|
||
}
|
||
};
|
||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||
match bd {
|
||
Some(b) => {
|
||
model.backdrops.insert(h, b);
|
||
}
|
||
None => {
|
||
model.backdrops.remove(&h);
|
||
}
|
||
}
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
// `GetBackdrop()` (`0x777370`; wow-re `ui/scratch/widget-api-batch-benilla.md` Q5) — five things
|
||
// a plausible implementation gets wrong, so each is spelled out with its reason:
|
||
//
|
||
// 1. It is a **reconstruction from the struct**, never the caller's table. The reference stores
|
||
// no Lua reference anywhere: `SetBackdrop` reads six keys into a fresh 0x68-byte struct at
|
||
// `frame+0x1ac` and drops the table, so `0x777426`–`0x7776b9` re-push every key from those
|
||
// fields. Handing back a stored clone would leak keys the reader never accepted and would
|
||
// keep stale values a later `SetBackdropColor` changed.
|
||
// 2. **No backdrop ⇒ ZERO Lua values, not `nil`** — the early bail is `xor eax,eax; ret`, which
|
||
// for a *return* path really is "no values" (contrast `binding_abi`'s note: the same two
|
||
// bytes after a `luaL_error` are unreachable boilerplate). Observable through `select('#')`,
|
||
// and it is the shape our `GetTitleRegion` will *not* have when it lands — that one pushes
|
||
// nil, i.e. one value. The client cannot distinguish "never set" from `SetBackdrop(nil)`.
|
||
// 3. **A partial `SetBackdrop` omits nothing on the way out.** Every `SetBackdrop` allocates a
|
||
// fresh struct (`0x777801`, ctor `0x77e5f0`), so a key the caller left out is a *ctor
|
||
// default* here, not an absent key and not the previous backdrop's value: `bgFile`/`edgeFile`
|
||
// `""`, `tileSize` 0, `edgeSize` **32**. Our `backdrop_from_table` already builds on
|
||
// `Backdrop::default()`, so this falls out — but only because `None` maps to `""` below
|
||
// rather than to nil.
|
||
// 4. **`tile` is the NUMBER `1`, or the key is ABSENT — never `true`/`false`.** The push is
|
||
// `0x3ff00000` (the double 1.0) on true and `lua_pushnil` on false, and `lua_settable` with a
|
||
// nil value creates no key (and *erases* one from a recycled table, which is why the nil is
|
||
// written rather than skipped). An addon reading `if backdrop.tile then` sees the same truth
|
||
// either way; one that round-trips the table into `SetBackdrop` is why the number matters,
|
||
// since `tile` there goes through a coercer that takes numbers.
|
||
// 5. The undocumented **in-place form** (`0x77740e`): `lua_type(L,2) == LUA_TTABLE` skips
|
||
// `lua_newtable` and fills arg 2, reusing an existing `insets` subtable rather than replacing
|
||
// it. Implemented — it is four lines, and an addon caching one table across frames would
|
||
// otherwise silently get a new one each call. A non-table arg 2 is ignored, not an error.
|
||
//
|
||
// No `bgColor`/`edgeColor`/`alpha` key exists: the two colors live in the struct but the reader
|
||
// never pushes them (`GetBackdropColor`/`GetBackdropBorderColor` are their only accessors).
|
||
m.set(
|
||
"GetBackdrop",
|
||
lua.create_function(|lua, (this, target): (Table, Value)| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
// Copied out before a single Lua write: filling a *caller-supplied* table can run a
|
||
// `__newindex` metamethod, which can re-enter us and would panic on the app-data borrow.
|
||
let bd = {
|
||
let model = lua.app_data_ref::<Model>().expect("model");
|
||
model.backdrops.get(&h).cloned()
|
||
};
|
||
let Some(bd) = bd else {
|
||
return Ok(mlua::MultiValue::new()); // trap 2 — zero values, not nil
|
||
};
|
||
let t = match target {
|
||
Value::Table(t) => t,
|
||
_ => lua.create_table()?,
|
||
};
|
||
t.set("bgFile", bd.bg_file.as_deref().unwrap_or(""))?;
|
||
t.set("edgeFile", bd.edge_file.as_deref().unwrap_or(""))?;
|
||
t.set(
|
||
"tile",
|
||
if bd.tile {
|
||
Value::Number(1.0) // trap 4
|
||
} else {
|
||
Value::Nil // trap 4 — erases the key from a recycled table
|
||
},
|
||
)?;
|
||
t.set("tileSize", f64::from(bd.tile_size))?;
|
||
t.set("edgeSize", f64::from(bd.edge_size))?;
|
||
let insets = match t.get::<Value>("insets") {
|
||
Ok(Value::Table(existing)) => existing, // trap 5 — reuse, don't replace
|
||
_ => {
|
||
let fresh = lua.create_table()?;
|
||
t.set("insets", &fresh)?;
|
||
fresh
|
||
}
|
||
};
|
||
insets.set("left", f64::from(bd.insets.left))?;
|
||
insets.set("right", f64::from(bd.insets.right))?;
|
||
insets.set("top", f64::from(bd.insets.top))?;
|
||
insets.set("bottom", f64::from(bd.insets.bottom))?;
|
||
Ok(mlua::MultiValue::from_vec(vec![Value::Table(t)]))
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"SetBackdropColor",
|
||
lua.create_function(
|
||
|lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||
if let Some(bd) = model.backdrops.get_mut(&h) {
|
||
bd.bg_color = [r, g, b, a.unwrap_or(1.0)];
|
||
}
|
||
Ok(())
|
||
},
|
||
)?,
|
||
)?;
|
||
m.set(
|
||
"SetBackdropBorderColor",
|
||
lua.create_function(
|
||
|lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||
if let Some(bd) = model.backdrops.get_mut(&h) {
|
||
bd.border_color = [r, g, b, a.unwrap_or(1.0)];
|
||
}
|
||
Ok(())
|
||
},
|
||
)?,
|
||
)?;
|
||
m.set(
|
||
"GetBackdropColor",
|
||
lua.create_function(|lua, this: Table| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let model = lua.app_data_ref::<Model>().expect("model");
|
||
match model.backdrops.get(&h) {
|
||
Some(bd) => Ok((
|
||
bd.bg_color[0],
|
||
bd.bg_color[1],
|
||
bd.bg_color[2],
|
||
bd.bg_color[3],
|
||
)),
|
||
None => Ok((1.0, 1.0, 1.0, 1.0)),
|
||
}
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"GetBackdropBorderColor",
|
||
lua.create_function(|lua, this: Table| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let model = lua.app_data_ref::<Model>().expect("model");
|
||
match model.backdrops.get(&h) {
|
||
Some(bd) => Ok((
|
||
bd.border_color[0],
|
||
bd.border_color[1],
|
||
bd.border_color[2],
|
||
bd.border_color[3],
|
||
)),
|
||
None => Ok((1.0, 1.0, 1.0, 1.0)),
|
||
}
|
||
})?,
|
||
)?;
|
||
// Mouse interaction (EnableMouse gates hit-testing; keyboard focus is out of scope)
|
||
m.set(
|
||
"EnableMouse",
|
||
lua.create_function(|lua, (this, enable): (Table, bool)| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
lua.app_data_mut::<Model>()
|
||
.expect("model")
|
||
.arena
|
||
.set_mouse_enabled(h, enable);
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"IsMouseEnabled",
|
||
lua.create_function(|lua, this: Table| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let model = lua.app_data_ref::<Model>().expect("model");
|
||
Ok(model.arena.is_mouse_enabled(h))
|
||
})?,
|
||
)?;
|
||
// EnableKeyboard(flag) / IsKeyboardEnabled() — `0x776f90` / `0x776ff0`, real Frame entries in
|
||
// wow-re's frame-API carve, beside the mouse pair above.
|
||
//
|
||
// 8 corpus addons call this and were RAISING on it (`ColorPickerPlus:258`,
|
||
// `Dewdrop-2.0.lua:2021-2022` — which is in ~65 addons — `AckisRecipeList/ARLFrame.lua:1650`).
|
||
// The flag round-trips; **key delivery is not gated on it**, exactly as the wheel's flag
|
||
// shipped in 1198, because the machinery it would gate does not exist here yet.
|
||
//
|
||
// Why that is the right half rather than a silent stub, which this codebase has been bitten by
|
||
// three times (1203/1205/1211): the majority of the corpus calls pass **false**, and for those
|
||
// our behaviour is already the reference's — we deliver no keys to arbitrary frames either. The
|
||
// `true` callers want delivery we do not do, but they did not get it before this landed; they
|
||
// got a raise that killed the enclosing function. And per
|
||
// `frame-key-script-delivery.md` §3.2 being enabled genuinely is separable from having a
|
||
// handler, so the flag is a real piece of the model, not a placeholder for one.
|
||
m.set(
|
||
"EnableKeyboard",
|
||
lua.create_function(|lua, (this, enable): (Table, bool)| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
lua.app_data_mut::<Model>()
|
||
.expect("model")
|
||
.arena
|
||
.set_keyboard_enabled(h, enable);
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"IsKeyboardEnabled",
|
||
lua.create_function(|lua, this: Table| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let model = lua.app_data_ref::<Model>().expect("model");
|
||
Ok(model.arena.is_keyboard_enabled(h))
|
||
})?,
|
||
)?;
|
||
// `EnableMouseWheel(flag)` / `IsMouseWheelEnabled()` — the wheel's own gate, a separate flag
|
||
// from `EnableMouse` in the reference and separate here (decision 1198).
|
||
//
|
||
// The flag is real and round-trips. **The dispatch is NOT gated on it yet, deliberately.**
|
||
// Our wheel dispatch keys off "does this frame carry an `OnMouseWheel` handler", walking up to
|
||
// the nearest ancestor that does — more permissive than the reference, which also requires the
|
||
// frame to be wheel-enabled so a scroll region can hand the wheel to the window behind it
|
||
// without tearing its handler out.
|
||
//
|
||
// Gating it today would break our own UI: 44 `OnMouseWheel` sites across 14 shipped files and
|
||
// **not one of them declares `enableMouseWheel`**, because the loader has never read that
|
||
// attribute. The condition to flip it is concrete rather than someday — teach the loader the
|
||
// attribute, declare it on those 44 sites, then gate. Until then this is a disclosed superset
|
||
// (1189's argument, pointed the other way), and the two corpus addons that stopped on the
|
||
// missing *method* are unblocked either way.
|
||
m.set(
|
||
"EnableMouseWheel",
|
||
lua.create_function(|lua, (this, enable): (Table, bool)| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
lua.app_data_mut::<Model>()
|
||
.expect("model")
|
||
.arena
|
||
.set_mouse_wheel_enabled(h, enable);
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"IsMouseWheelEnabled",
|
||
lua.create_function(|lua, this: Table| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let model = lua.app_data_ref::<Model>().expect("model");
|
||
Ok(model.arena.is_mouse_wheel_enabled(h))
|
||
})?,
|
||
)?;
|
||
// Clamp-to-screen (`0x776c00`/`0x776cb0`, geometry flags bit4 — layout.md): the layout resolve
|
||
// keeps the frame's assembled rect inside the window, size preserved. GameTooltip frames
|
||
// default true by construction (widget::Frame::clamped_to_screen — decision 0352).
|
||
m.set(
|
||
"SetClampedToScreen",
|
||
lua.create_function(|lua, (this, clamp): (Table, bool)| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||
let changed = model.arena.is_clamped_to_screen(h) != clamp;
|
||
model.arena.set_clamped_to_screen(h, clamp);
|
||
if changed {
|
||
model.touch_layout();
|
||
}
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"IsClampedToScreen",
|
||
lua.create_function(|lua, this: Table| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let model = lua.app_data_ref::<Model>().expect("model");
|
||
Ok(model.arena.is_clamped_to_screen(h))
|
||
})?,
|
||
)?;
|
||
// Hit-rect insets — the MOUSE rect only (widget::Frame::hit_rect_insets): the hit test shrinks
|
||
// the resolved rect by these four before testing the cursor, and nothing else reads them, so a
|
||
// frame's geometry/draw/anchor answers are unchanged. The ref sets them wherever a button's
|
||
// frame is larger than its art (the micro buttons' 18 px empty header).
|
||
m.set(
|
||
"SetHitRectInsets",
|
||
lua.create_function(
|
||
|lua, (this, left, right, top, bottom): (Table, f32, f32, f32, f32)| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
lua.app_data_mut::<Model>()
|
||
.expect("model")
|
||
.arena
|
||
.set_hit_rect_insets(h, [left, right, top, bottom]);
|
||
Ok(())
|
||
},
|
||
)?,
|
||
)?;
|
||
m.set(
|
||
"GetHitRectInsets",
|
||
lua.create_function(|lua, this: Table| {
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let model = lua.app_data_ref::<Model>().expect("model");
|
||
let i = model.arena.hit_rect_insets(h);
|
||
Ok((i[0], i[1], i[2], i[3]))
|
||
})?,
|
||
)?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Parse a Lua `SetBackdrop` table into a [`Backdrop`] (backdrop-mechanism.md §1). The keys read —
|
||
/// exactly the compiled reader's set — are `bgFile`/`edgeFile` (strings), `tile` (boolean, default
|
||
/// false), `tileSize`/`edgeSize` (numbers; a missing number leaves the ctor default: tileSize 0,
|
||
/// edgeSize 32), and `insets{left,right,top,bottom}` (numbers, each default 0). A non-string file or
|
||
/// non-number size is treated as absent (the client's per-key type gate). Colors stay the ctor
|
||
/// white — SetBackdrop never reads a color key; the color setters do that.
|
||
fn backdrop_from_table(t: &Table) -> mlua::Result<Backdrop> {
|
||
let mut bd = Backdrop::default();
|
||
if let Ok(Value::String(s)) = t.get::<Value>("bgFile") {
|
||
bd.bg_file = Some(s.to_str()?.to_string());
|
||
}
|
||
if let Ok(Value::String(s)) = t.get::<Value>("edgeFile") {
|
||
bd.edge_file = Some(s.to_str()?.to_string());
|
||
}
|
||
// `tile` — Lua truthiness (nil/false ⇒ false; a missing key reads as Nil).
|
||
bd.tile = !matches!(
|
||
t.get::<Value>("tile").unwrap_or(Value::Nil),
|
||
Value::Nil | Value::Boolean(false)
|
||
);
|
||
if let Ok(Value::Number(n)) = t.get::<Value>("tileSize") {
|
||
bd.tile_size = n as f32;
|
||
} else if let Ok(Value::Integer(n)) = t.get::<Value>("tileSize") {
|
||
bd.tile_size = n as f32;
|
||
}
|
||
if let Ok(Value::Number(n)) = t.get::<Value>("edgeSize") {
|
||
bd.edge_size = n as f32;
|
||
} else if let Ok(Value::Integer(n)) = t.get::<Value>("edgeSize") {
|
||
bd.edge_size = n as f32;
|
||
}
|
||
if let Ok(Value::Table(ins)) = t.get::<Value>("insets") {
|
||
let num = |k: &str| -> f32 {
|
||
match ins.get::<Value>(k) {
|
||
Ok(Value::Number(n)) => n as f32,
|
||
Ok(Value::Integer(n)) => n as f32,
|
||
_ => 0.0,
|
||
}
|
||
};
|
||
bd.insets = Insets {
|
||
left: num("left"),
|
||
right: num("right"),
|
||
top: num("top"),
|
||
bottom: num("bottom"),
|
||
};
|
||
}
|
||
Ok(bd)
|
||
}
|
||
|
||
fn strata_name(s: Strata) -> &'static str {
|
||
match s {
|
||
Strata::World => "WORLD",
|
||
Strata::Background => "BACKGROUND",
|
||
Strata::Low => "LOW",
|
||
Strata::Medium => "MEDIUM",
|
||
Strata::High => "HIGH",
|
||
Strata::Dialog => "DIALOG",
|
||
Strata::Fullscreen => "FULLSCREEN",
|
||
Strata::FullscreenDialog => "FULLSCREEN_DIALOG",
|
||
Strata::Tooltip => "TOOLTIP",
|
||
Strata::Blizzard => "BLIZZARD",
|
||
}
|
||
}
|
||
|
||
fn set_shown(lua: &Lua, this: &Table, shown: bool) -> mlua::Result<()> {
|
||
let h = frame_handle_of(lua, this)?;
|
||
let changed = {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||
model.arena.set_shown(h, shown)
|
||
};
|
||
event::fire_visibility_changes(lua, changed);
|
||
Ok(())
|
||
}
|
||
|
||
/// This frame's child frames as stable ids, in the arena's insertion order — the one walk
|
||
/// `GetChildren` and `GetNumChildren` share (see their comment on why they must share it).
|
||
fn children_of(lua: &Lua, this: &Table) -> mlua::Result<Vec<u32>> {
|
||
let h = frame_handle_of(lua, this)?;
|
||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||
let Some(children) = model.arena.frame(h).map(|f| f.children.clone()) else {
|
||
return Ok(Vec::new());
|
||
};
|
||
Ok(children.into_iter().map(|c| model.frame_id(c)).collect())
|
||
}
|
||
|
||
/// This frame's live regions as stable ids, in creation order — **the title region and detached
|
||
/// ones excluded** — the one walk `GetRegions` and `GetNumRegions` share.
|
||
///
|
||
/// Both exclusions are the client's list, not tidiness, and both are VERIFIED (wow-re
|
||
/// `ui/scratch/widget-list-bindings.md`, §5 quartet):
|
||
///
|
||
/// - **Detached.** `SetParent(nil)` reaches `0x77fd55` → the removal virtual `0x76a7f0`, which
|
||
/// unlinks the node and frees it, and then skips every re-link. We keep the entry only so the
|
||
/// arena can still free the slot; reporting it would hand an addon an object the reference never
|
||
/// would, and `StripTextures`-shaped code would "strip" a region already off the screen.
|
||
/// - **The title region.** Its two creation paths (`0x773910`, `0x769b79`) dispatch a vtable slot
|
||
/// that is a bare `[this+0x9c] = parent` and never reach the linker at all, so it was never in
|
||
/// this list — corroborated by `Hide`/`Show` needing an explicit extra `[frame+0xa8]` case
|
||
/// *because* the walk misses it. Ours is in [`crate::widget::Frame::regions`] so that
|
||
/// `WidgetArena::destroy` still frees it, which makes that a representation detail this walk
|
||
/// must not leak.
|
||
///
|
||
/// A Button's label and its four state textures are deliberately NOT excluded: they link through
|
||
/// `0x77fd10` like any other region in the reference, and ours go through `create_region` like any
|
||
/// other region here.
|
||
fn regions_of(lua: &Lua, this: &Table) -> mlua::Result<Vec<u32>> {
|
||
let h = frame_handle_of(lua, this)?;
|
||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||
let Some((regions, title)) = model
|
||
.arena
|
||
.frame(h)
|
||
.map(|f| (f.regions.clone(), f.title_region))
|
||
else {
|
||
return Ok(Vec::new());
|
||
};
|
||
let live: Vec<_> = regions
|
||
.into_iter()
|
||
.filter(|r| Some(*r) != title)
|
||
.filter(|r| model.arena.region(*r).is_some_and(|reg| !reg.detached))
|
||
.collect();
|
||
Ok(live.into_iter().map(|r| model.region_id(r)).collect())
|
||
}
|