From 73623e31fa6bc49ede20db3f16b512daebbb4409 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:08:29 +0700 Subject: [PATCH] Hover walk: no arg1, held during a move, no fling, published mouseover (#941) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/benilla-app/src/ui_aura.rs | 30 +- crates/benilla-app/src/ui_aura/units.rs | 28 ++ crates/benilla-app/src/ui_script/input.rs | 2 +- crates/benilla-app/src/ui_tooltip/mod.rs | 9 +- crates/benilla-ui/src/script/event.rs | 4 +- crates/benilla-ui/src/script/mod.rs | 27 ++ crates/benilla-ui/src/script/mouseover.rs | 12 +- crates/benilla-ui/src/script/object.rs | 2 +- .../benilla-ui/src/script/object/movable.rs | 29 +- crates/benilla-ui/src/script/pointer.rs | 31 +- .../benilla-ui/src/script/tests/hover_walk.rs | 291 ++++++++++++++++++ crates/benilla-ui/src/script/tests/mod.rs | 1 + crates/benilla-ui/src/script/visibility.rs | 3 + 13 files changed, 432 insertions(+), 37 deletions(-) create mode 100644 crates/benilla-ui/src/script/tests/hover_walk.rs diff --git a/crates/benilla-app/src/ui_aura.rs b/crates/benilla-app/src/ui_aura.rs index 199c7b2c..93064206 100644 --- a/crates/benilla-app/src/ui_aura.rs +++ b/crates/benilla-app/src/ui_aura.rs @@ -93,7 +93,14 @@ impl Plugin for UiAuraPlugin { .init_resource::() .init_resource::() // 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>, - hovered: Option>, - hovered_go: Option>, interact: Option>, } @@ -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, diff --git a/crates/benilla-app/src/ui_aura/units.rs b/crates/benilla-app/src/ui_aura/units.rs index f1d7d23d..ae82cf9a 100644 --- a/crates/benilla-app/src/ui_aura/units.rs +++ b/crates/benilla-app/src/ui_aura/units.rs @@ -965,10 +965,18 @@ mod tests { (selection.target, selection.guid) = (Some(mob), Some(MOB)); let mut hovered = app.world_mut().resource_mut::(); (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) { + app.world_mut() + .non_send_resource_mut::() + .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::() + .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] diff --git a/crates/benilla-app/src/ui_script/input.rs b/crates/benilla-app/src/ui_script/input.rs index 2a296cc6..9937b728 100644 --- a/crates/benilla-app/src/ui_script/input.rs +++ b/crates/benilla-app/src/ui_script/input.rs @@ -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(); } diff --git a/crates/benilla-app/src/ui_tooltip/mod.rs b/crates/benilla-app/src/ui_tooltip/mod.rs index 99e7bc87..5da45c34 100644 --- a/crates/benilla-app/src/ui_tooltip/mod.rs +++ b/crates/benilla-app/src/ui_tooltip/mod.rs @@ -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. diff --git a/crates/benilla-ui/src/script/event.rs b/crates/benilla-ui/src/script/event.rs index 77827a86..a362fe9a 100644 --- a/crates/benilla-ui/src/script/event.rs +++ b/crates/benilla-ui/src/script/event.rs @@ -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; diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index e76f5934..24232e20 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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`. diff --git a/crates/benilla-ui/src/script/mouseover.rs b/crates/benilla-ui/src/script/mouseover.rs index 5349092d..3a3928bd 100644 --- a/crates/benilla-ui/src/script/mouseover.rs +++ b/crates/benilla-ui/src/script/mouseover.rs @@ -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) { let class = leave_class(&model(lua), h); match class { @@ -50,7 +52,7 @@ pub(super) fn leave(lua: &Lua, h: FrameHandle, script: Option) { 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); } } diff --git a/crates/benilla-ui/src/script/object.rs b/crates/benilla-ui/src/script/object.rs index e50418ed..5daedb04 100644 --- a/crates/benilla-ui/src/script/object.rs +++ b/crates/benilla-ui/src/script/object.rs @@ -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 diff --git a/crates/benilla-ui/src/script/object/movable.rs b/crates/benilla-ui/src/script/object/movable.rs index 3eca13d8..d1194a36 100644 --- a/crates/benilla-ui/src/script/object/movable.rs +++ b/crates/benilla-ui/src/script/object/movable.rs @@ -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; diff --git a/crates/benilla-ui/src/script/pointer.rs b/crates/benilla-ui/src/script/pointer.rs index f1b1a726..ad9b5a02 100644 --- a/crates/benilla-ui/src/script/pointer.rs +++ b/crates/benilla-ui/src/script/pointer.rs @@ -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 { + { + 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); } } diff --git a/crates/benilla-ui/src/script/tests/hover_walk.rs b/crates/benilla-ui/src/script/tests/hover_walk.rs new file mode 100644 index 00000000..d6d5ee50 --- /dev/null +++ b/crates/benilla-ui/src/script/tests/hover_walk.rs @@ -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::("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::("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::("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::("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::("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::("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::("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::("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::("return Here:GetLeft()").unwrap(), 130.0); + s.pointer_left_hover(); + s.pointer_left_window(); + s.resolve(); + assert_eq!(s.eval::("return stops").unwrap(), 1); + assert_eq!(s.eval::("return Here:GetLeft()").unwrap(), 130.0); + assert_eq!(s.eval::("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()); +} diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs index 68d532e6..f063916e 100644 --- a/crates/benilla-ui/src/script/tests/mod.rs +++ b/crates/benilla-ui/src/script/tests/mod.rs @@ -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; diff --git a/crates/benilla-ui/src/script/visibility.rs b/crates/benilla-ui/src/script/visibility.rs index 45dca109..63a1980c 100644 --- a/crates/benilla-ui/src/script/visibility.rs +++ b/crates/benilla-ui/src/script/visibility.rs @@ -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 {