diff --git a/crates/benilla-ui/src/script/cursor.rs b/crates/benilla-ui/src/script/cursor.rs index c055ac24..e18515b2 100644 --- a/crates/benilla-ui/src/script/cursor.rs +++ b/crates/benilla-ui/src/script/cursor.rs @@ -17,7 +17,7 @@ mod pet; pub(crate) use bar::place_action; pub(crate) use drag::{ - abandon_drag, arm_drag, maybe_start_drag, take_drag, DragGesture, DragRelease, + abandon_drag, maybe_start_drag, press_drag, take_drag, DragGesture, DragRelease, }; /// Sentinel bag id for the player's equipped slots, whose `slot` is a 1-based inventory slot id diff --git a/crates/benilla-ui/src/script/cursor/drag.rs b/crates/benilla-ui/src/script/cursor/drag.rs index 29a84fbd..2c54c91e 100644 --- a/crates/benilla-ui/src/script/cursor/drag.rs +++ b/crates/benilla-ui/src/script/cursor/drag.rs @@ -7,6 +7,15 @@ use crate::widget::FrameHandle; /// or more from the press (`0x81c468`, squared by `0x76b300`). pub(crate) const DRAG_START_THRESHOLD: f32 = 4.0; +/// The event button bits in order, `1`..`0x10` (`0x76bf70`'s name table). +const HELD_ORDER: [&str; 5] = [ + "LeftButton", + "MiddleButton", + "RightButton", + "Button4", + "Button5", +]; + /// A drag armed at a press on a [`Model::drag_registered`] frame, `started` past the threshold. #[derive(Clone, Debug, PartialEq)] pub(crate) struct DragGesture { @@ -16,29 +25,57 @@ pub(crate) struct DragGesture { pub(crate) started: bool, } -/// A gesture whose button matched the release; [`Model::drag`] is cleared either way. +/// A gesture whose frame's drag mask held the released button; [`Model::drag`] is cleared either way. pub(crate) struct DragRelease { /// `OnDragStart` fired; an unstarted release fires nothing and the click proceeds. pub(crate) started: bool, pub(crate) source: FrameHandle, } -/// Arm or clear the gesture on a press, replacing any leftover one; the caller runs -/// [`abandon_drag`] first so a started gesture still gets its `OnDragStop`. -pub(crate) fn arm_drag(model: &mut Model, hit: Option, button: &str, pos: (f32, f32)) { - model.drag = hit - .filter(|&h| { - model - .drag_registered - .get(&h) - .is_some_and(|set| set.iter().any(|b| b.eq_ignore_ascii_case(button))) - }) - .map(|h| DragGesture { +/// The press arm `0x76bf70`, run on the frame the press lands on (the capture, else the hover). A +/// button in the frame's drag mask (`0x76bf7d`) re-arms: armed, not started, this button, this +/// point (`0x76bf82`..`0x76bfab`), with no `OnDragStop` for a started drag it replaces. Any other +/// button skips the arm (`0x76bf80` je `0x76bfb1`) and leaves the armed or started drag alone. +/// +/// The reference keeps this state per frame and benilla has the one [`Model::drag`], which holds +/// the same thing because a press always lands on the capture, the frame of any live gesture. A +/// gesture on another frame, or one whose button's release was lost (`release_lost`; the +/// reference cannot lose one), can no longer be moved or released there, so it is abandoned and +/// the returned source of a started one must get `OnDragStop`, or a frame it is moving follows the +/// cursor. +pub(crate) fn press_drag( + model: &mut Model, + target: Option, + button: &str, + pos: (f32, f32), + release_lost: bool, +) -> Option { + let elsewhere = model + .drag + .as_ref() + .is_some_and(|g| Some(g.source) != target); + let displaced = if release_lost || elsewhere { + abandon_drag(model) + } else { + None + }; + if let Some(h) = target.filter(|h| is_drag_button(model, *h, button)) { + model.drag = Some(DragGesture { button: button.to_string(), source: h, start: pos, started: false, }); + } + displaced +} + +/// Whether `button` is in the frame's `RegisterForDrag` mask (`[+0xfc]`). +fn is_drag_button(model: &Model, frame: FrameHandle, button: &str) -> bool { + model + .drag_registered + .get(&frame) + .is_some_and(|set| set.iter().any(|b| b.eq_ignore_ascii_case(button))) } /// Start the armed gesture once the cursor passes the threshold, returning the `OnDragStart` @@ -54,7 +91,19 @@ pub(crate) fn maybe_start_drag(model: &mut Model, pos: (f32, f32)) -> Option<(u3 if dist <= DRAG_START_THRESHOLD { return None; } - (g.source, g.button.clone()) + // `OnDragStart`'s argument is the lowest button of the move event's held mask + // (`0x76c1e0` over `[evt+0x18]`), not the armed one: a chord re-armed by the later button + // starts as the earlier. The held set is the buttons whose press took a frame. + let held = HELD_ORDER.iter().find(|name| { + model + .mouse_down_on + .keys() + .any(|k| k.eq_ignore_ascii_case(name)) + }); + ( + g.source, + held.map_or_else(|| g.button.clone(), |name| (*name).to_string()), + ) }; model.drag.as_mut().expect("checked Some above").started = true; // A dragged button un-presses at the threshold, not on leaving its rect: `CSimpleButton`'s @@ -68,18 +117,22 @@ pub(crate) fn maybe_start_drag(model: &mut Model, pos: (f32, f32)) -> Option<(u3 Some((id, button)) } -/// Drop a gesture whose release will never come, because the pointer left the window or a second -/// button was pressed; the reference cannot lose one, as the OS captures the pointer. Returns a -/// started one's source, which must get `OnDragStop` or a frame it is moving follows the cursor. +/// Drop a gesture whose release will never come, because the pointer left the window; the +/// reference cannot lose one, as the OS captures the pointer. Returns a started one's source, +/// which must get `OnDragStop` or a frame it is moving follows the cursor. pub(crate) fn abandon_drag(model: &mut Model) -> Option { model.drag.take().filter(|g| g.started).map(|g| g.source) } -/// Take the gesture whose button matches the release. +/// Take the gesture a release ends. `0x76c050` tests the released button against the frame's +/// drag mask, not the armed button `[+0x108]`, so any mask button's release clears the arm +/// (`0x76c05d`) and, with the drag started, is the drag release (`0x76c06e`). pub(crate) fn take_drag(model: &mut Model, button: &str) -> Option { - let g = model - .drag - .take_if(|g| g.button.eq_ignore_ascii_case(button))?; + let source = model.drag.as_ref()?.source; + if !is_drag_button(model, source, button) { + return None; + } + let g = model.drag.take()?; Some(DragRelease { started: g.started, source: g.source, @@ -693,18 +746,22 @@ mod tests { assert_eq!(s.eval::("return stops").unwrap(), 1); } + /// Each press runs the arm `0x76bf70` on the capture: a button outside the frame's drag mask + /// (`0x76bf7d` je `0x76bfb1`) skips the arm and touches neither `[+0x100]` nor `[+0x104]`, so + /// the started left drag runs on and only its own release (`0x76c040`) fires `OnDragStop`. #[test] - fn a_press_that_replaces_an_in_flight_gesture_stops_it_first() { + fn a_press_of_a_button_the_frame_does_not_drag_leaves_the_drag_running() { let mut s = drag_script(); s.run( r#" - stops, starts = 0, 0 + stops, starts, ups = 0, 0, 0 local a = CreateFrame("Frame", "A") a:SetPoint("BOTTOMLEFT", 100, 100); a:SetWidth(200); a:SetHeight(100) a:EnableMouse(true); a:SetMovable(true) a:RegisterForDrag("LeftButton") a:SetScript("OnDragStart", function() starts = starts + 1; this:StartMoving() end) a:SetScript("OnDragStop", function() stops = stops + 1; this:StopMovingOrSizing() end) + a:SetScript("OnMouseUp", function() ups = ups + 1 end) "#, ) .unwrap(); @@ -717,13 +774,188 @@ mod tests { s.mouse_button(200.0, 200.0, "RightButton", true); assert_eq!( s.eval::("return stops").unwrap(), + 0, + "the right press does not touch the left drag" + ); + s.mouse_move(250.0, 250.0); + s.mouse_move(300.0, 300.0); + s.resolve(); + assert_eq!( + s.eval::("return A:GetLeft()").unwrap(), + 200.0, + "the frame still follows the cursor" + ); + assert_eq!( + s.eval::("return starts").unwrap(), 1, - "the left drag the right press displaced was stopped, not forgotten" + "no second start" ); - // Replacing an unstarted press fires nothing, as releasing it would not. - s.mouse_button(150.0, 150.0, "LeftButton", true); - s.mouse_button(150.0, 150.0, "MiddleButton", true); + // The right release is no drag release (`0x76c050` je `0x76c099`): its `OnMouseUp` fires. + s.mouse_button(300.0, 300.0, "RightButton", false); + assert_eq!(s.eval::("return stops").unwrap(), 0); + assert_eq!(s.eval::("return ups").unwrap(), 1); + + s.mouse_button(300.0, 300.0, "LeftButton", false); + assert_eq!( + s.eval::("return stops").unwrap(), + 1, + "the left release ends the drag" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); + } + + /// A frame registered for both buttons. + fn logged_two_button_drag_frame() -> UiScript { + let mut s = drag_script(); + s.run( + r#" + log = {} + local function note(name) return function() table.insert(log, name) end end + local a = CreateFrame("Frame", "A") + a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(400); a:SetHeight(600); a:EnableMouse(true) + a:RegisterForDrag("LeftButton", "RightButton") + a:SetScript("OnMouseDown", note("down")) + a:SetScript("OnMouseUp", note("up")) + a:SetScript("OnDragStart", note("dragstart")) + a:SetScript("OnDragStop", note("dragstop")) + a:SetScript("OnReceiveDrag", note("receive")) + "#, + ) + .unwrap(); + s.resolve(); + s + } + + /// A press of a registered button re-arms (`0x76bf82`..`0x76bfab`: armed 1, started 0, the new + /// button and start point) and fires no `OnDragStop`. `0x76b320` then starts again from the new + /// point (`0x76b34c`), a second `OnDragStart`; the release of a mask button ends a started + /// drag whichever button armed it (`0x76c050`..`0x76c06e` read the released button only). + #[test] + fn a_press_of_a_registered_button_re_arms_without_a_stop() { + let mut s = logged_two_button_drag_frame(); + s.mouse_button(100.0, 300.0, "LeftButton", true); + s.mouse_move(130.0, 300.0); + assert_eq!(log(&s), "down dragstart"); + + s.mouse_button(130.0, 300.0, "RightButton", true); + assert_eq!(log(&s), "down dragstart down", "no OnDragStop"); + + s.mouse_move(132.0, 300.0); // 2px from the new arm point: under the threshold + assert_eq!(log(&s), "down dragstart down"); + s.mouse_move(160.0, 300.0); // past it: started again + assert_eq!(log(&s), "down dragstart down dragstart"); + + s.mouse_button(160.0, 300.0, "RightButton", false); + assert_eq!( + log(&s), + "down dragstart down dragstart dragstop receive", + "the release ends the started drag, no OnMouseUp" + ); + // The drag is over: the left release finds it unarmed and is a plain release. + s.mouse_button(160.0, 300.0, "LeftButton", false); + assert_eq!(log(&s), "down dragstart down dragstart dragstop receive up"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); + } + + /// `0x76c050` tests the released button against the mask, not `[+0x108]`: the first button's + /// release after the re-arm clears the arm (`0x76c05d`), and with the drag not restarted + /// (`[+0x104]` 0, `0x76c067` je `0x76c099`) is a plain `OnMouseUp`. Nothing starts afterwards. + #[test] + fn the_first_buttons_release_after_a_re_arm_disarms_it() { + let mut s = logged_two_button_drag_frame(); + s.mouse_button(100.0, 300.0, "LeftButton", true); + s.mouse_move(130.0, 300.0); + s.mouse_button(130.0, 300.0, "RightButton", true); + s.mouse_button(130.0, 300.0, "LeftButton", false); + assert_eq!(log(&s), "down dragstart down up", "no OnDragStop"); + + s.mouse_move(200.0, 300.0); // the arm is cleared: the right button drags nothing + assert_eq!(log(&s), "down dragstart down up"); + s.mouse_button(200.0, 300.0, "RightButton", false); + assert_eq!(log(&s), "down dragstart down up up"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); + } + + /// `OnDragStart`'s button is not the armed one: `0x76c1e0` names the lowest bit of the move + /// event's held mask (`[evt+0x18]`, the producer `0x423fe0` stores `ds:0x883f40` there), so + /// with Left still held a drag re-armed by Right starts as `LeftButton`. + #[test] + fn on_drag_start_names_the_lowest_held_button() { + let mut s = drag_script(); + s.run( + r#" + names = {} + local a = CreateFrame("Frame", "A") + a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(400); a:SetHeight(600); a:EnableMouse(true) + a:RegisterForDrag("LeftButton", "RightButton") + a:SetScript("OnDragStart", function() table.insert(names, arg1) end) + "#, + ) + .unwrap(); + s.resolve(); + s.mouse_button(100.0, 300.0, "LeftButton", true); + s.mouse_move(130.0, 300.0); + s.mouse_button(130.0, 300.0, "RightButton", true); // re-arms for Right, Left still held + s.mouse_move(160.0, 300.0); + s.mouse_button(160.0, 300.0, "RightButton", false); + s.mouse_button(160.0, 300.0, "LeftButton", false); + s.mouse_button(160.0, 300.0, "RightButton", true); // Right alone + s.mouse_move(200.0, 300.0); + assert_eq!( + s.eval::("return table.concat(names, ' ')").unwrap(), + "LeftButton LeftButton RightButton" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); + } + + /// The cursor payload is no part of the arm: a right click during an item drag leaves the + /// carried item and the drag, and the left release still drops on the frame under the cursor. + #[test] + fn an_item_drag_survives_a_right_click() { + let mut s = drag_script(); + s.run( + r#" + stops, receives = 0, 0 + local a = CreateFrame("Frame", "A") + a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(400); a:SetHeight(600); a:EnableMouse(true) + a:RegisterForDrag("LeftButton") + a:SetScript("OnDragStop", function() stops = stops + 1 end) + local b = CreateFrame("Frame", "B") + b:SetPoint("BOTTOMLEFT", 400, 0); b:SetWidth(400); b:SetHeight(600); b:EnableMouse(true) + b:SetScript("OnReceiveDrag", function() receives = receives + 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); + s.mouse_move(110.0, 300.0); // the drag starts + s.mouse_button(110.0, 300.0, "RightButton", true); + s.mouse_button(110.0, 300.0, "RightButton", false); + assert_eq!(s.eval::("return stops").unwrap(), 0); + assert!(s.cursor_item().is_some(), "the item is still carried"); + + s.mouse_move(600.0, 300.0); + s.mouse_button(600.0, 300.0, "LeftButton", false); assert_eq!(s.eval::("return stops").unwrap(), 1); + assert_eq!( + s.eval::("return receives").unwrap(), + 1, + "the drop reaches the frame under the cursor" + ); + assert!(s.cursor_item().is_some(), "a drag release never drops"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); } } diff --git a/crates/benilla-ui/src/script/pointer.rs b/crates/benilla-ui/src/script/pointer.rs index 6cce9292..c1a4a099 100644 --- a/crates/benilla-ui/src/script/pointer.rs +++ b/crates/benilla-ui/src/script/pointer.rs @@ -269,14 +269,18 @@ impl UiScript { // drag arm, a Button's press art and Down click), runs on that one frame. So a second // button pressed over another frame fires on the held one. `mouse_down_on` keeps the // frame per button for the release. + let mut release_lost = false; let press_target: Option = if down { let mut model = self.model_mut(); // A press for a button still held means its release never came (a focus loss); the // reference's capture never loses one. That frame gets the release edge so it does not // stay pushed, and with no other button down the stale capture goes too, so this // press is not aimed at it. - if let Some(stale) = model.mouse_down_on.remove(button).flatten() { - button::edge(&mut model, stale, ButtonState::on_mouse_up); + if let Some(entry) = model.mouse_down_on.remove(button) { + release_lost = true; + if let Some(stale) = entry { + button::edge(&mut model, stale, ButtonState::on_mouse_up); + } } if model.mouse_down_on.is_empty() { model.mouse_capture = None; @@ -388,10 +392,10 @@ impl UiScript { press_art = inside; // `0x7663e9` stores the target as the capture: the one held, else the hover. model.mouse_capture = press_target; - // A press replaces any in-flight gesture; a started one gets its `OnDragStop` + // The drag arm `0x76bf70`; a started gesture it displaces gets its `OnDragStop` // below, outside this borrow. - let abandoned = cursor::abandon_drag(&mut model); - cursor::arm_drag(&mut model, press_target, button, (x, y)); + let abandoned = + cursor::press_drag(&mut model, press_target, button, (x, y), release_lost); // A left press on a Slider grabs the thumb, or on the track seats the thumb under // the cursor first (the returned value jump). let jump = if button == "LeftButton" {