//! Pointer input dispatch: hit-testing and the mouse-move/-button/-wheel entry points that turn //! host pointer events into FrameScript handler calls (decision 0068). The pipeline is //! **hit-test → hover → click/drag**: //! //! - [`UiScript::hit_test`] answers "what's under `(x, y)`" — the topmost-drawn, mouse-enabled, //! effective-visible frame whose resolved rect (and effective ScrollFrame clip, decision 0112 //! §5) contains the point. Pure query; fires nothing, mutates nothing. //! - [`UiScript::mouse_move`] hit-tests every move and, on a captured-frame **change**, fires the //! `OnLeave`/`OnEnter` pair (RF-0025's `motion=true` hover boundary) — and, before that, //! advances an armed drag gesture, an in-flight **Slider thumb drag**, and an in-flight //! **frame move** (`StartMoving`, [`super::object`]'s `movable` cluster). All three move within //! one frame, crossing no hover boundary, so they must run before the boundary early-return. //! - [`UiScript::mouse_button`] resolves `OnMouseDown`/`OnMouseUp`, the `OnClick` //! press/release-registration rules ([`button::wants_click`]), the **drag trio** + **world //! drop** below, the **Slider thumb-drag** capture (a left press on a thumb, decision 0250 §5), //! and EditBox click-to-focus — all keyed off the same hit-test. The **double-click detector** //! ([`DOUBLE_CLICK_SECS`]) rides the release edge here too: a click on a frame whose previous //! click landed within 300 ms fires `OnDoubleClick` **instead of** that second `OnClick`. //! - [`UiScript::mouse_wheel`] fires `OnMouseWheel`, bubbling to the nearest ancestor with a //! handler when the hit frame has none (a ScrollFrame under a non-scrolling child). //! //! ## The drag trio + world drop (decisions 0216 §3, corrected by 0218) //! //! A press on a [`super::Model::drag_registered`] frame **arms** a gesture ([`cursor::arm_drag`]); once //! the cursor crosses the drag-start threshold it **starts** (firing `OnDragStart`) — that //! threshold check is [`cursor::maybe_start_drag`], run from `mouse_move` on every move. A //! release **resolves** the gesture ([`cursor::take_drag`]): a STARTED gesture fires //! `OnDragStop` on the source and `OnReceiveDrag` on whatever the release hit (a no-op if that //! frame carries no such script), and SUPPRESSES `OnClick` for that release — an un-started //! gesture leaves the ordinary click path untouched. The **world drop** //! ([`cursor::world_drop_click`]) fires only on a completed left CLICK over the game world: //! press AND release both landed over no frame, no drag started (the byte-verified trigger — the //! client's `0x495300` runs on the WorldFrame click release; a drag release routes as a drag, //! never a click), routed by the app-fed pick ([`super::Model::world_pick`], decisions 0571 + //! 0574 — an object pick keeps everything, terrain drops items only, nothing drops any arm). //! //! ## Division of labor with [`cursor`] //! //! [`cursor`] owns the payload/gesture **STATE**: [`super::Model::cursor`] (what the cursor //! carries), [`super::Model::drag`] (the in-flight gesture), and the pure functions that //! arm/advance/resolve them. This file owns the input **DISPATCH** — turning raw //! `(x, y, button, down)` host events into a hit-test plus the right sequence of Lua handler //! calls, calling into `cursor`'s state machine at the right points rather than touching //! [`super::Model::drag`]/[`super::Model::cursor`] directly. use mlua::Value; use crate::layout::Rect; use crate::order; use crate::widget::FrameHandle; use super::clip::{effective_clip, scroll_clip_sources}; use super::{button, cursor, editbox, event, Model, UiScript}; /// The double-click interval — **300 ms, VERIFIED off the bytes**, a hardcoded instruction /// immediate: `0x77937b 81 f9 2c 01 00 00 cmp ecx, 0x12c`, comparing `now − [this+0x334]` inside /// the Button mouse-UP dispatcher `0x7792d0` (wow-re `ui/scratch/button-doubleclick-law.md`, a §5 /// cross-check dispatched from here). /// /// The units are verified too, not assumed: `now` comes from `0x42c010` → `0x42b790`, whose /// scale is stored as `1.0/freq × 1000.0` and whose counter is `KERNEL32!GetTickCount` (import /// `[0x7ff310]`) — milliseconds either way. /// /// **What it is NOT**, because all three were live hypotheses and each is closed at the bytes: /// it is not the OS setting (`CS_DBLCLKS` is never requested — all four `RegisterClassExA` sites /// pass `style = 0x20` = `CS_OWNDC` alone — and `GetDoubleClickTime` is not imported), it is not a /// CVar (no such `.rdata` string; nothing near the compare reaches `CVar::Register 0x63db90`), and /// there is **no position gate** (the `0x766122`–`0x766140` "double-click detector" this was first /// chased through is really the zero-motion cursor-move coalescing filter — that gloss in /// `world-click-drag-arbitration.md` l.238 is refuted and corrected). The only spatial constraint /// is that each release hit-tests inside the frame, applied independently; the two clicks may land /// arbitrarily far apart. /// /// So the interval does not follow the host OS double-click speed and the player cannot change it. pub(super) const DOUBLE_CLICK_SECS: f64 = 0.300; // ── Input / hit-testing (decision 0068; spec-faithful, not byte-pinned) ───────────────────── // // The mouse-focus model (`propagation.md`): focus walks frames top-down in reverse draw order; // the first mouse-enabled, effective-visible frame whose resolved rect contains the cursor // captures. This is faithful to the *documented model*, **not** bit-exact — the real hit-test // leaf is a flagged RE-time item, not byte-pinned (`anchors.md`; see [`crate::order::hit_test`]). // Coordinates are WoW UI space (y-up, pixels) — the same space [`resolve`]/[`extract`] use. // Call [`resolve`] first so rects are populated; a frame with no resolved rect never captures. /// Install the pointer-adjacent Lua globals: `GetCursorPosition()` → the model's live cursor in /// the engine's y-up UI space (fed by the host's mouse_move/mouse_button) — what the ref's /// `MouseIsOver`/FCF hover logic reads (1.12 returns UI-scaled coords; benilla's UI space is /// 1:1 logical px, 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) })?, )?; // `GetMouseFocus()` — the frame the mouse is over, or nil. // // **The corpus's most-wanted engine verb**: 78 of 218 addons call it (decision 1195). Every // tooltip scanner, every "what am I hovering" helper and every mouseover-macro library is this // one call. It is a pure read of the hover state [`UiScript::mouse_move`] already maintains — // `Model::mouseover`, the frame that owns the `OnEnter`/`OnLeave` boundary — so the answer is // exactly the frame whose `OnEnter` last fired, which is what an addon means by "focus". // // Returns the frame's **wrapper table**, not its name: the reference returns a frame object and // callers immediately write `GetMouseFocus():GetName()`. lua.globals().set( "GetMouseFocus", lua.create_function(|lua, ()| { let id = { let mut model = lua.app_data_mut::().expect("model app_data"); // Re-check liveness: a frame can be destroyed between the hover and this call, and // handing back a wrapper for a dead handle is worse than answering 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 `(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 { /// Hit-test the cursor at `(x, y)` and return the **handle** of the captured frame (the /// topmost-drawn mouse-enabled, effective-visible frame whose rect contains the point **and** /// whose effective ScrollFrame clip, if any, also contains it — decision 0112 §5: a button /// scrolled out of its ScrollFrame's rect must not hit), or `None`. Pure query — fires nothing, /// mutates nothing. Call [`Self::resolve`] first; a frame with no resolved rect never captures. /// /// **The handle form exists because a name is not always available and an id is not always /// comparable.** [`Self::hit_test`] answers in ids (what the app and the Lua bindings speak) and /// [`Self::hit_test_name`] answers in names (what a human reads). An instrument that has to ask /// *whose* frame ate a click needs neither: the frame may be **anonymous**, and the only set it /// can be checked against is a handle set snapshotted earlier ([`Self::live_targets`]'s). The /// addon harness's use probe asks exactly that — a pointer event is only an addon's to be /// judged by when the frame under the cursor is one the addon itself created. /// Whether a frame id names a [`FrameKind::WorldFrame`] — the reference's world frame, whose /// mouse hits belong to the 3D world (decisions 1983/1984). The hit test still answers it (it /// is mouse-enabled by construction, so an addon's `OnEnter`/`OnMouseDown` on it fire — the /// reference runs those, then the click's `BUTTON1`/`BUTTON2` binding, which IS the world /// click), but the app's pointer arbiter does not count it as being over the UI. pub fn is_world_frame(&self, id: u32) -> bool { let model = self.model_ref(); model .id_to_frame .get(&id) .and_then(|h| model.arena.frame(*h)) .is_some_and(|f| f.kind == crate::widget::FrameKind::WorldFrame) } 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 inside one of this frame's hyperlink spans**. // // That second half is how the reference makes a chat link clickable on a frame that // takes no mouse at all. `CSimpleMessageScrollFrame`'s ctor leaves `[+0xcc] = 0`, so // `ChatFrame1` is never in the hit-test index — instead the engine synthesises one // mouse-enabled `CSimpleHyperlinkButton` child PER SPAN (`0x7a3240`, born through the // `CSimpleButton` ctor) and routes the three hyperlink scripts back to the parent // (wow-re `mouse-enable-law.md` + the follow-up round `68987021`). // // We hold the spans as rects on the parent rather than as child frames, so the same // law expresses as a disjunct here. What it buys is everything the reference gets from // the split: the chat window is a hit candidate only over a LINK, so `GetMouseFocus()` // answers what is behind it elsewhere, its `movable="true"` + // `` stay inert so it drags by its tab and not its // body, and a hover over plain text reassigns to whatever is underneath. (model.arena.is_mouse_enabled(fh) || link_span_hit(&model, fh, x, y)) && 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 — either bit, since a mouse-enabled /// frame with an `` is a candidate for both. Same rect and clip rules; only the /// enable predicate differs. 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 gates on the handler and CONTINUES** — the one genuine fall-through in // the engine (wow-re `ui/scratch/hittest-no-fallthrough-law.md`). Down/up deliberately // do not use the idiom; the wheel does. So this asks for the WHEEL flag alone and lets // the sweep walk past a frame that merely takes the mouse, which is what puts the wheel // through a scroll pane's chrome and into the pane. // // It used to also accept `is_mouse_enabled` as a stand-in, because the loader did not // arm the wheel kind and no frame ever carried the flag. The loader arms it now. 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() } /// Hit-test the cursor at `(x, y)` and return the **id** of the captured frame, or `None` — /// [`Self::hit_test_frame`] with the id lookup every caller in the input path wants. The id is /// the same one used across this host (e.g. the layout [`crate::layout::Handle`]); the app can /// hand it back or map it as it likes. 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)` — the pointer twin of /// [`super::UiScript::quad_owner_name`] ("who eats a click here?"), for tests and probe /// tooling. `None` when nothing captures the point or the captured frame is anonymous. 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)`: hit-test, and if the captured frame **changed** since the last /// move, fire `OnLeave(self, motion=true)` on the frame being left (if any and still live) and /// `OnEnter(self, motion=true)` on the newly-captured frame (if any). Tracks the current /// mouseover in the model. Also advances an armed [`super::Model::drag`] gesture (decision 0216 §3) /// and an in-flight [`super::Model::moving`] frame ([`super::object`]'s `movable` cluster): /// both run BEFORE the no-boundary-crossed early return below, since dragging within one /// frame crosses no hover boundary at all. Returns the captured frame id (so the app can drive /// `PointerOverUi`). Handler errors are collected into [`UiScript::errors`], never panicking. pub fn mouse_move(&mut self, x: f32, y: f32) -> Option { // A drag-active EditBox tracks every move (`0x77a860`): extend the selection to the // hovered char, cursor following — before any hover-boundary early return. editbox::drag_update(&self.lua, x, y); let new_id = self.hit_test(x, y); #[allow(clippy::type_complexity)] let (old_id, drag_start, boundary_crossed, slider_change, color_change): ( Option, Option<(u32, String)>, bool, Option<(u32, f32)>, Option<(u32, f64, f64, f64)>, ) = { let mut model = self.model_mut(); model.cursor_pos = (x, y); // Advance an in-flight Slider thumb drag first — dragging within one frame crosses no // hover boundary, so this must run before the early return below (decision 0250 §5). let slider_change = super::slider::drag_move(&mut model, x, y); // The colour picker's wheel/strip drag, for the identical reason — and it fires on // every move, not only on a change (the widget has no change-gate). let color_change = super::colorselect::drag_move(&mut model, x, y); // A frame in flight from `StartMoving()` follows the cursor here for the same reason // (`object::movable`): the frame moves *under* the cursor, so the hover boundary never // crosses and a pump behind the early return would only ever advance when the cursor // left the frame it is dragging. 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 { // no boundary crossed (None, drag_start, false, slider_change, color_change) } else { // Fire OnLeave only if the frame we're leaving is still live. 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; // The client's `0x7793f0` (leave) and `0x7791ed` (enter) both call `SetState`: // a press held over a button and then walked off it drops back to NORMAL, and // walking back on picks PUSHED up again. Ours latches the same transition from // the moved hover — both sides of the boundary, since each one's `hovered` // changed. for h in [old_handle, new_handle].into_iter().flatten() { button::settle(&mut model, h); } (old_id, drag_start, true, slider_change, color_change) } }; // The thumb-drag value change fires OnValueChanged (outside the borrow) — the scrollbar's // OnValueChanged→SetVerticalScroll wiring runs here, exactly as the arrow buttons drive it. 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 !boundary_crossed { return new_id; } if let Some(oid) = old_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) = new_id { if let Err(e) = event::fire_widget_handler(&self.lua, nid, "OnEnter", vec![Value::Boolean(true)]) { self.push_error(e); } } new_id } /// Is `(x, y)` inside this frame's title region? /// /// **False whenever the region has no resolved rect**, which is the common case and the correct /// one: a freshly created title region has NO anchors at all and does nothing until /// `SetPoint`/`SetAllPoints` (Q6). Both corpus consumers call `SetAllPoints` immediately, so /// theirs covers the whole window — the whole-window-as-drag-handle idiom. 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 transition at `(x, y)`: `button` is the WoW name (`"LeftButton"`, /// `"RightButton"`, …), `down` is press vs release. Fires `OnMouseDown(self, button)` on press or /// `OnMouseUp(self, button)` on release. **The press fires on the hit frame and the release on /// the frame that took the press** — the client's mouse capture (`root+0x80`); see the dispatch /// itself for the bytes and for the stuck-resize it prevents. /// /// Separately, `OnClick` fires per the hit frame's `RegisterForClicks` set /// ([`button::wants_click`]): a **press** fires immediately when the frame registered /// `"