benilla: initial public release

A from-scratch World of Warcraft 1.12.1 client in Rust + Bevy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-24 14:27:01 +07:00
co-authored by Claude Fable 5
commit 2ad0043523
778 changed files with 295134 additions and 0 deletions
+437
View File
@@ -0,0 +1,437 @@
//! 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 and an in-flight **Slider thumb drag** (both 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.
//! - [`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 super::clip::{effective_clip, scroll_clip_sources};
use super::{button, cursor, editbox, event, UiScript};
// ── 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)
})?,
)?;
Ok(())
}
impl UiScript {
/// Hit-test the cursor at `(x, y)` and return the **id** 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. 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 model = self.model_ref();
let sorted = order::traversal(&model.arena);
let scroll_sources = scroll_clip_sources(&model);
let hit = order::hit_test(&sorted, |fh| {
model.arena.is_mouse_enabled(fh)
&& model
.resolved
.get(&fh)
.is_some_and(|r| point_in_rect(*r, x, y))
&& effective_clip(&model, &scroll_sources, fh)
.is_none_or(|c| point_in_rect(c, x, y))
});
hit.and_then(|h| model.frame_to_id.get(&h).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):
/// this runs 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): (
Option<u32>,
Option<(u32, String)>,
bool,
Option<(u32, f32)>,
) = {
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);
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, false, slider_change) // no boundary crossed
} 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()
});
model.mouseover = new_handle;
(old_id, drag_start, true, slider_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, 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
}
/// 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, on the captured frame.
///
/// 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);
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.
let (click_id, drag_release, world_dropped): (
Option<u32>,
Option<cursor::DragRelease>,
bool,
) = {
let mut model = self.model_mut();
let hit_handle = hit_id.and_then(|id| model.id_to_frame.get(&id).copied());
if down {
match hit_handle {
Some(h) => {
model.mouse_down_on.insert(button.to_string(), h);
}
None => {
model.mouse_down_on.remove(button);
}
}
cursor::arm_drag(&mut model, hit_handle, button, (x, y));
// A left press on a Slider's thumb begins the engine thumb drag (decision 0250 §5),
// independent of the click/drag-trio path above (a Slider isn't drag_registered).
if button == "LeftButton" {
super::slider::begin_drag(&mut model, hit_handle, x, y);
}
let wants = format!("{button}Down");
let click = hit_handle
.filter(|&h| button::wants_click(&model, h, &wants))
.and(hit_id);
(click, None, false)
} else {
let pressed = model.mouse_down_on.remove(button);
// A left release ends any in-flight thumb drag (decision 0250 §5).
if button == "LeftButton" {
super::slider::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)
};
(click, release, dropped)
}
};
let script = if down { "OnMouseDown" } else { "OnMouseUp" };
if let Some(id) = hit_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);
}
}
// 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) {
let source_id = {
let model = self.model_ref();
model
.arena
.frame(release.source)
.is_some()
.then(|| model.frame_to_id.get(&release.source).copied())
.flatten()
};
if let Some(id) = source_id {
if let Err(e) = event::fire_widget_handler(&self.lua, id, "OnDragStop", Vec::new())
{
self.push_error(e);
}
}
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);
}
hit_id.is_some() || world_dropped
}
/// 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> = {
let hit = self.hit_test(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
}