diff --git a/crates/benilla-ui/src/script/cursor/drag.rs b/crates/benilla-ui/src/script/cursor/drag.rs index caf3cfc2..f3a2635c 100644 --- a/crates/benilla-ui/src/script/cursor/drag.rs +++ b/crates/benilla-ui/src/script/cursor/drag.rs @@ -473,6 +473,45 @@ mod tests { assert!(s.cursor_item().is_some()); } + /// A press whose frame hid (the removal cleared the capture, `0x764cad`) reaches no one on the + /// release: `0x766420` dispatches to the capture alone, and the world hook `0x492b50` runs + /// only in freelook, so the world click `0x495300` never runs. + #[test] + fn a_press_whose_frame_hid_is_not_a_world_drop() { + let mut s = drag_script(); + s.run( + r#" + WorldFrame = CreateFrame("WorldFrame", "WorldFrame") WorldFrame:SetAllPoints() + heard = 0 + local a = CreateFrame("Frame", "A") + a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(400); a:SetHeight(600); a:EnableMouse(true) + a:SetScript("OnMouseDown", function() A:Hide() end) + local f = CreateFrame("Frame", "Listener") + f:RegisterEvent("DELETE_ITEM_CONFIRM") + f:SetScript("OnEvent", function() heard = heard + 1 end) + "#, + ) + .unwrap(); + s.resolve(); + s.set_cursor_for_test(CursorPayload::Item(CursorItem { + bar_placeable: true, + bag: 0, + slot: 1, + item_id: 117, + texture: None, + link: None, + count: None, + quality: None, + equip_slots: Vec::new(), + })); + + s.mouse_button(100.0, 300.0, "LeftButton", true); // press on A, which hides itself + s.mouse_button(600.0, 300.0, "LeftButton", false); // release over the world + s.tick(0.01); + assert_eq!(s.eval::("return heard").unwrap(), 0, "no popup"); + assert!(s.cursor_item().is_some()); + } + /// A non-item payload's world click clears it silently (`0x495300`); a drag release keeps it. #[test] fn world_click_with_a_spell_payload_clears_silently() { diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index e12857a2..3b4e9a14 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -1008,7 +1008,8 @@ impl UiScript { self.fire_drag_stop(source); } let mut model = self.model_mut(); - let held: Vec = model.mouse_down_on.values().copied().collect(); + let held: Vec = + model.mouse_down_on.values().copied().flatten().collect(); model.mouse_down_on.clear(); // Every held button gets the release edge never fed (`0x7793de`), else it stays pushed. for h in held { diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index d63c5ac0..57cece19 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -204,12 +204,15 @@ pub(crate) struct Model { /// The mouse-enabled frame under the cursor, for the next move's `OnLeave`/`OnEnter`. pub(crate) mouseover: Option, - /// A frame under a still cursor hid or showed, so the tick re-runs the hover walk at the saved - /// position and a newly exposed frame gets `OnEnter` without a mouse move: the reference's - /// `[root+0x1100]` flag (set at `0x764cbb`/`0x764b8d`, pumped at `0x7657a1` into `0x7660d0`). + /// The hovered frame hid, or a mouse-enabled frame showed, so the tick re-runs the hover walk + /// at the saved position and a newly exposed frame gets `OnEnter` without a mouse move: the + /// reference's `[root+0x1100]` flag (set at `0x764cbb`/`0x764b8d`, pumped at `0x7657a1` into + /// `0x7660d0`). pub(crate) hover_repick: bool, - /// Per button, the frame its press landed on, for `OnClick`'s same-frame release test. - pub(crate) mouse_down_on: HashMap, + /// Per button held, the frame its press landed on, for `OnClick`'s same-frame release test; + /// `None` once that frame hid (the removal tail emptied the capture), so the release has no + /// target but the button still counts as held. + pub(crate) mouse_down_on: HashMap>, /// The client's one mouse-capture slot (`root+0x80`), read by the mouse-down raise. Set at /// `0x7663e6` to the capture, else the hovered frame, so a chorded press keeps the capture; /// cleared at `0x7664bb` once no button is down. A title-region press captures nothing. diff --git a/crates/benilla-ui/src/script/pointer.rs b/crates/benilla-ui/src/script/pointer.rs index 4de32da4..6de75ad2 100644 --- a/crates/benilla-ui/src/script/pointer.rs +++ b/crates/benilla-ui/src/script/pointer.rs @@ -313,7 +313,9 @@ impl UiScript { model .mouse_down_on .get(button) - .and_then(|h| model.frame_to_id.get(h).copied()) + .copied() + .flatten() + .and_then(|h| model.frame_to_id.get(&h).copied()) }; // The `GetTime()` clock for the double-click detector, read before the borrow. let now = self.now(); @@ -347,9 +349,10 @@ impl UiScript { let hit_handle = hit_id.and_then(|id| model.id_to_frame.get(&id).copied()); if down { let displaced = match hit_handle { - Some(h) => model.mouse_down_on.insert(button.to_string(), h), + Some(h) => model.mouse_down_on.insert(button.to_string(), Some(h)), None => model.mouse_down_on.remove(button), - }; + } + .flatten(); // The press edge, `SetButtonState(PUSHED)` (`0x7792b1`), for a button registered // for this mouse button up or down (`0x779210` tests `m | m << 8` at `0x77924b`). if let Some(h) = hit_handle.filter(|&h| button::wants_press_visual(&model, h, button)) @@ -389,7 +392,11 @@ impl UiScript { .and(hit_id); (click, None, false, jump, None, abandoned, color_jump) } else { - let pressed = model.mouse_down_on.remove(button); + let entry = model.mouse_down_on.remove(button); + let pressed = entry.flatten(); + // The pressed frame hid (`None` entry): the capture is gone, so nothing of this + // release runs, the world click included (`over_world(None)` would take it). + let capture_lost = entry == Some(None); // `0x7664bb` clears the capture only when no button is still held, so a chorded // release keeps it. if model.mouse_down_on.is_empty() { @@ -418,6 +425,7 @@ impl UiScript { && !started && over_world(&model, hit_handle) && over_world(&model, pressed) + && !capture_lost && cursor::world_drop_click(&mut model); let wants = format!("{button}Up"); let click = if started { diff --git a/crates/benilla-ui/src/script/tests/input.rs b/crates/benilla-ui/src/script/tests/input.rs index 10c54512..fbd8de30 100644 --- a/crates/benilla-ui/src/script/tests/input.rs +++ b/crates/benilla-ui/src/script/tests/input.rs @@ -793,6 +793,59 @@ fn a_press_and_release_on_one_frame_is_unchanged() { assert_eq!(log_of(&mut s), "A:down A:up"); } +/// Hiding a frame runs the input-index removal (`0x764ba0`), whose mouse tail clears the capture +/// when the frame holds it (`0x764ca5`, `0x764cad`), and `0x766420` delivers `OnMouseUp` to the +/// capture alone: a frame hidden while pressed gets no `OnMouseUp` on the release, hidden still +/// or shown again. +#[test] +fn a_frame_hidden_while_pressed_gets_no_release() { + let mut s = two_frames(); + s.run(r#"A:SetScript("OnMouseDown", function() table.insert(log, "A:down") A:Hide() end)"#) + .unwrap(); + s.mouse_button(200.0, 300.0, "LeftButton", true); + assert!( + s.model_ref().mouse_capture.is_none(), + "the hide cleared the capture slot" + ); + s.run("A:Show()").unwrap(); // shown again before the release: the capture stays lost + s.mouse_button(200.0, 300.0, "LeftButton", false); + assert_eq!(log_of(&mut s), "A:down"); +} + +/// The removal runs for a frame that hid because an ancestor did (`0x76ad50` walks its children +/// through slot `+0x84`, each gated on its own effective visibility). +#[test] +fn a_frame_whose_ancestor_hid_while_pressed_gets_no_release() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + log = {} + local p = CreateFrame("Frame", "P") + p:SetPoint("BOTTOMLEFT", 0, 0); p:SetWidth(800); p:SetHeight(600) + local c = CreateFrame("Frame", "C", p) + c:SetPoint("BOTTOMLEFT", 100, 200); c:SetWidth(200); c:SetHeight(200); c:EnableMouse(true) + c:SetScript("OnMouseDown", function() table.insert(log, "C:down") P:Hide() end) + c:SetScript("OnMouseUp", function() table.insert(log, "C:up") end) + "#, + ) + .unwrap(); + s.resolve(); + s.mouse_button(200.0, 300.0, "LeftButton", true); + s.mouse_button(200.0, 300.0, "LeftButton", false); + assert_eq!(log_of(&mut s), "C:down"); +} + +/// Only the held frame loses the press: hiding another frame leaves the release on its target. +#[test] +fn hiding_another_frame_keeps_the_press() { + let mut s = two_frames(); + s.mouse_button(200.0, 300.0, "LeftButton", true); + s.run("B:Hide()").unwrap(); + 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() { @@ -865,3 +918,109 @@ fn a_disabled_button_takes_the_hover_but_fires_no_enter_or_leave() { ); assert!(s.errors().is_empty(), "{:?}", s.errors()); } + +// ── The hover re-pick on a show (`0x764b8d`) ── + +/// A mouse-enabled `Mover` hidden-cursor setup: the cursor rests at (150, 150) over nothing, then +/// `Mover` is moved under it with no mouse move. Whether the next tick enters it is whether a +/// re-pick was armed. +fn mover_under_a_still_cursor() -> crate::script::UiScript { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + entered = {} + Mover = CreateFrame("Frame", "Mover") + Mover:SetPoint("BOTTOMLEFT", 500, 400); Mover:SetWidth(100); Mover:SetHeight(100) + Mover:EnableMouse(true) + Mover:SetScript("OnEnter", function() table.insert(entered, "mover") end) + + Plain = CreateFrame("Frame", "Plain") + Plain:SetPoint("BOTTOMLEFT", 0, 0); Plain:SetWidth(10); Plain:SetHeight(10) + Plain:Hide() + Plain:SetScript("OnShow", function() table.insert(entered, "plain-shown") end) + + PlainParent = CreateFrame("Frame", "PlainParent") + PlainParent:Hide() + PlainKid = CreateFrame("Frame", "PlainKid", PlainParent) + PlainKid:SetPoint("BOTTOMLEFT", 0, 0); PlainKid:SetWidth(10); PlainKid:SetHeight(10) + + Shown = CreateFrame("Frame", "Shown") + Shown:SetPoint("BOTTOMLEFT", 0, 0); Shown:SetWidth(10); Shown:SetHeight(10) + + Taker = CreateFrame("Frame", "Taker") + Taker:SetPoint("BOTTOMLEFT", 0, 0); Taker:SetWidth(10); Taker:SetHeight(10) + Taker:EnableMouse(true) + Taker:Hide() + + MouseParent = CreateFrame("Frame", "MouseParent") + MouseParent:Hide() + MouseKid = CreateFrame("Frame", "MouseKid", MouseParent) + MouseKid:SetPoint("BOTTOMLEFT", 0, 0); MouseKid:SetWidth(10); MouseKid:SetHeight(10) + MouseKid:EnableMouse(true) + "#, + ) + .unwrap(); + s.resolve(); + s.mouse_move(150.0, 150.0); // over nothing + s.tick(0.016); // drains whatever the setup armed + s.run( + r#" + Mover:ClearAllPoints() + Mover:SetPoint("BOTTOMLEFT", 100, 100) + "#, + ) + .unwrap(); + s.resolve(); + s +} + +fn entered(s: &mut crate::script::UiScript) -> String { + s.eval::("return table.concat(entered, ',')") + .unwrap() +} + +/// A show arms the re-pick only through the input-index insert, which a frame reaches only with +/// its mouse bit set (`0x76afa0` tests `[frame+0xcc]` per kind), so showing frames that take no +/// mouse input leaves the hover alone until the mouse moves. +#[test] +fn showing_a_frame_that_takes_no_mouse_does_not_re_pick() { + let mut s = mover_under_a_still_cursor(); + s.run("Plain:Show() PlainParent:Show()").unwrap(); + s.tick(0.016); + assert_eq!( + entered(&mut s), + "plain-shown", + "no mouse-enabled frame showed, so no re-pick: Mover is entered on the next move" + ); + s.mouse_move(151.0, 150.0); + assert_eq!(entered(&mut s), "plain-shown,mover"); +} + +/// `EnableMouse(true)` on a shown frame inserts with flag -1 (`0x76af00`), which does not arm it. +#[test] +fn enabling_the_mouse_on_a_shown_frame_does_not_re_pick() { + let mut s = mover_under_a_still_cursor(); + s.run("Shown:EnableMouse(true)").unwrap(); + s.tick(0.016); + assert_eq!(entered(&mut s), ""); +} + +/// A shown frame with its mouse bit set enters the index with flag 0 and arms the re-pick. +#[test] +fn showing_a_mouse_enabled_frame_re_picks() { + let mut s = mover_under_a_still_cursor(); + s.run("Taker:Show()").unwrap(); + s.tick(0.016); + assert_eq!(entered(&mut s), "mover"); +} + +/// A shown parent's children go through their own transitions (`0x76aed5`, slot `+0x88`), so a +/// mouse-enabled child arms the re-pick whatever its parent takes. +#[test] +fn showing_a_parent_re_picks_through_its_mouse_enabled_child() { + let mut s = mover_under_a_still_cursor(); + s.run("MouseParent:Show()").unwrap(); + s.tick(0.016); + assert_eq!(entered(&mut s), "mover"); +} diff --git a/crates/benilla-ui/src/script/visibility.rs b/crates/benilla-ui/src/script/visibility.rs index 2b11ff32..73d08f0b 100644 --- a/crates/benilla-ui/src/script/visibility.rs +++ b/crates/benilla-ui/src/script/visibility.rs @@ -31,8 +31,14 @@ pub(super) fn show(lua: &Lua, h: FrameHandle) { if !model.arena.show_step(h) { return; } - // Entering the mouse index schedules a hover re-pick (`0x764b8d`). - model.hover_repick = true; + // Only a frame entering the mouse index schedules a hover re-pick: the show's insert + // (`0x764780` → `0x76afa0`, flag 0) reaches `0x764aa0` for a kind whose bit is set, and + // its tail arms `[root+0x1100]` for the mouse kind alone (`0x764b84`, `0x764b8d`). A + // frame that takes no mouse arms nothing; a parent's mouse-enabled children arm it + // through their own transitions below. + if takes_mouse(&model, h) { + model.hover_repick = true; + } } walk_children(lua, h, show); super::object::toplevel::raise_on_show(&mut model_mut(lua), h); @@ -47,6 +53,7 @@ pub(super) fn hide(lua: &Lua, h: FrameHandle) { if !model.arena.hide_step(h) { return; } + drop_capture(&mut model, h); leave_if_hovered(&mut model, h) }; if let Some(id) = left { @@ -75,6 +82,32 @@ fn walk_children(lua: &Lua, parent: FrameHandle, step: fn(&Lua, FrameHandle)) { } } +/// Whether the frame is in the mouse input index when visible: its mouse bit (`[+0xcc]` bit 2), +/// or a hyperlink span, which the reference gives a mouse-enabled child of its own (`0x7a3240`). +fn takes_mouse(model: &Model, h: FrameHandle) -> bool { + model.arena.is_mouse_enabled(h) + || model + .link_spans + .get(&h) + .is_some_and(|spans| !spans.is_empty()) +} + +/// The removal tail's capture clear (`0x764ca5`–`0x764cad`): a frame leaving the mouse index +/// that holds the capture slot (`root+0x80`) empties it. `0x766420` delivers a release to the +/// capture alone, so the press this frame took reaches no one: no `OnMouseUp`, no click, and no +/// world click, since the world hook `0x492b50` runs only in freelook. The button stays held +/// until its release, which then finds nothing. +fn drop_capture(model: &mut Model, h: FrameHandle) { + if model.mouse_capture == Some(h) { + model.mouse_capture = None; + } + for pressed in model.mouse_down_on.values_mut() { + if *pressed == Some(h) { + *pressed = None; + } + } +} + /// The hide's de-registration (`0x764920`, at `0x76ad9d`, before the child walk) ends a hover on /// this frame: the removal tail (`0x764ba0`) clears the hover, schedules a re-pick (`0x764cbb`) /// and calls the leave notify with 1 (`0x764cce`), whose base `0x76b6f0` clears the drag-arm