Layout-mode drag, title press takes the slot, links are their own button (#961)
A title-region press refused while another drag held the slot, `SetLayoutMode` stored nothing, and a press on a chat link made the chat frame itself the target, so every link click started a title move of the window. In 1.12.1 the drag entry takes the slot unread (`0x7652b0`); a mid-drag press goes to the frozen hover (`0x76637f`). With `SetLayoutMode` (`0x488540`) on, a Ctrl or Alt press drags the nearest movable toplevel (`0x765490`) from a 3×3 grip grid (`0x765320`) and is consumed, world included; its release only ends the drag (`0x7664e0`). Each link span is its own Button (`0x7a3240`) routing `OnHyperlinkEnter`/`Leave`/`Click` to the parent (`0x7a3780`–`0x7a37e0`). These now hold; a release goes to the capture whatever its button (`0x76642b`), and number arguments keep `_ftol`'s low dword. Tests bite on the parent for each. Fixes #951 Fixes #952 Fixes #957 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
108c354fc6
commit
985dabff44
@@ -708,6 +708,9 @@ pub(super) fn latch_world_mouse(
|
||||
// ground-targeting veto (`+0x3c`, `0x7cba30`) arrives through `PointerOverUi`
|
||||
// (`UiScript::set_nameplate_hit_test_veto`). The wheel still zooms over a plate.
|
||||
pointer_over_ui: Res<crate::ui_script::PointerOverUi>,
|
||||
// A press the UI consumed with nothing hovered, the layout drag's: no frame saw it, the
|
||||
// world's included (`0x766353`).
|
||||
press_consumed: Option<Res<crate::ui_script::PlayerUiPressConsumed>>,
|
||||
mut rig: ResMut<CameraControl>,
|
||||
cameras: Query<&Camera, With<FlyCam>>,
|
||||
window: Single<&Window, With<PrimaryWindow>>,
|
||||
@@ -721,7 +724,7 @@ pub(super) fn latch_world_mouse(
|
||||
let Ok(camera) = cameras.single() else {
|
||||
return;
|
||||
};
|
||||
let over_ui = pointer_over_ui.0;
|
||||
let over_ui = pointer_over_ui.0 || press_consumed.is_some_and(|c| c.0);
|
||||
let world_press = rig.look.is_some() || (cursor_in_viewport(&window, camera) && !over_ui);
|
||||
let wm = &mut rig.world_mouse;
|
||||
wm.update(&buttons, world_press);
|
||||
|
||||
@@ -178,6 +178,25 @@ fn mouselook_start_turns_the_camera_and_the_character_with_no_button_held() {
|
||||
assert!(w.resource::<CameraControl>().is_looking());
|
||||
}
|
||||
|
||||
/// A right press over bare world that the UI consumed, as a layout-mode Alt press is
|
||||
/// (`0x766353`, before any frame sees it), turns nothing; the same press unconsumed turns.
|
||||
#[test]
|
||||
fn a_press_the_ui_consumed_over_the_world_turns_nothing() {
|
||||
let mut w = world(false);
|
||||
w.insert_resource(crate::ui_script::PlayerUiPressConsumed(true));
|
||||
press(&mut w, MouseButton::Right);
|
||||
frame(&mut w, 40.0);
|
||||
assert_eq!(w.resource::<CameraControl>().look, None);
|
||||
assert_eq!(yaw(&mut w), 0.0, "the camera did not turn");
|
||||
|
||||
let mut w = world(false);
|
||||
w.insert_resource(crate::ui_script::PlayerUiPressConsumed(false));
|
||||
press(&mut w, MouseButton::Right);
|
||||
frame(&mut w, 40.0);
|
||||
assert_eq!(w.resource::<CameraControl>().look, Some(LookButton::Right));
|
||||
assert!(yaw(&mut w) < 0.0, "an unconsumed press turns it");
|
||||
}
|
||||
|
||||
/// `MouselookStop` (`0x514240`) ends the session: the cursor comes back and the mouse turns
|
||||
/// nothing, and `IsMouselooking` answers nil.
|
||||
#[test]
|
||||
|
||||
@@ -536,9 +536,13 @@ fn drag_named_tab(s: &mut UiScript, window: &str, dx: f32, dy: f32) {
|
||||
Some(tab),
|
||||
"the tab is hit"
|
||||
);
|
||||
// As a player's mouse runs it: the hover is on the tab before the press, and a frame resolves
|
||||
// the moved layout before the release tests the cursor against the tab.
|
||||
s.mouse_move(tx, ty);
|
||||
s.mouse_button(tx, ty, "LeftButton", true);
|
||||
s.mouse_move(tx + DRAG_STARTS_AT, ty);
|
||||
s.mouse_move(tx + dx, ty + dy);
|
||||
s.resolve();
|
||||
s.mouse_button(tx + dx, ty + dy, "LeftButton", false);
|
||||
s.resolve();
|
||||
}
|
||||
@@ -571,6 +575,7 @@ fn an_undocked_tab_drags_again_and_a_plain_click_drops_the_window() {
|
||||
|
||||
reveal_window(&mut s, "ChatFrame2");
|
||||
let (tx, ty) = centre(&mut s, "ChatFrame2Tab");
|
||||
s.mouse_move(tx, ty);
|
||||
s.mouse_button(tx, ty, "LeftButton", true);
|
||||
assert!(
|
||||
s.eval::<bool>("return MOVING_CHATFRAME == ChatFrame2")
|
||||
|
||||
@@ -9,7 +9,10 @@ use bevy::window::PrimaryWindow;
|
||||
|
||||
use benilla_ui::script::UiScript;
|
||||
|
||||
use super::{CursorPayloadHeld, PlayerUiClickConsumed, PlayerUiHover, UiKeyboardCapture};
|
||||
use super::{
|
||||
CursorPayloadHeld, PlayerUiClickConsumed, PlayerUiHover, PlayerUiPressConsumed,
|
||||
UiKeyboardCapture,
|
||||
};
|
||||
use crate::bindings::WheelNotches;
|
||||
use crate::textinput::{self, keymap, HostClipboard};
|
||||
|
||||
@@ -20,6 +23,7 @@ use crate::textinput::{self, keymap, HostClipboard};
|
||||
pub(super) struct PointerFeed<'w> {
|
||||
hover: ResMut<'w, PlayerUiHover>,
|
||||
click_consumed: ResMut<'w, PlayerUiClickConsumed>,
|
||||
press_consumed: ResMut<'w, PlayerUiPressConsumed>,
|
||||
hovered: Res<'w, crate::target::Hovered>,
|
||||
hovered_object: Res<'w, crate::target::HoveredObject>,
|
||||
occlusion: Res<'w, crate::target::PickOcclusion>,
|
||||
@@ -101,12 +105,14 @@ pub(super) fn feed_ui_input(
|
||||
// capture pins it: skip the mouse half whole, else-arm included, whose `pointer_left_window`
|
||||
// would disarm the probe's gesture between its press and release.
|
||||
let synthetic = pointer.synthetic.0 || pointer.capture_pinned.0;
|
||||
let (hover, click_consumed, payload_held) = (
|
||||
let (hover, click_consumed, press_consumed, payload_held) = (
|
||||
&mut pointer.hover,
|
||||
&mut pointer.click_consumed,
|
||||
&mut pointer.press_consumed,
|
||||
&mut pointer.payload_held,
|
||||
);
|
||||
click_consumed.0 = false;
|
||||
press_consumed.0 = false;
|
||||
let Some(mut script) = script else {
|
||||
capture.typing = false;
|
||||
capture.arrows_fall_through = false;
|
||||
@@ -183,7 +189,8 @@ pub(super) fn feed_ui_input(
|
||||
click_consumed.0 = true;
|
||||
}
|
||||
}
|
||||
script.mouse_button(x, y, name, true);
|
||||
// A press the UI consumed is not the world's, hovered frame or not.
|
||||
press_consumed.0 |= script.mouse_button(x, y, name, true);
|
||||
}
|
||||
if buttons.just_released(btn) {
|
||||
script.mouse_button(x, y, name, false);
|
||||
|
||||
@@ -122,6 +122,12 @@ pub(crate) struct UiKeyboardCapture {
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct PlayerUiClickConsumed(pub(crate) bool);
|
||||
|
||||
/// A press the UI consumed this frame, whatever is under the pointer: the layout drag's press
|
||||
/// returns at `0x766353`–`0x76635b`, before any frame, the world frame included, sees it, so the
|
||||
/// world latch takes no press this frame even with no UI frame hovered.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct PlayerUiPressConsumed(pub(crate) bool);
|
||||
|
||||
/// Whether the cursor carries a payload, a `Send` mirror. A click on sky with one held never
|
||||
/// deselects: the reference gates its nothing-leg `SetSelection(0,0)` on no payload (`0x492d30`).
|
||||
#[derive(Resource, Default)]
|
||||
@@ -298,6 +304,7 @@ impl Plugin for UiScriptPlugin {
|
||||
.init_resource::<PlayerUiHover>()
|
||||
.init_resource::<UiKeyboardCapture>()
|
||||
.init_resource::<PlayerUiClickConsumed>()
|
||||
.init_resource::<PlayerUiPressConsumed>()
|
||||
.init_resource::<CursorPayloadHeld>()
|
||||
.init_resource::<UiClock>()
|
||||
.init_resource::<AddOnIdentity>()
|
||||
|
||||
@@ -16,16 +16,25 @@
|
||||
|
||||
use mlua::{Lua, Value};
|
||||
|
||||
/// Shape A: `is-number` (`0x6f34d0`), `tonumber` (`0x6f3620`), then the truncating `0x40a2b0`,
|
||||
/// raising the binding's `Usage:` on failure. A missing argument fails like a wrong-typed one
|
||||
/// (`0x6f3410` returns NULL past `L->top`); a numeric string passes. `0x40a2b0` stores a qword but
|
||||
/// the bindings consume only the low dword, hence `i32`.
|
||||
///
|
||||
/// Deviation: past ±2^63 Rust saturates where the x87 stores its integer-indefinite pattern,
|
||||
/// because no real argument reaches that range.
|
||||
/// `_ftol` (`0x40a2b0`) as the bindings consume it: the x87 rounding control set to chop, a
|
||||
/// `fistp` to a qword, and its low dword. What the qword cannot hold (an infinity, NaN, ±2^63 and
|
||||
/// beyond) stores the integer-indefinite pattern `0x8000_0000_0000_0000`, whose low dword is 0.
|
||||
pub(crate) fn ftol(n: f64) -> i32 {
|
||||
const TWO_63: f64 = 9_223_372_036_854_775_808.0;
|
||||
let t = n.trunc();
|
||||
if (-TWO_63..TWO_63).contains(&t) {
|
||||
t as i64 as i32
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Shape A: `is-number` (`0x6f34d0`), `tonumber` (`0x6f3620`), then the truncating `0x40a2b0`
|
||||
/// ([`ftol`]), raising the binding's `Usage:` on failure. A missing argument fails like a
|
||||
/// wrong-typed one (`0x6f3410` returns NULL past `L->top`); a numeric string passes.
|
||||
pub(crate) fn number_arg(lua: &Lua, v: Value, usage: &'static str) -> mlua::Result<i32> {
|
||||
match lua.coerce_number(v)? {
|
||||
Some(n) => Ok(n as i64 as i32),
|
||||
Some(n) => Ok(ftol(n)),
|
||||
None => Err(mlua::Error::RuntimeError(usage.into())),
|
||||
}
|
||||
}
|
||||
@@ -218,6 +227,20 @@ mod tests {
|
||||
Lua::new()
|
||||
}
|
||||
|
||||
/// `_ftol`'s low dword: chopped toward zero, wrapped to 32 bits, and 0 for whatever the qword
|
||||
/// `fistp` cannot hold.
|
||||
#[test]
|
||||
fn ftol_keeps_the_low_dword_of_the_x87_qword() {
|
||||
assert_eq!(ftol(-2.9), -2);
|
||||
assert_eq!(ftol(2.9), 2);
|
||||
assert_eq!(ftol(3e9), 3_000_000_000_i64 as i32);
|
||||
assert_eq!(ftol(4_294_967_301.0), 5);
|
||||
assert_eq!(ftol(-9_223_372_036_854_775_808.0), 0);
|
||||
for none in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN, 9.3e18, -9.3e18] {
|
||||
assert_eq!(ftol(none), 0, "{none}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bool_or_default_is_not_lua_truthiness() {
|
||||
let lua = lua();
|
||||
|
||||
@@ -1006,6 +1006,17 @@ pub(super) fn wants_press_visual(model: &Model, h: FrameHandle, button: &str) ->
|
||||
/// (`0x7826c0` passes 1, the mouse `0x779280`/`0x7793a4` pass 0): a LootButton then does nothing at
|
||||
/// all, not even its `OnClick` (`0x4c182b`); every other kind ignores it.
|
||||
pub(super) fn click_button(lua: &Lua, id: u32, button: &str, down: bool, scripted: bool) {
|
||||
// A hyperlink button's click slot (`+0x94`, `0x7a3510`) is its text frame's
|
||||
// `OnHyperlinkClick`, in place of the Button's `OnClick` firer.
|
||||
let target = lua
|
||||
.app_data_ref::<Model>()
|
||||
.expect("model app_data")
|
||||
.id_to_frame
|
||||
.get(&id)
|
||||
.copied();
|
||||
if target.is_some_and(|h| super::hyperlink::click(lua, h, button)) {
|
||||
return;
|
||||
}
|
||||
let mut take_loot = None;
|
||||
let fire = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
|
||||
@@ -53,17 +53,29 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// Three setters whose state has no reader here, so each takes its argument as the reference
|
||||
// does, never raising, and stores nothing; all return zero values.
|
||||
// `SetLayoutMode([mode])` (`0x488540`): a number or numeric string (`0x6f34d0`) truncates
|
||||
// (`0x40a2b0`, [`super::binding_abi::ftol`]) into the UI root's `+0xcf8`, anything else
|
||||
// stores 1 (`0x488573`); never raises, returns zero values. The press handler reads it
|
||||
// (`0x766301`).
|
||||
g.set(
|
||||
"SetLayoutMode",
|
||||
lua.create_function(|lua, v: Value| {
|
||||
let mode = lua.coerce_number(v)?.map_or(1, super::binding_abi::ftol);
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.layout_mode = mode;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// Two setters whose state has no reader here, so each takes its argument as the reference
|
||||
// does, never raising, and stores nothing; both return zero values.
|
||||
// - `SetEuropeanNumbers(flag)` (`0x48dec0`): `GetBoolOrDefault(1, true)` into `[0xcf4d24]`,
|
||||
// which turns on `FontString:SetText`'s digit-group pass (`0x771daa` into `0x771c50`).
|
||||
// benilla's `SetText` has no such pass.
|
||||
// - `SetLayoutMode(mode)` (`0x488540`): a number truncates into the UI root's `+0xcf8`, anything
|
||||
// else stores 1; read by the root's mouse handler (`0x766301`) for a frame-dragging mode
|
||||
// benilla does not build.
|
||||
// - `SetConsoleKey(key)` (`0x488640`): a one-character or named key becomes the console's
|
||||
// toggle key (`0x63cb00`), a non-string the default `0x30e`. benilla has no console screen.
|
||||
for name in ["SetEuropeanNumbers", "SetLayoutMode", "SetConsoleKey"] {
|
||||
for name in ["SetEuropeanNumbers", "SetConsoleKey"] {
|
||||
g.set(
|
||||
name,
|
||||
lua.create_function(|_, _: MultiValue| Ok(MultiValue::new()))?,
|
||||
@@ -361,9 +373,10 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The three setters never raise, whatever they are handed, and return zero values.
|
||||
/// The two stateless setters and `SetLayoutMode` never raise, whatever they are handed, and
|
||||
/// return zero values.
|
||||
#[test]
|
||||
fn the_stateless_setters_take_any_argument() {
|
||||
fn the_setters_take_any_argument() {
|
||||
let s = UiScript::new().unwrap();
|
||||
for name in ["SetEuropeanNumbers", "SetLayoutMode", "SetConsoleKey"] {
|
||||
for arg in ["", "nil", "1", "\"x\"", "{}", "\"ESCAPE\""] {
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
//! A text frame's hyperlink spans as the reference builds them: each span is a frame of its own, a
|
||||
//! `CSimpleHyperlinkButton` (ctor `0x7a3240`) on the Button constructor (`0x7786a0`), so a
|
||||
//! mouse-enabled child of the text frame with no name and no scripts. It takes the hover, the
|
||||
//! press, the capture and the click like any Button, and its vtable (`0x81d5b0`) differs from the
|
||||
//! Button's (`0x81c7f8`) in three code slots, each handing its link (`+0x4dc`) and text (`+0x4e0`)
|
||||
//! to the text frame: the enter (`+0x4c`, `0x7a34b0`) to `OnHyperlinkEnter`, the leave (`+0x50`,
|
||||
//! `0x7a34e0`) to `OnHyperlinkLeave`, and the click (`+0x94`, `0x7a3510`) to `OnHyperlinkClick`
|
||||
//! (the parent's firers `0x7a3780`/`0x7a37b0`/`0x7a37e0`). The enter and the leave pass the link
|
||||
//! alone (`"%s"`, `0x82e280`); the click passes the link, the text and the button name. Its click
|
||||
//! mask is `LeftButtonUp` and `RightButtonUp` (`0x500`, `0x7a327d`).
|
||||
//!
|
||||
//! The text frame keeps a pool (`[+0x314]`): a span takes a button from it (`0x7a3880`), which is
|
||||
//! placed over the span and shown (`0x7a3350`), and a span gone hides its button again (`0x7a3920`),
|
||||
//! freeing nothing. The app lays the text out, so its spans arrive here as screen rects.
|
||||
|
||||
use mlua::{Lua, Value};
|
||||
|
||||
use crate::layout::{Anchor, Anchors, Point, Rect};
|
||||
use crate::widget::{FrameHandle, FrameKind, KindState};
|
||||
|
||||
use super::{event, visibility, Model, UiScript, SCREEN};
|
||||
|
||||
/// The text frame and the `(link, text)` a hyperlink button carries, if `h` is one.
|
||||
pub(super) fn owner_link(model: &Model, h: FrameHandle) -> Option<(FrameHandle, String, String)> {
|
||||
let f = model.arena.frame(h)?;
|
||||
match &f.kind_state {
|
||||
KindState::Button(bs) => {
|
||||
let (link, text) = bs.hyperlink.clone()?;
|
||||
Some((f.parent?, link, text))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire the text frame's `OnHyperlinkEnter` or `OnHyperlinkLeave` for the button `h`, with the link
|
||||
/// alone; false when `h` is not a hyperlink button, whose own enter or leave then runs.
|
||||
pub(super) fn hover(lua: &Lua, h: FrameHandle, enter: bool) -> bool {
|
||||
let Some((owner, link, _)) = owner_link(&lua.app_data_ref::<Model>().expect("model"), h) else {
|
||||
return false;
|
||||
};
|
||||
let script = if enter {
|
||||
"OnHyperlinkEnter"
|
||||
} else {
|
||||
"OnHyperlinkLeave"
|
||||
};
|
||||
fire(lua, owner, script, &[&link]);
|
||||
true
|
||||
}
|
||||
|
||||
/// Fire the text frame's `OnHyperlinkClick(link, text, button)` for the button `h`; false when `h`
|
||||
/// is not a hyperlink button, whose own `OnClick` then runs.
|
||||
pub(super) fn click(lua: &Lua, h: FrameHandle, button: &str) -> bool {
|
||||
let Some((owner, link, text)) = owner_link(&lua.app_data_ref::<Model>().expect("model"), h)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
fire(lua, owner, "OnHyperlinkClick", &[&link, &text, button]);
|
||||
true
|
||||
}
|
||||
|
||||
fn fire(lua: &Lua, owner: FrameHandle, script: &str, args: &[&str]) {
|
||||
let id = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
if model.arena.frame(owner).is_none() {
|
||||
return;
|
||||
}
|
||||
model.frame_id(owner)
|
||||
};
|
||||
let args: mlua::Result<Vec<Value>> = args
|
||||
.iter()
|
||||
.map(|a| lua.create_string(a).map(Value::String))
|
||||
.collect();
|
||||
let result = args.and_then(|args| event::fire_widget_handler(lua, id, script, args));
|
||||
if let Err(e) = result {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model")
|
||||
.record_lua_error(&e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the spans on screen, `(text frame, y-up rect, link, text)`: each text frame's spans take
|
||||
/// its pooled buttons in order, a new one built when the pool runs out, and the buttons left over,
|
||||
/// or of a text frame with no span now, hide.
|
||||
pub(super) fn set_spans(lua: &Lua, spans: Vec<(FrameHandle, Rect, String, String)>) {
|
||||
// One span: its rect, link and text.
|
||||
type Span = (Rect, String, String);
|
||||
let mut by_owner: Vec<(FrameHandle, Vec<Span>)> = Vec::new();
|
||||
for (owner, rect, link, text) in spans {
|
||||
match by_owner.iter_mut().find(|(o, _)| *o == owner) {
|
||||
Some((_, list)) => list.push((rect, link, text)),
|
||||
None => by_owner.push((owner, vec![(rect, link, text)])),
|
||||
}
|
||||
}
|
||||
let (mut show, mut hide) = (Vec::new(), Vec::new());
|
||||
let mut moved = false;
|
||||
{
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let model = &mut *model;
|
||||
model
|
||||
.link_buttons
|
||||
.retain(|owner, _| model.arena.frame(*owner).is_some());
|
||||
for (owner, list) in &by_owner {
|
||||
if model.arena.frame(*owner).is_none() {
|
||||
continue;
|
||||
}
|
||||
for (i, (rect, link, text)) in list.iter().enumerate() {
|
||||
let pooled = model
|
||||
.link_buttons
|
||||
.get(owner)
|
||||
.and_then(|pool| pool.get(i))
|
||||
.copied();
|
||||
let h = pooled.unwrap_or_else(|| build(model, *owner));
|
||||
moved |= place(model, h, *rect, link, text);
|
||||
if !model.arena.frame(h).is_some_and(|f| f.shown) {
|
||||
show.push(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (owner, pool) in &model.link_buttons {
|
||||
let used = by_owner
|
||||
.iter()
|
||||
.find(|(o, _)| o == owner)
|
||||
.map_or(0, |(_, list)| list.len());
|
||||
hide.extend(
|
||||
pool.iter()
|
||||
.skip(used)
|
||||
.filter(|&&h| model.arena.frame(h).is_some_and(|f| f.shown)),
|
||||
);
|
||||
}
|
||||
}
|
||||
for h in hide {
|
||||
visibility::set_shown(lua, h, false);
|
||||
}
|
||||
for h in show {
|
||||
visibility::set_shown(lua, h, true);
|
||||
}
|
||||
// The pointer hit-tests resolved rects, so a moved button is solved before the next input.
|
||||
if moved {
|
||||
UiScript::resolve_layout(&mut lua.app_data_mut::<Model>().expect("model"));
|
||||
}
|
||||
}
|
||||
|
||||
/// `0x7a3880`'s miss: a new button, a Button child of the text frame in its stratum one level up,
|
||||
/// as a child is made, registered for `LeftButtonUp` and `RightButtonUp`. It is born hidden, so the
|
||||
/// caller's show is its one transition, as `0x7a3350`'s `Show` is.
|
||||
fn build(model: &mut Model, owner: FrameHandle) -> FrameHandle {
|
||||
let h = model.arena.create(FrameKind::Button, None, Some(owner));
|
||||
if let Some((strata, level)) = model.arena.frame(owner).map(|f| (f.strata, f.level)) {
|
||||
model.arena.set_frame_strata(h, strata);
|
||||
model.arena.set_frame_level(h, level + 1, false);
|
||||
}
|
||||
model.arena.set_shown(h, false);
|
||||
if let Some(KindState::Button(bs)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
|
||||
bs.registered_clicks = ["LeftButtonUp", "RightButtonUp"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
}
|
||||
model.frame_id(h);
|
||||
model.layout_inputs.entry(h).or_default();
|
||||
model.link_buttons.entry(owner).or_default().push(h);
|
||||
h
|
||||
}
|
||||
|
||||
/// `0x7a3350`: store the link and text, and seat the button over the span, one TOPLEFT on the
|
||||
/// screen with an explicit size, in its own units. True when its anchors or size changed.
|
||||
fn place(model: &mut Model, h: FrameHandle, rect: Rect, link: &str, text: &str) -> bool {
|
||||
if let Some(KindState::Button(bs)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
|
||||
if bs
|
||||
.hyperlink
|
||||
.as_ref()
|
||||
.is_none_or(|(l, t)| l != link || t != text)
|
||||
{
|
||||
bs.hyperlink = Some((link.to_string(), text.to_string()));
|
||||
}
|
||||
}
|
||||
let scale = f64::from(super::object::eff_scale(model, h));
|
||||
let root = model.screen;
|
||||
let x = ((f64::from(rect.left) - f64::from(root.left)) / scale) as f32;
|
||||
let y = ((f64::from(rect.top) - f64::from(root.top)) / scale) as f32;
|
||||
let w = ((f64::from(rect.right) - f64::from(rect.left)) / scale) as f32;
|
||||
let ht = ((f64::from(rect.top) - f64::from(rect.bottom)) / scale) as f32;
|
||||
let pin = Anchors::from([Anchor::new(Point::TopLeft, SCREEN, Point::TopLeft, x, y)]);
|
||||
let input = model.layout_inputs.entry(h).or_default();
|
||||
let changed = !input.anchors.bits_eq(&pin)
|
||||
|| input.width.to_bits() != w.to_bits()
|
||||
|| input.height.to_bits() != ht.to_bits();
|
||||
if changed {
|
||||
input.anchors = pin;
|
||||
input.width = w;
|
||||
input.height = ht;
|
||||
model.touch_layout_frame(h);
|
||||
model.note_authored_size(h);
|
||||
}
|
||||
changed
|
||||
}
|
||||
@@ -68,6 +68,7 @@ mod gm_ticket;
|
||||
mod gossip;
|
||||
mod guild;
|
||||
mod handler_prof;
|
||||
mod hyperlink;
|
||||
mod screenshot;
|
||||
mod tabard;
|
||||
pub use handler_prof::HandlerRow;
|
||||
@@ -409,7 +410,7 @@ pub const SCREEN: crate::layout::Handle = 0;
|
||||
/// ScrollFrame's three scroll kinds are `+0x32c`/`+0x334`/`+0x33c` (script-name map `0x786c40`).
|
||||
/// The EditBox's vtable (`0x81c910`) replaces the key and char slots: an EditBox never fires
|
||||
/// `OnKeyDown`, and fires `OnChar` only from `Insert`, with the inserted text (`0x77c13c`).
|
||||
const SCRIPT_KINDS: [&str; 39] = [
|
||||
const SCRIPT_KINDS: [&str; 41] = [
|
||||
"OnLoad",
|
||||
"OnEvent",
|
||||
"OnUpdate",
|
||||
@@ -445,9 +446,12 @@ const SCRIPT_KINDS: [&str; 39] = [
|
||||
"OnDragStart",
|
||||
"OnDragStop",
|
||||
"OnReceiveDrag",
|
||||
// A release over a message-frame hyperlink span, `OnHyperlinkClick(link, text, button)`;
|
||||
// `ChatFrameTemplate` passes it to `SetItemRef` (`ChatFrame.xml:15`, `ChatFrame.lua:1534`).
|
||||
// A text frame's hyperlink button hands it these ([`hyperlink`]): the click as
|
||||
// `OnHyperlinkClick(link, text, button)`, which `ChatFrameTemplate` passes to `SetItemRef`
|
||||
// (`ChatFrame.xml:15`, `ChatFrame.lua:1534`), and the enter and leave with the link alone.
|
||||
"OnHyperlinkClick",
|
||||
"OnHyperlinkEnter",
|
||||
"OnHyperlinkLeave",
|
||||
// The GameTooltip's engine-fired scripts: money render, money clear and world-hover default
|
||||
// placement, all three wired by the stock template (`GameTooltipTemplate.xml:617-625`).
|
||||
"OnTooltipAddMoney",
|
||||
@@ -860,17 +864,9 @@ impl UiScript {
|
||||
}
|
||||
|
||||
/// Replace the hyperlink spans `(frame, y-up rect, link, markup)` the app rasterized this
|
||||
/// frame; a release inside one fires `OnHyperlinkClick` on its frame ([`pointer`]).
|
||||
/// frame: each is a hyperlink button over its span, a child of its frame ([`hyperlink`]).
|
||||
pub fn set_link_spans(&mut self, spans: Vec<(FrameHandle, Rect, String, String)>) {
|
||||
let mut model = self.model_mut();
|
||||
model.link_spans.clear();
|
||||
for (fh, rect, link, markup) in spans {
|
||||
model
|
||||
.link_spans
|
||||
.entry(fh)
|
||||
.or_default()
|
||||
.push((rect, link, markup));
|
||||
}
|
||||
hyperlink::set_spans(&self.lua, spans);
|
||||
}
|
||||
|
||||
/// Whether the frame with this global name is shown with every ancestor (`IsVisible()`).
|
||||
|
||||
@@ -171,8 +171,9 @@ pub(crate) struct Model {
|
||||
pub(crate) layout_last_scope: (usize, usize),
|
||||
/// Fixpoint rounds run across all solves.
|
||||
pub(crate) layout_rounds: u64,
|
||||
/// Per frame, rasterized links `(y-up rect, link, full |H…|h markup)` for `OnHyperlinkClick`.
|
||||
pub(crate) link_spans: HashMap<FrameHandle, Vec<(Rect, String, String)>>,
|
||||
/// Per text frame, its pool of hyperlink buttons (`[+0x314]`), the shown ones first
|
||||
/// ([`super::hyperlink`]).
|
||||
pub(crate) link_buttons: HashMap<FrameHandle, Vec<FrameHandle>>,
|
||||
/// Region visuals (texture, colour, text) and layout (anchors, size, justify).
|
||||
pub(crate) region_data: HashMap<RegionHandle, RegionData>,
|
||||
/// Each frame's backdrop plate (`<Backdrop>` or `SetBackdrop`, the client's `frame+0x1ac`):
|
||||
@@ -643,6 +644,9 @@ pub(crate) struct Model {
|
||||
/// apart from `drag` as in the reference: a `StartMoving` or `StartSizing` outlives the button,
|
||||
/// since mouse-up's auto-stop skips a Lua drag.
|
||||
pub(crate) moving: Option<super::object::FrameMove>,
|
||||
/// `SetLayoutMode`'s value, the root's `+0xcf8` (0 at start, `0x7642a5`): while non-zero, a
|
||||
/// press with Control or Alt held is a layout drag (`0x766301`).
|
||||
pub(crate) layout_mode: i32,
|
||||
/// A Slider thumb drag, left press to release or pointer leave, handled here with no Lua.
|
||||
pub(crate) slider_drag: Option<slider::SliderDrag>,
|
||||
/// A ColorSelect wheel or value-bar drag, press to release.
|
||||
@@ -1131,7 +1135,7 @@ impl Model {
|
||||
layout_gate_walks: 0,
|
||||
layout_rounds: 0,
|
||||
resolved: HashMap::new(),
|
||||
link_spans: HashMap::new(),
|
||||
link_buttons: HashMap::new(),
|
||||
region_data: HashMap::new(),
|
||||
backdrops: HashMap::new(),
|
||||
simple_html: simplehtml::SimpleHtmlStates::new(),
|
||||
@@ -1337,6 +1341,7 @@ impl Model {
|
||||
drag_registered: HashMap::new(),
|
||||
drag: None,
|
||||
moving: None,
|
||||
layout_mode: 0,
|
||||
slider_drag: None,
|
||||
color_drag: None,
|
||||
gossip: None,
|
||||
|
||||
@@ -41,6 +41,11 @@ fn leave_class(model: &Model, h: FrameHandle) -> LeaveClass {
|
||||
/// a motion leave and 1 for a removal, only clears the drag arm (`0x76b725`); `OnLeave` runs
|
||||
/// through the plain runner (`0x76b753` → `0x702690`), which sets no arg global.
|
||||
pub(super) fn leave(lua: &Lua, h: FrameHandle, script: Option<u32>) {
|
||||
// A hyperlink button's leave is its text frame's `OnHyperlinkLeave` (`0x7a34e0`), in place of
|
||||
// the Button's.
|
||||
if super::hyperlink::hover(lua, h, false) {
|
||||
return;
|
||||
}
|
||||
let class = leave_class(&model(lua), h);
|
||||
match class {
|
||||
// `0x7cb8bf`: `0x492890` with an empty mouseover and an empty "previous", first.
|
||||
|
||||
@@ -211,9 +211,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
}
|
||||
|
||||
/// `SetScript(name, func)`: stores the closure under one of [`SCRIPT_KINDS`], else raises.
|
||||
/// Deviation: real 1.12 kinds nothing fires yet raise too (`OnHyperlinkEnter`, `OnHyperlinkLeave`,
|
||||
/// `OnMessageScrollChanged`, `OnInputLanguageChanged`, the movie frame's), because an accepted
|
||||
/// handler that never runs fails silently. `OnAttributeChanged` is 2.0's, with no 1.12 slot.
|
||||
/// Deviation: real 1.12 kinds nothing fires yet raise too (`OnMessageScrollChanged`,
|
||||
/// `OnInputLanguageChanged`, the movie frame's), because an accepted handler that never runs fails
|
||||
/// silently. `OnAttributeChanged` is 2.0's, with no 1.12 slot.
|
||||
fn set_script(lua: &Lua, this: &Table, name: &str, func: Option<Function>) -> mlua::Result<()> {
|
||||
let kind = SCRIPT_KINDS
|
||||
.iter()
|
||||
|
||||
@@ -270,17 +270,77 @@ fn start_moving(model: &mut Model, h: FrameHandle) -> mlua::Result<()> {
|
||||
|
||||
/// Begin a title-region move, drag mode 2 (`0x7662c0` → `0x765320` → `0x7652b0`), with no movable
|
||||
/// check, since nothing on that path reads the bit; `0x765320` asks for grip CENTER when it is not
|
||||
/// sizing (`0x765332` → `0x765464`). False when a drag is already in flight or did not start.
|
||||
/// sizing (`0x765332` → `0x765464`). Neither reads the slot first, so a drag in flight is displaced
|
||||
/// and nothing runs on its frame. False when the frame has no rect, so nothing started.
|
||||
pub(crate) fn start_title_move(model: &mut Model, h: FrameHandle) -> bool {
|
||||
if model.moving.is_some() {
|
||||
begin_drag(model, h, Point::Center, true)
|
||||
}
|
||||
|
||||
/// Begin the layout-mode modifier drag on the toplevel window `h` that `0x765490`'s walk lifted,
|
||||
/// from its flag test on: Control held sizes and needs the window resizable (`0x765570`), else it
|
||||
/// moves and needs it movable (`0x765560`). Then `0x765320` with drag mode 1, which the release
|
||||
/// ends: grip CENTER for a move, the cell of `grid_grip` for a size. False when nothing started.
|
||||
pub(crate) fn start_layout_drag(
|
||||
model: &mut Model,
|
||||
h: FrameHandle,
|
||||
at: (f32, f32),
|
||||
sizing: bool,
|
||||
) -> bool {
|
||||
let allowed = model
|
||||
.arena
|
||||
.frame(h)
|
||||
.is_some_and(|f| if sizing { f.resizable } else { f.movable });
|
||||
if !allowed {
|
||||
return false;
|
||||
}
|
||||
begin_drag(model, h, Point::Center, true)
|
||||
let grip = if sizing {
|
||||
grid_grip(model, h, at)
|
||||
} else {
|
||||
Some(Point::Center)
|
||||
};
|
||||
grip.is_some_and(|grip| begin_drag(model, h, grip, true))
|
||||
}
|
||||
|
||||
/// `0x765320`'s sizing grip: with the layout resolved (`0x76533e` → `0x7680e0`), the cell of a 3×3
|
||||
/// grid of the frame's rect (`0x768320`) that `at` falls in, each axis cut at a quarter and three
|
||||
/// quarters (`0.25` at `0x8029b0`, `3.0` at `0x80297c`), a cut belonging to the cell past it; the
|
||||
/// middle cell is CENTER, a move (`0x765384`–`0x765464`). The x offset and the quarter-width stay
|
||||
/// in x87 registers, the y offset and the quarter-height are stored as floats (`0x765390`,
|
||||
/// `0x7653ab`). None when the frame has no rect (`0x765379`).
|
||||
fn grid_grip(model: &mut Model, h: FrameHandle, (x, y): (f32, f32)) -> Option<Point> {
|
||||
UiScript::resolve_layout(model);
|
||||
let r = *model.resolved.get(&h)?;
|
||||
let dx = f64::from(x) - f64::from(r.left);
|
||||
let quarter_w = (f64::from(r.right) - f64::from(r.left)) * 0.25;
|
||||
let dy = f64::from((f64::from(y) - f64::from(r.bottom)) as f32);
|
||||
let quarter_h = f64::from(((f64::from(r.top) - f64::from(r.bottom)) * 0.25) as f32);
|
||||
// 0 the near cell (left, bottom), 1 the middle, 2 the far.
|
||||
let cell = |d: f64, quarter: f64| {
|
||||
if d < quarter {
|
||||
0
|
||||
} else if d >= quarter * 3.0 {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
}
|
||||
};
|
||||
Some(match (cell(dx, quarter_w), cell(dy, quarter_h)) {
|
||||
(0, 2) => Point::TopLeft,
|
||||
(1, 2) => Point::Top,
|
||||
(2, 2) => Point::TopRight,
|
||||
(0, 1) => Point::Left,
|
||||
(2, 1) => Point::Right,
|
||||
(0, 0) => Point::BottomLeft,
|
||||
(1, 0) => Point::Bottom,
|
||||
(2, 0) => Point::BottomRight,
|
||||
_ => Point::Center,
|
||||
})
|
||||
}
|
||||
|
||||
/// The drag entry `0x7652b0`: pin the frame (`0x7652c6` → `0x768430`) and, only when it has a rect
|
||||
/// to pin (`0x7652cd`), raise it (`0x7652d7`), set userPlaced (`0x7652e5`) and take the slot
|
||||
/// (`0x7652f3`–`0x76530f`). False when the frame has no rect, so nothing started.
|
||||
/// (`0x7652f3`–`0x76530f`) over whatever held it. False when the frame has no rect, so nothing
|
||||
/// started.
|
||||
fn begin_drag(model: &mut Model, h: FrameHandle, grip: Point, auto_stop: bool) -> bool {
|
||||
let Some(repinned) = pin_top_left(model, h, (0.0, 0.0), (0.0, 0.0)) else {
|
||||
return false;
|
||||
|
||||
@@ -97,8 +97,12 @@ pub(in crate::script) fn raise_on_show(model: &mut Model, h: FrameHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Step 1: the first frame up `+0x9c` with the toplevel bit (`0x7650fb..0x765135`).
|
||||
fn nearest_toplevel(model: &Model, frame: FrameHandle) -> Option<FrameHandle> {
|
||||
/// Step 1: the first frame up `+0x9c` with the toplevel bit (`0x7650fb..0x765135`), the walk the
|
||||
/// layout drag's lift repeats (`0x766fe0`).
|
||||
pub(in crate::script) fn nearest_toplevel(
|
||||
model: &Model,
|
||||
frame: FrameHandle,
|
||||
) -> Option<FrameHandle> {
|
||||
// A chain longer than the frame count is a cycle, which `SetParent` already refuses.
|
||||
let bound = model.arena.iter_frames().count();
|
||||
let mut cur = Some(frame);
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
//! turn host pointer events into FrameScript handler calls. [`cursor`] owns the payload and
|
||||
//! gesture state; this file dispatches.
|
||||
//!
|
||||
//! A frame captures the cursor when it is mouse-enabled and effective-visible and its rect and
|
||||
//! effective ScrollFrame clip hold the point (the reference's probe `0x76b020`), taking the first
|
||||
//! in the order of the hover walk (`0x7661cd` in `0x7660d0`), which [`crate::order::hit_test`]
|
||||
//! implements.
|
||||
//! A frame captures the cursor when it is in the mouse index, which `0x76af00` enters it into
|
||||
//! while it has the mouse bit (`[+0xcc] & 4`) and is visible (`[+0xd4]`), and the probe `0x76b020`
|
||||
//! takes the point: its rect is valid, and its hit rect and effective ScrollFrame clip hold the
|
||||
//! point. The first such frame in the order of the hover walk (`0x7661cd` in `0x7660d0`) wins,
|
||||
//! which [`crate::order::hit_test`] implements.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -58,6 +59,19 @@ pub(super) fn install(lua: &mlua::Lua) -> mlua::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The frame a release of `button` goes to: the release handler reads only the capture
|
||||
/// (`0x76642b`), which `mouse_down_on` keeps per button as the press that set it, `None` once that
|
||||
/// frame hid. A button with no press of its own, one a title or layout press took, releases onto
|
||||
/// whatever capture is held.
|
||||
fn release_target(model: &Model, button: &str) -> Option<FrameHandle> {
|
||||
match model.mouse_down_on.get(button) {
|
||||
Some(&pressed) => pressed,
|
||||
None => model
|
||||
.mouse_capture
|
||||
.filter(|&c| model.arena.frame(c).is_some()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `h` is the world frame, whose mouse hits belong to the 3D world; it wins the hover walk
|
||||
/// only when nothing else mouse-enabled is under the cursor.
|
||||
fn is_world_handle(model: &Model, h: FrameHandle) -> bool {
|
||||
@@ -81,15 +95,6 @@ fn over_world(model: &Model, target: Option<FrameHandle>) -> bool {
|
||||
target.is_none_or(|h| is_world_handle(model, h))
|
||||
}
|
||||
|
||||
/// Whether `(x, y)` falls inside one of this frame's hyperlink spans, the engine half of the
|
||||
/// reference's per-span `CSimpleHyperlinkButton` children.
|
||||
fn link_span_hit(model: &Model, fh: FrameHandle, x: f32, y: f32) -> bool {
|
||||
model
|
||||
.link_spans
|
||||
.get(&fh)
|
||||
.is_some_and(|spans| spans.iter().any(|(r, _, _)| point_in_rect(*r, x, y)))
|
||||
}
|
||||
|
||||
impl UiScript {
|
||||
/// Whether a frame id names the world frame: hit-tested like any frame, so scripts on it fire,
|
||||
/// but not counted by the app as over the UI.
|
||||
@@ -125,11 +130,10 @@ impl UiScript {
|
||||
}),
|
||||
);
|
||||
let hit = order::hit_test(&sorted, |fh| {
|
||||
// Mouse-enabled, or over one of the frame's hyperlink spans. The chat frame takes no
|
||||
// mouse (`[+0xcc] = 0`); the reference gives each span a mouse-enabled
|
||||
// `CSimpleHyperlinkButton` child (`0x7a3240`) that routes its scripts to the parent, so
|
||||
// a chat window hits only over a link and drags by its tab, not its body.
|
||||
let indexed = model.arena.is_mouse_enabled(fh) || link_span_hit(&model, fh, x, y);
|
||||
// In the mouse index. The chat frame takes no mouse (`[+0xcc] = 0`): its links are
|
||||
// Button children of their own ([`super::hyperlink`]), so a chat window hits only over
|
||||
// a link and drags by its tab, not its body.
|
||||
let indexed = model.arena.is_mouse_enabled(fh);
|
||||
// The nameplate's `+0x3c` veto (`0x7cba30`), not its mouse-enabled bit: while a
|
||||
// ground-targeted spell is armed a plate refuses the hit before its rect is tested,
|
||||
// so the reticle is placed through it.
|
||||
@@ -274,9 +278,13 @@ impl UiScript {
|
||||
super::mouseover::leave(&self.lua, h, script);
|
||||
}
|
||||
if let Some(nid) = enter_id {
|
||||
// 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);
|
||||
// A hyperlink button's enter is its text frame's `OnHyperlinkEnter` (`0x7a34b0`); the
|
||||
// base enter's plain runner (`0x76b6e5` → `0x702690`) sets no arg global.
|
||||
let routed = new_handle.is_some_and(|h| super::hyperlink::hover(&self.lua, h, true));
|
||||
if !routed {
|
||||
if let Err(e) = event::fire_widget_handler(&self.lua, nid, "OnEnter", Vec::new()) {
|
||||
self.push_error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
new_id
|
||||
@@ -294,12 +302,55 @@ impl UiScript {
|
||||
.is_some_and(|r| point_in_rect(*r, x, y))
|
||||
}
|
||||
|
||||
/// `0x765490`'s walk: strata FULLSCREEN_DIALOG down to WORLD, never TOOLTIP (the loop starts
|
||||
/// below `root+0xcf4`, `0x7654a3`), each level high to low, and each level's list from its head
|
||||
/// (the terminator's `next`, `[level+0x14]`) along each link's `next` (`+4`, `0x766aa0`). A
|
||||
/// frame links at the tail (`0x7648c9` → `0x7668c0` → `0x766921`), so that is the oldest link
|
||||
/// first, the hover sweep's order. Every shown frame is probed with `0x76b020` alone, whatever
|
||||
/// its mouse bit. The first hit is lifted to its nearest toplevel self-or-ancestor
|
||||
/// (`0x766fe0`); one with none gives up the rest of its stratum and the walk goes on in the
|
||||
/// next below (`0x76554a` → `0x765532`).
|
||||
fn layout_drag_target(&self, x: f32, y: f32) -> Option<FrameHandle> {
|
||||
let model = self.model_ref();
|
||||
let sorted = order::traversal(&model.arena);
|
||||
let scroll_sources = scroll_clip_sources(&model);
|
||||
let mut below = order::Strata::Tooltip;
|
||||
loop {
|
||||
let hit = order::hit_test(&sorted, |fh| {
|
||||
model.arena.frame(fh).is_some_and(|f| f.strata < below)
|
||||
&& frame_contains(&model, &scroll_sources, fh, x, y)
|
||||
})?;
|
||||
if let Some(t) = super::object::toplevel::nearest_toplevel(&model, hit) {
|
||||
return Some(t);
|
||||
}
|
||||
below = model.arena.frame(hit)?.strata;
|
||||
}
|
||||
}
|
||||
|
||||
/// A mouse button press or release at `(x, y)`; `button` is the WoW name (`"LeftButton"`, …).
|
||||
/// `OnClick` follows `RegisterForClicks` ([`button::wants_click`], default `{"LeftButtonUp"}`):
|
||||
/// a press fires it for `"<Button>ButtonDown"`, a release on the pressed frame for
|
||||
/// `"<Button>ButtonUp"`. Its `down` argument is not 1.12, whose firer (`0x779540`) passes only
|
||||
/// the button name. Returns whether the UI consumed the event.
|
||||
pub fn mouse_button(&mut self, x: f32, y: f32, button: &str, down: bool) -> bool {
|
||||
// ── The layout drag, ahead of everything ─────────────────────────────────────────────────
|
||||
//
|
||||
// With `SetLayoutMode` on, a press with Control (`0x41f8f0(1)`) or Alt (`0x41f8f0(2)`) held
|
||||
// goes to `0x765490` as drag mode 1, Control asking for a size, and the handler returns
|
||||
// consumed whatever that started (`0x766301`–`0x76635b`): no raise, title-region test,
|
||||
// capture or press slot.
|
||||
if down {
|
||||
let (_, ctrl, alt) = self.model_ref().modifiers;
|
||||
if self.model_ref().layout_mode != 0 && (ctrl || alt) {
|
||||
let target = self.layout_drag_target(x, y);
|
||||
let mut model = self.model_mut();
|
||||
model.cursor_pos = (x, y);
|
||||
if let Some(h) = target {
|
||||
crate::script::object::movable::start_layout_drag(&mut model, h, (x, y), ctrl);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let hit_id = self.hit_test(x, y);
|
||||
// ── The press target: the capture, else the hover ──────────────────────────────────────
|
||||
//
|
||||
@@ -309,6 +360,11 @@ 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.
|
||||
//
|
||||
// The hover is what the last walk left. Every press follows a move to its point, so that
|
||||
// is the frame under it, except while a move or size holds the slot: the walk then pumps
|
||||
// it and returns before its sweep (`0x766164`), and the hover stays the frame it was when
|
||||
// the drag began.
|
||||
let mut release_lost = false;
|
||||
let press_target: Option<crate::widget::FrameHandle> = if down {
|
||||
let mut model = self.model_mut();
|
||||
@@ -328,7 +384,12 @@ impl UiScript {
|
||||
let held = model
|
||||
.mouse_capture
|
||||
.filter(|&c| model.arena.frame(c).is_some());
|
||||
held.or_else(|| hit_id.and_then(|id| model.id_to_frame.get(&id).copied()))
|
||||
let hover = if model.moving.is_some() {
|
||||
model.mouseover.filter(|&h| model.arena.frame(h).is_some())
|
||||
} else {
|
||||
hit_id.and_then(|id| model.id_to_frame.get(&id).copied())
|
||||
};
|
||||
held.or(hover)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -347,9 +408,9 @@ impl UiScript {
|
||||
// ── The title region swallows the press ─────────────────────────────────────────────────
|
||||
//
|
||||
// A press inside the target's title region starts a mode-2 move and ends there:
|
||||
// `0x7662c0` tests the region first and only a miss reaches `0x7663e6`, the capture and
|
||||
// `OnMouseDown`. So no `OnMouseDown`, drag arm, `OnClick` or double-click stamp, and the
|
||||
// event is consumed.
|
||||
// `0x7662c0` tests the region before the capture (`0x766397`–`0x7663e5`) and only a miss
|
||||
// reaches `0x7663e6`, the capture and `OnMouseDown`. So no `OnMouseDown`, drag arm,
|
||||
// `OnClick` or double-click stamp, and the event is consumed.
|
||||
if let Some(h) = press_target {
|
||||
if self.title_region_hit(h, x, y) {
|
||||
self.model_mut().cursor_pos = (x, y);
|
||||
@@ -357,11 +418,14 @@ impl UiScript {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// ── The release ends a title move ───────────────────────────────────────────────────────
|
||||
// ── The release ends a title move, and that is all it does ─────────────────────────────
|
||||
//
|
||||
// `0x766420` cancels move modes 1 (modifier drag) and 2 (title region) on release and
|
||||
// leaves mode 3 (`StartMoving`) to the addon's `StopMovingOrSizing`;
|
||||
// `FrameMove::auto_stop` is that distinction.
|
||||
// `0x766420` tests the drag mode before anything else (`0x76646d`–`0x76647c`): any release
|
||||
// during mode 1 (modifier drag) or 2 (title region) clears the slot (`0x765640`) and
|
||||
// returns consumed, with no `OnMouseUp`, drag stop or click on the capture, which it
|
||||
// leaves held for a later release. Mode 3 (`StartMoving`) is left to the addon's
|
||||
// `StopMovingOrSizing`; `FrameMove::auto_stop` is that distinction. The button is up all
|
||||
// the same, so its own press entry goes.
|
||||
if !down
|
||||
&& self
|
||||
.model_ref()
|
||||
@@ -369,19 +433,17 @@ impl UiScript {
|
||||
.as_ref()
|
||||
.is_some_and(|m| m.auto_stop)
|
||||
{
|
||||
self.model_mut().moving = None;
|
||||
let mut model = self.model_mut();
|
||||
model.moving = None;
|
||||
model.mouse_down_on.remove(button);
|
||||
return true;
|
||||
}
|
||||
// The press this release belongs to, read before the borrow below drains `mouse_down_on`.
|
||||
// The frame this release goes to, read before the borrow below drains `mouse_down_on`.
|
||||
let captured_id: Option<u32> = if down {
|
||||
None
|
||||
} else {
|
||||
let model = self.model_ref();
|
||||
model
|
||||
.mouse_down_on
|
||||
.get(button)
|
||||
.copied()
|
||||
.flatten()
|
||||
.and_then(|h| model.frame_to_id.get(&h).copied())
|
||||
release_target(&model, button).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();
|
||||
@@ -456,8 +518,8 @@ impl UiScript {
|
||||
.and(press_id);
|
||||
(click, None, false, jump, None, abandoned, color_jump)
|
||||
} else {
|
||||
let pressed = release_target(&model, 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);
|
||||
@@ -539,43 +601,6 @@ impl UiScript {
|
||||
self.push_error(e);
|
||||
}
|
||||
}
|
||||
// A release in a hyperlink span fires `OnHyperlinkClick(link, markup, button)` on the
|
||||
// owning frame, except after a started drag.
|
||||
if !down && !drag_release.as_ref().is_some_and(|r| r.started) {
|
||||
if let Some(id) = hit_id {
|
||||
let span = {
|
||||
let model = self.model_ref();
|
||||
model
|
||||
.id_to_frame
|
||||
.get(&id)
|
||||
.and_then(|fh| model.link_spans.get(fh))
|
||||
.and_then(|spans| {
|
||||
spans
|
||||
.iter()
|
||||
.find(|(r, _, _)| point_in_rect(*r, x, y))
|
||||
.map(|(_, l, m)| (l.clone(), m.clone()))
|
||||
})
|
||||
};
|
||||
if let Some((link, markup)) = span {
|
||||
let args = match (
|
||||
self.lua.create_string(&link),
|
||||
self.lua.create_string(&markup),
|
||||
) {
|
||||
(Ok(l), Ok(m)) => {
|
||||
vec![Value::String(l), Value::String(m), btn.clone()]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
if !args.is_empty() {
|
||||
if let Err(e) =
|
||||
event::fire_widget_handler(&self.lua, id, "OnHyperlinkClick", args)
|
||||
{
|
||||
self.push_error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A left press in an EditBox places the cursor, starts a selection drag and focuses it
|
||||
// whatever its `autoFocus`, before any `OnClick` (`0x77b800`).
|
||||
if button == "LeftButton" {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
//! A text frame's hyperlink span is a frame of its own, a `CSimpleHyperlinkButton` (`0x7a3240`):
|
||||
//! a mouse-enabled Button child that takes the hover, the press and the click, and hands its
|
||||
//! enter, leave and click to the text frame's `OnHyperlinkEnter`/`OnHyperlinkLeave`/
|
||||
//! `OnHyperlinkClick`.
|
||||
|
||||
use super::common::script;
|
||||
use crate::layout::Rect;
|
||||
use crate::script::UiScript;
|
||||
|
||||
const LINK: &str = "item:7073";
|
||||
const MARKUP: &str = "|Hitem:7073|h[Broken Fang]|h";
|
||||
|
||||
/// A chat-like `CF` as `FloatingChatFrameTemplate` builds it (movable, toplevel, a full-frame title
|
||||
/// region, no mouse) at (100, 100) 430×120 on 800×600, with one link span over [110..160]×[120..134].
|
||||
fn chat() -> UiScript {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r##"
|
||||
log = {}
|
||||
CF = CreateFrame("ScrollingMessageFrame", "CF")
|
||||
CF:SetToplevel(true); CF:SetMovable(true)
|
||||
CF:SetPoint("BOTTOMLEFT", 100, 100); CF:SetWidth(430); CF:SetHeight(120)
|
||||
CF:CreateTitleRegion():SetAllPoints(CF)
|
||||
CF:SetScript("OnHyperlinkClick", function()
|
||||
table.insert(log, "click:"..arg1.."#"..arg2.."#"..arg3)
|
||||
end)
|
||||
"##,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
set_link(&mut s);
|
||||
s
|
||||
}
|
||||
|
||||
fn set_link(s: &mut UiScript) {
|
||||
let cf = s.model_ref().arena.lookup("CF").expect("CF");
|
||||
s.set_link_spans(vec![(
|
||||
cf,
|
||||
Rect::new(120.0, 110.0, 134.0, 160.0),
|
||||
LINK.to_string(),
|
||||
MARKUP.to_string(),
|
||||
)]);
|
||||
}
|
||||
|
||||
fn log_of(s: &UiScript) -> String {
|
||||
s.eval::<String>("return table.concat(log, ' ')").unwrap()
|
||||
}
|
||||
|
||||
fn click(s: &mut UiScript, (x, y): (f32, f32), button: &str) {
|
||||
s.mouse_move(x, y);
|
||||
s.mouse_button(x, y, button, true);
|
||||
s.mouse_button(x, y, button, false);
|
||||
}
|
||||
|
||||
/// The press lands on the link's button, whose title-region test finds none, so the chat window
|
||||
/// is neither pinned nor user-placed; the button's click (`0x7a3510`) fires the window's
|
||||
/// `OnHyperlinkClick(link, text, button)` (`0x7a37e0`), for either button (`0x500`, `0x7a327d`).
|
||||
#[test]
|
||||
fn a_link_click_fires_on_hyperlink_click_and_leaves_the_window_alone() {
|
||||
let mut s = chat();
|
||||
click(&mut s, (130.0, 127.0), "LeftButton");
|
||||
click(&mut s, (130.0, 127.0), "RightButton");
|
||||
assert_eq!(
|
||||
log_of(&s),
|
||||
format!("click:{LINK}#{MARKUP}#LeftButton click:{LINK}#{MARKUP}#RightButton")
|
||||
);
|
||||
assert!(!s.eval::<bool>("return CF:IsUserPlaced()").unwrap());
|
||||
let (point, x, y) = s
|
||||
.eval::<(String, f64, f64)>("local p, _, _, x, y = CF:GetPoint() return p, x, y")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(point.as_str(), x, y),
|
||||
("BOTTOMLEFT", 100.0, 100.0),
|
||||
"never pinned"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The hover over a link is the link's button, a child of the window, not the window.
|
||||
#[test]
|
||||
fn the_mouse_focus_over_a_link_is_its_button() {
|
||||
let mut s = chat();
|
||||
s.mouse_move(130.0, 127.0);
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"local f = GetMouseFocus() return f ~= nil and f ~= CF and f:GetParent() == CF"
|
||||
)
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>("return GetMouseFocus():GetObjectType() == 'Button'")
|
||||
.unwrap());
|
||||
s.mouse_move(300.0, 150.0);
|
||||
assert!(
|
||||
s.eval::<bool>("return GetMouseFocus() == nil").unwrap(),
|
||||
"the body takes no mouse"
|
||||
);
|
||||
}
|
||||
|
||||
/// The button's enter and leave (`0x7a34b0`/`0x7a34e0`) fire the window's `OnHyperlinkEnter` and
|
||||
/// `OnHyperlinkLeave` with the link alone (`"%s"`, `0x7a37a0`); a span that goes away while hovered
|
||||
/// hides its button, whose removal leaves.
|
||||
#[test]
|
||||
fn hovering_a_link_fires_on_hyperlink_enter_and_leave() {
|
||||
let mut s = chat();
|
||||
s.run(
|
||||
r#"
|
||||
CF:SetScript("OnHyperlinkEnter", function() table.insert(log, "enter:"..arg1) end)
|
||||
CF:SetScript("OnHyperlinkLeave", function() table.insert(log, "leave:"..arg1) end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.mouse_move(130.0, 127.0);
|
||||
s.mouse_move(300.0, 150.0);
|
||||
s.mouse_move(130.0, 127.0);
|
||||
s.set_link_spans(Vec::new());
|
||||
assert_eq!(
|
||||
log_of(&s),
|
||||
format!("enter:{LINK} leave:{LINK} enter:{LINK} leave:{LINK}")
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// With the body made hittable, a press on it outside a link title-drags the window, and a press
|
||||
/// on the link, a child a level above it, still clicks the link.
|
||||
#[test]
|
||||
fn a_hittable_body_title_drags_but_its_link_still_clicks() {
|
||||
let mut s = chat();
|
||||
s.run("CF:EnableMouse(true)").unwrap();
|
||||
s.resolve();
|
||||
click(&mut s, (130.0, 127.0), "LeftButton");
|
||||
assert_eq!(log_of(&s), format!("click:{LINK}#{MARKUP}#LeftButton"));
|
||||
assert!(!s.eval::<bool>("return CF:IsUserPlaced()").unwrap());
|
||||
s.mouse_move(300.0, 150.0);
|
||||
s.mouse_button(300.0, 150.0, "LeftButton", true);
|
||||
s.mouse_move(350.0, 170.0);
|
||||
s.mouse_button(350.0, 170.0, "LeftButton", false);
|
||||
s.resolve();
|
||||
assert_eq!(s.eval::<f64>("return CF:GetLeft()").unwrap(), 150.0);
|
||||
assert!(s.eval::<bool>("return CF:IsUserPlaced()").unwrap());
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
@@ -590,7 +590,6 @@ fn the_unfired_script_kinds_still_raise_rather_than_silently_accepting() {
|
||||
// A 2.0 secure-frame slot; no 1.12 resolver has it.
|
||||
"OnAttributeChanged",
|
||||
// Real 1.12 slots that benilla does not fire.
|
||||
"OnHyperlinkEnter",
|
||||
"OnMessageScrollChanged",
|
||||
"OnInputLanguageChanged",
|
||||
] {
|
||||
@@ -846,18 +845,16 @@ fn hiding_another_frame_keeps_the_press() {
|
||||
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.
|
||||
/// The release handler reads only the capture (`0x76642b`) and its mouse-up (`0x76c040`) fires
|
||||
/// `OnMouseUp` with no test of which button pressed, so a release of a button never pressed goes
|
||||
/// to the frame another button holds.
|
||||
#[test]
|
||||
fn the_capture_is_per_button() {
|
||||
fn a_release_goes_to_the_capture_whatever_its_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"
|
||||
);
|
||||
assert_eq!(log_of(&mut s), "A:down A:up");
|
||||
}
|
||||
|
||||
/// A disabled Button fires neither `OnEnter` nor `OnLeave`: `CSimpleButton::OnEnter` (`0x779490`)
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
//! `SetLayoutMode` and the modifier drag it arms: while the UI root's mode (`+0xcf8`) is non-zero,
|
||||
//! a press with Control or Alt held (`0x766301`–`0x76634e`) moves or sizes the toplevel window
|
||||
//! under the cursor (`0x765490`) and reaches nothing else.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::UiScript;
|
||||
|
||||
/// A toplevel, movable and resizable `Win` at (100, 100) 200×100 with a child `Kid` over its
|
||||
/// middle, on 800×600, every press and release logged.
|
||||
fn window() -> UiScript {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
log = {}
|
||||
local function note(f)
|
||||
f:SetScript("OnMouseDown", function() table.insert(log, f:GetName()..":down") end)
|
||||
f:SetScript("OnMouseUp", function() table.insert(log, f:GetName()..":up") end)
|
||||
end
|
||||
Win = CreateFrame("Frame", "Win")
|
||||
Win:SetToplevel(true); Win:SetMovable(true); Win:SetResizable(true)
|
||||
Win:SetPoint("BOTTOMLEFT", 100, 100); Win:SetWidth(200); Win:SetHeight(100)
|
||||
Win:EnableMouse(true)
|
||||
note(Win)
|
||||
Kid = CreateFrame("Button", "Kid", Win)
|
||||
Kid:SetPoint("CENTER", Win, "CENTER", 0, 0); Kid:SetWidth(40); Kid:SetHeight(20)
|
||||
Kid:EnableMouse(true)
|
||||
note(Kid)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s
|
||||
}
|
||||
|
||||
/// `(left, bottom, width, height)` of a frame, after a resolve.
|
||||
fn rect(s: &mut UiScript, name: &str) -> (f64, f64, f64, f64) {
|
||||
s.resolve();
|
||||
s.eval::<(f64, f64, f64, f64)>(&format!(
|
||||
"local f = getglobal('{name}') return f:GetLeft(), f:GetBottom(), f:GetWidth(), f:GetHeight()"
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn log_of(s: &UiScript) -> String {
|
||||
s.eval::<String>("return table.concat(log, ' ')").unwrap()
|
||||
}
|
||||
|
||||
/// A press and a move by `(dx, dy)`, then the release, with `(ctrl, alt)` held throughout.
|
||||
fn modifier_drag(
|
||||
s: &mut UiScript,
|
||||
(x, y): (f32, f32),
|
||||
(dx, dy): (f32, f32),
|
||||
(ctrl, alt): (bool, bool),
|
||||
) {
|
||||
s.set_modifiers(false, ctrl, alt);
|
||||
s.mouse_move(x, y);
|
||||
s.mouse_button(x, y, "LeftButton", true);
|
||||
s.mouse_move(x + dx, y + dy);
|
||||
s.mouse_button(x + dx, y + dy, "LeftButton", false);
|
||||
s.set_modifiers(false, false, false);
|
||||
}
|
||||
|
||||
/// Alt over the child moves its toplevel window (`0x766fe0` lifts the hit), marks it user-placed
|
||||
/// (`0x7652e5`), and the press reaches no script: the branch returns before the capture and the
|
||||
/// press slot. The release ends the move (`0x766420`, mode 1) and reaches no script either.
|
||||
#[test]
|
||||
fn an_alt_press_in_layout_mode_moves_the_toplevel_window() {
|
||||
let mut s = window();
|
||||
s.run("SetLayoutMode(1)").unwrap();
|
||||
assert!(!s.eval::<bool>("return Win:IsUserPlaced()").unwrap());
|
||||
modifier_drag(&mut s, (200.0, 150.0), (60.0, 20.0), (false, true));
|
||||
assert_eq!(rect(&mut s, "Win"), (160.0, 120.0, 200.0, 100.0));
|
||||
assert!(s.eval::<bool>("return Win:IsUserPlaced()").unwrap());
|
||||
s.mouse_move(400.0, 400.0);
|
||||
assert_eq!(
|
||||
rect(&mut s, "Win"),
|
||||
(160.0, 120.0, 200.0, 100.0),
|
||||
"the release ended it"
|
||||
);
|
||||
assert_eq!(
|
||||
log_of(&s),
|
||||
"",
|
||||
"neither the press nor the release reached a script"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// Control sizes from the cell of a 3×3 grid the press falls in, the rect cut at a quarter and
|
||||
/// three quarters per axis (`0x765384`–`0x765464`); the middle cell moves. A cut belongs to the
|
||||
/// cell past it: x at exactly a quarter is the middle column.
|
||||
#[test]
|
||||
fn a_control_press_sizes_from_the_grid_cell_and_the_middle_moves() {
|
||||
let mut s = window();
|
||||
s.run("SetLayoutMode(1)").unwrap();
|
||||
// Top-right cell: right of 250, above 175.
|
||||
modifier_drag(&mut s, (290.0, 195.0), (20.0, 10.0), (true, false));
|
||||
assert_eq!(
|
||||
rect(&mut s, "Win"),
|
||||
(100.0, 100.0, 220.0, 110.0),
|
||||
"TOPRIGHT"
|
||||
);
|
||||
// Now 220×110 from (100, 100): the left column is x < 155, the middle row 127.5..182.5.
|
||||
modifier_drag(&mut s, (154.0, 150.0), (-10.0, 0.0), (true, false));
|
||||
assert_eq!(rect(&mut s, "Win"), (90.0, 100.0, 230.0, 110.0), "LEFT");
|
||||
// Now 230×110 from (90, 100): x 147.5 is exactly a quarter in.
|
||||
modifier_drag(&mut s, (147.5, 150.0), (10.0, 10.0), (true, false));
|
||||
assert_eq!(
|
||||
rect(&mut s, "Win"),
|
||||
(100.0, 110.0, 230.0, 110.0),
|
||||
"the middle cell moves"
|
||||
);
|
||||
assert_eq!(log_of(&s), "");
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The window must be movable for Alt and resizable for Control (`0x765553`–`0x765573`); a press
|
||||
/// that starts nothing is consumed all the same (`0x766353`).
|
||||
#[test]
|
||||
fn a_window_without_the_flag_does_not_move_and_still_eats_the_press() {
|
||||
let mut s = window();
|
||||
s.run("SetLayoutMode(1) Win:SetResizable(false)").unwrap();
|
||||
modifier_drag(&mut s, (290.0, 195.0), (20.0, 10.0), (true, false));
|
||||
assert_eq!(
|
||||
rect(&mut s, "Win"),
|
||||
(100.0, 100.0, 200.0, 100.0),
|
||||
"Control needs resizable"
|
||||
);
|
||||
s.run("Win:SetResizable(true) Win:SetMovable(false)")
|
||||
.unwrap();
|
||||
modifier_drag(&mut s, (200.0, 150.0), (60.0, 20.0), (false, true));
|
||||
assert_eq!(
|
||||
rect(&mut s, "Win"),
|
||||
(100.0, 100.0, 200.0, 100.0),
|
||||
"Alt needs movable"
|
||||
);
|
||||
assert!(!s.eval::<bool>("return Win:IsUserPlaced()").unwrap());
|
||||
assert_eq!(log_of(&s), "", "both presses were consumed");
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// With the mode 0, or without Control or Alt, a press takes the ordinary path. The mode starts at
|
||||
/// 0 (`0x7642a5`), and `SetLayoutMode` truncates a number (`0x40a2b0`) and stores 1 for anything
|
||||
/// else (`0x488573`).
|
||||
#[test]
|
||||
fn mode_zero_or_no_modifier_passes_the_press_through() {
|
||||
let mut s = window();
|
||||
modifier_drag(&mut s, (200.0, 150.0), (60.0, 20.0), (false, true));
|
||||
assert_eq!(log_of(&s), "Kid:down Kid:up", "mode 0 at start");
|
||||
s.run("log = {} SetLayoutMode(0.9)").unwrap();
|
||||
modifier_drag(&mut s, (200.0, 150.0), (60.0, 20.0), (false, true));
|
||||
assert_eq!(log_of(&s), "Kid:down Kid:up", "0.9 truncates to 0");
|
||||
s.run("log = {} SetLayoutMode('x')").unwrap();
|
||||
modifier_drag(&mut s, (200.0, 150.0), (60.0, 20.0), (false, false));
|
||||
assert_eq!(log_of(&s), "Kid:down Kid:up", "no modifier held");
|
||||
assert_eq!(rect(&mut s, "Win"), (100.0, 100.0, 200.0, 100.0));
|
||||
modifier_drag(&mut s, (200.0, 150.0), (60.0, 20.0), (false, true));
|
||||
assert_eq!(
|
||||
rect(&mut s, "Win"),
|
||||
(160.0, 120.0, 200.0, 100.0),
|
||||
"a non-number stored 1"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `0x765490`'s own walk: strata FULLSCREEN_DIALOG down to WORLD, never TOOLTIP (`0x7654a3` starts
|
||||
/// below `root+0xcf4`), probing with `0x76b020`, which reads no mouse bit. A hit with no toplevel
|
||||
/// self-or-ancestor gives up its whole stratum and the walk goes on below it (`0x76554a` →
|
||||
/// `0x765532`).
|
||||
#[test]
|
||||
fn the_walk_skips_tooltip_and_a_stratum_whose_hit_has_no_toplevel() {
|
||||
let mut s = window();
|
||||
s.run(
|
||||
r#"
|
||||
Win:EnableMouse(false); Kid:EnableMouse(false)
|
||||
Cover = CreateFrame("Frame", "Cover")
|
||||
Cover:SetFrameStrata("HIGH")
|
||||
Cover:SetPoint("BOTTOMLEFT", 0, 0); Cover:SetWidth(800); Cover:SetHeight(600)
|
||||
Top = CreateFrame("Frame", "Top", Cover)
|
||||
Top:SetToplevel(true); Top:SetMovable(true)
|
||||
Top:SetPoint("BOTTOMLEFT", 500, 500); Top:SetWidth(50); Top:SetHeight(50)
|
||||
Cover:SetFrameLevel(5)
|
||||
Under = CreateFrame("Frame", "Under")
|
||||
Under:SetFrameStrata("HIGH"); Under:SetToplevel(true); Under:SetMovable(true)
|
||||
Under:SetPoint("BOTTOMLEFT", 150, 120); Under:SetWidth(100); Under:SetHeight(60)
|
||||
Tip = CreateFrame("Frame", "Tip")
|
||||
Tip:SetFrameStrata("TOOLTIP"); Tip:SetToplevel(true); Tip:SetMovable(true)
|
||||
Tip:SetPoint("BOTTOMLEFT", 150, 120); Tip:SetWidth(100); Tip:SetHeight(60)
|
||||
SetLayoutMode(1)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
modifier_drag(&mut s, (200.0, 150.0), (60.0, 20.0), (false, true));
|
||||
assert_eq!(rect(&mut s, "Win"), (160.0, 120.0, 200.0, 100.0));
|
||||
assert_eq!(rect(&mut s, "Tip"), (150.0, 120.0, 100.0, 60.0));
|
||||
assert_eq!(
|
||||
rect(&mut s, "Under"),
|
||||
(150.0, 120.0, 100.0, 60.0),
|
||||
"below Cover in HIGH"
|
||||
);
|
||||
assert_eq!(rect(&mut s, "Cover"), (0.0, 0.0, 800.0, 600.0));
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// A release while the modifier drag holds the slot ends it and nothing else: no `OnMouseUp` on
|
||||
/// the capture a chorded press left (`0x76646d` → `0x7664e0`).
|
||||
#[test]
|
||||
fn a_release_that_ends_a_layout_drag_reaches_no_script() {
|
||||
let mut s = window();
|
||||
s.run("SetLayoutMode(1)").unwrap();
|
||||
s.mouse_move(120.0, 110.0);
|
||||
s.mouse_button(120.0, 110.0, "RightButton", true); // capture Win
|
||||
s.set_modifiers(false, false, true);
|
||||
s.mouse_button(120.0, 110.0, "LeftButton", true); // the layout drag
|
||||
s.set_modifiers(false, false, false);
|
||||
s.mouse_button(120.0, 110.0, "RightButton", false);
|
||||
assert_eq!(
|
||||
log_of(&s),
|
||||
"Win:down",
|
||||
"the right release reached no script"
|
||||
);
|
||||
s.mouse_move(220.0, 110.0);
|
||||
assert_eq!(rect(&mut s, "Win").0, 100.0, "and ended the drag");
|
||||
// The capture is still Win: the left release, the drag over, goes to it (`0x766498`–
|
||||
// `0x7664a4`) and, with no button held, clears it (`0x7664bb`).
|
||||
s.run("Win:SetScript('OnMouseUp', function() table.insert(log, 'Win:up:'..arg1) end)")
|
||||
.unwrap();
|
||||
s.mouse_button(220.0, 110.0, "LeftButton", false);
|
||||
assert_eq!(log_of(&s), "Win:down Win:up:LeftButton");
|
||||
s.mouse_move(200.0, 150.0);
|
||||
s.mouse_button(200.0, 150.0, "LeftButton", true);
|
||||
s.mouse_button(200.0, 150.0, "LeftButton", false);
|
||||
assert_eq!(
|
||||
log_of(&s),
|
||||
"Win:down Win:up:LeftButton Kid:down Kid:up",
|
||||
"no capture or held button is left behind"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// A layout press is consumed even where it hits nothing: the handler returns before any frame,
|
||||
/// the world's included, sees it (`0x766353`–`0x76635b`).
|
||||
#[test]
|
||||
fn a_layout_press_over_nothing_is_consumed() {
|
||||
let mut s = window();
|
||||
s.set_modifiers(false, false, true);
|
||||
assert!(!s.mouse_button(700.0, 500.0, "RightButton", true), "mode 0");
|
||||
s.mouse_button(700.0, 500.0, "RightButton", false);
|
||||
s.run("SetLayoutMode(1)").unwrap();
|
||||
assert!(s.mouse_button(700.0, 500.0, "RightButton", true));
|
||||
}
|
||||
|
||||
/// `SetLayoutMode` keeps the low dword of `_ftol` (`0x40a2b0`, `fistp` to a qword): what the x87
|
||||
/// cannot hold (an infinity, NaN, 2^63 and up) stores its integer-indefinite pattern, whose low
|
||||
/// dword is 0, so those turn the mode off; 2^32 + 1 keeps 1.
|
||||
#[test]
|
||||
fn set_layout_mode_keeps_the_low_dword_of_ftol() {
|
||||
let mut s = window();
|
||||
let armed = |s: &mut UiScript, arg: &str| {
|
||||
s.run(&format!("log = {{}} SetLayoutMode({arg})")).unwrap();
|
||||
modifier_drag(s, (200.0, 150.0), (0.0, 0.0), (false, true));
|
||||
log_of(s).is_empty()
|
||||
};
|
||||
for off in ["1/0", "-1/0", "0/0", "2^63", "2^64", "2^32", "-2^63"] {
|
||||
assert!(!armed(&mut s, off), "SetLayoutMode({off}) is off");
|
||||
}
|
||||
for on in ["2^32 + 1", "-2^31", "-1.5"] {
|
||||
assert!(armed(&mut s, on), "SetLayoutMode({on}) is on");
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,12 @@ mod handler_prof;
|
||||
mod host_state;
|
||||
mod hover_publish;
|
||||
mod hover_walk;
|
||||
mod hyperlink;
|
||||
mod input;
|
||||
mod keep_rect;
|
||||
mod keyboard;
|
||||
mod layout_gate;
|
||||
mod layout_mode;
|
||||
mod layout_structure;
|
||||
mod line_spacing;
|
||||
mod measure;
|
||||
|
||||
@@ -671,3 +671,127 @@ fn the_apply_seats_position_behind_movable_and_size_behind_resizable() {
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
// ── The drag slot is taken, never refused (`0x7652b0`) ──────────────────────────────────────────
|
||||
|
||||
/// A title-region frame `A` at (100, 100) 200×80 and a movable `B` at (500, 100), on 800×600.
|
||||
fn title_and_mover() -> UiScript {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
log = {}
|
||||
A = CreateFrame("Frame", "A")
|
||||
A:SetPoint("BOTTOMLEFT", 100, 100); A:SetWidth(200); A:SetHeight(80)
|
||||
A:EnableMouse(true)
|
||||
A:SetScript("OnMouseUp", function() table.insert(log, "A:up:"..arg1) end)
|
||||
local title = A:CreateTitleRegion()
|
||||
title:SetPoint("TOPLEFT", A, "TOPLEFT", 0, 0)
|
||||
title:SetPoint("BOTTOMRIGHT", A, "TOPRIGHT", 0, -20)
|
||||
B = CreateFrame("Frame", "B")
|
||||
B:SetPoint("BOTTOMLEFT", 500, 100); B:SetWidth(100); B:SetHeight(100)
|
||||
B:SetMovable(true)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s
|
||||
}
|
||||
|
||||
fn lefts(s: &mut UiScript) -> (f64, f64) {
|
||||
s.resolve();
|
||||
s.eval::<(f64, f64)>("return A:GetLeft(), B:GetLeft()")
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A title press while a scripted `StartMoving` holds the slot takes it: the drag entry stores the
|
||||
/// new frame over the old one with no test of the slot (`0x7652f3`–`0x76530f`), and nothing runs
|
||||
/// on the frame it displaced, which simply stops following.
|
||||
#[test]
|
||||
fn a_title_press_takes_the_slot_from_a_running_move() {
|
||||
let mut s = title_and_mover();
|
||||
s.mouse_move(150.0, 170.0); // the hover: A, in its title strip
|
||||
s.run("B:StartMoving()").unwrap();
|
||||
s.mouse_button(150.0, 170.0, "LeftButton", true);
|
||||
// The slot holds A now, so B's own stop clears nothing (`0x776990` tests the holder).
|
||||
s.run("B:StopMovingOrSizing()").unwrap();
|
||||
s.mouse_move(250.0, 170.0);
|
||||
assert_eq!(
|
||||
lefts(&mut s),
|
||||
(200.0, 500.0),
|
||||
"A follows; B stopped where it was"
|
||||
);
|
||||
assert!(s.eval::<bool>("return A:IsUserPlaced()").unwrap());
|
||||
// The title move is mode 2, which the release ends.
|
||||
s.mouse_button(250.0, 170.0, "LeftButton", false);
|
||||
s.mouse_move(350.0, 170.0);
|
||||
assert_eq!(lefts(&mut s), (200.0, 500.0));
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The press goes to the capture, else the hover (`0x76637f`–`0x766389`), and a walk that finds a
|
||||
/// move in flight pumps it and returns before its sweep (`0x766164`): the hover stays the frame it
|
||||
/// was when the move began. So with a window riding the cursor (as a clicked chat tab leaves it),
|
||||
/// a press over another frame's title region reaches the riding window, not that frame.
|
||||
#[test]
|
||||
fn a_press_during_a_move_goes_to_the_hover_the_move_froze() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
log = {}
|
||||
W = CreateFrame("Frame", "W")
|
||||
W:SetPoint("BOTTOMLEFT", 100, 100); W:SetWidth(200); W:SetHeight(100)
|
||||
W:EnableMouse(true); W:SetMovable(true)
|
||||
W:SetScript("OnMouseDown", function() table.insert(log, "W:down"); this:StartMoving() end)
|
||||
T = CreateFrame("Frame", "T")
|
||||
T:SetFrameStrata("HIGH"); T:SetToplevel(true); T:SetMovable(true)
|
||||
T:SetPoint("BOTTOMLEFT", 400, 100); T:SetWidth(200); T:SetHeight(100)
|
||||
T:EnableMouse(true)
|
||||
T:SetScript("OnMouseDown", function() table.insert(log, "T:down") end)
|
||||
T:CreateTitleRegion():SetAllPoints(T)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s.mouse_move(150.0, 150.0);
|
||||
s.mouse_button(150.0, 150.0, "LeftButton", true); // W starts moving (mode 3)
|
||||
s.mouse_button(150.0, 150.0, "LeftButton", false); // which the release does not end
|
||||
s.mouse_move(450.0, 150.0); // W rides to 400, under T
|
||||
assert_eq!(s.hit_test_name(450.0, 150.0).as_deref(), Some("T"));
|
||||
s.mouse_button(450.0, 150.0, "LeftButton", true);
|
||||
s.mouse_button(450.0, 150.0, "LeftButton", false);
|
||||
s.mouse_move(500.0, 150.0);
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64)>("return W:GetLeft(), T:GetLeft()")
|
||||
.unwrap(),
|
||||
(450.0, 400.0),
|
||||
"W still rides; T never moved"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return table.concat(log, ' ')").unwrap(),
|
||||
"W:down W:down"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// A release while a title move (mode 2) holds the slot ends it and does nothing else
|
||||
/// (`0x76646d`–`0x76647c` → `0x7664e0`): no `OnMouseUp` on the capture a chorded press left.
|
||||
#[test]
|
||||
fn a_release_that_ends_a_title_move_fires_nothing() {
|
||||
let mut s = title_and_mover();
|
||||
s.mouse_move(150.0, 120.0); // A, below its title strip
|
||||
s.mouse_button(150.0, 120.0, "RightButton", true); // capture A
|
||||
s.mouse_move(150.0, 170.0);
|
||||
s.mouse_button(150.0, 170.0, "LeftButton", true); // A's title region: mode 2
|
||||
s.mouse_button(150.0, 170.0, "RightButton", false);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return table.concat(log, ' ')").unwrap(),
|
||||
"",
|
||||
"the right release ended the move and reached no script"
|
||||
);
|
||||
s.mouse_move(250.0, 170.0);
|
||||
assert_eq!(lefts(&mut s).0, 100.0, "and the move is over");
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -123,14 +123,9 @@ 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`).
|
||||
/// Whether the frame is in the mouse input index when visible: its mouse bit (`[+0xcc]` bit 2).
|
||||
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 Lua `EnableMouse` (`0x777070`). Enabling inserts with flag -1 and arms nothing
|
||||
|
||||
@@ -770,6 +770,9 @@ pub struct ButtonState {
|
||||
pub loot_slot: Option<u32>,
|
||||
/// A CheckButton's checked flag (`+0x4dc`, XML `checked`).
|
||||
pub checked: bool,
|
||||
/// A `CSimpleHyperlinkButton`'s link (`+0x4dc`) and text (`+0x4e0`), which its enter, leave
|
||||
/// and click hand to its text frame (`script::hyperlink`); `None` on any other Button.
|
||||
pub hyperlink: Option<(String, String)>,
|
||||
/// `<NormalTexture>`/`SetNormalTexture` (`+0x4bc`).
|
||||
pub normal: Option<RegionHandle>,
|
||||
/// `<PushedTexture>` (`+0x4c0`); a button with none keeps what it showed ([`Self::set_state`]).
|
||||
@@ -824,6 +827,7 @@ impl Default for ButtonState {
|
||||
loot_slot: None,
|
||||
locked: false,
|
||||
checked: false,
|
||||
hyperlink: None,
|
||||
normal: None,
|
||||
pushed: None,
|
||||
disabled: None,
|
||||
|
||||
@@ -300,7 +300,7 @@ impl Default for WidgetArena {
|
||||
/// (`0x778771`, CheckButton through it), Slider (`0x789467`), ColorSelect (`0x78b2b5`), EditBox
|
||||
/// (`0x779ced`..`0x779d03`, with char and key) and Minimap (`0x4edc38`) set its mouse bit. The
|
||||
/// ScrollFrame and ScrollingMessageFrame ctors set none; a chat link takes clicks through a
|
||||
/// mouse-enabled child per span (`0x7a3240`), which `script::pointer` models as rects.
|
||||
/// mouse-enabled Button child per span (`0x7a3240`), which `script::hyperlink` builds.
|
||||
pub fn mouse_enabled_by_ctor(kind: FrameKind) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
|
||||
Reference in New Issue
Block a user