diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs index f5965a3c..5c785c3c 100644 --- a/crates/benilla-app/src/capture/fixtures.rs +++ b/crates/benilla-app/src/capture/fixtures.rs @@ -889,6 +889,8 @@ pub(super) fn seed_ui_fixture( "mouseover", Some(benilla_ui::script::UnitState { exists: true, + // A unit is found by its guid; the builder hides for guid 0 (`0x52a04e`). + guid: 0xF130_0000_0044_0001, name: Some("Stormwind Guard".into()), health: 38, max_health: 50, @@ -916,6 +918,7 @@ pub(super) fn seed_ui_fixture( "mouseover", Some(benilla_ui::script::UnitState { exists: true, + guid: 0x0000_0000_0000_0B0B, is_player: true, name: Some("Bob".into()), level: 60, diff --git a/crates/benilla-app/src/minimap/blips/dots.rs b/crates/benilla-app/src/minimap/blips/dots.rs index 3f976cdb..c9f7fee5 100644 --- a/crates/benilla-app/src/minimap/blips/dots.rs +++ b/crates/benilla-app/src/minimap/blips/dots.rs @@ -107,9 +107,9 @@ pub(in crate::minimap) fn emit_quest_dots( color: [tint, tint, tint, ctx.alpha], ..default() }); - if let (Some(c), Some(ui)) = (ctx.cursor, ctx.cursor_ui) { + if let Some(c) = ctx.cursor { if rect.contains(c) { - *hover = MinimapBlipHover::Npc(npc, ui, grey); + *hover = MinimapBlipHover::Npc(npc, grey); } } } @@ -200,11 +200,11 @@ pub(in crate::minimap) fn emit_tracking_dots( color: [tint, tint, tint, ctx.alpha], ..default() }); - if let (Some(c), Some(ui)) = (ctx.cursor, ctx.cursor_ui) { + if let Some(c) = ctx.cursor { if rect.contains(c) { *hover = match name { - DotName::Guid => MinimapBlipHover::Npc(guid, ui, grey), - DotName::Known(n) => MinimapBlipHover::TrackedGo(n, ui, grey), + DotName::Guid => MinimapBlipHover::Npc(guid, grey), + DotName::Known(n) => MinimapBlipHover::TrackedGo(n, grey), }; } } diff --git a/crates/benilla-app/src/minimap/blips/mod.rs b/crates/benilla-app/src/minimap/blips/mod.rs index 463d8595..6bdab528 100644 --- a/crates/benilla-app/src/minimap/blips/mod.rs +++ b/crates/benilla-app/src/minimap/blips/mod.rs @@ -144,13 +144,13 @@ const POI_ICON_PX: f32 = 16.0; pub(crate) enum MinimapBlipHover { #[default] None, - /// A landmark's name and the cursor's UI-space point, the tooltip's seat. - Landmark(String, Vec2), - /// A unit dot's guid, resolved through the name cache, the cursor point, and whether the dot - /// drew grey; the tooltip line greys the same way (`|cffb0b0b0`). - Npc(u64, Vec2, bool), - /// A tracked GameObject dot: its template name, the cursor point and the grey flag. - TrackedGo(String, Vec2, bool), + /// A landmark's name. + Landmark(String), + /// A unit dot's guid, resolved through the name cache, and whether the dot drew grey; the + /// tooltip line greys the same way (`|cffb0b0b0`). + Npc(u64, bool), + /// A tracked GameObject dot: its template name and the grey flag. + TrackedGo(String, bool), } /// The blip layer's system inputs, tupled to keep `emit_minimap` under Bevy's 16-parameter cap. @@ -199,9 +199,6 @@ pub(super) struct BlipCtx { pub(super) wz: f32, /// The cursor in the quads' y-down logical px. pub(super) cursor: Option, - /// The cursor in the y-up root units; the reference seats the blip tooltip at the cursor, its - /// exact offset untraced. - pub(super) cursor_ui: Option, /// Window px per root unit: everything else here is window px, while Lua /// ([`Minimap:PingLocation`](super::ping)) hands root units. pub(super) seam: f32, @@ -306,9 +303,9 @@ pub(super) fn emit_landmarks( color: [1.0, 1.0, 1.0, ctx.alpha], ..default() }); - if let (Some(c), Some(ui)) = (ctx.cursor, ctx.cursor_ui) { + if let Some(c) = ctx.cursor { if rect.contains(c) { - *hover = MinimapBlipHover::Landmark(poi.name.clone(), ui); + *hover = MinimapBlipHover::Landmark(poi.name.clone()); } } } @@ -318,9 +315,9 @@ pub(super) fn emit_landmarks( continue; }; let rect = push_rim_arrow(ctx, poi.pos, tex, quads); - if let (Some(c), Some(ui)) = (ctx.cursor, ctx.cursor_ui) { + if let Some(c) = ctx.cursor { if rect.contains(c) { - *hover = MinimapBlipHover::Landmark(poi.name.clone(), ui); + *hover = MinimapBlipHover::Landmark(poi.name.clone()); } } } @@ -402,15 +399,15 @@ pub(super) fn emit_party_arrows( } } -/// Show the hovered blip's name; a unit's resolves through the name cache. Hover loss fades the -/// tooltip, never hides it at once. Runs after `UnitFeed`, so a same-frame move from a world -/// hover to a blip ends on the blip's tooltip. +/// Show the hovered blip's name; a unit's resolves through the name cache. Hover loss hides the +/// tooltip at once (`0x4eb0c0`). Runs after `UnitFeed`, so a same-frame move from a world hover to +/// a blip ends on the blip's tooltip. pub(super) fn drive_blip_tooltip( script: Option>, hover: Res, names: Res, commands: Res, - mut last: Local>>, + mut last: Local>>, ) { let Some(mut script) = script else { return; @@ -418,26 +415,22 @@ pub(super) fn drive_blip_tooltip( let last = last.get(&script); let show = match &*hover { MinimapBlipHover::None => None, - MinimapBlipHover::Landmark(name, at) => Some((name.clone(), *at, false)), - MinimapBlipHover::Npc(npc, at, grey) => names + MinimapBlipHover::Landmark(name) => Some((name.clone(), false)), + MinimapBlipHover::Npc(npc, grey) => names .resolve(*npc, &commands) - .map(|n| (n.to_string(), *at, *grey)), - MinimapBlipHover::TrackedGo(name, at, grey) => Some((name.clone(), *at, *grey)), + .map(|n| (n.to_string(), *grey)), + MinimapBlipHover::TrackedGo(name, grey) => Some((name.clone(), *grey)), }; match (&show, &*last) { - // Same blip: the reference's blip tooltip follows the cursor. - (Some((t, at, _)), Some((lt, lat))) if t == lt => { - if at != lat { - script.world_tooltip_move(at.x, at.y); - *last = Some((t.clone(), *at)); - } - } - (Some((t, at, grey)), _) => { - script.minimap_tooltip(t, at.x, at.y, *grey); - *last = Some((t.clone(), *at)); + // Same blip: the plate, owned in mode 6, follows the cursor in its own update. + (Some((t, _)), Some(lt)) if t == lt => {} + (Some((t, grey)), _) => { + script.minimap_tooltip(t, *grey); + *last = Some(t.clone()); } + // No blip hovered: `0x4eb0c0` hides the plate directly, with no fade. (None, Some(_)) => { - script.world_tooltip_fade(); + script.world_tooltip_hide(); *last = None; } (None, None) => {} diff --git a/crates/benilla-app/src/minimap/mod.rs b/crates/benilla-app/src/minimap/mod.rs index c3909946..1cb786d9 100644 --- a/crates/benilla-app/src/minimap/mod.rs +++ b/crates/benilla-app/src/minimap/mod.rs @@ -636,11 +636,6 @@ fn emit_minimap( wy: (wy / q).round() * q, wz: wow[2], cursor, - // The cursor in the root's units (y-up, ÷ the seam scale): the tooltip anchor resolves - // in them, not window px. - cursor_ui: cursor - .zip(win) - .map(|(c, w)| crate::ui_script::window_to_ui(w.height(), c)), seam, } }); diff --git a/crates/benilla-app/src/minimap/ping.rs b/crates/benilla-app/src/minimap/ping.rs index bf4e6ba2..ea242668 100644 --- a/crates/benilla-app/src/minimap/ping.rs +++ b/crates/benilla-app/src/minimap/ping.rs @@ -189,7 +189,6 @@ mod tests { wy: 0.0, wz: 0.0, cursor: None, - cursor_ui: None, seam: 1.0, } } diff --git a/crates/benilla-app/src/ui_script/tooltip_anchor_tests.rs b/crates/benilla-app/src/ui_script/tooltip_anchor_tests.rs index 02e2e2ca..7001d9b7 100644 --- a/crates/benilla-app/src/ui_script/tooltip_anchor_tests.rs +++ b/crates/benilla-app/src/ui_script/tooltip_anchor_tests.rs @@ -37,6 +37,7 @@ fn harness(extra: &[&str]) -> UiScript { fn wolf() -> UnitState { UnitState { exists: true, + guid: 0xF130_0000_4500_0001, name: Some("Timber Wolf".into()), health: 30, max_health: 50, @@ -232,6 +233,8 @@ fn your_own_portrait_explains_the_menu_instead_of_showing_your_health() { s.set_unit( "target", Some(UnitState { + // Another unit than `player`, so `UnitIsUnit("target", "player")` is false. + guid: 0x0000_0000_0000_0C0C, is_player: true, player_controlled: true, name: Some("Someone".into()), @@ -472,7 +475,7 @@ fn buff_hover_hangs_below_left_of_the_button() { fn a_cursor_seated_gameobject_plate_is_owned() { benilla_formats::wow_data_or_skip!(); let mut s = harness(&[]); - assert!(s.world_tooltip_gameobject("Brill", &[], Some((512.0, 384.0)))); + assert!(s.world_tooltip_gameobject("Brill", &[], true)); let owned: bool = s.eval("return GameTooltip:IsOwned(UIParent)").unwrap(); assert!( owned, @@ -491,7 +494,7 @@ fn a_cursor_seated_gameobject_plate_survives_an_addons_on_show_hook() { // Questie's hook, in one line: the plate's own show event calls Show() again. s.run(r#"GameTooltip:SetScript("OnShow", function() GameTooltip:Show() end)"#) .unwrap(); - assert!(s.world_tooltip_gameobject("Brill", &[], Some((512.0, 384.0)))); + assert!(s.world_tooltip_gameobject("Brill", &[], true)); let shown: bool = s .eval("return GameTooltip:IsShown() and true or false") .unwrap(); @@ -510,7 +513,7 @@ fn a_cursor_seated_gameobject_plate_survives_an_addons_on_show_hook() { fn a_corner_seated_gameobject_plate_is_owned_and_shown() { benilla_formats::wow_data_or_skip!(); let mut s = harness(&[]); - assert!(s.world_tooltip_gameobject("Ironforge Main Gate", &[], None)); + assert!(s.world_tooltip_gameobject("Ironforge Main Gate", &[], false)); let owned: bool = s.eval("return GameTooltip:IsOwned(UIParent)").unwrap(); assert!(owned, "the corner plate is owned; errors: {:?}", s.errors()); let shown: bool = s @@ -526,7 +529,7 @@ fn a_corner_seated_gameobject_plate_survives_an_addons_on_show_hook() { let mut s = harness(&[]); s.run(r#"GameTooltip:SetScript("OnShow", function() GameTooltip:Show() end)"#) .unwrap(); - assert!(s.world_tooltip_gameobject("Ironforge Main Gate", &[], None)); + assert!(s.world_tooltip_gameobject("Ironforge Main Gate", &[], false)); let shown: bool = s .eval("return GameTooltip:IsShown() and true or false") .unwrap(); diff --git a/crates/benilla-app/src/ui_script/unit_frame_tests.rs b/crates/benilla-app/src/ui_script/unit_frame_tests.rs index 843dacae..875b292b 100644 --- a/crates/benilla-app/src/ui_script/unit_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/unit_frame_tests.rs @@ -1562,6 +1562,8 @@ fn status_bar_text_paints_the_player_numerals_but_not_the_targets() { let alive = |health: u32, power: u32, power_type: u8| UnitState { exists: true, + // The tooltip builder finds a unit by its guid (`0x52a021`). + guid: 0xF130_0000_0000_0B0D, name: Some("Somebody".into()), health, max_health: 100, diff --git a/crates/benilla-app/src/ui_tooltip/mod.rs b/crates/benilla-app/src/ui_tooltip/mod.rs index 16df94a9..05c7d44d 100644 --- a/crates/benilla-app/src/ui_tooltip/mod.rs +++ b/crates/benilla-app/src/ui_tooltip/mod.rs @@ -58,6 +58,13 @@ enum LastHover { Corpse(u64), } +/// The publisher's teardown (`0x4928d5`–`0x492909`): every change of the mouseover away from a +/// non-null one requests the fade (`0x530ae0`) before the new plate builds, so a cursor-seated +/// plate hides and the next one shows afresh; a first hover has nothing to tear down. +fn fades_on_change(last: LastHover, next: LastHover) -> bool { + last != LastHover::None && last != next +} + /// The world-hover driver's memory, one `SystemParam` because the driver is at Bevy's 16-param /// ceiling. #[derive(bevy::ecs::system::SystemParam)] @@ -163,6 +170,10 @@ fn drive_mouseover_tooltip( script.set_unit("mouseover", Some(state)); *published = true; if *last != LastHover::Unit(guid) || last_lines.as_ref() != Some(&key) { + // A late name rebuilds under the same hover with no teardown. + if fades_on_change(*last, LastHover::Unit(guid)) { + script.world_tooltip_fade(); + } script.world_tooltip_unit("mouseover"); *last = LastHover::Unit(guid); *last_lines = Some(key); @@ -213,7 +224,10 @@ fn drive_mouseover_tooltip( ) else { return; }; - script.world_tooltip_gameobject(&plate, &[], None); + if fades_on_change(*last, LastHover::Corpse(guid)) { + script.world_tooltip_fade(); + } + script.world_tooltip_gameobject(&plate, &[], false); *last = LastHover::Corpse(guid); return; } @@ -252,10 +266,8 @@ fn drive_mouseover_tooltip( *trace = line; } } - // The cursor arm follows the pointer; the corner arm has nothing to re-seat. - if let Some((x, y)) = cursor_ui { - script.world_tooltip_move(x, y); - } + // A cursor-seated plate follows the pointer in its own update (mode 6); the corner arm + // has nothing to re-seat. return; } if cursor_seated && cursor_ui.is_none() { @@ -344,12 +356,15 @@ fn drive_mouseover_tooltip( _ => {} } } - script.world_tooltip_gameobject(&template.name, &lines, cursor_ui); + if fades_on_change(*last, LastHover::Go(guid)) { + script.world_tooltip_fade(); + } + script.world_tooltip_gameobject(&template.name, &lines, cursor_seated); *last = LastHover::Go(guid); return; } - if !matches!(*last, LastHover::None) { - // Hover lost: arm the fade (`0x492909` → `0x530ae0`). + if fades_on_change(*last, LastHover::None) { + // Hover lost: the same teardown, with no plate after it. script.world_tooltip_fade(); *last = LastHover::None; } diff --git a/crates/benilla-app/src/ui_tooltip/tests.rs b/crates/benilla-app/src/ui_tooltip/tests.rs index 8d9534e4..2c401778 100644 --- a/crates/benilla-app/src/ui_tooltip/tests.rs +++ b/crates/benilla-app/src/ui_tooltip/tests.rs @@ -1323,3 +1323,19 @@ fn the_feed_builds_the_pet_views_against_the_pet_and_rebuilds_them_on_its_level( ) ); } + +/// `0x492890` tears down on every change away from a non-null mouseover (`0x4928d5`), loss and +/// replacement alike, and not for a first hover or an unchanged one. +#[test] +fn every_change_away_from_a_hover_requests_the_fade() { + let (a, b) = (LastHover::Go(1), LastHover::Go(2)); + assert!(fades_on_change(a, b), "one object replaced by another"); + assert!( + fades_on_change(a, LastHover::Unit(2)), + "an object replaced by a unit" + ); + assert!(fades_on_change(LastHover::Unit(1), LastHover::Corpse(3))); + assert!(fades_on_change(a, LastHover::None), "hover lost"); + assert!(!fades_on_change(LastHover::None, a), "a first hover"); + assert!(!fades_on_change(a, a), "unchanged"); +} diff --git a/crates/benilla-ui/src/script/tests/engine_updates.rs b/crates/benilla-ui/src/script/tests/engine_updates.rs index 2d25bdf6..33e28260 100644 --- a/crates/benilla-ui/src/script/tests/engine_updates.rs +++ b/crates/benilla-ui/src/script/tests/engine_updates.rs @@ -72,9 +72,9 @@ fn a_frame_walked_after_the_tooltip_sees_this_frames_fade() { ) .unwrap(); take_log(&s); - s.tick(0.3); - assert_eq!(take_log(&s), "shown@0.4", "this frame's ramp, not the last"); - s.tick(0.3); + s.tick(1.5); + assert_eq!(take_log(&s), "shown@0.5", "this frame's ramp, not the last"); + s.tick(0.5); assert_eq!( take_log(&s), "hide hidden", diff --git a/crates/benilla-ui/src/script/tests/tooltip.rs b/crates/benilla-ui/src/script/tests/tooltip.rs index 0696ac5a..faa71207 100644 --- a/crates/benilla-ui/src/script/tests/tooltip.rs +++ b/crates/benilla-ui/src/script/tests/tooltip.rs @@ -331,7 +331,7 @@ fn fadeout_ramps_then_hides() { ) .unwrap(); // Half the ramp: still shown, alpha well below 1. - s.tick(0.25); + s.tick(1.5); s.run(r#"assert(TT5:IsShown(), "still shown mid-fade")"#) .unwrap(); s.resolve(); @@ -366,7 +366,7 @@ fn fadeout_ramps_then_hides() { assert_eq!(fresh_alpha, 1.0, "fresh content restored full alpha"); // A full ramp ends hidden and cleared. s.run("TT5:FadeOut()").unwrap(); - s.tick(0.6); + s.tick(2.1); s.run( r#" assert(not TT5:IsShown(), "hidden at ramp end") @@ -878,3 +878,195 @@ fn tooltip_anchor_type_round_trips_every_reachable_mode() { s.warnings() ); } + +/// `FadeOut` (`0x530ae0`) arms `[+0x3b8]` with the 2.0 s at `0x808e7c` on every call +/// (`0x530b10`); the update (`0x530b20`) holds 255 while at least half remains (`0x7ffa24` = 0.5, +/// `0x530c0c`) and below it sets `trunc(255 × left / half)` (`0x530c18`–`0x530c2c`), then hides at +/// zero (`0x530bc8`–`0x530be9`). +#[test] +fn fadeout_holds_a_second_then_ramps_over_the_next_and_every_call_rearms() { + let mut s = script(); + s.run( + r#" + FadeHost = CreateFrame("Frame", "FadeHost") + FadeTip = CreateFrame("GameTooltip", "FadeTip") + FadeTip:SetOwner(FadeHost, "ANCHOR_RIGHT") + FadeTip:SetText("Timber Wolf") + FadeTip:FadeOut() + "#, + ) + .unwrap(); + let alpha = |s: &UiScript| s.eval::("return FadeTip:GetAlpha()").unwrap(); + let shown = |s: &UiScript| s.eval::("return FadeTip:IsShown() == 1").unwrap(); + s.tick(0.5); + assert_eq!(alpha(&s), 1.0, "1.5 s left of 2: the hold"); + assert!(shown(&s)); + s.tick(1.0); + assert_eq!( + alpha(&s), + 127.0 / 255.0, + "0.5 s left: trunc(255 × 0.5 / 1.0)" + ); + assert!(shown(&s)); + s.tick(0.5); + assert!(!shown(&s), "2.0 s: the fade ends in the hide"); + + // A second call restarts the countdown from the full duration. + s.run( + r#" + FadeTip:SetOwner(FadeHost, "ANCHOR_RIGHT") + FadeTip:SetText("Timber Wolf") + FadeTip:FadeOut() + "#, + ) + .unwrap(); + s.tick(1.5); + assert_eq!(alpha(&s), 127.0 / 255.0); + s.run("FadeTip:FadeOut()").unwrap(); + s.tick(0.5); + assert_eq!(alpha(&s), 1.0, "re-armed: 1.5 s left, back in the hold"); + s.tick(1.0); + assert!( + shown(&s), + "the first arm's end has passed, the second's has not" + ); + s.tick(0.5); + assert!(!shown(&s)); + assert!(s.take_errors().is_empty()); +} + +/// `FadeOut` on an `ANCHOR_CURSOR` plate (mode 6) hides it at once through the hide slot +/// (`0x530ae3`–`0x530aff`), which un-owns it; the shown bit is not written, so only visibility +/// drops. +#[test] +fn fadeout_on_a_cursor_anchored_tooltip_hides_at_once() { + let mut s = script(); + s.run( + r#" + hid = 0 + CurHost = CreateFrame("Frame", "CurHost") + CurTip = CreateFrame("GameTooltip", "CurTip") + CurTip:SetScript("OnHide", function() hid = hid + 1 end) + CurTip:SetOwner(CurHost, "ANCHOR_CURSOR") + CurTip:AddLine("Copper Ore") + CurTip:Show() + assert(CurTip:IsVisible()) + hid = 0 + CurTip:FadeOut() + assert(not CurTip:IsVisible(), "mode 6 hides at once") + assert(CurTip:IsShown(), "through the hide slot alone: the shown bit stays (0x530aee)") + assert(hid == 1, "OnHide fired in the call") + assert(not CurTip:IsOwned(CurHost), "the hide slot un-owns") + assert(CurTip:NumLines() == 0, "and clears") + assert(CurTip:GetAlpha() == 1, "no fade ran") + "#, + ) + .unwrap(); + assert!(s.take_errors().is_empty()); +} + +/// Only `FadeOut`, the show, the `SetOwner` core and the ctor write the fade flag `[+0x3b4]`; +/// `AddLine` (`0x531630`) and `AddDoubleLine` append through `0x5303b0` and leave it. +#[test] +fn a_line_added_mid_fade_keeps_fading() { + let mut s = script(); + s.run( + r#" + LateHost = CreateFrame("Frame", "LateHost") + LateTip = CreateFrame("GameTooltip", "LateTip") + LateTip:SetOwner(LateHost, "ANCHOR_RIGHT") + LateTip:SetText("Linen Cloth") + LateTip:FadeOut() + "#, + ) + .unwrap(); + s.tick(1.5); + s.run( + r#" + LateTip:AddLine("Sell Price: 1c") + LateTip:AddDoubleLine("Vendor", "1c") + assert(math.abs(LateTip:GetAlpha() - 127 / 255) < 1e-6, "the appends leave the ramp, got " .. LateTip:GetAlpha()) + "#, + ) + .unwrap(); + s.tick(0.5); + s.run(r#"assert(not LateTip:IsShown(), "the fade ends on time")"#) + .unwrap(); + assert!(s.take_errors().is_empty()); +} + +/// `SetOwner` (`0x5310d0`) clears the shown bit (`0x531179`) and calls the tooltip's own hide +/// (`0x531183` → `0x530a60`: the core with no owner, which clears and fires `OnTooltipCleared`, +/// then the base hide's `OnHide`), and only then runs the core with the new owner, which clears +/// and fires again. +#[test] +fn set_owner_hides_the_tooltip_first() { + let mut s = script(); + s.run( + r#" + log = {} + OwnA = CreateFrame("Frame", "OwnA") + OwnB = CreateFrame("Frame", "OwnB") + ReTip = CreateFrame("GameTooltip", "ReTip") + ReTip:SetScript("OnTooltipCleared", function() table.insert(log, "cleared") end) + ReTip:SetScript("OnHide", function() table.insert(log, "hide") end) + ReTip:SetOwner(OwnA, "ANCHOR_RIGHT") + ReTip:SetText("first hover") + assert(ReTip:IsShown()) + log = {} + ReTip:SetOwner(OwnB, "ANCHOR_LEFT") + assert(table.concat(log, ",") == "cleared,hide,cleared", "got " .. table.concat(log, ",")) + assert(not ReTip:IsShown(), "hidden until the next content call") + assert(ReTip:IsOwned(OwnB), "owned by the new owner") + assert(ReTip:GetAnchorType() == "ANCHOR_LEFT") + "#, + ) + .unwrap(); + assert!(s.take_errors().is_empty()); +} + +/// Every hide un-owns through the core with no owner, which stores mode 7 (`0x530012`), so +/// `GetAnchorType` (`0x5313e0`) answers `"ANCHOR_NONE"` until the next `SetOwner`. +#[test] +fn get_anchor_type_answers_anchor_none_after_a_hide() { + let mut s = script(); + s.run( + r#" + NoneHost = CreateFrame("Frame", "NoneHost") + NoneTip = CreateFrame("GameTooltip", "NoneTip") + NoneTip:SetOwner(NoneHost, "ANCHOR_RIGHT") + NoneTip:SetText("Peacebloom") + assert(NoneTip:GetAnchorType() == "ANCHOR_RIGHT", "owned: the name round-trips") + NoneTip:Hide() + assert(NoneTip:GetAnchorType() == "ANCHOR_NONE", "got " .. NoneTip:GetAnchorType()) + "#, + ) + .unwrap(); + assert!(s.take_errors().is_empty()); +} + +/// The fade's end sets full alpha (`0x530bd8`) before the hide slot (`0x530be9`), so the hide's +/// `OnTooltipCleared` reads 255, not the last step of the ramp. +#[test] +fn the_fade_end_restores_full_alpha_before_its_hide() { + let mut s = script(); + s.run( + r#" + seen = nil + EndHost = CreateFrame("Frame", "EndHost") + EndTip = CreateFrame("GameTooltip", "EndTip") + EndTip:SetOwner(EndHost, "ANCHOR_RIGHT") + EndTip:SetText("Silverleaf") + EndTip:SetScript("OnTooltipCleared", function() seen = EndTip:GetAlpha() end) + EndTip:FadeOut() + "#, + ) + .unwrap(); + s.tick(1.5); + s.run(r#"assert(seen == nil and EndTip:GetAlpha() < 0.5)"#) + .unwrap(); + s.tick(0.5); + s.run(r#"assert(not EndTip:IsShown() and seen == 1, "cleared at alpha " .. tostring(seen))"#) + .unwrap(); + assert!(s.take_errors().is_empty()); +} diff --git a/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs b/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs index 76224607..6ecc5498 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs @@ -424,8 +424,12 @@ fn ts_recipe(spell_id: u32, name: &str, group: (u32, u32, &str)) -> TradeSkillRe fn set_trade_skill_item_indexes_visible_rows_not_raw_recipes() { let mut s = script(); s.set_screen_size(800.0, 600.0); - s.run(r#"local tt = CreateFrame("GameTooltip", "TT")"#) - .unwrap(); + // Owned, as the stock hover owns it before the setter; an unowned tooltip's show hides it. + s.run( + r#"local tt = CreateFrame("GameTooltip", "TT") + tt:SetOwner(CreateFrame("Frame", "TTOwner"), "ANCHOR_NONE")"#, + ) + .unwrap(); // Two groups, two recipes each. Visible order: [1] "Bolts" header, [2] Alpha Bolt, // [3] Beta Bolt, [4] "Cloak" header, [5] Alpha Cloak, [6] Beta Cloak. @@ -568,6 +572,11 @@ fn set_merchant_compare_item_answers_one_or_nil_per_candidate_slot() { r#" CreateFrame("GameTooltip", "ShoppingTooltip1"):Hide() CreateFrame("GameTooltip", "ShoppingTooltip2"):Hide() + -- Owned, as the stock compare owns them before the setter; an unowned tooltip's show + -- hides it. + local owner = CreateFrame("Frame", "CompareOwner") + ShoppingTooltip1:SetOwner(owner, "ANCHOR_NONE") + ShoppingTooltip2:SetOwner(owner, "ANCHOR_NONE") "#, ) .unwrap(); diff --git a/crates/benilla-ui/src/script/tests/tooltip_item/mechanics.rs b/crates/benilla-ui/src/script/tests/tooltip_item/mechanics.rs index e4133906..cb84d15e 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/mechanics.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/mechanics.rs @@ -189,6 +189,7 @@ fn item_render_hides_the_unit_health_bar() { s.set_unit( "mouseover", Some(UnitState { + guid: 0xF130_0000_0000_0100, exists: true, name: Some("Timber Wolf".into()), health: 30, @@ -212,6 +213,8 @@ fn item_render_hides_the_unit_health_bar() { local tt = CreateFrame("GameTooltip", "GameTooltip"); tt:Hide() local bar = CreateFrame("StatusBar", "GameTooltipStatusBar", tt) bar:SetPoint("TOPLEFT", tt, "BOTTOMLEFT", 2, -1); bar:SetWidth(100); bar:SetHeight(8) + -- The stock default anchor owns the plate; an unowned one's show hides it. + tt:SetScript("OnTooltipSetDefaultAnchor", function() GameTooltip:SetOwner(Slot6, "ANCHOR_NONE") end) "#, ) .unwrap(); @@ -840,3 +843,46 @@ fn random_property_template_hover_shows_the_placeholder() { "an instance's known roll replaces the placeholder — got {texts:?}" ); } + +/// An item whose template is not cached ends the builder in its own hide (`0x52b72e`–`0x52b73f`), +/// which un-owns and clears: the clear zeroes the remembered item id (`0x5300dd`), so the cache +/// callback (`0x52e530`) finds nothing to rebuild (`0x52e55e`), and the stock hover loop's +/// `IsOwned` refresh (`ContainerFrame.lua:657`) stops. The plate stays down until the next hover. +#[test] +fn an_uncached_item_hides_and_un_owns_until_the_next_hover() { + let mut s = script(); + s.run( + r#" + shows = 0 + UncSlot = CreateFrame("Button", "UncSlot") + UncTip = CreateFrame("GameTooltip", "UncTip") + UncTip:SetScript("OnShow", function() shows = shows + 1 end) + UncTip:SetOwner(UncSlot, "ANCHOR_RIGHT") + SetItemByIdForTest(UncTip, 4242) + assert(UncTip:NumLines() == 0, "no line for a template in flight") + assert(not UncTip:IsShown(), "the builder's own hide") + assert(not UncTip:IsOwned(UncSlot), "and its un-own: the hover loop stops") + assert(shows == 0) + "#, + ) + .unwrap(); + s.set_item_template( + 4242, + ItemTemplateView { + name: "Copper Bar".into(), + quality: 1, + ..Default::default() + }, + ); + s.tick(0.1); + s.run( + r#" + assert(not UncTip:IsShown(), "the template's arrival rebuilds nothing") + UncTip:SetOwner(UncSlot, "ANCHOR_RIGHT") + SetItemByIdForTest(UncTip, 4242) + assert(UncTip:IsShown() and UncTipTextLeft1:GetText() == "Copper Bar", "the next hover") + "#, + ) + .unwrap(); + assert!(s.take_errors().is_empty()); +} diff --git a/crates/benilla-ui/src/script/tests/tooltip_unit.rs b/crates/benilla-ui/src/script/tests/tooltip_unit.rs index 900b99a2..81a2a9d0 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_unit.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_unit.rs @@ -263,6 +263,7 @@ fn the_tooltip_title_is_the_pvp_name() { s.set_unit( "target", Some(UnitState { + guid: 0xF130_0000_0000_0100, exists: true, name: Some("Defias Civilian".into()), level: 20, @@ -297,6 +298,7 @@ fn faction_line_and_civilian_gate() { s.set_unit( "target", Some(UnitState { + guid: 0xF130_0000_0000_0101, exists: true, name: Some("Marshal McBride".into()), level: 20, @@ -325,6 +327,7 @@ fn faction_line_and_civilian_gate() { s.set_unit( "target", Some(UnitState { + guid: 0xF130_0000_0000_0102, exists: true, name: Some("Defias Civilian".into()), level: 20, @@ -351,6 +354,7 @@ fn faction_line_and_civilian_gate() { s.set_unit( "target", Some(UnitState { + guid: 0xF130_0000_0000_0103, exists: true, name: Some("Defias Civilian".into()), level: 25, @@ -386,6 +390,7 @@ fn level_line_variants() { s.set_unit( "target", Some(UnitState { + guid: 0xF130_0000_0000_0104, exists: true, name: Some("Bandit".into()), level: 32, @@ -411,6 +416,7 @@ fn level_line_variants() { s.set_unit( "target", Some(UnitState { + guid: 0xF130_0000_0000_0105, exists: true, name: Some("Slain Wolf".into()), level: 3, @@ -432,6 +438,7 @@ fn level_line_variants() { s.set_unit( "target", Some(UnitState { + guid: 0xF130_0000_0000_0106, exists: true, name: Some("Kazzak".into()), level: 63, @@ -451,6 +458,7 @@ fn level_line_variants() { .unwrap(); // A hostile unit (reaction 2 or less) 10 levels up reads "??". let ten_up = |reaction: u8, is_player: bool| UnitState { + guid: 0xF130_0000_0000_0107, exists: true, name: Some("Elder".into()), level: 70, @@ -512,6 +520,7 @@ fn a_players_level_line_ignores_the_snapshots_creature_type() { ) .unwrap(); let druid = |reaction: u8, creature_type: Option<&str>| UnitState { + guid: 0xF130_0000_0000_0108, exists: true, name: Some("Fenwick".into()), level: 40, @@ -624,32 +633,34 @@ fn world_hover_drive_and_health_watcher() { s.set_unit("target", Some(as_target)); s.run(r#"assert(GameTooltipStatusBar:GetValue() == 8, "the watcher follows the guid")"#) .unwrap(); - s.tick(0.5); + s.tick(2.0); s.run(r#"assert(not GameTooltip:IsShown(), "faded out after the ramp")"#) .unwrap(); assert!(s.take_errors().is_empty()); } -/// The minimap blip tooltip (`minimap_tooltip`): refused without `UIParent`, otherwise one line -/// seated above the cursor, world-owned so the shared fade hides it on hover loss. +/// The minimap blip tooltip (`minimap_tooltip`): refused without `Minimap`, otherwise one line +/// seated above the cursor in mode 6, which follows the cursor and hides at once on hover loss. #[test] -fn minimap_blip_tooltip_shows_and_fades() { +fn minimap_blip_tooltip_shows_follows_and_hides() { let mut s = script(); s.set_screen_size(800.0, 600.0); s.run(r#"CreateFrame("GameTooltip", "GameTooltip")"#) .unwrap(); assert!( - !s.minimap_tooltip("Stormwind", 700.0, 500.0, false), - "no UIParent yet: the show refuses" + !s.minimap_tooltip("Stormwind", false), + "no Minimap yet: the show refuses" ); s.run( r#" local up = CreateFrame("Frame", "UIParent") up:SetPoint("BOTTOMLEFT", 0, 0); up:SetWidth(800); up:SetHeight(600) + CreateFrame("Minimap", "Minimap", up) "#, ) .unwrap(); - assert!(s.minimap_tooltip("Stormwind", 795.0, 500.0, false)); + s.mouse_move(795.0, 500.0); + assert!(s.minimap_tooltip("Stormwind", false)); s.run( r#" assert(GameTooltipTextLeft1:GetText() == "Stormwind") @@ -669,13 +680,18 @@ fn minimap_blip_tooltip_shows_and_fades() { r#"assert(GameTooltip:GetRight() <= 800, "clamped inside the screen, got " .. tostring(GameTooltip:GetRight()))"#, ) .unwrap(); - s.world_tooltip_move(400.0, 300.0); - s.run(r#"assert(GameTooltipTextLeft1:GetText() == "Stormwind", "content survives a move")"#) - .unwrap(); - // Hover loss rides the shared world fade. - s.world_tooltip_fade(); - s.tick(0.6); - s.run(r#"assert(not GameTooltip:IsShown(), "faded out after the ramp")"#) + s.mouse_move(400.0, 300.0); + s.resolve(); + s.run( + r#" + assert(GameTooltipTextLeft1:GetText() == "Stormwind", "content survives a move") + assert(GameTooltip:GetBottom() == 300, "the plate follows the cursor") + "#, + ) + .unwrap(); + // Hover loss hides at once. + s.world_tooltip_hide(); + s.run(r#"assert(not GameTooltip:IsShown(), "hidden with no fade")"#) .unwrap(); assert!(s.take_errors().is_empty()); } @@ -691,6 +707,7 @@ fn a_cursor_seated_plate_lands_on_the_cursor_under_the_ui_scale() { r#" local up = CreateFrame("Frame", "UIParent") up:SetPoint("BOTTOMLEFT", 0, 0); up:SetPoint("TOPRIGHT", 0, 0) + CreateFrame("Minimap", "Minimap", up) CreateFrame("GameTooltip", "GameTooltip", UIParent) "#, ) @@ -711,16 +728,219 @@ fn a_cursor_seated_plate_lands_on_the_cursor_under_the_ui_scale() { ) .unwrap() }; - assert!(s.minimap_tooltip("Stormwind", 400.0, 300.0, false)); + s.mouse_move(400.0, 300.0); + assert!(s.minimap_tooltip("Stormwind", false)); let (x, y) = at(&mut s); assert!( (x - 400.0).abs() < 1e-3 && (y - 300.0).abs() < 1e-3, "({x}, {y})" ); - s.world_tooltip_move(200.0, 100.0); + s.mouse_move(200.0, 100.0); let (x, y) = at(&mut s); assert!( (x - 200.0).abs() < 1e-3 && (y - 100.0).abs() < 1e-3, "({x}, {y})" ); } + +/// The unit builder shows `StatusBar` with a full show (`0x52a13c`/`0x52a146`), and the +/// tooltip clear hides it the same way (`0x53009b`/`0x5300a1`) only while the tooltip holds a unit +/// (`0x530070`), first in the clear, so its `OnHide` runs before `OnTooltipCleared`. +#[test] +fn the_health_bar_shows_and_hides_through_its_own_transitions() { + let mut s = script(); + s.set_unit("target", Some(wolf())); + s.run( + r#" + log = {} + BarHost = CreateFrame("Frame", "BarHost") + BarTip = CreateFrame("GameTooltip", "BarTip") + local bar = CreateFrame("StatusBar", "BarTipStatusBar", BarTip) + bar:Hide() + bar:SetScript("OnShow", function() table.insert(log, "bar show") end) + bar:SetScript("OnHide", function() table.insert(log, "bar hide") end) + BarTip:SetScript("OnTooltipCleared", function() table.insert(log, "cleared") end) + BarTip:SetScript("OnHide", function() table.insert(log, "tip hide") end) + BarTip:SetOwner(BarHost, "ANCHOR_RIGHT") + log = {} + assert(BarTip:SetUnit("target") == 1) + assert(BarTipStatusBar:IsShown(), "the unit shows the bar") + local n = 0 + for _, e in ipairs(log) do if e == "bar show" then n = n + 1 end end + assert(n == 1, "the bar's OnShow fired once: " .. table.concat(log, ",")) + log = {} + BarTip:ClearLines() + assert(not BarTipStatusBar:IsShown(), "the clear hides the bar") + assert(table.concat(log, ",") == "bar hide,cleared", "got " .. table.concat(log, ",")) + + -- No unit held: the clear leaves the bar alone. + BarTip:SetOwner(BarHost, "ANCHOR_RIGHT") + BarTip:SetText("Peacebloom") + BarTipStatusBar:Show() + log = {} + BarTip:ClearLines() + assert(BarTipStatusBar:IsShown(), "no unit: the bar stays") + assert(table.concat(log, ",") == "cleared", "got " .. table.concat(log, ",")) + + -- The hide's clear runs before the base hide. + BarTip:SetOwner(BarHost, "ANCHOR_RIGHT") + BarTip:SetUnit("target") + log = {} + BarTip:Hide() + assert(table.concat(log, ",") == "bar hide,cleared,tip hide", "got " .. table.concat(log, ",")) + "#, + ) + .unwrap(); + assert!(s.take_errors().is_empty()); +} + +/// The world handler's fade request is `0x530ae0`, which hides a mode-6 plate at once; a +/// cursor-seated GameObject plate is owned in mode 6 (`0x492a1e`), so hover loss hides it. +#[test] +fn a_cursor_seated_world_plate_hides_at_once_on_hover_loss() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + local up = CreateFrame("Frame", "UIParent"); up:SetAllPoints() + CreateFrame("GameTooltip", "GameTooltip") + "#, + ) + .unwrap(); + assert!(s.world_tooltip_gameobject("Brill", &[], true)); + s.run(r#"assert(GameTooltip:GetAnchorType() == "ANCHOR_CURSOR", "seated in mode 6")"#) + .unwrap(); + s.world_tooltip_fade(); + s.run(r#"assert(not GameTooltip:IsVisible(), "gone at once")"#) + .unwrap(); + assert!(s.take_errors().is_empty()); +} + +/// The minimap blip tooltip is owned by `Minimap` in mode 6 (`0x4eb19c`, `0x4eb587`), and when no blip is hovered +/// `0x4eb0c0` hides it directly (`0x4eb12b`–`0x4eb166`), with no fade. +#[test] +fn a_minimap_blip_plate_hides_at_once_on_hover_loss() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + local up = CreateFrame("Frame", "UIParent"); up:SetAllPoints() + CreateFrame("Minimap", "Minimap", up) + CreateFrame("GameTooltip", "GameTooltip") + "#, + ) + .unwrap(); + assert!(s.minimap_tooltip("Stormwind", false)); + s.run( + r#" + assert(GameTooltip:IsShown()) + assert(GameTooltip:IsOwned(Minimap), "owned by the Minimap, whose probe it is") + assert(not GameTooltip:IsOwned(UIParent)) + assert(GameTooltip:GetAnchorType() == "ANCHOR_CURSOR", "seated in mode 6") + "#, + ) + .unwrap(); + s.world_tooltip_hide(); + s.run(r#"assert(not GameTooltip:IsShown(), "gone at once")"#) + .unwrap(); + assert!(s.take_errors().is_empty()); +} + +/// The world handler tears down on every mouseover change (`0x492909`), so moving from one +/// cursor-seated object straight to another hides the first plate (mode 6, `0x530ae3`) and shows +/// the second afresh. Each seat clears twice: the SetOwner core's clear (`0x52ffe6`), then the +/// builder's own (`0x52aa40`). +#[test] +fn a_cursor_plate_replaced_by_another_hides_then_shows_and_clears_twice_per_seat() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + log = {} + local up = CreateFrame("Frame", "UIParent"); up:SetAllPoints() + local tt = CreateFrame("GameTooltip", "GameTooltip"); tt:Hide() + tt:SetScript("OnTooltipCleared", function() table.insert(log, "cleared") end) + tt:SetScript("OnShow", function() table.insert(log, "show") end) + tt:SetScript("OnHide", function() table.insert(log, "hide") end) + "#, + ) + .unwrap(); + assert!(s.world_tooltip_gameobject("Brill", &[], true)); + s.run(r#"assert(table.concat(log, ",") == "cleared,cleared,show", table.concat(log, ",")) log = {}"#) + .unwrap(); + // The driver's change: the teardown, then the next seat. + s.world_tooltip_fade(); + assert!(s.world_tooltip_gameobject("Tirisfal Glades", &[], true)); + s.run( + r#" + assert(table.concat(log, ",") == "cleared,hide,cleared,cleared,show", table.concat(log, ",")) + assert(GameTooltipTextLeft1:GetText() == "Tirisfal Glades") + "#, + ) + .unwrap(); + assert!(s.take_errors().is_empty()); +} + +/// A Lua content re-set of the world plate (an `UPDATE_MOUSEOVER_UNIT` handler's `SetUnit`) keeps +/// it the world plate, so hover loss still fades it; a re-own by a window's `SetOwner` does not. +#[test] +fn hover_loss_fades_a_re_set_world_plate_but_not_a_re_owned_one() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.set_unit("mouseover", Some(wolf())); + s.run( + r#" + UIParent = CreateFrame("Frame", "UIParent"); UIParent:SetAllPoints() + local tt = CreateFrame("GameTooltip", "GameTooltip"); tt:Hide() + tt:SetScript("OnTooltipSetDefaultAnchor", function() + GameTooltip:SetOwner(UIParent, "ANCHOR_NONE") + end) + tt:RegisterEvent("UPDATE_MOUSEOVER_UNIT") + tt:SetScript("OnEvent", function() GameTooltip:SetUnit("mouseover") end) + "#, + ) + .unwrap(); + assert!(s.world_tooltip_unit("mouseover")); + assert!( + s.world_tooltip_up(), + "the re-set plate is still the world's" + ); + s.world_tooltip_fade(); + s.tick(2.0); + s.run(r#"assert(not GameTooltip:IsShown(), "faded out")"#) + .unwrap(); + + assert!(s.world_tooltip_unit("mouseover")); + s.run( + r#" + GameTooltip:SetOwner(CreateFrame("Button", "Bag1"), "ANCHOR_RIGHT") + GameTooltip:SetText("Bag slot") + "#, + ) + .unwrap(); + assert!(!s.world_tooltip_up()); + s.world_tooltip_fade(); + s.tick(2.0); + s.run(r#"assert(GameTooltip:IsShown() and GameTooltip:GetAlpha() == 1, "the window's tooltip stays")"#) + .unwrap(); + assert!(s.take_errors().is_empty()); +} + +/// A unit is found by its guid (`0x52a021`); guid 0 finds no object and the builder hides through +/// its own hide (`0x52a04e`–`0x52a058`), so `SetUnit` answers nil. +#[test] +fn a_unit_with_guid_zero_is_no_unit() { + let mut s = script(); + s.set_unit("target", Some(UnitState { guid: 0, ..wolf() })); + s.run( + r#" + ZeroHost = CreateFrame("Frame", "ZeroHost") + ZeroTip = CreateFrame("GameTooltip", "ZeroTip") + ZeroTip:SetOwner(ZeroHost, "ANCHOR_RIGHT") + assert(ZeroTip:SetUnit("target") == nil, "no object, no plate") + assert(not ZeroTip:IsShown() and not ZeroTip:IsOwned(ZeroHost)) + "#, + ) + .unwrap(); + 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 dfbca95c..ba430a57 100644 --- a/crates/benilla-ui/src/script/tooltip/mod.rs +++ b/crates/benilla-ui/src/script/tooltip/mod.rs @@ -267,9 +267,9 @@ pub(super) fn write_cell( model.touch_measure(rh); } -/// Hides and blanks every line cell and resets the counters; the caller fires `OnTooltipCleared` -/// outside the model borrow. -pub(super) fn clear_content(model: &mut Model, h: FrameHandle) { +/// Blanks every line cell and resets the counters and the subject: the model half of the clear +/// ([`clear_silent`]). +fn clear_content(model: &mut Model, h: FrameHandle) { let (lefts, rights) = match model.arena.frame(h).map(|f| &f.kind_state) { Some(KindState::Tooltip(t)) => (t.left_lines.clone(), t.right_lines.clone()), _ => return, @@ -289,20 +289,37 @@ pub(super) fn clear_content(model: &mut Model, h: FrameHandle) { t.min_width = 0.0; // Clear drops the subject and unregisters its health watcher (`0x5300ad`, `0x53007c`). 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 - .arena - .frame(h) - .and_then(|f| f.name.clone()) - .and_then(|n| model.arena.lookup(&format!("{n}StatusBar"))); +} + +/// The tooltip's `StatusBar` health bar (`[+0x348]`), if it has one. +pub(super) fn status_bar(model: &Model, h: FrameHandle) -> Option { + let name = model.arena.frame(h)?.name.as_deref()?; + model.arena.lookup(&format!("{name}StatusBar")) +} + +/// The tooltip clear (`0x530050`) without its last act, the `OnTooltipCleared` fire: while the +/// tooltip holds a unit (`0x530070`) the health bar hides by its own transition, shown bit then +/// hide slot (`0x53009b`/`0x5300a1`), so its `OnHide` runs; then the lines, counters and subject. +pub(super) fn clear_silent(lua: &Lua, h: FrameHandle) { + let bar = { + let mut model = lua.app_data_mut::().expect("model app_data"); + let held = tip_mut(&mut model, h).is_ok_and(|t| t.unit_guid.is_some()); + held.then(|| status_bar(&model, h)).flatten() + }; if let Some(bar) = bar { - model.arena.set_shown(bar, false); + visibility::set_shown(lua, bar, false); } + let mut model = lua.app_data_mut::().expect("model app_data"); + clear_content(&mut model, h); +} + +/// The tooltip clear (`0x530050`): [`clear_silent`], then `OnTooltipCleared` (`0x530263`). +pub(super) fn clear(lua: &Lua, h: FrameHandle) { + clear_silent(lua, h); + fire_cleared(lua, h); } /// Fires `OnTooltipCleared`; a script error is recorded, never propagated. @@ -318,23 +335,28 @@ pub(super) fn fire_cleared(lua: &Lua, h: FrameHandle) { } } -/// A content `Set*`'s tail: shown with lines, hidden without. The reference leaves an uncached -/// tooltip cleared and collapsed to nothing; this plate keeps its declared size, so it hides. It -/// takes the base transitions past the tooltip's own ([`hide_override`] would un-own it), to keep -/// the owner, which the hover loop's `IsOwned` needs. +/// A content `Set*`'s tail: the builders' show, the shown bit and the show slot (`0x52a711`/ +/// `0x52a71b`, `0x52e14e`), whose override restores full alpha and disarms a fade, or, for a plate +/// with no owner or no lines, hides and un-owns it (`0x530aba`). An item builder whose template is +/// not cached ends the same way, by its own hide (`0x52b735`–`0x52b73f`), and its clear zeroes the +/// remembered item id (`0x5300dd`) that the cache callback `0x52e530` needs (`0x52e55e`), so the +/// plate stays down until the next hover. +/// +/// A spell render still waiting on its view ([`Model::spell_tooltip_waits`], filled by the host +/// ahead of a hover) is the one exception: the reference reads spells synchronously and has no +/// such state. That plate takes the base hide, keeping its owner and its wait, so the view's +/// arrival renders it in place. pub(super) fn show_or_hide_empty(lua: &Lua, h: FrameHandle) { - let lines = { + let waiting = { let mut model = lua.app_data_mut::().expect("model app_data"); - match tip_mut(&mut model, h) { - Ok(t) => t.num_lines, - Err(_) => 0, - } + let empty = tip_mut(&mut model, h).is_ok_and(|t| t.num_lines == 0); + empty && model.spell_tooltip_waits.contains_key(&h) }; - visibility::set_shown_bit(lua, h, lines > 0); - if lines > 0 { - visibility::base_show(lua, h); - } else { + if waiting { + visibility::set_shown_bit(lua, h, false); visibility::base_hide(lua, h); + } else { + visibility::set_shown(lua, h, true); } } @@ -360,44 +382,125 @@ pub(in crate::script) fn show_override(lua: &Lua, h: FrameHandle) { } /// The tooltip's hide (`0x530a60`, slot `+0x84`), however the hide comes, its parent's included: -/// the SetOwner core with no owner (`0x52ffe0`), then the base hide. The core clears the content -/// and fires `OnTooltipCleared` (`0x530050`, whose last act is the fire at `0x530263`), then -/// restores full alpha (`0x52fff4`), disarms the fade (`0x530002`) and drops the owner -/// (`0x53000c`); the mode it leaves at 7 (`0x530012`) is not kept (see `GetAnchorType`). It runs -/// whether or not the tooltip is shown: only the base hide gates on visibility (`0x76ad5b`). +/// the SetOwner core with no owner ([`set_owner_core`]), then the base hide. It runs whether or +/// not the tooltip is shown: only the base hide gates on visibility (`0x76ad5b`). pub(in crate::script) fn hide_override(lua: &Lua, h: FrameHandle) { - { + let is_tip = { let mut model = lua.app_data_mut::().expect("model app_data"); - if tip_mut(&mut model, h).is_err() { - drop(model); - visibility::base_hide(lua, h); - return; - } - clear_content(&mut model, h); - } - fire_cleared(lua, h); - { - let mut model = lua.app_data_mut::().expect("model app_data"); - full_alpha(&mut model, h); - if let Ok(t) = tip_mut(&mut model, h) { - t.owner = None; - } + tip_mut(&mut model, h).is_ok() + }; + if is_tip { + set_owner_core(lua, h, None, TooltipAnchor::None, 0.0, 0.0); } visibility::base_hide(lua, h); } -/// New content cancels a running fade at full alpha; with no fade running, an alpha the addon set -/// stays, since only the paths in [`full_alpha`] reset it. -fn cancel_fade(model: &mut Model, h: FrameHandle) { - if let Ok(t) = tip_mut(model, h) { - if t.fade_left.take().is_some() { - model.arena.set_alpha(h, 1.0); +/// The SetOwner core (`0x52ffe0`), reached from the Lua `SetOwner` after its hide, from the +/// tooltip's own hide with no owner, and from the engine's mode-6 seats (`0x492a1e`, `0x4eb587`). +/// It clears and fires `OnTooltipCleared` ([`clear`], `0x52ffe6`), restores full alpha +/// (`0x52fff4`), disarms the fade (`0x530002`), stores the owner (`0x53000c`) and mode 7, +/// `ANCHOR_NONE` (`0x530012`); only an owner overwrites the mode with the caller's (`0x530031`) +/// and applies the anchor (`0x52fe90`, [`apply_anchor`]). +pub(in crate::script) fn set_owner_core( + lua: &Lua, + h: FrameHandle, + owner: Option, + anchor: TooltipAnchor, + x: f32, + y: f32, +) { + clear(lua, h); + let mut model = lua.app_data_mut::().expect("model app_data"); + full_alpha(&mut model, h); + let Ok(t) = tip_mut(&mut model, h) else { + return; + }; + t.owner = owner; + t.anchor = TooltipAnchor::None; + // A re-own, or the hide's un-own, ends the world plate; a content re-set alone does not. + t.world_owned = false; + if let Some(owner) = owner { + t.anchor = anchor; + apply_anchor(&mut model, h, owner, anchor, x, y); + } +} + +/// The anchor apply (`0x52fe90`, with the core's 1): `ANCHOR_PRESERVE` returns first +/// (`0x52fead`); every other mode clears all points (`0x767ed0` at `0x52fec2`); modes 0..5 then +/// set one point on the owner (`0x52ffbc`), and 6 and 7 stop there. The clear lets +/// `GameTooltip_SetDefaultAnchor` (GameTooltip.lua:76-77) set one point with no stale owner +/// anchor; mode 6 is then placed per frame by `cursor_anchor`. +fn apply_anchor( + model: &mut Model, + h: FrameHandle, + owner: FrameHandle, + anchor: TooltipAnchor, + x: f32, + y: f32, +) { + let pts = match anchor { + TooltipAnchor::Right => Some((Point::BottomLeft, Point::TopRight)), + TooltipAnchor::Left => Some((Point::BottomRight, Point::TopLeft)), + TooltipAnchor::TopRight => Some((Point::BottomRight, Point::TopRight)), + TooltipAnchor::TopLeft => Some((Point::BottomLeft, Point::TopLeft)), + TooltipAnchor::BottomRight => Some((Point::TopLeft, Point::BottomRight)), + TooltipAnchor::BottomLeft => Some((Point::TopRight, Point::BottomLeft)), + TooltipAnchor::Cursor | TooltipAnchor::None | TooltipAnchor::Preserve => None, + }; + // The drop is a named retarget to no targets, not a conservative touch. + if matches!(anchor, TooltipAnchor::Cursor | TooltipAnchor::None) { + let dropped: Option> = match model.layout_inputs.get_mut(&h) { + Some(input) if !input.anchors.is_empty() => { + Some(input.anchors.drain(..).map(|a| a.relative_to).collect()) + } + _ => None, + }; + if let Some(old_targets) = dropped { + model.touch_layout_retarget_frame(h, &old_targets, &[]); + } + } + if let Some((own, rel)) = pts { + let owner_id = model.frame_id(owner); + let new = Anchor::new(own, owner_id, rel, x, y); + let input = model.layout_inputs.entry(h).or_default(); + // No-op when unchanged, so the bag hover's per-frame SetOwner does not dirty the layout. + let same = input.anchors.len() == 1 + && crate::script::object::anchor_bits_eq(&input.anchors[0], &new); + if !same { + // A named retarget: the cached layout graph re-points this edge instead of being + // rebuilt. + let old_targets: Vec = input.anchors.iter().map(|a| a.relative_to).collect(); + input.anchors = vec![new]; + model.touch_layout_retarget_frame(h, &old_targets, &[owner_id]); } } } -/// `SetAlpha(255)`, unconditional: the SetOwner core `0x52ffe0` does it first (`0x52fff4`), and -/// Show (`0x530a80`) and its self-hide (`0x530a60`, the core with no owner) reach it too. +/// The fade request (`0x530ae0`), from the Lua `FadeOut` (`0x531fba`) and the world mouseover's +/// change (`0x492909`). A cursor-anchored plate, mode 6, hides at once through the hide slot, +/// with no shown-bit write, and is disarmed (`0x530ae3`–`0x530af4`); any other mode arms the fade +/// with the full duration on every call (`0x530b00`–`0x530b10`), so a second call restarts it. +pub(in crate::script) fn fade_out(lua: &Lua, h: FrameHandle) { + { + let mut model = lua.app_data_mut::().expect("model app_data"); + let Ok(t) = tip_mut(&mut model, h) else { + return; + }; + if t.anchor != TooltipAnchor::Cursor { + t.fade_left = Some(TOOLTIP_FADE_SECS); + return; + } + } + visibility::hide(lua, h); + let mut model = lua.app_data_mut::().expect("model app_data"); + if let Ok(t) = tip_mut(&mut model, h) { + t.fade_left = None; + } +} + +/// `SetAlpha(255)` and the fade disarmed, unconditional: the SetOwner core `0x52ffe0` does both +/// (`0x52fff4`, `0x530002`), and Show (`0x530a80`) and its self-hide (`0x530a60`, the core with no +/// owner) reach them too. fn full_alpha(model: &mut Model, h: FrameHandle) { if let Ok(t) = tip_mut(model, h) { t.fade_left = None; @@ -524,9 +627,9 @@ fn text_of(v: Option<&Value>) -> String { } } -/// Appends one line and cancels any fade, without showing the tooltip: `AddLine` callers call -/// `Show`. The reference's line core drops a line whose sides are both empty (`0x530270`); this -/// appends it. +/// Appends one line, without showing the tooltip or touching a fade (`0x5303b0`): `AddLine` +/// callers call `Show`. The reference's line core drops a line whose sides are both empty +/// (`0x530270`); this appends it. pub(super) fn append_line( lua: &Lua, this: &Table, @@ -562,7 +665,6 @@ pub(super) fn append_line( if let Ok(t) = tip_mut(&mut model, h) { t.num_lines = n; } - cancel_fade(&mut model, h); Ok(()) } @@ -706,9 +808,11 @@ pub(super) fn layout_tooltips(model: &mut Model) { } /// The tooltip's own update after its `OnUpdate` (`0x530b20`, from `0x530b9c`): an armed fade -/// counts down by the elapsed time (`0x530baa`–`0x530bc2`) and ramps the alpha; at zero the full +/// counts down by the elapsed time (`0x530baa`–`0x530bc2`). At zero or below it ends in the full /// hide (`0x530bd8` full alpha, `0x530be9` the hide slot), whose `OnHide` runs here, in the -/// tooltip's place in the walk. Nothing gates it on shown, owner or lines. +/// tooltip's place in the walk. Otherwise the alpha is 255 while at least half the duration +/// remains (`0x7ffa24` = 0.5, `0x530c0c`), else `trunc(255 × left / half)` (`0x530c18`–`0x530c2c`, +/// `0x40a2b0` truncates). Nothing gates it on shown, owner or lines. pub(in crate::script) fn update(lua: &Lua, h: FrameHandle, elapsed: f32) { let done = { let mut model = lua.app_data_mut::().expect("model app_data"); @@ -723,16 +827,34 @@ pub(in crate::script) fn update(lua: &Lua, h: FrameHandle, elapsed: f32) { return; }; let left = left - elapsed; + tip.fade_left = Some(left); if left <= 0.0 { true } else { - tip.fade_left = Some(left); - model.arena.set_alpha(h, left / TOOLTIP_FADE_SECS); + model.arena.set_alpha(h, fade_alpha(left)); false } }; - // The end: shown bit cleared, then the hide slot, as the Lua `Hide` does. + // The end: full alpha (`0x530bd8`), then the shown bit cleared and the hide slot (`0x530bdf`, + // `0x530be9`), as the Lua `Hide` does, so the hide's `OnTooltipCleared` sees 255. if done { + lua.app_data_mut::() + .expect("model app_data") + .arena + .set_alpha(h, 1.0); set_shown(lua, h, false); } } + +/// The fade's alpha with `left` seconds to go, in whole 255ths as the reference's `SetAlpha` +/// takes them. The product is formed in f64, where `left × 255` is exact as in the x87 original. +fn fade_alpha(left: f32) -> f32 { + let half = f64::from(TOOLTIP_FADE_SECS) * 0.5; + let left = f64::from(left); + let units = if left < half { + (left / half * 255.0).trunc() + } else { + 255.0 + }; + (units / 255.0) as f32 +} diff --git a/crates/benilla-ui/src/script/tooltip/verbs.rs b/crates/benilla-ui/src/script/tooltip/verbs.rs index b3a745a7..1432935e 100644 --- a/crates/benilla-ui/src/script/tooltip/verbs.rs +++ b/crates/benilla-ui/src/script/tooltip/verbs.rs @@ -7,123 +7,79 @@ use crate::script::binding_abi::optional_string; use crate::script::object::frame_handle_of; use crate::script::region::region_handle_of; use crate::script::Model; -use crate::widget::{KindState, TooltipAnchor, TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD}; +use crate::widget::{KindState, TooltipAnchor, TOOLTIP_LINE_GAP, TOOLTIP_PAD}; use super::{ - append_line, bool_arg, clear_content, fire_cleared, full_alpha, parse_line_color, - parse_line_tail, set_shown, text_of, tip_mut, with_tip, write_cell, REG_TOOLTIP_METHODS, + append_line, bool_arg, clear, parse_line_color, parse_line_tail, set_owner_core, set_shown, + text_of, tip_mut, with_tip, write_cell, REG_TOOLTIP_METHODS, }; pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { let m = lua.create_table()?; - // SetOwner(owner, anchorType [, x, y]): the binding `0x5310d0` calls the core `0x52ffe0`, - // which sets full alpha (`0x52fff4`), disarms the fade, stores the owner (`0x53000c`), mode - // and offsets, then applies the anchor (`0x52fe90`). The content clears and OnTooltipCleared - // fires, so a new owner never inherits the last hover's lines or money. + // SetOwner(owner, anchorType [, x, y]) (`0x5310d0`): first the tooltip's own hide, the shown + // bit cleared (`0x531179`) and the hide slot (`0x531183`), so a shown tooltip fires + // `OnTooltipCleared` and `OnHide` and stays hidden until a content call shows it; then the + // core with the new owner ([`set_owner_core`], `0x5313cf`), which clears and fires again. // // A missing, non-string or unknown anchorType is mode 0, ANCHOR_LEFT, with no error: the // binding's local starts at 0 (`0x53120d`). The core's pre-store of 7 (`0x530012`) survives // only a NULL owner, the un-own path (`0x530a60`) of every hide. - // - // The anchor apply returns first for ANCHOR_PRESERVE (8, `0x52fead`), then clears all points - // (`0x767ed0` at `0x52fec2`) for modes 0..7; modes 0..5 then set a point (`0x52ffbc`). m.set( "SetOwner", lua.create_function( - |lua, (this, owner, anchor, x, y): (Table, Table, Value, Option, Option)| { + |lua, (this, owner, anchor, x, y): (Table, Value, Value, Option, Option)| { let h = frame_handle_of(lua, &this)?; + set_shown(lua, h, false); + // The owner is read after the hide (`0x531189`), so a bad one raises on a + // hidden tooltip. + let owner: Table = lua.unpack(owner)?; let owner_h = frame_handle_of(lua, &owner)?; // The gate is `lua_isstring` (`0x6f3510`): a string or number is read, and any // other type counts as absent rather than raising. let anchor = optional_string(lua, &anchor); + // The warning on an unknown name is ours; the reference is silent, and Lua sees + // no difference. + let anchor = match anchor + .as_deref() + .map(str::to_ascii_uppercase) + .as_deref() + .unwrap_or("") { - let mut model = lua.app_data_mut::().expect("model app_data"); - clear_content(&mut model, h); - full_alpha(&mut model, h); - tip_mut(&mut model, h)?.owner = Some(owner_h); - let owner_id = model.frame_id(owner_h); - // Resolved once: the placement and `GetAnchorType` both read this value. The - // warning on an unknown name is ours; the reference is silent, and Lua sees no - // difference. - let anchor = match anchor - .as_deref() - .map(str::to_ascii_uppercase) - .as_deref() - .unwrap_or("") - { - "ANCHOR_LEFT" => TooltipAnchor::Left, - "ANCHOR_RIGHT" => TooltipAnchor::Right, - "ANCHOR_BOTTOMLEFT" => TooltipAnchor::BottomLeft, - "ANCHOR_BOTTOMRIGHT" => TooltipAnchor::BottomRight, - "ANCHOR_TOPLEFT" => TooltipAnchor::TopLeft, - "ANCHOR_TOPRIGHT" => TooltipAnchor::TopRight, - "ANCHOR_CURSOR" => TooltipAnchor::Cursor, - "ANCHOR_NONE" => TooltipAnchor::None, - "ANCHOR_PRESERVE" => TooltipAnchor::Preserve, - "" => TooltipAnchor::Left, - other => { - model.record_warning(format!( + "ANCHOR_LEFT" => TooltipAnchor::Left, + "ANCHOR_RIGHT" => TooltipAnchor::Right, + "ANCHOR_BOTTOMLEFT" => TooltipAnchor::BottomLeft, + "ANCHOR_BOTTOMRIGHT" => TooltipAnchor::BottomRight, + "ANCHOR_TOPLEFT" => TooltipAnchor::TopLeft, + "ANCHOR_TOPRIGHT" => TooltipAnchor::TopRight, + "ANCHOR_CURSOR" => TooltipAnchor::Cursor, + "ANCHOR_NONE" => TooltipAnchor::None, + "ANCHOR_PRESERVE" => TooltipAnchor::Preserve, + "" => TooltipAnchor::Left, + other => { + lua.app_data_mut::() + .expect("model app_data") + .record_warning(format!( "SetOwner: unknown anchor '{other}' (mode 0, ANCHOR_LEFT)" )); - TooltipAnchor::Left - } - }; - tip_mut(&mut model, h)?.anchor = anchor; - let pts = match anchor { - TooltipAnchor::Right => Some((Point::BottomLeft, Point::TopRight)), - TooltipAnchor::Left => Some((Point::BottomRight, Point::TopLeft)), - TooltipAnchor::TopRight => Some((Point::BottomRight, Point::TopRight)), - TooltipAnchor::TopLeft => Some((Point::BottomLeft, Point::TopLeft)), - TooltipAnchor::BottomRight => Some((Point::TopLeft, Point::BottomRight)), - TooltipAnchor::BottomLeft => Some((Point::TopRight, Point::BottomLeft)), - TooltipAnchor::Cursor | TooltipAnchor::None | TooltipAnchor::Preserve => { - None - } - }; - // Modes 6 and 7 clear all points and stop. The clear lets - // `GameTooltip_SetDefaultAnchor` (GameTooltip.lua:76-77) set one point with no - // stale owner anchor; mode 6 is then placed per frame by `cursor_anchor`. The - // drop is a named retarget to no targets, not a conservative touch. - if matches!(anchor, TooltipAnchor::Cursor | TooltipAnchor::None) { - let dropped = match model.layout_inputs.get_mut(&h) { - Some(input) if !input.anchors.is_empty() => { - Some(input.anchors.drain(..).map(|a| a.relative_to).collect()) - } - _ => None, - }; - if let Some(old_targets) = dropped { - let old_targets: Vec = old_targets; - model.touch_layout_retarget_frame(h, &old_targets, &[]); - } + TooltipAnchor::Left } - if let Some((own, rel)) = pts { - let new = - Anchor::new(own, owner_id, rel, x.unwrap_or(0.0), y.unwrap_or(0.0)); - let input = model.layout_inputs.entry(h).or_default(); - // No-op when unchanged, so the bag hover's per-frame SetOwner does not - // dirty the layout. - let same = input.anchors.len() == 1 - && crate::script::object::anchor_bits_eq(&input.anchors[0], &new); - if !same { - // A named retarget: the cached layout graph re-points this edge - // instead of being rebuilt. - let old_targets: Vec = - input.anchors.iter().map(|a| a.relative_to).collect(); - input.anchors = vec![new]; - model.touch_layout_retarget_frame(h, &old_targets, &[owner_id]); - } - } - } - fire_cleared(lua, h); + }; + set_owner_core( + lua, + h, + Some(owner_h), + anchor, + x.unwrap_or(0.0), + y.unwrap_or(0.0), + ); Ok(()) }, )?, )?; - // GetAnchorType(): the last SetOwner's mode by name (`0x5313e0`, name table `0x531530`), never - // nil; a never-owned tooltip answers "ANCHOR_NONE". The reference also answers it after any - // hide, whose un-own leaves mode 7; this keeps the last mode. Addons compare it with what they - // passed to SetOwner, so the name must round-trip. + // GetAnchorType(): `[+0x318]` by name (`0x5313e0`, name table `0x531530`), never nil. While + // owned it is the last SetOwner's mode; a never-owned tooltip, and any tooltip since a hide + // (whose un-own stores mode 7, `0x530012`), answers "ANCHOR_NONE". m.set( "GetAnchorType", lua.create_function(|lua, this: Table| { @@ -166,11 +122,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { _ => 1.0, }; let wrap = bool_arg(args.get(5)); - { - let mut model = lua.app_data_mut::().expect("model app_data"); - clear_content(&mut model, h); - } - fire_cleared(lua, h); + clear(lua, h); append_line(lua, &this, (text, [r, g, b, a]), None, wrap)?; set_shown(lua, h, true); Ok(()) @@ -235,11 +187,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { "ClearLines", lua.create_function(|lua, this: Table| { let h = frame_handle_of(lua, &this)?; - { - let mut model = lua.app_data_mut::().expect("model app_data"); - clear_content(&mut model, h); - } - fire_cleared(lua, h); + clear(lua, h); Ok(()) })?, )?; @@ -316,16 +264,13 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { with_tip(lua, &this, |t| t.padding = w.max(0.0)) })?, )?; - // FadeOut(): alpha ramps to 0 over `TOOLTIP_FADE_SECS`, then the full hide; new content or - // Show cancels it. + // FadeOut() (`0x531f00`): the fade request ([`super::fade_out`]). m.set( "FadeOut", lua.create_function(|lua, this: Table| { - with_tip(lua, &this, |tip| { - if tip.fade_left.is_none() { - tip.fade_left = Some(TOOLTIP_FADE_SECS); - } - }) + let h = frame_handle_of(lua, &this)?; + super::fade_out(lua, h); + Ok(()) })?, )?; // The content methods: items, spells and auras, talents, units. diff --git a/crates/benilla-ui/src/script/tooltip_item/mod.rs b/crates/benilla-ui/src/script/tooltip_item/mod.rs index 4c66763b..60de4faf 100644 --- a/crates/benilla-ui/src/script/tooltip_item/mod.rs +++ b/crates/benilla-ui/src/script/tooltip_item/mod.rs @@ -13,7 +13,7 @@ use mlua::{Lua, MultiValue, Table, Value}; use super::object::frame_handle_of; -use super::tooltip::{append_line, clear_content, fire_cleared, show_or_hide_empty}; +use super::tooltip::{append_line, clear, clear_silent, show_or_hide_empty}; use super::{ItemTemplateView, Model}; mod names; @@ -151,11 +151,7 @@ pub(super) fn render_by_id( fb_q: Option, ) -> mlua::Result<()> { let h = frame_handle_of(lua, this)?; - { - let mut model = lua.app_data_mut::().expect("model app_data"); - clear_content(&mut model, h); - } - fire_cleared(lua, h); + clear(lua, h); match view_of(lua, item_id) { Some(v) => { render_view(lua, this, &v, BuilderFlags::default(), None)?; @@ -185,10 +181,7 @@ fn set_quest_item_view( Some(it) => render_by_id(lua, this, it.item_id, it.name.clone(), Some(it.quality)), None => { let h = frame_handle_of(lua, this)?; - { - let mut model = lua.app_data_mut::().expect("model app_data"); - clear_content(&mut model, h); - } + clear_silent(lua, h); super::tooltip::show_or_hide_empty(lua, h); Ok(()) } @@ -284,11 +277,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { return Ok(()); }; let h = frame_handle_of(lua, &this)?; - { - let mut model = lua.app_data_mut::().expect("model app_data"); - clear_content(&mut model, h); - } - fire_cleared(lua, h); + clear(lua, h); // The reference's format is `"%s|Hitem:%d:%d:%d:%d|h[%s]|h%s"` (`0x8549c8`), and // `SetHyperlink` (`0x532181`) parses it into a block (p6=1): the enchant id to slot 0 // (`+0x3d0`), the roll to `+0x424` (expanded into slots 2..6 by `0x52b7bf`), the @@ -393,11 +382,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { currently_equipped, name_only, }; - { - let mut model = lua.app_data_mut::().expect("model app_data"); - clear_content(&mut model, h); - } - fire_cleared(lua, h); + clear(lua, h); match view_of(lua, item_id) { Some(v) => render_view(lua, &this, &v, flags, Some(&inst))?, None => { @@ -486,11 +471,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { None => return Ok(MultiValue::from_vec(vec![Value::Nil])), } }; - { - let mut model = lua.app_data_mut::().expect("model app_data"); - clear_content(&mut model, h); - } - fire_cleared(lua, h); + clear(lua, h); match view_of(lua, item_id) { Some(v) => { render_view(lua, &this, &v, BuilderFlags::default(), Some(&inst))?; @@ -551,11 +532,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { let Some(item) = item.filter(|i| i.item_id != 0) else { return Ok(()); }; - { - let mut model = lua.app_data_mut::().expect("model app_data"); - clear_content(&mut model, h); - } - fire_cleared(lua, h); + clear(lua, h); let inst = render::ItemInstance { name: item.name.clone(), ..Default::default() @@ -600,11 +577,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { let Some(row) = row else { return Ok(()); }; - { - let mut model = lua.app_data_mut::().expect("model app_data"); - clear_content(&mut model, h); - } - fire_cleared(lua, h); + clear(lua, h); // The row's app-composed name has the suffix joined, as `0x5d8b00` does; the lines // come from the roll id, the reference's `+0x424` (`0x52b7bf`). let inst = render::ItemInstance { @@ -653,11 +626,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { let Some(entry) = entry else { return Ok(()); }; - { - let mut model = lua.app_data_mut::().expect("model app_data"); - clear_content(&mut model, h); - } - fire_cleared(lua, h); + clear(lua, h); let inst = render::ItemInstance { name: entry.name.clone(), enchants: roll_enchants(lua, entry.random_property_id), @@ -832,11 +801,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { list.get(index.saturating_sub(1)).cloned() }; let Some(row) = row else { return Ok(()) }; - { - let mut model = lua.app_data_mut::().expect("model app_data"); - clear_content(&mut model, h); - } - fire_cleared(lua, h); + clear(lua, h); match view_of(lua, row.item_id) { Some(v) => { render_view(lua, &this, &v, BuilderFlags::default(), None)?; @@ -891,11 +856,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { if item_id == 0 { return Ok(()); } - { - let mut model = lua.app_data_mut::().expect("model app_data"); - clear_content(&mut model, h); - } - fire_cleared(lua, h); + clear(lua, h); // A block source (`0x5355fa`, p6=1) carrying the attachment's roll: no placeholder. let inst = render::ItemInstance { name, @@ -933,11 +894,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { if item_id == 0 { return Ok(()); } - { - let mut model = lua.app_data_mut::().expect("model app_data"); - clear_content(&mut model, h); - } - fire_cleared(lua, h); + clear(lua, h); // A block source (`0x5359d9`, p6=1) carrying the listing's roll: no placeholder. let inst = render::ItemInstance { name, @@ -970,11 +927,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { if item_id == 0 { return Ok(()); } - { - let mut model = lua.app_data_mut::().expect("model app_data"); - clear_content(&mut model, h); - } - fire_cleared(lua, h); + clear(lua, h); if let Some(v) = view_of(lua, item_id) { render_view(lua, &this, &v, BuilderFlags::default(), None)?; } @@ -1000,11 +953,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { if item_id == 0 { return Ok(()); } - { - let mut model = lua.app_data_mut::().expect("model app_data"); - clear_content(&mut model, h); - } - fire_cleared(lua, h); + clear(lua, h); if let Some(v) = view_of(lua, item_id) { render_view(lua, &this, &v, BuilderFlags::default(), None)?; } diff --git a/crates/benilla-ui/src/script/tooltip_spell.rs b/crates/benilla-ui/src/script/tooltip_spell.rs index 01f65334..c25382ed 100644 --- a/crates/benilla-ui/src/script/tooltip_spell.rs +++ b/crates/benilla-ui/src/script/tooltip_spell.rs @@ -16,7 +16,7 @@ use mlua::{Lua, Table, Value}; use super::object::{frame_handle_of, frame_wrapper}; -use super::tooltip::{append_line, clear_content, fire_cleared, tip_mut}; +use super::tooltip::{append_line, clear, clear_silent, tip_mut}; use super::{CraftTooltip, Model, TrainerTooltip}; use crate::widget::FrameHandle; @@ -223,9 +223,9 @@ fn answer_wait(lua: &Lua, h: FrameHandle, spell_id: u32, alt_caster: bool) -> ml model.spell_tooltip_waits.remove(&h); return Ok(()); } - clear_content(&mut model, h); (model.frame_id(h), wait) }; + clear_silent(lua, h); let this = frame_wrapper(lua, id)?; fill_spell( lua, @@ -436,11 +436,7 @@ pub(super) fn set_spell_with_talent( talent: TalentLines, ) -> mlua::Result<()> { let h = frame_handle_of(lua, this)?; - { - let mut model = lua.app_data_mut::().expect("model app_data"); - clear_content(&mut model, h); - } - fire_cleared(lua, h); + clear(lua, h); if talent.next_desc.is_none() { super::talent::ask_next_rank(lua, talent.next_spell); } @@ -474,10 +470,7 @@ fn set_reward_spell( Some((id, name)) => set_spell_by_id(lua, this, id, name, SpellRenderOpts::default(), None), None => { let h = frame_handle_of(lua, this)?; - { - let mut model = lua.app_data_mut::().expect("model app_data"); - clear_content(&mut model, h); - } + clear_silent(lua, h); super::tooltip::show_or_hide_empty(lua, h); Ok(()) } @@ -493,11 +486,7 @@ pub(super) fn set_spell_by_id( remaining: Option, ) -> mlua::Result<()> { let h = frame_handle_of(lua, this)?; - { - let mut model = lua.app_data_mut::().expect("model app_data"); - clear_content(&mut model, h); - } - fire_cleared(lua, h); + clear(lua, h); fill_spell(lua, this, h, spell_id, fallback_name, opts, remaining) } @@ -721,11 +710,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { }; let (spell_id, fallback_name) = hit.unwrap_or((0, None)); let h = frame_handle_of(lua, &this)?; - { - let mut model = lua.app_data_mut::().expect("model app_data"); - clear_content(&mut model, h); - } - fire_cleared(lua, h); + clear(lua, h); match spell_view_of(lua, spell_id, false) { Some(v) => { append_line(lua, &this, (v.name.clone(), GOLD), None, false)?; @@ -806,14 +791,13 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { 0x80 => super::tooltip_item::render_by_id(lua, &this, a.action, None, None), 0x40 => { let h = frame_handle_of(lua, &this)?; + clear(lua, h); + // The same 1..36 lookup as `GetActionText` and the bar icon, so the three + // agree on a slot's macro. let name = { - let mut model = lua.app_data_mut::().expect("model app_data"); - clear_content(&mut model, h); - // The same 1..36 lookup as `GetActionText` and the bar icon, so the - // three agree on a slot's macro. + let model = lua.app_data_ref::().expect("model app_data"); model.macros.get(a.action as usize).map(|m| m.name.clone()) }; - fire_cleared(lua, h); if let Some(name) = name { append_line(lua, &this, (name, WHITE), None, false)?; } diff --git a/crates/benilla-ui/src/script/tooltip_unit.rs b/crates/benilla-ui/src/script/tooltip_unit.rs index 2106c57b..0aaa893a 100644 --- a/crates/benilla-ui/src/script/tooltip_unit.rs +++ b/crates/benilla-ui/src/script/tooltip_unit.rs @@ -7,12 +7,11 @@ use mlua::{Lua, Table}; use super::object::frame_handle_of; -use super::tooltip::{append_line, clear_content, fire_cleared, show_or_hide_empty, tip_mut}; +use super::tooltip::{append_line, clear, set_owner_core, show_or_hide_empty}; use super::unit::{is_civilian_kill, level_reads_unknown, unknownobject}; use super::{KindState, Model, UnitState}; -use crate::layout::{Anchor, Point}; use crate::strings::{fill, Arg}; -use crate::widget::{FrameHandle, TOOLTIP_FADE_SECS}; +use crate::widget::{FrameHandle, TooltipAnchor}; const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0]; @@ -98,26 +97,32 @@ fn level_line(lua: &Lua, u: &UnitState, player_level: u32) -> Option { Some(fill(&get(key)?, &args)) } -/// Render the unit tooltip for `token`'s current snapshot; returns whether the unit existed. +/// Render the unit tooltip for `token`'s current snapshot (`0x529fe0`); returns whether the unit +/// existed. The clear first (`0x52a003`); no unit hides the tooltip through its own hide, shown bit +/// then slot (`0x52a050`/`0x52a058`). A unit is held (`0x52a070`), its bar seeded and shown +/// (`0x52a0e0`–`0x52a146`) before any line, and the lines end in the show (`0x52a711`/`0x52a71b`). fn render_unit(lua: &Lua, this: &Table, token: &str) -> mlua::Result { let h = frame_handle_of(lua, this)?; + // A unit is its object, found by guid (`0x52a021`); guid 0 finds none. let (unit, player_level) = { let model = lua.app_data_mut::().expect("model app_data"); ( - model.unit(token).cloned().filter(|u| u.exists), + model + .unit(token) + .cloned() + .filter(|u| u.exists && u.guid != 0), model.player_req.level, ) }; - { - let mut model = lua.app_data_mut::().expect("model app_data"); - clear_content(&mut model, h); - } - fire_cleared(lua, h); + clear(lua, h); let Some(u) = unit else { - set_subject(lua, h, None); - show_or_hide_empty(lua, h); + super::visibility::set_shown(lua, h, false); return Ok(false); }; + set_subject(lua, h, Some(u.guid)); + if let Some(bar) = seed_bar(lua, h, &u) { + super::visibility::set_shown(lua, bar, true); + } // The name: `CGUnit_C::GetUnitName` `0x609210` (`0x52a187`), whose every miss falls to // `UNKNOWNOBJECT`, as `UnitName`'s does, then decorated by `0x609370` with the flag a literal // `1` (`0x52a19b`), as `UnitPVPName` does, so no CVar gates it: a ranked player's rank and @@ -152,9 +157,6 @@ fn render_unit(lua: &Lua, this: &Table, token: &str) -> mlua::Result { if u.racial_leader && u.pvp { append_line(lua, this, ("Leader".into(), WHITE), None, false)?; } - // An unresolved guid (0) is no subject: nothing can push for it. - set_subject(lua, h, Some(u.guid).filter(|&g| g != 0)); - update_bar(lua, h, Some(&u)); show_or_hide_empty(lua, h); Ok(true) } @@ -179,36 +181,27 @@ fn set_subject(lua: &Lua, h: FrameHandle, guid: Option) { } } -/// Drive the `StatusBar` child from a unit snapshot (`None` hides it); FrameXML's -/// `HealthBar_OnValueChanged` colours the fill. -fn update_bar(lua: &Lua, h: FrameHandle, unit: Option<&UnitState>) { +/// Seed the `StatusBar` child from a unit snapshot, as the builder (`0x52a0eb` min/max, +/// `0x52a12e` value) and the health watcher (`0x529560`) do, firing `OnValueChanged` on a change; +/// FrameXML's `HealthBar_OnValueChanged` colours the fill. Neither shows it. Returns the bar. +fn seed_bar(lua: &Lua, h: FrameHandle, u: &UnitState) -> Option { let bar = { - let model = lua.app_data_mut::().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 model = lua.app_data_ref::().expect("model app_data"); + super::tooltip::status_bar(&model, h)? }; - let Some(bar) = bar else { return }; - let (changed, shown) = { + let changed = { let mut model = lua.app_data_mut::().expect("model app_data"); - let Some(f) = model.arena.frame_mut(bar) else { - return; + let Some(KindState::StatusBar(sb)) = model.arena.frame_mut(bar).map(|f| &mut f.kind_state) + else { + return None; }; - 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), - } + 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 }; - { - let mut model = lua.app_data_mut::().expect("model app_data"); - model.arena.set_shown(bar, shown); - } if changed { let (id, value) = { let mut model = lua.app_data_mut::().expect("model app_data"); @@ -229,6 +222,7 @@ fn update_bar(lua: &Lua, h: FrameHandle, unit: Option<&UnitState>) { .record_lua_error(&e); } } + Some(bar) } /// A `set_unit` push of the unit a tooltip shows updates its health bar without rebuilding the @@ -261,16 +255,16 @@ pub(super) fn on_unit_push(lua: &Lua, token: &str) { (unit.clone(), hits) }; for h in hits { - update_bar(lua, h, Some(&unit)); + seed_bar(lua, h, &unit); } } impl super::UiScript { /// Show the world-mouseover unit tooltip: fire `OnTooltipSetDefaultAnchor`, render `token`, - /// fire `UPDATE_MOUSEOVER_UNIT`. Call on a hover-target change; a re-show mid-fade returns to - /// full alpha. `false` when there is no tooltip frame or no such unit. + /// fire `UPDATE_MOUSEOVER_UNIT`. Call on a hover-target change; the render's show returns a + /// fading plate to full alpha. `false` when there is no tooltip frame or no such unit. pub fn world_tooltip_unit(&mut self, token: &str) -> bool { - let h = { + let (h, id) = { let mut model = self.model_mut(); let Some(h) = model.arena.lookup("GameTooltip") else { return false; @@ -278,7 +272,6 @@ impl super::UiScript { 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![]) { @@ -300,15 +293,6 @@ impl super::UiScript { }; if shown { set_world_owned(&self.lua, h); - let mut model = self.model_mut(); - if let Some(f) = model.arena.frame_mut(h) { - if let KindState::Tooltip(t) = &mut f.kind_state { - if t.fade_left.take().is_some() { - model.arena.set_alpha(h, 1.0); - } - } - } - drop(model); self.fire_event("UPDATE_MOUSEOVER_UNIT", vec![]); } shown @@ -316,17 +300,17 @@ impl super::UiScript { /// Show the world-mouseover GameObject tooltip (builder `0x52aa20`): the name in gold, then /// the caller's lock lines. The anchor forks on the object's `[vtbl+0x5c]`: true (`0x492a01`) - /// seats it at the cursor, anchor state 6 (`0x52ffe0(owner, 6, 0, 0)`); false (`0x492a42`) - /// keeps the default corner, as units (`0x492983`) always do. `cursor` is that verdict, and - /// [`Self::world_tooltip_move`] follows a cursor-seated plate. Hover loss arms the unit flow's - /// fade, where the reference's loss path (`0x530ae0`) hides an anchor-state-6 plate at once. + /// owns it by `UIParent` in mode 6, `ANCHOR_CURSOR` (`0x52ffe0(UIParent, 6, 0, 0)` at + /// `0x492a1e`), which the tooltip's own update then keeps on the cursor; false (`0x492a42`) + /// fires `OnTooltipSetDefaultAnchor` for the default corner, as units (`0x492983`) always do. + /// `cursor` is that verdict. pub fn world_tooltip_gameobject( &mut self, name: &str, lines: &[(String, TooltipTint)], - cursor: Option<(f32, f32)>, + cursor: bool, ) -> bool { - let (h, id, root, root_id) = { + let (h, id, root) = { let mut model = self.model_mut(); let Some(h) = model.arena.lookup("GameTooltip") else { return false; @@ -334,42 +318,14 @@ impl super::UiScript { let Some(root) = model.arena.lookup("UIParent") else { return false; }; - let (id, root_id) = (model.frame_id(h), model.frame_id(root)); - (h, id, root, root_id) + (h, model.frame_id(h), root) }; - match cursor { - // The cursor arm: centred above the pointer; compare-then-touch, so a still pointer - // never re-layouts. - Some((ui_x, ui_y)) => { - let mut model = self.model_mut(); - // The owner: this arm is the SetOwner core (`0x52ffe0`), which stores it at - // `+0x314` (`0x53000c`); the other arms get theirs from FrameXML's - // `OnTooltipSetDefaultAnchor`. Without one, any Lua `GameTooltip:Show()` hides the - // plate: `0x530a80` shows only when `+0x314` and `+0x31c` are both set, else it - // takes `0x530a60`. - if let Ok(t) = tip_mut(&mut model, h) { - t.owner = Some(root); - } - let new = cursor_anchor_at(&model, h, root_id, ui_x, ui_y); - let input = model.layout_inputs.entry(h).or_default(); - let same = input.anchors.len() == 1 - && super::object::anchor_bits_eq(&input.anchors[0], &new); - if !same { - input.anchors = vec![new]; - model.touch_layout(); - } - } - // The corner arm: the default-anchor handler the unit flow fires too. - None => { - if let Err(e) = super::event::fire_widget_handler( - &self.lua, - id, - "OnTooltipSetDefaultAnchor", - vec![], - ) { - self.push_error(e); - } - } + if cursor { + set_owner_core(&self.lua, h, Some(root), TooltipAnchor::Cursor, 0.0, 0.0); + } else 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, @@ -379,11 +335,7 @@ impl super::UiScript { } }; let render = || -> mlua::Result<()> { - { - let mut model = self.lua.app_data_mut::().expect("model app_data"); - clear_content(&mut model, h); - } - fire_cleared(&self.lua, h); + clear(&self.lua, h); append_line(&self.lua, &wrapper, (name.to_string(), GOLD), None, false)?; for (text, tint) in lines { let colour = match tint { @@ -400,35 +352,25 @@ impl super::UiScript { 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_left.take().is_some() { - model.arena.set_alpha(h, 1.0); - } - } - } - } show_or_hide_empty(&self.lua, h); true } - /// Show the minimap blip tooltip (an `AreaPOI` or quest-dot name): one gold line, faint gold - /// for a cross-interior dot, its bottom at the given UI point above the cursor. - /// [`Self::world_tooltip_move`] follows the pointer, and hover loss arms - /// [`Self::world_tooltip_fade`]. - pub fn minimap_tooltip(&mut self, text: &str, ui_x: f32, ui_y: f32, grey: bool) -> bool { - let (h, id, root_id) = { + /// Show the minimap blip tooltip (an `AreaPOI` or quest-dot name), from `Minimap`'s own hover + /// probe `0x4eb0c0`: owned by `Minimap` itself (`0x4eb19c` keeps `this + 0x24`, pushed at + /// `0x4eb57d`) in mode 6, `ANCHOR_CURSOR` (`0x4eb587`), so the tooltip's own update keeps it on + /// the cursor; one gold line, faint gold for a cross-interior dot; then the show + /// (`0x4eb85f`/`0x4eb869`). Hover loss is [`Self::world_tooltip_hide`]. + pub fn minimap_tooltip(&mut self, text: &str, grey: bool) -> bool { + let (h, id, root) = { let mut model = self.model_mut(); let Some(h) = model.arena.lookup("GameTooltip") else { return false; }; - let Some(root) = model.arena.lookup("UIParent") else { + let Some(root) = model.arena.lookup("Minimap") else { return false; }; - let (id, root_id) = (model.frame_id(h), model.frame_id(root)); - (h, id, root_id) + (h, model.frame_id(h), root) }; let wrapper = match super::object::frame_wrapper(&self.lua, id) { Ok(w) => w, @@ -437,22 +379,7 @@ impl super::UiScript { return false; } }; - { - let mut model = self.model_mut(); - clear_content(&mut model, h); - // Anchor only: the frame's own clamp flag (`Frame::clamped_to_screen`, G flags bit 4) - // slides a plate near the window edge back on screen. - let new = cursor_anchor_at(&model, h, root_id, ui_x, ui_y); - let input = model.layout_inputs.entry(h).or_default(); - // Compare-then-touch, so a still cursor does not dirty the layout. - let same = - input.anchors.len() == 1 && super::object::anchor_bits_eq(&input.anchors[0], &new); - if !same { - input.anchors = vec![new]; - model.touch_layout(); - } - } - fire_cleared(&self.lua, h); + set_owner_core(&self.lua, h, Some(root), TooltipAnchor::Cursor, 0.0, 0.0); // A cross-interior entry's `|cffb0b0b0` wrap modulates the gold: faint gold, not grey. let dim = 0xb0 as f32 / 255.0; let color = if grey { @@ -465,76 +392,50 @@ impl super::UiScript { 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_left.take().is_some() { - model.arena.set_alpha(h, 1.0); - } - } - } - } show_or_hide_empty(&self.lua, h); true } - /// Re-seat the shown world-owned tooltip at a new pointer point, anchor only; else a no-op. - pub fn world_tooltip_move(&mut self, ui_x: f32, ui_y: f32) { - let mut model = self.model_mut(); - let Some(h) = model.arena.lookup("GameTooltip") else { - return; - }; - let Some(root) = model.arena.lookup("UIParent") else { - return; - }; - let owned_shown = model - .arena - .frame(h) - .map(|f| matches!(&f.kind_state, KindState::Tooltip(t) if t.world_owned) && f.shown) - .unwrap_or(false); - if !owned_shown { - return; - } - let root_id = model.frame_id(root); - let new = cursor_anchor_at(&model, h, root_id, ui_x, ui_y); - let input = model.layout_inputs.entry(h).or_default(); - // Compare-then-touch: this runs on every pointer event. - let same = - input.anchors.len() == 1 && super::object::anchor_bits_eq(&input.anchors[0], &new); - if !same { - input.anchors = vec![new]; - model.touch_layout(); - } + /// Whether `GameTooltip` is still the shown plate a world hover put up, with no `SetOwner` or + /// hide since; a Lua content re-set (an `UPDATE_MOUSEOVER_UNIT` handler's `SetUnit`) keeps it. + pub fn world_tooltip_up(&mut self) -> bool { + self.world_plate().is_some() } - /// Whether `GameTooltip` is still the shown plate a world hover put up, with no Lua - /// `SetOwner`, content `Set*` or `Hide` since. - pub fn world_tooltip_up(&mut self) -> bool { + /// The world plate, when [`Self::world_tooltip_up`]. + fn world_plate(&mut self) -> Option { let model = self.model_mut(); - let Some(h) = model.arena.lookup("GameTooltip") else { - return false; - }; + let h = model.arena.lookup("GameTooltip")?; model .arena .frame(h) - .map(|f| matches!(&f.kind_state, KindState::Tooltip(t) if t.world_owned) && f.shown) - .unwrap_or(false) + .is_some_and(|f| { + matches!(&f.kind_state, KindState::Tooltip(t) if t.world_owned) && f.shown + }) + .then_some(h) } - /// On hover loss, arm the world tooltip's timed fade (the reference's unit plate fades rather - /// than hiding); a no-op unless a world-owned plate is shown. + /// On world hover loss, the fade request (`0x492909` → `0x530ae0`): a cursor-seated plate hides + /// at once, any other fades. The reference requests it whenever the previous mouseover goes, + /// whoever holds the tooltip. Moving onto a window, its hover walk runs the WorldFrame's leave, + /// which publishes the empty mouseover and fades (`0x483b10` → `0x483b46`), before the window's + /// enter takes the tooltip (`0x766229` before `0x766239`). The host's world drive runs in the + /// feed phase, before the UI input pass, so it learns of the loss only after the window's enter + /// has run. So this acts only while the world plate is up ([`Self::world_tooltip_up`]): a + /// tooltip re-owned since, by a window or by an addon while the pointer stays on the world, is + /// left alone where the reference would fade it, and a hidden one is not touched. pub fn world_tooltip_fade(&mut self) { - 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_left.is_none() { - t.fade_left = Some(TOOLTIP_FADE_SECS); - } - } + if let Some(h) = self.world_plate() { + super::tooltip::fade_out(&self.lua, h); + } + } + + /// On minimap blip hover loss, the direct hide (`0x4eb0c0`: shown bit, then the hide slot, + /// `0x4eb15e`–`0x4eb166`), with no fade. The reference hides whoever holds the tooltip; as in + /// [`Self::world_tooltip_fade`], this acts only while the world plate is still up. + pub fn world_tooltip_hide(&mut self) { + if let Some(h) = self.world_plate() { + super::visibility::set_shown(&self.lua, h, false); } } } @@ -558,17 +459,3 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { )?; Ok(()) } - -/// The plate's BOTTOM on the cursor: `(ui_x, ui_y)` is in the screen root's units, and an anchor -/// offset is in the tooltip's own, so it is divided by the tooltip's effective scale, the UI scale -/// included, as the cursor arm `0x530b20` does ([`super::tooltip`]'s `cursor_anchor`). -fn cursor_anchor_at(model: &Model, h: FrameHandle, root_id: u32, ui_x: f32, ui_y: f32) -> Anchor { - let s = super::object::eff_scale(model, h); - Anchor::new( - Point::Bottom, - root_id, - Point::BottomLeft, - ui_x / s, - ui_y / s, - ) -} diff --git a/crates/benilla-ui/src/widget/kinds/mod.rs b/crates/benilla-ui/src/widget/kinds/mod.rs index 4e6973b1..5cad5b73 100644 --- a/crates/benilla-ui/src/widget/kinds/mod.rs +++ b/crates/benilla-ui/src/widget/kinds/mod.rs @@ -242,19 +242,23 @@ pub struct TooltipState { pub right_lines: Vec, /// `SetOwner`'s frame, dropped on hide. pub owner: Option, - /// The last `SetOwner`'s mode, read back by `GetAnchorType()`. + /// The anchor mode (`+0x318`), read back by `GetAnchorType()`: the last `SetOwner`'s while + /// owned, `None` (mode 7) since a hide. pub anchor: TooltipAnchor, /// `SetMinimumWidth`: a floor on the auto-sized width, cleared with the content. pub min_width: f32, /// `FadeOut()`'s countdown in seconds (`[+0x3b4]` armed, `[+0x3b8]` left): the tooltip's own /// update counts it down by its elapsed, so it stands still while the tooltip is not walked. - /// Any fresh content or `Show` cancels it. + /// `FadeOut` arms it; the show, the SetOwner core (so every hide) and a cursor-mode `FadeOut` + /// disarm it, and nothing else. pub fade_left: Option, /// The guid of the unit this tooltip shows (`+0x368`/`+0x36c`, stored at `0x52a070`), whose /// health watcher (`0x467e70`, registered at `0x52a0a8`) drives the bar; a `set_unit` push of /// that unit, under any token, re-drives it. pub unit_guid: Option, - /// Shows world-hover content, which fades when the hover is lost; a window hover never fades. + /// The plate a world hover put up, set after its build and cleared by the next SetOwner core + /// (a Lua `SetOwner`, an engine seat, or any hide's un-own), not by a content re-set. Hover loss + /// fades only such a plate. pub world_owned: bool, /// Armed for a compare render: the next `SetInventoryItem` prepends "Currently Equipped" /// (`[arg+0x18]`, not the compact `[arg+0x14]`), surviving the stock caller's `SetOwner` in @@ -272,8 +276,9 @@ pub const TOOLTIP_PAD: f32 = 10.0; pub const TOOLTIP_LINE_GAP: f32 = 2.0; /// The least double-line column gap, the stock `TextRightN` offset; the reference's is untraced. pub const TOOLTIP_DOUBLE_GAP: f32 = 40.0; -/// `FadeOut`'s ramp length in seconds; the reference's constant is untraced. -pub const TOOLTIP_FADE_SECS: f32 = 0.5; +/// `FadeOut`'s duration in seconds, `[0x808e7c]` = 2.0: the plate holds full alpha for the first +/// half and ramps over the second. +pub const TOOLTIP_FADE_SECS: f32 = 2.0; /// A wrap-flagged line's width, pinned when it is appended; the reference's column is untraced. pub const TOOLTIP_WRAP_WIDTH: f32 = 260.0;