benilla: initial public release
A from-scratch World of Warcraft 1.12.1 client in Rust + Bevy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,649 @@
|
||||
//! The `GameTooltip` method surface + engine behavior (decision 0274): the line stack, the
|
||||
//! owner/anchor law, auto-size, and the fade — the modeled behavior of the client's game-layer
|
||||
//! tooltip class over `CSimpleFrame` (wow-re `ui/scratch/bindings.md` pins the full 38-binding
|
||||
//! Lua surface; `tooltip-money.md` the money/cooldown internals; the *content* builders land per
|
||||
//! 0274's phases on top of these mechanics).
|
||||
//!
|
||||
//! **Lines are real named FontString regions**, engine-created on demand and published as Lua
|
||||
//! globals (`<name>TextLeft1` …) — reference Lua addresses them by name
|
||||
//! (`GameTooltipTextLeft1:SetTextColor`, UnitFrame.lua), so they cannot be private state. The
|
||||
//! real template pre-declares 30 pairs and the class grows past them with `AddFontStrings`;
|
||||
//! benilla uses the one growth mechanism for all of them.
|
||||
//!
|
||||
//! **Layout is a `resolve` pre-pass** ([`layout_tooltips`]): frame size = max measured line width
|
||||
//! (+ the double-line gap) + 2·pad × summed line heights + gaps, with `SetMinimumWidth` as a
|
||||
//! floor, and each double line's right column re-pointed flush to the text inset — the job the
|
||||
//! real client's C++ line layout does over the template's static anchors. It reads the measure
|
||||
//! round-trip's cached extents, so a fresh tooltip converges in ~2 frames (same as the retired
|
||||
//! Lua `OnUpdate` measure loop it replaces).
|
||||
//!
|
||||
//! `AddLine` accepts both shipped-FrameXML shapes — `(text, r, g, b[, wrap])` and the archaic
|
||||
//! `(text, "", r, g, b)` (both appear across the real 1.12 corpus; the non-number second slot is
|
||||
//! skipped). A wrap-flagged line has [`crate::widget::TOOLTIP_WRAP_WIDTH`] pinned onto its
|
||||
//! region at APPEND time, so its very first measure comes back wrapped — one round-trip, stable
|
||||
//! under the hover re-enter loop's per-frame content clears (the item description /
|
||||
//! trigger-effect lines).
|
||||
|
||||
use mlua::{Lua, Table, Value};
|
||||
|
||||
use super::event;
|
||||
use super::object::{frame_handle_of, publish_global};
|
||||
use super::region::region_wrapper;
|
||||
use super::{FontObject, Model, RegionData};
|
||||
use crate::layout::{Anchor, Point};
|
||||
use crate::order::DrawLayer;
|
||||
use crate::widget::{
|
||||
FrameHandle, KindState, RegionKind, TooltipState, TOOLTIP_DOUBLE_GAP, TOOLTIP_FADE_SECS,
|
||||
TOOLTIP_LINE_GAP, TOOLTIP_PAD,
|
||||
};
|
||||
|
||||
/// Registry key of the GameTooltip method table (the MAXCSTACK discipline: named registry root).
|
||||
pub(super) const REG_TOOLTIP_METHODS: &str = "__benilla_tooltip_methods";
|
||||
|
||||
/// Run `f` over a frame's tooltip state under one short write borrow. Errors if `this` is not a
|
||||
/// live GameTooltip (unreachable through the kind dispatcher, but the table is a plain Lua value).
|
||||
fn with_tip<T>(lua: &Lua, this: &Table, f: impl FnOnce(&mut TooltipState) -> T) -> mlua::Result<T> {
|
||||
let h = frame_handle_of(lua, this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
tip_mut(&mut model, h).map(f)
|
||||
}
|
||||
|
||||
pub(super) fn tip_mut(model: &mut Model, h: FrameHandle) -> mlua::Result<&mut TooltipState> {
|
||||
match model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
|
||||
Some(KindState::Tooltip(t)) => Ok(t),
|
||||
_ => Err(mlua::Error::runtime("not a GameTooltip")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy a named font object's resolved paint onto a line region (the `SetFontObject` copy,
|
||||
/// minus the error on a missing name: engine tests load file subsets where Fonts.xml may be
|
||||
/// absent — an unregistered name leaves the renderer's default face rather than failing the
|
||||
/// hover).
|
||||
fn apply_font(d: &mut RegionData, name: &str, fo: Option<&FontObject>) {
|
||||
d.font_object = Some(name.to_string());
|
||||
if let Some(f) = fo {
|
||||
d.font_path = f.font.clone();
|
||||
d.font_height = f.height;
|
||||
d.outline = f.outline;
|
||||
d.font_shadow = f.shadow;
|
||||
if let Some(c) = f.color {
|
||||
d.color = Some(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure the line-pair pool holds at least `n` pairs — ADOPTING XML-declared pairs
|
||||
/// (`<name>TextLeft<i>`/`TextRight<i>`, the ref template's pre-declared ladder: their faces
|
||||
/// ride, the engine owns justify/visibility/anchors) and creating the rest (faces cloned from
|
||||
/// the previous pair when a ladder exists, else the header/text defaults).
|
||||
/// Pair `i` (1-based): left at TOPLEFT (pad,−pad) of the frame (i = 1) or the previous left's
|
||||
/// BOTTOMLEFT (0,−gap); right at RIGHT→left RIGHT (its x offset is [`layout_tooltips`]'s to
|
||||
/// recompute). Line 1 wears `GameTooltipHeaderText`, the rest `GameTooltipText` — the real
|
||||
/// template's fonts.
|
||||
fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> {
|
||||
let h = frame_handle_of(lua, this)?;
|
||||
// (id, global name) pairs to publish once the model borrow is dropped.
|
||||
let mut publish: Vec<(u32, String)> = Vec::new();
|
||||
{
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let frame_name = model
|
||||
.arena
|
||||
.frame(h)
|
||||
.and_then(|f| f.name.clone())
|
||||
.unwrap_or_default();
|
||||
let frame_id = model.frame_id(h);
|
||||
loop {
|
||||
let (have, prev_left, prev_right, clone_prev) =
|
||||
match model.arena.frame(h).map(|f| &f.kind_state) {
|
||||
Some(KindState::Tooltip(t)) => (
|
||||
t.left_lines.len(),
|
||||
t.left_lines.last().copied(),
|
||||
t.right_lines.last().copied(),
|
||||
t.xml_declared_lines,
|
||||
),
|
||||
_ => return Err(mlua::Error::runtime("not a GameTooltip")),
|
||||
};
|
||||
if have >= n {
|
||||
break;
|
||||
}
|
||||
let i = have + 1;
|
||||
|
||||
// An XML-declared pair for this index? The ref template mechanism: FrameXML declares
|
||||
// the leading pairs (ShoppingTooltipTemplate's small-font ladder, TextLeft1..N), the
|
||||
// class ADOPTS them and, past the ladder, grows lines that clone the previous pair's
|
||||
// faces. GameTooltip declares none, so the header/text defaults hold there.
|
||||
let declared = |model: &Model, cell: &str| -> Option<crate::widget::RegionHandle> {
|
||||
if frame_name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let id = model
|
||||
.region_names
|
||||
.get(&format!("{frame_name}Text{cell}{i}"))
|
||||
.copied()?;
|
||||
let rh = model.id_to_region.get(&id).copied()?;
|
||||
(model.arena.region(rh)?.owner == h).then_some(rh)
|
||||
};
|
||||
// Font source for a CREATED cell: the previous line's declared/cloned faces when the
|
||||
// frame carries an XML ladder, else the engine defaults.
|
||||
let seed_font =
|
||||
|model: &mut Model,
|
||||
d: &mut RegionData,
|
||||
prev: Option<crate::widget::RegionHandle>| {
|
||||
let cloned = clone_prev
|
||||
.then(|| prev.and_then(|p| model.region_data.get(&p)).cloned())
|
||||
.flatten();
|
||||
match cloned {
|
||||
Some(src) => {
|
||||
d.font_object = src.font_object.clone();
|
||||
d.font_path = src.font_path.clone();
|
||||
d.font_height = src.font_height;
|
||||
d.outline = src.outline;
|
||||
d.font_shadow = src.font_shadow;
|
||||
d.color = src.color;
|
||||
}
|
||||
None => {
|
||||
let font = if i == 1 {
|
||||
"GameTooltipHeaderText"
|
||||
} else {
|
||||
"GameTooltipText"
|
||||
};
|
||||
let fo = model.font_objects.get(font).cloned();
|
||||
apply_font(d, font, fo.as_ref());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let adopted_left = declared(&model, "Left");
|
||||
let left = match adopted_left {
|
||||
Some(rh) => rh,
|
||||
None => model
|
||||
.arena
|
||||
.create_region(h, RegionKind::FontString, DrawLayer::Artwork, 0)
|
||||
.ok_or_else(|| mlua::Error::runtime("dead tooltip frame"))?,
|
||||
};
|
||||
{
|
||||
let mut d = if adopted_left.is_some() {
|
||||
// Keep the declared faces; the engine owns justify/visibility/anchors.
|
||||
model.region_data.remove(&left).unwrap_or_default()
|
||||
} else {
|
||||
let mut d = RegionData {
|
||||
hidden: true,
|
||||
..RegionData::default()
|
||||
};
|
||||
seed_font(&mut model, &mut d, prev_left);
|
||||
d
|
||||
};
|
||||
d.hidden = true;
|
||||
// Left-justified like the template's line stack — invisible while the rect hugs
|
||||
// the measured text, load-bearing once the wrap consumer pins a region to the
|
||||
// wrap column.
|
||||
d.justify_h = super::JustifyH::Left;
|
||||
d.anchors = vec![match prev_left {
|
||||
None => Anchor::new(
|
||||
Point::TopLeft,
|
||||
frame_id,
|
||||
Point::TopLeft,
|
||||
TOOLTIP_PAD,
|
||||
-TOOLTIP_PAD,
|
||||
),
|
||||
Some(prev) => {
|
||||
let prev_id = model.region_id(prev);
|
||||
Anchor::new(
|
||||
Point::TopLeft,
|
||||
prev_id,
|
||||
Point::BottomLeft,
|
||||
0.0,
|
||||
-TOOLTIP_LINE_GAP,
|
||||
)
|
||||
}
|
||||
}];
|
||||
model.region_data.insert(left, d);
|
||||
}
|
||||
let left_id = model.region_id(left);
|
||||
|
||||
let adopted_right = declared(&model, "Right");
|
||||
let right = match adopted_right {
|
||||
Some(rh) => rh,
|
||||
None => model
|
||||
.arena
|
||||
.create_region(h, RegionKind::FontString, DrawLayer::Artwork, 0)
|
||||
.ok_or_else(|| mlua::Error::runtime("dead tooltip frame"))?,
|
||||
};
|
||||
{
|
||||
let mut d = if adopted_right.is_some() {
|
||||
model.region_data.remove(&right).unwrap_or_default()
|
||||
} else {
|
||||
let mut d = RegionData {
|
||||
hidden: true,
|
||||
..RegionData::default()
|
||||
};
|
||||
seed_font(&mut model, &mut d, prev_right);
|
||||
d
|
||||
};
|
||||
d.hidden = true;
|
||||
d.justify_h = super::JustifyH::Right;
|
||||
d.anchors = vec![Anchor::new(Point::Right, left_id, Point::Right, 0.0, 0.0)];
|
||||
model.region_data.insert(right, d);
|
||||
}
|
||||
let right_id = model.region_id(right);
|
||||
|
||||
if !frame_name.is_empty() {
|
||||
// Adopted pairs are already named + published by the loader.
|
||||
if adopted_left.is_none() {
|
||||
let ln = format!("{frame_name}TextLeft{i}");
|
||||
model.region_names.entry(ln.clone()).or_insert(left_id);
|
||||
publish.push((left_id, ln));
|
||||
}
|
||||
if adopted_right.is_none() {
|
||||
let rn = format!("{frame_name}TextRight{i}");
|
||||
model.region_names.entry(rn.clone()).or_insert(right_id);
|
||||
publish.push((right_id, rn));
|
||||
}
|
||||
}
|
||||
|
||||
let t = tip_mut(&mut model, h)?;
|
||||
t.left_lines.push(left);
|
||||
t.right_lines.push(right);
|
||||
if i == 1 && adopted_left.is_some() {
|
||||
t.xml_declared_lines = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, name) in publish {
|
||||
let wrapper = region_wrapper(lua, id)?;
|
||||
publish_global(lua, &name, &wrapper)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One text cell's write: text + color + shown. `color` alpha defaults opaque.
|
||||
fn write_cell(model: &mut Model, rh: crate::widget::RegionHandle, text: &str, color: [f32; 4]) {
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
d.text = Some(text.to_string());
|
||||
d.color = Some(color);
|
||||
d.hidden = false;
|
||||
}
|
||||
|
||||
/// Hide + blank every line cell and zero the counters. The caller fires `OnTooltipCleared`
|
||||
/// (outside the model borrow) — the engine-fired widget script the reference wires to its
|
||||
/// money-row clear.
|
||||
pub(super) fn clear_content(model: &mut Model, h: FrameHandle) {
|
||||
let (lefts, rights) = match model.arena.frame(h).map(|f| &f.kind_state) {
|
||||
Some(KindState::Tooltip(t)) => (t.left_lines.clone(), t.right_lines.clone()),
|
||||
_ => return,
|
||||
};
|
||||
for rh in lefts.into_iter().chain(rights) {
|
||||
if let Some(d) = model.region_data.get_mut(&rh) {
|
||||
d.text = None;
|
||||
d.hidden = true;
|
||||
d.measured = None;
|
||||
// A wrap-pinned width (the layout pass's wrap consumer) is per-content — the next
|
||||
// content re-derives it.
|
||||
d.size = None;
|
||||
}
|
||||
}
|
||||
if let Ok(t) = tip_mut(model, h) {
|
||||
t.num_lines = 0;
|
||||
t.min_width = 0.0;
|
||||
t.unit_token = None;
|
||||
t.world_owned = false;
|
||||
// Content-scoped like the rest; `compare_armed` and `padding` survive on purpose —
|
||||
// the arm spans FrameXML's SetOwner (kinds.rs has the seam note), the padding is a
|
||||
// frame property (ref: set once in OnLoad).
|
||||
t.compare_slots.clear();
|
||||
}
|
||||
// The mouseover health bar is UNIT content: it hides with the lines (the ref bar exists
|
||||
// only while a unit shows; the byte law's watcher re-shows it on the next unit render).
|
||||
// Without this, the bar from the last mob hover rode under every ITEM tooltip.
|
||||
let bar = model
|
||||
.arena
|
||||
.frame(h)
|
||||
.and_then(|f| f.name.clone())
|
||||
.and_then(|n| model.arena.lookup(&format!("{n}StatusBar")));
|
||||
if let Some(bar) = bar {
|
||||
model.arena.set_shown(bar, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire `OnTooltipCleared` on the tooltip frame (errors → [`Model::errors`], never propagated).
|
||||
pub(super) fn fire_cleared(lua: &Lua, h: FrameHandle) {
|
||||
let id = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.frame_id(h)
|
||||
};
|
||||
if let Err(e) = event::fire_widget_handler(lua, id, "OnTooltipCleared", Vec::new()) {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.errors
|
||||
.push(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// A content `Set*`'s tail: show when it rendered lines, hide when it rendered none. The real
|
||||
/// client's uncached path leaves a CLEARED tooltip whose size collapses to nothing — with our
|
||||
/// declared-size plate the visible equivalent is a hide; the hover's re-enter repaints the
|
||||
/// moment the app answers the ask. Hiding through the arena directly (not [`hide_tooltip`])
|
||||
/// keeps the owner: IsOwned must stay true for the re-enter loop.
|
||||
pub(super) fn show_or_hide_empty(lua: &Lua, h: FrameHandle) {
|
||||
let lines = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
match tip_mut(&mut model, h) {
|
||||
Ok(t) => t.num_lines,
|
||||
Err(_) => 0,
|
||||
}
|
||||
};
|
||||
set_shown(lua, h, lines > 0);
|
||||
}
|
||||
|
||||
/// Cancel a running fade and restore full alpha (any fresh content or an explicit Show does
|
||||
/// this — re-hovering during the fade-out resurrects the tooltip at full strength).
|
||||
fn cancel_fade(model: &mut Model, h: FrameHandle) {
|
||||
if let Ok(t) = tip_mut(model, h) {
|
||||
if t.fade_start.take().is_some() {
|
||||
model.arena.set_alpha(h, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine's `GetTime` clock (the `__benilla_now` global [`super::UiScript::tick`] advances).
|
||||
fn now(lua: &Lua) -> f64 {
|
||||
lua.globals().get("__benilla_now").unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Show/hide through the arena with the visibility events fired (the shared Show/Hide's path).
|
||||
pub(super) fn set_shown(lua: &Lua, h: FrameHandle, shown: bool) {
|
||||
let changed = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.arena.set_shown(h, shown)
|
||||
};
|
||||
event::fire_visibility_changes(lua, changed);
|
||||
}
|
||||
|
||||
/// The engine tooltip's default text colour — the byte constant `0xc0d3e8` = `0xffffd200` gold
|
||||
/// every no-colour `AddLine`/`SetText` renders with. This is why the reference's zone tooltip is
|
||||
/// GOLD despite Minimap.lua's `AddLine(text, "", 1.0, 1.0, 1.0)`: 1.12's binding reads the colour
|
||||
/// at its fixed positions, the `""` in the r-slot is not a number, and the whole colour tail is
|
||||
/// dropped in favour of this default — the trailing 1.0s never shift into place (the last one is
|
||||
/// consumed as the truthy wrap flag). Byte-pinned: the static-init writer `0x528e50` stores
|
||||
/// `0xffffd200` at `0xc0d3e8` (§0-BINDINGS verdict, 2026-07-14).
|
||||
pub(in crate::script) const DEFAULT_TEXT_GOLD: [f32; 4] = [1.0, 210.0 / 255.0, 0.0, 1.0];
|
||||
|
||||
/// A colour-component argument — `lua_tonumber`'s coercion (a numeric string counts, `""` and
|
||||
/// other strings don't).
|
||||
fn color_num(v: Option<&Value>) -> Option<f32> {
|
||||
match v {
|
||||
Some(Value::Number(n)) => Some(*n as f32),
|
||||
Some(Value::Integer(i)) => Some(*i as f32),
|
||||
Some(Value::String(s)) => s.to_str().ok().and_then(|s| s.trim().parse::<f32>().ok()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bindings' boolean-argument read (`GetBoolOrDefault`, byte-pinned in the §0-BINDINGS
|
||||
/// verdict): nil/absent, false, numeric 0, and the strings `"0"`/`"off"`/`"disabled"` are
|
||||
/// false; everything else is true.
|
||||
fn bool_arg(v: Option<&Value>) -> bool {
|
||||
match v {
|
||||
None | Some(Value::Nil) | Some(Value::Boolean(false)) => false,
|
||||
Some(Value::Integer(i)) => *i != 0,
|
||||
Some(Value::Number(n)) => *n != 0.0,
|
||||
Some(Value::String(s)) => !s.to_str().ok().is_some_and(|s| {
|
||||
s == "0" || s.eq_ignore_ascii_case("off") || s.eq_ignore_ascii_case("disabled")
|
||||
}),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour block of an `AddLine`-family tail (the values at the r/g/b positions): applied
|
||||
/// only when the r-slot is a number (`lua_isnumber` — the sole gate, §0-BINDINGS verdict);
|
||||
/// anything else drops the WHOLE block to the default gold [`DEFAULT_TEXT_GOLD`] — the ref's
|
||||
/// gold zone tooltip, whose `(text, "", 1.0, 1.0, 1.0)` shape has `""` at the r-slot. When the
|
||||
/// gate passes, g/b are UNGATED `lua_tonumber` reads: a missing/non-number component is **0.0**
|
||||
/// (byte-pinned). Alpha is forced opaque (the bindings pack `0xFF`).
|
||||
fn parse_line_color(r: Option<&Value>, g: Option<&Value>, b: Option<&Value>) -> [f32; 4] {
|
||||
match color_num(r) {
|
||||
Some(r) => [
|
||||
r,
|
||||
color_num(g).unwrap_or(0.0),
|
||||
color_num(b).unwrap_or(0.0),
|
||||
1.0,
|
||||
],
|
||||
None => DEFAULT_TEXT_GOLD,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an `AddLine` tail (the args after `text`): positional `r, g, b, wrapText` — the
|
||||
/// byte-pinned `0x531630` signature. The wrap flag is read at its position REGARDLESS of the
|
||||
/// colour gate (the ref's zone-tooltip shape wraps: its trailing `1.0` lands in the wrap slot).
|
||||
fn parse_line_tail(args: &[Value]) -> ([f32; 4], bool) {
|
||||
(
|
||||
parse_line_color(args.first(), args.get(1), args.get(2)),
|
||||
bool_arg(args.get(3)),
|
||||
)
|
||||
}
|
||||
|
||||
/// A Lua text argument → the line's string: strings pass through, numbers stringify (Lua
|
||||
/// coercion), nil/absent is an empty line (`GameTooltip:AddLine()` ships in the corpus).
|
||||
fn text_of(v: Option<&Value>) -> String {
|
||||
match v {
|
||||
Some(Value::String(s)) => s.to_str().map(|s| s.to_string()).unwrap_or_default(),
|
||||
Some(Value::Number(n)) => {
|
||||
let n = *n;
|
||||
if n == n.trunc() && n.abs() < 1e15 {
|
||||
format!("{}", n as i64)
|
||||
} else {
|
||||
format!("{n}")
|
||||
}
|
||||
}
|
||||
Some(Value::Integer(i)) => format!("{i}"),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Append one line (both columns; `right = None` leaves the right cell hidden). Grows the pool,
|
||||
/// writes the cells, bumps `num_lines` (+ the line's wrap flag), cancels any fade. Does NOT show
|
||||
/// the tooltip — `AddLine` leaves showing to the caller (the corpus' `AddLine … Show()`),
|
||||
/// `SetText`/the content builders show themselves.
|
||||
pub(super) fn append_line(
|
||||
lua: &Lua,
|
||||
this: &Table,
|
||||
left: (String, [f32; 4]),
|
||||
right: Option<(String, [f32; 4])>,
|
||||
wrap: bool,
|
||||
) -> mlua::Result<()> {
|
||||
let h = frame_handle_of(lua, this)?;
|
||||
let n = with_tip(lua, this, |t| t.num_lines + 1)?;
|
||||
ensure_lines(lua, this, n)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let (lh, rh) = match model.arena.frame(h).map(|f| &f.kind_state) {
|
||||
Some(KindState::Tooltip(t)) => (t.left_lines[n - 1], t.right_lines[n - 1]),
|
||||
_ => return Err(mlua::Error::runtime("not a GameTooltip")),
|
||||
};
|
||||
write_cell(&mut model, lh, &left.0, left.1);
|
||||
// A wrap-flagged line PINS its wrap column at write time, so its very first measure ask
|
||||
// carries the width and comes back WRAPPED (multi-row height) in the same frame. The old
|
||||
// shape — layout re-pinning after an overflowing single-line measure — needed a second
|
||||
// round-trip that the bag re-enter loop's per-frame content clear kept wiping out: wrapped
|
||||
// lines never converged live (the plate under-counted their rows forever — the bread/
|
||||
// hearthstone spill). One step, no oscillation.
|
||||
if wrap {
|
||||
if let Some(d) = model.region_data.get_mut(&lh) {
|
||||
d.size = Some((crate::widget::TOOLTIP_WRAP_WIDTH, 0.0));
|
||||
}
|
||||
}
|
||||
if let Some((text, color)) = right {
|
||||
write_cell(&mut model, rh, &text, color);
|
||||
}
|
||||
if let Ok(t) = tip_mut(&mut model, h) {
|
||||
t.num_lines = n;
|
||||
}
|
||||
cancel_fade(&mut model, h);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
mod verbs;
|
||||
pub(super) use verbs::install;
|
||||
|
||||
/// The full hide: drop the owner, cancel the fade, clear the content, hide the frame, fire
|
||||
/// `OnTooltipCleared`. Shared by the `Hide` method and the fade's end-of-ramp.
|
||||
pub(super) fn hide_tooltip(lua: &Lua, h: FrameHandle) {
|
||||
{
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
cancel_fade(&mut model, h);
|
||||
if let Ok(t) = tip_mut(&mut model, h) {
|
||||
t.owner = None;
|
||||
}
|
||||
clear_content(&mut model, h);
|
||||
}
|
||||
set_shown(lua, h, false);
|
||||
fire_cleared(lua, h);
|
||||
}
|
||||
|
||||
/// One line cell's layout input: `Some((w, h))` once the cell is shown with text AND the measure
|
||||
/// round-trip has answered; `Some((0, 0))` for a shown but EMPTY cell (the corpus' `AddLine("")`
|
||||
/// — a real zero-height row that still charges its slot gap, the ref's empty-line shape); `None`
|
||||
/// for hidden/cleared cells and for PENDING measures. A pending cell contributes nothing — no
|
||||
/// width, no height, and (in the caller) no gaps — so a fresh tooltip holds its declared default
|
||||
/// size until real extents land, instead of collapsing to a gaps-only rect (the guard the first
|
||||
/// cut got wrong: it summed LINE_GAP/DOUBLE_GAP for unmeasured rows, caught by the migration
|
||||
/// pass).
|
||||
type Cell = Option<(f32, f32)>;
|
||||
|
||||
fn cell(model: &Model, rh: crate::widget::RegionHandle) -> Cell {
|
||||
let d = model.region_data.get(&rh)?;
|
||||
let text = d.text.as_deref()?;
|
||||
if d.hidden {
|
||||
return None;
|
||||
}
|
||||
if text.is_empty() {
|
||||
return Some((0.0, 0.0));
|
||||
}
|
||||
d.measured.map(|m| (m.w, m.h))
|
||||
}
|
||||
|
||||
/// The auto-size + right-flush pre-pass, run at the top of every layout resolve (decision 0274):
|
||||
/// for each live GameTooltip, frame size = max measured line width (a double line is
|
||||
/// left + gap + right) + 2·pad, floored by `SetMinimumWidth`, × summed line heights + gaps; each
|
||||
/// visible right column's anchor re-points so its right edge sits at the text inset. Skips
|
||||
/// frames whose lines haven't been measured yet (the XML default size holds until the measure
|
||||
/// round-trip lands, one frame later — the same convergence the Lua loop had).
|
||||
pub(super) fn layout_tooltips(model: &mut Model) {
|
||||
let tips: Vec<FrameHandle> = model
|
||||
.frame_to_id
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|&h| {
|
||||
matches!(
|
||||
model.arena.frame(h).map(|f| &f.kind_state),
|
||||
Some(KindState::Tooltip(_))
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for h in tips {
|
||||
let (num, lefts, rights, min_w, pad_w) = match model.arena.frame(h).map(|f| &f.kind_state) {
|
||||
Some(KindState::Tooltip(t)) => (
|
||||
t.num_lines,
|
||||
t.left_lines.clone(),
|
||||
t.right_lines.clone(),
|
||||
t.min_width,
|
||||
t.padding,
|
||||
),
|
||||
_ => continue,
|
||||
};
|
||||
if num == 0 {
|
||||
continue;
|
||||
}
|
||||
// (Wrap columns are pinned at append time — append_line — so every measure here is
|
||||
// already the wrapped one; no second round-trip.)
|
||||
// Per-line metrics next (one read pass), then the size + right-flush writes. Only
|
||||
// measured cells contribute — a row with no measured cell adds no height and no gap, and
|
||||
// a tooltip with nothing measured yet is skipped whole (its declared size holds until
|
||||
// the measure round-trip lands, one frame later).
|
||||
let n = num.min(lefts.len()).min(rights.len());
|
||||
let rows: Vec<(Cell, Cell)> = (0..n)
|
||||
.map(|i| (cell(model, lefts[i]), cell(model, rights[i])))
|
||||
.collect();
|
||||
let mut maxw: f32 = 0.0;
|
||||
let mut totalh: f32 = 0.0;
|
||||
let mut counted = 0usize;
|
||||
for (l, r) in &rows {
|
||||
if l.is_none() && r.is_none() {
|
||||
continue;
|
||||
}
|
||||
let (lw, lh) = l.unwrap_or((0.0, 0.0));
|
||||
let (rw, rh_h) = r.map_or((0.0, 0.0), |(w, hgt)| (TOOLTIP_DOUBLE_GAP + w, hgt));
|
||||
if counted > 0 {
|
||||
totalh += TOOLTIP_LINE_GAP;
|
||||
}
|
||||
totalh += lh.max(rh_h);
|
||||
counted += 1;
|
||||
maxw = maxw.max(lw + rw);
|
||||
}
|
||||
if counted == 0 {
|
||||
continue; // nothing measured yet — hold the declared size
|
||||
}
|
||||
maxw = maxw.max(min_w);
|
||||
if maxw <= 0.0 || totalh <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let input = model.layout_inputs.entry(h).or_default();
|
||||
// `pad_w` = SetPadding's extra width (ref ItemRefTooltip: room for the close button).
|
||||
input.width = maxw + 2.0 * TOOLTIP_PAD + pad_w;
|
||||
input.height = totalh + 2.0 * TOOLTIP_PAD;
|
||||
// Right-flush each double line: right edge = left.right + (maxw − left.width)
|
||||
// = frame.left + pad + maxw = the text inset's right.
|
||||
for (i, (l, r)) in rows.iter().enumerate() {
|
||||
if r.is_none() {
|
||||
continue;
|
||||
}
|
||||
let lw = l.map_or(0.0, |(w, _)| w);
|
||||
if let Some(d) = model.region_data.get_mut(&rights[i]) {
|
||||
if let Some(a) = d.anchors.first_mut() {
|
||||
a.x_off = maxw - lw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance every fading tooltip (called from [`super::UiScript::tick`], the ScrollingMessage/
|
||||
/// Cooldown pattern): alpha ramps 1→0 over [`TOOLTIP_FADE_SECS`]; at the end the tooltip hides
|
||||
/// for real (owner dropped, content cleared, `OnTooltipCleared` fired).
|
||||
pub(super) fn tick_fades(lua: &Lua) {
|
||||
let t = now(lua);
|
||||
let mut ramping: Vec<(FrameHandle, f32)> = Vec::new();
|
||||
let mut finished: Vec<FrameHandle> = Vec::new();
|
||||
{
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
for &h in model.frame_to_id.keys() {
|
||||
let Some(frame) = model.arena.frame(h) else {
|
||||
continue;
|
||||
};
|
||||
let KindState::Tooltip(tip) = &frame.kind_state else {
|
||||
continue;
|
||||
};
|
||||
let Some(start) = tip.fade_start else {
|
||||
continue;
|
||||
};
|
||||
if !frame.shown {
|
||||
finished.push(h); // hidden mid-fade by other means — just tidy the state
|
||||
continue;
|
||||
}
|
||||
let a = 1.0 - ((t - start) / TOOLTIP_FADE_SECS) as f32;
|
||||
if a <= 0.0 {
|
||||
finished.push(h);
|
||||
} else {
|
||||
ramping.push((h, a));
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
for (h, a) in ramping {
|
||||
model.arena.set_alpha(h, a);
|
||||
}
|
||||
}
|
||||
for h in finished {
|
||||
hide_tooltip(lua, h);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! The GameTooltip Lua verb table — [`install`] registers the widget methods (SetOwner /
|
||||
//! AddLine / AddDoubleLine / SetText / ClearLines / SetMinimumWidth / SetPadding / FadeOut /
|
||||
//! IsOwned / NumLines / the anchor law) over the parent module's line-pool mechanics, then
|
||||
//! chains the item/unit content builders' method sets.
|
||||
|
||||
use mlua::{Lua, Table, Value, Variadic};
|
||||
|
||||
use crate::layout::{Anchor, Point};
|
||||
use crate::script::object::{frame_handle_of, frame_wrapper};
|
||||
use crate::script::Model;
|
||||
|
||||
use super::{
|
||||
append_line, bool_arg, cancel_fade, clear_content, fire_cleared, hide_tooltip, now,
|
||||
parse_line_color, parse_line_tail, set_shown, text_of, tip_mut, with_tip, REG_TOOLTIP_METHODS,
|
||||
};
|
||||
|
||||
pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let m = lua.create_table()?;
|
||||
|
||||
// SetOwner(owner, anchorType [, x, y]) — remember the owner, clear the content (the engine
|
||||
// fires OnTooltipCleared: a fresh owner never inherits the last hover's lines or money), and
|
||||
// point the plate per the anchor law. The six owner anchors are the documented 1.12 API set
|
||||
// (spec-faithful, same posture as StatusBar's contract; the corpus exercises RIGHT/LEFT/
|
||||
// BOTTOMRIGHT); ANCHOR_NONE leaves pointing to the caller (GameTooltip_SetDefaultAnchor),
|
||||
// ANCHOR_PRESERVE keeps the previous placement.
|
||||
m.set(
|
||||
"SetOwner",
|
||||
lua.create_function(
|
||||
|lua,
|
||||
(this, owner, anchor, x, y): (
|
||||
Table,
|
||||
Table,
|
||||
Option<String>,
|
||||
Option<f32>,
|
||||
Option<f32>,
|
||||
)| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let owner_h = frame_handle_of(lua, &owner)?;
|
||||
{
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
clear_content(&mut model, h);
|
||||
cancel_fade(&mut model, h);
|
||||
tip_mut(&mut model, h)?.owner = Some(owner_h);
|
||||
let owner_id = model.frame_id(owner_h);
|
||||
let anchor = anchor.unwrap_or_else(|| "ANCHOR_RIGHT".into());
|
||||
let pts = match anchor.to_ascii_uppercase().as_str() {
|
||||
"ANCHOR_RIGHT" => Some((Point::BottomLeft, Point::TopRight)),
|
||||
"ANCHOR_LEFT" => Some((Point::BottomRight, Point::TopLeft)),
|
||||
"ANCHOR_TOPRIGHT" => Some((Point::BottomRight, Point::TopRight)),
|
||||
"ANCHOR_TOPLEFT" => Some((Point::BottomLeft, Point::TopLeft)),
|
||||
"ANCHOR_BOTTOMRIGHT" => Some((Point::TopLeft, Point::BottomRight)),
|
||||
"ANCHOR_BOTTOMLEFT" => Some((Point::TopRight, Point::BottomLeft)),
|
||||
"ANCHOR_NONE" | "ANCHOR_PRESERVE" => None,
|
||||
other => {
|
||||
model.warnings.push(format!(
|
||||
"SetOwner: unknown anchor '{other}' (kept ANCHOR_RIGHT)"
|
||||
));
|
||||
Some((Point::BottomLeft, Point::TopRight))
|
||||
}
|
||||
};
|
||||
match pts {
|
||||
Some((own, rel)) => {
|
||||
let input = model.layout_inputs.entry(h).or_default();
|
||||
input.anchors = vec![Anchor::new(
|
||||
own,
|
||||
owner_id,
|
||||
rel,
|
||||
x.unwrap_or(0.0),
|
||||
y.unwrap_or(0.0),
|
||||
)];
|
||||
}
|
||||
None if anchor.eq_ignore_ascii_case("ANCHOR_NONE") => {
|
||||
// The caller points it (ClearAllPoints+SetPoint) — drop ours now so a
|
||||
// stale owner anchor never wins the frame the caller forgets to.
|
||||
if let Some(input) = model.layout_inputs.get_mut(&h) {
|
||||
input.anchors.clear();
|
||||
}
|
||||
}
|
||||
None => {} // ANCHOR_PRESERVE
|
||||
}
|
||||
}
|
||||
fire_cleared(lua, h);
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
m.set(
|
||||
"GetOwner",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let owner = with_tip(lua, &this, |t| t.owner)?;
|
||||
let id = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
owner
|
||||
.filter(|&h| model.arena.frame(h).is_some())
|
||||
.map(|h| model.frame_id(h))
|
||||
};
|
||||
match id {
|
||||
Some(id) => Ok(Value::Table(frame_wrapper(lua, id)?)),
|
||||
None => Ok(Value::Nil),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
// IsOwned(frame) — the hover re-enter loop's gate (ref ContainerFrame.lua OnUpdate): true
|
||||
// only while `frame` is the live owner; the owner drops on hide, so a hidden tooltip never
|
||||
// resurrects from a stale OnUpdate.
|
||||
m.set(
|
||||
"IsOwned",
|
||||
lua.create_function(|lua, (this, frame): (Table, Table)| {
|
||||
let owner = with_tip(lua, &this, |t| t.owner)?;
|
||||
let fh = frame_handle_of(lua, &frame)?;
|
||||
Ok(owner == Some(fh))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SetText(text, r, g, b, alpha, textWrap) — the byte-pinned `0x531b90` signature (alpha is
|
||||
// a REAL parsed argument here, its own isnumber gate, default 1.0 — unlike AddLine's forced
|
||||
// opaque; GameTooltip_AddNewbieTip passes `(text, r, g, b, 1, 1)`). `text` is REQUIRED — a
|
||||
// non-string raises the binding's own 'Usage: SetText("text" [, color])' FrameScript error
|
||||
// and adds no line. No colour args → the engine default GOLD. Clears, writes line 1,
|
||||
// SHOWS — the corpus never calls Show after SetText (PaperDoll's empty-slot text relies
|
||||
// on it).
|
||||
m.set(
|
||||
"SetText",
|
||||
lua.create_function(|lua, (this, args): (Table, Variadic<Value>)| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
if !matches!(
|
||||
args.first(),
|
||||
Some(Value::String(_)) | Some(Value::Number(_)) | Some(Value::Integer(_))
|
||||
) {
|
||||
return Err(mlua::Error::RuntimeError(
|
||||
"Usage: SetText(\"text\" [, color])".into(),
|
||||
));
|
||||
}
|
||||
let text = text_of(args.first());
|
||||
let [r, g, b, _] = parse_line_color(args.get(1), args.get(2), args.get(3));
|
||||
let a = match args.get(4) {
|
||||
Some(Value::Number(n)) => *n as f32,
|
||||
Some(Value::Integer(i)) => *i as f32,
|
||||
_ => 1.0,
|
||||
};
|
||||
let wrap = bool_arg(args.get(5));
|
||||
{
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
clear_content(&mut model, h);
|
||||
}
|
||||
fire_cleared(lua, h);
|
||||
append_line(lua, &this, (text, [r, g, b, a]), None, wrap)?;
|
||||
set_shown(lua, h, true);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
// AddLine(text [, r, g, b [, wrap]]) — positional; the corpus' archaic `(text, "", r, g, b)`
|
||||
// shape has a non-number in the r-slot, so the real binding drops the colour tail and the
|
||||
// line renders the default GOLD (the ref's zone/taxi/repair tooltips — director-matched).
|
||||
// Appends without showing.
|
||||
m.set(
|
||||
"AddLine",
|
||||
lua.create_function(|lua, (this, args): (Table, Variadic<Value>)| {
|
||||
let text = text_of(args.first());
|
||||
let (color, wrap) = parse_line_tail(&args[1.min(args.len())..]);
|
||||
append_line(lua, &this, (text, color), None, wrap)
|
||||
})?,
|
||||
)?;
|
||||
// AddDoubleLine(textL, textR, rL, gL, bL, rR, gR, bR [, wrap]) — the two-column line
|
||||
// (slot|type, damage|speed), byte-pinned `0x531840`: each side's colour gates on ITS OWN
|
||||
// r-slot isnumber (default gold, g/b ungated → 0.0, alpha forced opaque), and the wrap arg
|
||||
// is dead weight — the core forces wrap=0 whenever the right text is non-empty. The right
|
||||
// column right-flushes in the layout pass.
|
||||
m.set(
|
||||
"AddDoubleLine",
|
||||
lua.create_function(
|
||||
|lua, (this, tl, tr, cl): (Table, Value, Value, Variadic<Value>)| {
|
||||
let left_color = parse_line_color(cl.first(), cl.get(1), cl.get(2));
|
||||
let right_color = parse_line_color(cl.get(3), cl.get(4), cl.get(5));
|
||||
append_line(
|
||||
lua,
|
||||
&this,
|
||||
(text_of(Some(&tl)), left_color),
|
||||
Some((text_of(Some(&tr)), right_color)),
|
||||
false,
|
||||
)
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
m.set(
|
||||
"ClearLines",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
{
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
clear_content(&mut model, h);
|
||||
}
|
||||
fire_cleared(lua, h);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"NumLines",
|
||||
lua.create_function(|lua, this: Table| with_tip(lua, &this, |t| t.num_lines as i64))?,
|
||||
)?;
|
||||
// SetMinimumWidth(w) — a floor on the auto-sized width (the ref's SetTooltipMoney calls it
|
||||
// with the money row's width so coins never overhang the plate).
|
||||
m.set(
|
||||
"SetMinimumWidth",
|
||||
lua.create_function(|lua, (this, w): (Table, f32)| {
|
||||
with_tip(lua, &this, |t| t.min_width = w.max(0.0))
|
||||
})?,
|
||||
)?;
|
||||
// SetPadding(w) — extra auto-size width (ref ItemRefTooltip OnLoad: SetPadding(16) keeps the
|
||||
// corner close button off the text). A frame property — persists across content clears.
|
||||
m.set(
|
||||
"SetPadding",
|
||||
lua.create_function(|lua, (this, w): (Table, f32)| {
|
||||
with_tip(lua, &this, |t| t.padding = w.max(0.0))
|
||||
})?,
|
||||
)?;
|
||||
// FadeOut() — the world-mouseover loss ramp (alpha 1→0 over TOOLTIP_FADE_SECS, INTERIM
|
||||
// constant, then a real hide). Fresh content or Show cancels it.
|
||||
m.set(
|
||||
"FadeOut",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let t = now(lua);
|
||||
with_tip(lua, &this, |tip| {
|
||||
if tip.fade_start.is_none() {
|
||||
tip.fade_start = Some(t);
|
||||
}
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
// Show/Hide — shadow the shared pair (kind tables resolve first) to keep the tooltip's
|
||||
// state honest: Show cancels a fade at full alpha; Hide drops the owner + content and fires
|
||||
// OnTooltipCleared (the ref clears its money row through exactly that script).
|
||||
m.set(
|
||||
"Show",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
{
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
cancel_fade(&mut model, h);
|
||||
}
|
||||
set_shown(lua, h, true);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"Hide",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
hide_tooltip(lua, h);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// The item content channels (SetItemById/SetBagItem/SetMerchantItem/SetBuybackItem) live in
|
||||
// their own module — the one shared renderer + the red usable law (decision 0274 P1).
|
||||
crate::script::tooltip_item::install_methods(lua, &m)?;
|
||||
// The spell/aura/action content channels (SetSpell/SetShapeshift/SetPlayerBuff/SetAction) —
|
||||
// the verified spell-builder law (decision 0274 P2, grounded by 0276).
|
||||
crate::script::tooltip_spell::install_methods(lua, &m)?;
|
||||
// The talent channel (SetTalent) — the spell builder with the talent interleave
|
||||
// (decision 0304).
|
||||
crate::script::talent::install_tooltip_method(lua, &m)?;
|
||||
// The unit content channel (SetUnit + the world-mouseover drivers) — the verified unit law
|
||||
// (decision 0274 P3, grounded by 0276).
|
||||
crate::script::tooltip_unit::install_methods(lua, &m)?;
|
||||
|
||||
lua.set_named_registry_value(REG_TOOLTIP_METHODS, m)?;
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user