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:
co-authored by
Claude Opus 5.5
Sam
parent
ae6d9f3500
commit
db94ea038e
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
{
|
||||
|
||||
@@ -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."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user