From 5be43004016ec08d9c30a9c5930cd1bc2a65e839 Mon Sep 17 00:00:00 2001 From: chairmanpow Date: Tue, 6 Oct 2026 23:58:32 +0100 Subject: [PATCH] ui: OnHyperlinkEnter/OnHyperlinkLeave fire as the cursor crosses a link span SetScript raised "unsupported script 'OnHyperlinkEnter'" for chat-link addons, because only OnHyperlinkClick was registered. The reference fires the enter/leave pair on the owning frame with (link, text) as the hover crosses a link span, so the kinds are now accepted and fired from the pointer's move and when a hovered frame hides. --- crates/benilla-ui/src/script/event.rs | 38 +++++++++++++-- crates/benilla-ui/src/script/mod.rs | 6 ++- crates/benilla-ui/src/script/model.rs | 4 ++ .../src/script/object/events_regions.rs | 6 +-- crates/benilla-ui/src/script/pointer.rs | 46 +++++++++++++++++++ 5 files changed, 92 insertions(+), 8 deletions(-) diff --git a/crates/benilla-ui/src/script/event.rs b/crates/benilla-ui/src/script/event.rs index 854788fd..8a444e55 100644 --- a/crates/benilla-ui/src/script/event.rs +++ b/crates/benilla-ui/src/script/event.rs @@ -128,7 +128,7 @@ pub(super) fn fire_visibility_changes(lua: &Lua, changed: Vec) { // before its `OnHide` (`0x764ba0`'s tail in `0x76ad50`, the leave at `0x764cce`), clears the // hover and the drag-arm (`+0x100`/`+0x104`) and arms the re-pick; a show arms it too // (`0x764b8d`). This leave is how a tooltip closes with its window; the reference has no other. - let left: Option<(FrameHandle, u32)> = { + let (left, link_left): (Option<(FrameHandle, u32)>, Option<(u32, String, String)>) = { let mut model = lua.app_data_mut::().expect("model"); let hidden_hover = model .mouseover @@ -136,6 +136,19 @@ pub(super) fn fire_visibility_changes(lua: &Lua, changed: Vec) { // The leave is a virtual call (`0x764cce`, `[vtable+0x50]`): a disabled Button's own // `0x7794e0` skips its `OnLeave`, while the hover and the drag-arm still clear. let notified = super::button::hover_notify_runs(&model, hidden_hover); + // A hidden frame's hovered hyperlink span leaves with it, so a chat-link tooltip closes. + let link_hidden = model + .hover_link + .as_ref() + .is_some_and(|(fh, _, _)| items.iter().any(|&(h, _, vis)| h == *fh && !vis)); + let link_left = if link_hidden { + model + .hover_link + .take() + .map(|(fh, link, markup)| (model.frame_id(fh), link, markup)) + } else { + None + }; if let Some(m) = hidden_hover { model.mouseover = None; if model.drag.as_ref().is_some_and(|d| d.source == m) { @@ -143,10 +156,27 @@ pub(super) fn fire_visibility_changes(lua: &Lua, changed: Vec) { } } model.hover_repick |= hidden_hover.is_some() || items.iter().any(|&(_, _, vis)| vis); - hidden_hover - .filter(|_| notified) - .map(|m| (m, model.frame_id(m))) + ( + hidden_hover + .filter(|_| notified) + .map(|m| (m, model.frame_id(m))), + link_left, + ) }; + if let Some((oid, link, markup)) = link_left { + if let (Ok(l), Ok(m)) = (lua.create_string(&link), lua.create_string(&markup)) { + if let Err(e) = fire_widget_handler( + lua, + oid, + "OnHyperlinkLeave", + vec![Value::String(l), Value::String(m)], + ) { + lua.app_data_mut::() + .expect("model") + .record_script_error(e.to_string()); + } + } + } if let Some((_, oid)) = left { if let Err(e) = fire_widget_handler(lua, oid, "OnLeave", vec![Value::Boolean(true)]) { lua.app_data_mut::() diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 22bf8a8c..45bd8f14 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -383,7 +383,7 @@ pub const SCREEN: crate::layout::Handle = 0; /// ScrollFrame's three scroll kinds are `+0x32c`/`+0x334`/`+0x33c` (script-name map `0x786c40`). /// The EditBox's vtable (`0x81c910`) replaces the key and char slots: an EditBox never fires /// `OnKeyDown`, and fires `OnChar` only from `Insert`, with the inserted text (`0x77c13c`). -const SCRIPT_KINDS: [&str; 39] = [ +const SCRIPT_KINDS: [&str; 41] = [ "OnLoad", "OnEvent", "OnUpdate", @@ -420,6 +420,10 @@ const SCRIPT_KINDS: [&str; 39] = [ // A release over a message-frame hyperlink span, `OnHyperlinkClick(link, text, button)`; // `ChatFrameTemplate` passes it to `SetItemRef` (`ChatFrame.xml:15`, `ChatFrame.lua:1534`). "OnHyperlinkClick", + // The per-span hover pair, fired by [`pointer`]'s move as the cursor crosses a link span of the + // hovered frame (`0x4e2e30`/`0x4e2ee0`, the chat frames' item and ability tooltips). + "OnHyperlinkEnter", + "OnHyperlinkLeave", // The GameTooltip's engine-fired scripts: money render, money clear and world-hover default // placement, all three wired by the stock template (`GameTooltipTemplate.xml:617-625`). "OnTooltipAddMoney", diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index d1d1db02..f860bf66 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -196,6 +196,9 @@ pub(crate) struct Model { /// position and a newly exposed frame gets `OnEnter` without a mouse move: the reference's /// `[root+0x1100]` flag (set at `0x764cbb`/`0x764b8d`, pumped at `0x7657a1` into `0x7660d0`). pub(crate) hover_repick: bool, + /// The hyperlink span the cursor is inside, `(frame, link, markup)`, for the next move's + /// `OnHyperlinkLeave`/`OnHyperlinkEnter`; `None` off every span. + pub(crate) hover_link: Option<(FrameHandle, String, String)>, /// Per button, the frame its press landed on, for `OnClick`'s same-frame release test. pub(crate) mouse_down_on: HashMap, /// The client's one mouse-capture slot (`root+0x80`), read by the mouse-down raise. Set at @@ -1045,6 +1048,7 @@ impl Model { focused_editbox: None, mouseover: None, hover_repick: false, + hover_link: None, mouse_down_on: HashMap::new(), mouse_capture: None, last_click: HashMap::new(), diff --git a/crates/benilla-ui/src/script/object/events_regions.rs b/crates/benilla-ui/src/script/object/events_regions.rs index 333d21a0..dfad7aff 100644 --- a/crates/benilla-ui/src/script/object/events_regions.rs +++ b/crates/benilla-ui/src/script/object/events_regions.rs @@ -211,9 +211,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { } /// `SetScript(name, func)`: stores the closure under one of [`SCRIPT_KINDS`], else raises. -/// Deviation: real 1.12 kinds nothing fires yet raise too (`OnHyperlinkEnter`, `OnHyperlinkLeave`, -/// `OnMessageScrollChanged`, `OnInputLanguageChanged`, the movie frame's), because an accepted -/// handler that never runs fails silently. `OnAttributeChanged` is 2.0's, with no 1.12 slot. +/// Deviation: real 1.12 kinds nothing fires yet raise too (`OnMessageScrollChanged`, +/// `OnInputLanguageChanged`, the movie frame's), because an accepted handler that never runs fails +/// silently. `OnAttributeChanged` is 2.0's, with no 1.12 slot. fn set_script(lua: &Lua, this: &Table, name: &str, func: Option) -> mlua::Result<()> { let kind = SCRIPT_KINDS .iter() diff --git a/crates/benilla-ui/src/script/pointer.rs b/crates/benilla-ui/src/script/pointer.rs index 3239e0af..f83722a6 100644 --- a/crates/benilla-ui/src/script/pointer.rs +++ b/crates/benilla-ui/src/script/pointer.rs @@ -90,6 +90,33 @@ impl UiScript { .is_some_and(|h| is_world_handle(&model, *h)) } + /// The hyperlink span containing `(x, y)` on the frame `id`, as `(frame, link, markup)`. + fn span_at(&self, id: u32, x: f32, y: f32) -> Option<(FrameHandle, String, String)> { + let model = self.model_ref(); + let fh = model.id_to_frame.get(&id).copied()?; + model + .link_spans + .get(&fh)? + .iter() + .find(|(r, _, _)| point_in_rect(*r, x, y)) + .map(|(_, link, markup)| (fh, link.clone(), markup.clone())) + } + + /// Fire a hyperlink script with `(link, markup)`, recording a handler error. + fn fire_hyperlink(&mut self, id: u32, script: &str, link: &str, markup: &str) { + let (Ok(l), Ok(m)) = (self.lua.create_string(link), self.lua.create_string(markup)) else { + return; + }; + if let Err(e) = event::fire_widget_handler( + &self.lua, + id, + script, + vec![Value::String(l), Value::String(m)], + ) { + self.push_error(e); + } + } + /// 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 { @@ -239,6 +266,25 @@ impl UiScript { self.push_error(e); } } + // Crossing into or out of a hyperlink span fires `OnHyperlinkEnter`/`OnHyperlinkLeave` + // `(link, text)` on the owning frame; a chat frame carries no `OnEnter` of its own but its + // spans do, and the leave must fire when the cursor drops onto bare text in the same frame. + let new_span = new_id.and_then(|id| self.span_at(id, x, y)); + let old_span = std::mem::replace(&mut self.model_mut().hover_link, new_span.clone()); + if old_span != new_span { + if let Some((fh, link, markup)) = old_span { + let id = self.model_ref().frame_to_id.get(&fh).copied(); + if let Some(id) = id { + self.fire_hyperlink(id, "OnHyperlinkLeave", &link, &markup); + } + } + if let Some((fh, link, markup)) = new_span { + let id = self.model_ref().frame_to_id.get(&fh).copied(); + if let Some(id) = id { + self.fire_hyperlink(id, "OnHyperlinkEnter", &link, &markup); + } + } + } new_id }