This checkout had no git history at all, so this one commit carries the whole
of the Everwood work as a single import on top of chairmanpow/benilla-octo's
`main` (706cd71). The two trees have genuinely diverged — each has files the
other does not — so this is a fork point, not a catch-up.
Today's work, which is the reason a baseline exists at all:
- Booth previews no longer cast world shadows (`NO_BOOTH_SHADOW`). Portrait
characters were entering the sun's shadow cascades, so every skinned-rig
shadow specialization compiled live mid-frame; the hitch detector logged
"main thread blocked 1316 ms" beside a `compiled LIVE` warning. This was
the Fashion/Tmog stutter.
- Per-pane dress-up: each `DressUpModel` pane assembles and stages its own
tried-on look instead of every pane mirroring the live player.
- Item template cache is append-only JSONL written on the IO pool, replacing
an 11.6 MB whole-map JSON rewrite on the main thread every 10 s.
- `WOW_SYSTEM_TIMING` rebuilt per-thread: it took 2 mutexes per system enter
and 4 per exit, costing 15 ms a frame (590 fps -> 60) and corrupting the
measurements it was taken to produce.
- The visibility-churn sweep moved to its own `WOW_FLASH_CHURN` switch, so a
frame-cost reading no longer pays 2.7 ms a frame for it.
Local state (`benilla-config/`, `logs/`, `target/`) is excluded by the
existing .gitignore; the account credential it holds is not in this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
923 lines
34 KiB
Rust
923 lines
34 KiB
Rust
//! Mouse input: hit order, hover, clicks, double clicks and the release capture.
|
||
|
||
use super::common::script;
|
||
|
||
#[test]
|
||
fn enable_mouse_gates_hit_testing() {
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
r#"
|
||
who = nil
|
||
-- Two full-screen frames. `b` is created later (drawn on top) but mouse-disabled, so it is
|
||
-- transparent to hits; the enabled frame behind it (`a`) must capture.
|
||
local a = CreateFrame("Frame", "A")
|
||
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(800); a:SetHeight(600); a:EnableMouse(true)
|
||
a:SetScript("OnEnter", function(self) who = self:GetName() end)
|
||
local b = CreateFrame("Frame", "B")
|
||
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(800); b:SetHeight(600); b:EnableMouse(false)
|
||
b:SetScript("OnEnter", function(self) who = self:GetName() end)
|
||
-- 1/nil, not a boolean: this is the exact comparison shape that inverts, so the
|
||
-- test asserts it rather than leaning on truthiness.
|
||
assert(a:IsMouseEnabled() == 1 and b:IsMouseEnabled() == nil)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.resolve();
|
||
|
||
let hit = s.mouse_move(400.0, 300.0);
|
||
assert!(
|
||
hit.is_some(),
|
||
"a mouse-enabled frame under the cursor captures"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return who").unwrap(),
|
||
"A",
|
||
"the top frame is mouse-disabled ⇒ the enabled frame behind it captures"
|
||
);
|
||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||
}
|
||
|
||
/// At a strata and level tie the earlier-linked frame takes the hit, though the later one draws on
|
||
/// top: `0x764aa0` appends equal keys after (strict `ja`) and `0x7660d0` sweeps from index 0.
|
||
#[test]
|
||
fn hit_order_is_strata_then_level_then_the_earlier_linked_frame() {
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
r#"
|
||
who = nil
|
||
local a = CreateFrame("Frame", "A")
|
||
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(800); a:SetHeight(600); a:EnableMouse(true)
|
||
a:SetScript("OnEnter", function(self) who = self:GetName() end)
|
||
local b = CreateFrame("Frame", "B")
|
||
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(800); b:SetHeight(600); b:EnableMouse(true)
|
||
b:SetScript("OnEnter", function(self) who = self:GetName() end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.resolve();
|
||
|
||
// Same strata and level: A, linked first, captures though B draws on top.
|
||
s.mouse_move(400.0, 300.0);
|
||
assert_eq!(s.eval::<String>("return who").unwrap(), "A");
|
||
|
||
// Move off everything to clear the mouseover (fires OnLeave, resets focus) before re-testing.
|
||
s.mouse_move(-10.0, -10.0);
|
||
|
||
// Strata outranks the tie; no rect changes, so no re-resolve.
|
||
s.run("A:SetFrameStrata('DIALOG')").unwrap();
|
||
s.mouse_move(400.0, 300.0);
|
||
assert_eq!(s.eval::<String>("return who").unwrap(), "A");
|
||
|
||
s.mouse_move(-10.0, -10.0);
|
||
|
||
// Same strata, higher level: level outranks link order, so B captures.
|
||
|
||
s.run("B:SetFrameStrata('DIALOG'); B:SetFrameLevel(10)")
|
||
.unwrap();
|
||
s.mouse_move(400.0, 300.0);
|
||
assert_eq!(s.eval::<String>("return who").unwrap(), "B");
|
||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||
}
|
||
|
||
#[test]
|
||
fn mouse_move_fires_enter_then_leave_across_a_boundary_with_correct_self() {
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
r#"
|
||
enters, leaves = 0, 0
|
||
enter_self_ok, leave_self_ok = false, false
|
||
local a = CreateFrame("Frame", "A") -- left half only
|
||
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(400); a:SetHeight(600); a:EnableMouse(true)
|
||
a:SetScript("OnEnter", function(self) enters = enters + 1; enter_self_ok = (self == a) end)
|
||
a:SetScript("OnLeave", function(self) leaves = leaves + 1; leave_self_ok = (self == a) end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.resolve();
|
||
|
||
s.mouse_move(200.0, 300.0); // inside A ⇒ OnEnter
|
||
s.mouse_move(600.0, 300.0); // outside A ⇒ OnLeave
|
||
let (enters, leaves): (i64, i64) = s.eval("return enters, leaves").unwrap();
|
||
assert_eq!((enters, leaves), (1, 1));
|
||
assert!(
|
||
s.eval::<bool>("return enter_self_ok").unwrap(),
|
||
"OnEnter self is the frame"
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("return leave_self_ok").unwrap(),
|
||
"OnLeave self is the frame"
|
||
);
|
||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||
}
|
||
|
||
#[test]
|
||
fn onclick_fires_on_press_release_same_frame_not_when_release_lands_elsewhere() {
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
r#"
|
||
clicks_a, clicks_b, click_btn = 0, 0, nil
|
||
local a = CreateFrame("Frame", "A") -- left half
|
||
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(400); a:SetHeight(600); a:EnableMouse(true)
|
||
a:SetScript("OnClick", function(self, button, down) clicks_a = clicks_a + 1; click_btn = button end)
|
||
local b = CreateFrame("Frame", "B") -- right half
|
||
b:SetPoint("BOTTOMLEFT", 400, 0); b:SetWidth(400); b:SetHeight(600); b:EnableMouse(true)
|
||
b:SetScript("OnClick", function(self, button, down) clicks_b = clicks_b + 1 end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.resolve();
|
||
|
||
s.mouse_button(200.0, 300.0, "LeftButton", true);
|
||
s.mouse_button(200.0, 300.0, "LeftButton", false);
|
||
assert_eq!(s.eval::<i64>("return clicks_a").unwrap(), 1);
|
||
assert_eq!(s.eval::<String>("return click_btn").unwrap(), "LeftButton");
|
||
|
||
s.mouse_button(200.0, 300.0, "LeftButton", true);
|
||
s.mouse_button(600.0, 300.0, "LeftButton", false);
|
||
assert_eq!(
|
||
s.eval::<i64>("return clicks_a").unwrap(),
|
||
1,
|
||
"release landed off A ⇒ no click on A"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<i64>("return clicks_b").unwrap(),
|
||
0,
|
||
"release on B was not preceded by a press on B"
|
||
);
|
||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||
}
|
||
|
||
#[test]
|
||
fn hidden_or_effective_hidden_frame_never_captures() {
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
r#"
|
||
entered = false
|
||
-- own shown = false
|
||
local own = CreateFrame("Frame", "OwnHidden")
|
||
own:SetPoint("BOTTOMLEFT", 0, 0); own:SetWidth(800); own:SetHeight(600); own:EnableMouse(true)
|
||
own:SetScript("OnEnter", function() entered = true end)
|
||
own:Hide()
|
||
-- effective-hidden: child is shown but its parent is hidden
|
||
local parent = CreateFrame("Frame", "Par")
|
||
parent:SetPoint("BOTTOMLEFT", 0, 0); parent:SetWidth(800); parent:SetHeight(600)
|
||
local child = CreateFrame("Frame", "Ch", parent)
|
||
child:SetPoint("BOTTOMLEFT", 0, 0); child:SetWidth(800); child:SetHeight(600); child:EnableMouse(true)
|
||
child:SetScript("OnEnter", function() entered = true end)
|
||
parent:Hide()
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.resolve();
|
||
|
||
assert!(
|
||
s.hit_test(400.0, 300.0).is_none(),
|
||
"no visible mouse-enabled frame ⇒ no capture"
|
||
);
|
||
assert!(s.mouse_move(400.0, 300.0).is_none());
|
||
assert!(
|
||
!s.eval::<bool>("return entered").unwrap(),
|
||
"hidden / effective-hidden frames fire no OnEnter"
|
||
);
|
||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||
}
|
||
|
||
#[test]
|
||
fn mouse_wheel_passes_delta_to_the_captured_frame() {
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
r#"
|
||
wheel = nil
|
||
local a = CreateFrame("Frame", "A")
|
||
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(800); a:SetHeight(600); a:EnableMouse(true)
|
||
-- The wheel is its own index and its own flag: SetScript never auto-enables (that law is
|
||
-- XML-load-time only), so a runtime-created frame needs this explicitly, exactly as it
|
||
-- needs EnableMouse. Mouse-enablement alone puts nothing in the wheel index.
|
||
a:EnableMouseWheel(true)
|
||
a:SetScript("OnMouseWheel", function(self, delta) wheel = delta end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.resolve();
|
||
|
||
s.mouse_wheel(400.0, 300.0, 1.0);
|
||
assert!((s.eval::<f64>("return wheel").unwrap() - 1.0).abs() < 1e-6);
|
||
s.mouse_wheel(400.0, 300.0, -1.0);
|
||
assert!((s.eval::<f64>("return wheel").unwrap() + 1.0).abs() < 1e-6);
|
||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||
}
|
||
|
||
#[test]
|
||
fn hit_rect_insets_shrink_the_mouse_rect_only() {
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
// A 100×100 frame at the origin; the top inset is the micro buttons' 18
|
||
// (`MainMenuBarMicroButtons.xml:9`), with 5 off each other side.
|
||
s.run(
|
||
r#"
|
||
entered = false
|
||
local a = CreateFrame("Frame", "A")
|
||
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(100); a:SetHeight(100); a:EnableMouse(true)
|
||
a:SetScript("OnEnter", function() entered = true end)
|
||
a:SetHitRectInsets(5, 5, 18, 5)
|
||
local l, r, t, b = a:GetHitRectInsets()
|
||
assert(l == 5 and r == 5 and t == 18 and b == 5)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.resolve();
|
||
|
||
assert_eq!(s.eval::<f64>("return A:GetHeight()").unwrap(), 100.0);
|
||
assert_eq!(s.eval::<f64>("return A:GetTop()").unwrap(), 100.0);
|
||
|
||
assert!(
|
||
s.hit_test(50.0, 90.0).is_none(),
|
||
"a point in the inset header is outside the hit rect"
|
||
);
|
||
assert!(s.mouse_move(50.0, 90.0).is_none());
|
||
assert!(!s.eval::<bool>("return entered").unwrap());
|
||
assert!(s.hit_test(2.0, 50.0).is_none(), "left inset");
|
||
assert!(s.hit_test(98.0, 50.0).is_none(), "right inset");
|
||
assert!(s.hit_test(50.0, 2.0).is_none(), "bottom inset");
|
||
|
||
// Just below the header, inside the shrunken rect: captures.
|
||
assert!(s.hit_test(50.0, 80.0).is_some());
|
||
assert!(s.mouse_move(50.0, 80.0).is_some());
|
||
assert!(s.eval::<bool>("return entered").unwrap());
|
||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||
}
|
||
|
||
#[test]
|
||
fn hit_rect_insets_default_to_zero() {
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(r#"local a = CreateFrame("Frame", "Plain")"#).unwrap();
|
||
let (l, r, t, b) = s
|
||
.eval::<(f64, f64, f64, f64)>("return Plain:GetHitRectInsets()")
|
||
.unwrap();
|
||
assert_eq!((l, r, t, b), (0.0, 0.0, 0.0, 0.0), "no inset by default");
|
||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||
}
|
||
|
||
#[test]
|
||
fn the_mouse_wheel_flag_round_trips_and_the_scrolling_kinds_are_born_enabled() {
|
||
let s = script();
|
||
s.run(
|
||
r#"
|
||
Plain = CreateFrame("Frame", "WheelPlain")
|
||
Scroll = CreateFrame("ScrollFrame", "WheelScroll")
|
||
Msg = CreateFrame("ScrollingMessageFrame", "WheelMsg")
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
|
||
// A plain frame is born wheel-disabled, as in the reference; the two scrolling kinds' own
|
||
// constructors enable it.
|
||
assert!(!s
|
||
.eval::<bool>("return Plain:IsMouseWheelEnabled()")
|
||
.unwrap());
|
||
assert!(s
|
||
.eval::<bool>("return Scroll:IsMouseWheelEnabled()")
|
||
.unwrap());
|
||
assert!(s.eval::<bool>("return Msg:IsMouseWheelEnabled()").unwrap());
|
||
|
||
s.run("Plain:EnableMouseWheel(true)").unwrap();
|
||
assert!(s
|
||
.eval::<bool>("return Plain:IsMouseWheelEnabled()")
|
||
.unwrap());
|
||
s.run("Plain:EnableMouseWheel(false)").unwrap();
|
||
assert!(!s
|
||
.eval::<bool>("return Plain:IsMouseWheelEnabled()")
|
||
.unwrap());
|
||
// Lua truthiness, like every other flag binding: `EnableMouseWheel(1)` is on.
|
||
s.run("Plain:EnableMouseWheel(1)").unwrap();
|
||
assert!(s
|
||
.eval::<bool>("return Plain:IsMouseWheelEnabled()")
|
||
.unwrap());
|
||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||
}
|
||
|
||
/// The reference takes a closed-vocabulary value with stray whitespace: the zBar addon ships
|
||
/// `frameStrata="BACKGROUND "` (`zBar.xml:146`). Frame kind, strata and draw layer all trim.
|
||
#[test]
|
||
fn an_enum_attribute_tolerates_the_whitespace_a_real_addon_ships() {
|
||
let s = script();
|
||
s.run(
|
||
r#"
|
||
Padded = CreateFrame(" Frame ", "PaddedKind")
|
||
Padded:SetFrameStrata("BACKGROUND ")
|
||
Trailing = Padded:CreateTexture("PaddedTex", " OVERLAY")
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||
assert_eq!(
|
||
s.eval::<String>("return Padded:GetFrameStrata()").unwrap(),
|
||
"BACKGROUND",
|
||
"the frame took the strata, and reports it in the canonical spelling"
|
||
);
|
||
// The padded kind resolved: an unknown kind would have raised from `CreateFrame`.
|
||
assert!(s.eval::<bool>("return PaddedKind ~= nil").unwrap());
|
||
assert!(s.eval::<bool>("return PaddedTex ~= nil").unwrap());
|
||
}
|
||
|
||
// ── OnDoubleClick ──
|
||
//
|
||
// The interval is a fixed 300 ms (`0x77937b`, `cmp ecx, 0x12c`) and the only fire site is the
|
||
// mouse-up dispatcher `0x7792d0`. The double replaces the second `OnClick` (`0x77939d` jumps past
|
||
// it), a completed double zeroes the stamp so clicks pair up, the detector arms only when the frame
|
||
// has an `OnDoubleClick` script (`[+0x4d4] != 0`), and it keys on no button: `RegisterForClicks`
|
||
// is what confines it to the left button.
|
||
|
||
#[test]
|
||
fn a_second_fast_click_fires_on_double_click_instead_of_the_second_on_click() {
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
r#"
|
||
clicks, doubles, dblbtn = 0, 0, nil
|
||
local b = CreateFrame("Button", "DblB")
|
||
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(200); b:SetHeight(200); b:EnableMouse(true)
|
||
b:SetScript("OnClick", function(self, button) clicks = clicks + 1 end)
|
||
b:SetScript("OnDoubleClick", function(self, button) doubles = doubles + 1 dblbtn = button end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.resolve();
|
||
|
||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||
assert_eq!(
|
||
s.eval::<i64>("return clicks").unwrap(),
|
||
0,
|
||
"the default RegisterForClicks is LeftButtonUp — a press fires no click at all"
|
||
);
|
||
s.mouse_button(50.0, 50.0, "LeftButton", false);
|
||
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 1);
|
||
assert_eq!(
|
||
s.eval::<i64>("return doubles").unwrap(),
|
||
0,
|
||
"one click is not a double click"
|
||
);
|
||
|
||
// Click two, inside the 300 ms.
|
||
s.tick(0.1);
|
||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||
s.mouse_button(50.0, 50.0, "LeftButton", false);
|
||
assert_eq!(
|
||
s.eval::<i64>("return doubles").unwrap(),
|
||
1,
|
||
"the second click inside the interval is a double click"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return dblbtn").unwrap(),
|
||
"LeftButton",
|
||
"OnDoubleClick(self, button) — the same single button-name arg OnClick gets"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<i64>("return clicks").unwrap(),
|
||
1,
|
||
"…and it REPLACED the second OnClick — the two legs are exclusive"
|
||
);
|
||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||
}
|
||
|
||
/// The detector tests `[+0x4d4] != 0`, so a widget with no `OnDoubleClick` script always takes
|
||
/// the single-click leg.
|
||
#[test]
|
||
fn a_frame_with_no_double_click_handler_keeps_every_rapid_on_click() {
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
r#"
|
||
clicks = 0
|
||
local b = CreateFrame("Button", "NoDbl")
|
||
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(200); b:SetHeight(200); b:EnableMouse(true)
|
||
b:SetScript("OnClick", function() clicks = clicks + 1 end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.resolve();
|
||
for _ in 0..4 {
|
||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||
s.mouse_button(50.0, 50.0, "LeftButton", false);
|
||
}
|
||
assert_eq!(
|
||
s.eval::<i64>("return clicks").unwrap(),
|
||
4,
|
||
"no OnDoubleClick script ⇒ four rapid clicks are four clicks"
|
||
);
|
||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||
}
|
||
|
||
#[test]
|
||
fn the_double_click_boundary_is_time_and_frame_and_clicks_pair_up() {
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
r#"
|
||
clicks, doubles = 0, 0
|
||
local function watch(f)
|
||
f:SetScript("OnClick", function() clicks = clicks + 1 end)
|
||
f:SetScript("OnDoubleClick", function() doubles = doubles + 1 end)
|
||
end
|
||
local a = CreateFrame("Button", "DblA")
|
||
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(100); a:SetHeight(100); a:EnableMouse(true); watch(a)
|
||
local b = CreateFrame("Button", "DblBB")
|
||
b:SetPoint("BOTTOMLEFT", 300, 0); b:SetWidth(100); b:SetHeight(100); b:EnableMouse(true); watch(b)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.resolve();
|
||
let click = |s: &mut crate::script::UiScript, x: f32| {
|
||
s.mouse_button(x, 50.0, "LeftButton", true);
|
||
s.mouse_button(x, 50.0, "LeftButton", false);
|
||
};
|
||
|
||
// (1) Too slow: 0.35 s is past the 300 ms, so the second click starts afresh.
|
||
click(&mut s, 50.0);
|
||
s.tick(0.35);
|
||
click(&mut s, 50.0);
|
||
assert_eq!(s.eval::<i64>("return doubles").unwrap(), 0);
|
||
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 2);
|
||
|
||
// (2) A different frame: the stamp lives on the widget, so B has its own.
|
||
s.run("clicks, doubles = 0, 0").unwrap();
|
||
s.tick(1.0);
|
||
click(&mut s, 50.0);
|
||
click(&mut s, 350.0);
|
||
assert_eq!(s.eval::<i64>("return doubles").unwrap(), 0);
|
||
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 2);
|
||
|
||
// (3) Pairing: four rapid clicks on one frame are click, double, click, double.
|
||
s.run("clicks, doubles = 0, 0").unwrap();
|
||
s.tick(1.0);
|
||
for _ in 0..4 {
|
||
click(&mut s, 50.0);
|
||
}
|
||
assert_eq!(
|
||
(
|
||
s.eval::<i64>("return clicks").unwrap(),
|
||
s.eval::<i64>("return doubles").unwrap()
|
||
),
|
||
(2, 2),
|
||
"clicks pair up — the completed double zeroes the stamp, so there is no triple-click"
|
||
);
|
||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||
}
|
||
|
||
/// `[CButton+0x334]` is a bare timestamp, so a button registered for two `…Up` types completes a
|
||
/// double click across them, and `arg1` is the second click's button.
|
||
#[test]
|
||
fn a_multi_registered_button_pairs_a_left_click_with_a_right_one() {
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
r#"
|
||
doubles, dblbtn = 0, nil
|
||
local b = CreateFrame("Button", "MixDbl")
|
||
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(200); b:SetHeight(200); b:EnableMouse(true)
|
||
b:RegisterForClicks("LeftButtonUp", "RightButtonUp")
|
||
b:SetScript("OnDoubleClick", function(self, button) doubles = doubles + 1 dblbtn = button end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.resolve();
|
||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||
s.mouse_button(50.0, 50.0, "LeftButton", false);
|
||
s.tick(0.1);
|
||
s.mouse_button(50.0, 50.0, "RightButton", true);
|
||
s.mouse_button(50.0, 50.0, "RightButton", false);
|
||
assert_eq!(s.eval::<i64>("return doubles").unwrap(), 1);
|
||
assert_eq!(
|
||
s.eval::<String>("return dblbtn").unwrap(),
|
||
"RightButton",
|
||
"arg1 is the button of the completing click, not of the one that armed it"
|
||
);
|
||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||
}
|
||
|
||
/// Nothing clears a half-finished double click, not hide, disable or the cursor leaving the
|
||
/// window: none of `[+0x334]`'s three writers is one of those, so
|
||
/// [`crate::script::UiScript::pointer_left_window`] must not reset it.
|
||
#[test]
|
||
fn a_half_finished_double_click_survives_the_cursor_leaving_the_window() {
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
r#"
|
||
doubles = 0
|
||
local b = CreateFrame("Button", "LeaveDbl")
|
||
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(200); b:SetHeight(200); b:EnableMouse(true)
|
||
b:SetScript("OnDoubleClick", function() doubles = doubles + 1 end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.resolve();
|
||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||
s.mouse_button(50.0, 50.0, "LeftButton", false);
|
||
s.pointer_left_window();
|
||
s.tick(0.1);
|
||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||
s.mouse_button(50.0, 50.0, "LeftButton", false);
|
||
assert_eq!(
|
||
s.eval::<i64>("return doubles").unwrap(),
|
||
1,
|
||
"the pointer left and came back inside 300 ms — the reference still pairs them"
|
||
);
|
||
}
|
||
|
||
/// A name in `SCRIPT_KINDS` must have something that fires it, so this asserts the fire.
|
||
#[test]
|
||
fn set_script_on_double_click_is_accepted_because_something_fires_it() {
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
r#"
|
||
ran = false
|
||
Btn = CreateFrame("Button", "DblAccepted")
|
||
Btn:SetPoint("BOTTOMLEFT", 0, 0); Btn:SetWidth(100); Btn:SetHeight(100); Btn:EnableMouse(true)
|
||
Btn:SetScript("OnDoubleClick", function() ran = true end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
assert!(
|
||
s.eval::<bool>(r#"return Btn:GetScript("OnDoubleClick") ~= nil"#)
|
||
.unwrap(),
|
||
"SetScript stored it"
|
||
);
|
||
s.resolve();
|
||
for _ in 0..2 {
|
||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||
s.mouse_button(50.0, 50.0, "LeftButton", false);
|
||
}
|
||
assert!(s.eval::<bool>("return ran").unwrap(), "…and it FIRED");
|
||
}
|
||
|
||
/// Script names nothing fires still raise; a name leaves this list only when something fires it.
|
||
#[test]
|
||
fn the_unfired_script_kinds_still_raise_rather_than_silently_accepting() {
|
||
let s = script();
|
||
s.run(r#"Raiser = CreateFrame("EditBox", "RaiserBox")"#)
|
||
.unwrap();
|
||
for name in [
|
||
// A 2.0 secure-frame slot; no 1.12 resolver has it.
|
||
"OnAttributeChanged",
|
||
// Real 1.12 slots that benilla does not fire. `OnHyperlinkEnter`/`Leave` left this list
|
||
// when `pointer::update_hyperlink_hover` started firing them off the rasterized link
|
||
// spans — which is the rule this test states, not an exception to it.
|
||
"OnMessageScrollChanged",
|
||
"OnInputLanguageChanged",
|
||
] {
|
||
let err = s
|
||
.run(&format!(r#"Raiser:SetScript("{name}", function() end)"#))
|
||
.unwrap_err();
|
||
assert!(
|
||
err.to_string().contains(name),
|
||
"{name} must raise by name, got {err}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Hiding the hovered frame fires its `OnLeave` inside the hide, before its `OnHide` (`0x764ba0`'s
|
||
/// removal tail, the leave at `0x764cce`), and an ancestor's `Hide()` reaches it by the cascade.
|
||
#[test]
|
||
fn hiding_the_hovered_frame_fires_its_onleave_before_onhide() {
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
r#"
|
||
log = {}
|
||
local win = CreateFrame("Frame", "Win")
|
||
win:SetPoint("BOTTOMLEFT", 0, 0); win:SetWidth(800); win:SetHeight(600)
|
||
local slot = CreateFrame("Button", "Slot", win)
|
||
slot:SetPoint("BOTTOMLEFT", 100, 100); slot:SetWidth(100); slot:SetHeight(100); slot:EnableMouse(true)
|
||
slot:SetScript("OnLeave", function() table.insert(log, "leave") end)
|
||
slot:SetScript("OnHide", function() table.insert(log, "hide") end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.resolve();
|
||
s.mouse_move(150.0, 150.0); // hover the slot
|
||
s.run("Win:Hide()").unwrap(); // the ancestor hides; the cascade reaches the slot
|
||
assert_eq!(
|
||
s.eval::<String>("return table.concat(log, ',')").unwrap(),
|
||
"leave,hide",
|
||
"OnLeave fires synchronously inside the hide, before the frame's own OnHide"
|
||
);
|
||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||
}
|
||
|
||
/// `0x764cce` dispatches the leave virtually (`[edx+0x50]`), so a disabled Button's own guard
|
||
/// (`0x7794e0`) swallows it; `OnHide` is not the hover notify and fires either way.
|
||
#[test]
|
||
fn hiding_a_hovered_disabled_button_fires_onhide_but_no_onleave() {
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
r#"
|
||
log = {}
|
||
local slot = CreateFrame("Button", "Slot")
|
||
slot:SetPoint("BOTTOMLEFT", 100, 100); slot:SetWidth(100); slot:SetHeight(100); slot:EnableMouse(true)
|
||
slot:SetScript("OnLeave", function() table.insert(log, "leave") end)
|
||
slot:SetScript("OnHide", function() table.insert(log, "hide") end)
|
||
slot:Disable()
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.resolve();
|
||
s.mouse_move(150.0, 150.0);
|
||
s.run("Slot:Hide()").unwrap();
|
||
assert_eq!(
|
||
s.eval::<String>("return table.concat(log, ',')").unwrap(),
|
||
"hide",
|
||
"the removal tail reaches the button's own leave notify, which is DISABLED-gated"
|
||
);
|
||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||
}
|
||
|
||
/// The reference's removal tail acts only when the removed frame is the cached hover.
|
||
#[test]
|
||
fn hiding_an_unhovered_frame_fires_no_onleave() {
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
r#"
|
||
leaves = 0
|
||
local a = CreateFrame("Button", "Away")
|
||
a:SetPoint("BOTTOMLEFT", 500, 500); a:SetWidth(50); a:SetHeight(50); a:EnableMouse(true)
|
||
a:SetScript("OnLeave", function() leaves = leaves + 1 end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.resolve();
|
||
s.mouse_move(10.0, 10.0); // nowhere near it
|
||
s.run("Away:Hide()").unwrap();
|
||
assert_eq!(s.eval::<f64>("return leaves").unwrap(), 0.0);
|
||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||
}
|
||
|
||
/// After the hovered frame hides, the next tick re-hovers what is topmost at the unmoved cursor
|
||
/// (`[root+0x1100]`, the pump tail `0x7660d0` on the saved event); a frame shown under the cursor
|
||
/// is hovered the same way (`0x764b8d`).
|
||
#[test]
|
||
fn the_repick_re_hovers_without_a_mouse_move() {
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
r#"
|
||
entered = {}
|
||
local under = CreateFrame("Button", "Under")
|
||
under:SetPoint("BOTTOMLEFT", 100, 100); under:SetWidth(100); under:SetHeight(100); under:EnableMouse(true)
|
||
under:SetScript("OnEnter", function() table.insert(entered, "under") end)
|
||
local over = CreateFrame("Button", "Over")
|
||
over:SetPoint("BOTTOMLEFT", 100, 100); over:SetWidth(100); over:SetHeight(100); over:EnableMouse(true)
|
||
over:SetScript("OnEnter", function() table.insert(entered, "over") end)
|
||
-- Say "on top" with the LEVEL, not with declaration order: at equal levels the sweep takes
|
||
-- the earlier-linked frame (`Under`), which is the tie law, not what this test is about.
|
||
over:SetFrameLevel(10)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.resolve();
|
||
s.mouse_move(150.0, 150.0); // hovers Over (topmost)
|
||
s.run("Over:Hide()").unwrap();
|
||
s.tick(0.016); // the pump's re-pick; the cursor has not moved
|
||
assert_eq!(
|
||
s.eval::<String>("return table.concat(entered, ',')")
|
||
.unwrap(),
|
||
"over,under",
|
||
"the frame beneath is entered on the next tick with no mouse move"
|
||
);
|
||
s.run("Over:Show()").unwrap();
|
||
s.tick(0.016);
|
||
assert_eq!(
|
||
s.eval::<String>("return table.concat(entered, ',')")
|
||
.unwrap(),
|
||
"over,under,over",
|
||
"a frame shown under the cursor is entered on the next tick"
|
||
);
|
||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||
}
|
||
|
||
// ── The mouse-up dispatch (`0x766420`) ──
|
||
|
||
/// Two side-by-side frames, each recording every `OnMouseDown`/`OnMouseUp` it gets.
|
||
fn two_frames() -> crate::script::UiScript {
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
r#"
|
||
log = {}
|
||
local function mk(name, x)
|
||
local f = CreateFrame("Frame", name)
|
||
f:SetPoint("BOTTOMLEFT", x, 200); f:SetWidth(200); f:SetHeight(200); f:EnableMouse(true)
|
||
f:SetScript("OnMouseDown", function(self) table.insert(log, self:GetName()..":down") end)
|
||
f:SetScript("OnMouseUp", function(self) table.insert(log, self:GetName()..":up") end)
|
||
return f
|
||
end
|
||
mk("A", 100)
|
||
mk("B", 400)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.resolve();
|
||
s
|
||
}
|
||
|
||
fn log_of(s: &mut crate::script::UiScript) -> String {
|
||
s.eval::<String>("return table.concat(log, ' ')").unwrap()
|
||
}
|
||
|
||
/// `0x766420` reads the capture `[mgr+0x80]` at entry (`0x76642b`) and never the hover
|
||
/// `[mgr+0x7c]`, so the release reaches the pressed frame wherever the cursor is; a chat resize
|
||
/// grip's `FCF_StopResize` relies on it (`FloatingChatFrame.xml:292`).
|
||
#[test]
|
||
fn the_release_goes_to_the_frame_that_took_the_press() {
|
||
let mut s = two_frames();
|
||
s.mouse_button(200.0, 300.0, "LeftButton", true); // press on A
|
||
s.mouse_move(500.0, 300.0); // ... cursor wanders onto B
|
||
s.mouse_button(500.0, 300.0, "LeftButton", false); // ... and releases there
|
||
assert_eq!(
|
||
log_of(&mut s),
|
||
"A:down A:up",
|
||
"the capture takes the release; B gets nothing on that edge"
|
||
);
|
||
}
|
||
|
||
/// The down handler falls back to the hover (`root+0x80` else `root+0x7c`), the up handler does
|
||
/// not: with no capture, `0x766498` jumps to `0x7664cd` and dispatches nothing.
|
||
#[test]
|
||
fn a_press_that_captured_nothing_delivers_no_release() {
|
||
let mut s = two_frames();
|
||
s.mouse_button(50.0, 50.0, "LeftButton", true); // press over open space
|
||
s.mouse_move(500.0, 300.0); // ... cursor onto B
|
||
s.mouse_button(500.0, 300.0, "LeftButton", false);
|
||
assert_eq!(
|
||
log_of(&mut s),
|
||
"",
|
||
"no capture, no dispatch — the UP handler has no hover arm"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_press_and_release_on_one_frame_is_unchanged() {
|
||
let mut s = two_frames();
|
||
s.mouse_button(200.0, 300.0, "LeftButton", true);
|
||
s.mouse_button(200.0, 300.0, "LeftButton", false);
|
||
assert_eq!(log_of(&mut s), "A:down A:up");
|
||
}
|
||
|
||
/// `Model::mouse_down_on` is keyed by button name, so the capture is per button.
|
||
#[test]
|
||
fn the_capture_is_per_button() {
|
||
let mut s = two_frames();
|
||
s.mouse_button(200.0, 300.0, "LeftButton", true); // A holds the left button
|
||
s.mouse_move(500.0, 300.0);
|
||
s.mouse_button(500.0, 300.0, "RightButton", false); // a right release, never pressed
|
||
assert_eq!(
|
||
log_of(&mut s),
|
||
"A:down",
|
||
"the right button captured nothing, so its release dispatches nothing"
|
||
);
|
||
}
|
||
|
||
/// A disabled Button fires neither `OnEnter` nor `OnLeave`: `CSimpleButton::OnEnter` (`0x779490`)
|
||
/// and `OnLeave` (`0x7794e0`) test `[esi+0x328]` and skip the base notify (`0x76b6a0`,
|
||
/// `0x76b6f0`). It still takes the hover: the walk sets `[root+0x7c]` before the notify.
|
||
#[test]
|
||
fn a_disabled_button_takes_the_hover_but_fires_no_enter_or_leave() {
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
r#"
|
||
log = {}
|
||
local function mk(name, x)
|
||
local b = CreateFrame("Button", name)
|
||
b:SetPoint("BOTTOMLEFT", x, 0); b:SetWidth(100); b:SetHeight(100); b:EnableMouse(true)
|
||
b:SetScript("OnEnter", function(self) table.insert(log, self:GetName()..":enter") end)
|
||
b:SetScript("OnLeave", function(self) table.insert(log, self:GetName()..":leave") end)
|
||
return b
|
||
end
|
||
Off = mk("Off", 0)
|
||
On = mk("On", 200)
|
||
Off:Disable()
|
||
-- The guard the addons actually wrote, and why it cannot be theirs: a NUMBER, never nil.
|
||
assert(Off:IsEnabled() == 0 and On:IsEnabled() == 1)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.resolve();
|
||
|
||
let hit = s.mouse_move(50.0, 50.0);
|
||
assert!(hit.is_some(), "a disabled button still captures the point");
|
||
assert_eq!(
|
||
s.eval::<String>("return GetMouseFocus():GetName()")
|
||
.unwrap(),
|
||
"Off",
|
||
"the hover target moves before the notify — a disabled button IS the mouse focus"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return table.concat(log, ',')").unwrap(),
|
||
"",
|
||
"entering a disabled button fires nothing"
|
||
);
|
||
|
||
s.mouse_move(250.0, 50.0);
|
||
assert_eq!(
|
||
s.eval::<String>("return table.concat(log, ',')").unwrap(),
|
||
"On:enter",
|
||
"no leave from the disabled button; the enabled one notifies normally"
|
||
);
|
||
|
||
// Disabled while hovered: the leave is gated on the state at leave time.
|
||
s.run("On:Disable()").unwrap();
|
||
s.mouse_move(400.0, 50.0);
|
||
assert_eq!(
|
||
s.eval::<String>("return table.concat(log, ',')").unwrap(),
|
||
"On:enter",
|
||
"a button disabled under the cursor swallows its own leave"
|
||
);
|
||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||
}
|
||
|
||
/// **pfUI's nameplate right-click emulator, transcribed** (`modules/nameplates.lua:1899-1934`),
|
||
/// the one corpus caller of the mouse-look trio. The emulator exists because a right-press on a
|
||
/// plate frame is ambiguous — it may be the start of a camera drag or a right-click — and it
|
||
/// resolves it by starting a look and then asking whether one is running.
|
||
///
|
||
/// The property under test is the *arming*: a nil `MouselookStart` aborted `OnMouseDown` before it
|
||
/// recorded the frame and the timestamp, so the `OnUpdate` body below never ran and right-clicking
|
||
/// a pfUI nameplate did nothing at all. This fails against that absence, and against an
|
||
/// `IsMouselooking` that answers truthy for a press the world never owned — either way the
|
||
/// synthesised click never happens.
|
||
#[test]
|
||
fn pfuis_nameplate_right_click_emulator_arms_and_then_synthesises_the_click() {
|
||
let mut s = script();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
r#"
|
||
clicked, attacked = 0, 0
|
||
AttackTarget = function() attacked = attacked + 1 end
|
||
local plate = CreateFrame("Button", "pfPlate")
|
||
plate:SetPoint("BOTTOMLEFT", 0, 0); plate:SetWidth(800); plate:SetHeight(600)
|
||
plate:EnableMouse(true)
|
||
plate:RegisterForClicks("LeftButtonUp", "RightButtonUp")
|
||
plate:SetScript("OnClick", function() clicked = clicked + 1 end)
|
||
|
||
look = CreateFrame("Frame", nil, nil)
|
||
look.time, look.frame = nil, nil
|
||
plate:SetScript("OnMouseDown", function()
|
||
if arg1 and arg1 == "RightButton" then
|
||
MouselookStart()
|
||
look.time = GetTime()
|
||
look.frame = this
|
||
end
|
||
end)
|
||
-- `:1917-1934`, with the 0.5 s `clickthreshold` inlined.
|
||
function PollLook(now)
|
||
if not look.time or not look.frame then return end
|
||
if not IsMouselooking() and look.time + 0.5 < now then look.time = nil return end
|
||
if not IsMouselooking() then
|
||
look.frame:Click("LeftButton")
|
||
if UnitCanAttack("player", "target") then AttackTarget() end
|
||
look.time = nil
|
||
end
|
||
end
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
|
||
s.resolve();
|
||
s.mouse_move(400.0, 300.0);
|
||
s.mouse_button(400.0, 300.0, "RightButton", true);
|
||
assert!(
|
||
s.errors().is_empty(),
|
||
"the press must not raise: {:?}",
|
||
s.errors()
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("return look.frame ~= nil and look.time ~= nil")
|
||
.unwrap(),
|
||
"OnMouseDown ran past MouselookStart and armed the poll"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<Option<i64>>("return IsMouselooking()").unwrap(),
|
||
None,
|
||
"a press the plate captured is a press the world never owned, so no look is running"
|
||
);
|
||
|
||
s.run("PollLook(GetTime())").unwrap();
|
||
assert_eq!(
|
||
s.eval::<i64>("return clicked").unwrap(),
|
||
1,
|
||
"the emulated click fired"
|
||
);
|
||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||
}
|