From 6fff0fa7a1ef7b21190de2b06ff43c097bb119b6 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:28:42 +0700 Subject: [PATCH] Hover leaves publish the world mouseover loss before the next OnEnter (#935) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving from the world onto a window, the world mouseover's loss reached the tooltip a frame after the window's OnEnter, so a gate kept the fade off any tooltip the world had not put up. In 1.12.1 the hover walk runs the old frame's leave before the new frame's enter (`0x766229`, `0x766239`). The WorldFrame's leave (`0x483b10`) runs OnLeave, then publishes the empty mouseover, whose publisher fades GameTooltip whoever holds it (`0x492890` β†’ `0x530ae0`). A nameplate's leave publishes first (`0x7cb8b0`); the Minimap's hides the tooltip (`0x4eb880`), and its probe drops a held blip on a miss (`0x4eb0c0`). The VM now holds the published mouseover and runs these leaves in the walk, the hide and every mouse disable, freelook's plates included (`0x60f830`); the ownership gate is gone. Tests: OnEnter no longer sees "mouseover", an addon tooltip fades on a change, blip and Minimap leaves hide it. Fixes #901 πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/src/minimap/blips/mod.rs | 29 +-- .../src/minimap/blips/tooltip_tests.rs | 68 ++++++ crates/benilla-app/src/minimap/mod.rs | 4 +- crates/benilla-app/src/target/hover.rs | 16 +- crates/benilla-app/src/target/mod.rs | 3 + crates/benilla-app/src/ui_script/mod.rs | 4 +- .../src/ui_tooltip/hover_order_tests.rs | 199 ++++++++++++++++++ crates/benilla-app/src/ui_tooltip/mod.rs | 95 ++++----- crates/benilla-app/src/ui_tooltip/tests.rs | 30 +-- crates/benilla-app/src/vplates.rs | 22 +- crates/benilla-ui/src/script/mod.rs | 1 + crates/benilla-ui/src/script/model.rs | 9 + crates/benilla-ui/src/script/mouseover.rs | 154 ++++++++++++++ crates/benilla-ui/src/script/nameplate.rs | 17 +- crates/benilla-ui/src/script/pointer.rs | 83 +++++--- .../src/script/tests/hover_publish.rs | 168 +++++++++++++++ crates/benilla-ui/src/script/tests/mod.rs | 1 + .../benilla-ui/src/script/tests/nameplate.rs | 69 ++++++ .../src/script/tests/tooltip_unit.rs | 88 ++++---- crates/benilla-ui/src/script/tooltip/mod.rs | 2 - crates/benilla-ui/src/script/tooltip_unit.rs | 65 +----- crates/benilla-ui/src/script/visibility.rs | 27 ++- crates/benilla-ui/src/widget/kinds/mod.rs | 4 - 23 files changed, 900 insertions(+), 258 deletions(-) create mode 100644 crates/benilla-app/src/minimap/blips/tooltip_tests.rs create mode 100644 crates/benilla-app/src/ui_tooltip/hover_order_tests.rs create mode 100644 crates/benilla-ui/src/script/mouseover.rs create mode 100644 crates/benilla-ui/src/script/tests/hover_publish.rs diff --git a/crates/benilla-app/src/minimap/blips/mod.rs b/crates/benilla-app/src/minimap/blips/mod.rs index 6bdab528..af37aed6 100644 --- a/crates/benilla-app/src/minimap/blips/mod.rs +++ b/crates/benilla-app/src/minimap/blips/mod.rs @@ -399,20 +399,23 @@ pub(super) fn emit_party_arrows( } } -/// 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. +/// Show the hovered blip's name; a unit's resolves through the name cache. This is the in-rect +/// half of the Minimap's probe `0x4eb0c0`, which the hover walk runs on the Minimap while it holds +/// the hover or the capture ([`UiScript::minimap_probed`]). A new blip shows; no blip where one +/// was held hides `GameTooltip`, whoever holds it (`0x4eb55f`). The cursor off the Minimap is the +/// walk's: the probe's miss and the Minimap's leave. pub(super) fn drive_blip_tooltip( script: Option>, hover: Res, names: Res, commands: Res, - mut last: Local>>, ) { let Some(mut script) = script else { return; }; - let last = last.get(&script); + if !script.minimap_probed() { + return; + } let show = match &*hover { MinimapBlipHover::None => None, MinimapBlipHover::Landmark(name) => Some((name.clone(), false)), @@ -421,22 +424,20 @@ pub(super) fn drive_blip_tooltip( .map(|n| (n.to_string(), *grey)), MinimapBlipHover::TrackedGo(name, grey) => Some((name.clone(), *grey)), }; - match (&show, &*last) { + match (show, script.minimap_blip()) { // Same blip: the plate, owned in mode 6, follows the cursor in its own update. - (Some((t, _)), Some(lt)) if t == lt => {} + (Some((t, _)), Some(held)) if t == held => {} (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_hide(); - *last = None; + script.minimap_tooltip(&t, grey); } + (None, Some(_)) => script.minimap_blip_lost(), (None, None) => {} } } +#[cfg(test)] +mod tooltip_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/benilla-app/src/minimap/blips/tooltip_tests.rs b/crates/benilla-app/src/minimap/blips/tooltip_tests.rs new file mode 100644 index 00000000..1c208922 --- /dev/null +++ b/crates/benilla-app/src/minimap/blips/tooltip_tests.rs @@ -0,0 +1,68 @@ +//! The blip tooltip driver against the UI's own hover: the Minimap's probe and leave. + +use benilla_ui::script::UiScript; +use bevy::prelude::*; + +use super::{drive_blip_tooltip, MinimapBlipHover}; +use crate::names::NameCache; +use crate::net::NetCommands; + +/// A full-screen `UIParent`, a mouse-enabled `Minimap` at (600, 400)–(740, 540) with the cursor on +/// it, and a `GameTooltip`; the driver runs each update. +fn minimap_app() -> App { + let mut app = App::new(); + let (tx, _rx) = crossbeam_channel::unbounded(); + app.insert_resource(NetCommands(tx)) + .init_resource::() + .init_resource::() + .add_systems(Update, drive_blip_tooltip); + let mut script = UiScript::new().unwrap(); + script.set_screen_size(800.0, 600.0); + script + .run( + r#" + UIParent = CreateFrame("Frame", "UIParent"); UIParent:SetAllPoints() + local map = CreateFrame("Minimap", "Minimap", UIParent) + map:SetPoint("BOTTOMLEFT", 600, 400); map:SetWidth(140); map:SetHeight(140) + map:EnableMouse(true) + CreateFrame("GameTooltip", "GameTooltip", UIParent):Hide() + "#, + ) + .unwrap(); + script.resolve(); + script.mouse_move(670.0, 470.0); + app.insert_non_send_resource(script); + app +} + +fn blip(app: &mut App, hover: MinimapBlipHover) { + *app.world_mut().resource_mut::() = hover; + app.update(); +} + +fn shown(app: &mut App) -> bool { + app.world_mut() + .non_send_resource_mut::() + .eval::("return GameTooltip:IsShown() == 1") + .unwrap() +} + +/// Off every blip, the probe `0x4eb0c0` hides `GameTooltip` directly (`0x4eb55f`–`0x4eb567`), +/// whoever holds it: an addon tooltip taken over the blip's goes too. +#[test] +fn a_blip_hover_loss_hides_whoever_holds_the_tooltip() { + let mut app = minimap_app(); + blip(&mut app, MinimapBlipHover::Landmark("Stormwind".into())); + assert!(shown(&mut app), "the blip tooltip is up"); + app.world_mut() + .non_send_resource_mut::() + .run(r#"GameTooltip:SetOwner(UIParent, "ANCHOR_NONE"); GameTooltip:SetText("Addon")"#) + .unwrap(); + blip(&mut app, MinimapBlipHover::None); + assert!(!shown(&mut app), "the addon's tooltip is hidden"); + assert!(app + .world_mut() + .non_send_resource_mut::() + .take_errors() + .is_empty()); +} diff --git a/crates/benilla-app/src/minimap/mod.rs b/crates/benilla-app/src/minimap/mod.rs index 1cb786d9..7e161f7a 100644 --- a/crates/benilla-app/src/minimap/mod.rs +++ b/crates/benilla-app/src/minimap/mod.rs @@ -901,8 +901,8 @@ impl Plugin for MinimapPlugin { .run_if(crate::ui_script::ingame_ui_up), // Before the script tick, so GameTimeFrame reads this frame's minute. feed_game_time.in_set(crate::ui_script::UiFeed), - // After the world-mouseover drive (UnitFeed): a same-frame world-hoverβ†’blip - // transition must end with the blip tooltip shown, not the fade. + // In the feed, off the hover the last walk left; after the world drive only so + // the two VM writers keep one order. blips::drive_blip_tooltip .after(crate::ui_unit::UnitFeed) .in_set(crate::ui_script::UiFeed), diff --git a/crates/benilla-app/src/target/hover.rs b/crates/benilla-app/src/target/hover.rs index 2b3de0f8..904d4bd1 100644 --- a/crates/benilla-app/src/target/hover.rs +++ b/crates/benilla-app/src/target/hover.rs @@ -182,7 +182,7 @@ pub(super) fn update_hover( rig: Res, pointer_over_ui: Res, occlusion: Res, - // The unit whose plate holds the pointer, as of last frame's plate layout. + // The unit whose plate holds the hover after this frame's UI walk. plate_hover: Res, mut hovered: ResMut, mesh_assets: Res>, @@ -235,6 +235,7 @@ pub(super) fn update_hover( hovered.corpse_guid = None; hovered.distance = f32::MAX; hovered.refused = false; + hovered.plate = false; // A plate under the pointer is the mouseover, read before the UI gate below, which the plate // itself trips: the reference's plate OnEnter (`0x7cb850`) publishes `[0xb4e2c8]` ungraded, // with no world pick behind it. Freelook wins, as the plates hand the mouse back (`0x60f830`). @@ -243,6 +244,7 @@ pub(super) fn update_hover( hovered.target = Some(entity); hovered.guid = units.get(entity).ok().map(|(g, _)| g.0); hovered.distance = 0.0; // topmost UI: it beats any world GameObject at a tie + hovered.plate = true; *last_pick = Some(entity); return; } @@ -791,6 +793,18 @@ mod tests { use crate::spell::targeting::corpse_fixture as fx; use bevy::ecs::system::RunSystemOnce; + /// The plate hover the pick reads is this frame's walk's: a plate's leave publishes the loss + /// inside the walk (`0x7cb8b0`), so a pick a frame behind would publish the plate again. + #[test] + fn the_pick_reads_the_plate_hover_of_this_frames_walk() { + let mut app = crate::game_plugins::schedule_tests::headless_client(); + assert!(crate::test_support::runs_before( + &mut app, + crate::vplates::seed_plate_hover, + update_hover + )); + } + fn card(bone: u16, owner: Entity) -> BillboardCard { let info = benilla_assets::BillboardInfo { pivot: Vec3::ZERO, diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs index 4b99cd49..3977ad0f 100644 --- a/crates/benilla-app/src/target/mod.rs +++ b/crates/benilla-app/src/target/mod.rs @@ -113,6 +113,9 @@ pub(crate) struct Hovered { /// [`Self::distance`] holds the hit, so a chest behind the unit does not inherit the mouseover. /// To [`click::select_on_click`] it is an object hit, which clears no selection. pub(crate) refused: bool, + /// The pick is the hovered plate's unit, the plate's enter publishing it (`0x7cb850`), not the + /// world's own pick: it stands only while that plate still holds the hover. + pub(crate) plate: bool, } impl Hovered { diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index 2b01b917..d3a5c7e5 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -346,6 +346,8 @@ impl Plugin for UiScriptPlugin { ( extract::tick_script, input::feed_ui_input.in_set(crate::char_select::InWorldGated), + // Off the walk that just ran, for the pick and the arbiter after the pass. + crate::vplates::seed_plate_hover, ) .chain() .in_set(UiInput) @@ -396,7 +398,7 @@ fn arbitrate_pointer_over_ui( ) { // No cinematic term: the hit test finds the full-screen, mouse-enabled `CinematicFrame`. over.0 = egui.is_some_and(|e| e.0) || hover.0.is_some(); - // Chrome excludes the plates; the plate hover is last frame's, as the plate driver runs later. + // Chrome excludes the plates, whose hover is this frame's walk's. panel.0 = over.0 && plate_hover.0.is_none(); } diff --git a/crates/benilla-app/src/ui_tooltip/hover_order_tests.rs b/crates/benilla-app/src/ui_tooltip/hover_order_tests.rs new file mode 100644 index 00000000..bbebf860 --- /dev/null +++ b/crates/benilla-app/src/ui_tooltip/hover_order_tests.rs @@ -0,0 +1,199 @@ +//! The world mouseover's loss against the UI hover walk: the driver and the walk together, as a +//! frame runs them. + +use benilla_ui::script::UiScript; +use bevy::prelude::*; + +use super::tests::{eval, hover, hover_unit, mouseover_app, WOLF}; +use crate::target::{Hovered, HoveredObject}; + +/// A full-screen `UIParent` and `WorldFrame`, a `GameTooltip` whose default anchor seats it in +/// `anchor`, and a bag button at (100, 100)–(140, 140) whose `OnEnter` records what it found and +/// takes the tooltip, as a stock slot does. +fn world_and_bag(app: &mut App, anchor: &str) { + let mut script = app.world_mut().non_send_resource_mut::(); + script.set_screen_size(800.0, 600.0); + script + .run(&format!( + r#" + UIParent = CreateFrame("Frame", "UIParent"); UIParent:SetAllPoints() + WorldFrame = CreateFrame("WorldFrame", "WorldFrame"); WorldFrame:SetAllPoints() + local tt = CreateFrame("GameTooltip", "GameTooltip", UIParent); tt:Hide() + tt:SetScript("OnTooltipSetDefaultAnchor", function() + GameTooltip:SetOwner(UIParent, "{anchor}") + end) + Bag = CreateFrame("Button", "Bag", UIParent) + Bag:SetPoint("BOTTOMLEFT", 100, 100); Bag:SetWidth(40); Bag:SetHeight(40) + Bag:EnableMouse(true) + Bag:SetScript("OnEnter", function() + entered_mouseover = UnitExists("mouseover") + entered_visible = GameTooltip:IsVisible() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetText("Bag slot") + end) + "# + )) + .unwrap(); + script.resolve(); + // The cursor on the world: the WorldFrame holds the hover. + script.mouse_move(400.0, 300.0); +} + +fn script(app: &mut App) -> Mut<'_, UiScript> { + app.world_mut().non_send_resource_mut::() +} + +/// From a world unit onto a window: the walk runs the WorldFrame's leave before the button's +/// enter (`0x766229`, then `0x766239`), and that leave publishes the empty mouseover +/// (`0x483b10` β†’ `0x483b46`), whose change requests the fade (`0x492909` β†’ `0x530ae0`). So the +/// button's `OnEnter` already finds `"mouseover"` naming nobody and a cursor-seated plate gone, +/// and its own tooltip, taken after, is not faded by the hover loss. +#[test] +fn a_window_entered_from_a_world_unit_keeps_its_tooltip() { + let (mut app, wolf, _) = mouseover_app(); + world_and_bag(&mut app, "ANCHOR_CURSOR"); + hover_unit(&mut app, wolf, WOLF); + assert_eq!( + eval(&mut app, "return GameTooltip:IsShown()"), + Some(1), + "the world plate is up" + ); + + // This frame's walk moves onto the bag; the world pick then stands down over the UI. + script(&mut app).mouse_move(120.0, 120.0); + assert_eq!( + eval(&mut app, "return entered_mouseover"), + None, + "the WorldFrame's leave cleared the mouseover before the button's enter" + ); + assert_eq!( + eval(&mut app, "return entered_visible"), + None, + "the fade request hid the cursor-seated plate before the button's enter" + ); + hover(&mut app, Hovered::default(), HoveredObject::default()); + script(&mut app).tick(2.5); + assert_eq!( + eval( + &mut app, + r#"return GameTooltip:IsShown() and GameTooltip:IsOwned(Bag) + and GameTooltipTextLeft1:GetText() == "Bag slot" + and GameTooltip:GetAlpha() == 1 and 1"# + ), + Some(1), + "the button's tooltip stays at full alpha" + ); + assert!(script(&mut app).take_errors().is_empty()); +} + +/// With the cursor on the world, every mouseover change requests the fade on `GameTooltip` +/// (`0x492909`), whoever holds it: an addon's tooltip shown over the world fades with the hover. +#[test] +fn an_addon_tooltip_over_the_world_fades_when_the_mouseover_changes() { + let (mut app, wolf, _) = mouseover_app(); + world_and_bag(&mut app, "ANCHOR_NONE"); + hover_unit(&mut app, wolf, WOLF); + script(&mut app) + .run(r#"GameTooltip:SetOwner(UIParent, "ANCHOR_NONE"); GameTooltip:SetText("Addon")"#) + .unwrap(); + hover(&mut app, Hovered::default(), HoveredObject::default()); + script(&mut app).tick(2.5); + assert_eq!( + eval(&mut app, "return GameTooltip:IsShown()"), + None, + "the addon's tooltip faded with the hover" + ); + assert!(script(&mut app).take_errors().is_empty()); +} + +/// A hovered plate retired by its unit leaving takes the plate's leave (`0x764cce` β†’ `0x7cb8b0`), +/// which publishes the empty mouseover and fades the plate's tooltip; the hover then re-picks to +/// the WorldFrame. The pick taken before the retire still names the plate's unit, and must not +/// publish it again: no second build, no second `UPDATE_MOUSEOVER_UNIT`, no `"mouseover"`. +#[test] +fn a_retired_hovered_plate_is_not_published_again() { + use benilla_ui::script::{PlateGeometry, PlateState}; + + let (mut app, wolf, _) = mouseover_app(); + world_and_bag(&mut app, "ANCHOR_CURSOR"); + let geometry = PlateGeometry { + width: 128.0, + height: 32.0, + bar_off_x: 4.0, + bar_off_y: 4.0, + bar_width: 103.0, + bar_height: 9.0, + level_off_x: 11.8, + level_off_y: 9.1, + skull_size: 12.8, + raid_size: 25.6, + name_height: 12.8, + level_height: 11.0, + shadow_offset: 1.0, + }; + let plate = PlateState { + key: WOLF, + top_centre: (400.0, 400.0), + health: 30.0, + max_health: 40.0, + bar_colour: [1.0, 0.0, 0.0], + name: "Wolf".into(), + level: Some(12), + skull: false, + level_colour: [1.0, 1.0, 0.0], + raid_icon: None, + alpha: 1.0, + lit: false, + hovered: false, + }; + { + let mut s = script(&mut app); + s.run( + r#" + builds, updates = 0, 0 + GameTooltip:SetScript("OnShow", function() builds = builds + 1 end) + GameTooltip:RegisterEvent("UPDATE_MOUSEOVER_UNIT") + GameTooltip:SetScript("OnEvent", function() updates = updates + 1 end) + "#, + ) + .unwrap(); + s.sync_nameplates(geometry, std::slice::from_ref(&plate)); + s.resolve(); + s.mouse_move(400.0, 384.0); + assert_eq!( + s.hovered_nameplate(), + Some(WOLF), + "the plate holds the hover" + ); + } + // The plate's pick, as the pick takes it off this frame's walk. + let on_plate = Hovered { + target: Some(wolf), + guid: Some(WOLF), + distance: 0.0, + plate: true, + ..Default::default() + }; + hover(&mut app, on_plate, HoveredObject::default()); + assert_eq!(eval(&mut app, "return builds"), Some(1)); + // The plate driver retires it after the pick, in the same frame. + script(&mut app).sync_nameplates(geometry, &[]); + assert_eq!( + eval(&mut app, "return GameTooltip:IsVisible()"), + None, + "faded" + ); + // The next frame's driver still reads that pick. + hover(&mut app, on_plate, HoveredObject::default()); + hover(&mut app, Hovered::default(), HoveredObject::default()); + assert_eq!( + eval( + &mut app, + "return builds == 1 and updates == 1 and UnitExists('mouseover') == nil \ + and not GameTooltip:IsVisible() and 1 or nil" + ), + Some(1), + "built once, announced once, never named again" + ); + assert!(script(&mut app).take_errors().is_empty()); +} diff --git a/crates/benilla-app/src/ui_tooltip/mod.rs b/crates/benilla-app/src/ui_tooltip/mod.rs index 05c7d44d..99e7bc87 100644 --- a/crates/benilla-app/src/ui_tooltip/mod.rs +++ b/crates/benilla-app/src/ui_tooltip/mod.rs @@ -1,7 +1,11 @@ //! The world-mouseover tooltip and the spell tooltip views. The world tooltip rebuilds once per -//! hover-target change and a lost hover arms a fade; the pick is [`Hovered`] or -//! [`HoveredObject`], arbitrated by [`go_is_nearest`] as the click router does. The spell views -//! are built and pushed ahead of a hover by [`spell_feed`]. +//! hover-target change, published through the VM ([`UiScript::publish_world_mouseover`]), whose +//! change requests the fade; the pick is [`Hovered`] or [`HoveredObject`], arbitrated by +//! [`go_is_nearest`] as the click router does. The pick stands for the WorldFrame's own update +//! (`0x481790`), which picks only while the WorldFrame holds the hover (`0x4817ae`); the loss when +//! the cursor moves onto a window is published by the WorldFrame's leave in the UI's walk, before +//! the window's `OnEnter`. The spell views are built and pushed ahead of a hover by +//! [`spell_feed`]. use bevy::prelude::*; use bevy::window::PrimaryWindow; @@ -47,31 +51,11 @@ fn keyed(get: &dyn Fn(&str) -> Option, key: &str, args: &[Arg<'_>]) -> O (!text.is_empty()).then_some(text) } -/// What the world tooltip was last built for; the reference rebuilds once per hover-target change. -#[derive(Default, PartialEq, Clone, Copy)] -enum LastHover { - #[default] - None, - Unit(u64), - Go(u64), - /// A hovered corpse object, keyed on the corpse's guid. - 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. +/// ceiling. What it last published is the VM's ([`UiScript::world_mouseover`]), which a hover +/// leave in the UI's walk also writes. #[derive(bevy::ecs::system::SystemParam)] pub(crate) struct HoverMemo<'s> { - /// Which world plate is up. On an unchanged mouseover the reference makes no call (`0x482090` - /// returns at `0x4820b5`), so a plate Lua takes mid-hover stays gone. - last: Local<'s, crate::ui_script::VmMemo>, /// The line-affecting fields the current unit plate was built from, so a late-arriving name or /// creature-info rebuilds it under the same hover. last_lines: Local<'s, crate::ui_script::VmMemo>>, @@ -129,14 +113,25 @@ fn drive_mouseover_tooltip( let Some(mut script) = script else { return; }; - let (last, last_lines, trace) = (&mut memo.last, &mut memo.last_lines, &mut *memo.trace); - let last = last.get(&script); + let (last_lines, trace) = (&mut memo.last_lines, &mut *memo.trace); let last_lines = last_lines.get(&script); + // The publisher's change check (`0x482090`) compares against the VM's last publish. + let last = script.world_mouseover(); let published = memo.published.get(&script); let self_store = self_q.iter().next(); let chr = tables.classes(); let types = tables.types(&names); + // A plate's pick stands for its enter's publish (`0x7cb850`), so it holds only while that plate + // holds the hover: a plate retired after the pick was taken has run its leave (`0x7cb8b0`), + // which published the loss, and the hover re-picks to the WorldFrame. + if hovered.plate && script.hovered_nameplate() != hovered.guid { + if std::mem::take(published) { + script.set_unit("mouseover", None); + } + script.publish_world_mouseover(None); + return; + } let unit = hovered.mouseover(&hovered_go).and_then(|(entity, guid)| { let store = stores.get(entity).ok()?; let name = names @@ -164,18 +159,15 @@ fn drive_mouseover_tooltip( }); if let Some((guid, state)) = unit { - // Push first: the builder reads the token. Rebuild on a new target or a late name or - // creature answer; health and power stay out of the key, since the watcher drives the bar. + // Publish, which tears the old hover down, then push: the builder reads the token. Rebuild + // on a new target or a late name or creature answer, which rebuilds under the same hover + // with no teardown; health and power stay out of the key, since the watcher drives the bar. let key = lines_view(&state); + let changed = script.publish_world_mouseover(Some(guid)); 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(); - } + if changed || last_lines.as_ref() != Some(&key) { script.world_tooltip_unit("mouseover"); - *last = LastHover::Unit(guid); *last_lines = Some(key); } return; @@ -200,13 +192,10 @@ fn drive_mouseover_tooltip( }; // A bone pile with nothing to take publishes no mouseover: no plate, no brighten. if !crate::target::corpse_mouseover_eligible(store) { - if !matches!(*last, LastHover::None) { - script.world_tooltip_fade(); - *last = LastHover::None; - } + script.publish_world_mouseover(None); return; } - if *last == LastHover::Corpse(guid) { + if last == Some(guid) { return; // already up; a corner plate never re-seats } // A corpse carries `CORPSE_FIELD_OWNER`, not a name; the plate waits for the name query. @@ -224,11 +213,8 @@ fn drive_mouseover_tooltip( ) else { return; }; - if fades_on_change(*last, LastHover::Corpse(guid)) { - script.world_tooltip_fade(); - } + script.publish_world_mouseover(Some(guid)); script.world_tooltip_gameobject(&plate, &[], false); - *last = LastHover::Corpse(guid); return; } if let Some((entity, guid)) = go { @@ -255,12 +241,9 @@ fn drive_mouseover_tooltip( }) }) .flatten(); - if *last == LastHover::Go(guid) { + if last == Some(guid) { if crate::target::hover_probe_armed() { - let line = format!( - "held Go({guid:#x}) β€” plate up {}", - script.world_tooltip_up() - ); + let line = format!("held Go({guid:#x})"); if *trace != line { info!("hover probe/tooltip: {line}"); *trace = line; @@ -356,18 +339,12 @@ fn drive_mouseover_tooltip( _ => {} } } - if fades_on_change(*last, LastHover::Go(guid)) { - script.world_tooltip_fade(); - } + script.publish_world_mouseover(Some(guid)); script.world_tooltip_gameobject(&template.name, &lines, cursor_seated); - *last = LastHover::Go(guid); return; } - if fades_on_change(*last, LastHover::None) { - // Hover lost: the same teardown, with no plate after it. - script.world_tooltip_fade(); - *last = LastHover::None; - } + // Hover lost: the same teardown, with no plate after it. + script.publish_world_mouseover(None); } #[cfg(test)] @@ -377,4 +354,6 @@ mod deps_tests; #[cfg(test)] mod feed_tests; #[cfg(test)] +mod hover_order_tests; +#[cfg(test)] mod tests; diff --git a/crates/benilla-app/src/ui_tooltip/tests.rs b/crates/benilla-app/src/ui_tooltip/tests.rs index 2c401778..7b3f3cd6 100644 --- a/crates/benilla-app/src/ui_tooltip/tests.rs +++ b/crates/benilla-app/src/ui_tooltip/tests.rs @@ -870,13 +870,13 @@ fn the_spell_feed_runs_after_the_trainer_feed() { )); } -const ME: u64 = 0x77; -const WOLF: u64 = 0xF130_0000_4500_0001; -const BOAR: u64 = 0xF130_0000_4600_0002; +pub(super) const ME: u64 = 0x77; +pub(super) const WOLF: u64 = 0xF130_0000_4500_0001; +pub(super) const BOAR: u64 = 0xF130_0000_4600_0002; /// The world-hover driver over a bare VM holding `"player"` (us) and `"target"` (the wolf), each /// with its guid, as the unit feed pushes them; returns the app, the wolf and the boar. -fn mouseover_app() -> (App, Entity, Entity) { +pub(super) fn mouseover_app() -> (App, Entity, Entity) { use benilla_protocol::ObjectFields; let mut app = App::new(); @@ -917,13 +917,13 @@ fn mouseover_app() -> (App, Entity, Entity) { } /// Set this frame's pick and run the driver. -fn hover(app: &mut App, pick: Hovered, object: HoveredObject) { +pub(super) fn hover(app: &mut App, pick: Hovered, object: HoveredObject) { *app.world_mut().resource_mut::() = pick; *app.world_mut().resource_mut::() = object; app.update(); } -fn hover_unit(app: &mut App, entity: Entity, guid: u64) { +pub(super) fn hover_unit(app: &mut App, entity: Entity, guid: u64) { let pick = Hovered { target: Some(entity), guid: Some(guid), @@ -933,7 +933,7 @@ fn hover_unit(app: &mut App, entity: Entity, guid: u64) { hover(app, pick, HoveredObject::default()); } -fn eval(app: &mut App, lua: &str) -> Option { +pub(super) fn eval(app: &mut App, lua: &str) -> Option { app.world_mut() .non_send_resource_mut::() .eval::>(lua) @@ -1323,19 +1323,3 @@ 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-app/src/vplates.rs b/crates/benilla-app/src/vplates.rs index 057bac0c..d5fd7e40 100644 --- a/crates/benilla-app/src/vplates.rs +++ b/crates/benilla-app/src/vplates.rs @@ -66,12 +66,25 @@ impl VPlateMode { #[derive(Resource, Default)] pub(crate) struct VPlates(pub(crate) EntityHashSet); -/// The unit whose plate the pointer is inside, from last frame's layout: the plate's OnEnter +/// The unit whose plate holds the hover after this frame's UI walk: the plate's OnEnter /// publishing the mouseover (`0x7cb850` into `[0xb4e2c8]`), since the world pick stands down -/// over UI. +/// over UI. Read in the frame of the walk, as the plate's leave publishes the loss inside it +/// (`0x7cb8b0`), so the pick never re-publishes a plate the walk has just left. #[derive(Resource, Default)] pub(crate) struct PlateHover(pub(crate) Option); +/// Read [`PlateHover`] off the walk that just ran, at the end of the UI input pass. +pub(crate) fn seed_plate_hover( + script: Option>, + guids: Option>, + mut hover: ResMut, +) { + hover.0 = script + .and_then(|s| s.hovered_nameplate()) + .zip(guids) + .and_then(|(guid, guids)| guids.0.get(&guid).copied()); +} + /// Completed plate clicks, physical or an addon's `plate:Click`, waiting for the targeting chain: /// the reference's click slot (`0x7cb910`) ends in the same `SetSelection` as a body click. #[derive(Resource, Default)] @@ -300,7 +313,6 @@ struct PlateWorld<'w, 's> { fn drive_vplates( mode: Res, mut plates: ResMut, - mut plate_hover: ResMut, mut plate_clicks: ResMut, rig: Res, world: PlateWorld, @@ -321,7 +333,6 @@ fn drive_vplates( mut mouse_told: Local>>, ) { plates.0.clear(); - plate_hover.0 = None; bucket.clear(); // Every early return retires the plates first, or the widgets stay on screen; the reference // hides each live plate and returns it to the pool (`0x608a10`). @@ -543,9 +554,6 @@ fn drive_vplates( let mark = group.raid_target_index(guid.0); let x_units = (plate.min.x + plate.max.x) * 0.5 / seam; let y_units = (viewport.y - plate.min.y) / seam; - if hovered_key == Some(guid.0) { - plate_hover.0 = Some(entity); - } for click in &clicked { if click.key == guid.0 { match click.button.as_str() { diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index aa85fb67..7d68dca4 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -96,6 +96,7 @@ pub mod nameplate; pub use nameplate::{PlateBitWrites, PlateGeometry, PlateState, PLATE_BIT_ENEMY, PLATE_BIT_FRIEND}; mod model; mod modelframe; +mod mouseover; mod net_stats; pub(crate) mod object; pub use object::frame_kind_from_tag; diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 7b8c93b6..a5eb6f74 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -218,6 +218,13 @@ pub(crate) struct Model { /// reference's `[root+0x1100]` flag (set at `0x764cbb`/`0x764b8d`, pumped at `0x7657a1` into /// `0x7660d0`). pub(crate) hover_repick: bool, + /// The world mouseover as last published (`[0xb4e2c8]`, with the WorldFrame's own copy + /// `[wf+0x340]`): the pick's change check and the hover leaves read and write it + /// ([`super::mouseover`]). + pub(crate) world_mouseover: Option, + /// The blip whose tooltip the Minimap's probe put up, its stand-in for the hit set it keeps + /// (`0xbc81e4`, `0xbdcbac`), which a probe that misses the Minimap drops (`0x4eb12b`). + pub(crate) minimap_blip: Option, /// Per button held, the frame its press went to, the capture else the hover (`0x7662c0`), for /// the release; `None` once that frame left the mouse index (hide or mouse disable emptied /// the capture), so the release has no target but the button still counts as held. @@ -1129,6 +1136,8 @@ impl Model { focused_editbox: None, mouseover: None, hover_repick: false, + world_mouseover: None, + minimap_blip: None, mouse_down_on: HashMap::new(), mouse_capture: None, last_click: HashMap::new(), diff --git a/crates/benilla-ui/src/script/mouseover.rs b/crates/benilla-ui/src/script/mouseover.rs new file mode 100644 index 00000000..5349092d --- /dev/null +++ b/crates/benilla-ui/src/script/mouseover.rs @@ -0,0 +1,154 @@ +//! The world mouseover's publisher (`0x492890`) and the engine half of a hover leave. The world's +//! pick publishes through the change check (`0x482090`), and a leave publishes inside the hover +//! walk, which runs the old frame's leave before the new frame's enter (`0x766229`, `0x766239`): +//! the WorldFrame's (`0x483b10`) and a nameplate's (`0x7cb8b0`) publish the empty mouseover, and +//! the Minimap's (`0x4eb880`) hides `GameTooltip`. So the tooltip a window's `OnEnter` takes is +//! never torn down by the hover it replaced. +//! +//! The WorldFrame's enter is the base one (`0x76b6a0`) and publishes nothing: the world gains the +//! mouseover from its own update (`0x481790`), which picks only while the WorldFrame holds the +//! hover (`0x4817ae`), the app's world drive. + +use mlua::{Lua, Value}; + +use crate::widget::{FrameHandle, FrameKind}; + +use super::{event, Model, UiScript}; + +/// What a frame's leave notify (`vtbl+0x50`) does beyond its `OnLeave`. +enum LeaveClass { + /// `0x483b10`: the base leave, then the empty mouseover. + World, + /// `0x7cb8b0`: the empty mouseover, then the Button's leave. + Nameplate, + /// `0x4eb880`: `GameTooltip` hidden, then the base leave. + Minimap, + Other, +} + +fn leave_class(model: &Model, h: FrameHandle) -> LeaveClass { + match model.arena.frame(h).map(|f| f.kind) { + Some(FrameKind::WorldFrame) => LeaveClass::World, + Some(FrameKind::Minimap) => LeaveClass::Minimap, + Some(_) if model.nameplates.is_plate(h) => LeaveClass::Nameplate, + _ => LeaveClass::Other, + } +} + +/// A frame's leave notify, by its class, around its `OnLeave(true)`; `script` is the id whose +/// `OnLeave` fires, `None` where the notify skips it (a disabled Button's `0x7794e0`). The walk's +/// motion leave and the removal tail's leave (`0x764cce`) both come here. +pub(super) fn leave(lua: &Lua, h: FrameHandle, script: Option) { + let class = leave_class(&model(lua), h); + match class { + // `0x7cb8bf`: `0x492890` with an empty mouseover and an empty "previous", first. + LeaveClass::Nameplate => { + publish(lua, None); + } + // `0x4eb88e`–`0x4eb898`: the shown bit, then the hide slot, whoever holds it. + LeaveClass::Minimap => hide_tooltip(lua), + LeaveClass::World | LeaveClass::Other => {} + } + if let Some(id) = script { + if let Err(e) = event::fire_widget_handler(lua, id, "OnLeave", vec![Value::Boolean(true)]) { + model(lua).record_lua_error(&e); + } + } + // `0x483b1d` runs the base leave first; then, only while the WorldFrame's own last publish is + // not empty (`0x483b30`), the empty one (`0x483b46`). + if let LeaveClass::World = class { + publish(lua, None); + } +} + +/// The publisher behind the change check: an unchanged mouseover does nothing (`0x4820b5`). A +/// change away from a published one requests the fade on `GameTooltip` (`0x4928d5`, `0x492909` β†’ +/// `0x530ae0`), whoever holds it, after `"mouseover"` stops naming the old unit (the pair is zeroed +/// at `0x4928e8` first). Returns whether it changed. +pub(super) fn publish(lua: &Lua, guid: Option) -> bool { + let (prev, tooltip) = { + let mut model = model(lua); + if model.world_mouseover == guid { + return false; + } + let prev = std::mem::replace(&mut model.world_mouseover, guid); + // The token resolver reads the pair (`0x515970`), so `"mouseover"` names a unit only while + // it is the published guid; the app pushes the new unit after the publish. + if model + .pushed_unit("mouseover") + .is_some_and(|u| Some(u.guid) != guid) + { + model.units_by_lower.remove("mouseover"); + } + if model.unit_guids.mouseover != 0 && Some(model.unit_guids.mouseover) != guid { + model.unit_guids.mouseover = 0; + } + (prev, model.arena.lookup("GameTooltip")) + }; + if let (Some(_), Some(h)) = (prev, tooltip) { + super::tooltip::fade_out(lua, h); + } + true +} + +/// The Minimap's direct hide of `GameTooltip`: the shown bit, then the hide slot, with no fade +/// (`0x4eb15e`–`0x4eb166`, `0x4eb55f`–`0x4eb567`, `0x4eb88e`–`0x4eb898`). +fn hide_tooltip(lua: &Lua) { + let tooltip = model(lua).arena.lookup("GameTooltip"); + if let Some(h) = tooltip { + super::visibility::set_shown(lua, h, false); + } +} + +/// The Minimap's probe (`0x4eb0c0`, its `vtbl+0x3c`) found the cursor outside it: a blip still +/// held is dropped and `GameTooltip` hidden (`0x4eb0ee`, `0x4eb12b`–`0x4eb166`). The walk probes +/// the Minimap while it holds the capture (`0x7661ca`), and in the sweep when no frame ahead of it +/// took the hit. +pub(super) fn minimap_probe_miss(lua: &Lua) { + if model(lua).minimap_blip.take().is_some() { + hide_tooltip(lua); + } +} + +fn model(lua: &Lua) -> mlua::AppDataRefMut<'_, Model> { + lua.app_data_mut::().expect("model app_data") +} + +impl UiScript { + /// The world mouseover's guid as last published (`[0xb4e2c8]`), a unit, a GameObject or a + /// corpse; `None` once the pick or a hover leave published the empty one. + pub fn world_mouseover(&self) -> Option { + self.model_ref().world_mouseover + } + + /// Publish the world pick's mouseover ([`publish`]): the caller then pushes a unit's + /// `"mouseover"` and builds its plate. Returns whether it changed. + pub fn publish_world_mouseover(&mut self, guid: Option) -> bool { + publish(&self.lua, guid) + } + + /// Whether the walk probes the Minimap with the cursor on it: it holds the hover, which a hit + /// in the sweep makes it, or the capture, whose probe runs before the sweep (`0x7661ca`). Its + /// probe shows and drops the blip tooltip. + pub fn minimap_probed(&self) -> bool { + let model = self.model_ref(); + [model.mouseover, model.mouse_capture] + .into_iter() + .flatten() + .filter_map(|h| model.arena.frame(h)) + .any(|f| f.kind == FrameKind::Minimap) + } + + /// The blip whose tooltip [`Self::minimap_tooltip`] put up, while the probe holds it. + pub fn minimap_blip(&self) -> Option { + self.model_ref().minimap_blip.clone() + } + + /// The probe found no blip under the cursor inside the Minimap: it drops the held one and hides + /// `GameTooltip`, whoever holds it (`0x4eb54c`–`0x4eb567`). + pub fn minimap_blip_lost(&mut self) { + if self.model_mut().minimap_blip.take().is_some() { + hide_tooltip(&self.lua); + } + } +} diff --git a/crates/benilla-ui/src/script/nameplate.rs b/crates/benilla-ui/src/script/nameplate.rs index fd9ce8fa..4ad555d1 100644 --- a/crates/benilla-ui/src/script/nameplate.rs +++ b/crates/benilla-ui/src/script/nameplate.rs @@ -138,6 +138,11 @@ pub(crate) struct NamePlates { } impl NamePlates { + /// Whether `frame` is a plate, whose leave publishes the empty mouseover (`0x7cb8b0`). + pub(crate) fn is_plate(&self, frame: FrameHandle) -> bool { + self.by_frame.contains_key(&frame) + } + /// Whether the `0x7cba30` veto refuses `frame`: only a plate, only while the veto stands. pub(crate) fn vetoes(&self, frame: FrameHandle) -> bool { self.hit_test_vetoed && self.by_frame.contains_key(&frame) @@ -272,13 +277,17 @@ impl UiScript { } /// The freelook toggle (`0x60f830`): entering camera freelook disables mouse input on every - /// plate and leaving re-enables it (called from `0x483e80` and `0x483e70`). + /// plate and leaving re-enables it (called from `0x483e80` and `0x483e70`). The disable is + /// `EnableMouse(false)`'s (`0x76af50(2)`), whose removal tail (`0x764ba0`, flag 0, `0x76af7f`) + /// ends a hovered plate's hover through its leave (`0x7cb8b0`). pub fn set_nameplate_mouse(&mut self, enabled: bool) { let lua = self.lua(); - let mut model = lua.app_data_mut::().expect("model app_data"); - let frames: Vec = model.nameplates.plates.iter().map(|p| p.frame).collect(); + let frames: Vec = { + let model = lua.app_data_ref::().expect("model app_data"); + model.nameplates.plates.iter().map(|p| p.frame).collect() + }; for frame in frames { - model.arena.set_mouse_enabled(frame, enabled); + super::visibility::set_mouse_enabled(lua, frame, enabled); } } diff --git a/crates/benilla-ui/src/script/pointer.rs b/crates/benilla-ui/src/script/pointer.rs index c1a4a099..f1b1a726 100644 --- a/crates/benilla-ui/src/script/pointer.rs +++ b/crates/benilla-ui/src/script/pointer.rs @@ -67,6 +67,14 @@ fn is_world_handle(model: &Model, h: FrameHandle) -> bool { .is_some_and(|f| f.kind == crate::widget::FrameKind::WorldFrame) } +/// Whether `h` is a `Minimap`, whose probe drops its blip on a miss (`0x4eb0c0`). +fn is_minimap_handle(model: &Model, h: FrameHandle) -> bool { + model + .arena + .frame(h) + .is_some_and(|f| f.kind == crate::widget::FrameKind::Minimap) +} + /// Whether a press or release target is the world: the world frame, the reference's world click /// target, or no frame where `WorldFrame.xml` is not loaded (most tests). fn over_world(model: &Model, target: Option) -> bool { @@ -96,21 +104,44 @@ impl UiScript { /// The handle of the frame that captures `(x, y)`, for a caller that checks it against a /// handle set taken earlier (an anonymous frame has no name). Needs [`Self::resolve`] first. pub fn hit_test_frame(&self, x: f32, y: f32) -> Option { + self.hover_walk(x, y).0 + } + + /// The hover walk's probes: the capture frame's first (`[root+0x80]`, `0x7661ba`–`0x7661ca`), + /// unconditionally and its answer discarded, then the sweep (`0x7661cd`–`0x766213`). Returns the + /// first frame whose probe takes the point, and whether a `Minimap` was probed and missed it, + /// which then drops its blip (`0x4eb0c0`). A frame that takes the hit ends the sweep, so a + /// window over the Minimap keeps it from the sweep's probe; a held capture is probed anyway. + fn hover_walk(&self, x: f32, y: f32) -> (Option, bool) { let model = self.model_ref(); let sorted = order::traversal(&model.arena); let scroll_sources = scroll_clip_sources(&model); - order::hit_test(&sorted, |fh| { + let blip_held = model.minimap_blip.is_some(); + let minimap_missed = std::cell::Cell::new( + blip_held + && model.mouse_capture.is_some_and(|c| { + is_minimap_handle(&model, c) + && !frame_contains(&model, &scroll_sources, c, x, y) + }), + ); + let hit = order::hit_test(&sorted, |fh| { // Mouse-enabled, or over one of the frame's hyperlink spans. The chat frame takes no // mouse (`[+0xcc] = 0`); the reference gives each span a mouse-enabled // `CSimpleHyperlinkButton` child (`0x7a3240`) that routes its scripts to the parent, so // a chat window hits only over a link and drags by its tab, not its body. - (model.arena.is_mouse_enabled(fh) || link_span_hit(&model, fh, x, y)) - // The nameplate's `+0x3c` veto (`0x7cba30`), not its mouse-enabled bit: while a - // ground-targeted spell is armed a plate refuses the hit before its rect is - // tested, so the reticle is placed through it. + let indexed = model.arena.is_mouse_enabled(fh) || link_span_hit(&model, fh, x, y); + // The nameplate's `+0x3c` veto (`0x7cba30`), not its mouse-enabled bit: while a + // ground-targeted spell is armed a plate refuses the hit before its rect is tested, + // so the reticle is placed through it. + let hit = indexed && !model.nameplates.vetoes(fh) - && frame_contains(&model, &scroll_sources, fh, x, y) - }) + && frame_contains(&model, &scroll_sources, fh, x, y); + if blip_held && indexed && !hit && is_minimap_handle(&model, fh) { + minimap_missed.set(true); + } + hit + }); + (hit, minimap_missed.get()) } /// [`Self::hit_test`] over the frames that take the wheel: the same rect and clip rules, gated @@ -154,10 +185,15 @@ impl UiScript { pub fn mouse_move(&mut self, x: f32, y: f32) -> Option { // A drag-active EditBox extends its selection on every move (`0x77a860`). editbox::drag_update(&self.lua, x, y); - let new_id = self.hit_test(x, y); + let (new_handle, minimap_missed) = self.hover_walk(x, y); + let new_id = new_handle.and_then(|h| self.model_ref().frame_to_id.get(&h).copied()); + // The Minimap's probe runs in the sweep, ahead of the leave and the enter. + if minimap_missed { + super::mouseover::minimap_probe_miss(&self.lua); + } #[allow(clippy::type_complexity)] - let (leave_id, drag_start, enter_id, slider_change, color_change): ( - Option, + let (leave, drag_start, enter_id, slider_change, color_change): ( + Option<(FrameHandle, Option)>, Option<(u32, String)>, Option, Option<(u32, f32)>, @@ -173,19 +209,13 @@ impl UiScript { super::object::advance_move(&mut model, (x, y)); super::object::advance_size(&mut model, (x, y)); let drag_start = cursor::maybe_start_drag(&mut model, (x, y)); - let new_handle = new_id.and_then(|id| model.id_to_frame.get(&id).copied()); + let new_handle = new_id.and(new_handle); if new_handle == model.mouseover { (None, drag_start, None, slider_change, color_change) } else { - let old_id = model.mouseover.and_then(|h| { - model - .arena - .frame(h) - .is_some() - .then(|| model.frame_to_id.get(&h).copied()) - .flatten() - }); - let old_handle = model.mouseover; + // A frame destroyed since it took the hover gets no leave. + let old_handle = model.mouseover.filter(|&h| model.arena.frame(h).is_some()); + let old_id = old_handle.and_then(|h| model.frame_to_id.get(&h).copied()); model.mouseover = new_handle; // Crossing a button's boundary changes no button state: the enter/leave notifies // (`0x779490`/`0x7794e0`) read `[+0x328]` only as a disabled guard, so a press @@ -194,7 +224,8 @@ impl UiScript { // is still the hover: the reference moves `[root+0x7c]` before either notify. let old_id = old_id.filter(|_| button::hover_notify_runs(&model, old_handle)); let enter_id = new_id.filter(|_| button::hover_notify_runs(&model, new_handle)); - (old_id, drag_start, enter_id, slider_change, color_change) + let leave = old_handle.map(|h| (h, old_id)); + (leave, drag_start, enter_id, slider_change, color_change) } }; // A thumb drag fires `OnValueChanged`, outside the borrow. @@ -225,12 +256,10 @@ impl UiScript { self.push_error(e); } } - if let Some(oid) = leave_id { - if let Err(e) = - event::fire_widget_handler(&self.lua, oid, "OnLeave", vec![Value::Boolean(true)]) - { - self.push_error(e); - } + // The old frame's leave, its class's engine half included ([`super::mouseover::leave`]), + // runs before the new frame's enter (`0x766229`, then `0x766239`). + if let Some((h, script)) = leave { + super::mouseover::leave(&self.lua, h, script); } if let Some(nid) = enter_id { if let Err(e) = diff --git a/crates/benilla-ui/src/script/tests/hover_publish.rs b/crates/benilla-ui/src/script/tests/hover_publish.rs new file mode 100644 index 00000000..10479184 --- /dev/null +++ b/crates/benilla-ui/src/script/tests/hover_publish.rs @@ -0,0 +1,168 @@ +//! The engine half of a hover leave: what the WorldFrame, a nameplate and the Minimap do to the +//! mouseover and to `GameTooltip` inside the hover walk, before the next frame's `OnEnter`. + +use super::common::script; +use crate::script::UiScript; + +/// A full-screen `UIParent`, a mouse-enabled `Minimap` at (600, 400)–(740, 540), a `GameTooltip`, +/// and a bag button at (100, 100)–(140, 140) whose `OnEnter` records whether the tooltip was up +/// and then takes it. +fn minimap_and_bag() -> UiScript { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + UIParent = CreateFrame("Frame", "UIParent"); UIParent:SetAllPoints() + local map = CreateFrame("Minimap", "Minimap", UIParent) + map:SetPoint("BOTTOMLEFT", 600, 400); map:SetWidth(140); map:SetHeight(140) + map:EnableMouse(true) + CreateFrame("GameTooltip", "GameTooltip", UIParent):Hide() + Bag = CreateFrame("Button", "Bag", UIParent) + Bag:SetPoint("BOTTOMLEFT", 100, 100); Bag:SetWidth(40); Bag:SetHeight(40) + Bag:EnableMouse(true) + Bag:SetScript("OnEnter", function() + entered_visible = GameTooltip:IsVisible() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetText("Bag slot") + end) + "#, + ) + .unwrap(); + s.resolve(); + s +} + +/// The Minimap's leave (`0x4eb880`, its `vtbl+0x50`) hides `GameTooltip` (`0x4eb88e`–`0x4eb898`) +/// before its base leave, and the walk runs it before the next frame's enter, so a blip's tooltip +/// is gone when the button takes the tooltip, and the button's stays. +#[test] +fn leaving_the_minimap_hides_the_tooltip_before_the_next_enter() { + let mut s = minimap_and_bag(); + s.mouse_move(670.0, 470.0); + assert!(s.minimap_tooltip("Stormwind", false)); + s.mouse_move(120.0, 120.0); + assert!( + s.eval::("return entered_visible == nil").unwrap(), + "the blip's tooltip was hidden before the bag's OnEnter" + ); + assert!(s + .eval::(r#"return GameTooltip:IsShown() == 1 and GameTooltip:IsOwned(Bag) and true"#) + .unwrap()); + assert!(s.take_errors().is_empty()); +} + +/// The WorldFrame's leave (`0x483b10`) runs the base leave first (`0x483b1d`), so its `OnLeave` +/// still finds the mouseover, and then publishes the empty one (`0x483b46`): `"mouseover"` names +/// nobody and the published guid is gone. Its enter is the base one and publishes nothing. +#[test] +fn the_world_frames_leave_runs_its_onleave_then_publishes_the_loss() { + let mut s = minimap_and_bag(); + s.run( + r#" + WorldFrame = CreateFrame("WorldFrame", "WorldFrame"); WorldFrame:SetAllPoints() + WorldFrame:SetScript("OnLeave", function() left_mouseover = UnitExists("mouseover") end) + "#, + ) + .unwrap(); + s.resolve(); + const WOLF: u64 = 0xF130_0000_4500_0001; + s.mouse_move(400.0, 300.0); + assert_eq!( + s.world_mouseover(), + None, + "the WorldFrame's enter publishes nothing" + ); + assert!(s.publish_world_mouseover(Some(WOLF))); + s.set_unit( + "mouseover", + Some(crate::script::UnitState { + exists: true, + guid: WOLF, + ..Default::default() + }), + ); + s.mouse_move(120.0, 120.0); + assert!(s + .eval::("return left_mouseover == 1 and UnitExists('mouseover') == nil") + .unwrap()); + assert_eq!(s.world_mouseover(), None); + assert!(s.take_errors().is_empty()); +} + +/// With no capture held, the walk probes the Minimap only in its sweep, and the sweep only when no +/// frame ahead of it took the hit (`0x76620c`): off it, the probe drops the blip and hides the +/// tooltip (`0x4eb12b`–`0x4eb166`), so the same blip shows afresh later; onto a window over it, +/// the probe never runs and the blip stays held, though the Minimap's leave still hides the +/// tooltip. A held capture is probed before the sweep (the next test). +#[test] +fn the_minimap_probe_drops_its_blip_only_when_the_walk_probes_it() { + let mut s = minimap_and_bag(); + s.run( + r#" + Cover = CreateFrame("Frame", "Cover", UIParent) + Cover:SetPoint("BOTTOMLEFT", 700, 500); Cover:SetWidth(40); Cover:SetHeight(40) + Cover:SetFrameStrata("DIALOG"); Cover:EnableMouse(true) + "#, + ) + .unwrap(); + s.resolve(); + s.mouse_move(670.0, 470.0); + assert!(s.minimap_tooltip("Stormwind", false)); + s.mouse_move(720.0, 520.0); + assert_eq!(s.minimap_blip().as_deref(), Some("Stormwind"), "not probed"); + assert!(!s + .eval::("return GameTooltip:IsVisible() == 1") + .unwrap()); + s.mouse_move(670.0, 470.0); + assert!(s.minimap_tooltip("Stormwind", false)); + s.mouse_move(300.0, 300.0); + assert_eq!(s.minimap_blip(), None, "probed and missed"); + assert!(!s + .eval::("return GameTooltip:IsVisible() == 1") + .unwrap()); + assert!(s.take_errors().is_empty()); +} + +/// A window shown under a still cursor takes the hover at the pump's re-pick (`0x7657be` β†’ +/// `0x7660d0`), after the update walk and its drain, and that walk runs the WorldFrame's leave +/// like a moving one: the loss is published before the window's `OnEnter`, in the same tick. +#[test] +fn the_re_pick_onto_a_shown_window_publishes_the_worlds_loss_first() { + let mut s = minimap_and_bag(); + s.run( + r#" + WorldFrame = CreateFrame("WorldFrame", "WorldFrame"); WorldFrame:SetAllPoints() + Bag:Hide() + "#, + ) + .unwrap(); + s.resolve(); + s.mouse_move(120.0, 120.0); + assert!(s.publish_world_mouseover(Some(7))); + s.run(r#"GameTooltip:SetOwner(UIParent, "ANCHOR_CURSOR"); GameTooltip:SetText("Wolf")"#) + .unwrap(); + s.run("Bag:Show()").unwrap(); + s.tick(0.016); + assert_eq!(s.world_mouseover(), None); + assert!(s + .eval::(r#"return entered_visible == nil and GameTooltip:IsOwned(Bag) and true"#) + .unwrap()); + assert!(s.take_errors().is_empty()); +} + +/// Before its sweep the walk probes the capture frame (`[root+0x80]`, `0x7661ba`–`0x7661ca`), +/// unconditionally and for nothing but the probe's own work: a Minimap pressed and dragged off onto +/// a frame that wins the sweep is still probed, misses, and drops its blip (`0x4eb12b`). +#[test] +fn a_captured_minimap_is_probed_though_the_sweep_never_reaches_it() { + let mut s = minimap_and_bag(); + s.run(r#"Bag:SetFrameStrata("DIALOG")"#).unwrap(); + s.resolve(); + s.mouse_move(670.0, 470.0); + assert!(s.minimap_tooltip("Stormwind", false)); + s.mouse_button(670.0, 470.0, "LeftButton", true); + s.mouse_move(120.0, 120.0); + assert_eq!(s.minimap_blip(), None, "the capture's probe missed"); + s.mouse_button(120.0, 120.0, "LeftButton", false); + assert!(s.take_errors().is_empty()); +} diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs index 03c00724..68d532e6 100644 --- a/crates/benilla-ui/src/script/tests/mod.rs +++ b/crates/benilla-ui/src/script/tests/mod.rs @@ -26,6 +26,7 @@ mod generic_for; mod gm_ticket; mod handler_prof; mod host_state; +mod hover_publish; mod input; mod keep_rect; mod keyboard; diff --git a/crates/benilla-ui/src/script/tests/nameplate.rs b/crates/benilla-ui/src/script/tests/nameplate.rs index 304d3ae3..346e5b37 100644 --- a/crates/benilla-ui/src/script/tests/nameplate.rs +++ b/crates/benilla-ui/src/script/tests/nameplate.rs @@ -735,3 +735,72 @@ fn a_ground_target_veto_refuses_the_hit_without_disabling_the_mouse() { "and the plate takes it back when the cast is gone" ); } + +/// A plate's leave (`0x7cb8b0`) publishes the empty mouseover first (`0x7cb8bf`), whose change +/// requests the fade (`0x492909`), so a window entered from a plate finds the plate's cursor-seated +/// tooltip gone; a hovered plate retired by its unit leaving takes the same leave (`0x764cce`). +#[test] +fn leaving_a_plate_publishes_the_empty_mouseover_before_the_next_enter() { + let mut s = vm(); + let wolf = plate("Wolf", 30.0, 40.0); + let key = wolf.key; + s.run( + r#" + CreateFrame("GameTooltip", "GameTooltip"):Hide() + Bag = CreateFrame("Button", "Bag") + Bag:SetPoint("BOTTOMLEFT", 40, 40); Bag:SetWidth(40); Bag:SetHeight(40) + Bag:EnableMouse(true) + Bag:SetScript("OnEnter", function() entered_visible = GameTooltip:IsVisible() end) + "#, + ) + .unwrap(); + drive(&mut s, std::slice::from_ref(&wolf)); + let hover_plate = |s: &mut UiScript| { + s.mouse_move(500.0, 384.0); + assert!(s.publish_world_mouseover(Some(key)), "the plate's enter"); + s.run(r#"GameTooltip:SetOwner(WorldFrame, "ANCHOR_CURSOR"); GameTooltip:SetText("Wolf")"#) + .unwrap(); + }; + hover_plate(&mut s); + s.mouse_move(60.0, 60.0); + assert!(s.eval::("return entered_visible == nil").unwrap()); + assert_eq!(s.world_mouseover(), None); + + hover_plate(&mut s); + drive(&mut s, &[]); + assert_eq!(s.world_mouseover(), None, "the retired plate's leave"); + assert!(!s + .eval::("return GameTooltip:IsVisible() == 1") + .unwrap()); + assert!(s.take_errors().is_empty()); +} + +/// Freelook's entry disables every plate's mouse through `0x76af50(2)` (`0x60f830`), whose removal +/// tail (`0x764ba0`, flag 0, `0x76af7f`) ends the hovered plate's hover and runs its leave +/// (`0x7cb8b0`): `OnLeave` fires, the mouseover is published empty, and the hover re-picks. +#[test] +fn freelook_ends_a_hovered_plates_hover_through_its_leave() { + let mut s = vm(); + let wolf = plate("Wolf", 30.0, 40.0); + let key = wolf.key; + drive(&mut s, std::slice::from_ref(&wolf)); + s.run( + r#" + left = 0 + local plate = WorldFrame:GetChildren() + plate:SetScript("OnLeave", function() left = left + 1 end) + "#, + ) + .unwrap(); + s.mouse_move(500.0, 384.0); + assert!(s.publish_world_mouseover(Some(key)), "the plate's enter"); + s.set_nameplate_mouse(false); + assert_eq!( + s.eval::("return left").unwrap(), + 1, + "the plate's OnLeave" + ); + assert_eq!(s.world_mouseover(), None, "published empty"); + assert_eq!(s.hovered_nameplate(), None); + 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 81a2a9d0..a6b237f0 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_unit.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_unit.rs @@ -579,6 +579,10 @@ fn world_hover_drive_and_health_watcher() { "#, ) .unwrap(); + assert!( + s.publish_world_mouseover(Some(wolf().guid)), + "a first hover" + ); assert!(s.world_tooltip_unit("mouseover"), "the hover shows"); s.run( r#" @@ -614,8 +618,7 @@ fn world_hover_drive_and_health_watcher() { .unwrap(); // Hover lost: the fade arms and `"mouseover"` names nobody (`0x492890` zeroes the pair), yet // the fading plate keeps its bar, whose watcher follows the unit's guid, not the token. - s.world_tooltip_fade(); - s.set_unit("mouseover", None); + assert!(s.publish_world_mouseover(None)); s.tick(0.1); s.run( r#" @@ -655,7 +658,8 @@ fn minimap_blip_tooltip_shows_follows_and_hides() { r#" local up = CreateFrame("Frame", "UIParent") up:SetPoint("BOTTOMLEFT", 0, 0); up:SetWidth(800); up:SetHeight(600) - CreateFrame("Minimap", "Minimap", up) + -- The cursor stays on the Minimap, whose probe drops the blip on a miss. + CreateFrame("Minimap", "Minimap", up):SetAllPoints(up) "#, ) .unwrap(); @@ -690,7 +694,7 @@ fn minimap_blip_tooltip_shows_follows_and_hides() { ) .unwrap(); // Hover loss hides at once. - s.world_tooltip_hide(); + s.minimap_blip_lost(); s.run(r#"assert(not GameTooltip:IsShown(), "hidden with no fade")"#) .unwrap(); assert!(s.take_errors().is_empty()); @@ -707,7 +711,8 @@ 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) + -- The cursor stays on the Minimap, whose probe drops the blip on a miss. + CreateFrame("Minimap", "Minimap", up):SetAllPoints(up) CreateFrame("GameTooltip", "GameTooltip", UIParent) "#, ) @@ -807,10 +812,11 @@ fn a_cursor_seated_world_plate_hides_at_once_on_hover_loss() { "#, ) .unwrap(); + s.publish_world_mouseover(Some(1)); 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.publish_world_mouseover(None); s.run(r#"assert(not GameTooltip:IsVisible(), "gone at once")"#) .unwrap(); assert!(s.take_errors().is_empty()); @@ -840,7 +846,7 @@ fn a_minimap_blip_plate_hides_at_once_on_hover_loss() { "#, ) .unwrap(); - s.world_tooltip_hide(); + s.minimap_blip_lost(); s.run(r#"assert(not GameTooltip:IsShown(), "gone at once")"#) .unwrap(); assert!(s.take_errors().is_empty()); @@ -865,11 +871,12 @@ fn a_cursor_plate_replaced_by_another_hides_then_shows_and_clears_twice_per_seat "#, ) .unwrap(); + s.publish_world_mouseover(Some(1)); 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(); + s.publish_world_mouseover(Some(2)); assert!(s.world_tooltip_gameobject("Tirisfal Glades", &[], true)); s.run( r#" @@ -881,48 +888,47 @@ fn a_cursor_plate_replaced_by_another_hides_then_shows_and_clears_twice_per_seat 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. +/// The publisher tears down on every change away from a published mouseover (`0x4928d5`), loss +/// and replacement alike, and not for a first one or an unchanged one (`0x4820b5`); its fade +/// request is on `GameTooltip` whoever holds it (`0x492909`), so a tooltip a window took since +/// fades too. #[test] -fn hover_loss_fades_a_re_set_world_plate_but_not_a_re_owned_one() { +fn a_mouseover_change_fades_the_tooltip_whoever_holds_it() { 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) + CreateFrame("GameTooltip", "GameTooltip"):Hide() + Bag = CreateFrame("Button", "Bag") + function bag_tooltip() + GameTooltip:SetOwner(Bag, "ANCHOR_RIGHT"); GameTooltip:SetText("Bag slot") + 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(); + let up = |s: &mut crate::script::UiScript| { + s.tick(2.5); + s.eval::("return GameTooltip:IsShown() == 1").unwrap() + }; + // A first publish has nothing to tear down; an unchanged one does nothing. + s.run("bag_tooltip()").unwrap(); + assert!(s.publish_world_mouseover(Some(1))); + assert!(!s.publish_world_mouseover(Some(1))); + assert!(up(&mut s), "no fade for a first or an unchanged mouseover"); + // A replacement and a loss each request the fade. + for next in [Some(2), None] { + s.run("bag_tooltip()").unwrap(); + assert!(s.publish_world_mouseover(next)); + assert!( + !up(&mut s), + "the bag's tooltip faded on the change to {next:?}" + ); + } + // From the empty mouseover, a new one tears nothing down. + s.run("bag_tooltip()").unwrap(); + assert!(s.publish_world_mouseover(Some(3))); + assert!(up(&mut s)); 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 01e2da07..d4e1e6ee 100644 --- a/crates/benilla-ui/src/script/tooltip/mod.rs +++ b/crates/benilla-ui/src/script/tooltip/mod.rs @@ -424,8 +424,6 @@ pub(in crate::script) fn set_owner_core( }; 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); diff --git a/crates/benilla-ui/src/script/tooltip_unit.rs b/crates/benilla-ui/src/script/tooltip_unit.rs index 0aaa893a..f1385279 100644 --- a/crates/benilla-ui/src/script/tooltip_unit.rs +++ b/crates/benilla-ui/src/script/tooltip_unit.rs @@ -161,16 +161,6 @@ fn render_unit(lua: &Lua, this: &Table, token: &str) -> mlua::Result { Ok(true) } -/// Mark the tooltip as world-hover-owned (the fade-on-loss gate). -fn set_world_owned(lua: &Lua, h: FrameHandle) { - let mut model = lua.app_data_mut::().expect("model app_data"); - if let Some(f) = model.arena.frame_mut(h) { - if let KindState::Tooltip(t) = &mut f.kind_state { - t.world_owned = true; - } - } -} - /// Remember (or drop) the guid of the unit the tooltip shows, the health watcher's key. fn set_subject(lua: &Lua, h: FrameHandle, guid: Option) { let mut model = lua.app_data_mut::().expect("model app_data"); @@ -264,13 +254,12 @@ impl super::UiScript { /// 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, id) = { + let id = { let mut model = self.model_mut(); let Some(h) = model.arena.lookup("GameTooltip") else { return false; }; - let id = model.frame_id(h); - (h, id) + model.frame_id(h) }; if let Err(e) = super::event::fire_widget_handler(&self.lua, id, "OnTooltipSetDefaultAnchor", vec![]) @@ -292,7 +281,6 @@ impl super::UiScript { } }; if shown { - set_world_owned(&self.lua, h); self.fire_event("UPDATE_MOUSEOVER_UNIT", vec![]); } shown @@ -351,7 +339,6 @@ impl super::UiScript { self.push_error(e); return false; } - set_world_owned(&self.lua, h); show_or_hide_empty(&self.lua, h); true } @@ -360,7 +347,8 @@ impl super::UiScript { /// 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`]. + /// (`0x4eb85f`/`0x4eb869`). The blip is held until the probe finds none + /// ([`Self::minimap_blip_lost`]) or misses the Minimap. pub fn minimap_tooltip(&mut self, text: &str, grey: bool) -> bool { let (h, id, root) = { let mut model = self.model_mut(); @@ -391,53 +379,10 @@ impl super::UiScript { self.push_error(e); return false; } - set_world_owned(&self.lua, h); + self.model_mut().minimap_blip = Some(text.to_string()); show_or_hide_empty(&self.lua, h); true } - - /// 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() - } - - /// The world plate, when [`Self::world_tooltip_up`]. - fn world_plate(&mut self) -> Option { - let model = self.model_mut(); - let h = model.arena.lookup("GameTooltip")?; - model - .arena - .frame(h) - .is_some_and(|f| { - matches!(&f.kind_state, KindState::Tooltip(t) if t.world_owned) && f.shown - }) - .then_some(h) - } - - /// 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) { - 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); - } - } } /// Register the unit content channel into the GameTooltip kind method table. diff --git a/crates/benilla-ui/src/script/visibility.rs b/crates/benilla-ui/src/script/visibility.rs index 68c7e8d6..45dca109 100644 --- a/crates/benilla-ui/src/script/visibility.rs +++ b/crates/benilla-ui/src/script/visibility.rs @@ -8,7 +8,7 @@ //! (`0x76abfd`, `0x76ab41`). [`show`] and [`hide`] are that dispatch, so a kind's override runs //! the same however the transition comes. -use mlua::{Lua, Table, Value}; +use mlua::{Lua, Table}; use super::{event, Model}; use crate::widget::{ButtonState, ChildWalk, FrameHandle, FrameKind}; @@ -95,9 +95,8 @@ pub(super) fn base_hide(lua: &Lua, h: FrameHandle) { } leave_mouse_index(&mut model, h) }; - if let Some(id) = left { - let fired = event::fire_widget_handler(lua, id, "OnLeave", vec![Value::Boolean(true)]); - record(lua, fired); + if let Some(script) = left { + super::mouseover::leave(lua, h, script); } walk_children(lua, h, hide); // `CSimpleButton`'s hide notify (`+0x34`, `0x7791e0`) un-presses it, unless disabled or @@ -147,17 +146,16 @@ pub(super) fn set_mouse_enabled(lua: &Lua, h: FrameHandle, enabled: bool) { } leave_mouse_index(&mut model, h) }; - if let Some(id) = left { - let fired = event::fire_widget_handler(lua, id, "OnLeave", vec![Value::Boolean(true)]); - record(lua, fired); + if let Some(script) = left { + super::mouseover::leave(lua, h, script); } model_mut(lua).arena.set_mouse_enabled(h, false); } /// The mouse kind's removal tail (`0x764ba0`, kind 2, flag 0), shared by the hide and a mouse -/// disable: the capture and the press it took drop, then the hover ends. The id whose `OnLeave` -/// the caller fires. -fn leave_mouse_index(model: &mut Model, h: FrameHandle) -> Option { +/// disable: the capture and the press it took drop, then the hover ends. `Some` when the frame +/// held the hover, holding the id whose `OnLeave` its leave notify fires. +fn leave_mouse_index(model: &mut Model, h: FrameHandle) -> Option> { drop_capture(model, h); leave_if_hovered(model, h) } @@ -188,9 +186,10 @@ fn drop_capture(model: &mut Model, h: FrameHandle) { /// and calls the leave notify with 1 (`0x764cce`), whose base `0x76b6f0` clears the drag-arm /// (`+0x100`/`+0x104`), so the hovered frame's OnLeave runs before its own OnHide and its /// ancestors'. -/// The leave is a virtual call: a disabled Button's own `0x7794e0` skips its OnLeave. This leave is -/// how a tooltip closes with its window; the reference has no other. The id whose OnLeave fires. -fn leave_if_hovered(model: &mut Model, h: FrameHandle) -> Option { +/// The leave is a virtual call ([`super::mouseover::leave`]): a disabled Button's own `0x7794e0` +/// skips its OnLeave. This leave is how a tooltip closes with its window; the reference has no +/// other. `Some` when the frame held the hover, holding the id whose OnLeave fires. +fn leave_if_hovered(model: &mut Model, h: FrameHandle) -> Option> { if model.mouseover != Some(h) { return None; } @@ -200,7 +199,7 @@ fn leave_if_hovered(model: &mut Model, h: FrameHandle) -> Option { model.drag = None; } model.hover_repick = true; - notified.then(|| model.frame_id(h)) + Some(notified.then(|| model.frame_id(h))) } /// The notify slot (`+0x30` OnShow, `+0x34` OnHide): the base fires the script unless the frame is diff --git a/crates/benilla-ui/src/widget/kinds/mod.rs b/crates/benilla-ui/src/widget/kinds/mod.rs index b5a88d6d..a306e52d 100644 --- a/crates/benilla-ui/src/widget/kinds/mod.rs +++ b/crates/benilla-ui/src/widget/kinds/mod.rs @@ -265,10 +265,6 @@ pub struct TooltipState { /// 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, - /// 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 /// between (`MerchantFrame.xml:67-72`); the reference's route to `0x52b650` is untraced.