- loot rows show their Quickslot border, depress on click and light up under the cursor: the stock `<LootButton>` is parsed by the Button loader, as the reference's vtable has it (2195) - holding W keeps you running through Enter to chat and M to close the map: a UI frame eating a key never releases a held movement binding (2196) - food, drink, bandages and mounts grey out on the action bar in combat: an ITEM slot runs its on-use spell's usable walk, and its cooldown gate is the item-keyed on-hold record (2200, 2201) - re-clicking the character you already have selected keeps its facing instead of snapping it square (2194) - the shift-compare plate seats beside the tooltip and is owned by the engine, so a stale plate never rides over the next hover (2202) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
902 lines
44 KiB
Rust
902 lines
44 KiB
Rust
//! 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, TooltipAnchor, 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());
|
||
// The severance mask is left standing — a re-point does NOT restore inheritance in the
|
||
// reference (see `super::font`'s module doc on the `+0x2c` inheritMask). A freshly created
|
||
// line has a clean mask anyway; an adopted XML-declared line keeps whatever its own attributes
|
||
// severed. Either way the line inherits the object LIVE from here: a later
|
||
// `GameTooltipText:SetFont(…)` re-paints every tooltip line through `font::propagate`.
|
||
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.vertex_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.vertex_color = src.vertex_color;
|
||
}
|
||
None => {
|
||
let font = if i == 1 {
|
||
"GameTooltipHeaderText"
|
||
} else {
|
||
"GameTooltipText"
|
||
};
|
||
let fo = model.font_object(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.set_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);
|
||
model.touch_measure(left); // an adopted cell can arrive text-in-hand
|
||
model.touch_layout(); // a line row entered the layout graph (decision 0740)
|
||
}
|
||
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.set_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);
|
||
model.touch_measure(right); // an adopted cell can arrive text-in-hand
|
||
model.touch_layout(); // a line row entered the layout graph (decision 0740)
|
||
}
|
||
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.
|
||
pub(super) 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.vertex_color = Some(color);
|
||
// `AddLine("…", r, g, b)`'s colour is this line's OWN colour, exactly as if Lua had called
|
||
// `SetTextColor` on the cell — so it must survive a later mutation of the font object the
|
||
// line inherits ([`super::types::FontExplicit`]). Without the mark, one
|
||
// `GameTooltipText:SetTextColor(…)` would repaint every red/green line in the tooltip.
|
||
d.font_explicit.color = true;
|
||
d.hidden = false;
|
||
model.touch_measure(rh);
|
||
}
|
||
|
||
/// 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;
|
||
// `measured` is deliberately KEPT. It is a cache whose validity gate is its own
|
||
// content hash ([`RegionData::measure_key`] over text/font/wrap/outline), so wiping
|
||
// it here invalidates nothing the key would not — while destroying the one thing that
|
||
// makes the hover re-enter loop affordable. That loop
|
||
// (`ContainerFrameItemButton_OnUpdate`, unthrottled in 1.12 and shipped verbatim as
|
||
// the reference's `ContainerFrameItemButton_OnUpdate`) clears and rebuilds the SAME content
|
||
// every frame: keeping the cache lets each rebuilt line re-validate against its own
|
||
// key, so nothing re-shapes, the measure list stays empty, its forced second resolve
|
||
// never happens, and the layout gate's fingerprint — which exists precisely to
|
||
// "absorb idempotent writes for free" (layout.rs) — closes over the whole loop.
|
||
// Wiping it cost +10 CPU ms/frame on a live bag hover (measured 13.2 → 23.9 cpu_ms,
|
||
// `WOW_CAPTURE=ui-bag` vs `ui-tooltip`); the gate is
|
||
// `layout_gate::the_hover_re_enter_loop_neither_re_measures_nor_re_solves`.
|
||
//
|
||
// The wrap pin (`size`) is likewise KEPT, and for the stronger reason: wiping it here
|
||
// was the last write that made the hover re-enter loop differ from itself. The wipe
|
||
// and `append_line`'s re-pin are a round trip inside ONE frame — same cell, same
|
||
// width, no observable state between them — but each half bumped the layout epoch, so
|
||
// tier 1 went dirty every hover frame and tier 2 hashed the whole UI to conclude
|
||
// nothing had moved (~0.65 ms/frame, `WOW_UI_COST=1` on `WOW_CAPTURE=ui-tooltip`, at
|
||
// solves=0). The pin is now written from the line's own wrap flag at append time —
|
||
// set for a wrap line, cleared for a plain one — which covers the case this wipe
|
||
// existed for (a cell whose new content doesn't wrap) without a write per frame.
|
||
// A hidden, textless cell contributes nothing to layout in between either way.
|
||
}
|
||
}
|
||
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_offer = None;
|
||
}
|
||
// 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 (fresh CONTENT does this — re-hovering during
|
||
/// the fade-out resurrects the tooltip at full strength). Conditional on a fade actually running,
|
||
/// because an addon that lowered the plate's alpha itself and then added a line keeps its alpha:
|
||
/// only the three sites in [`full_alpha`] are documented to stamp it back.
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `SetAlpha(255)` — **UNCONDITIONAL**, and the FIRST thing the SetOwner core `0x52ffe0` does
|
||
/// (`0x52fff4`), before the five stores; `0x530a80`'s show arm does it too, and its self-hide arm
|
||
/// reaches it through `0x530a60` → `0x52ffe0(0, 0, 0, 0)`. So all three of SetOwner, Show and the
|
||
/// effective-hide leave the plate at full strength whether or not one of OUR fades was running
|
||
/// (wow-re `system/ui/ledger.tsv` rows `0x52ffe0` / `0x530a80`, both verified, and
|
||
/// `scratch/hover-hide-and-tooltip-owner-law.md` §4).
|
||
///
|
||
/// [`cancel_fade`] is not this: it only restores the alpha a fade of ours took away, so a plate
|
||
/// left dim by any other path never recovered where the reference recovers on the next SetOwner.
|
||
fn full_alpha(model: &mut Model, h: FrameHandle) {
|
||
if let Ok(t) = tip_mut(model, h) {
|
||
t.fade_start = None;
|
||
}
|
||
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];
|
||
|
||
/// The client's duration-string formatter, `0x52fa50` — the one that turns a millisecond count
|
||
/// into "2 hours remaining" / "45 seconds remaining" (wow-re `ui/scratch/tooltip-content-law.md`
|
||
/// §3-BUFF-TIME-FORMAT, the 2026-08-14 carve). Its parameter vector is the binary's:
|
||
/// `(V_ms, keyPrefix, roundUp)`, minus the `extraArg` slot only the enchant line fills.
|
||
///
|
||
/// **The ladder, with the thresholds decoded from the compare instructions** (`52fa76`, `52fab4`,
|
||
/// `52fb03`, falling through to `0x52fb4f`): `≥ 86_400_000` → `_DAYS` · `≥ 3_600_000` → `_HOURS` ·
|
||
/// `≥ 60_000` → `_MIN` · else `_SEC`. Each arm divides by its own threshold, so the number shown is
|
||
/// always in the unit the arm names.
|
||
///
|
||
/// **`roundUp` ceils — but only in the top three arms; `_SEC` always truncates.** That asymmetry is
|
||
/// the whole reason this is a shared function and not four `format!`s, and it is what produces the
|
||
/// reference's three surprising readings: 61 s is "2 minutes remaining" (ceil, not "1"), 3 599 999 ms
|
||
/// is "60 minutes remaining" (the hour arm needs the full hour, so there is no "1 hour" until
|
||
/// exactly 3 600 000), and the final second before an aura lapses reads "**0** seconds remaining"
|
||
/// (truncation, not "1").
|
||
///
|
||
/// **The text is the player's own `GlobalStrings.lua`, never ours.** `get` is the VM-global lookup
|
||
/// (`load_global_strings` runs the shipped file into this VM at boot, off the patch chain); the
|
||
/// template arrives as `"%d minutes remaining"` and the count fills its one `%d`. A key the string
|
||
/// table doesn't carry yields **no line at all** rather than an invented one — the same disposition
|
||
/// 0620 §5 took for a reagent whose template hasn't streamed. That is also why this house ships no
|
||
/// copy of the eight strings: on an install they are read, and off one there is nothing to render.
|
||
pub(in crate::script) fn duration_text(
|
||
ms: u32,
|
||
key_prefix: &str,
|
||
round_up: bool,
|
||
get: &dyn Fn(&str) -> Option<String>,
|
||
) -> Option<String> {
|
||
const DAY_MS: u32 = 86_400_000;
|
||
const HOUR_MS: u32 = 3_600_000;
|
||
const MIN_MS: u32 = 60_000;
|
||
const SEC_MS: u32 = 1_000;
|
||
|
||
let (suffix, divisor) = if ms >= DAY_MS {
|
||
("DAYS", DAY_MS)
|
||
} else if ms >= HOUR_MS {
|
||
("HOURS", HOUR_MS)
|
||
} else if ms >= MIN_MS {
|
||
("MIN", MIN_MS)
|
||
} else {
|
||
("SEC", SEC_MS)
|
||
};
|
||
// `roundUp` reaches the day/hour/minute arms only — the seconds arm has no rounding branch at
|
||
// all, so it truncates whatever `roundUp` says.
|
||
let n = if round_up && divisor != SEC_MS {
|
||
ms.div_ceil(divisor)
|
||
} else {
|
||
ms / divisor
|
||
};
|
||
let template = plural_template(&format!("{key_prefix}_{suffix}"), n, get)?;
|
||
Some(template.replacen("%d", &n.to_string(), 1))
|
||
}
|
||
|
||
/// [`crate::strings::plural`] for this module's callers — the `_P1` pick behind
|
||
/// `GetText(token, gender, n)`, whose byte law (and the refuted `> 1` reading: `703c89` is a
|
||
/// *signed* test, so a zero ordinal takes the plural arm and a lapsing aura reads "0 seconds
|
||
/// remaining") now lives with the primitive rather than in a fourth private copy of it. Four
|
||
/// copies of one rule is the shape decision 2045 wrote about; `fill` had eight.
|
||
///
|
||
/// **Gender is not modelled, and that is faithful, not a gap:** `0x52fa50` pushes a literal `0`
|
||
/// on both arms (`52fb8d: 6a 00`, `52fbaa: 6a 00`), which `0x703bf0` turns into nil, and enUS
|
||
/// folds nil to no suffix.
|
||
///
|
||
/// (When BOTH the twin and the bare token are absent the reference renders an EMPTY line, off its
|
||
/// pre-seeded `0x882748`; we render no line at all. Unreachable with shipped data — all eight of
|
||
/// the aura family's keys ship — and off an install there is no string table to be faithful to.)
|
||
pub(in crate::script) fn plural_template(
|
||
token: &str,
|
||
n: u32,
|
||
get: &dyn Fn(&str) -> Option<String>,
|
||
) -> Option<String> {
|
||
crate::strings::plural(token, Some(n), get)
|
||
}
|
||
|
||
/// 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.
|
||
// The wrap pin is written from the LINE's own flag every append — set for a wrap line,
|
||
// cleared for a plain one — so a cell that changes role can never keep a stale pin, and
|
||
// `clear_content` needs no wipe of its own (which is what used to make this write differ
|
||
// every frame). The epoch bump is gated on a REAL change: in the hover re-enter loop the
|
||
// rebuilt line pins the same width it already carried, so tier 1 stays clean and the
|
||
// whole-UI fingerprint (tier 2) never runs — see `clear_content`'s note.
|
||
let pin = wrap.then_some((crate::widget::TOOLTIP_WRAP_WIDTH, 0.0));
|
||
if let Some(d) = model.region_data.get_mut(&lh) {
|
||
if d.size != pin {
|
||
d.size = pin;
|
||
// NAMED, not conservative (decision 1388). This writes one region's EXPLICIT SIZE and
|
||
// nothing else — no anchor target, no roster membership — which is the exact shape
|
||
// 1388 migrated every size setter to. It was missed because it is an internal write
|
||
// rather than a `SetWidth` binding, and the miss is expensive in the one place it can
|
||
// least afford to be: the pin flips whenever a line changes ROLE between wrapped and
|
||
// plain, which is what every hover from one item or spell to a differently-shaped one
|
||
// does. A conservative touch there re-derives the whole layout graph — every live
|
||
// frame's scale re-synced, every anchored region re-hashed — on each hover.
|
||
model.touch_layout_region(lh);
|
||
// The wrap pin is the measure key's wrap-width input.
|
||
model.touch_measure(lh);
|
||
}
|
||
}
|
||
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");
|
||
full_alpha(&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; one floored unit each way for a shown but EMPTY cell (the reference's
|
||
/// `AddLine("")` adds no line at all, and its virtual `GetHeight` never reads back `0.0`); `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() {
|
||
// The same floor the sweep applies to the line's own rect
|
||
// (`layout::FONTSTRING_MIN_SPAN`): the reference's `GetHeight` is virtual and ends in a
|
||
// one-unit clamp, so `0.0` is not a height any caller — plate arithmetic included — can
|
||
// read back off a FontString (wow-re `region-size-fallback.md` §3,
|
||
// `tooltip-blank-line-height.md` §3). Reporting zero here while the chain below the row
|
||
// resolved one unit is the B309 shape at 1/14th the size, and the point of one constant
|
||
// is that the two cannot drift apart.
|
||
return Some((
|
||
super::layout::FONTSTRING_MIN_SPAN,
|
||
super::layout::FONTSTRING_MIN_SPAN,
|
||
));
|
||
}
|
||
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 extents
|
||
/// land. With a [`TextMeasure`](super::TextMeasure) installed that is the SAME `resolve`
|
||
/// (`resolve` solves, fills the pending measures inline, and solves again, so this pre-pass runs
|
||
/// a second time with real extents); without one it is the host's batch round-trip, one frame
|
||
/// later, which is the engine-less path every measurer-free VM still takes.
|
||
/// **ANCHOR_CURSOR — mode 6, re-anchored to the live cursor every frame** (`0x530b20`, the
|
||
/// `CGameTooltip` override of `vtable+0x38` on `0x808f60`; wow-re
|
||
/// `system/ui/scratch/tooltip-cursor-anchor-law.md` §1, three independent derivations agreeing
|
||
/// instruction-for-instruction). Nine corpus files ask for it — `pfUI`'s tooltip, xpbar and chat
|
||
/// modules, `pfQuest/browser.lua`, `TipBuddy` — and until decision 2176 benilla warned and placed
|
||
/// them by some other mode.
|
||
///
|
||
/// The whole of the re-anchor is one `SetPoint`:
|
||
///
|
||
/// ```text
|
||
/// 530b32 cmp [this+0x318],6 ; jne -- every other mode skips
|
||
/// 530b3b eax = [this+0xa0] -- the CSimpleTop root == [0xcf0bd8]
|
||
/// 530b4f ecx = [eax+0x111c] ; edx = [eax+0x1118] -- the SAVED cursor, normalised [0,1]
|
||
/// 530b63 call 0x41ad80 -- x *= G44 (screen w), y *= G48 (screen h)
|
||
/// 530b68 fld [this+0x7c] ; fdivr 1.0f -- 1 / the tooltip's own effective scale
|
||
/// 530b97 call 0x767c70(BOTTOM=7, [0xcf0bd8], BOTTOMLEFT=6, x/scale, y/scale, doResolve=1)
|
||
/// ```
|
||
///
|
||
/// So **the plate's BOTTOM-centre is pinned to the screen's BOTTOMLEFT plus the cursor's absolute
|
||
/// position** — centred horizontally on the cursor and sitting directly above it. Four details are
|
||
/// worth keeping because none is guessable from the name:
|
||
///
|
||
/// * The `relativeTo` is the screen root passed **raw**, not `+0x24` like every other anchor — the
|
||
/// `CSimpleTop`'s `CLayoutFrame` base sits at offset 0. Ours is [`SCREEN`](super::SCREEN), which
|
||
/// is the same object.
|
||
/// * The **divide by the tooltip's own effective scale** is the "cursor box recip": an anchor
|
||
/// offset is stored raw and multiplied by the child's scale at resolve time, so dividing here is
|
||
/// what makes the result land at the cursor's *absolute* position. Ours does the same divide,
|
||
/// against the same scale [`GetWidth`](super::object::eff_scale) reads.
|
||
/// * **The `SetOwner`-time offsets `+0x3c4/+0x3c8` do not participate** — 12 reads of them, every
|
||
/// one on a modes-0..5 arm of `0x52fe90`.
|
||
/// * There is **no `ClearAllPoints` here** (earned zero over the function's complete call set): one
|
||
/// anchor slot is overwritten, and `0x767c70`'s own no-op gate — same target, same
|
||
/// `relativePoint`, both offsets within 2⁻²² — is what keeps a still cursor free.
|
||
///
|
||
/// It runs from the per-frame update pump, which a hidden tooltip is not in (`0x76ad9d`
|
||
/// deregisters on hide), and in the **same frame** as the placement it produces: the layout drain
|
||
/// `0x768ed0` runs at `0x765799`, right after the `+0x38` loop and before the draw. Hence its home
|
||
/// here, at the top of the resolve, beside the auto-size pass.
|
||
fn cursor_anchor(model: &mut Model, h: FrameHandle) {
|
||
let scale = crate::script::object::eff_scale(model, h);
|
||
let (cx, cy) = model.cursor_pos;
|
||
let new = Anchor::new(
|
||
Point::Bottom,
|
||
super::SCREEN,
|
||
Point::BottomLeft,
|
||
cx / scale,
|
||
cy / scale,
|
||
);
|
||
let input = model.layout_inputs.entry(h).or_default();
|
||
// `0x767c70`'s change-detect, in our own terms: one slot, replaced only on a real change.
|
||
let same =
|
||
input.anchors.len() == 1 && crate::script::object::anchor_bits_eq(&input.anchors[0], &new);
|
||
if same {
|
||
return;
|
||
}
|
||
let old_targets: Vec<u32> = input.anchors.iter().map(|a| a.relative_to).collect();
|
||
input.anchors = vec![new];
|
||
model.touch_layout_retarget_frame(h, &old_targets, &[super::SCREEN]);
|
||
}
|
||
|
||
pub(super) fn layout_tooltips(model: &mut Model) {
|
||
// The arena's tooltip registry, not the resolve's whole frame roster: this pre-pass runs at
|
||
// the top of EVERY resolve, and finding two or three tooltips by scanning ~4000 ids was most
|
||
// of what it cost (decision 1634). `frame_to_id` still gates each one — a tooltip outside the
|
||
// resolve's roster would mint an id here and take the ledger's conservative branch with it.
|
||
let tips: Vec<FrameHandle> = model
|
||
.arena
|
||
.tooltip_kinds()
|
||
.iter()
|
||
.copied()
|
||
.filter(|h| model.frame_to_id.contains_key(h))
|
||
.collect();
|
||
for h in tips {
|
||
// Mode 6 first, and **before the line gate below**: the reference's per-frame update is
|
||
// not gated on content at all, only on the plate being in the pump's list — which is the
|
||
// shown list. See [`cursor_anchor`].
|
||
let cursor_mode = match model.arena.frame(h).map(|f| (&f.kind_state, f.shown)) {
|
||
Some((KindState::Tooltip(t), shown)) => shown && t.anchor == TooltipAnchor::Cursor,
|
||
_ => false,
|
||
};
|
||
if cursor_mode {
|
||
cursor_anchor(model, h);
|
||
}
|
||
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
|
||
// extents land — this resolve's own `fill_measures` with a measurer installed, the host's
|
||
// batch round-trip a frame later without one).
|
||
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;
|
||
// This pre-pass runs INSIDE the resolve, so it must not open tier 1 (see the note method's
|
||
// own doc) — but the per-node ledger decision 1388's incremental pass seeds from still has
|
||
// to hear that these two inputs were written by something other than a Lua setter.
|
||
model.note_layout_frame_write(h);
|
||
// 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);
|
||
let mut wrote = false;
|
||
if let Some(d) = model.region_data.get_mut(&rights[i]) {
|
||
if let Some(a) = d.anchors.first_mut() {
|
||
a.x_off = maxw - lw;
|
||
wrote = true;
|
||
}
|
||
}
|
||
if wrote {
|
||
model.note_layout_region_write(rights[i]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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");
|
||
// The arena's tooltip registry (1634) — this runs every tick, and the roster it used to
|
||
// walk is ~4000 entries at a corpus UI. The `frame_to_id` gate is kept: `hide_tooltip`
|
||
// below writes layout, and a frame outside the resolve's roster takes the ledger's
|
||
// conservative branch.
|
||
for &h in model.arena.tooltip_kinds() {
|
||
if !model.frame_to_id.contains_key(&h) {
|
||
continue;
|
||
}
|
||
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);
|
||
}
|
||
}
|