- the autocast shine is the reference's own Model: `UI-AutoCastButton.mdx` has no batch at all — four bones walking the corners of a 0.02-unit square on a 2 s loop, each with a spline emitter pouring 300 `GlowStar` sparks a second — and the tile renderer plays exactly that off the file's own tracks, so the hand-transcribed lane retires whole: `autocast_shine.rs` (the token texture, the extract's parked shine sites, the append-lane producer and its motion goldens), the extract's shine side channel, and `PetActionBarAdapters.xml`, since the pet button's numbers are the reference's (`setAllPoints` at `scale="1.2"`) and nothing was left to adapt; what stays is the director's two look calls, re-seated onto the reference's own objects — `SpellBookAdapters.xml` puts the stock `$parentAutoCast` Model at 37×37 CENTER (0,0) with `SetModelScale(1.48)` and the overlay at 71.53, the two tests that pin them reading the live panes, and deleting that file is the exact reference (36×36 at CENTER (1,1), 1.22, overlay 60); `assets/ui` is nine files (2014) - a button's shown state texture is latched on the transition, not resolved at paint (B369): `ReputationDetailAtWarCheckBox` has a `<NormalTexture>` and no `<DisabledTexture>`, and once `ReputationFrame_Update` `Disable()`d it ours drew nothing — `ButtonState` resolved the shown texture as a pure function of (enabled, pushed, held+hovered); the client's `SetState` `0x779790` gates both its hide-old and show-new steps on the NEW state's slot being non-null, with no Normal fallback anywhere in the function, and the ctor `0x7786a0` ends in `SetState(NORMAL)`, so every button reaches its first `Disable()` (`0x77ffd0`) already wearing its normal art and keeps it; `ButtonState` now carries the client's two words, `shown` (`+0x4c4`) and `state` (`+0x328`), written only by `set_state` and the `0x778fd0` setter family's conditional push, with transitions applied where the client applies them — `Enable`/`Disable`/`SetButtonState` and the four mouse edges — and the old `pushed.or(normal)` special case was this rule seen from one side; the stock corpus has 70 such declarations, so empty spellbook slots wear their `UI-Quickslot2` ring again, which 0227 had removed on a premise the shipped Lua falsifies in one line (`SpellButton_UpdateButton` calls `Disable()` at runtime); and `Disable()` switches off the whole HIGHLIGHT draw layer — `0x779160` follows `SetState` with `push 4; call 0x76a730`, the per-layer enable on the same `[frame+0x198]` array `Enable/DisableDrawLayer` writes — so a `<Layer level="HIGHLIGHT">` region no longer draws over a disabled button and the `enabled &&` term on the HighlightTexture alone is gone; the reputation tests' header stops claiming the page and the watch bar were ours (2011) - the world's mouse buttons are latched, not read raw (B364): right-clicking a Who-list row swung the camera round to behind the character — ~180° over nine seconds on a body that never turned — because the click was the UI's but the camera's input command word (`[InputControl+0x4]`) built its two mouse bits straight off `ButtonInput` with no pointer-over-UI term, `RIGHT_MOUSE` alone classifies as `Turn`, and Smart's Turn row is `(0.0, 1.0)`, so the return armed on the press edge and ran to completion; 1.12 never reads the mouse for any of this — it reads two bindings, `TurnOrAction` and `CameraOrSelectOrMove`, and a press a UI frame captured dispatches neither — so `camera::WorldMouse` latches each primary at the press and holds it to that button's own release, written in its own system ordered ahead of every reader (latched rather than re-tested, because a level test would hand a button back to the UI mid-drag the moment a frame appeared under the locked cursor, and an edge is what arms a return); five readers move onto it — the follow word's mouse bits, the both-button run, `/follow`'s both-button cancel, the look session's engage, chord cancel and other-primary tests, and the both-button → single-button handoff — and `control` drops its `PointerOverUi` and `&Camera`, the viewport-vs-UI question no longer being its to ask - the clutter onset is the cutout, and it gets the reference's own dial: the ramp runs 52.64 → 69.86 yd but the draw alpha-tests `texel.a × ramp` against `detailDoodadAlpha` = 128/255 with the ramp capped at 252/255, so nothing survives past 61.11 yd — grass appears there already at 50.2 % opacity with 8.5 yd left to reach full, and the last third of the ramp is spent where nothing is drawn, which is exactly the "started before it became visible from another gate" the director saw; `detailDoodadAlpha` is a console command in the reference (`0x6739a0`, registered through the command table `0x63f9e0`, not `CVar::Register` `0x63db90`, so it never persists), and we had it only as the boot-time `WOW_CLUTTER_ALPHA`, which cannot be A/B'd against a running client — `/console detailDoodadAlpha <0-255>` now sets it live, bare reports, and out of range is rejected rather than clamped (`0x6739b9: cmp eax,0xff; jbe`), classified in the console drain of 2008's `ConsoleExec` seam so it never reaches the CVar store, the bubble re-cut on a change because the ref is baked into the material; at 128 grass appears at 61.11 yd at half opacity, at 8 the onset walks out to ~69.8 yd and fades in from nothing; a late-build tripwire in the streamer — a warning whenever a chunk is built whose box is already inside the fade horizon, naming whether the distance gate or the per-frame cap was the cause — fired at once on the second: the build budget was spent in query order, so a chunk 90 yd out where the ramp is already zero took a slot ahead of one at 48 yd in plain view; candidates are sorted by distance now, and a 30 s Elwynn run reports one late-build frame, the cold first, then silence (2012) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
888 lines
50 KiB
Rust
888 lines
50 KiB
Rust
//! 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::<super::Model>().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::<super::Model>().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<FrameHandle> {
|
||
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"` +
|
||
// `<TitleRegion setAllPoints="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 `<OnMouseWheel>` 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<u32> {
|
||
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<u32> {
|
||
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<String> {
|
||
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<u32> {
|
||
// 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<u32>,
|
||
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
|
||
/// `"<Button>ButtonDown"` (`OnClick(self, button, down=true)`); a **release** whose matching
|
||
/// **press** captured the *same* frame fires when the frame registered `"<Button>ButtonUp"`
|
||
/// (`OnClick(self, button, down=false)` — the Era signature,
|
||
/// `warcraft.wiki.gg/wiki/UIHANDLER_OnClick`). The default set is `{"LeftButtonUp"}`, so
|
||
/// unregistered buttons (a bare right-click, say) reach neither. Press targets are tracked per
|
||
/// button.
|
||
///
|
||
/// The drag trio + world drop (decision 0216 §3, corrected by 0218) ride the same
|
||
/// transition: a press ARMS a [`super::Model::drag`] gesture on a [`super::Model::drag_registered`] frame
|
||
/// ([`cursor::arm_drag`]); a release RESOLVES it ([`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 this 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 world —
|
||
/// press AND release both over no frame, no started drag (the byte-verified trigger: the
|
||
/// client's `0x495300` runs on the WorldFrame click release; a drag release routes as a
|
||
/// drag, never a click — wow-re cursor-dragdrop-payload.md, and the director's report it
|
||
/// confirmed) — routed by the app-fed pick (decisions 0571 + 0574): over an object nothing
|
||
/// drops (the reference keeps an item payload and selects); over terrain an item fires
|
||
/// `DELETE_ITEM_CONFIRM` and stays held while a spell/action survives; over nothing the
|
||
/// item pops and any other payload clears silently. A drag released over nothing keeps its
|
||
/// payload untouched.
|
||
///
|
||
/// Handler errors are collected into [`UiScript::errors`]. Returns whether the UI consumed
|
||
/// the event (`hit_id.is_some()` or a world drop was handled) — a new public return; existing
|
||
/// statement-position calls (`script.mouse_button(...);`) are unaffected.
|
||
pub fn mouse_button(&mut self, x: f32, y: f32, button: &str, down: bool) -> bool {
|
||
let hit_id = self.hit_test(x, y);
|
||
// ── The RAISE, and it happens FIRST ─────────────────────────────────────────────────────
|
||
//
|
||
// wow-re ledger `0x7662c0` (VERIFIED, the `toplevel-raise.md` §5 fan-out): the
|
||
// mouse-button-DOWN handler "resolves target = root+0x80 (existing capture) else
|
||
// root+0x7c (hover frame), calls `0x76a5b0` Raise() **UNGUARDED** @`0x766392` (the
|
||
// toplevel gate is inside `0x7650f0`, so the raised frame may be an ancestor of the
|
||
// clicked one), then title-region drag or capture+OnMouseDown".
|
||
//
|
||
// Four clauses, each load-bearing and each visible:
|
||
//
|
||
// · **DOWN only.** The sibling category-`0xe` handler `0x766420` never raises — a window
|
||
// comes forward on the press, not on the release.
|
||
// · **Before the title-region swallow below**, so grabbing a window by its title bar
|
||
// raises it too, even though that path dispatches no `OnMouseDown` at all.
|
||
// · **Unguarded** — no toplevel test out here. [`toplevel::raise`] walks to the nearest
|
||
// toplevel self-or-ancestor and does nothing when there is none, which is exactly what
|
||
// makes a click on a *child* (a button, a tab, the window's own background texture)
|
||
// bring the whole window forward.
|
||
// · **Capture else hover.** [`Model::mouse_capture`] is `root+0x80`; while a button is
|
||
// held it wins, so a chorded press over a different window raises the one being
|
||
// dragged, not the one under the cursor.
|
||
//
|
||
// Nothing here is gated on the frame being mouse-enabled: the "hover frame" is the
|
||
// hit-test result, which already applies that gate.
|
||
if down {
|
||
let target = self.model_ref().mouse_capture;
|
||
if let Some(t) = target.or_else(|| self.hit_test_frame(x, y)) {
|
||
let mut model = self.model_mut();
|
||
super::object::toplevel::raise(&mut model, t);
|
||
}
|
||
}
|
||
// ── The title region, and it SWALLOWS the press ─────────────────────────────────────────
|
||
//
|
||
// A mouse-down inside `frame:GetTitleRegion()` starts a mode-2 move and never reaches the
|
||
// ordinary path: the reference's `0x7662c0` tests `titleRegion+0x24` point-in-rect FIRST
|
||
// and only a MISS falls through to `0x7663e6`, which sets the captured frame and dispatches
|
||
// `vtable[0x68]` — the OnMouseDown script. So a hit takes `OnMouseDown`/`OnMouseUp` and
|
||
// `RegisterForDrag`'s `OnDragStart` with it (wow-re `widget-api-batch-benilla.md` Q6).
|
||
//
|
||
// Returning early is what implements that swallow: no OnMouseDown, no drag arm, no OnClick,
|
||
// no double-click bookkeeping. It reports the event CONSUMED, because it was.
|
||
if down {
|
||
if let Some(h) = self.hit_test_frame(x, y) {
|
||
if self.title_region_hit(h, x, y) {
|
||
self.model_mut().cursor_pos = (x, y);
|
||
crate::script::object::movable::start_title_move(&mut self.model_mut(), h);
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
// ── …and the release ends it, where a scripted move would survive ───────────────────────
|
||
//
|
||
// `0x766420` auto-cancels modes 1 (modifier-drag) and 2 (title region) and leaves mode 3
|
||
// (`StartMoving`) alone — so a window grabbed by its title bar stops when the button comes
|
||
// up, while `OnDragStart → StartMoving` keeps moving until the addon's
|
||
// `StopMovingOrSizing`. Reduced to [`FrameMove::auto_stop`], because that one bit is all
|
||
// of the mode that is observable from Lua.
|
||
if !down
|
||
&& self
|
||
.model_ref()
|
||
.moving
|
||
.as_ref()
|
||
.is_some_and(|m| m.auto_stop)
|
||
{
|
||
self.model_mut().moving = None;
|
||
}
|
||
// The capture this release belongs to — read HERE because the borrow below *drains*
|
||
// `mouse_down_on` for this button. See the `OnMouseUp` dispatch further down for why the
|
||
// press target rather than the hit frame.
|
||
let captured_id: Option<u32> = if down {
|
||
None
|
||
} else {
|
||
let model = self.model_ref();
|
||
model
|
||
.mouse_down_on
|
||
.get(button)
|
||
.and_then(|h| model.frame_to_id.get(h).copied())
|
||
};
|
||
// The session clock, read before the borrow below — the double-click detector's only input
|
||
// beyond the hit ([`Model::last_click`]). Same `GetTime()` seconds every script sees.
|
||
let now = self.now();
|
||
self.model_mut().cursor_pos = (x, y);
|
||
let btn = match self.lua.create_string(button) {
|
||
Ok(s) => Value::String(s),
|
||
Err(e) => {
|
||
self.push_error(e);
|
||
return hit_id.is_some();
|
||
}
|
||
};
|
||
// Resolve the OnClick target (if any), arm/resolve the drag gesture, and handle a world
|
||
// drop — all under one short borrow. A press fires OnClick when the hit frame registered
|
||
// "<Button>ButtonDown"; a release fires it when press+release landed on the same frame AND
|
||
// it registered "<Button>ButtonUp" — UNLESS a started drag is being resolved instead.
|
||
#[allow(clippy::type_complexity)]
|
||
let (
|
||
click_id,
|
||
drag_release,
|
||
world_dropped,
|
||
slider_jump,
|
||
double_id,
|
||
abandoned,
|
||
color_jump,
|
||
): (
|
||
Option<u32>,
|
||
Option<cursor::DragRelease>,
|
||
bool,
|
||
Option<(u32, f32)>,
|
||
Option<u32>,
|
||
Option<crate::widget::FrameHandle>,
|
||
Option<(u32, f64, f64, f64)>,
|
||
) = {
|
||
let mut model = self.model_mut();
|
||
let hit_handle = hit_id.and_then(|id| model.id_to_frame.get(&id).copied());
|
||
if down {
|
||
let displaced = match hit_handle {
|
||
Some(h) => model.mouse_down_on.insert(button.to_string(), h),
|
||
None => model.mouse_down_on.remove(button),
|
||
};
|
||
// `0x7792ad`: the press SetState(PUSHED), unconditional past the registration
|
||
// and hit gates. The displaced entry is settled too — a second press of the same
|
||
// button elsewhere releases whatever it was holding.
|
||
for h in [displaced, hit_handle].into_iter().flatten() {
|
||
button::settle(&mut model, h);
|
||
}
|
||
// `0x7663e6` writes the resolved target into `root+0x80` — capture-else-hover, so
|
||
// this is an `or`, not an assignment: an existing capture is not displaced by a
|
||
// second button pressed elsewhere. (This is the point the reference reaches only
|
||
// on a title-region MISS, which is why the swallow above returns before it.)
|
||
model.mouse_capture = model.mouse_capture.or(hit_handle);
|
||
// A press REPLACES any in-flight gesture, so a started one has to be ended first
|
||
// — `abandon_drag`'s own doc carries why a silent drop is a stuck UI rather than a
|
||
// cancelled drag. Its `OnDragStop` fires below, outside this borrow.
|
||
let abandoned = cursor::abandon_drag(&mut model);
|
||
cursor::arm_drag(&mut model, hit_handle, button, (x, y));
|
||
// A left press on a Slider begins the engine drag (0250 §5): a thumb press grabs
|
||
// it in place; a track press seats the thumb under the cursor first (the returned
|
||
// value jump — 0989), independent of the click/drag-trio path above (a Slider
|
||
// isn't drag_registered).
|
||
let jump = if button == "LeftButton" {
|
||
super::slider::begin_drag(&mut model, hit_handle, x, y)
|
||
} else {
|
||
None
|
||
};
|
||
// A left press inside a ColorSelect's wheel or value strip captures the colour
|
||
// drag AND jumps the colour to the click point in the same call — the widget's
|
||
// press handler invokes its own cursor-position handler before returning
|
||
// (`0x78bf70 call [eax+0x3c]`), so there is no such thing as a press that only
|
||
// arms.
|
||
let color_jump = if button == "LeftButton" {
|
||
super::colorselect::begin_drag(&mut model, hit_handle, x, y)
|
||
} else {
|
||
None
|
||
};
|
||
let wants = format!("{button}Down");
|
||
let click = hit_handle
|
||
.filter(|&h| button::wants_click(&model, h, &wants))
|
||
.and(hit_id);
|
||
(click, None, false, jump, None, abandoned, color_jump)
|
||
} else {
|
||
let pressed = model.mouse_down_on.remove(button);
|
||
// `0x7793c2`: the release SetState(NORMAL) on the frame the press captured.
|
||
if let Some(h) = pressed {
|
||
button::settle(&mut model, h);
|
||
}
|
||
// `root+0x80` is cleared at `0x7664bb` **only when the post-event button mask is
|
||
// zero** — a chorded release keeps the capture for the button still held. With the
|
||
// per-button map already drained above, "mask is zero" is "the map is empty".
|
||
if model.mouse_down_on.is_empty() {
|
||
model.mouse_capture = None;
|
||
}
|
||
// A left release ends any in-flight thumb drag (decision 0250 §5).
|
||
if button == "LeftButton" {
|
||
super::slider::end_drag(&mut model);
|
||
super::colorselect::end_drag(&mut model);
|
||
}
|
||
let same_frame = matches!(
|
||
(pressed, hit_handle),
|
||
(Some(p), Some(released)) if p == released
|
||
);
|
||
let release = cursor::take_drag(&mut model, button);
|
||
let started = release.as_ref().is_some_and(|r| r.started);
|
||
// The world drop: press AND release both over no frame, no drag started —
|
||
// a completed left click on the game world (never a drag release, which just
|
||
// keeps carrying; decision 0218's byte-verified trigger). What actually drops
|
||
// is routed inside `world_drop_click` by the app-fed world pick (decisions
|
||
// 0571 + 0574): an object pick keeps everything, terrain drops items only,
|
||
// nothing drops any arm.
|
||
let dropped = button == "LeftButton"
|
||
&& !started
|
||
&& hit_id.is_none()
|
||
&& pressed.is_none()
|
||
&& cursor::world_drop_click(&mut model);
|
||
let wants = format!("{button}Up");
|
||
let click = if started {
|
||
None
|
||
} else {
|
||
hit_handle
|
||
.filter(|&h| same_frame && button::wants_click(&model, h, &wants))
|
||
.and(hit_id)
|
||
};
|
||
// ── The double-click adjudication — on the RELEASE edge, on the click that just
|
||
// qualified, and STRICTLY EXCLUSIVE with it.
|
||
//
|
||
// All three of those are byte-verified (wow-re `ui/scratch/button-doubleclick-law.md`):
|
||
// the only site that fires the `+0x4d4` slot is `0x77938d`, inside the mouse-UP
|
||
// dispatcher `0x7792d0` — the mouse-DOWN dispatcher `0x779210` has no double-click
|
||
// leg at all — and `0x77939d jmp 0x7793b5` skips *past* the single leg's
|
||
// `call [edx+0x94]`, so **`OnDoubleClick` REPLACES the second `OnClick`; it is not
|
||
// additive**. Whatever suppresses the click (a resolved drag, a release on another
|
||
// frame, a button this widget never registered) suppresses this too, by
|
||
// construction: it hangs off `click`.
|
||
//
|
||
// The handler-presence test is load-bearing, not an optimisation. The binary's
|
||
// chain includes `[+0x4d4] != 0`, so a widget with no `OnDoubleClick` script takes
|
||
// the ordinary single leg every time — without that gate, rattling any button in
|
||
// the UI would silently swallow every second `OnClick`.
|
||
let target = click.and(hit_handle);
|
||
let double = target.filter(|&h| {
|
||
model
|
||
.scripts
|
||
.get(&h)
|
||
.is_some_and(|s| s.contains("OnDoubleClick"))
|
||
&& model
|
||
.last_click
|
||
.get(&h)
|
||
.is_some_and(|&t| now - t <= DOUBLE_CLICK_SECS)
|
||
});
|
||
if let Some(h) = double {
|
||
// A completed double ZEROES the stamp (`0x779393`), so clicks PAIR: four fast
|
||
// clicks are Click · DoubleClick · Click · DoubleClick, never one click and
|
||
// three doubles.
|
||
model.last_click.remove(&h);
|
||
} else if let Some(h) = target {
|
||
// A fired single ARMS the detector (`0x7793af`) — on the click *predicate*,
|
||
// not on an `OnClick` handler existing: the binary stamps after calling the
|
||
// firer, which is itself a no-op when no script is bound.
|
||
model.last_click.insert(h, now);
|
||
}
|
||
let double_id = double.and_then(|h| model.frame_to_id.get(&h).copied());
|
||
// Exclusive, not additive — the double leg is taken *instead of* the single one.
|
||
let click = if double_id.is_some() { None } else { click };
|
||
(click, release, dropped, None, double_id, None, None)
|
||
}
|
||
};
|
||
// A track press's value jump fires OnValueChanged first (outside the borrow), the same
|
||
// seam as `mouse_move`'s in-drag changes — the press IS the first move of the gesture.
|
||
if let Some((id, value)) = slider_jump {
|
||
if let Err(e) = event::fire_widget_handler(
|
||
&self.lua,
|
||
id,
|
||
"OnValueChanged",
|
||
vec![Value::Number(f64::from(value))],
|
||
) {
|
||
self.push_error(e);
|
||
}
|
||
}
|
||
// The press's colour jump fires OnColorSelect the same way.
|
||
if let Some((id, r, g, b)) = color_jump {
|
||
if let Err(e) = super::colorselect::fire_by_id(&self.lua, id, r, g, b) {
|
||
self.push_error(e);
|
||
}
|
||
}
|
||
// ── `OnMouseUp` goes to the frame that took the PRESS, not to whatever is under the
|
||
// cursor now — the mouse CAPTURE (`root+0x80`).
|
||
//
|
||
// The client's mouse-DOWN handler `0x7662c0` takes the pointer: "resolves target =
|
||
// root+0x80 (existing capture) else root+0x7c (hover frame) … then title-region drag or
|
||
// **capture**+OnMouseDown" (wow-re ledger `0x7662c0`).
|
||
//
|
||
// The **release** half is `0x766420`, and it is VERIFIED — wow-re
|
||
// `system/ui/scratch/mouseup-dispatch-law.md`, a §5 dispatched from this repo for exactly
|
||
// this line after it first landed on an extrapolation (decision 1599). It reads the capture
|
||
// and **nothing else**: `[mgr+0x80]` is snapshotted into `ebx` at entry (`0x76642b`),
|
||
// before anything, and `[mgr+0x7c]` — the hover frame — is **never read** anywhere in
|
||
// `[0x766420, 0x7664f0)`. One dispatch, `0x7664a4 call [eax+0x6c]`.
|
||
//
|
||
// So a press on A released over B fires `OnMouseUp` on **A**, and B gets nothing on the
|
||
// release edge. We already track exactly that capture — [`Model::mouse_down_on`], the press
|
||
// target per button, which `OnClick`'s same-frame rule reads — and firing `OnMouseUp` at
|
||
// the *hit* frame was the one place it went unused. This function's doc always said "on the
|
||
// captured frame"; the code did not.
|
||
//
|
||
// It is not a nicety: a chat window's resize grip is a 16×16 button that the drag pulls
|
||
// out from under the cursor the moment the size clamps at `SetMinResize`/`SetMaxResize`
|
||
// (the rebate plants the edge on the bound while the cursor keeps going). Its
|
||
// `OnMouseUp → FCF_StopResize` then never ran, and `Model::sizing` stayed held for the
|
||
// rest of the session — the stuck-drag failure `cursor::abandon_drag` exists to prevent on
|
||
// the other path.
|
||
//
|
||
// **A press that captured NOTHING fires nothing**, and this is the half the first version
|
||
// of this code got wrong: it fell back to the frame under the cursor, reading the DOWN
|
||
// handler's `+0x80`-else-`+0x7c` precedence as if the UP handler shared it. It does not —
|
||
// `0x766498 test ebx,ebx / je 0x7664cd` returns with no virtual call at all. The fallback
|
||
// was a divergence that let a press over open space deliver a release to whatever the
|
||
// cursor had wandered onto.
|
||
//
|
||
// (Two things the same §5 settled that are invisible from here: the capture is cleared at
|
||
// `0x7664bb`, **after** the dispatch — so a C++ consumer inside `OnMouseUp` still sees it,
|
||
// though no Lua binding reads it, `GetMouseFocus 0x48df40` being `+0x7c`; and the clear is
|
||
// gated on `evt+0x18 == 0`, the button mask *after* the event, so a **chorded** release
|
||
// fires `OnMouseUp` and keeps the capture for the button still held.)
|
||
let script = if down { "OnMouseDown" } else { "OnMouseUp" };
|
||
if let Some(id) = if down { hit_id } else { captured_id } {
|
||
if let Err(e) = event::fire_widget_handler(&self.lua, id, script, vec![btn.clone()]) {
|
||
self.push_error(e);
|
||
}
|
||
}
|
||
// A release inside a hyperlink span fires `OnHyperlinkClick(link, markup, button)` on the
|
||
// owning frame — the ChatFrameTemplate wires it to `SetItemRef` (decision 0288 P2). Never
|
||
// on a drag resolution (same suppression as OnClick); spans were fed by the app after the
|
||
// last rasterize (`UiScript::set_link_spans`), so they match what's on screen.
|
||
if !down && !drag_release.as_ref().is_some_and(|r| r.started) {
|
||
if let Some(id) = hit_id {
|
||
let span = {
|
||
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)| (l.clone(), m.clone()))
|
||
})
|
||
};
|
||
if let Some((link, markup)) = span {
|
||
let args = match (
|
||
self.lua.create_string(&link),
|
||
self.lua.create_string(&markup),
|
||
) {
|
||
(Ok(l), Ok(m)) => {
|
||
vec![Value::String(l), Value::String(m), btn.clone()]
|
||
}
|
||
_ => Vec::new(),
|
||
};
|
||
if !args.is_empty() {
|
||
if let Err(e) =
|
||
event::fire_widget_handler(&self.lua, id, "OnHyperlinkClick", args)
|
||
{
|
||
self.push_error(e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// The EditBox click law (`0x77b800`): a LeftButton press inside an EditBox places the
|
||
// cursor at the clicked char, starts the drag, and focuses it UNCONDITIONALLY
|
||
// (independent of autoFocus), before any OnClick fires. A release ends any drag.
|
||
if button == "LeftButton" {
|
||
if down {
|
||
if let Some(id) = hit_id {
|
||
editbox::click(&self.lua, id, x, y);
|
||
}
|
||
} else {
|
||
editbox::drag_end(&self.lua);
|
||
}
|
||
}
|
||
// A gesture this press REPLACED gets its `OnDragStop` too, before anything the new press
|
||
// does: the handler on the other end has a `StopMovingOrSizing` in it, and until that runs
|
||
// the engine's one move slot is still held (see `cursor::abandon_drag`).
|
||
if let Some(source) = abandoned {
|
||
self.fire_drag_stop(source);
|
||
}
|
||
// The drag trio's stop/receive pair, fired outside the model borrow above (only for a
|
||
// gesture that actually started — see `cursor::DragRelease::started`).
|
||
if let Some(release) = drag_release.filter(|r| r.started) {
|
||
self.fire_drag_stop(release.source);
|
||
if let Some(id) = hit_id {
|
||
if let Err(e) =
|
||
event::fire_widget_handler(&self.lua, id, "OnReceiveDrag", Vec::new())
|
||
{
|
||
self.push_error(e);
|
||
}
|
||
}
|
||
}
|
||
if let Some(id) = click_id {
|
||
// One click home for the physical and programmatic (`Click()`) paths: gated on a
|
||
// Button's enabled flag, a CheckButton toggles before OnClick fires (button.rs).
|
||
button::click_button(&self.lua, id, button, down, false);
|
||
}
|
||
// `OnDoubleClick(self, button)` — fired where `OnClick` would have been and instead of it
|
||
// (`0x77938d call [edx+0x98]`, the mouse-UP dispatcher's exclusive second leg). One
|
||
// argument, the button name of the *completing* release, and it plays the same click sound
|
||
// as a single: the base firer `0x779650` is the byte-twin of the single leg's `0x779540`.
|
||
//
|
||
// `arg2` is 0 in both legs; we pass it only for `OnClick`, where our binding already spells
|
||
// it as the documented `down` flag, and drop it here to match the Era-facing
|
||
// `OnDoubleClick(self, button)` signature the corpus is written against.
|
||
if let Some(id) = double_id {
|
||
if let Err(e) = event::fire_widget_handler(&self.lua, id, "OnDoubleClick", vec![btn]) {
|
||
self.push_error(e);
|
||
}
|
||
}
|
||
hit_id.is_some() || world_dropped
|
||
}
|
||
|
||
/// Fire `OnDragStop` on a gesture's source — the ONE place that ends a started drag, so the
|
||
/// release path and the two abandon paths ([`cursor::abandon_drag`]) cannot drift apart.
|
||
/// A source that died between the press and here fires nothing.
|
||
pub(super) fn fire_drag_stop(&mut self, source: crate::widget::FrameHandle) {
|
||
let id = {
|
||
let model = self.model_ref();
|
||
model
|
||
.arena
|
||
.frame(source)
|
||
.is_some()
|
||
.then(|| model.frame_to_id.get(&source).copied())
|
||
.flatten()
|
||
};
|
||
if let Some(id) = id {
|
||
if let Err(e) = event::fire_widget_handler(&self.lua, id, "OnDragStop", Vec::new()) {
|
||
self.push_error(e);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A mouse-wheel spin at `(x, y)`: fires `OnMouseWheel(self, delta)` on the hit frame — or,
|
||
/// when the hit frame carries no OnMouseWheel handler, on its NEAREST ANCESTOR that does (the
|
||
/// client's bubbling: a reward-row Button inside a ScrollFrame doesn't eat the wheel, the
|
||
/// pane keeps scrolling under the cursor). WoW's convention is `+1` per notch up / `-1` down
|
||
/// (`warcraft.wiki.gg/wiki/UIHANDLER_OnMouseWheel`); `delta` is passed through as given, so
|
||
/// the app-side feed picks the magnitude/sign. Handler errors are collected into
|
||
/// [`UiScript::errors`].
|
||
pub fn mouse_wheel(&mut self, x: f32, y: f32, delta: f32) {
|
||
let target: Option<u32> = {
|
||
// **Wheel-enabled OR mouse-enabled**, not mouse-enabled alone. The reference's wheel
|
||
// dispatcher `0x7664f0` walks its OWN index and calls the hit probe directly, ignoring
|
||
// hover, capture and the mouse bit — `EnableMouse` is not a prerequisite
|
||
// (wow-re `mouse-enable-law.md` §6). Ours went through the mouse hit test, which
|
||
// coupled the two: correcting the ctor list to the reference's (ScrollFrame is inert
|
||
// on the mouse bit) silently stopped every scroll pane from taking the wheel.
|
||
let hit = self.hit_test_wheel(x, y);
|
||
let model = self.model_ref();
|
||
hit.and_then(|id| {
|
||
let mut h = *model.id_to_frame.get(&id)?;
|
||
loop {
|
||
if model
|
||
.scripts
|
||
.get(&h)
|
||
.is_some_and(|s| s.contains("OnMouseWheel"))
|
||
{
|
||
// A frame carrying a handler was registered through Lua, so it has an id.
|
||
return model.frame_to_id.get(&h).copied();
|
||
}
|
||
h = model.arena.frame(h)?.parent?;
|
||
}
|
||
})
|
||
};
|
||
if let Some(id) = target {
|
||
if let Err(e) = event::fire_widget_handler(
|
||
&self.lua,
|
||
id,
|
||
"OnMouseWheel",
|
||
vec![Value::Number(f64::from(delta))],
|
||
) {
|
||
self.push_error(e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Point-in-rect for hit-testing, in WoW UI coords (**y-up**): the cursor `(x, y)` is inside `r` when
|
||
/// it lies within `[left, right] × [bottom, top]` (edges inclusive). This is the same space
|
||
/// [`UiScript::resolve`]/[`UiScript::extract`] produce rects in — the verified `point_in_rect
|
||
/// 0x76b020` primitive (`propagation.md`), modeled on our [`Rect`] convention.
|
||
pub(super) fn point_in_rect(r: Rect, x: f32, y: f32) -> bool {
|
||
x >= r.left && x <= r.right && y >= r.bottom && y <= r.top
|
||
}
|
||
|
||
/// A frame's **mouse** rect: its resolved rect shrunk by `[left, right, top, bottom]`
|
||
/// ([`crate::widget::Frame::hit_rect_insets`]). y-up, so a `top` inset lowers `top` and a `bottom`
|
||
/// inset raises `bottom`. Insets that over-shrink collapse the rect rather than inverting it — an
|
||
/// empty rect simply captures nothing, which is what the API's own "inset past the far edge" case
|
||
/// means.
|
||
fn inset_rect(r: Rect, [left, right, top, bottom]: [f32; 4]) -> Rect {
|
||
Rect {
|
||
left: r.left + left,
|
||
right: (r.right - right).max(r.left + left),
|
||
top: r.top - top,
|
||
bottom: (r.bottom + bottom).min(r.top - top),
|
||
}
|
||
}
|