the addon survey seats a populated world, and hidden frames arrive
- the corpus harness now seats a buff, a target and a ticking cooldown instead of an empty world, so "nothing to draw" and "failed to draw" stop sharing a row — and the richer seat immediately exposed two real gaps - an addon button inheriting our action-button template now works even when it replaces the template's OnLoad: the handlers resolve their regions by name, the way the reference does on every access - more of the reference's hidden-by-default frames exist by name — ReputationWatchBar, MiniMapBattlefieldFrame, MiniMapMeetingStoneFrame — for the addons that reposition them - StartSizing lands beside its stop verb, and the sourced bag-anchor code runs against windows we actually build Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -523,6 +523,9 @@ pub(crate) struct Model {
|
||||
/// with nothing moving, and a move outlives the mouse button (the reference's mouse-up
|
||||
/// auto-stop skips the Lua drag type).
|
||||
pub(crate) moving: Option<super::object::FrameMove>,
|
||||
/// The in-flight `StartSizing` drag — the resize twin of [`Self::moving`], cleared by the same
|
||||
/// `StopMovingOrSizing` (`0x776990`).
|
||||
pub(crate) sizing: Option<super::object::FrameSizing>,
|
||||
/// The in-flight Slider thumb drag: set when a LeftButton press lands on a Slider's thumb, held
|
||||
/// until release / pointer-leave (decision 0250 §5, the engine's C++-equivalent thumb drag —
|
||||
/// like a scrollbar dragging in the real client, no Lua involved). `None` between drags
|
||||
@@ -1038,6 +1041,7 @@ impl Model {
|
||||
drag_registered: HashMap::new(),
|
||||
drag: None,
|
||||
moving: None,
|
||||
sizing: None,
|
||||
slider_drag: None,
|
||||
gossip: None,
|
||||
gossip_selects: Vec::new(),
|
||||
|
||||
@@ -31,7 +31,7 @@ mod layout_methods;
|
||||
mod movable;
|
||||
pub(crate) mod toplevel;
|
||||
pub(crate) use layout_methods::anchor_bits_eq;
|
||||
pub(crate) use movable::{advance_move, FrameMove};
|
||||
pub(crate) use movable::{advance_move, advance_size, FrameMove, FrameSizing};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// id ↔ lightuserdata
|
||||
|
||||
@@ -274,8 +274,18 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
/// **97 of the 108 addons that drew and then raised on being touched died on that one line.** No
|
||||
/// missing verb: `GetLeft` was always there, and always answered nil.
|
||||
///
|
||||
/// Cheap: `resolve_layout`'s tier-1 gate is one epoch comparison that returns immediately when
|
||||
/// nothing has been touched, so a settled tree pays a compare per getter.
|
||||
/// Cheap on a SETTLED tree: `resolve_layout`'s tier-1 gate is one epoch comparison that returns
|
||||
/// immediately when nothing has been touched, so a run of getters pays a compare each.
|
||||
///
|
||||
/// **The interleaved case is not, and this is measured rather than asserted.** A write bumps the
|
||||
/// epoch, so `SetPoint; GetLeft; SetPoint; GetLeft; …` resolves the whole graph once per iteration.
|
||||
/// Timed on a 200-frame tree: 30 alternating pairs cost **1.41 ms** against **54 µs** for the same
|
||||
/// writes with a single read at the end — ~26x, about 47 µs per settle, and the per-settle half
|
||||
/// scales with the GRAPH, not the loop.
|
||||
///
|
||||
/// That is exactly the shape Dewdrop's menu builder has, so opening a menu pays it once. It buys a
|
||||
/// menu that works at all, which is the trade taken here. If it ever reads as a hitch, the fix is a
|
||||
/// narrower resolve (the queried frame's subtree), not a return to the stale cache.
|
||||
///
|
||||
/// **It deliberately does NOT fire `OnSizeChanged`.** That drain runs Lua handlers, and re-entering
|
||||
/// Lua from inside a binding is how a borrow panic or an unbounded recursion happens. The
|
||||
|
||||
@@ -103,6 +103,19 @@ pub(crate) struct FrameMove {
|
||||
sample: (f32, f32),
|
||||
}
|
||||
|
||||
/// An in-flight `StartSizing` drag: which frame, which grip, and the cursor at the last pump.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct FrameSizing {
|
||||
frame: FrameHandle,
|
||||
/// The grip the caller named — the EDGES this drag moves. `LEFT` moves the left edge and
|
||||
/// leaves the right where it is, `BOTTOMRIGHT` moves both of those, and so on.
|
||||
left: bool,
|
||||
right: bool,
|
||||
top: bool,
|
||||
bottom: bool,
|
||||
sample: (f32, f32),
|
||||
}
|
||||
|
||||
/// Populate `m`'s movable/resizable methods (see the module doc).
|
||||
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// SetMovable(flag) / IsMovable() — flag word bit 0x100 (`0x776420`/`0x7764d0` through the
|
||||
@@ -177,6 +190,57 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
start_moving(&mut model, h)
|
||||
})?,
|
||||
)?;
|
||||
// StartSizing(point) — begin a resize drag from a named grip (`0x776830`, verified in wow-re's
|
||||
// ledger; the reference's own caller is `FloatingChatFrame.lua:600`,
|
||||
// `this:GetParent():StartSizing(anchorPoint)`).
|
||||
//
|
||||
// **What is verified and what is read, kept apart on purpose.** Verified: the verb exists, it
|
||||
// takes the grip name, it returns nothing, and `StopMovingOrSizing` ends it (the same slot
|
||||
// clear as a move). NOT recorded anywhere in wow-re — the ledger has it as ORCHESTRATION with
|
||||
// no inline math — is WHICH EDGES a given grip moves. Taken here as the plain meaning of an
|
||||
// anchor point, which is how the reference's own caller uses it (its resize grips pass the
|
||||
// corner they sit in): the named edges follow the cursor and the opposite ones stay put. If an
|
||||
// RE pass ever contradicts that, this comment is where to correct it.
|
||||
//
|
||||
// Four corpus addons reach it through ONE line — `FuBar_Panel.lua:980`, replicated into
|
||||
// FuBar_CorkFu, FuBar_FuXPFu, FuBar_SpellStatusFu and oRA2 — which is 1207's rule and why the
|
||||
// count is not four independent votes.
|
||||
m.set(
|
||||
"StartSizing",
|
||||
lua.create_function(|lua, (this, point): (Table, Option<String>)| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
if !model.arena.frame(h).is_some_and(|f| f.resizable) {
|
||||
return Err(not_flagged(&model, h, "resizable"));
|
||||
}
|
||||
// Same "do not start a second one" guard `start_moving` carries.
|
||||
if model.sizing.is_some() || model.moving.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let p = point.unwrap_or_default().to_ascii_uppercase();
|
||||
let (left, right) = (p.contains("LEFT"), p.contains("RIGHT"));
|
||||
let (top, bottom) = (p.contains("TOP"), p.contains("BOTTOM"));
|
||||
// A grip naming no edge would resize nothing; the reference has no such call and we
|
||||
// refuse rather than invent one.
|
||||
if !(left || right || top || bottom) {
|
||||
return Ok(());
|
||||
}
|
||||
super::toplevel::raise(&mut model, h);
|
||||
if let Some(f) = model.arena.frame_mut(h) {
|
||||
f.user_placed = true;
|
||||
}
|
||||
let sample = model.cursor_pos;
|
||||
model.sizing = Some(FrameSizing {
|
||||
frame: h,
|
||||
left,
|
||||
right,
|
||||
top,
|
||||
bottom,
|
||||
sample,
|
||||
});
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
// StopMovingOrSizing() — leave the drag (`0x776990` → `0x765640`): a state clear, and only
|
||||
// when `self` IS the frame in the slot. Nothing is written back — the pump has been moving the
|
||||
// anchors all along, so the frame simply keeps the last position it was dragged to. Harmless
|
||||
@@ -187,6 +251,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
if model.sizing.is_some_and(|sz| sz.frame == h) {
|
||||
model.sizing = None;
|
||||
}
|
||||
if model.moving.is_some_and(|mv| mv.frame == h) {
|
||||
model.moving = None;
|
||||
}
|
||||
@@ -283,6 +350,62 @@ fn start_moving(model: &mut Model, h: FrameHandle) -> mlua::Result<()> {
|
||||
/// Applies `(pos − sample)` scaled into the frame's anchor offsets and re-centers the sample; a
|
||||
/// zero delta does nothing at all, and a frame that died mid-move ends the move rather than
|
||||
/// writing to a dead handle.
|
||||
/// Pump an in-flight `StartSizing`: move the gripped edges to the cursor, leave the others.
|
||||
///
|
||||
/// The mirror of [`advance_move`] — same sample-and-recentre, same dead-frame bail, same
|
||||
/// local-unit scaling (offsets are pre-scale, so a cursor delta divides by the frame's own scale on
|
||||
/// the way in). Where a move translates every anchor, a resize moves the gripped edges only: the
|
||||
/// width/height change, and the anchor offsets follow only for the edges that actually moved, which
|
||||
/// is what keeps the OPPOSITE edge planted.
|
||||
pub(crate) fn advance_size(model: &mut Model, pos: (f32, f32)) {
|
||||
let Some(sz) = model.sizing else { return };
|
||||
if model.arena.frame(sz.frame).is_none() {
|
||||
model.sizing = None;
|
||||
return;
|
||||
}
|
||||
let (dx, dy) = (pos.0 - sz.sample.0, pos.1 - sz.sample.1);
|
||||
if dx == 0.0 && dy == 0.0 {
|
||||
return;
|
||||
}
|
||||
let inv = 1.0 / eff_scale(model, sz.frame);
|
||||
let (dx, dy) = (dx * inv, dy * inv);
|
||||
let Some(input) = model.layout_inputs.get_mut(&sz.frame) else {
|
||||
return;
|
||||
};
|
||||
// Width grows when the RIGHT grip goes right, or the LEFT grip goes left. Height likewise with
|
||||
// y-up: the TOP grip going up grows it, the BOTTOM grip going down grows it.
|
||||
let dw = if sz.right {
|
||||
dx
|
||||
} else if sz.left {
|
||||
-dx
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let dh = if sz.top {
|
||||
dy
|
||||
} else if sz.bottom {
|
||||
-dy
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
input.width = (input.width + dw).max(1.0);
|
||||
input.height = (input.height + dh).max(1.0);
|
||||
// The planted edge: a frame anchored by its LEFT that is gripped on the LEFT has to move too,
|
||||
// or the resize would push the right edge instead. Only the gripped axis shifts.
|
||||
if !input.anchors.is_empty() && (sz.left || sz.bottom) {
|
||||
for a in &mut input.anchors {
|
||||
if sz.left {
|
||||
a.x_off += dx;
|
||||
}
|
||||
if sz.bottom {
|
||||
a.y_off += dy;
|
||||
}
|
||||
}
|
||||
}
|
||||
model.touch_layout();
|
||||
model.sizing = Some(FrameSizing { sample: pos, ..sz });
|
||||
}
|
||||
|
||||
pub(crate) fn advance_move(model: &mut Model, pos: (f32, f32)) {
|
||||
let Some(mv) = model.moving else { return };
|
||||
if model.arena.frame(mv.frame).is_none() {
|
||||
|
||||
@@ -204,6 +204,7 @@ impl UiScript {
|
||||
// crosses and a pump behind the early return would only ever advance when the cursor
|
||||
// left the frame it is dragging.
|
||||
super::object::advance_move(&mut model, (x, y));
|
||||
super::object::advance_size(&mut model, (x, y));
|
||||
let drag_start = cursor::maybe_start_drag(&mut model, (x, y));
|
||||
let new_handle = new_id.and_then(|id| model.id_to_frame.get(&id).copied());
|
||||
if new_handle == model.mouseover {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
||||
//! Region method-table cluster: **layout** — size, anchors and the resolved-rect readers.
|
||||
//! Split out of `region.rs` at the 0716 file-size budget.
|
||||
|
||||
use mlua::{Lua, Table, Value};
|
||||
|
||||
use crate::layout::{Anchor, Point};
|
||||
use crate::script::object::anchor_bits_eq;
|
||||
use crate::script::Model;
|
||||
|
||||
/// Resolve `self` (a region wrapper) to its live [`RegionHandle`].
|
||||
use super::{
|
||||
measured_wh, region_handle_of, region_owner_id, region_set_point, resolve_target, size_bits_eq,
|
||||
};
|
||||
|
||||
/// Populate `m`'s layout methods (see the module doc).
|
||||
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// Region explicit size (drawn centered on the owner; region anchors come later).
|
||||
m.set(
|
||||
"SetWidth",
|
||||
lua.create_function(|lua, (this, w): (Table, f32)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
let new = Some((w, d.size.map_or(0.0, |s| s.1)));
|
||||
let changed = !size_bits_eq(d.size, new);
|
||||
d.size = new;
|
||||
if changed {
|
||||
model.touch_layout();
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
m.set(
|
||||
"SetHeight",
|
||||
lua.create_function(|lua, (this, h): (Table, f32)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
let new = Some((d.size.map_or(0.0, |s| s.0), h));
|
||||
let changed = !size_bits_eq(d.size, new);
|
||||
d.size = new;
|
||||
if changed {
|
||||
model.touch_layout();
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
m.set(
|
||||
"SetSize",
|
||||
lua.create_function(|lua, (this, w, h): (Table, f32, f32)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
let new = Some((w, h));
|
||||
let changed = !size_bits_eq(d.size, new);
|
||||
d.size = new;
|
||||
if changed {
|
||||
model.touch_layout();
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
m.set(
|
||||
"GetWidth",
|
||||
lua.create_function(|lua, this: Table| Ok(measured_wh(lua, &this)?.0))?,
|
||||
)?;
|
||||
|
||||
m.set(
|
||||
"GetHeight",
|
||||
lua.create_function(|lua, this: Table| Ok(measured_wh(lua, &this)?.1))?,
|
||||
)?;
|
||||
|
||||
// GetLeft/GetRight/GetTop/GetBottom — the region's RESOLVED edges (y-up UI units; frame twin
|
||||
// in object.rs). An anchored region reads its own resolved rect; an unanchored one has no
|
||||
// rect of its own (it draws relative to its owner at extract) → nil, same as pre-resolve.
|
||||
for (name, pick) in [
|
||||
("GetLeft", 0u8),
|
||||
("GetRight", 1u8),
|
||||
("GetTop", 2u8),
|
||||
("GetBottom", 3u8),
|
||||
] {
|
||||
m.set(
|
||||
name,
|
||||
lua.create_function(move |lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
Ok(model.region_resolved.get(&rh).map(|r| match pick {
|
||||
0 => r.left,
|
||||
1 => r.right,
|
||||
2 => r.top,
|
||||
_ => r.bottom,
|
||||
}))
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
// Region anchors: SetPoint/ClearAllPoints/SetAllPoints mirror the frame versions
|
||||
// ([`super::object`]) but write [`super::RegionData::anchors`]. An unspecified `relativeTo`
|
||||
// defaults to the **owner frame**; a named one may be a frame or a sibling region (the real
|
||||
// XML anchors regions to sibling regions everywhere — merchant label plate → `$parentSlot`).
|
||||
m.set(
|
||||
"SetPoint",
|
||||
lua.create_function(
|
||||
|lua, (this, p, a2, a3, a4, a5): (Table, String, Value, Value, Value, Value)| {
|
||||
region_set_point(lua, &this, &p, [a2, a3, a4, a5])
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
|
||||
m.set(
|
||||
"ClearAllPoints",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
let changed = !d.anchors.is_empty();
|
||||
d.anchors.clear();
|
||||
if changed {
|
||||
model.touch_layout();
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
m.set(
|
||||
"SetAllPoints",
|
||||
lua.create_function(|lua, (this, target): (Table, Value)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let owner = region_owner_id(&mut model, rh);
|
||||
let rel_id = resolve_target(&mut model, &target, owner);
|
||||
let pair = [
|
||||
Anchor::new(Point::TopLeft, rel_id, Point::TopLeft, 0.0, 0.0),
|
||||
Anchor::new(Point::BottomRight, rel_id, Point::BottomRight, 0.0, 0.0),
|
||||
];
|
||||
let data = model.region_data.entry(rh).or_default();
|
||||
let same = data.anchors.len() == 2
|
||||
&& data
|
||||
.anchors
|
||||
.iter()
|
||||
.zip(&pair)
|
||||
.all(|(a, b)| anchor_bits_eq(a, b));
|
||||
if !same {
|
||||
data.anchors.clear();
|
||||
data.anchors.extend_from_slice(&pair);
|
||||
model.touch_layout();
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
//! Region method-table cluster: **paint** — what a Texture shows and how it is tinted,
|
||||
//! blended, cropped and layered. Split out of `region.rs` at the 0716 file-size budget.
|
||||
|
||||
use mlua::{Lua, Table, Value};
|
||||
|
||||
use crate::script::object::{as_f32, draw_layer_from_str};
|
||||
use crate::script::{Model, TexCoords};
|
||||
|
||||
/// Resolve `self` (a region wrapper) to its live [`RegionHandle`].
|
||||
use super::region_handle_of;
|
||||
|
||||
/// Populate `m`'s paint methods (see the module doc).
|
||||
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// SetAlpha/GetAlpha — the region's own alpha, distinct from the owner frame's. The ref kit reads
|
||||
// it back to ramp a texture (CastingBarFrame's completion flash does
|
||||
// `CastingBarFlash:SetAlpha(GetAlpha() + CASTING_BAR_FLASH_STEP)`), so the getter must return the
|
||||
// region's value, never the frame's. Draw law + the open question: [`RegionData::alpha`].
|
||||
m.set(
|
||||
"SetAlpha",
|
||||
lua.create_function(|lua, (this, alpha): (Table, f32)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
model.region_data.entry(rh).or_default().alpha = Some(alpha.clamp(0.0, 1.0));
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
m.set(
|
||||
"GetAlpha",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
Ok(model
|
||||
.region_data
|
||||
.get(&rh)
|
||||
.and_then(|d| d.alpha)
|
||||
.unwrap_or(1.0))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
m.set(
|
||||
"SetVertexColor",
|
||||
lua.create_function(
|
||||
|lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
d.vertex_color = Some([r, g, b, a.unwrap_or(1.0)]);
|
||||
// The same slot `SetTextColor` writes on a FontString, so it is the same explicit
|
||||
// colour set as far as font-object inheritance is concerned.
|
||||
d.font_explicit.color = true;
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
|
||||
// GetVertexColor — the setter's own pair, a real 5875 binding (`0x79aa50`, wow-re
|
||||
// `system/ui/ledger.tsv`; it sits directly above `SetVertexColor 0x79abd0` in the same region
|
||||
// method family). Never set = the untinted white every region draws at by default.
|
||||
m.set(
|
||||
"GetVertexColor",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
let c = model
|
||||
.region_data
|
||||
.get(&rh)
|
||||
.and_then(|d| d.vertex_color)
|
||||
.unwrap_or([1.0, 1.0, 1.0, 1.0]);
|
||||
Ok((c[0], c[1], c[2], c[3]))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SetGradientAlpha(orientation, r1,g1,b1,a1, r2,g2,b2,a2) and its alpha-less twin
|
||||
// SetGradient(orientation, r1,g1,b1, r2,g2,b2) — the two-stop linear gradient the client
|
||||
// generates into the same `+0xcc` texture slot the colour form of SetTexture fills.
|
||||
//
|
||||
// These were missing, and they were the single wall in front of the corpus's largest family:
|
||||
// `FuBar\FuBar_Panel.lua:144` calls SetGradientAlpha while building the bar, so all 20 FuBar
|
||||
// plugins died there — after the debugstack/chunk-name fix got them that far.
|
||||
//
|
||||
// The orientation token is matched case-insensitively and anything that is not "VERTICAL" is
|
||||
// horizontal, which is the client's own leniency and matters because addons spell it both ways.
|
||||
// The gradient is stored WHOLE (see `RegionData::gradient`); the paint folds it to its midpoint
|
||||
// because a quad carries one tint today. That approximation is visible and is recorded at the
|
||||
// field, not hidden here.
|
||||
for (name, with_alpha) in [("SetGradientAlpha", true), ("SetGradient", false)] {
|
||||
m.set(
|
||||
name,
|
||||
lua.create_function(move |lua, args: mlua::MultiValue| {
|
||||
let mut it = args.into_iter();
|
||||
let this: Table = match it.next() {
|
||||
Some(Value::Table(t)) => t,
|
||||
_ => return Err(mlua::Error::runtime("expected a region")),
|
||||
};
|
||||
let orientation = match it.next() {
|
||||
Some(Value::String(s)) => s.to_str()?.to_string(),
|
||||
// A missing/!string orientation is horizontal, like any non-"VERTICAL" token.
|
||||
_ => String::new(),
|
||||
};
|
||||
let n = if with_alpha { 8 } else { 6 };
|
||||
let mut c = [0.0f32; 8];
|
||||
for slot in c.iter_mut().take(n) {
|
||||
*slot = it.next().as_ref().map(as_f32).unwrap_or(0.0);
|
||||
}
|
||||
let (start, end) = if with_alpha {
|
||||
([c[0], c[1], c[2], c[3]], [c[4], c[5], c[6], c[7]])
|
||||
} else {
|
||||
// SetGradient has no alpha stops: both ends are opaque.
|
||||
([c[0], c[1], c[2], 1.0], [c[3], c[4], c[5], 1.0])
|
||||
};
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
d.gradient = Some(crate::script::Gradient {
|
||||
vertical: orientation.eq_ignore_ascii_case("VERTICAL"),
|
||||
start,
|
||||
end,
|
||||
});
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
m.set(
|
||||
"SetTexture",
|
||||
// The trailing three are `Value`, not `Option<f32>`, and that is a fidelity fix rather than
|
||||
// laxity. The path form reads ONE argument (`0x770200`); only the colour form
|
||||
// (`0x770360`) reads up to four. A C function takes what it wants off the Lua stack and
|
||||
// ignores the rest, so `SetTexture(path, true)` is fine on the real client — and typing
|
||||
// these as `Option<f32>` made us raise on it, `bad argument #3: error converting Lua
|
||||
// boolean to f32`, in a call the client accepts silently.
|
||||
//
|
||||
// Found by `_LazyPig/LazyPigMenu.lua:182`
|
||||
// (`texture_title:SetTexture("Interface\DialogFrame\UI-DialogBox-Header", true)`), which
|
||||
// reached us only once the survey started seating the addon registry — the whole point of
|
||||
// that instrument fix. The stray `true` is meaningless in 1.12 and the addon author
|
||||
// presumably meant a later client's second parameter; either way the client shrugs.
|
||||
lua.create_function(
|
||||
|lua, (this, arg, g, b, a): (Table, Value, Value, Value, Value)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let data = model.region_data.entry(rh).or_default();
|
||||
// A plain SetTexture makes the region ordinary again — drop any portrait circular mask
|
||||
// and any live-unit-portrait binding.
|
||||
data.circular = false;
|
||||
data.portrait_unit = None;
|
||||
// Both forms write the SAME `+0xcc` texture slot — the path form loads a file
|
||||
// there (`0x770200`), the colour form generates an 8×8 solid into it
|
||||
// (`0x770360`) — so each clears the other. NEITHER touches the vertex colour at
|
||||
// `+0xb8`: a tint outlives the art it was tinting.
|
||||
match &arg {
|
||||
// SetTexture("") clears, same as SetTexture(nil) — the ref lua blanks state
|
||||
// art with the empty string (QuestLogFrame.lua:165-166).
|
||||
Value::String(s) if s.to_str()?.is_empty() => {
|
||||
data.texture = None;
|
||||
data.fill = None;
|
||||
}
|
||||
Value::String(s) => {
|
||||
data.texture = Some(s.to_str()?.to_string());
|
||||
data.fill = None;
|
||||
}
|
||||
// The colour form, and the ONLY branch that looks at the trailing three. A
|
||||
// non-numeric there takes the same default a missing one does, which is what
|
||||
// reading off a C stack does: `lua_tonumber` on a non-number yields 0.
|
||||
Value::Number(_) | Value::Integer(_) => {
|
||||
let chan = |v: &Value, dflt: f32| match v {
|
||||
Value::Number(_) | Value::Integer(_) => as_f32(v),
|
||||
Value::String(s) => s
|
||||
.to_str()
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<f32>().ok())
|
||||
.unwrap_or(dflt),
|
||||
_ => dflt,
|
||||
};
|
||||
data.fill =
|
||||
Some([as_f32(&arg), chan(&g, 0.0), chan(&b, 0.0), chan(&a, 1.0)]);
|
||||
data.texture = None;
|
||||
}
|
||||
// SetTexture(nil) clears (the live API's blank-the-region form); a cleared
|
||||
// texture region draws nothing.
|
||||
Value::Nil => {
|
||||
data.texture = None;
|
||||
data.fill = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
|
||||
// SetDesaturated(flag) -> shaderSupported — Texture only (`0x79c1e0`, wow-re ledger; the
|
||||
// reference's own `ItemButtonTemplate.lua:69` is `local shaderSupported =
|
||||
// icon:SetDesaturated(desaturated)`).
|
||||
//
|
||||
// **The RETURN is the whole design, and it is the half a plausible implementation drops.**
|
||||
// 1.12 ran on cards that could not do the shader, so the verb reports whether it took effect
|
||||
// and FrameXML falls back by hand:
|
||||
//
|
||||
// if ( not desaturated ) then r,g,b = 1,1,1
|
||||
// elseif ( not r or not shaderSupported ) then r,g,b = 0.5,0.5,0.5 end
|
||||
// icon:SetVertexColor(r, g, b)
|
||||
//
|
||||
// We have no desaturation in the renderer, so we answer **nil — unsupported**, which is a real
|
||||
// 1.12 machine's answer and not a lie. Claiming support would suppress that grey fallback and
|
||||
// leave disabled icons drawn at full colour: strictly worse-looking than saying no. When a
|
||||
// desaturating shader lands, flip the return and this comment with it.
|
||||
//
|
||||
// Why it matters far past one verb: **98 of the 109 addons that draw and then raise on being
|
||||
// used, raise here** — `FuBar_Panel.lua:43`'s right-click reaches Dewdrop's `AddLine`
|
||||
// (`Dewdrop-2.0.lua:2172`), which calls `button.arrow:SetDesaturated(true)` unguarded. A
|
||||
// static scan costed this at 61 addons and it was declined; the use-probe costed it at 98 the
|
||||
// moment anyone right-clicks.
|
||||
m.set(
|
||||
"SetDesaturated",
|
||||
lua.create_function(|lua, (this, flag): (Table, Value)| {
|
||||
let on = !matches!(flag, Value::Nil | Value::Boolean(false));
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
model.region_data.entry(rh).or_default().desaturated = on;
|
||||
// nil, not false: the reference's `shaderSupported` is a 1|nil C answer and callers
|
||||
// write `not shaderSupported`.
|
||||
Ok(Value::Nil)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTexture() — Texture only (`0x79ba70`), verified in wow-re's widget-method batch
|
||||
// (`system/ui/scratch/widget-api-batch-benilla.md`). Three contract details are each the kind a
|
||||
// plausible implementation gets silently wrong, so each is spelled out:
|
||||
//
|
||||
// · **Exactly ONE return value**, never a multi-return.
|
||||
// · **The colour form returns the literal string `"Solid Texture"`** (`0x835708`), NOT nil.
|
||||
// `SetTexture(r,g,b)` synthesizes an 8x8 solid, and the getter reports that name — so an
|
||||
// addon's `if not tex then` passes straight through on a colour-filled region. Returning nil
|
||||
// here would look tidier and would be wrong in the one direction callers test for.
|
||||
// · **The path is stripped at the LAST `.`** (`0x79baf0`): the loader appends `.blp`/`.tga` to
|
||||
// what was set and the getter strips an extension back off. Taken verbatim rather than
|
||||
// "strip only a real extension" — a directory containing a dot is mangled by the real client
|
||||
// too, and this surface is a transcription, not an improvement.
|
||||
//
|
||||
// Four corpus addons: `AtlasQuest.lua:228` (`AQATLASMAP = AtlasMap:GetTexture()`) and
|
||||
// `FuBarPlugin-2.0.lua:343` (`return self.iconFrame:GetTexture()`), each reached by two addons.
|
||||
m.set(
|
||||
"GetTexture",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_mut::<Model>().expect("model");
|
||||
let Some(data) = model.region_data.get(&rh) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if data.fill.is_some() {
|
||||
return Ok(Some("Solid Texture".to_string()));
|
||||
}
|
||||
Ok(data.texture.as_ref().map(|t| match t.rfind('.') {
|
||||
Some(i) => t[..i].to_string(),
|
||||
None => t.clone(),
|
||||
}))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SetPortraitToTexture(path) — set the texture AND mark the region a portrait (drawn masked to
|
||||
// its inscribed circle). The client's portraits are circular (model or icon fallback); the frame
|
||||
// ring is a thin band whose transparent corners would otherwise show the square texture's edges.
|
||||
// The live API's `SetPortraitToTexture(texture, path)` is a global crop helper; ours is the
|
||||
// region-method face of the same intent. This is the *icon/path* portrait — distinct from the
|
||||
// `SetPortraitTexture(region, unit)` live model bake, so it drops any live-unit binding.
|
||||
// `SetTexture` on the same region clears the flag (a plain texture again).
|
||||
m.set(
|
||||
"SetPortraitToTexture",
|
||||
lua.create_function(|lua, (this, path): (Table, String)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let data = model.region_data.entry(rh).or_default();
|
||||
data.texture = Some(path);
|
||||
data.circular = true;
|
||||
data.portrait_unit = None;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SetAlphaGradient(start, length) — the per-character write-on reveal (CSimpleFontString;
|
||||
// the quest-description machinery, ref QuestFrame.lua:548/558). Returns whether `start` is
|
||||
// still inside the text (chars) — the ref's OnUpdate loop advances until this goes false.
|
||||
m.set(
|
||||
"SetAlphaGradient",
|
||||
lua.create_function(|lua, (this, start, length): (Table, f32, f32)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let data = model.region_data.entry(rh).or_default();
|
||||
data.alpha_gradient = Some((start, length));
|
||||
let chars = data.text.as_deref().map_or(0, |t| t.chars().count());
|
||||
Ok(start < chars as f32)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SetBlendMode("BLEND"|"ADD"|…) — the shared alphaMode enum (0x811aa8); only ADD changes
|
||||
// draw behavior in v1 (DISABLE/ALPHAKEY/MOD are accepted as straight alpha, a stated gap).
|
||||
m.set(
|
||||
"SetBlendMode",
|
||||
lua.create_function(|lua, (this, mode): (Table, String)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
model.region_data.entry(rh).or_default().additive = mode.eq_ignore_ascii_case("ADD");
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
m.set(
|
||||
"SetDrawLayer",
|
||||
lua.create_function(|lua, (this, layer, sub): (Table, String, Option<i64>)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let dl = draw_layer_from_str(&layer)
|
||||
.ok_or_else(|| mlua::Error::runtime(format!("unknown draw layer '{layer}'")))?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
if let Some(region) = model.arena.region_mut(rh) {
|
||||
region.draw_layer = dl;
|
||||
if let Some(s) = sub {
|
||||
region.sub_level = s.clamp(i64::from(i8::MIN), i64::from(i8::MAX)) as i8;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SetRotation(radians) — spin the texture about its center, counterclockwise-positive (the
|
||||
// later-era Texture API, shipped early: the world-map player arrow's stand-in rotation —
|
||||
// see `QuadContent::Texture::rotation`). No-arg/nil resets to 0.
|
||||
m.set(
|
||||
"SetRotation",
|
||||
lua.create_function(|lua, (this, radians): (Table, Option<f32>)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model")
|
||||
.region_data
|
||||
.entry(rh)
|
||||
.or_default()
|
||||
.rotation = radians.unwrap_or(0.0);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SetTexCoord(left, right, top, bottom) — the 4-edge form (XML `<TexCoords>`): a UV sub-rect in
|
||||
// 0..1 texture space (top-left origin) the Texture region samples, slicing quadrant/atlas art
|
||||
// (decision 0084). SetTexCoord(ULx,ULy, LLx,LLy, URx,URy, LRx,LRy) — the 8-arg affine form: an
|
||||
// arbitrary UV quad (rotation/shear — the reference's `DrawRouteLine` route lines), stored per
|
||||
// corner in the renderer's screen winding.
|
||||
m.set(
|
||||
"SetTexCoord",
|
||||
lua.create_function(|lua, (this, rest): (Table, mlua::Variadic<f32>)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let coords = match rest.len() {
|
||||
4 => Some(TexCoords::Rect([rest[0], rest[1], rest[2], rest[3]])),
|
||||
// The live arg order is UL, LL, UR, LR (corner pairs); [`TexCoords::Corners`]
|
||||
// stores screen order [TL, TR, BR, BL].
|
||||
8 => Some(TexCoords::Corners([
|
||||
[rest[0], rest[1]], // UL → TL
|
||||
[rest[4], rest[5]], // UR → TR
|
||||
[rest[6], rest[7]], // LR → BR
|
||||
[rest[2], rest[3]], // LL → BL
|
||||
])),
|
||||
// No args resets to the full texture (the live API's clear form).
|
||||
0 => None,
|
||||
n => {
|
||||
return Err(mlua::Error::runtime(format!(
|
||||
"SetTexCoord: expected 4 (edges) or 8 (corner pairs) args, got {n}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model")
|
||||
.region_data
|
||||
.entry(rh)
|
||||
.or_default()
|
||||
.tex_coords = coords;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTexCoord() → left, right, top, bottom (the 4-edge form; full texture if never set; an
|
||||
// affine mapping reports its bounding edges).
|
||||
m.set(
|
||||
"GetTexCoord",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
let [l, r, t, b] = model
|
||||
.region_data
|
||||
.get(&rh)
|
||||
.and_then(|d| d.tex_coords)
|
||||
.map(|tc| tc.edges())
|
||||
.unwrap_or([0.0, 1.0, 0.0, 1.0]);
|
||||
Ok((l, r, t, b))
|
||||
})?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
//! Region method-table cluster: **text** — a FontString's string, its faces, colour, shadow
|
||||
//! and justification. Split out of `region.rs` at the 0716 file-size budget.
|
||||
//!
|
||||
//! **These duplicate names the `<Font>` object table also carries, deliberately for now**: a region
|
||||
//! may override its inherited font object per-property, and the severance mask (`FontExplicit`) is
|
||||
//! what keeps the two apart. Collapsing them onto one shared block is the follow-on 1231 named.
|
||||
|
||||
use mlua::{Lua, Table, Value};
|
||||
|
||||
use crate::script::{JustifyH, JustifyV, Model, Outline};
|
||||
|
||||
/// Resolve `self` (a region wrapper) to its live [`RegionHandle`].
|
||||
use super::{measured_wh, region_handle_of};
|
||||
|
||||
/// Populate `m`'s text methods (see the module doc).
|
||||
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
m.set(
|
||||
"SetText",
|
||||
lua.create_function(|lua, (this, text): (Table, Option<String>)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let data = model.region_data.entry(rh).or_default();
|
||||
data.text = text;
|
||||
// Fresh text draws whole — an armed write-on gradient belongs to the old string.
|
||||
data.alpha_gradient = None;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SetFormattedText(fmt, ...) = SetText(format(fmt, ...)) — routed through the stdlib's
|
||||
// positional-aware `format` so `%N$s` specs behave (a consensus call across the 0068 targets).
|
||||
m.set(
|
||||
"SetFormattedText",
|
||||
lua.create_function(|lua, (this, args): (Table, mlua::MultiValue)| {
|
||||
let format: mlua::Function = lua
|
||||
.globals()
|
||||
.get::<Table>("string")?
|
||||
.get::<mlua::Function>("format")?;
|
||||
let text: String = format.call(args)?;
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let data = model.region_data.entry(rh).or_default();
|
||||
data.text = Some(text);
|
||||
data.alpha_gradient = None;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
m.set(
|
||||
"GetText",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let text = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
model.region_data.get(&rh).and_then(|d| d.text.clone())
|
||||
};
|
||||
match text {
|
||||
Some(t) => Ok(Value::String(lua.create_string(&t)?)),
|
||||
None => Ok(Value::Nil),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetStringWidth/GetStringHeight (FontString): the host-measured text extent from the measure
|
||||
// round-trip ([`super::UiScript::set_measured_text`]) — the client asks its font engine for the
|
||||
// laid-out string's metrics exactly here (`fontstring.md`), and the tooltip's auto-size sums
|
||||
// these to fit its lines. `0` until the string has been measured (a frame's latency; converges).
|
||||
// The stored measure only counts while its key matches the CURRENT text/font/wrap
|
||||
// ([`RegionData::measure_key`]): after a SetText the old string's width is not this string's
|
||||
// metric — serving it is how the whisper header's `GetWidth()` latched the edit-box insets on
|
||||
// the previous header's width. A poll-until-nonzero caller (the chat header machine) now
|
||||
// converges on the RIGHT measure instead of settling on a stale one.
|
||||
// `GetWidth`/`GetHeight` prefer the measured extent, falling back to an explicit `SetSize` — the
|
||||
// real client's `SmallTextTooltipText:GetWidth()` idiom (ref-GameTooltip.xml l.63).
|
||||
// GetStringWidth is the **natural, unwrapped** extent — never the declared box, and never the
|
||||
// wrapped one (wow-re `fontstring-overflow.md`, "The measurement echo": the reference's getter
|
||||
// re-measures the raw text with NO wrap constraint). Unlike `GetWidth` below it deliberately
|
||||
// does NOT fall back to an explicit `SetSize`: the declared width is the very thing a caller
|
||||
// asks this to be independent of. A kit that sizes a box from this number and then sets a width
|
||||
// on the string — which is what the reference's own `PanelTemplates_TabResize` does — would
|
||||
// otherwise read its own output back as its next input and never settle (decision 0997, the
|
||||
// macro window's character tab changing width every frame). `0` until measured, as ever.
|
||||
fn natural_w(lua: &Lua, this: &Table) -> mlua::Result<f32> {
|
||||
let rh = region_handle_of(lua, this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
let Some(d) = model.region_data.get(&rh) else {
|
||||
return Ok(0.0);
|
||||
};
|
||||
let scale = model
|
||||
.arena
|
||||
.region(rh)
|
||||
.and_then(|r| model.arena.frame(r.owner))
|
||||
.map(|f| f.effective_scale)
|
||||
.unwrap_or(1.0);
|
||||
Ok(d.measured
|
||||
.filter(|m| m.key == d.measure_key(scale))
|
||||
.map(|m| m.natural_w)
|
||||
.unwrap_or(0.0))
|
||||
}
|
||||
m.set(
|
||||
"GetStringWidth",
|
||||
lua.create_function(|lua, this: Table| natural_w(lua, &this))?,
|
||||
)?;
|
||||
|
||||
m.set(
|
||||
"GetStringHeight",
|
||||
lua.create_function(|lua, this: Table| Ok(measured_wh(lua, &this)?.1))?,
|
||||
)?;
|
||||
|
||||
// SetJustifyH("LEFT"|"CENTER"|"RIGHT") — a FontString's horizontal justification (XML `justifyH`).
|
||||
m.set(
|
||||
"SetJustifyH",
|
||||
lua.create_function(|lua, (this, j): (Table, String)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let jh = match j.to_ascii_uppercase().as_str() {
|
||||
"LEFT" => JustifyH::Left,
|
||||
"RIGHT" => JustifyH::Right,
|
||||
_ => JustifyH::Center,
|
||||
};
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
d.justify_h = jh;
|
||||
d.font_explicit.justify_h = true;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SetJustifyV("TOP"|"MIDDLE"|"BOTTOM") — a FontString's vertical justification (XML `justifyV`).
|
||||
m.set(
|
||||
"SetJustifyV",
|
||||
lua.create_function(|lua, (this, j): (Table, String)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let jv = match j.to_ascii_uppercase().as_str() {
|
||||
"TOP" => JustifyV::Top,
|
||||
"BOTTOM" => JustifyV::Bottom,
|
||||
_ => JustifyV::Middle,
|
||||
};
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
d.justify_v = jv;
|
||||
d.font_explicit.justify_v = true;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SetFontObject(GameFontNormal) — re-point this FontString at a Font object: its resolved paint
|
||||
// (face/height/color/outline/shadow) becomes the region's, and the link is kept live, so a later
|
||||
// `GameFontNormal:SetFont(…)` re-paints this region too ([`super::font`]'s module doc).
|
||||
//
|
||||
// All three argument forms the reference's own usage string names (`.rdata 0x87c5cc`:
|
||||
// `SetFontObject(font or "font" or nil)`) — the **object**, which is what 3,180 of the corpus's
|
||||
// 3,186 call sites pass (`Gratuity-2.0.lua:57`, every FuBar/Ace label); a **name string**, for
|
||||
// our own shipped XML and the 6 sites that use it; and **nil**, which severs the link. A frame,
|
||||
// a number, or an unknown name is an error — never a silent no-op (1203/1205/1211's class).
|
||||
m.set(
|
||||
"SetFontObject",
|
||||
lua.create_function(|lua, (this, font): (Table, Value)| {
|
||||
let name = crate::script::font::resolve("SetFontObject", &font)?;
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
// The nil form: unlink, and leave the paint standing (the reference stores a null
|
||||
// parent; nothing re-reads and nothing is cleared).
|
||||
let Some(name) = name else {
|
||||
model.region_data.entry(rh).or_default().font_object = None;
|
||||
return Ok(());
|
||||
};
|
||||
let Some(fo) = model.font_objects.get(&name).cloned() else {
|
||||
return Err(mlua::Error::runtime(format!(
|
||||
"SetFontObject: no font object named '{name}' is registered"
|
||||
)));
|
||||
};
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
d.font_object = Some(name);
|
||||
// The severance mask is deliberately NOT reset here. §5-verified: the real "stop
|
||||
// inheriting this property" signal is a CLEARED bit in the inheritMask at
|
||||
// `FONTINSTANCE+0x2c` (FontString `+0xd4`, per-axis justify at `+0x124`), cleared by
|
||||
// each local setter and never restored — "a FontString that set its own colour stays
|
||||
// severed even across a later SetFontObject" (wow-re
|
||||
// `system/ui/scratch/font-object-lua-surface.md`). This corrects our first cut, which
|
||||
// reset it.
|
||||
crate::script::font::repaint(d, &fo);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetFontObject() → the font OBJECT this FontString last resolved (or nil).
|
||||
//
|
||||
// The object, not its name: `Dewdrop-2.0.lua:2181` is
|
||||
// `button.text:SetTextColor(button.text:GetFontObject():GetTextColor())` — 65 sites across 62
|
||||
// corpus addons that index the result immediately. A name string there raises.
|
||||
m.set(
|
||||
"GetFontObject",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let name = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
model
|
||||
.region_data
|
||||
.get(&rh)
|
||||
.and_then(|d| d.font_object.clone())
|
||||
.filter(|n| model.font_objects.contains_key(n))
|
||||
};
|
||||
match name {
|
||||
Some(n) => Ok(Value::Table(crate::script::font::wrapper(lua, &n)?)),
|
||||
None => Ok(Value::Nil),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SetNonSpaceWrap(enable) / CanNonSpaceWrap() — FontString only (`0x79e9f0` / `0x79ead0`).
|
||||
//
|
||||
// Two contract details from wow-re's batch, both easy to get wrong:
|
||||
// · the getter is **`CanNonSpaceWrap`**, not `GetNonSpaceWrap`, and it answers **`1` or nil**,
|
||||
// not a boolean — 1.12 predates that convention and an addon may compare against 1.
|
||||
// · a **no-argument call ENABLES** it (the default is on), rather than being a query.
|
||||
//
|
||||
// `oRA2/Leader/Item.lua:561` is `f.textname:SetNonSpaceWrap(false)`, reached by two addons.
|
||||
m.set(
|
||||
"SetNonSpaceWrap",
|
||||
lua.create_function(|lua, (this, enable): (Table, Value)| {
|
||||
let on = match &enable {
|
||||
Value::Nil => true,
|
||||
Value::Boolean(b) => *b,
|
||||
_ => true,
|
||||
};
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
model.region_data.entry(rh).or_default().non_space_wrap = Some(on);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
m.set(
|
||||
"CanNonSpaceWrap",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_mut::<Model>().expect("model");
|
||||
let on = model
|
||||
.region_data
|
||||
.get(&rh)
|
||||
.and_then(|d| d.non_space_wrap)
|
||||
.unwrap_or(true);
|
||||
Ok(if on { Some(1i64) } else { None })
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// ── shadow, on the REGION ────────────────────────────────────────────────────────────────
|
||||
// These already existed on the font-object table; a FontString made by `CreateFontString` had
|
||||
// none of them, and that is where the corpus calls them:
|
||||
// `FuBar_NavigatorFu/NavigatorFu.lua:31` does
|
||||
// `coordText:SetShadowColor(GameFontNormal:GetShadowColor())` — the GETTER on a font object,
|
||||
// the SETTER on a fresh region — and `KLHThreatMeter/.../KTM_Gui.lua:404` is
|
||||
// `fontstring:SetShadowColor(0,0,0,0.3)`.
|
||||
//
|
||||
// **`GetShadowColor` returns FOUR values, not three** (`0x79dd2f`, `mov eax,0x4` — wow-re's
|
||||
// widget-method batch). Three is the plausible wrong answer and it silently drops the alpha
|
||||
// that NavigatorFu is round-tripping. `GetShadowOffset` returns two, in UI units.
|
||||
//
|
||||
// Either half may be set before the other, so each starts from whatever is there — the same
|
||||
// rule the font-object versions already follow.
|
||||
m.set(
|
||||
"SetShadowColor",
|
||||
lua.create_function(
|
||||
|lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
let offset = d.font_shadow.map_or([0.0, 0.0], |s| s.offset);
|
||||
d.font_shadow = Some(crate::script::FontShadow {
|
||||
offset,
|
||||
color: [r, g, b, a.unwrap_or(1.0)],
|
||||
});
|
||||
d.font_explicit.shadow = true;
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
|
||||
m.set(
|
||||
"GetShadowColor",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_mut::<Model>().expect("model");
|
||||
let c = model
|
||||
.region_data
|
||||
.get(&rh)
|
||||
.and_then(|d| d.font_shadow)
|
||||
.map_or([0.0, 0.0, 0.0, 1.0], |s| s.color);
|
||||
Ok((c[0], c[1], c[2], c[3]))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
m.set(
|
||||
"SetShadowOffset",
|
||||
lua.create_function(|lua, (this, x, y): (Table, f32, f32)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
let color = d.font_shadow.map_or([0.0, 0.0, 0.0, 1.0], |s| s.color);
|
||||
d.font_shadow = Some(crate::script::FontShadow {
|
||||
offset: [x, y],
|
||||
color,
|
||||
});
|
||||
d.font_explicit.shadow = true;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
m.set(
|
||||
"GetShadowOffset",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_mut::<Model>().expect("model");
|
||||
let o = model
|
||||
.region_data
|
||||
.get(&rh)
|
||||
.and_then(|d| d.font_shadow)
|
||||
.map_or([0.0, 0.0], |s| s.offset);
|
||||
Ok((o[0], o[1]))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SetFont(path, height [, flags]) — the direct face/size/outline setter (the real region API and
|
||||
// the XML `font=`/`<FontHeight>`/`outline=` join). `flags` is an OUTLINETYPE-ish string
|
||||
// ("OUTLINE"/"THICKOUTLINE"/…"); anything else clears the outline. A nil/empty `path` keeps the
|
||||
// current face (so a FontString with only `<FontHeight>` retains its inherited object's font).
|
||||
// Returns true (the live API returns whether the font loaded; we always accept — face
|
||||
// availability is the renderer's concern).
|
||||
m.set(
|
||||
"SetFont",
|
||||
lua.create_function(
|
||||
|lua, (this, path, height, flags): (Table, Option<String>, Option<f32>, Option<String>)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
// Each argument actually supplied is an EXPLICIT set: it must survive a later
|
||||
// mutation of the font object this region inherits (`FontExplicit`).
|
||||
if let Some(p) = path.filter(|p| !p.is_empty()) {
|
||||
d.font_path = Some(p);
|
||||
d.font_explicit.face = true;
|
||||
}
|
||||
if let Some(h) = height {
|
||||
d.font_height = Some(h);
|
||||
d.font_explicit.height = true;
|
||||
}
|
||||
if let Some(f) = flags {
|
||||
d.outline = Outline::flags(&f);
|
||||
d.font_explicit.outline = true;
|
||||
}
|
||||
Ok(true)
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
|
||||
// SetTextHeight(height) — switch the FontString to the scaled-string regime (§5-verified,
|
||||
// wow-re `fontstring-overflow.md`: `0x771600` is the ONLY clearer of the one-to-one bit
|
||||
// `0x200`; the literal size then flows through UNCAPPED, magnified from the raster). Stored
|
||||
// as the distinct [`RegionData::text_height`] — the font object is untouched, so GetFont
|
||||
// keeps reporting the face's own height like the real API.
|
||||
m.set(
|
||||
"SetTextHeight",
|
||||
lua.create_function(|lua, (this, height): (Table, f32)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model")
|
||||
.region_data
|
||||
.entry(rh)
|
||||
.or_default()
|
||||
.text_height = Some(height);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetFont() → path, height, flags — the resolved face/size/outline (nil path if never set).
|
||||
m.set(
|
||||
"GetFont",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
let d = model.region_data.get(&rh);
|
||||
let path = d.and_then(|d| d.font_path.clone());
|
||||
let height = d.and_then(|d| d.font_height);
|
||||
let flags = d.map(|d| d.outline).unwrap_or_default().as_str();
|
||||
let path = match path {
|
||||
Some(p) => Value::String(lua.create_string(&p)?),
|
||||
None => Value::Nil,
|
||||
};
|
||||
Ok((path, height, flags))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SetTextColor(r, g, b [, a]) — a FontString's text color. A different binding name for the
|
||||
// same `+0xb8` vertex-colour slot `SetVertexColor` writes: a FontString has no texel of its own
|
||||
// to multiply against, so its vertex colour IS the colour it draws.
|
||||
m.set(
|
||||
"SetTextColor",
|
||||
lua.create_function(
|
||||
|lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
d.vertex_color = Some([r, g, b, a.unwrap_or(1.0)]);
|
||||
d.font_explicit.color = true;
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
|
||||
// GetTextColor() → r, g, b, a — `SetTextColor`'s missing pair, and a real binding in the same
|
||||
// FontInstance family. 11 corpus sites read it off a FontString directly (`CustomNameplates`
|
||||
// re-tints a level tag from the name's colour; `TipBuddy` snapshots every tooltip line), on top
|
||||
// of the 65 that reach it through `GetFontObject()`. Never set = the white every region draws
|
||||
// at, same convention as `GetVertexColor` (the same slot).
|
||||
m.set(
|
||||
"GetTextColor",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
let c = model
|
||||
.region_data
|
||||
.get(&rh)
|
||||
.and_then(|d| d.vertex_color)
|
||||
.unwrap_or([1.0, 1.0, 1.0, 1.0]);
|
||||
Ok((c[0], c[1], c[2], c[3]))
|
||||
})?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -348,3 +348,67 @@ fn the_xml_movable_and_resizable_attributes_reach_the_methods() {
|
||||
(true, true)
|
||||
);
|
||||
}
|
||||
|
||||
/// **`StartSizing(grip)` moves the gripped edges and plants the opposite ones.**
|
||||
///
|
||||
/// `0x776830`, verified in wow-re's ledger; the reference's own caller is
|
||||
/// `FloatingChatFrame.lua:600`. Four corpus addons reach it through ONE line —
|
||||
/// `FuBar_Panel.lua:980`, replicated into FuBar_CorkFu, FuBar_FuXPFu, FuBar_SpellStatusFu and oRA2
|
||||
/// (1207: one library, not four votes).
|
||||
///
|
||||
/// What the ledger does NOT record is which edges a grip moves — it has the verb as pure
|
||||
/// orchestration with no inline math. Taken here as the plain meaning of an anchor point, which is
|
||||
/// how the reference's caller uses it. This test is where that reading is pinned, so an RE pass
|
||||
/// that contradicts it fails here first.
|
||||
#[test]
|
||||
fn start_sizing_moves_the_gripped_edge_and_plants_the_other() {
|
||||
let mut s = crate::script::UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.run(
|
||||
r#"
|
||||
f = CreateFrame("Frame", "Sizer", UIParent)
|
||||
f:SetResizable(true)
|
||||
f:SetWidth(200) f:SetHeight(100)
|
||||
f:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", 100, 50)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// Grip the RIGHT edge and drag 40 right: width grows, the left edge stays planted.
|
||||
s.mouse_move(300.0, 100.0);
|
||||
s.run("Sizer:StartSizing(\"RIGHT\")").unwrap();
|
||||
s.mouse_move(340.0, 100.0);
|
||||
s.resolve();
|
||||
assert_eq!(s.eval::<f32>("return Sizer:GetWidth()").unwrap(), 240.0);
|
||||
assert_eq!(s.eval::<f32>("return Sizer:GetLeft()").unwrap(), 100.0);
|
||||
s.run("Sizer:StopMovingOrSizing()").unwrap();
|
||||
|
||||
// Grip the LEFT edge and drag 30 right: width SHRINKS and the left edge follows the cursor,
|
||||
// so the right edge is the one that stays put.
|
||||
let right_before = s.eval::<f32>("return Sizer:GetRight()").unwrap();
|
||||
s.run("Sizer:StartSizing(\"LEFT\")").unwrap();
|
||||
s.mouse_move(370.0, 100.0);
|
||||
s.resolve();
|
||||
assert_eq!(s.eval::<f32>("return Sizer:GetWidth()").unwrap(), 210.0);
|
||||
assert_eq!(s.eval::<f32>("return Sizer:GetLeft()").unwrap(), 130.0);
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return Sizer:GetRight()").unwrap(),
|
||||
right_before,
|
||||
"the ungripped edge must not move"
|
||||
);
|
||||
|
||||
// StopMovingOrSizing ends it: further motion changes nothing.
|
||||
s.run("Sizer:StopMovingOrSizing()").unwrap();
|
||||
s.mouse_move(500.0, 100.0);
|
||||
s.resolve();
|
||||
assert_eq!(s.eval::<f32>("return Sizer:GetWidth()").unwrap(), 210.0);
|
||||
|
||||
// A frame that is not resizable refuses, like the movable family's siblings.
|
||||
s.run("g = CreateFrame(\"Frame\", \"NotSizer\", UIParent)")
|
||||
.unwrap();
|
||||
assert!(
|
||||
s.run("NotSizer:StartSizing(\"RIGHT\")").is_err(),
|
||||
"StartSizing must refuse a frame that is not resizable"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user