grom reports a frame drop on hovering signs, and asked for that one first. A signpost is the only GameObject whose plate follows the cursor — GO_TYPE_GENERIC with floating_tooltip, the `cursor_seated` leg of drive_mouseover_tooltip — so every frame the hand on the mouse moves, GameTooltip is re-anchored. That re-anchor called the whole-graph Model::touch_layout for a write that moves two offsets and nothing else. Measured on his own interface, not guessed at: a new bench window prices a held plate against a drifting one against no plate at all. A drifting pointer cost +8.6 ms a frame over the baseline; a held one cost +0.0, so the plate is not the cost, the re-anchor is. It is now +0.2 ms. tooltip_unit::seat_on_cursor is the shared seat for both arms (the raise in world_tooltip_gameobject and the follow in world_tooltip_move). An offset-only move names the frame — touch_layout_frame, exactly as SetPoint's own write_anchor does for a dragged window — and only a roster or target change gives up naming. The bench asserts the ratio rather than the absolute, so a loaded machine scales every window together, and reads GetLeft at the end: a "fix" that stopped the plate moving would price identically, and this test would otherwise applaud it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
654 lines
28 KiB
Rust
654 lines
28 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);
|
|
}
|
|
seat_on_cursor(&mut model, h, root_id, ui_x, ui_y);
|
|
}
|
|
// 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);
|
|
// Compare-then-touch, and when it does touch, name the frame: this runs on every pointer
|
|
// event, which is what made it the hover path's whole cost. See [`seat_on_cursor`].
|
|
seat_on_cursor(&mut model, h, root_id, ui_x, ui_y);
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Re-seat a plate on the pointer: one anchor, `Bottom` to `UIParent`'s `BottomLeft`.
|
|
///
|
|
/// An **offset-only** move takes [`Model::touch_layout_frame`], never the whole-graph
|
|
/// [`Model::touch_layout`]. The target, both points and the roster are all unchanged, which is the
|
|
/// exact case `SetPoint`'s own `write_anchor` calls value-only — a dragged window moves this way
|
|
/// every frame and has always been cheap; this path was the one that re-derived.
|
|
///
|
|
/// What that cost: a signpost is the one GameObject whose plate follows the cursor
|
|
/// (`GO_TYPE_GENERIC` with `floating_tooltip`, `ui_tooltip::drive_mouseover_tooltip`), so hovering
|
|
/// one re-anchors `GameTooltip` every frame the hand on the mouse moves. Measured on grom's
|
|
/// interface (`addon_harness::tick_bench_tests::a_cursor_seated_sign_plate_prices_its_own_re_anchor`)
|
|
/// the full derive cost **+8.6 ms a frame** over the same interface with no plate up, against a
|
|
/// held-still plate's +0.0 — which is his "frame drops on hovering signs", and nothing else in the
|
|
/// hover path showed a cost at all.
|
|
///
|
|
/// Returns without touching anything when the pointer has not moved, as before.
|
|
fn seat_on_cursor(model: &mut Model, h: FrameHandle, root_id: u32, ui_x: f32, ui_y: f32) {
|
|
let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y);
|
|
let value_only = {
|
|
let input = model.layout_inputs.entry(h).or_default();
|
|
let verdict = match &input.anchors[..] {
|
|
// A still pointer never re-layouts.
|
|
[only] if super::object::anchor_bits_eq(only, &new) => return,
|
|
[only] => {
|
|
only.point == new.point
|
|
&& only.relative_to == new.relative_to
|
|
&& only.relative_point == new.relative_point
|
|
}
|
|
// Seated by another arm, or not seated at all: the roster changes, so the graph has
|
|
// to be derived in full. This is the raise, which happens once per hover.
|
|
_ => false,
|
|
};
|
|
input.anchors = vec![new];
|
|
verdict
|
|
};
|
|
if value_only {
|
|
model.touch_layout_frame(h);
|
|
} else {
|
|
model.touch_layout();
|
|
}
|
|
}
|
|
|
|
/// 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(())
|
|
}
|