Files
benilla-octo/crates/benilla-ui/src/script/tooltip_unit.rs
T
gromandClaude Opus 5 a2da3e4da7 ui: a skill lock you can open stops reading red
The GameObject tooltip's skill-lock line took the opener-*unknown* arm
unconditionally — `LOCKED_WITH_SPELL` in red — whatever the player's skill. The
known arm was simply not built, and the code said so where it was missing.

The reference forks on whether any known spell opens the slot's LockType
(`0x52abcb`): known takes `LOCKED_WITH_SPELL_KNOWN`, which carries the
requirement as a `%d` and the colour of the skill-difficulty ramp `0x529fa0`.
Built now, both halves.

grom reported it on a copper vein at Mining 70, which under the ramp is +69 over
the vein's requirement of 1 — the green rung, not the grey one he expected; grey
starts at +100, so a copper vein greys at Mining 101. Either way not red, and
red is what it showed.

The resolve is hoisted out of the `flag_locked` branch, because the "Locked"
line's colour and this line's known arm are two readings of one answer and must
not disagree with the click. `lock::skill_margin` recomputes the two numbers from
the opener the resolver already matched, with the same two functions its own
value test uses (`0x5f850f`), so hover and click cannot diverge on which opener
is in play.

Deviation, recorded at the constants: the three new rung RGBs are the standard
trade-skill palette, not values read out of the binary's table. Only the ramp's
shape is traced — five rungs, bands at +0/25/50/100 — and the rung a line lands
on is what the player reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-21 22:17:04 -06:00

625 lines
26 KiB
Rust

//! The engine unit tooltip, in `0x529fe0`'s line order: the name (gold; FrameXML recolours it by
//! reaction), the creature subtitle, the level line, the faction name, then "PvP", "Skinnable",
//! "Civilian" and "Leader", with health on the `<name>StatusBar` child. The world mouseover drives
//! it through [`super::UiScript::world_tooltip_unit`]; unit-frame hovers call `SetUnit` from Lua.
use mlua::{Lua, Table};
use super::object::frame_handle_of;
use super::tooltip::{append_line, clear_content, fire_cleared, show_or_hide_empty, tip_mut};
use super::unit::{is_civilian_kill, level_reads_unknown, unknownobject};
use super::{KindState, Model, UnitState};
use crate::layout::{Anchor, Point};
use crate::strings::{fill, Arg};
use crate::widget::FrameHandle;
const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0];
const RED: [f32; 4] = [1.0, 32.0 / 255.0, 32.0 / 255.0, 1.0];
/// `0xc0d420` = `0xff40c040`, the satisfiable-lock green, and the ramp's green rung.
const LOCK_OPEN: [f32; 4] = [64.0 / 255.0, 192.0 / 255.0, 64.0 / 255.0, 1.0];
/// The other three rungs of the skill-difficulty ramp `0x529fa0`, whose green rung is
/// [`LOCK_OPEN`] and whose bottom rung is [`RED`]. Deviation: these three RGBs are the standard
/// trade-skill palette, not values read out of the binary's table — only the ramp's *shape*
/// (five rungs, bands at +0/25/50/100 over the requirement) is traced. The rung a line lands on
/// is what matters to the player and that is faithful; a channel or two may not be.
const SKILL_GREY: [f32; 4] = [128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 1.0];
const SKILL_YELLOW: [f32; 4] = [1.0, 1.0, 0.0, 1.0];
const SKILL_ORANGE: [f32; 4] = [1.0, 128.0 / 255.0, 64.0 / 255.0, 1.0];
/// `0xffffd200`, the unit name's colour until FrameXML recolours it by reaction.
const GOLD: [f32; 4] = [1.0, 210.0 / 255.0, 0.0, 1.0];
/// A GameObject tooltip line's colour, as the builder `0x52aa20` picks it; the app picks the tint
/// for each line.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TooltipTint {
/// `0xc0cf60`: a requirement line's normal colour (the key item's "Requires %s").
White,
/// `0xc0d3a8` = `0xffff2020`, the unmet "Locked" line. Deviation: the unknown-skill
/// "Requires %s" shares it, though its reference red is `0xffff0000`, because a 32/255
/// difference in G and B does not show.
Red,
/// The satisfiable "Locked" line: `0xc0d420` = `0xff40c040`, the green rung of the
/// skill-difficulty ramp `0x529fa0`, not the ordinary green `0xc0d3ac` = `0xff00ff00`.
LockOpen,
/// The three rungs of the ramp `0x529fa0` that are neither its green ([`TooltipTint::LockOpen`])
/// nor its red ([`TooltipTint::Red`]), for the "Requires %s (%d)" line of a skill lock whose
/// opener the player knows: trivial at +100 over the requirement, then +50 and +25.
SkillGrey,
SkillYellow,
SkillOrange,
}
/// The rung of `0x529fa0` for a skill lock the player can open: `have` is what the opener provides,
/// `required` the slot's requirement. Bands at +0/25/50/100, as the reference's table is indexed.
///
/// The unmet case is the caller's: it takes the *unknown-opener* arm's red and a different
/// GlobalString, so it never reaches here.
pub fn skill_margin_tint(have: u32, required: u32) -> TooltipTint {
match have.saturating_sub(required) {
m if m >= 100 => TooltipTint::SkillGrey,
m if m >= 50 => TooltipTint::LockOpen,
m if m >= 25 => TooltipTint::SkillYellow,
_ => TooltipTint::SkillOrange,
}
}
/// The rank word's GlobalString key, from the builder's table `0x854158` (indexed at `0x52a5bd`
/// and `0x52a5d4`): ranks 1 and 2 (elite, rare elite) are `ELITE`, 3 is `BOSS`, and 0 and 4 (rare)
/// are the empty string `0x882748`.
fn rank_key(rank: u32) -> Option<&'static str> {
match rank {
1 | 2 => Some("ELITE"),
3 => Some("BOSS"),
_ => None,
}
}
/// The level line: level, class and type slots in one of the four `TOOLTIP_UNIT_LEVEL*`
/// GlobalStrings, keyed as the builder keys them (`0x52a622` `_CLASS_TYPE`, `0x52a64d` `_CLASS`,
/// `0x52a682` `_TYPE`, `0x52a6ac` bare), never by their English, which other keys share. `None`
/// when the install has no template for the combination, and then no row shows.
fn level_line(lua: &Lua, u: &UnitState, player_level: u32) -> Option<String> {
let get = |key: &str| crate::strings::global(lua, key);
// "??" by the gate `UnitLevel`'s -1 shares (`0x529fe0`), so tooltip and target frame agree.
let level_text = if level_reads_unknown(u, player_level) {
"??".to_string()
} else {
u.level.to_string()
};
// `CORPSE` when dead, "Race Class" for a player, the creature type only if hostile or neutral.
let class_slot = if u.dead {
get("CORPSE")
} else if u.is_player {
match (&u.race, &u.class) {
(Some(r), Some(c)) => Some(format!("{r} {c}")),
(Some(r), None) => Some(r.clone()),
(None, Some(c)) => Some(c.clone()),
(None, None) => None,
}
} else if u.reaction != 0 && u.reaction <= 4 {
u.creature_type_name.clone()
} else {
None
};
let type_slot = if u.is_player {
get("PLAYER")
} else {
rank_key(u.rank).and_then(&get)
};
let (key, args): (&str, Vec<Arg<'_>>) = match (&class_slot, &type_slot) {
(Some(c), Some(t)) => (
"TOOLTIP_UNIT_LEVEL_CLASS_TYPE",
vec![Arg::S(&level_text), Arg::S(c), Arg::S(t)],
),
(Some(c), None) => (
"TOOLTIP_UNIT_LEVEL_CLASS",
vec![Arg::S(&level_text), Arg::S(c)],
),
(None, Some(t)) => (
"TOOLTIP_UNIT_LEVEL_TYPE",
vec![Arg::S(&level_text), Arg::S(t)],
),
(None, None) => ("TOOLTIP_UNIT_LEVEL", vec![Arg::S(&level_text)]),
};
Some(fill(&get(key)?, &args))
}
/// Render the unit tooltip for `token`'s current snapshot; returns whether the unit existed.
fn render_unit(lua: &Lua, this: &Table, token: &str) -> mlua::Result<bool> {
let h = frame_handle_of(lua, this)?;
let (unit, player_level) = {
let model = lua.app_data_mut::<Model>().expect("model app_data");
(
model.unit(token).cloned().filter(|u| u.exists),
model.player_req.level,
)
};
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
clear_content(&mut model, h);
}
fire_cleared(lua, h);
let Some(u) = unit else {
set_live_token(lua, h, None, None);
show_or_hide_empty(lua, h);
return Ok(false);
};
// The name: `CGUnit_C::GetUnitName` `0x609210` (`0x52a187`), whose every miss falls to
// `UNKNOWNOBJECT`, as `UnitName`'s does. A token with no object never reaches the builder
// (`0x468460`): no plate, and `SetUnit` answers nil.
let title = match &u.name {
Some(n) => n.clone(),
None => unknownobject(lua)?.to_str()?.to_string(),
};
append_line(lua, this, (title, GOLD), None, false)?;
if let Some(sub) = &u.subtitle {
append_line(lua, this, (sub.clone(), WHITE), None, false)?;
}
if let Some(level) = level_line(lua, &u, player_level) {
append_line(lua, this, (level, WHITE), None, false)?;
}
// The faction name, between the level line and "PvP" (`0x52a7a0`); the app applied its gates.
if let Some(faction) = &u.faction_name {
append_line(lua, this, (faction.clone(), WHITE), None, false)?;
}
if u.pvp {
append_line(lua, this, ("PvP".into(), WHITE), None, false)?;
}
if u.skinnable {
append_line(lua, this, ("Skinnable".into(), RED), None, false)?;
}
// "Civilian" (`0x612550`): PvP-flagged, a civilian, hostile, and grey to kill (`0x5f0700`).
if is_civilian_kill(&u, player_level) {
append_line(lua, this, ("Civilian".into(), GREEN), None, false)?;
}
// "Leader" (`0x6125c0`): PvP-flagged and a racial leader, no other gate.
if u.racial_leader && u.pvp {
append_line(lua, this, ("Leader".into(), WHITE), None, false)?;
}
// A zero guid is an unresolved snapshot, not an identity: `UnitGUID` filters it the same way.
set_live_token(
lua,
h,
Some(token.to_string()),
(u.guid != 0).then_some(u.guid),
);
update_bar(lua, h, Some(&u));
show_or_hide_empty(lua, h);
Ok(true)
}
/// Mark the tooltip as world-hover-owned (the fade-on-loss gate).
fn set_world_owned(lua: &Lua, h: FrameHandle) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if let Some(f) = model.arena.frame_mut(h) {
if let KindState::Tooltip(t) = &mut f.kind_state {
t.world_owned = true;
}
}
}
/// Remember (or drop) the tooltip's live unit token, the health watcher's key, together with the
/// guid that token named *right now* — ClassicAPI's `GetUnitGUID` reads the second
/// ([`crate::widget::kinds::TooltipState::unit_guid`]), which is a set-time fact the token itself
/// stops carrying the moment it moves on.
fn set_live_token(lua: &Lua, h: FrameHandle, token: Option<String>, guid: Option<u64>) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if let Some(f) = model.arena.frame_mut(h) {
if let KindState::Tooltip(t) = &mut f.kind_state {
t.unit_token = token;
t.unit_guid = guid;
}
}
}
/// Drive the `<name>StatusBar` child from a unit snapshot (`None` hides it); FrameXML's
/// `HealthBar_OnValueChanged` colours the fill.
fn update_bar(lua: &Lua, h: FrameHandle, unit: Option<&UnitState>) {
let bar = {
let model = lua.app_data_mut::<Model>().expect("model app_data");
let name = model.arena.frame(h).and_then(|f| f.name.clone());
name.and_then(|n| model.arena.lookup(&format!("{n}StatusBar")))
};
let Some(bar) = bar else { return };
let (changed, shown) = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(f) = model.arena.frame_mut(bar) else {
return;
};
match (&mut f.kind_state, unit) {
(KindState::StatusBar(sb), Some(u)) => {
sb.min = 0.0;
sb.max = u.max_health.max(1) as f32;
let v = u.health.min(u.max_health.max(1)) as f32;
let changed = (sb.value - v).abs() > f32::EPSILON;
sb.value = v;
(changed, true)
}
_ => (false, false),
}
};
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.arena.set_shown(bar, shown);
}
if changed {
let (id, value) = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let v = match model.arena.frame(bar).map(|f| &f.kind_state) {
Some(KindState::StatusBar(sb)) => sb.value,
_ => 0.0,
};
(model.frame_id(bar), v)
};
if let Err(e) = super::event::fire_widget_handler(
lua,
id,
"OnValueChanged",
vec![mlua::Value::Number(f64::from(value))],
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.record_script_error(e.to_string());
}
}
}
/// A `set_unit` push for a tooltip's live token updates its health bar without rebuilding the
/// lines, as the reference's HEALTH field watcher does.
pub(super) fn on_unit_push(lua: &Lua, token: &str) {
let hits: Vec<FrameHandle> = {
let model = lua.app_data_mut::<Model>().expect("model app_data");
// Registered frames only, as `update_bar` writes layout.
model
.arena
.tooltip_kinds()
.iter()
.copied()
.filter(|&h| {
model.frame_to_id.contains_key(&h)
&& matches!(
model.arena.frame(h).map(|f| &f.kind_state),
Some(KindState::Tooltip(t)) if t.unit_token.as_deref() == Some(token)
)
})
.collect()
};
for h in hits {
let unit = {
let model = lua.app_data_mut::<Model>().expect("model app_data");
model.unit(token).cloned().filter(|u| u.exists)
};
update_bar(lua, h, unit.as_ref());
}
}
impl super::UiScript {
/// Show the world-mouseover unit tooltip: fire `OnTooltipSetDefaultAnchor`, render `token`,
/// fire `UPDATE_MOUSEOVER_UNIT`. Call on a hover-target change; a re-show mid-fade returns to
/// full alpha. `false` when there is no tooltip frame or no such unit.
pub fn world_tooltip_unit(&mut self, token: &str) -> bool {
let h = {
let mut model = self.model_mut();
let Some(h) = model.arena.lookup("GameTooltip") else {
return false;
};
let id = model.frame_id(h);
(h, id)
};
let (h, id) = h;
// Retire the old plate before building the new one, so a rebuild over a tooltip that is
// STILL SHOWN raises `OnHide`/`OnShow` rather than silently swapping its contents.
//
// This call is already once-per-subject-change (`ui_tooltip`'s driver gates it on the
// hovered guid and the line set), so this is a hide/show pair per new subject, not per
// frame, and both happen before the frame paints — there is no flicker.
//
// Why it matters: `render_unit` calls `clear_content`, which wipes every line — including
// the ones an addon appended on the previous `OnShow`. Without the hide, `show_or_hide_
// empty` is a no-op on an already-shown frame, so the addon is never told to put them
// back. benilla publishes a mouseover from a V-key plate as well as from the body
// (`target/hover.rs`, the reference's plate `OnEnter` at `0x7cb850`), so moving between a
// mob and its own nameplate is exactly this case — and it is why hovering from empty sky
// worked while mob ↔ nameplate did not. pfQuest's objective annotation
// (`pfQuest/map.lua:253`) and pfUI's own `GetUnitGUID` reader (`modules/tooltip.lua:81`)
// both hang off `OnShow`.
super::tooltip::set_shown(&self.lua, h, false);
if let Err(e) =
super::event::fire_widget_handler(&self.lua, id, "OnTooltipSetDefaultAnchor", vec![])
{
self.push_error(e);
}
let wrapper = match super::object::frame_wrapper(&self.lua, id) {
Ok(w) => w,
Err(e) => {
self.push_error(e);
return false;
}
};
let shown = match render_unit(&self.lua, &wrapper, token) {
Ok(s) => s,
Err(e) => {
self.push_error(e);
false
}
};
if shown {
set_world_owned(&self.lua, h);
let mut model = self.model_mut();
if let Some(f) = model.arena.frame_mut(h) {
if let KindState::Tooltip(t) = &mut f.kind_state {
if t.fade_start.take().is_some() {
model.arena.set_alpha(h, 1.0);
}
}
}
drop(model);
self.fire_event("UPDATE_MOUSEOVER_UNIT", vec![]);
}
shown
}
/// Show the world-mouseover GameObject tooltip (builder `0x52aa20`): the name in gold, then
/// the caller's lock lines. The anchor forks on the object's `[vtbl+0x5c]`: true (`0x492a01`)
/// seats it at the cursor, anchor state 6 (`0x52ffe0(owner, 6, 0, 0)`); false (`0x492a42`)
/// keeps the default corner, as units (`0x492983`) always do. `cursor` is that verdict, and
/// [`Self::world_tooltip_move`] follows a cursor-seated plate. Hover loss arms the unit flow's
/// fade, where the reference's loss path (`0x530ae0`) hides an anchor-state-6 plate at once.
///
/// `object_id` is the hovered object's `OBJECT_FIELD_ENTRY`, recorded as the tooltip's subject
/// for ClassicAPI's `GameTooltip:GetGameObject`
/// ([`crate::widget::TooltipSubject::GameObject`]) — a set-time fact, as `GetUnitGUID`'s guid
/// is, since the plate outlives the hover that built it. `None` records none, which is the
/// corpse plate: it borrows this builder for its single gold line and names no object.
pub fn world_tooltip_gameobject(
&mut self,
name: &str,
object_id: Option<u32>,
lines: &[(String, TooltipTint)],
cursor: Option<(f32, f32)>,
) -> bool {
let (h, id, root, root_id) = {
let mut model = self.model_mut();
let Some(h) = model.arena.lookup("GameTooltip") else {
return false;
};
let Some(root) = model.arena.lookup("UIParent") else {
return false;
};
let (id, root_id) = (model.frame_id(h), model.frame_id(root));
(h, id, root, root_id)
};
match cursor {
// The cursor arm: centred above the pointer; compare-then-touch, so a still pointer
// never re-layouts.
Some((ui_x, ui_y)) => {
let mut model = self.model_mut();
// The owner: this arm is the SetOwner core (`0x52ffe0`), which stores it at
// `+0x314` (`0x53000c`); the other arms get theirs from FrameXML's
// `OnTooltipSetDefaultAnchor`. Without one, any Lua `GameTooltip:Show()` hides the
// plate: `0x530a80` shows only when `+0x314` and `+0x31c` are both set, else it
// takes `0x530a60`.
if let Ok(t) = tip_mut(&mut model, h) {
t.owner = Some(root);
}
let input = model.layout_inputs.entry(h).or_default();
let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y);
let same = input.anchors.len() == 1
&& super::object::anchor_bits_eq(&input.anchors[0], &new);
if !same {
input.anchors = vec![new];
model.touch_layout();
}
}
// The corner arm: the default-anchor handler the unit flow fires too.
None => {
if let Err(e) = super::event::fire_widget_handler(
&self.lua,
id,
"OnTooltipSetDefaultAnchor",
vec![],
) {
self.push_error(e);
}
}
}
let wrapper = match super::object::frame_wrapper(&self.lua, id) {
Ok(w) => w,
Err(e) => {
self.push_error(e);
return false;
}
};
let render = || -> mlua::Result<()> {
{
let mut model = self.lua.app_data_mut::<Model>().expect("model app_data");
clear_content(&mut model, h);
}
fire_cleared(&self.lua, h);
append_line(&self.lua, &wrapper, (name.to_string(), GOLD), None, false)?;
for (text, tint) in lines {
let colour = match tint {
TooltipTint::White => WHITE,
TooltipTint::Red => RED,
TooltipTint::LockOpen => LOCK_OPEN,
TooltipTint::SkillGrey => SKILL_GREY,
TooltipTint::SkillYellow => SKILL_YELLOW,
TooltipTint::SkillOrange => SKILL_ORANGE,
};
append_line(&self.lua, &wrapper, (text.clone(), colour), None, false)?;
}
Ok(())
};
if let Err(e) = render() {
self.push_error(e);
return false;
}
set_world_owned(&self.lua, h);
{
let mut model = self.model_mut();
if let Some(f) = model.arena.frame_mut(h) {
if let KindState::Tooltip(t) = &mut f.kind_state {
// After `render`, which goes through `clear_content` and so nils the subject
// along with the lines it described.
t.subject =
object_id.map(|object_id| crate::widget::TooltipSubject::GameObject {
object_id,
name: name.to_string(),
});
if t.fade_start.take().is_some() {
model.arena.set_alpha(h, 1.0);
}
}
}
}
show_or_hide_empty(&self.lua, h);
true
}
/// Show the minimap blip tooltip (an `AreaPOI` or quest-dot name): one gold line, faint gold
/// for a cross-interior dot, its bottom at the given UI point above the cursor.
/// [`Self::world_tooltip_move`] follows the pointer, and hover loss arms
/// [`Self::world_tooltip_fade`].
pub fn minimap_tooltip(&mut self, text: &str, ui_x: f32, ui_y: f32, grey: bool) -> bool {
let (h, id, root_id) = {
let mut model = self.model_mut();
let Some(h) = model.arena.lookup("GameTooltip") else {
return false;
};
let Some(root) = model.arena.lookup("UIParent") else {
return false;
};
let (id, root_id) = (model.frame_id(h), model.frame_id(root));
(h, id, root_id)
};
let wrapper = match super::object::frame_wrapper(&self.lua, id) {
Ok(w) => w,
Err(e) => {
self.push_error(e);
return false;
}
};
{
let mut model = self.model_mut();
clear_content(&mut model, h);
let input = model.layout_inputs.entry(h).or_default();
// Anchor only: the frame's own clamp flag (`Frame::clamped_to_screen`, G flags bit 4)
// slides a plate near the window edge back on screen.
let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y);
// Compare-then-touch, so a still cursor does not dirty the layout.
let same =
input.anchors.len() == 1 && super::object::anchor_bits_eq(&input.anchors[0], &new);
if !same {
input.anchors = vec![new];
model.touch_layout();
}
}
fire_cleared(&self.lua, h);
// A cross-interior entry's `|cffb0b0b0` wrap modulates the gold: faint gold, not grey.
let dim = 0xb0 as f32 / 255.0;
let color = if grey {
[GOLD[0] * dim, GOLD[1] * dim, GOLD[2] * dim, 1.0]
} else {
GOLD
};
if let Err(e) = append_line(&self.lua, &wrapper, (text.to_string(), color), None, false) {
self.push_error(e);
return false;
}
set_world_owned(&self.lua, h);
{
let mut model = self.model_mut();
if let Some(f) = model.arena.frame_mut(h) {
if let KindState::Tooltip(t) = &mut f.kind_state {
if t.fade_start.take().is_some() {
model.arena.set_alpha(h, 1.0);
}
}
}
}
show_or_hide_empty(&self.lua, h);
true
}
/// Re-seat the shown world-owned tooltip at a new pointer point, anchor only; else a no-op.
pub fn world_tooltip_move(&mut self, ui_x: f32, ui_y: f32) {
let mut model = self.model_mut();
let Some(h) = model.arena.lookup("GameTooltip") else {
return;
};
let Some(root) = model.arena.lookup("UIParent") else {
return;
};
let owned_shown = model
.arena
.frame(h)
.map(|f| matches!(&f.kind_state, KindState::Tooltip(t) if t.world_owned) && f.shown)
.unwrap_or(false);
if !owned_shown {
return;
}
let root_id = model.frame_id(root);
let input = model.layout_inputs.entry(h).or_default();
let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y);
// Compare-then-touch: this runs on every pointer event.
let same =
input.anchors.len() == 1 && super::object::anchor_bits_eq(&input.anchors[0], &new);
if !same {
input.anchors = vec![new];
model.touch_layout();
}
}
/// Whether `GameTooltip` is still the shown plate a world hover put up, with no Lua
/// `SetOwner`, content `Set*` or `Hide` since.
pub fn world_tooltip_up(&mut self) -> bool {
let model = self.model_mut();
let Some(h) = model.arena.lookup("GameTooltip") else {
return false;
};
model
.arena
.frame(h)
.map(|f| matches!(&f.kind_state, KindState::Tooltip(t) if t.world_owned) && f.shown)
.unwrap_or(false)
}
/// On hover loss, arm the world tooltip's timed fade (the reference's unit plate fades rather
/// than hiding); a no-op unless a world-owned plate is shown.
pub fn world_tooltip_fade(&mut self) {
let now = self.now();
let mut model = self.model_mut();
let Some(h) = model.arena.lookup("GameTooltip") else {
return;
};
if let Some(f) = model.arena.frame_mut(h) {
if let KindState::Tooltip(t) = &mut f.kind_state {
if t.world_owned && f.shown && t.fade_start.is_none() {
t.fade_start = Some(now);
}
}
}
}
}
/// Register the unit content channel into the GameTooltip kind method table.
pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
// GameTooltip:SetUnit(token) → 1 or nil, which `UnitFrame_OnEnter` reads to arm its re-poll.
m.set(
"SetUnit",
lua.create_function(|lua, (this, token): (Table, String)| {
// The token gate every `Unit*` verb uses: an unknown token raises. Not in
// `render_unit`, which the app calls with canonical tokens.
crate::script::unit::check_unit_token(&Some(token.clone()))?;
let ok = render_unit(lua, &this, &token)?;
Ok(if ok {
mlua::Value::Integer(1)
} else {
mlua::Value::Nil
})
})?,
)?;
Ok(())
}