- the whole loop plays: the line arcs rod tip to bobber, the bite splashes with its own sound, and clicking the bobber reels in - the verdicts read like 1.12: yellow "your fish got away" lines, the reel-in sound, the fishing loot window - "You feel rested." prints on the transition, rest states come from the client's own Exhaustion table, and level 60 trades the XP strip for the max-level rail - the Skills pane lists what the real client lists: the hidden rows, the sort, gray numberless single-rank lines Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
680 lines
31 KiB
Rust
680 lines
31 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, 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.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_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);
|
||
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_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_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.
|
||
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);
|
||
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;
|
||
// `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
|
||
// `BagFrame.xml`'s `BenillaBagSlot_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_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.
|
||
// 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;
|
||
model.touch_layout();
|
||
}
|
||
}
|
||
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);
|
||
}
|
||
}
|