Hover walk: no arg1, held during a move, no fling, published mouseover (#941)

Four pointer defects: OnEnter and OnLeave set `arg1 = true`; a frame being moved or sized still ran the hover walk; the pointer leaving the window fed `f32::MIN` to a move in flight, flinging the frame off-screen; and the mouseover token read the world pick, not the published mouseover.

In 1.12.1 both scripts run through `0x702690`, which sets no arg global. While `[root+0xcfc]` holds a move or size target, the walk pumps it (`0x7655b0`) and returns before any probe (`0x766164`–`0x7661a0`); a hide clears that slot (`0x76492e`). The pump only sees real cursor events. The token resolver reads the published pair `[0xb4e2c8]` (`0x515970`).

OnEnter and OnLeave fire with no arguments, `mouse_move` pumps a move or size and returns, a hide ends it, the pointer leave has its own call, and the aura feed reads the published mouseover.

Tests bite on the parent for each.

Fixes #932
Fixes #933
Fixes #936
Fixes #938

🤖 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 04:08:29 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 0fc40d3f87
commit 73623e31fa
13 changed files with 432 additions and 37 deletions
+18 -12
View File
@@ -93,7 +93,14 @@ impl Plugin for UiAuraPlugin {
.init_resource::<PlayerAuraCache>()
.init_resource::<AuraFeedMemory>()
// Feed before the VM dispatch, drain after it.
.add_systems(Update, feed_auras.in_set(UnitFeed).in_set(AuraEvents))
// After this frame's world publish, so the gain names the unit in the same frame.
.add_systems(
Update,
feed_auras
.in_set(UnitFeed)
.in_set(AuraEvents)
.after(crate::ui_tooltip::MouseoverPublish),
)
.add_systems(Update, drain_aura_cancels.after(UiInput))
// Torn down only at the session's end, not with the avatar, which a worldport drops.
.add_systems(OnExit(ClientState::InWorld), end_session_aura_state);
@@ -377,7 +384,8 @@ fn tracking_state_of(
})
}
/// The units the resolver's guid globals name, from `UnitTokens`' own sources.
/// The units the resolver's guid globals name, from `UnitTokens`' own sources; the mouseover is
/// the VM's published one.
#[derive(bevy::ecs::system::SystemParam)]
struct AuraUnits<'w> {
selection: Res<'w, Selection>,
@@ -385,8 +393,6 @@ struct AuraUnits<'w> {
// `Option`: a UI-only harness lacks their plugins; without the party's no group token names
// anyone.
group: Option<Res<'w, crate::ui_party::GroupState>>,
hovered: Option<Res<'w, crate::target::Hovered>>,
hovered_go: Option<Res<'w, crate::target::HoveredObject>>,
interact: Option<Res<'w, crate::ui_session::InteractNpc>>,
}
@@ -530,6 +536,13 @@ fn feed_auras(
)
};
let target_guid = units.selection.guid.unwrap_or(0);
// The resolver reads the published pair `[0xb4e2c8]` (`0x515bb6`, `0x515bdb`), not the pick,
// and takes it only as a unit (`0x515bca mov ecx,8`, `0x515bd9 je`): a GameObject, a corpse or
// nothing names nobody.
let mouseover = script
.world_mouseover()
.filter(|&guid| held(guid).is_some_and(|s| s.is_unit()))
.unwrap_or(0);
let catalog_moved = spells.as_ref().is_some_and(|s| s.is_changed());
let unit_changed = memo.units.feed(
&mut script,
@@ -538,14 +551,7 @@ fn feed_auras(
me: (self_guid.0, changed),
pet: pet_guid,
target: target_guid,
// The world pick, as `UnitTokens` resolves `"mouseover"`.
mouseover: units
.hovered
.as_deref()
.and_then(|h| {
h.mouseover(&units.hovered_go.as_deref().copied().unwrap_or_default())
})
.map_or(0, |(_, guid)| guid),
mouseover,
npc: units.interact.as_deref().and_then(|i| i.1).unwrap_or(0),
held,
buffs_visible,
+28
View File
@@ -965,10 +965,18 @@ mod tests {
(selection.target, selection.guid) = (Some(mob), Some(MOB));
let mut hovered = app.world_mut().resource_mut::<crate::target::Hovered>();
(hovered.target, hovered.guid) = (Some(add), Some(ADD));
publish_mouseover(&mut app, Some(ADD));
app.update();
app
}
/// The world mouseover as the publisher (`0x492890`) writes it to `[0xb4e2c8]`.
fn publish_mouseover(app: &mut App, guid: Option<u64>) {
app.world_mut()
.non_send_resource_mut::<UiScript>()
.publish_world_mouseover(guid);
}
/// `UnitDebuff` reads the list of whatever unit the token names, through the resolver
/// (`0x515970`, called at `0x519542`): the mouseover, a member's target, any depth of `target`
/// chain, in any case, and the player's own list at the end of a chain that comes back to us.
@@ -1036,11 +1044,31 @@ mod tests {
guid: Some(0xF110_0000_0000_0004),
distance: 5.0,
});
publish_mouseover(&mut app, Some(0xF110_0000_0000_0004));
app.update();
assert!(listed(&mut app, "mouseover", true).is_empty());
assert!(listed(&mut app, "mouseovertarget", true).is_empty());
}
/// A hovered plate retired after the pick: its leave (`0x7cb8b0`) publishes the empty
/// mouseover, while the pick still names the plate's unit. The resolver reads the published
/// pair (`0x515bb6`, `0x515bdb`), so in the next frame `"mouseover"` and its chains name
/// nobody.
#[test]
fn a_retired_hovered_plate_names_no_mouseover_in_the_next_frame() {
let mut app = chain_app();
assert_eq!(listed(&mut app, "mouseover", true), [770]);
assert_eq!(listed(&mut app, "mouseovertarget", true), [6788]);
app.world_mut()
.resource_mut::<crate::target::Hovered>()
.plate = true;
publish_mouseover(&mut app, None);
app.update();
assert!(listed(&mut app, "mouseover", true).is_empty());
assert!(listed(&mut app, "mouseovertarget", true).is_empty());
assert!(listed(&mut app, "mouseovertargettarget", true).is_empty());
}
/// A raid member's target that is out of view but on the roster reads its record, as
/// `UnitDebuff`'s roster walk finds it (`0x519b25`); a hop off a unit not held is nobody.
#[test]
+1 -1
View File
@@ -194,7 +194,7 @@ pub(super) fn feed_ui_input(
// The OS pointer left: leave the hovered frame once, and disarm any press or drag every
// frame, since no release ends it and a stale gesture would fire on re-entry.
if hover.0.take().is_some() {
script.mouse_move(f32::MIN, f32::MIN);
script.pointer_left_hover();
}
script.pointer_left_window();
}
+8 -1
View File
@@ -26,12 +26,19 @@ use spell_feed::feed_spell_tooltips;
pub struct UiTooltipPlugin;
/// The frame's world-mouseover publish ([`UiScript::publish_world_mouseover`]), which the readers
/// of the published guid follow.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct MouseoverPublish;
impl Plugin for UiTooltipPlugin {
fn build(&self, app: &mut App) {
app.add_systems(
Update,
(
drive_mouseover_tooltip.in_set(UnitFeed),
drive_mouseover_tooltip
.in_set(UnitFeed)
.in_set(MouseoverPublish),
// After the trainer and quest feeds, so a list that lands this frame is hoverable
// in its tick; outside `UnitFeed`, which the trainer feed follows, so it takes that
// set's gate.
+2 -2
View File
@@ -1,7 +1,7 @@
//! Handler firing: the FrameScript calling convention for events, ticks and show/hide. The
//! reference sets `this` (`0x872e64`), `event` (`0x84b648`) and `arg1..argN` (`0x8722dc`) around
//! the call, restoring each after, and `pcall`s with no arguments; the handler here also gets the
//! same values as `(self, event, ...)` arguments, which 1.12 does not pass.
//! the call, restoring each after, and `pcall`s with no arguments; so does [`invoke_with_globals`],
//! which sets only the args it is given, so a script fired with none leaves `arg1..argN` as found.
//! Handler errors return to the caller, which records them in [`super::Model::errors`].
use std::borrow::Cow;
+27
View File
@@ -1001,6 +1001,33 @@ impl UiScript {
// ── Input: the pointer leaving the window ────────────────────────────────────────────────────
/// The OS pointer left the window: the hovered frame loses the hover, with its leave notify
/// ([`mouseover::leave`]) as a walk that found nothing would run it. It is no cursor event, so
/// it feeds no position: no probe runs and a move or size in flight holds its last sample
/// (`[root+0xd08]`/`[root+0xd0c]`), as the reference's pump only sees real cursor events
/// (`0x7655b0`). The saved cursor position stays too.
pub fn pointer_left_hover(&mut self) {
let leave = {
let mut model = self.model_mut();
// A frame destroyed since it took the hover gets no leave.
let old = model
.mouseover
.take()
.filter(|&h| model.arena.frame(h).is_some());
old.map(|h| {
let script = model
.frame_to_id
.get(&h)
.copied()
.filter(|_| button::hover_notify_runs(&model, Some(h)));
(h, script)
})
};
if let Some((h, script)) = leave {
mouseover::leave(&self.lua, h, script);
}
}
/// The OS pointer left the window, so no release will be fed: clears [`Model::drag`], which
/// would start a drag on re-entry, and [`Model::mouse_down_on`], which would click the frame
/// it left. The app calls it where it fires the synthetic `OnLeave`.
+7 -5
View File
@@ -9,7 +9,7 @@
//! mouseover from its own update (`0x481790`), which picks only while the WorldFrame holds the
//! hover (`0x4817ae`), the app's world drive.
use mlua::{Lua, Value};
use mlua::Lua;
use crate::widget::{FrameHandle, FrameKind};
@@ -35,9 +35,11 @@ fn leave_class(model: &Model, h: FrameHandle) -> LeaveClass {
}
}
/// A frame's leave notify, by its class, around its `OnLeave(true)`; `script` is the id whose
/// `OnLeave` fires, `None` where the notify skips it (a disabled Button's `0x7794e0`). The walk's
/// motion leave and the removal tail's leave (`0x764cce`) both come here.
/// A frame's leave notify, by its class, around its `OnLeave`; `script` is the id whose `OnLeave`
/// fires, `None` where the notify skips it (a disabled Button's `0x7794e0`). The walk's motion
/// leave and the removal tail's leave (`0x764cce`) both come here. The notify's own argument, 0 for
/// a motion leave and 1 for a removal, only clears the drag arm (`0x76b725`); `OnLeave` runs
/// through the plain runner (`0x76b753` → `0x702690`), which sets no arg global.
pub(super) fn leave(lua: &Lua, h: FrameHandle, script: Option<u32>) {
let class = leave_class(&model(lua), h);
match class {
@@ -50,7 +52,7 @@ pub(super) fn leave(lua: &Lua, h: FrameHandle, script: Option<u32>) {
LeaveClass::World | LeaveClass::Other => {}
}
if let Some(id) = script {
if let Err(e) = event::fire_widget_handler(lua, id, "OnLeave", vec![Value::Boolean(true)]) {
if let Err(e) = event::fire_widget_handler(lua, id, "OnLeave", Vec::new()) {
model(lua).record_lua_error(&e);
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ mod layout_methods;
pub(crate) use layout_methods::eff_scale;
pub(crate) mod movable;
pub(crate) mod toplevel;
pub(crate) use movable::{advance_move, advance_size, FrameMove, FrameSizing};
pub(crate) use movable::{cancel_on_hide, pump_move_or_size, FrameMove, FrameSizing};
// ─────────────────────────────────────────────────────────────────────────────────────────────
// id ↔ lightuserdata
+24 -5
View File
@@ -359,8 +359,29 @@ fn clamp_resize(model: &Model, h: FrameHandle, w: f32, hgt: f32) -> (f32, f32) {
(axis(w, min.0, max.0), axis(hgt, min.1, max.1))
}
/// The hover walk's move/size branch (`0x766164`–`0x766193`): while the drag slot holds a target,
/// the walk pumps it to `pos` (`0x7655b0`) and returns before any probe. True when one is in
/// flight, a target whose frame is gone dropped first.
pub(crate) fn pump_move_or_size(model: &mut Model, pos: (f32, f32)) -> bool {
advance_move(model, pos);
advance_size(model, pos);
model.moving.is_some() || model.sizing.is_some()
}
/// A hide ends the move or size whose target is the frame going hidden: the hide's de-registration
/// (`0x76ad9d` → `0x764920`, flag 0) clears the slot when it holds the frame
/// (`0x76492e`–`0x76493a` → `0x765640`).
pub(crate) fn cancel_on_hide(model: &mut Model, h: FrameHandle) {
if model.moving.is_some_and(|mv| mv.frame == h) {
model.moving = None;
}
if model.sizing.is_some_and(|sz| sz.frame == h) {
model.sizing = None;
}
}
/// Pump an in-flight `StartSizing`: the gripped edges follow the cursor, the opposite ones stay.
pub(crate) fn advance_size(model: &mut Model, pos: (f32, f32)) {
fn advance_size(model: &mut Model, pos: (f32, f32)) {
let Some(sz) = model.sizing else { return };
if model.arena.frame(sz.frame).is_none() {
model.sizing = None;
@@ -441,10 +462,8 @@ pub(crate) fn advance_size(model: &mut Model, pos: (f32, f32)) {
model.sizing = Some(FrameSizing { sample: pos, ..sz });
}
/// Pump an in-flight move to the cursor at `pos` (`0x7655b0`). Called from
/// [`crate::script::UiScript::mouse_move`] before its hover-boundary early return, since a frame
/// dragged under the cursor crosses no boundary.
pub(crate) fn advance_move(model: &mut Model, pos: (f32, f32)) {
/// Pump an in-flight move to the cursor at `pos` (`0x7655b0`).
fn advance_move(model: &mut Model, pos: (f32, f32)) {
let Some(mv) = model.moving else { return };
if model.arena.frame(mv.frame).is_none() {
model.moving = None;
+21 -10
View File
@@ -178,11 +178,26 @@ impl UiScript {
model.arena.frame(fh)?.name.clone()
}
/// Move the cursor to `(x, y)`. When the hovered frame changes, fires `OnLeave(self, true)` on
/// the old one if still live and `OnEnter(self, true)` on the new; a disabled Button takes the
/// hover but fires neither ([`button::hover_notify_runs`]). Drags and frame moves advance
/// first. Returns the hovered frame id.
/// Move the cursor to `(x, y)`. When the hovered frame changes, fires `OnLeave` on the old one
/// if still live and `OnEnter` on the new, both with the arg globals untouched; a disabled
/// Button takes the hover but fires neither ([`button::hover_notify_runs`]). A frame move or
/// size in flight takes the move instead, and nothing else runs. Returns the hovered frame id.
pub fn mouse_move(&mut self, x: f32, y: f32) -> Option<u32> {
{
let mut model = self.model_mut();
// The walk saves the event before its move/size check (`0x76615f`), so the re-pick's
// position follows the cursor through a drag.
model.cursor_pos = (x, y);
// A move or size in flight is pumped and the walk returns (`0x766164`–`0x7661a0`)
// before any probe (`vtbl+0x3c`): no hit test, no drag start (`0x76b320`), no
// EditBox, slider or colour thumb drag (`0x77a860`, `0x789ba0`, `0x78bd80`), no
// Minimap probe (`0x4eb0c0`), and the hover holds until the drop.
if super::object::pump_move_or_size(&mut model, (x, y)) {
return model
.mouseover
.and_then(|h| model.frame_to_id.get(&h).copied());
}
}
// A drag-active EditBox extends its selection on every move (`0x77a860`).
editbox::drag_update(&self.lua, x, y);
let (new_handle, minimap_missed) = self.hover_walk(x, y);
@@ -200,14 +215,11 @@ impl UiScript {
Option<(u32, f64, f64, f64)>,
) = {
let mut model = self.model_mut();
model.cursor_pos = (x, y);
// Drags advance on every move, before the hover test: a drag within one frame crosses
// no hover boundary.
let slider_change = super::slider::drag_move(&mut model, x, y);
// The colour picker fires on every move, not only on a change: it has no change gate.
let color_change = super::colorselect::drag_move(&mut model, x, y);
super::object::advance_move(&mut model, (x, y));
super::object::advance_size(&mut model, (x, y));
let drag_start = cursor::maybe_start_drag(&mut model, (x, y));
let new_handle = new_id.and(new_handle);
if new_handle == model.mouseover {
@@ -262,9 +274,8 @@ impl UiScript {
super::mouseover::leave(&self.lua, h, script);
}
if let Some(nid) = enter_id {
if let Err(e) =
event::fire_widget_handler(&self.lua, nid, "OnEnter", vec![Value::Boolean(true)])
{
// 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);
}
}
@@ -0,0 +1,291 @@
//! The cursor-move hover walk (`0x7660d0`): what its `OnEnter` and `OnLeave` see, and its early
//! return while a frame is moving or sizing.
use super::common::script;
use crate::script::UiScript;
/// The joined `LOG` entries, emptied.
fn take_log(s: &UiScript) -> String {
s.eval::<String>("local out = table.concat(LOG, ' ') LOG = {} return out")
.unwrap()
}
/// `OnEnter` and `OnLeave` run through the plain runner (`0x702690` → `0x704d50`), whose call
/// (`0x704e75` → `0x6f41a0`) passes no arguments and sets no global but `this`: the arg globals
/// keep what the last event left, on the walk's enter and leave and on a hide's leave.
#[test]
fn on_enter_and_on_leave_leave_the_arg_globals_untouched() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
LOG = {}
local function rec(tag)
return function() table.insert(LOG, tag .. "=" .. tostring(arg1) .. "," .. tostring(arg2)) end
end
A = CreateFrame("Frame", "ArgA")
A:SetPoint("BOTTOMLEFT", 100, 100); A:SetWidth(100); A:SetHeight(100)
A:EnableMouse(true)
A:SetScript("OnEnter", rec("enterA"))
A:SetScript("OnLeave", rec("leaveA"))
B = CreateFrame("Frame", "ArgB")
B:SetPoint("BOTTOMLEFT", 300, 100); B:SetWidth(100); B:SetHeight(100)
B:EnableMouse(true)
B:SetScript("OnEnter", rec("enterB"))
B:SetScript("OnLeave", rec("leaveB"))
arg1, arg2 = "sentinel", "second"
"#,
)
.unwrap();
s.resolve();
s.mouse_move(150.0, 150.0);
assert_eq!(take_log(&s), "enterA=sentinel,second");
s.mouse_move(350.0, 150.0);
assert_eq!(
take_log(&s),
"leaveA=sentinel,second enterB=sentinel,second",
"a boundary crossing sets no arg global on either side"
);
// The removal tail's leave (`0x764cce`) runs through the same base leave and runner.
s.run("B:Hide()").unwrap();
assert_eq!(take_log(&s), "leaveB=sentinel,second");
assert_eq!(
s.eval::<String>("return arg1 .. ',' .. arg2").unwrap(),
"sentinel,second"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// Three mouse-enabled frames on a 1024×768 screen: `Here` under the cursor's start, `There` to
/// its right, and `Far`, movable and resizable, well away from both. Each logs its enter and
/// leave.
fn three_frames() -> UiScript {
let mut s = script();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
LOG = {}
local function frame(name, x, y)
local f = CreateFrame("Frame", name)
f:SetPoint("BOTTOMLEFT", x, y); f:SetWidth(100); f:SetHeight(100)
f:EnableMouse(true)
f:SetScript("OnEnter", function() table.insert(LOG, "enter" .. name) end)
f:SetScript("OnLeave", function() table.insert(LOG, "leave" .. name) end)
return f
end
Here = frame("Here", 100, 100)
There = frame("There", 300, 100)
Far = frame("Far", 700, 500)
Far:SetMovable(true)
Far:SetResizable(true)
"#,
)
.unwrap();
s.resolve();
s.mouse_move(150.0, 150.0);
assert_eq!(take_log(&s), "enterHere");
s
}
fn focus(s: &UiScript) -> String {
s.eval::<String>("local f = GetMouseFocus() return f and f:GetName() or 'nil'")
.unwrap()
}
/// While `[root+0xcfc]` holds a move or size target (`0x7652fc`), the walk runs the move/size
/// tick (`0x766193` → `0x7655b0`) and returns (`0x7661a0`) before any probe: the hover stays and
/// no enter or leave fires until `StopMovingOrSizing` clears the slot (`0x765640`).
fn assert_walk_waits_for_the_drop(start: &str) {
let mut s = three_frames();
s.run(start).unwrap();
s.mouse_move(350.0, 150.0);
s.resolve();
assert_eq!(take_log(&s), "", "{start}: no enter or leave mid-drag");
assert_eq!(focus(&s), "Here", "{start}: the hover stays put mid-drag");
s.mouse_move(352.0, 150.0);
assert_eq!(take_log(&s), "", "{start}: still nothing on the next move");
s.run("Far:StopMovingOrSizing()").unwrap();
assert_eq!(take_log(&s), "", "{start}: the stop alone walks nothing");
s.mouse_move(355.0, 150.0);
assert_eq!(
take_log(&s),
"leaveHere enterThere",
"{start}: the next move after the drop walks again"
);
assert_eq!(focus(&s), "There");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn a_frame_moving_skips_the_hover_walk_until_the_drop() {
assert_walk_waits_for_the_drop("Far:StartMoving()");
// The move itself still follows the cursor.
let mut s = three_frames();
s.run("Far:StartMoving()").unwrap();
s.mouse_move(350.0, 150.0);
s.resolve();
assert_eq!(s.eval::<f64>("return Far:GetLeft()").unwrap(), 900.0);
}
#[test]
fn a_frame_sizing_skips_the_hover_walk_until_the_drop() {
assert_walk_waits_for_the_drop("Far:StartSizing(\"RIGHT\")");
// The size itself still follows the cursor.
let mut s = three_frames();
s.run("Far:StartSizing(\"RIGHT\")").unwrap();
s.mouse_move(350.0, 150.0);
s.resolve();
assert_eq!(s.eval::<f64>("return Far:GetWidth()").unwrap(), 300.0);
}
/// The pump tail's re-pick (`0x7657be`) is the same walk on the saved event, so it waits for the
/// drop too: a frame shown under the cursor mid-drag gets no `OnEnter`.
#[test]
fn the_tick_re_pick_waits_for_the_drop_too() {
let mut s = three_frames();
s.run("Here:Hide() Far:StartMoving()").unwrap();
assert_eq!(take_log(&s), "leaveHere");
s.run("Here:Show()").unwrap();
s.tick(0.016);
assert_eq!(
take_log(&s),
"",
"the re-pick finds the slot set and returns"
);
assert_eq!(focus(&s), "nil");
s.run("Far:StopMovingOrSizing()").unwrap();
s.mouse_move(151.0, 150.0);
assert_eq!(take_log(&s), "enterHere");
}
/// The drag-start check lives in the base probe (`0x76b320`), which the early return skips, so a
/// frame that starts moving on its press never reaches `OnDragStart`, however far it is dragged.
#[test]
fn a_move_started_on_the_press_never_reaches_the_drag_start() {
let mut s = three_frames();
s.run(
r#"
starts = 0
Here:SetMovable(true)
Here:RegisterForDrag("LeftButton")
Here:SetScript("OnMouseDown", function() this:StartMoving() end)
Here:SetScript("OnMouseUp", function() this:StopMovingOrSizing() end)
Here:SetScript("OnDragStart", function() starts = starts + 1 end)
"#,
)
.unwrap();
s.mouse_button(150.0, 150.0, "LeftButton", true);
s.mouse_move(250.0, 150.0);
s.mouse_move(350.0, 150.0);
s.mouse_button(350.0, 150.0, "LeftButton", false);
assert_eq!(s.eval::<i64>("return starts").unwrap(), 0);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// Hiding the moving frame cancels the move: the hide's de-registration (`0x76ad9d` →
/// `0x764920`) clears the slot when it holds the frame (`0x76492e`–`0x76493a` → `0x765640`), so
/// the next move walks again.
#[test]
fn hiding_the_moving_frame_ends_the_move_and_the_walk_resumes() {
let mut s = three_frames();
s.run("Far:StartMoving()").unwrap();
s.mouse_move(250.0, 150.0);
assert_eq!(take_log(&s), "");
s.run("Far:Hide()").unwrap();
s.mouse_move(350.0, 150.0);
assert_eq!(take_log(&s), "leaveHere enterThere");
s.run("Far:Show()").unwrap();
s.resolve();
assert_eq!(
s.eval::<f64>("return Far:GetLeft()").unwrap(),
800.0,
"the move ended with the hide: the later cursor delta did not reach it"
);
}
/// The pointer leaving the window ends the hover with the hovered frame's `OnLeave`, and the next
/// real move walks again.
#[test]
fn the_pointer_leaving_the_window_leaves_the_hovered_frame() {
let mut s = three_frames();
s.pointer_left_hover();
s.pointer_left_window();
assert_eq!(take_log(&s), "leaveHere");
assert_eq!(focus(&s), "nil");
s.mouse_move(150.0, 150.0);
assert_eq!(take_log(&s), "enterHere");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A move in flight follows real cursor events only (`0x7655b0` adds each event's delta from the
/// last sample, `[root+0xd08]`/`[root+0xd0c]`): the pointer leaving moves nothing and stamps no
/// layout, and the first real move back applies its delta from the last real sample.
#[test]
fn a_frame_moving_when_the_pointer_leaves_keeps_its_offsets() {
let mut s = three_frames();
s.run("Far:StartMoving()").unwrap();
s.mouse_move(250.0, 150.0);
s.resolve();
let point = |s: &UiScript| {
s.eval::<(f64, f64)>("local _, _, _, x, y = Far:GetPoint() return x, y")
.unwrap()
};
assert_eq!(point(&s), (800.0, 500.0));
let saved = s.user_placed_layouts();
s.pointer_left_hover();
s.pointer_left_window();
s.resolve();
assert_eq!(point(&s), (800.0, 500.0), "the leave moves nothing");
assert_eq!(s.eval::<f64>("return Far:GetLeft()").unwrap(), 800.0);
assert_eq!(
s.user_placed_layouts(),
saved,
"the layout cache keeps the last real position"
);
assert_eq!(take_log(&s), "leaveHere", "the hover ends mid-move too");
// The move is `StartMoving`'s, which only `StopMovingOrSizing` ends: back in the window, the
// next real event moves it by its delta from the last real one.
s.mouse_move(260.0, 150.0);
s.resolve();
assert_eq!(point(&s), (810.0, 500.0));
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The canonical drag idiom: the pointer leaving mid-drag ends the gesture with `OnDragStop`,
/// whose `StopMovingOrSizing` keeps the frame where the last real move put it.
#[test]
fn a_drag_cut_short_by_the_pointer_leaving_keeps_the_last_real_position() {
let mut s = three_frames();
s.run(
r#"
stops = 0
Here:SetMovable(true)
Here:RegisterForDrag("LeftButton")
Here:SetScript("OnDragStart", function() this:StartMoving() end)
Here:SetScript("OnDragStop", function() stops = stops + 1 this:StopMovingOrSizing() end)
"#,
)
.unwrap();
s.mouse_button(150.0, 150.0, "LeftButton", true);
s.mouse_move(170.0, 150.0); // past the threshold: OnDragStart, StartMoving
s.mouse_move(200.0, 160.0);
s.resolve();
assert_eq!(s.eval::<f64>("return Here:GetLeft()").unwrap(), 130.0);
s.pointer_left_hover();
s.pointer_left_window();
s.resolve();
assert_eq!(s.eval::<i64>("return stops").unwrap(), 1);
assert_eq!(s.eval::<f64>("return Here:GetLeft()").unwrap(), 130.0);
assert_eq!(s.eval::<f64>("return Here:GetBottom()").unwrap(), 110.0);
let saved = s.user_placed_layouts();
let here = saved
.iter()
.find(|l| l.name == "Here")
.expect("Here is saved");
assert_eq!((here.points[0].x, here.points[0].y), (130.0, 110.0));
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
@@ -27,6 +27,7 @@ mod gm_ticket;
mod handler_prof;
mod host_state;
mod hover_publish;
mod hover_walk;
mod input;
mod keep_rect;
mod keyboard;
@@ -93,6 +93,9 @@ pub(super) fn base_hide(lua: &Lua, h: FrameHandle) {
if !model.arena.hide_step(h) {
return;
}
// The de-registration ends a move or size on this frame before its index removal
// (`0x76493a`, then `0x764942`).
super::object::cancel_on_hide(&mut model, h);
leave_mouse_index(&mut model, h)
};
if let Some(script) = left {