Files
benilla-octo/crates/benilla-ui/src/script/tooltip/mod.rs
T
Sam SungandClaude Opus 5.5 fa5568dcdc Creating or freeing a frame or region re-derives only what it touches (#915)
Any write that changed the layout graph's roster (a frame or region created, freed, re-owned or re-pointed) made the next resolve derive the whole graph: about 1.35 ms for one `CreateTexture` or `CreateFrame` on the stock UI, against 0.05 ms for a no-op.

A write now names the nodes it touched, and the resolve re-derives just those in the cached graph: a joining node is added with its edges, a leaving one is removed and its dependents seeded, a staying one has its edges and inputs re-synced. The full derivation stays the fallback. A region's owner and an art change are now layout inputs that name their region. Verify now also compares the graph itself.

A randomized test checks rects and graph against a scratch solve after every batch of creates, frees, re-owns and re-anchors. `CreateTexture` now costs 0.14 ms, `CreateFrame` 0.15 ms, a backdrop rebuild 0.22 ms.

Fixes #903

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:05:23 +07:00

857 lines
35 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The `GameTooltip` method surface and engine behaviour: the line stack, owner and anchor,
//! auto-size and the fade.
//!
//! Lines are named FontString regions published as Lua globals (`<name>TextLeft1` …), since
//! FrameXML addresses them by name (`GameTooltipTextLeft1:SetTextColor`, `UnitFrame.lua:76`). The
//! template declares 30 pairs, which are adopted; the reference grows past them through
//! `AddFontStrings`, and this also creates pairs on demand.
//!
//! Layout is a pre-pass of every `resolve` ([`layout_tooltips`]): the width is the widest line (a
//! double line adds the column gap) plus padding, floored by `SetMinimumWidth`; the height is the
//! summed line heights and gaps; each right column sits flush with the text inset.
use mlua::{Lua, Table, Value};
use super::event;
use super::object::{frame_handle_of, publish_global};
use super::region::region_wrapper;
use super::visibility;
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, a named registry root (the MAXCSTACK discipline).
pub(super) const REG_TOOLTIP_METHODS: &str = "__benilla_tooltip_methods";
impl super::UiScript {
/// The name of the frame that owns the GameTooltip named `tooltip`; `None` for no such
/// tooltip, no owner or an unnamed one. 1.12 answers only `IsOwned(frame)`, so the host's
/// recorders read the owner here.
pub fn tooltip_owner_name(&self, tooltip: &str) -> Option<String> {
let model = self.model_ref();
let h = model.arena.lookup(tooltip)?;
let owner = match &model.arena.frame(h)?.kind_state {
KindState::Tooltip(t) => t.owner?,
_ => return None,
};
model.arena.frame(owner)?.name.clone()
}
}
/// Runs `f` over a GameTooltip's state under one short write borrow.
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")),
}
}
/// `SetFontObject` onto a line cell, except that a missing font object keeps the default face
/// rather than raising (a VM loaded without Fonts.xml).
fn apply_font(d: &mut RegionData, name: &str, fo: Option<&FontObject>) {
d.font_object = Some(name.to_string());
// The inherit mask (`+0x2c`) stays: a re-point does not restore inheritance in the reference.
// From here the line follows the font object live, 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);
}
}
}
/// Grows the line-pair pool to `n` pairs: XML-declared pairs (`<name>TextLeft<i>`) are adopted
/// with their fonts, and created pairs clone the previous pair's fonts past a declared ladder, or
/// else wear the template's `GameTooltipHeaderText` (line 1) and `GameTooltipText`.
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;
// The template's declared pair for this index (ShoppingTooltipTemplate's ladder).
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)
};
// A created cell's fonts: the previous line's under an XML ladder, else the 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 as in the template; it shows once a wrap line pins its width.
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_region(left); // a line row entered the layout graph
}
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_region(right); // a line row entered the layout graph
}
let right_id = model.region_id(right);
if !frame_name.is_empty() {
// Adopted pairs are already named and 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(())
}
/// Writes a line cell's text and colour and shows it.
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);
// The line's own colour, as if set by `SetTextColor`: a later
// `GameTooltipText:SetTextColor` must not repaint it.
d.font_explicit.color = true;
d.hidden = false;
model.touch_measure(rh);
}
/// Blanks every line cell and resets the counters and the subject: the model half of the clear
/// ([`clear_silent`]).
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` and the wrap pin (`size`) stay: the measure cache is keyed by content
// ([`RegionData::measure_key`]) and `append_line` rewrites the pin, so the hover
// loop's per-frame clear and rebuild (`ContainerFrameItemButton_OnUpdate`) re-measures
// and re-lays out nothing.
}
}
if let Ok(t) = tip_mut(model, h) {
t.num_lines = 0;
t.min_width = 0.0;
// Clear drops the subject and unregisters its health watcher (`0x5300ad`, `0x53007c`).
t.unit_guid = None;
}
// New content replaces a spell render still waiting on its view.
model.spell_tooltip_waits.remove(&h);
}
/// The tooltip's `<name>StatusBar` health bar (`[+0x348]`), if it has one.
pub(super) fn status_bar(model: &Model, h: FrameHandle) -> Option<FrameHandle> {
let name = model.arena.frame(h)?.name.as_deref()?;
model.arena.lookup(&format!("{name}StatusBar"))
}
/// The tooltip clear (`0x530050`) without its last act, the `OnTooltipCleared` fire: while the
/// tooltip holds a unit (`0x530070`) the health bar hides by its own transition, shown bit then
/// hide slot (`0x53009b`/`0x5300a1`), so its `OnHide` runs; then the lines, counters and subject.
pub(super) fn clear_silent(lua: &Lua, h: FrameHandle) {
let bar = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let held = tip_mut(&mut model, h).is_ok_and(|t| t.unit_guid.is_some());
held.then(|| status_bar(&model, h)).flatten()
};
if let Some(bar) = bar {
visibility::set_shown(lua, bar, false);
}
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
clear_content(&mut model, h);
}
/// The tooltip clear (`0x530050`): [`clear_silent`], then `OnTooltipCleared` (`0x530263`).
pub(super) fn clear(lua: &Lua, h: FrameHandle) {
clear_silent(lua, h);
fire_cleared(lua, h);
}
/// Fires `OnTooltipCleared`; a script error is recorded, 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")
.record_lua_error(&e);
}
}
/// A content `Set*`'s tail: the builders' show, the shown bit and the show slot (`0x52a711`/
/// `0x52a71b`, `0x52e14e`), whose override restores full alpha and disarms a fade, or, for a plate
/// with no owner or no lines, hides and un-owns it (`0x530aba`). An item builder whose template is
/// not cached ends the same way, by its own hide (`0x52b735`–`0x52b73f`), and its clear zeroes the
/// remembered item id (`0x5300dd`) that the cache callback `0x52e530` needs (`0x52e55e`), so the
/// plate stays down until the next hover.
///
/// A spell render still waiting on its view ([`Model::spell_tooltip_waits`], filled by the host
/// ahead of a hover) is the one exception: the reference reads spells synchronously and has no
/// such state. That plate takes the base hide, keeping its owner and its wait, so the view's
/// arrival renders it in place.
pub(super) fn show_or_hide_empty(lua: &Lua, h: FrameHandle) {
let waiting = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let empty = tip_mut(&mut model, h).is_ok_and(|t| t.num_lines == 0);
empty && model.spell_tooltip_waits.contains_key(&h)
};
if waiting {
visibility::set_shown_bit(lua, h, false);
visibility::base_hide(lua, h);
} else {
visibility::set_shown(lua, h, true);
}
}
/// The tooltip's show (`0x530a80`, slot `+0x88`), however the show comes: with an owner and a line
/// (`0x530a83`–`0x530a95`) it restores full alpha, disarms the fade (`0x530aa3`) and runs the base
/// show; else it clears its shown bit and hides through its own hide (`0x530abe`, `0x530ac8`).
/// So a parent's show brings back only a tooltip that still has an owner.
pub(in crate::script) fn show_override(lua: &Lua, h: FrameHandle) {
let live = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let live = tip_mut(&mut model, h).is_ok_and(|t| t.owner.is_some() && t.num_lines > 0);
if live {
full_alpha(&mut model, h);
}
live
};
if live {
visibility::base_show(lua, h);
} else {
visibility::set_shown_bit(lua, h, false);
visibility::hide(lua, h);
}
}
/// The tooltip's hide (`0x530a60`, slot `+0x84`), however the hide comes, its parent's included:
/// the SetOwner core with no owner ([`set_owner_core`]), then the base hide. It runs whether or
/// not the tooltip is shown: only the base hide gates on visibility (`0x76ad5b`).
pub(in crate::script) fn hide_override(lua: &Lua, h: FrameHandle) {
let is_tip = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
tip_mut(&mut model, h).is_ok()
};
if is_tip {
set_owner_core(lua, h, None, TooltipAnchor::None, 0.0, 0.0);
}
visibility::base_hide(lua, h);
}
/// The SetOwner core (`0x52ffe0`), reached from the Lua `SetOwner` after its hide, from the
/// tooltip's own hide with no owner, and from the engine's mode-6 seats (`0x492a1e`, `0x4eb587`).
/// It clears and fires `OnTooltipCleared` ([`clear`], `0x52ffe6`), restores full alpha
/// (`0x52fff4`), disarms the fade (`0x530002`), stores the owner (`0x53000c`) and mode 7,
/// `ANCHOR_NONE` (`0x530012`); only an owner overwrites the mode with the caller's (`0x530031`)
/// and applies the anchor (`0x52fe90`, [`apply_anchor`]).
pub(in crate::script) fn set_owner_core(
lua: &Lua,
h: FrameHandle,
owner: Option<FrameHandle>,
anchor: TooltipAnchor,
x: f32,
y: f32,
) {
clear(lua, h);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
full_alpha(&mut model, h);
let Ok(t) = tip_mut(&mut model, h) else {
return;
};
t.owner = owner;
t.anchor = TooltipAnchor::None;
// A re-own, or the hide's un-own, ends the world plate; a content re-set alone does not.
t.world_owned = false;
if let Some(owner) = owner {
t.anchor = anchor;
apply_anchor(&mut model, h, owner, anchor, x, y);
}
}
/// The anchor apply (`0x52fe90`, with the core's 1): `ANCHOR_PRESERVE` returns first
/// (`0x52fead`); every other mode clears all points (`0x767ed0` at `0x52fec2`); modes 0..5 then
/// set one point on the owner (`0x52ffbc`), and 6 and 7 stop there. The clear lets
/// `GameTooltip_SetDefaultAnchor` (GameTooltip.lua:76-77) set one point with no stale owner
/// anchor; mode 6 is then placed per frame by `cursor_anchor`.
fn apply_anchor(
model: &mut Model,
h: FrameHandle,
owner: FrameHandle,
anchor: TooltipAnchor,
x: f32,
y: f32,
) {
let pts = match anchor {
TooltipAnchor::Right => Some((Point::BottomLeft, Point::TopRight)),
TooltipAnchor::Left => Some((Point::BottomRight, Point::TopLeft)),
TooltipAnchor::TopRight => Some((Point::BottomRight, Point::TopRight)),
TooltipAnchor::TopLeft => Some((Point::BottomLeft, Point::TopLeft)),
TooltipAnchor::BottomRight => Some((Point::TopLeft, Point::BottomRight)),
TooltipAnchor::BottomLeft => Some((Point::TopRight, Point::BottomLeft)),
TooltipAnchor::Cursor | TooltipAnchor::None | TooltipAnchor::Preserve => None,
};
if matches!(anchor, TooltipAnchor::Cursor | TooltipAnchor::None) {
let dropped = match model.layout_inputs.get_mut(&h) {
Some(input) if !input.anchors.is_empty() => {
input.anchors.clear();
true
}
_ => false,
};
if dropped {
model.touch_layout_frame(h);
}
}
if let Some((own, rel)) = pts {
let owner_id = model.frame_id(owner);
let new = Anchor::new(own, owner_id, rel, x, y);
let input = model.layout_inputs.entry(h).or_default();
// No-op when unchanged, so the bag hover's per-frame SetOwner does not dirty the layout.
let same = input.anchors.len() == 1
&& crate::script::object::anchor_bits_eq(&input.anchors[0], &new);
if !same {
input.anchors = vec![new];
model.touch_layout_frame(h);
}
}
}
/// The fade request (`0x530ae0`), from the Lua `FadeOut` (`0x531fba`) and the world mouseover's
/// change (`0x492909`). A cursor-anchored plate, mode 6, hides at once through the hide slot,
/// with no shown-bit write, and is disarmed (`0x530ae3`–`0x530af4`); any other mode arms the fade
/// with the full duration on every call (`0x530b00`–`0x530b10`), so a second call restarts it.
pub(in crate::script) fn fade_out(lua: &Lua, h: FrameHandle) {
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Ok(t) = tip_mut(&mut model, h) else {
return;
};
if t.anchor != TooltipAnchor::Cursor {
t.fade_left = Some(TOOLTIP_FADE_SECS);
return;
}
}
visibility::hide(lua, h);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if let Ok(t) = tip_mut(&mut model, h) {
t.fade_left = None;
}
}
/// `SetAlpha(255)` and the fade disarmed, unconditional: the SetOwner core `0x52ffe0` does both
/// (`0x52fff4`, `0x530002`), and Show (`0x530a80`) and its self-hide (`0x530a60`, the core with no
/// owner) reach them too.
fn full_alpha(model: &mut Model, h: FrameHandle) {
if let Ok(t) = tip_mut(model, h) {
t.fade_left = None;
}
model.arena.set_alpha(h, 1.0);
}
/// Shows or hides as the Lua verbs do, handlers included.
pub(super) fn set_shown(lua: &Lua, h: FrameHandle, shown: bool) {
crate::script::visibility::set_shown(lua, h, shown);
}
/// The default line colour, gold `0xffffd200` (stored at `0xc0d3e8` by `0x528e50`). The zone
/// tooltip's `AddLine(text, "", 1.0, 1.0, 1.0)` (`Minimap.lua:40`) renders in it: the `""` r-slot
/// drops the whole colour, and the last `1.0` lands in the wrap slot.
pub(in crate::script) const DEFAULT_TEXT_GOLD: [f32; 4] = [1.0, 210.0 / 255.0, 0.0, 1.0];
/// The client's duration formatter (`0x52fa50`): days, hours, minutes or seconds by threshold,
/// each dividing by its own unit. `round_up` ceils every arm but seconds, which truncates, so 61 s
/// reads "2 minutes" and 3 599 999 ms "60 minutes". The text is the player's own
/// `GlobalStrings.lua` through `get`; a missing key renders no line.
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)
};
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`], the `_P1` pick of `GetText(token, gender, n)`. There is no gender:
/// the formatter passes a literal 0 (`0x52fa50`), which `0x703bf0` reads as nil. With neither key
/// present the lookup yields "" (`0x882748`), which the line core drops, so no line either way.
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 by `lua_tonumber`'s coercion: a numeric string counts, other strings do not.
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,
}
}
/// A wrap flag: nil, false, 0 and the strings "0", "off" and "disabled" are false, anything else
/// true. The reference reads it with `GetBoolOrDefault` (`0x6f1c10`,
/// `binding_abi::bool_or_default`), where "false", "no" and 0.5 are false too.
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,
}
}
/// An `AddLine`-family colour, gated on the r-slot alone (`lua_isnumber`, `0x6f34d0`), else
/// [`DEFAULT_TEXT_GOLD`]; past the gate a non-number g or b reads 0, and alpha is always opaque.
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,
}
}
/// An `AddLine` tail, positional `r, g, b, wrap` (`0x531630`); the wrap flag is read whatever the
/// colour gate decides.
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 text argument as a line's string: numbers stringify as Lua does, and nil is empty.
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(),
}
}
/// Appends one line, without showing the tooltip or touching a fade (`0x5303b0`): `AddLine`
/// callers call `Show`. The reference's line core drops a line whose sides are both empty
/// (`0x530270`); this appends it.
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);
// The wrap width is pinned at append, so the first measure already comes back wrapped. It is
// written from this line's own flag every time, and layout is touched only on a change.
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;
// A size-only write takes the named touch: a conservative one would re-derive the
// whole layout graph on every hover that turns a line between wrapped and plain.
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;
}
Ok(())
}
mod verbs;
pub(super) use verbs::install;
/// A line cell's measured extent; `None` for a hidden or unmeasured cell, which adds no size and
/// no gap, so a fresh tooltip keeps its declared size until its lines are measured.
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() {
// One unit each way, the floor of the line's own rect: the reference's `GetHeight`
// (`0x772a60`) clamps to one unit, so no FontString reads back 0.
return Some((
super::layout::FONTSTRING_MIN_SPAN,
super::layout::FONTSTRING_MIN_SPAN,
));
}
d.measured.map(|m| (m.w, m.h))
}
/// `ANCHOR_CURSOR` (mode 6), re-anchored every frame by the update override `0x530b20`: one
/// `SetPoint` (`0x767c70`) pins the plate's BOTTOM to the screen root's BOTTOMLEFT (`0xcf0bd8`)
/// at the cursor position divided by the tooltip's own effective scale, so it sits centred above
/// the cursor. There is no `ClearAllPoints`, and `SetPoint`'s no-op gate keeps a still cursor
/// free. The `SetOwner` offsets play no part (only `0x52fe90` reads them). Only a shown tooltip is
/// in the update pump (`0x76ad9d`), and the placement lands the same frame, before the layout
/// drain (`0x768ed0`).
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: the one slot is 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;
}
input.anchors = vec![new];
model.touch_layout_frame(h);
}
pub(super) fn layout_tooltips(model: &mut Model) {
// Only tooltips in the resolve's roster: one outside it would mint an id here and take the
// layout ledger's conservative branch.
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 runs before the line gate: the reference re-anchors any shown plate, lines or not.
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;
}
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: the declared size holds
}
maxw = maxw.max(min_w);
if maxw <= 0.0 || totalh <= 0.0 {
continue;
}
let input = model.layout_inputs.entry(h).or_default();
// `pad_w` is `SetPadding`'s extra width (ItemRefTooltip's room for its close button).
input.width = maxw + 2.0 * TOOLTIP_PAD + pad_w;
input.height = totalh + 2.0 * TOOLTIP_PAD;
// Inside the resolve, so a note and not a touch; the incremental pass still has to hear
// that these inputs changed.
model.note_layout_frame_write(h);
// Right-flush each double line: left.right + (maxw - left.width) is the text inset.
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]);
}
}
}
}
/// The tooltip's own update after its `OnUpdate` (`0x530b20`, from `0x530b9c`): an armed fade
/// counts down by the elapsed time (`0x530baa`–`0x530bc2`). At zero or below it ends in the full
/// hide (`0x530bd8` full alpha, `0x530be9` the hide slot), whose `OnHide` runs here, in the
/// tooltip's place in the walk. Otherwise the alpha is 255 while at least half the duration
/// remains (`0x7ffa24` = 0.5, `0x530c0c`), else `trunc(255 × left / half)` (`0x530c18`–`0x530c2c`,
/// `0x40a2b0` truncates). Nothing gates it on shown, owner or lines.
pub(in crate::script) fn update(lua: &Lua, h: FrameHandle, elapsed: f32) {
let done = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// Roster-gated as in `layout_tooltips`, since the hide writes layout.
if !model.frame_to_id.contains_key(&h) {
return;
}
let Ok(tip) = tip_mut(&mut model, h) else {
return;
};
let Some(left) = tip.fade_left else {
return;
};
let left = left - elapsed;
tip.fade_left = Some(left);
if left <= 0.0 {
true
} else {
model.arena.set_alpha(h, fade_alpha(left));
false
}
};
// The end: full alpha (`0x530bd8`), then the shown bit cleared and the hide slot (`0x530bdf`,
// `0x530be9`), as the Lua `Hide` does, so the hide's `OnTooltipCleared` sees 255.
if done {
lua.app_data_mut::<Model>()
.expect("model app_data")
.arena
.set_alpha(h, 1.0);
set_shown(lua, h, false);
}
}
/// The fade's alpha with `left` seconds to go, in whole 255ths as the reference's `SetAlpha`
/// takes them. The product is formed in f64, where `left × 255` is exact as in the x87 original.
fn fade_alpha(left: f32) -> f32 {
let half = f64::from(TOOLTIP_FADE_SECS) * 0.5;
let left = f64::from(left);
let units = if left < half {
(left / half * 255.0).trunc()
} else {
255.0
};
(units / 255.0) as f32
}