From db94ea038e3960fc406c03d9d605d1934195772d Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Mon, 28 Sep 2026 12:36:21 +0200 Subject: [PATCH] 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) Co-Authored-By: Sam --- crates/benilla-app/src/ui_aura.rs | 7 - crates/benilla-app/src/ui_quest/mod.rs | 2 +- crates/benilla-app/src/ui_tooltip/mod.rs | 46 +-- crates/benilla-app/src/ui_tooltip/tests.rs | 123 ++++++ crates/benilla-ui/src/script/model.rs | 3 + .../src/script/tests/tooltip_spell.rs | 353 ++++++++++++++++++ crates/benilla-ui/src/script/tooltip/mod.rs | 2 + crates/benilla-ui/src/script/tooltip_spell.rs | 183 ++++++++- 8 files changed, 673 insertions(+), 46 deletions(-) diff --git a/crates/benilla-app/src/ui_aura.rs b/crates/benilla-app/src/ui_aura.rs index 6e16327e..071d1fd9 100644 --- a/crates/benilla-app/src/ui_aura.rs +++ b/crates/benilla-app/src/ui_aura.rs @@ -79,13 +79,6 @@ pub(crate) struct PlayerAuraCache { auras: Vec, } -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 + '_ { - 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)] diff --git a/crates/benilla-app/src/ui_quest/mod.rs b/crates/benilla-app/src/ui_quest/mod.rs index 26d0948f..8979e37b 100644 --- a/crates/benilla-app/src/ui_quest/mod.rs +++ b/crates/benilla-app/src/ui_quest/mod.rs @@ -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>, mut giver: ResMut, objects: crate::net::Objects, diff --git a/crates/benilla-app/src/ui_tooltip/mod.rs b/crates/benilla-app/src/ui_tooltip/mod.rs index a49ec218..f343fc05 100644 --- a/crates/benilla-app/src/ui_tooltip/mod.rs +++ b/crates/benilla-app/src/ui_tooltip/mod.rs @@ -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>, actions: Option>, spell_sources: SpellTooltipSources, - auras: Option>, - selection: Res, - stores: Query<&ObjectStore>, self_q: Query<&ObjectStore, With>, // The auto-attack target, the melee range cell's second reach. engaged_q: Query<&crate::creature_anim::Engaged, With>, @@ -369,6 +369,13 @@ fn feed_spell_tooltips( return; }; let mut wanted: Vec = 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 = 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(); { diff --git a/crates/benilla-app/src/ui_tooltip/tests.rs b/crates/benilla-app/src/ui_tooltip/tests.rs index b82b59b8..6cbe1265 100644 --- a/crates/benilla-app/src/ui_tooltip/tests.rs +++ b/crates/benilla-app/src/ui_tooltip/tests.rs @@ -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::() + .init_resource::() + .init_resource::() + .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::(); + 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::("return PET").unwrap(), + "Firebolt | Deals Fire damage." + ); + assert_eq!( + script.eval::("return CRAFT").unwrap(), + "Bite | Bite the enemy." + ); + assert_eq!( + script.eval::("return TOT").unwrap(), + "Shadow Word: Pain | Shadow damage over time." + ); +} diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 98ea7a4c..b69e455b 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -770,6 +770,8 @@ pub(crate) struct Model { /// Spell id to its tooltip view, and the misses asked for. pub(crate) spell_tooltips: HashMap, pub(crate) spell_tooltip_asks: HashSet, + /// The tooltips whose spell render missed its view, re-rendered when the app answers. + pub(crate) spell_tooltip_waits: HashMap, /// `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, diff --git a/crates/benilla-ui/src/script/tests/tooltip_spell.rs b/crates/benilla-ui/src/script/tests/tooltip_spell.rs index ee671691..32651787 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_spell.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_spell.rs @@ -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::("return TT:IsShown() == 1").unwrap()); + assert!( + s.take_spell_tooltip_asks().is_empty(), + "the re-render found the view" + ); + assert_eq!( + s.eval::("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::("return TT:NumLines()").unwrap(), 0); + assert!(!s.eval::("return TT:IsShown() == 1").unwrap()); + assert_eq!( + s.eval::("return REFTextLeft1:GetText()").unwrap(), + "Enchant Weapon - Crusader" + ); + assert_eq!(s.eval::("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::("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::("return HID:NumLines()").unwrap(), 0); + assert!(!s.eval::("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::("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::("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::("return ATextLeft1:GetText()").unwrap(), + "Firebolt" + ); + assert!(s.take_errors().is_empty()); +} diff --git a/crates/benilla-ui/src/script/tooltip/mod.rs b/crates/benilla-ui/src/script/tooltip/mod.rs index da15e8d5..107e381c 100644 --- a/crates/benilla-ui/src/script/tooltip/mod.rs +++ b/crates/benilla-ui/src/script/tooltip/mod.rs @@ -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 `StatusBar` health bar is unit content: it hides with the lines, and the next // unit render re-shows it. let bar = model diff --git a/crates/benilla-ui/src/script/tooltip_spell.rs b/crates/benilla-ui/src/script/tooltip_spell.rs index 93d6e71a..1d884f31 100644 --- a/crates/benilla-ui/src/script/tooltip_spell.rs +++ b/crates/benilla-ui/src/script/tooltip_spell.rs @@ -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 = { + 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 { 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 { + 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, + opts: SpellRenderOpts, + remaining: Option, + drawn: Drawn, +} + +/// Each shown line's left and right cell as `(text, colour)`. +type Drawn = Vec<(Option, Option<[f32; 4]>)>; + +fn drawn_lines(model: &mut Model, h: FrameHandle) -> mlua::Result { + 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::().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, + opts: SpellRenderOpts, + remaining: Option, +) -> 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::().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);