diff --git a/crates/benilla-app/src/player/camera.rs b/crates/benilla-app/src/player/camera.rs index 33c00431..1993dab2 100644 --- a/crates/benilla-app/src/player/camera.rs +++ b/crates/benilla-app/src/player/camera.rs @@ -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, + // A press the UI consumed with nothing hovered, the layout drag's: no frame saw it, the + // world's included (`0x766353`). + press_consumed: Option>, mut rig: ResMut, cameras: Query<&Camera, With>, window: Single<&Window, With>, @@ -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); diff --git a/crates/benilla-app/src/player/camera/mouselook_tests.rs b/crates/benilla-app/src/player/camera/mouselook_tests.rs index fb813748..3c9c9ff4 100644 --- a/crates/benilla-app/src/player/camera/mouselook_tests.rs +++ b/crates/benilla-app/src/player/camera/mouselook_tests.rs @@ -178,6 +178,25 @@ fn mouselook_start_turns_the_camera_and_the_character_with_no_button_held() { assert!(w.resource::().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::().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::().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] diff --git a/crates/benilla-app/src/ui_script/chat_resize_tests.rs b/crates/benilla-app/src/ui_script/chat_resize_tests.rs index f39e7328..1ba2225c 100644 --- a/crates/benilla-app/src/ui_script/chat_resize_tests.rs +++ b/crates/benilla-app/src/ui_script/chat_resize_tests.rs @@ -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::("return MOVING_CHATFRAME == ChatFrame2") diff --git a/crates/benilla-app/src/ui_script/input.rs b/crates/benilla-app/src/ui_script/input.rs index 9937b728..e8fe34f7 100644 --- a/crates/benilla-app/src/ui_script/input.rs +++ b/crates/benilla-app/src/ui_script/input.rs @@ -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); diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index d3a5c7e5..2ccdedc6 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -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::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() diff --git a/crates/benilla-ui/src/script/binding_abi.rs b/crates/benilla-ui/src/script/binding_abi.rs index 9b76b6b6..14c8a867 100644 --- a/crates/benilla-ui/src/script/binding_abi.rs +++ b/crates/benilla-ui/src/script/binding_abi.rs @@ -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 { 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(); diff --git a/crates/benilla-ui/src/script/button.rs b/crates/benilla-ui/src/script/button.rs index 92a546ed..1fc78140 100644 --- a/crates/benilla-ui/src/script/button.rs +++ b/crates/benilla-ui/src/script/button.rs @@ -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::() + .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::().expect("model app_data"); diff --git a/crates/benilla-ui/src/script/client.rs b/crates/benilla-ui/src/script/client.rs index 8a192437..41d63671 100644 --- a/crates/benilla-ui/src/script/client.rs +++ b/crates/benilla-ui/src/script/client.rs @@ -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::() + .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\""] { diff --git a/crates/benilla-ui/src/script/hyperlink.rs b/crates/benilla-ui/src/script/hyperlink.rs new file mode 100644 index 00000000..c0dea21d --- /dev/null +++ b/crates/benilla-ui/src/script/hyperlink.rs @@ -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::().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::().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::().expect("model"); + if model.arena.frame(owner).is_none() { + return; + } + model.frame_id(owner) + }; + let args: mlua::Result> = 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::() + .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)> = 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::().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::().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 +} diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index de2c2cf3..c2ae0a3d 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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()`). diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 7df03634..5893f627 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -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>, + /// Per text frame, its pool of hyperlink buttons (`[+0x314]`), the shown ones first + /// ([`super::hyperlink`]). + pub(crate) link_buttons: HashMap>, /// Region visuals (texture, colour, text) and layout (anchors, size, justify). pub(crate) region_data: HashMap, /// Each frame's backdrop plate (`` 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, + /// `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, /// 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, diff --git a/crates/benilla-ui/src/script/mouseover.rs b/crates/benilla-ui/src/script/mouseover.rs index 3a3928bd..aadb30fb 100644 --- a/crates/benilla-ui/src/script/mouseover.rs +++ b/crates/benilla-ui/src/script/mouseover.rs @@ -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) { + // 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. diff --git a/crates/benilla-ui/src/script/object/events_regions.rs b/crates/benilla-ui/src/script/object/events_regions.rs index 9b3d0cc0..723ec352 100644 --- a/crates/benilla-ui/src/script/object/events_regions.rs +++ b/crates/benilla-ui/src/script/object/events_regions.rs @@ -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) -> mlua::Result<()> { let kind = SCRIPT_KINDS .iter() diff --git a/crates/benilla-ui/src/script/object/movable.rs b/crates/benilla-ui/src/script/object/movable.rs index aac5bd3b..a700e9e1 100644 --- a/crates/benilla-ui/src/script/object/movable.rs +++ b/crates/benilla-ui/src/script/object/movable.rs @@ -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 { + 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; diff --git a/crates/benilla-ui/src/script/object/toplevel.rs b/crates/benilla-ui/src/script/object/toplevel.rs index 77496f60..b6072abb 100644 --- a/crates/benilla-ui/src/script/object/toplevel.rs +++ b/crates/benilla-ui/src/script/object/toplevel.rs @@ -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 { +/// 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 { // 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); diff --git a/crates/benilla-ui/src/script/pointer.rs b/crates/benilla-ui/src/script/pointer.rs index ad9b5a02..9af98f1a 100644 --- a/crates/benilla-ui/src/script/pointer.rs +++ b/crates/benilla-ui/src/script/pointer.rs @@ -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 { + 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) -> 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 { + 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 `"