Layout-mode drag, title press takes the slot, links are their own button (#961)

A title-region press refused while another drag held the slot, `SetLayoutMode` stored nothing, and a press on a chat link made the chat frame itself the target, so every link click started a title move of the window.

In 1.12.1 the drag entry takes the slot unread (`0x7652b0`); a mid-drag press goes to the frozen hover (`0x76637f`). With `SetLayoutMode` (`0x488540`) on, a Ctrl or Alt press drags the nearest movable toplevel (`0x765490`) from a 3×3 grip grid (`0x765320`) and is consumed, world included; its release only ends the drag (`0x7664e0`). Each link span is its own Button (`0x7a3240`) routing `OnHyperlinkEnter`/`Leave`/`Click` to the parent (`0x7a3780`–`0x7a37e0`).

These now hold; a release goes to the capture whatever its button (`0x76642b`), and number arguments keep `_ftol`'s low dword.

Tests bite on the parent for each.

Fixes #951
Fixes #952
Fixes #957

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-12 07:22:54 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 108c354fc6
commit 985dabff44
24 changed files with 1049 additions and 135 deletions
+100 -75
View File
@@ -2,10 +2,11 @@
//! 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.
//! A frame captures the cursor when it is in the mouse index, which `0x76af00` enters it into
//! while it has the mouse bit (`[+0xcc] & 4`) and is visible (`[+0xd4]`), and the probe `0x76b020`
//! takes the point: its rect is valid, and its hit rect and effective ScrollFrame clip hold the
//! point. The first such frame in the order of the hover walk (`0x7661cd` in `0x7660d0`) wins,
//! which [`crate::order::hit_test`] implements.
use std::collections::HashMap;
@@ -58,6 +59,19 @@ pub(super) fn install(lua: &mlua::Lua) -> mlua::Result<()> {
Ok(())
}
/// The frame a release of `button` goes to: the release handler reads only the capture
/// (`0x76642b`), which `mouse_down_on` keeps per button as the press that set it, `None` once that
/// frame hid. A button with no press of its own, one a title or layout press took, releases onto
/// whatever capture is held.
fn release_target(model: &Model, button: &str) -> Option<FrameHandle> {
match model.mouse_down_on.get(button) {
Some(&pressed) => pressed,
None => model
.mouse_capture
.filter(|&c| model.arena.frame(c).is_some()),
}
}
/// 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 {
@@ -81,15 +95,6 @@ fn over_world(model: &Model, target: Option<FrameHandle>) -> 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.
@@ -125,11 +130,10 @@ impl UiScript {
}),
);
let hit = 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.
let indexed = model.arena.is_mouse_enabled(fh) || link_span_hit(&model, fh, x, y);
// In the mouse index. The chat frame takes no mouse (`[+0xcc] = 0`): its links are
// Button children of their own ([`super::hyperlink`]), so a chat window hits only over
// a link and drags by its tab, not its body.
let indexed = model.arena.is_mouse_enabled(fh);
// 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.
@@ -274,9 +278,13 @@ impl UiScript {
super::mouseover::leave(&self.lua, h, script);
}
if let Some(nid) = enter_id {
// The base enter's plain runner (`0x76b6e5` → `0x702690`) sets no arg global.
if let Err(e) = event::fire_widget_handler(&self.lua, nid, "OnEnter", Vec::new()) {
self.push_error(e);
// A hyperlink button's enter is its text frame's `OnHyperlinkEnter` (`0x7a34b0`); the
// base enter's plain runner (`0x76b6e5` → `0x702690`) sets no arg global.
let routed = new_handle.is_some_and(|h| super::hyperlink::hover(&self.lua, h, true));
if !routed {
if let Err(e) = event::fire_widget_handler(&self.lua, nid, "OnEnter", Vec::new()) {
self.push_error(e);
}
}
}
new_id
@@ -294,12 +302,55 @@ impl UiScript {
.is_some_and(|r| point_in_rect(*r, x, y))
}
/// `0x765490`'s walk: strata FULLSCREEN_DIALOG down to WORLD, never TOOLTIP (the loop starts
/// below `root+0xcf4`, `0x7654a3`), each level high to low, and each level's list from its head
/// (the terminator's `next`, `[level+0x14]`) along each link's `next` (`+4`, `0x766aa0`). A
/// frame links at the tail (`0x7648c9` → `0x7668c0` → `0x766921`), so that is the oldest link
/// first, the hover sweep's order. Every shown frame is probed with `0x76b020` alone, whatever
/// its mouse bit. The first hit is lifted to its nearest toplevel self-or-ancestor
/// (`0x766fe0`); one with none gives up the rest of its stratum and the walk goes on in the
/// next below (`0x76554a` → `0x765532`).
fn layout_drag_target(&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);
let mut below = order::Strata::Tooltip;
loop {
let hit = order::hit_test(&sorted, |fh| {
model.arena.frame(fh).is_some_and(|f| f.strata < below)
&& frame_contains(&model, &scroll_sources, fh, x, y)
})?;
if let Some(t) = super::object::toplevel::nearest_toplevel(&model, hit) {
return Some(t);
}
below = model.arena.frame(hit)?.strata;
}
}
/// 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 `"<Button>ButtonDown"`, a release on the pressed frame for
/// `"<Button>ButtonUp"`. Its `down` argument is not 1.12, whose firer (`0x779540`) passes only
/// the button name. Returns whether the UI consumed the event.
pub fn mouse_button(&mut self, x: f32, y: f32, button: &str, down: bool) -> bool {
// ── The layout drag, ahead of everything ─────────────────────────────────────────────────
//
// With `SetLayoutMode` on, a press with Control (`0x41f8f0(1)`) or Alt (`0x41f8f0(2)`) held
// goes to `0x765490` as drag mode 1, Control asking for a size, and the handler returns
// consumed whatever that started (`0x766301`–`0x76635b`): no raise, title-region test,
// capture or press slot.
if down {
let (_, ctrl, alt) = self.model_ref().modifiers;
if self.model_ref().layout_mode != 0 && (ctrl || alt) {
let target = self.layout_drag_target(x, y);
let mut model = self.model_mut();
model.cursor_pos = (x, y);
if let Some(h) = target {
crate::script::object::movable::start_layout_drag(&mut model, h, (x, y), ctrl);
}
return true;
}
}
let hit_id = self.hit_test(x, y);
// ── The press target: the capture, else the hover ──────────────────────────────────────
//
@@ -309,6 +360,11 @@ impl UiScript {
// drag arm, a Button's press art and Down click), runs on that one frame. So a second
// button pressed over another frame fires on the held one. `mouse_down_on` keeps the
// frame per button for the release.
//
// The hover is what the last walk left. Every press follows a move to its point, so that
// is the frame under it, except while a move or size holds the slot: the walk then pumps
// it and returns before its sweep (`0x766164`), and the hover stays the frame it was when
// the drag began.
let mut release_lost = false;
let press_target: Option<crate::widget::FrameHandle> = if down {
let mut model = self.model_mut();
@@ -328,7 +384,12 @@ impl UiScript {
let held = model
.mouse_capture
.filter(|&c| model.arena.frame(c).is_some());
held.or_else(|| hit_id.and_then(|id| model.id_to_frame.get(&id).copied()))
let hover = if model.moving.is_some() {
model.mouseover.filter(|&h| model.arena.frame(h).is_some())
} else {
hit_id.and_then(|id| model.id_to_frame.get(&id).copied())
};
held.or(hover)
} else {
None
};
@@ -347,9 +408,9 @@ impl UiScript {
// ── The title region swallows the press ─────────────────────────────────────────────────
//
// A press inside the target's title region starts a mode-2 move and ends there:
// `0x7662c0` tests the region first and only a miss reaches `0x7663e6`, the capture and
// `OnMouseDown`. So no `OnMouseDown`, drag arm, `OnClick` or double-click stamp, and the
// event is consumed.
// `0x7662c0` tests the region before the capture (`0x766397`–`0x7663e5`) and only a miss
// reaches `0x7663e6`, the capture and `OnMouseDown`. So no `OnMouseDown`, drag arm,
// `OnClick` or double-click stamp, and the event is consumed.
if let Some(h) = press_target {
if self.title_region_hit(h, x, y) {
self.model_mut().cursor_pos = (x, y);
@@ -357,11 +418,14 @@ impl UiScript {
return true;
}
}
// ── The release ends a title move ───────────────────────────────────────────────────────
// ── The release ends a title move, and that is all it does ─────────────────────────────
//
// `0x766420` cancels move modes 1 (modifier drag) and 2 (title region) on release and
// leaves mode 3 (`StartMoving`) to the addon's `StopMovingOrSizing`;
// `FrameMove::auto_stop` is that distinction.
// `0x766420` tests the drag mode before anything else (`0x76646d`–`0x76647c`): any release
// during mode 1 (modifier drag) or 2 (title region) clears the slot (`0x765640`) and
// returns consumed, with no `OnMouseUp`, drag stop or click on the capture, which it
// leaves held for a later release. Mode 3 (`StartMoving`) is left to the addon's
// `StopMovingOrSizing`; `FrameMove::auto_stop` is that distinction. The button is up all
// the same, so its own press entry goes.
if !down
&& self
.model_ref()
@@ -369,19 +433,17 @@ impl UiScript {
.as_ref()
.is_some_and(|m| m.auto_stop)
{
self.model_mut().moving = None;
let mut model = self.model_mut();
model.moving = None;
model.mouse_down_on.remove(button);
return true;
}
// The press this release belongs to, read before the borrow below drains `mouse_down_on`.
// The frame this release goes to, read before the borrow below drains `mouse_down_on`.
let captured_id: Option<u32> = if down {
None
} else {
let model = self.model_ref();
model
.mouse_down_on
.get(button)
.copied()
.flatten()
.and_then(|h| model.frame_to_id.get(&h).copied())
release_target(&model, button).and_then(|h| model.frame_to_id.get(&h).copied())
};
// The `GetTime()` clock for the double-click detector, read before the borrow.
let now = self.now();
@@ -456,8 +518,8 @@ impl UiScript {
.and(press_id);
(click, None, false, jump, None, abandoned, color_jump)
} else {
let pressed = release_target(&model, button);
let entry = model.mouse_down_on.remove(button);
let pressed = entry.flatten();
// The pressed frame hid (`None` entry): the capture is gone, so nothing of this
// release runs, the world click included (`over_world(None)` would take it).
let capture_lost = entry == Some(None);
@@ -539,43 +601,6 @@ impl UiScript {
self.push_error(e);
}
}
// A release in a hyperlink span fires `OnHyperlinkClick(link, markup, button)` on the
// owning frame, except after a started drag.
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);
}
}
}
}
}
// A left press in an EditBox places the cursor, starts a selection drag and focuses it
// whatever its `autoFocus`, before any `OnClick` (`0x77b800`).
if button == "LeftButton" {