benilla: initial public release
A from-scratch World of Warcraft 1.12.1 client in Rust + Bevy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,537 @@
|
||||
//! The engine **unit tooltip builder** (decision 0274 P3) — the byte-verified line law of
|
||||
//! `0x529fe0` (wow-re `ui/scratch/tooltip-content-law.md`, the 0276 fold-back):
|
||||
//!
|
||||
//! - NAME (gold — FrameXML recolors `TextLeft1` by reaction on `UPDATE_MOUSEOVER_UNIT`, exactly
|
||||
//! like the reference's `GameTooltip_UnitColor`; the guild line is likewise FrameXML's and
|
||||
//! joins when guild data streams);
|
||||
//! - the creature SUBTITLE ("Stable Master") — white;
|
||||
//! - the LEVEL line, composed from three slots over the four `TOOLTIP_UNIT_LEVEL*` templates:
|
||||
//! level text (`"??"` for a world boss, a much-higher hostile, or level ≤ 0 — the hostile
|
||||
//! delta is INTERIM at +10 pending a byte pin of the comparison), the class slot (the creature
|
||||
//! TYPE word for hostile/neutral creatures, `"Race Class"` for players, `"Corpse"` when dead),
|
||||
//! and the type slot (the rank word `{"", Elite, Elite, Boss, ""}`; `"Player"` for players);
|
||||
//! - the FACTION NAME ("Stormwind", white) — the builder-tail block `0x52a7a0..` the law's §2
|
||||
//! order originally omitted: the app resolves it (`faction_name`), every gate applied;
|
||||
//! - "PvP" (white) · "Skinnable" (**red**) · "Civilian" (green, `0x612550`: PvP-flagged +
|
||||
//! query-civilian + HOSTILE + GREY/trivial — the dishonorable-kill warning) · "Leader"
|
||||
//! (white, `0x6125c0`: PvP-flagged + query racial_leader);
|
||||
//! - health on the attached status bar (`<name>StatusBar`, the template child below the plate),
|
||||
//! refreshed by every `set_unit` push for the live token — the byte law's HEALTH watcher.
|
||||
//!
|
||||
//! The world mouseover drives this through [`super::UiScript::world_tooltip_unit`] (the engine
|
||||
//! shows the tooltip at the default anchor by firing `OnTooltipSetDefaultAnchor`, renders, and
|
||||
//! fires `UPDATE_MOUSEOVER_UNIT` for the Lua recolor) and [`super::UiScript::world_tooltip_fade`]
|
||||
//! (the byte law ARMS a fade on hover loss, not an instant hide). Unit-frame hovers call the
|
||||
//! same `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};
|
||||
// The grey band + trivial/GREY check (`0x5f0700`, the CIVILIAN line's last gate: a green-or-
|
||||
// better con never warns of a dishonorable kill) and the "??" gate live in one shared home
|
||||
// (`unit.rs`), alongside `UnitLevel`'s −1 return and the `GetQuestGreenRange` binding.
|
||||
use super::unit::{level_reads_unknown, unit_is_grey};
|
||||
use super::{KindState, Model, UnitState};
|
||||
use crate::layout::{Anchor, Point};
|
||||
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];
|
||||
/// Unit names render gold (byte-verified `0xffffd200`); FrameXML recolors line 1 by reaction.
|
||||
const GOLD: [f32; 4] = [1.0, 210.0 / 255.0, 0.0, 1.0];
|
||||
|
||||
/// The rank word table — byte-verified `0x854158[]`: rare-elite prints ELITE, rare prints
|
||||
/// nothing (there is no distinct "Rare Elite" word in 1.12's builder).
|
||||
fn rank_word(rank: u32) -> Option<&'static str> {
|
||||
match rank {
|
||||
1 | 2 => Some("Elite"),
|
||||
3 => Some("Boss"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The level line — three slots over the four `TOOLTIP_UNIT_LEVEL*` templates ("Level %s" /
|
||||
/// "Level %s %s" / "Level %s (%s)" / "Level %s %s (%s)", the extracted enUS strings).
|
||||
fn level_line(u: &UnitState, player_level: u32) -> String {
|
||||
// The "??" gate, byte-pinned (0x529fe0 §2-LEVEL): much-higher HOSTILE — internal reaction
|
||||
// ≤ 1 = hated/hostile = UnitReaction ≤ 2 on our 1..8 API scale (the UnitIsEnemy mapping) —
|
||||
// with playerLevel ≤ targetLevel−10; OR WorldBoss; OR level ≤ 0. Players NEVER read "??".
|
||||
// Shared with `UnitLevel`'s −1 return, so the tooltip and the target frame can never
|
||||
// disagree on who reads "??" ([`level_reads_unknown`]).
|
||||
let level_text = if level_reads_unknown(u, player_level) {
|
||||
"??".to_string()
|
||||
} else {
|
||||
u.level.to_string()
|
||||
};
|
||||
// The class slot: "Race Class" for players; the creature TYPE word for hostile/neutral
|
||||
// creatures (a friendly creature shows none — the byte law's hostile/neutral-only gate);
|
||||
// "Corpse" when dead.
|
||||
let class_slot = if u.dead {
|
||||
Some("Corpse".to_string())
|
||||
} 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
|
||||
};
|
||||
// The type slot: the rank word, or "Player" for players.
|
||||
let type_slot = if u.is_player {
|
||||
Some("Player".to_string())
|
||||
} else {
|
||||
rank_word(u.rank).map(str::to_string)
|
||||
};
|
||||
match (class_slot, type_slot) {
|
||||
(Some(c), Some(t)) => format!("Level {level_text} {c} ({t})"),
|
||||
(Some(c), None) => format!("Level {level_text} {c}"),
|
||||
(None, Some(t)) => format!("Level {level_text} ({t})"),
|
||||
(None, None) => format!("Level {level_text}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.units.get(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 {
|
||||
// No unit: drop the live token and leave the tooltip hidden-empty.
|
||||
set_live_token(lua, h, None);
|
||||
show_or_hide_empty(lua, h);
|
||||
return Ok(false);
|
||||
};
|
||||
append_line(
|
||||
lua,
|
||||
this,
|
||||
(u.name.clone().unwrap_or_default(), GOLD),
|
||||
None,
|
||||
false,
|
||||
)?;
|
||||
if let Some(sub) = &u.subtitle {
|
||||
append_line(lua, this, (sub.clone(), WHITE), None, false)?;
|
||||
}
|
||||
append_line(
|
||||
lua,
|
||||
this,
|
||||
(level_line(&u, player_level), WHITE),
|
||||
None,
|
||||
false,
|
||||
)?;
|
||||
// The faction-name line ("Stormwind", white) sits between the level line and "PvP" — the
|
||||
// builder-tail block at `0x52a7a0`. The app resolved every gate into `faction_name`.
|
||||
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 (green) — `0x612550`, whole: the unit's PvP bit + the query civilian flag + the
|
||||
// unit is HOSTILE to the player (UnitReaction ≤ 2 — internal reaction < 2) + the kill would
|
||||
// be GREY/trivial. PVP_RANK_CIVILIAN = "Civilian" (extracted GlobalStrings).
|
||||
if u.civilian
|
||||
&& u.pvp
|
||||
&& u.reaction != 0
|
||||
&& u.reaction <= 2
|
||||
&& unit_is_grey(player_level, u.level)
|
||||
{
|
||||
append_line(lua, this, ("Civilian".into(), GREEN), None, false)?;
|
||||
}
|
||||
// LEADER (white) — `0x6125c0`: the PvP bit + the query racial_leader flag, no other gate.
|
||||
// PVP_RANK_LEADER = "Leader" (extracted GlobalStrings).
|
||||
if u.racial_leader && u.pvp {
|
||||
append_line(lua, this, ("Leader".into(), WHITE), None, false)?;
|
||||
}
|
||||
set_live_token(lua, h, Some(token.to_string()));
|
||||
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.
|
||||
fn set_live_token(lua: &Lua, h: FrameHandle, token: Option<String>) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive the `<name>StatusBar` child from a unit snapshot (`None` hides it) — the engine pushes
|
||||
/// health, FrameXML's `HealthBar_OnValueChanged` colors 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")
|
||||
.errors
|
||||
.push(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `set_unit` push hook: a fresh snapshot for the tooltip's LIVE token re-drives the health
|
||||
/// bar (the byte law's HEALTH UpdateField watcher — the bar tracks without a line rebuild).
|
||||
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");
|
||||
model
|
||||
.frame_to_id
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|&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.units.get(token).cloned().filter(|u| u.exists)
|
||||
};
|
||||
update_bar(lua, h, unit.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
impl super::UiScript {
|
||||
/// Show the world-mouseover unit tooltip (the engine-driven flow the byte law describes):
|
||||
/// fire `OnTooltipSetDefaultAnchor` (FrameXML seats the plate at the default corner), render
|
||||
/// `token`'s snapshot, fire `UPDATE_MOUSEOVER_UNIT` (FrameXML recolors the name line by
|
||||
/// reaction). Call on hover-target CHANGE — the health bar tracks pushes in between; a
|
||||
/// re-show mid-fade resurrects at full alpha. `false` = no tooltip frame / 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;
|
||||
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);
|
||||
// Cancel any running fade at full alpha (a re-hover resurrects).
|
||||
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 — the byte-verified GO builder `0x52aa20`
|
||||
/// (decision 0276): the NAME (gold) + the red lock-requirement lines ("Requires <key>",
|
||||
/// the Lock.dbc chain) when the object is flag-locked. Seated centred ABOVE the cursor and
|
||||
/// clamped to the screen, like the minimap blip tooltip — the reference's signpost hover
|
||||
/// shows the engine plate AT the pointer, not the FrameXML corner (director's reference
|
||||
/// shot, 2026-07-13, superseding 0281's "units + GOs → corner" row for the GO half; the
|
||||
/// exact engine anchor law is INTERIM pending its pin). Follows the pointer via
|
||||
/// [`Self::world_tooltip_move`]. Same fade lifecycle as the unit flow (no
|
||||
/// `UPDATE_MOUSEOVER_UNIT` — that recolor is the unit line's).
|
||||
pub fn world_tooltip_gameobject(
|
||||
&mut self,
|
||||
name: &str,
|
||||
requirements: &[String],
|
||||
ui_x: f32,
|
||||
ui_y: f32,
|
||||
) -> 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 mut model = self.model_mut();
|
||||
let input = model.layout_inputs.entry(h).or_default();
|
||||
// Anchor only — the screen clamp is the tooltip FRAME's own flag
|
||||
// (`Frame::clamped_to_screen`), synced with live extents at every resolve.
|
||||
input.anchors = vec![Anchor::new(
|
||||
Point::Bottom,
|
||||
root_id,
|
||||
Point::BottomLeft,
|
||||
ui_x,
|
||||
ui_y,
|
||||
)];
|
||||
}
|
||||
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 req in requirements {
|
||||
append_line(&self.lua, &wrapper, (req.clone(), RED), 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 {
|
||||
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 — a landmark's `AreaPOI` name or a quest-dot NPC's name,
|
||||
/// one GOLD line (the reference's engine SetText gold; a cross-interior dot renders FAINT
|
||||
/// gold — the byte law's `|cffb0b0b0` wrap modulating the gold base, director-matched),
|
||||
/// seated centred ABOVE the cursor: the tooltip's BOTTOM at the given UI-space point. The
|
||||
/// plate FOLLOWS the pointer — [`Self::minimap_tooltip_move`] re-seats it as the cursor
|
||||
/// drifts within one blip. Same world-owned fade lifecycle as the mouseover tooltip:
|
||||
/// 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 screen clamp (G flags bit4: a cursor-seated plate near the window
|
||||
// edge slides back in instead of clipping, director's report at the screen-right
|
||||
// minimap) is the tooltip FRAME's own flag (`Frame::clamped_to_screen`), synced with
|
||||
// live extents at every resolve.
|
||||
input.anchors = vec![Anchor::new(
|
||||
Point::Bottom,
|
||||
root_id,
|
||||
Point::BottomLeft,
|
||||
ui_x,
|
||||
ui_y,
|
||||
)];
|
||||
}
|
||||
fire_cleared(&self.lua, h);
|
||||
// The blip name renders GOLD like the reference (the engine tooltip's SetText line —
|
||||
// the same 0xffffd200 the unit-name line uses); a cross-interior entry renders the
|
||||
// byte law's `|cffb0b0b0` wrap MODULATING that gold — faint gold, not flat grey
|
||||
// (director-matched against the reference, 2026-07-13).
|
||||
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 showing cursor-anchored tooltip at a new pointer point (the follow half of
|
||||
/// [`Self::minimap_tooltip`] and [`Self::world_tooltip_gameobject`]): anchor-only — no
|
||||
/// content rebuild, no fade churn. No-op when the tooltip isn't the world-owned shown plate.
|
||||
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();
|
||||
// Anchor only — the frame's own clamp flag keeps the plate on screen (see
|
||||
// `Frame::clamped_to_screen`).
|
||||
input.anchors = vec![Anchor::new(
|
||||
Point::Bottom,
|
||||
root_id,
|
||||
Point::BottomLeft,
|
||||
ui_x,
|
||||
ui_y,
|
||||
)];
|
||||
}
|
||||
|
||||
/// Arm the mouseover tooltip's fade-out (hover loss — the byte law arms a timestamped fade,
|
||||
/// never an instant hide). No-op when the tooltip isn't showing a world unit.
|
||||
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/nil — the unit-frame hover (ref UnitFrame_OnEnter reads
|
||||
// the return to arm its re-poll).
|
||||
m.set(
|
||||
"SetUnit",
|
||||
lua.create_function(|lua, (this, token): (Table, String)| {
|
||||
let ok = render_unit(lua, &this, &token)?;
|
||||
Ok(if ok {
|
||||
mlua::Value::Integer(1)
|
||||
} else {
|
||||
mlua::Value::Nil
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user