//! Pointer input dispatch: the hit test, and the mouse move, button and wheel entry points that //! turn host pointer events into FrameScript handler calls. [`cursor`] owns the payload and //! gesture state; this file dispatches. //! //! A frame captures the cursor when it is mouse-enabled and effective-visible and its rect and //! effective ScrollFrame clip hold the point (the reference's probe `0x76b020`), taking the first //! in the order of the hover walk (`0x7661cd` in `0x7660d0`), which [`crate::order::hit_test`] //! implements. use mlua::Value; use crate::layout::Rect; use crate::order; use crate::widget::{ButtonState, FrameHandle}; use super::clip::{effective_clip, scroll_clip_sources}; use super::{button, cursor, editbox, event, Model, UiScript}; /// The double-click interval, 300 ms, a constant in the Button mouse-up dispatcher `0x7792d0` /// (`0x77937b cmp ecx, 0x12c` on the `0x42c010` millisecond clock). It is not the OS double-click /// time (`GetDoubleClickTime` is not imported) and not a CVar, and there is no distance limit: /// each release only has to hit the frame. pub(super) const DOUBLE_CLICK_SECS: f64 = 0.300; /// Install `GetCursorPosition()`, `GetMouseFocus()` and the mouse-look trio. The cursor is in UI /// units, as 1.12 answers it; the engine's y-up UI space is 1:1 logical pixels, so no scale factor /// applies. pub(super) fn install(lua: &mlua::Lua) -> mlua::Result<()> { lua.globals().set( "GetCursorPosition", lua.create_function(|lua, ()| { let model = lua.app_data_ref::().expect("model app_data"); Ok(model.cursor_pos) })?, )?; // `IsMouselooking()` (`0x5147b0`): the camera's own mouse-look flag, `[cam+0x90] & 1`, pushed // each tick as [`super::SelfMovement::mouselooking`] off `CameraControl::mouselooking`. A // right-held turn or a both-button run; a left-button orbit is **not** mouse-look, and // conflating the two would have an orbit read as a turn. lua.globals().set( "IsMouselooking", lua.create_function(|lua, ()| { let model = lua.app_data_ref::().expect("model app_data"); Ok(super::binding_abi::flag(model.self_movement.mouselooking)) })?, )?; // `MouselookStart()`/`MouselookStop()` (`0x514780`/`0x5147a0`) — installed, and **they do not // start or stop a look session**. That is a behavioural deviation, so it is stated here rather // than left for a reader to discover: // // benilla's mouse-look is not a mode the engine can be put into from outside. It is latched // per frame from the buttons *the world owns* (`player/camera.rs`'s `latch_world_mouse` then // `run_look_session`): the session begins on a world press and ends when that press is // released, and a press captured by a frame is dropped before the latch ever sees it. A Lua // `MouselookStart()` has no press behind it and therefore no release edge either, so an // implementation that forced the flag on would leave the camera turning with no way to stop — // worse than doing nothing, and not what the reference does, where the call joins a session // the hardwired `BUTTON2` binding already owns and that binding's release ends. // // `bindings/commands.rs` records the same fact from the other side: `TURNORACTION` is declared // absent there because "right-button mouse-look is hardwired, not routed through a binding". // // The one corpus caller is pfUI's nameplate right-click emulator // (`modules/nameplates.lua:1899-1934`): it calls `MouselookStart()` on a plate's // `OnMouseDown`, then polls `IsMouselooking()` to tell a drag-look from a click, and // synthesises the click once the poll says no look is running. A nil global aborted that // handler *before* it armed the poll, which is why right-clicking a pfUI nameplate did nothing // at all. With these installed the poll runs and reads `false` — which is the truth, because // the press that armed it was captured by the plate frame and so is a press the world never // owned. Making the pair real needs a look session the VM can open and close, which is a // camera-side state machine change, not a binding. for verb in ["MouselookStart", "MouselookStop"] { lua.globals() .set(verb, lua.create_function(|_, ()| Ok(()))?)?; } // `GetMouseFocus()` (`0x48df40`): the hover frame `[root+0x7c]`, a disabled Button included, as // its frame object (not its name), or nil. lua.globals().set( "GetMouseFocus", lua.create_function(|lua, ()| { let id = { let mut model = lua.app_data_mut::().expect("model app_data"); // A frame destroyed since the hover answers nil. model .mouseover .filter(|&h| model.arena.frame(h).is_some()) .map(|h| model.frame_id(h)) }; match id { Some(id) => Ok(Value::Table(super::object::frame_wrapper(lua, id)?)), None => Ok(Value::Nil), } })?, )?; Ok(()) } /// Whether `h` is the world frame, whose mouse hits belong to the 3D world; it wins the hover walk /// only when nothing else mouse-enabled is under the cursor. fn is_world_handle(model: &Model, h: FrameHandle) -> bool { model .arena .frame(h) .is_some_and(|f| f.kind == crate::widget::FrameKind::WorldFrame) } /// 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 { target.is_none_or(|h| is_world_handle(model, h)) } /// Whether `(x, y)` falls inside one of this frame's hyperlink spans, the engine half of the /// reference's per-span `CSimpleHyperlinkButton` children. fn link_span_hit(model: &Model, fh: FrameHandle, x: f32, y: f32) -> bool { model .link_spans .get(&fh) .is_some_and(|spans| spans.iter().any(|(r, _, _)| point_in_rect(*r, x, y))) } impl UiScript { /// Whether a frame id names the world frame: hit-tested like any frame, so scripts on it fire, /// but not counted by the app as over the UI. pub fn is_world_frame(&self, id: u32) -> bool { let model = self.model_ref(); model .id_to_frame .get(&id) .is_some_and(|h| is_world_handle(&model, *h)) } /// 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 { let model = self.model_ref(); let sorted = order::traversal(&model.arena); let scroll_sources = scroll_clip_sources(&model); 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. && !model.nameplates.vetoes(fh) && model.resolved.get(&fh).is_some_and(|r| { point_in_rect(inset_rect(*r, model.arena.hit_rect_insets(fh)), x, y) }) && effective_clip(&model, &scroll_sources, fh) .is_none_or(|c| point_in_rect(c, x, y)) }) } /// [`Self::hit_test`] over the frames that take the wheel: the same rect and clip rules, gated /// on the wheel flag alone. fn hit_test_wheel(&self, x: f32, y: f32) -> Option { let model = self.model_ref(); let sorted = order::traversal(&model.arena); let scroll_sources = scroll_clip_sources(&model); let fh = order::hit_test(&sorted, |fh| { // The wheel flag alone: the wheel index is separate from the mouse's, so the wheel // passes a frame that only takes the mouse, as a scroll pane's chrome does. model.arena.is_mouse_wheel_enabled(fh) && model.resolved.get(&fh).is_some_and(|r| { point_in_rect(inset_rect(*r, model.arena.hit_rect_insets(fh)), x, y) }) && effective_clip(&model, &scroll_sources, fh) .is_none_or(|c| point_in_rect(c, x, y)) })?; model.frame_to_id.get(&fh).copied() } /// [`Self::hit_test_frame`] as a frame id, the id the app and the Lua bindings use. pub fn hit_test(&self, x: f32, y: f32) -> Option { let hit = self.hit_test_frame(x, y)?; self.model_ref().frame_to_id.get(&hit).copied() } /// The name of the frame [`Self::hit_test`] captures at `(x, y)`, for tests and probes. pub fn hit_test_name(&self, x: f32, y: f32) -> Option { let id = self.hit_test(x, y)?; let model = self.model_ref(); let fh = model.id_to_frame.get(&id).copied()?; model.arena.frame(fh)?.name.clone() } /// Move the cursor to `(x, y)`. When the hovered frame changes, fires `OnLeave(self, true)` on /// the old one if still live and `OnEnter(self, true)` on the new; a disabled Button takes the /// hover but fires neither ([`button::hover_notify_runs`]). Drags and frame moves advance /// first. Returns the hovered frame id. 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); #[allow(clippy::type_complexity)] let (leave_id, drag_start, enter_id, slider_change, color_change): ( Option, Option<(u32, String)>, Option, Option<(u32, f32)>, Option<(u32, f64, f64, f64)>, ) = { let mut model = self.model_mut(); model.cursor_pos = (x, y); // Drags advance on every move, before the hover test: a drag within one frame crosses // no hover boundary. let slider_change = super::slider::drag_move(&mut model, x, y); // The colour picker fires on every move, not only on a change: it has no change gate. let color_change = super::colorselect::drag_move(&mut model, x, y); 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()); 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; 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 // walked off a button stays pushed until the drag start (`0x7793f0`) un-presses a // drag-registered one. A disabled Button runs neither script, on either side, and // 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) } }; // A thumb drag fires `OnValueChanged`, outside the borrow. if let Some((id, value)) = slider_change { if let Err(e) = event::fire_widget_handler( &self.lua, id, "OnValueChanged", vec![Value::Number(f64::from(value))], ) { self.push_error(e); } } if let Some((id, r, g, b)) = color_change { if let Err(e) = super::colorselect::fire_by_id(&self.lua, id, r, g, b) { self.push_error(e); } } if let Some((id, button)) = drag_start { let btn = match self.lua.create_string(&button) { Ok(s) => Value::String(s), Err(e) => { self.push_error(e); return new_id; } }; if let Err(e) = event::fire_widget_handler(&self.lua, id, "OnDragStart", vec![btn]) { 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); } } if let Some(nid) = enter_id { if let Err(e) = event::fire_widget_handler(&self.lua, nid, "OnEnter", vec![Value::Boolean(true)]) { self.push_error(e); } } self.update_hyperlink_hover(new_id, x, y); new_id } /// `OnHyperlinkEnter(link, markup)` / `OnHyperlinkLeave(link, markup)` on the frame owning the /// span under the cursor. /// /// Not 1.12's: that client routes a hyperlink only on release (`OnHyperlinkClick`, fired by /// the press path above) and has no hover edge at all. TurtleWoW's client added these, and the /// addons built against it expect them — `ChatSuey-ItemLinkHover` is nothing but these two /// handlers, and registering one used to raise outright. /// /// The spans are the ones already rasterized for the click path ([`Model::link_spans`]), so a /// hover and a click can never disagree about where a link is. fn update_hyperlink_hover(&mut self, hit_id: Option, x: f32, y: f32) { let now = hit_id.and_then(|id| { let model = self.model_ref(); model .id_to_frame .get(&id) .and_then(|fh| model.link_spans.get(fh)) .and_then(|spans| { spans .iter() .find(|(r, _, _)| point_in_rect(*r, x, y)) .map(|(_, l, m)| (id, l.clone(), m.clone())) }) }); let was = self.model_ref().hovered_link.clone(); // Same frame and same markup is the same hover, however the line moved under the cursor. if was.as_ref().map(|(i, _, m)| (*i, m)) == now.as_ref().map(|(i, _, m)| (*i, m)) { return; } self.model_mut().hovered_link = now.clone(); let fire = |this: &mut Self, id: u32, link: &str, markup: &str, script: &str| { let args = match (this.lua.create_string(link), this.lua.create_string(markup)) { (Ok(l), Ok(m)) => vec![Value::String(l), Value::String(m)], _ => return, }; if let Err(e) = event::fire_widget_handler(&this.lua, id, script, args) { this.push_error(e); } }; if let Some((id, link, markup)) = was { fire(self, id, &link, &markup, "OnHyperlinkLeave"); } if let Some((id, link, markup)) = now { fire(self, id, &link, &markup, "OnHyperlinkEnter"); } } /// Whether `(x, y)` is inside the frame's title region. A new title region has no anchors and /// hits nothing until `SetPoint`/`SetAllPoints` (`CreateTitleRegion 0x773910`). fn title_region_hit(&self, h: crate::widget::FrameHandle, x: f32, y: f32) -> bool { let model = self.model_ref(); model .arena .frame(h) .and_then(|f| f.title_region) .and_then(|rh| model.region_resolved.get(&rh)) .is_some_and(|r| point_in_rect(*r, x, y)) } /// A mouse button press or release at `(x, y)`; `button` is the WoW name (`"LeftButton"`, …). /// `OnClick` follows `RegisterForClicks` ([`button::wants_click`], default `{"LeftButtonUp"}`): /// a press fires it for `"