fix: show spell tooltips whole on the first hover (#472)

- Spell tooltips for pet spells, quest reward spells, Beast Training, enchant links and pet or target-of-target debuffs show whole on the first hover
- The spell-tooltip tests' doc comments say the one thing the test name does not, in a line
- The spell feed builds each view once per frame, the pet-bar Firebolt test slot is one helper, and the wait's comment reads in one pass
- A spell tooltip completed by its view fires no second OnTooltipCleared: one setter call, one event, as in 1.12.1
- The spell feed reads every aura and the tracking spell from the VM alone, dropping the three app-side aura reads that duplicated it
- The spell feed reads the auras from the VM's player list and every guid's list, the aura model main moved to
- A spell tooltip waiting on its view re-renders only while it still shows what the miss drew, so a line Lua rewrote in place, by AppendText or a cell's SetTextColor, is kept
- The spell tooltip's one-frame re-render is stated as a known difference from the reference, which builds every spell tooltip at the call

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
This commit is contained in:
Mathih13
2026-09-28 17:36:21 +07:00
committed by GitHub
co-authored by Claude Opus 5.5 Sam
parent ae6d9f3500
commit db94ea038e
8 changed files with 673 additions and 46 deletions
-7
View File
@@ -79,13 +79,6 @@ pub(crate) struct PlayerAuraCache {
auras: Vec<CachedAura>,
}
impl PlayerAuraCache {
/// The live aura spell ids, pre-fed to `ui_tooltip` for a buff-bar hover's `SetPlayerBuff`.
pub(crate) fn spell_ids(&self) -> impl Iterator<Item = u32> + '_ {
self.auras.iter().map(|a| a.spell_id)
}
}
/// [`feed_auras`] fires `PLAYER_AURAS_CHANGED` and `UNIT_AURA` inline, so a feed whose state their
/// handlers re-read (the stance feed) runs `.before(AuraEvents)` or they read last frame's.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
+1 -1
View File
@@ -389,7 +389,7 @@ fn panel_event(panel: QuestPanel) -> &'static str {
/// Push the quest view into the VM and fire its events: a panel's open event on a new panel,
/// `QUEST_ITEM_UPDATE` on a content change, `QUEST_FINISHED` on close.
fn feed_quest(
pub(crate) fn feed_quest(
script: Option<NonSendMut<UiScript>>,
mut giver: ResMut<QuestGiver>,
objects: crate::net::Objects,
+17 -29
View File
@@ -26,11 +26,13 @@ impl Plugin for UiTooltipPlugin {
Update,
(
drive_mouseover_tooltip.in_set(UnitFeed),
// After the trainer feed, so a list that lands this frame is hoverable in its tick;
// outside `UnitFeed`, which the trainer feed follows, so it takes that set's gate.
// After the trainer and quest feeds, so a list that lands this frame is hoverable
// in its tick; outside `UnitFeed`, which the trainer feed follows, so it takes that
// set's gate.
feed_spell_tooltips
.in_set(UiFeed)
.after(TrainerFeed)
.after(crate::ui_quest::feed_quest)
.run_if(crate::ui_script::ingame_ui_up),
),
);
@@ -331,15 +333,13 @@ struct SpellTooltipSources<'w> {
}
/// Push a view for every spell the UI can hover (the book, the class's talent ranks, the open
/// trainer's services, the auras) before it is hovered, as the reference reads them all locally;
/// an ask for any other id too.
/// trainer's services, and what the VM holds: the pet's spells, the quest rewards, the craft's
/// subjects, every unit's auras and the tracking spell) before it is hovered, as the reference
/// reads them all locally; an ask for any other id too.
fn feed_spell_tooltips(
script: Option<NonSendMut<UiScript>>,
actions: Option<Res<PlayerActions>>,
spell_sources: SpellTooltipSources,
auras: Option<Res<crate::ui_aura::PlayerAuraCache>>,
selection: Res<crate::target::Selection>,
stores: Query<&ObjectStore>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
// The auto-attack target, the melee range cell's second reach.
engaged_q: Query<&crate::creature_anim::Engaged, With<SelfPlayer>>,
@@ -369,6 +369,13 @@ fn feed_spell_tooltips(
return;
};
let mut wanted: Vec<u32> = script.take_spell_tooltip_asks();
// What the VM holds for a hover: pet spells, quest rewards, craft subjects, auras, tracking.
wanted.extend(
script
.spell_tooltip_subjects()
.into_iter()
.filter(|s| !memory.pushed.contains(s)),
);
if let Some(actions) = actions.as_deref() {
wanted.extend(
actions
@@ -403,28 +410,6 @@ fn feed_spell_tooltips(
}
}
}
if let Some(auras) = auras.as_deref() {
wanted.extend(auras.spell_ids().filter(|s| !memory.pushed.contains(s)));
}
// `SetTrackingSpell`: the display cache leaves the tracking aura out, so read the raw auras.
if let Ok(store) = self_q.single() {
wanted.extend(
store
.0
.unit_auras()
.map(|a| a.spell_id)
.filter(|s| !memory.pushed.contains(s)),
);
}
if let Some(store) = selection.target.and_then(|e| stores.get(e).ok()) {
wanted.extend(
store
.0
.unit_auras()
.map(|a| a.spell_id)
.filter(|s| !memory.pushed.contains(s)),
);
}
let home_area: Option<String> = home_bind
.as_deref()
.and_then(|b| b.0)
@@ -497,6 +482,9 @@ fn feed_spell_tooltips(
memory.reagents = reagent_state;
wanted.extend(memory.pushed.drain());
}
// The sources overlap (a self-buff is in the book and on the player): build each id once.
wanted.sort_unstable();
wanted.dedup();
// Build, then push: the build's borrow of the VM's strings ends before the store is written.
let mut built: Vec<(u32, benilla_ui::script::SpellTooltipView)> = Vec::new();
{
+123
View File
@@ -675,3 +675,126 @@ fn the_mouseover_token_clears_when_no_unit_is_hovered() {
assert_eq!(named(&mut app), (None, None), "after {what}");
}
}
/// A quest panel that opens this frame is hoverable in its tick.
#[test]
fn the_spell_feed_runs_after_the_quest_feed() {
let mut app = crate::game_plugins::schedule_tests::headless_client();
assert!(crate::test_support::runs_before(
&mut app,
crate::ui_quest::feed_quest,
feed_spell_tooltips
));
}
/// The pet bar, Beast Training and target-of-target hovers are whole on the first hover.
#[test]
fn the_feed_pushes_the_spells_the_vm_holds_before_a_hover() {
use benilla_ui::script::{
AuraState, CraftRecipe, CraftState, CraftTooltip, PetActionView, TradeSkillDifficulty,
};
let spell = |name: &str, description: &str| benilla_formats::SpellDisplay {
name: name.into(),
description: Some(description.into()),
..Default::default()
};
let catalog = std::collections::HashMap::from([
(3110, spell("Firebolt", "Deals Fire damage.")),
(17253, spell("Bite", "Bite the enemy.")),
(589, spell("Shadow Word: Pain", "Shadow damage over time.")),
]);
let (tx, _rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.insert_resource(Spells {
catalog: benilla_formats::SpellCatalog::from_displays(catalog),
..Spells::empty_for_tests()
})
.insert_resource(NetCommands(tx))
.init_resource::<Items>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<crate::spell::SpellModifiers>()
.add_systems(Update, feed_spell_tooltips);
let mut script = UiScript::new().unwrap();
script.set_pet_actions(
true,
true,
true,
vec![PetActionView {
name: Some("Firebolt".into()),
spell_id: Some(3110),
..Default::default()
}],
);
script.set_craft(Some(CraftState {
name: "Beast Training".into(),
rank: 0,
max_rank: 0,
craft_type: 1,
recipes: vec![CraftRecipe {
spell_id: 24599,
tooltip: CraftTooltip::Spell(17253),
name: "Bite".into(),
sub_name: String::new(),
difficulty: TradeSkillDifficulty::Optimal,
num_available: 1,
icon: None,
description: None,
needs_item_target: false,
reagents: vec![],
tools: vec![],
spell_level: 0,
}],
}));
// We target a mob that targets party1, whose list the VM holds by guid.
const ME: u64 = 0x10;
const MOB: u64 = 0xF130_0000_0000_0001;
const TOT: u64 = 0x21;
script.set_unit_guids(&benilla_ui::script::UnitGuids {
player: ME,
target: MOB,
party: [TOT, 0, 0, 0],
held: std::collections::HashMap::from([(ME, MOB), (MOB, TOT), (TOT, 0)]),
..Default::default()
});
script.set_unit_auras(
TOT,
Some(vec![AuraState {
spell_id: 589,
name: Some("Shadow Word: Pain".into()),
..Default::default()
}]),
);
app.insert_non_send_resource(script);
app.update();
let script = app.world().non_send_resource::<UiScript>();
script
.run(
r#"
local a = CreateFrame("Button", "B"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10)
CreateFrame("GameTooltip", "TT")
local function lines()
local t = {}
for i = 1, TT:NumLines() do t[i] = getglobal("TTTextLeft" .. i):GetText() end
return table.concat(t, " | ")
end
TT:SetOwner(B, "ANCHOR_RIGHT"); TT:SetPetAction(1); PET = lines()
TT:SetOwner(B, "ANCHOR_RIGHT"); TT:SetCraftSpell(1); CRAFT = lines()
TT:SetOwner(B, "ANCHOR_RIGHT"); TT:SetUnitDebuff("targettarget", 1); TOT = lines()
"#,
)
.unwrap();
assert_eq!(
script.eval::<String>("return PET").unwrap(),
"Firebolt | Deals Fire damage."
);
assert_eq!(
script.eval::<String>("return CRAFT").unwrap(),
"Bite | Bite the enemy."
);
assert_eq!(
script.eval::<String>("return TOT").unwrap(),
"Shadow Word: Pain | Shadow damage over time."
);
}
+3
View File
@@ -770,6 +770,8 @@ pub(crate) struct Model {
/// Spell id to its tooltip view, and the misses asked for.
pub(crate) spell_tooltips: HashMap<u32, super::SpellTooltipView>,
pub(crate) spell_tooltip_asks: HashSet<u32>,
/// The tooltips whose spell render missed its view, re-rendered when the app answers.
pub(crate) spell_tooltip_waits: HashMap<FrameHandle, super::tooltip_spell::SpellWait>,
/// `CollapseQuestHeader`/`ExpandQuestHeader` as `(1-based entry, collapse)`, entry 0 for all.
pub(crate) quest_log_collapses: Vec<(u32, bool)>,
/// Watched quest ids in watch order, pruned when a quest leaves the log.
@@ -1319,6 +1321,7 @@ impl Model {
player_req: PlayerReqState::default(),
spell_tooltips: HashMap::new(),
spell_tooltip_asks: HashSet::new(),
spell_tooltip_waits: HashMap::new(),
quest_log_collapses: Vec::new(),
quest_log_watched: Vec::new(),
server_unix_time: None,
@@ -1034,3 +1034,356 @@ fn quest_reward_spell_getters_and_hovers() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A craft recipe row whose detail icon hovers `tooltip`.
fn craft_recipe(spell_id: u32, name: &str, tooltip: CraftTooltip) -> CraftRecipe {
CraftRecipe {
spell_id,
tooltip,
name: name.into(),
sub_name: String::new(),
difficulty: TradeSkillDifficulty::Optimal,
num_available: 1,
icon: None,
description: None,
needs_item_target: false,
reagents: vec![],
tools: vec![],
spell_level: 0,
}
}
/// Firebolt (3110) on a pet-bar slot.
fn firebolt_slot() -> PetActionView {
PetActionView {
name: Some("Firebolt".into()),
spell_id: Some(3110),
..Default::default()
}
}
fn firebolt() -> SpellTooltipView {
SpellTooltipView {
name: "Firebolt".into(),
rank: Some("Rank 1".into()),
cost: Some("10 Mana".into()),
range: Some("30 yd range".into()),
cast_time: Some("1 sec cast".into()),
description: "Deals 7 to 10 Fire damage to a target.".into(),
..Default::default()
}
}
/// Pet-bar tokens and craft item subjects name no spell, so they are left out.
#[test]
fn the_spell_subjects_are_what_the_setters_read_from_the_vm() {
let mut s = script();
s.set_pet_actions(
true,
true,
true,
vec![
PetActionView {
name: Some("PET_ACTION_ATTACK".into()),
is_token: true,
..Default::default()
},
firebolt_slot(),
],
);
s.set_pet_book(PetBookState {
token: Some("DEMON".into()),
slots: vec![SpellSlotView {
spell_id: 6307,
name: "Blood Pact".into(),
..Default::default()
}],
});
let reward = |spell_id| {
Some(QuestRewardSpell {
spell_id,
..Default::default()
})
};
s.set_quest(Some(QuestState {
panel: QuestPanel::Reward,
reward_spell: reward(133),
..QuestState::default()
}));
s.set_quest_log(QuestLogState {
entries: vec![QuestLogEntryView {
quest_id: 7,
detail: Some(QuestLogDetail {
reward_spell: reward(116),
..Default::default()
}),
..Default::default()
}],
..Default::default()
});
s.set_craft(Some(CraftState {
name: "Beast Training".into(),
rank: 0,
max_rank: 0,
craft_type: 1,
recipes: vec![
craft_recipe(24599, "Bite", CraftTooltip::Spell(17253)),
craft_recipe(7421, "Runed Copper Rod", CraftTooltip::Item(6218)),
],
}));
let aura = |spell_id| AuraState {
spell_id,
..Default::default()
};
s.set_player_auras(vec![aura(1459)]);
// The pet's, the target-of-target's and party1's lists, by guid.
s.set_unit_auras(0xF140_0000_0000_0077, Some(vec![aura(172)]));
s.set_unit_auras(0x21, Some(vec![aura(589)]));
s.set_unit_auras(0x22, Some(vec![aura(8921)]));
s.set_tracking(Some(TrackingState {
spell_id: 2580,
name: Some("Find Minerals".into()),
icon: None,
cancelable: true,
}));
let mut subjects = s.spell_tooltip_subjects();
subjects.sort_unstable();
assert_eq!(
subjects,
vec![116, 133, 172, 589, 1459, 2580, 3110, 6307, 8921, 17253]
);
}
/// No second hover and no second `OnTooltipCleared`: the reference builds the tooltip at the call.
#[test]
fn a_missed_view_re_renders_the_tooltip_when_the_app_answers() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_pet_actions(true, true, true, vec![firebolt_slot()]);
s.run(
r#"
local a = CreateFrame("Button", "PB1"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10)
CreateFrame("GameTooltip", "TT")
TT:SetOwner(PB1, "ANCHOR_RIGHT")
CLEARED = 0
TT:SetScript("OnTooltipCleared", function() CLEARED = CLEARED + 1 end)
TT:SetPetAction(1)
"#,
)
.unwrap();
assert_eq!(left_lines(&mut s), vec!["Firebolt"]);
assert_eq!(s.take_spell_tooltip_asks(), vec![3110]);
s.set_spell_tooltip(3110, firebolt());
assert_eq!(
left_lines(&mut s),
vec![
"Firebolt",
"10 Mana",
"1 sec cast",
"Deals 7 to 10 Fire damage to a target."
]
);
assert!(s.eval::<bool>("return TT:IsShown() == 1").unwrap());
assert!(
s.take_spell_tooltip_asks().is_empty(),
"the re-render found the view"
);
assert_eq!(
s.eval::<i64>("return CLEARED").unwrap(),
1,
"one setter call, one OnTooltipCleared"
);
assert!(s.take_errors().is_empty());
}
/// Beast Training's icon has no fallback name, so its miss is a hidden plate.
#[test]
fn a_hidden_miss_shows_and_an_enchant_link_fills_when_answered() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_craft(Some(CraftState {
name: "Beast Training".into(),
rank: 0,
max_rank: 0,
craft_type: 1,
recipes: vec![craft_recipe(24599, "Bite", CraftTooltip::Spell(17253))],
}));
s.run(
r#"
local a = CreateFrame("Button", "CI"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10)
CreateFrame("GameTooltip", "TT")
CreateFrame("GameTooltip", "REF")
TT:SetOwner(CI, "ANCHOR_RIGHT")
TT:SetCraftSpell(1)
REF:SetOwner(CI, "ANCHOR_PRESERVE")
REF:SetHyperlink("|cffffd000|Henchant:20034|h[Enchant Weapon - Crusader]|h|r")
"#,
)
.unwrap();
assert_eq!(s.eval::<i64>("return TT:NumLines()").unwrap(), 0);
assert!(!s.eval::<bool>("return TT:IsShown() == 1").unwrap());
assert_eq!(
s.eval::<String>("return REFTextLeft1:GetText()").unwrap(),
"Enchant Weapon - Crusader"
);
assert_eq!(s.eval::<i64>("return REF:NumLines()").unwrap(), 1);
s.set_spell_tooltip(
17253,
SpellTooltipView {
name: "Bite".into(),
description: "Bite the enemy.".into(),
..Default::default()
},
);
s.set_spell_tooltip(
20034,
SpellTooltipView {
name: "Enchant Weapon - Crusader".into(),
cast_time: Some("5 sec cast".into()),
description: "Permanently enchant a melee weapon.".into(),
..Default::default()
},
);
assert!(s.eval::<bool>("return TT:IsShown() == 1").unwrap());
assert_eq!(
s.eval::<(String, String)>("return TTTextLeft1:GetText(), TTTextLeft2:GetText()")
.unwrap(),
("Bite".to_string(), "Bite the enemy.".to_string())
);
assert_eq!(
s.eval::<(i64, String)>("return REF:NumLines(), REFTextLeft3:GetText()")
.unwrap(),
(3, "Permanently enchant a melee weapon.".to_string())
);
assert!(s.take_errors().is_empty());
}
/// A new hover, a hide, an added line or a running fade leaves the tooltip as it is.
#[test]
fn new_content_or_an_added_line_ends_the_wait() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_pet_actions(true, true, true, vec![firebolt_slot()]);
s.run(
r#"
local a = CreateFrame("Button", "PB1"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10)
CreateFrame("GameTooltip", "TT")
CreateFrame("GameTooltip", "HID")
CreateFrame("GameTooltip", "ADD")
TT:SetOwner(PB1, "ANCHOR_RIGHT")
TT:SetPetAction(1)
TT:SetOwner(PB1, "ANCHOR_RIGHT")
TT:SetText("Attack")
HID:SetOwner(PB1, "ANCHOR_RIGHT")
HID:SetPetAction(1)
HID:Hide()
ADD:SetOwner(PB1, "ANCHOR_RIGHT")
ADD:SetPetAction(1)
ADD:AddLine("an addon's line")
CreateFrame("GameTooltip", "FAD")
FAD:SetOwner(PB1, "ANCHOR_RIGHT")
FAD:SetPetAction(1)
FAD:FadeOut()
"#,
)
.unwrap();
s.set_spell_tooltip(3110, firebolt());
assert_eq!(left_lines(&mut s), vec!["Attack"]);
assert_eq!(s.eval::<i64>("return HID:NumLines()").unwrap(), 0);
assert!(!s.eval::<bool>("return HID:IsShown() == 1").unwrap());
assert_eq!(
s.eval::<(i64, String)>("return ADD:NumLines(), ADDTextLeft2:GetText()")
.unwrap(),
(2, "an addon's line".to_string())
);
assert_eq!(s.eval::<i64>("return FAD:NumLines()").unwrap(), 1);
assert!(s.take_errors().is_empty());
}
/// What Lua wrote after the miss is kept: a line rewritten in place, by `AppendText` or a cell's
/// `SetTextColor`, or new content that reads the same as the miss's line.
#[test]
fn a_line_rewritten_in_place_ends_the_wait() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_pet_actions(true, true, true, vec![firebolt_slot()]);
s.run(
r#"
local a = CreateFrame("Button", "PB1"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10)
CreateFrame("GameTooltip", "APP")
CreateFrame("GameTooltip", "COL")
APP:SetOwner(PB1, "ANCHOR_RIGHT")
APP:SetPetAction(1)
APP:AppendText(" (Pet)")
COL:SetOwner(PB1, "ANCHOR_RIGHT")
COL:SetPetAction(1)
COLTextLeft1:SetTextColor(1, 0, 0)
CreateFrame("GameTooltip", "SAM")
SAM:SetOwner(PB1, "ANCHOR_RIGHT")
SAM:SetPetAction(1)
SAM:SetOwner(PB1, "ANCHOR_RIGHT")
SAM:SetText("Firebolt", 1, 1, 1)
"#,
)
.unwrap();
s.set_spell_tooltip(3110, firebolt());
assert_eq!(
s.eval::<(i64, String)>("return APP:NumLines(), APPTextLeft1:GetText()")
.unwrap(),
(1, "Firebolt (Pet)".to_string())
);
assert_eq!(
s.eval::<(i64, f32, f32)>(
"local r, g = COLTextLeft1:GetTextColor(); return COL:NumLines(), r, g"
)
.unwrap(),
(1, 1.0, 0.0)
);
assert_eq!(s.eval::<i64>("return SAM:NumLines()").unwrap(), 1);
assert!(s.take_errors().is_empty());
}
/// A re-render's `OnShow` can move another waiting tooltip onto a new spell.
#[test]
fn a_wait_replaced_by_an_earlier_re_render_is_not_replayed() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_pet_actions(true, true, true, vec![firebolt_slot()]);
s.set_craft(Some(CraftState {
name: "Beast Training".into(),
rank: 0,
max_rank: 0,
craft_type: 1,
recipes: vec![craft_recipe(24599, "Firebolt", CraftTooltip::Spell(3110))],
}));
s.run(
r#"
local a = CreateFrame("Button", "PB1"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10)
CreateFrame("GameTooltip", "A")
CreateFrame("GameTooltip", "B")
A:SetOwner(PB1, "ANCHOR_RIGHT")
A:SetCraftSpell(1)
B:SetOwner(PB1, "ANCHOR_RIGHT")
B:SetPetAction(1)
A:SetScript("OnShow", function()
B:SetOwner(PB1, "ANCHOR_RIGHT")
B:SetHyperlink("|cffffd000|Henchant:20034|h[Enchant Weapon - Crusader]|h|r")
end)
"#,
)
.unwrap();
s.set_spell_tooltip(3110, firebolt());
assert_eq!(
s.eval::<(i64, String)>("return B:NumLines(), BTextLeft1:GetText()")
.unwrap(),
(1, "Enchant Weapon - Crusader".to_string())
);
assert_eq!(
s.eval::<String>("return ATextLeft1:GetText()").unwrap(),
"Firebolt"
);
assert!(s.take_errors().is_empty());
}
@@ -275,6 +275,8 @@ pub(super) fn clear_content(model: &mut Model, h: FrameHandle) {
t.unit_guid = None;
t.world_owned = false;
}
// New content replaces a spell render still waiting on its view.
model.spell_tooltip_waits.remove(&h);
// The `<name>StatusBar` health bar is unit content: it hides with the lines, and the next
// unit render re-shows it.
let bar = model
+174 -9
View File
@@ -5,14 +5,18 @@
//! Only `SetPlayerBuff` adds the gold time-remaining line.
//!
//! The app resolves each spell into a [`SpellTooltipView`] (`$`-tokens, cast, range and duration
//! text), kept in an ask-once store by spell id: a miss records the id, the app pushes the view and
//! the next hover repaints.
//! text), kept in an ask-once store by spell id and pushed ahead of a hover. A miss records the
//! id; a `set_spell_by_id` miss also waits, and the app's answer re-renders that tooltip a frame
//! after the hover. That frame is a known difference from the reference, which builds every spell
//! tooltip at the call (`0x52e610`); a spell no pushed set names, such as an `enchant:` link's,
//! always meets it. A talent or tracking miss shows its view on the next hover.
use mlua::{Lua, Table, Value};
use super::object::frame_handle_of;
use super::tooltip::{append_line, clear_content, fire_cleared};
use super::object::{frame_handle_of, frame_wrapper};
use super::tooltip::{append_line, clear_content, fire_cleared, tip_mut};
use super::{CraftTooltip, Model, TrainerTooltip};
use crate::widget::FrameHandle;
const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
/// The rank column's gray, `0xff808080`.
@@ -62,17 +66,150 @@ pub struct SpellTooltipView {
}
impl super::UiScript {
/// Store or replace a spell's view, answering its ask.
/// Store or replace a spell's view, answering its ask: a tooltip waiting on it re-renders now,
/// a frame after its hover, where the reference builds it whole at the call (`0x52e610`).
pub fn set_spell_tooltip(&mut self, spell_id: u32, view: SpellTooltipView) {
let mut model = self.model_mut();
model.spell_tooltip_asks.remove(&spell_id);
model.spell_tooltips.insert(spell_id, view);
let waiting: Vec<FrameHandle> = {
let mut model = self.model_mut();
model.spell_tooltip_asks.remove(&spell_id);
model.spell_tooltips.insert(spell_id, view);
model
.spell_tooltip_waits
.iter()
.filter(|(_, w)| w.spell_id == spell_id)
.map(|(h, _)| *h)
.collect()
};
for h in waiting {
if let Err(e) = answer_wait(&self.lua, h, spell_id) {
self.push_error(e);
}
}
}
/// Drain the spell ids the renderers asked for and the store lacked.
pub fn take_spell_tooltip_asks(&mut self) -> Vec<u32> {
self.model_mut().spell_tooltip_asks.drain().collect()
}
/// The spells a hover reads from the VM's own state rather than the player's book: the pet's
/// bar and book, the open quest's and each log entry's reward spell, the open craft's spell
/// subjects, the player's and every other unit's auras, and the tracking spell. The app pushes
/// their views ahead of a hover.
pub fn spell_tooltip_subjects(&self) -> Vec<u32> {
let model = self.model_ref();
let pet_bar = model
.pet_bar
.slots
.iter()
.filter(|s| !s.view.is_token)
.filter_map(|s| s.view.spell_id);
let pet_book = model.pet_book.slots.iter().map(|s| s.spell_id);
let rewards = model
.quest
.iter()
.filter_map(|q| q.reward_spell.as_ref())
.chain(
model
.quest_log
.entries
.iter()
.filter_map(|e| e.detail.as_ref()?.reward_spell.as_ref()),
)
.map(|s| s.spell_id);
let craft = model
.craft
.iter()
.flat_map(|c| &c.recipes)
.filter_map(|r| match r.tooltip {
CraftTooltip::Spell(id) => Some(id),
CraftTooltip::Item(_) => None,
});
let auras = model
.player_auras
.iter()
.chain(model.unit_auras.values().flatten())
.map(|a| a.spell_id);
let tracking = model.tracking.iter().map(|t| t.spell_id);
pet_bar
.chain(pet_book)
.chain(rewards)
.chain(craft)
.chain(auras)
.chain(tracking)
.filter(|&id| id != 0)
.collect()
}
}
/// A spell render that missed its view, kept to re-run when the view lands. The re-run is skipped
/// unless the tooltip still shows `drawn`, so a line Lua added or rewrote since is kept.
#[derive(Clone)]
pub(crate) struct SpellWait {
spell_id: u32,
fallback_name: Option<String>,
opts: SpellRenderOpts,
remaining: Option<String>,
drawn: Drawn,
}
/// Each shown line's left and right cell as `(text, colour)`.
type Drawn = Vec<(Option<String>, Option<[f32; 4]>)>;
fn drawn_lines(model: &mut Model, h: FrameHandle) -> mlua::Result<Drawn> {
let t = tip_mut(model, h)?;
let n = t.num_lines;
let cells: Vec<_> = t
.left_lines
.iter()
.take(n)
.chain(t.right_lines.iter().take(n))
.copied()
.collect();
Ok(cells
.into_iter()
.map(|rh| {
let d = model.region_data.get(&rh);
(
d.and_then(|d| d.text.clone()),
d.and_then(|d| d.vertex_color),
)
})
.collect())
}
/// Re-run the render `h` still waits with on `spell_id`, read afresh: an earlier re-render's Lua
/// may have replaced it. Lines Lua added or rewrote since, or a fade under way, keep the tooltip as
/// it is. The clear is silent: Lua made one setter call and saw its one `OnTooltipCleared`.
fn answer_wait(lua: &Lua, h: FrameHandle, spell_id: u32) -> mlua::Result<()> {
let (id, wait) = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(wait) = model
.spell_tooltip_waits
.get(&h)
.filter(|w| w.spell_id == spell_id)
.cloned()
else {
return Ok(());
};
let fading = tip_mut(&mut model, h)?.fade_start.is_some();
if fading || drawn_lines(&mut model, h)? != wait.drawn {
model.spell_tooltip_waits.remove(&h);
return Ok(());
}
clear_content(&mut model, h);
(model.frame_id(h), wait)
};
let this = frame_wrapper(lua, id)?;
fill_spell(
lua,
&this,
h,
wait.spell_id,
wait.fallback_name,
wait.opts,
wait.remaining,
)
}
/// Look up a spell's view; a miss records the ask.
@@ -294,12 +431,40 @@ pub(super) fn set_spell_by_id(
clear_content(&mut model, h);
}
fire_cleared(lua, h);
fill_spell(lua, this, h, spell_id, fallback_name, opts, remaining)
}
/// Fill a cleared tooltip with the spell's view, or with the fallback name and a wait on the ask.
fn fill_spell(
lua: &Lua,
this: &Table,
h: FrameHandle,
spell_id: u32,
fallback_name: Option<String>,
opts: SpellRenderOpts,
remaining: Option<String>,
) -> mlua::Result<()> {
match spell_view_of(lua, spell_id) {
Some(v) => render_spell(lua, this, &v, opts, remaining, None)?,
None => {
if let Some(name) = fallback_name {
if let Some(name) = fallback_name.clone() {
append_line(lua, this, (name, WHITE), None, false)?;
}
// The fallback stands until the app answers the ask; id 0 asks nothing.
if spell_id != 0 {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let drawn = drawn_lines(&mut model, h)?;
model.spell_tooltip_waits.insert(
h,
SpellWait {
spell_id,
fallback_name,
opts,
remaining,
drawn,
},
);
}
}
}
super::tooltip::show_or_hide_empty(lua, h);