From 5af22799d0e74425d723b2546f51c642500e4a2f Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Mon, 12 Oct 2026 05:57:59 +0700 Subject: [PATCH] A drag pins one TOPLEFT anchor at the frame rect, as in 1.12.1 (#953) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A drag kept the frame's authored anchors: `GetPoint()` answered them afterwards, the frame kept following its old target, and a resize grip on a CENTER- or TOPLEFT-anchored frame moved the wrong edges. In 1.12.1 every drag enters at `0x7652b0`, whose first act pins the frame (`0x768430`): `ClearAllPoints`, one TOPLEFT on the screen root's TOPLEFT at the current rect, and an explicit size. The pump `0x768710` moves that anchor (case 4) or, for each of the eight grips, computes the clamped size and re-pins shifted by the left and top travel only (`0x76898a`). A frame with no rect starts no drag (`0x7652cd`). Drags now pin and pump that way, with one drag slot; `StartSizing` defaults to BOTTOMRIGHT (`0x776913`). Tests: the pin and `GetPoint` after a drag, the eight grips on a CENTER-anchored frame, the clamps, the layout cache round trip, and stock chat-window resize and off-screen drop. Fixes #173 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/src/ui_layout.rs | 10 +- .../src/ui_script/chat_resize_tests.rs | 83 ++++ crates/benilla-ui/src/layout.rs | 9 +- crates/benilla-ui/src/script/model.rs | 8 +- crates/benilla-ui/src/script/object.rs | 2 +- .../benilla-ui/src/script/object/movable.rs | 456 ++++++++++-------- .../benilla-ui/src/script/tests/drag_pin.rs | 293 +++++++++++ .../benilla-ui/src/script/tests/hover_walk.rs | 11 +- crates/benilla-ui/src/script/tests/mod.rs | 1 + crates/benilla-ui/src/script/tests/movable.rs | 13 +- 10 files changed, 655 insertions(+), 231 deletions(-) create mode 100644 crates/benilla-ui/src/script/tests/drag_pin.rs diff --git a/crates/benilla-app/src/ui_layout.rs b/crates/benilla-app/src/ui_layout.rs index 229fb3da..2894c0de 100644 --- a/crates/benilla-app/src/ui_layout.rs +++ b/crates/benilla-app/src/ui_layout.rs @@ -6,8 +6,10 @@ //! `FrameLevel:`, `X:`, `Y:`, `W:` and `H:` lines; ours is //! `benilla-config/layout/-.txt`, keeping `Frame:`, `W:` and `H:`. //! Deviation: one `Point: ` line per anchor, `-` for -//! the screen root, instead of `X:`/`Y:`, because a benilla drag moves the authored anchors -//! rather than re-anchoring to one TOPLEFT, and a pair would lose them. There is no +//! the screen root, instead of `X:`/`Y:`. A drag leaves one TOPLEFT against the screen root +//! (`0x768430`), whose line holds the reference's X and Y unrounded, where the reference saves +//! integers (`0x490fc4`–`0x490fdf`); a frame user-placed without a drag keeps its authored +//! anchors here, where the reference saves its rect's top-left. There is no //! `FrameLevel:`: the reference saves an undocked window's raise, and benilla's raise does not //! persist. //! @@ -35,8 +37,8 @@ const SCREEN_TOKEN: &str = "-"; const HEADER: &str = "\ # benilla window layout — every frame the player has moved or resized (the client's userPlaced # bit). A relative of the reference's layout-cache.txt: same scope, same Frame:/W:/H: keys, but -# anchors instead of its X:/Y: pair, because a benilla drag moves a frame's anchors rather than -# collapsing it to a screen position. `-` as a Point: target means the screen root. +# anchors instead of its X:/Y: pair; a dragged window has one, TOPLEFT on the screen's TOPLEFT. +# `-` as a Point: target means the screen root. "; /// The character's file: its path, the VM it was restored into, and whether a write is owed. 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 214b418e..f39e7328 100644 --- a/crates/benilla-app/src/ui_script/chat_resize_tests.rs +++ b/crates/benilla-app/src/ui_script/chat_resize_tests.rs @@ -591,3 +591,86 @@ fn an_undocked_tab_drags_again_and_a_plain_click_drops_the_window() { ); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } + +/// `(left, right, bottom, top)` of `frame`, after a resolve. +fn edges(s: &mut UiScript, frame: &str) -> (f32, f32, f32, f32) { + s.resolve(); + let (l, r, b, t): (f64, f64, f64, f64) = s + .eval(&format!( + "return {frame}:GetLeft(), {frame}:GetRight(), {frame}:GetBottom(), {frame}:GetTop()" + )) + .unwrap(); + (l as f32, r as f32, b as f32, t as f32) +} + +/// Unlocking a docked window re-anchors it at CENTER (`FloatingChatFrame.lua:785-792`); a grip +/// then moves only the grabbed edge, since the resize starts by pinning the window to one TOPLEFT +/// anchor at its rect (`0x7652b0` → `0x768430`). +#[test] +fn an_unlocked_centred_window_resizes_from_the_grabbed_edge_only() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut s = chat_ui(); + reveal(&mut s); + right_click(&mut s, "ChatFrame2Tab"); + assert!(s + .eval::("return DropDownList1Button1:GetText() == UNLOCK_WINDOW") + .unwrap()); + left_click(&mut s, "DropDownList1Button1"); + assert_eq!( + s.eval::<(String, i64)>("return (ChatFrame2:GetPoint(1)), ChatFrame2:GetNumPoints()") + .unwrap(), + ("CENTER".to_string(), 1), + "FCF_ToggleLock undocked it and centred it" + ); + + for (grip, dx, dy) in [("Right", 40.0, 0.0), ("Top", 0.0, 30.0)] { + reveal_window(&mut s, "ChatFrame2"); + let before = edges(&mut s, "ChatFrame2"); + let name = format!("ChatFrame2Resize{grip}"); + let (x, y) = centre(&mut s, &name); + assert_eq!(s.hit_test_name(x, y).as_deref(), Some(name.as_str())); + s.mouse_button(x, y, "LeftButton", true); + s.mouse_move(x + dx, y + dy); + s.mouse_button(x + dx, y + dy, "LeftButton", false); + let after = edges(&mut s, "ChatFrame2"); + assert_eq!( + after, + (before.0, before.1 + dx, before.2, before.3 + dy), + "the {grip} grip moved its own edge and no other" + ); + } + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + +/// A tab dropped off the bottom of the screen: `FCF_ValidateChatFramePosition`'s +/// `SetPoint("TOPLEFT", …)` (`FloatingChatFrame.lua:1304-1334`) replaces the drag's one TOPLEFT +/// anchor, so the window comes back whole at its own size. +#[test] +fn a_tab_dropped_off_screen_puts_the_window_back_whole() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut s = chat_ui(); + s.run("FCF_SetLocked(ChatFrame1, nil)").unwrap(); + let (w, h) = (width(&mut s), height(&mut s)); + drag_tab(&mut s, 0.0, -600.0); + let (n, point, rel, rp) = s + .eval::<(i64, String, String, String)>( + "local p, rel, rp = ChatFrame1:GetPoint(1) \ + return ChatFrame1:GetNumPoints(), p, rel:GetName(), rp", + ) + .unwrap(); + assert_eq!( + (n, point.as_str(), rel.as_str(), rp.as_str()), + (1, "TOPLEFT", "UIParent", "TOPLEFT"), + "the validate's anchor is the only one" + ); + assert_eq!((width(&mut s), height(&mut s)), (w, h), "at its own size"); + let want_top: f64 = s + .eval("return MainMenuBar:GetHeight() + ChatFrame1Tab:GetHeight()") + .unwrap(); + let top: f64 = s.eval("return ChatFrame1:GetTop()").unwrap(); + assert!( + (top - want_top).abs() < 1e-3, + "back on screen: {top} vs {want_top}" + ); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} diff --git a/crates/benilla-ui/src/layout.rs b/crates/benilla-ui/src/layout.rs index 6f20c213..d71fd251 100644 --- a/crates/benilla-ui/src/layout.rs +++ b/crates/benilla-ui/src/layout.rs @@ -249,13 +249,12 @@ impl Anchors { self.0.iter().flatten() } - /// Each held anchor's offsets in point order; the point is not writable here, so an anchor + /// The offsets of the anchor held for `point`; the point is not writable here, so an anchor /// never leaves its slot. #[inline] - pub fn offsets_mut(&mut self) -> impl Iterator { - self.0 - .iter_mut() - .flatten() + pub fn offset_mut(&mut self, point: Point) -> Option<(&mut f32, &mut f32)> { + self.0[point.id() as usize] + .as_mut() .map(|a| (&mut a.x_off, &mut a.y_off)) } diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 8b95202b..7df03634 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -639,11 +639,10 @@ pub(crate) struct Model { pub(crate) drag_registered: HashMap>, /// The drag gesture, armed by a press on a drag-registered frame, started past the threshold. pub(crate) drag: Option, - /// The one `StartMoving()` in flight, the client's `root+0xcfc` slot, apart from `drag` as in - /// the reference: a move outlives the button, since mouse-up's auto-stop skips a Lua drag. + /// The one move or size in flight, the client's `root+0xcfc` slot with its grip (`+0xd04`), + /// 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, - /// The `StartSizing` in flight, cleared by `StopMovingOrSizing` (`0x776990`). - pub(crate) sizing: Option, /// 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. @@ -1338,7 +1337,6 @@ impl Model { drag_registered: HashMap::new(), drag: None, moving: None, - sizing: None, slider_drag: None, color_drag: None, gossip: None, diff --git a/crates/benilla-ui/src/script/object.rs b/crates/benilla-ui/src/script/object.rs index b88c016f..b2f3734b 100644 --- a/crates/benilla-ui/src/script/object.rs +++ b/crates/benilla-ui/src/script/object.rs @@ -25,7 +25,7 @@ pub(crate) use layout_methods::eff_scale; pub(in crate::script) use layout_methods::settle; pub(crate) mod movable; pub(crate) mod toplevel; -pub(crate) use movable::{cancel_on_hide, pump_move_or_size, FrameMove, FrameSizing}; +pub(crate) use movable::{cancel_on_hide, pump_move_or_size, FrameMove}; // ───────────────────────────────────────────────────────────────────────────────────────────── // id ↔ lightuserdata diff --git a/crates/benilla-ui/src/script/object/movable.rs b/crates/benilla-ui/src/script/object/movable.rs index d1194a36..aac5bd3b 100644 --- a/crates/benilla-ui/src/script/object/movable.rs +++ b/crates/benilla-ui/src/script/object/movable.rs @@ -1,47 +1,39 @@ -//! Frame methods: movable and resizable windows, and the drag pumps behind `StartMoving` and +//! Frame methods: movable and resizable windows, and the drag pump behind `StartMoving` and //! `StartSizing`. The gesture that usually calls them, `OnDragStart`/`OnDragStop` (`0x76bf70`/ //! `0x76c040`), is [`crate::script::cursor`]'s. //! //! The flags are bits of `[frame+0xb4]`, set by `0x76a3c0`: movable `0x100`, resizable `0x200`, -//! userPlaced `0x1000`. A drag (`0x7652b0`) re-anchors the frame to one TOPLEFT against the screen -//! root at its current rect (`0x768430`), raises it, sets userPlaced and takes the one drag slot -//! (`root+0xcfc`). The pump (`0x7655b0`) adds each cursor delta, over the frame's scale, straight -//! into that anchor's offsets (`0x768710` case 4), so stopping (`0x765640`) writes nothing. -//! -//! Here nothing re-anchors: a move translates every authored anchor, and a resize keeps the set. +//! userPlaced `0x1000`. Every drag enters at `0x7652b0`, which pins the frame to one TOPLEFT +//! anchor against the screen root at its current rect, with an explicit size (`0x768430`), then +//! raises it, sets userPlaced and takes the one drag slot (`root+0xcfc`) with its grip (`+0xd04`). +//! The pump (`0x7655b0` → `0x768710`) works on that anchor and size: a move, grip CENTER, adds +//! each cursor delta over the frame's scale to the anchor's offsets (case 4), and each of the +//! eight grips sizes the frame and re-pins it shifted by its left and top travel only. Stopping +//! (`0x765640`) writes nothing, so after any drag `GetPoint` answers that one TOPLEFT. use mlua::{Lua, MultiValue, Table, Value}; -use crate::script::Model; +use crate::layout::{Anchor, Anchors, Point}; +use crate::script::{Model, UiScript, SCREEN}; use crate::widget::FrameHandle; -use super::frame_handle_of; use super::layout_methods::eff_scale; +use super::{frame_handle_of, point_from_str}; -/// The one in-flight move, the reference's drag slot, held in [`Model::moving`]. +/// The one move or size in flight, the reference's drag slot, held in [`Model::moving`]. #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct FrameMove { - /// The frame being moved (`root+0xcfc`). + /// The frame being dragged (`root+0xcfc`). frame: FrameHandle, + /// The grip (`root+0xd04`): CENTER moves the frame, any other point resizes it from there. + grip: Point, /// The cursor position at the last pump (`root+0xd08`/`+0xd0c`), UI px, y-up. sample: (f32, f32), - /// Whether mouse-up ends the move, the drag mode as Lua sees it: `0x766420` ends modes 1 - /// (modifier drag) and 2 (title region), never 3 (`StartMoving`). + /// Whether mouse-up ends the drag, the drag mode as Lua sees it: `0x766420` ends modes 1 + /// (modifier drag) and 2 (title region), never 3 (`StartMoving`, `StartSizing`). pub(crate) auto_stop: bool, } -/// An in-flight `StartSizing` drag: which frame, which grip, and the cursor at the last pump. -#[derive(Clone, Copy, Debug, PartialEq)] -pub(crate) struct FrameSizing { - frame: FrameHandle, - /// The edges the named grip moves: `LEFT` the left edge, `BOTTOMRIGHT` the bottom and right. - left: bool, - right: bool, - top: bool, - bottom: bool, - sample: (f32, f32), -} - /// Populate `m`'s movable and resizable methods. pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { // SetMovable / IsMovable (`0x776420`/`0x7764d0`): a pure flag write, so clearing it mid-drag @@ -172,48 +164,28 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { start_moving(&mut model, h) })?, )?; - // StartSizing(point) (`0x776830`; stock caller `FloatingChatFrame.lua:600`): a resize from the - // named grip, ended by `StopMovingOrSizing`. The reference's per-grip switch (`0x768710`) - // moves the named edges with the cursor and keeps the opposite ones. + // StartSizing([point]) (`0x776830`; stock caller `FloatingChatFrame.lua:600`): a resize from + // the named grip, ended by `StopMovingOrSizing`. The grip starts as BOTTOMRIGHT (`0x776913`) + // and only a string naming a point, in any case, replaces it (`0x77691a`, `0x6f1840`). m.set( "StartSizing", - lua.create_function(|lua, (this, point): (Table, Option)| { + lua.create_function(|lua, (this, point): (Table, Value)| { let h = frame_handle_of(lua, &this)?; let mut model = lua.app_data_mut::().expect("model"); if !model.arena.frame(h).is_some_and(|f| f.resizable) { return Err(not_flagged(&model, h, "resizable")); } - // Same "do not start a second one" guard `start_moving` carries. - if model.sizing.is_some() || model.moving.is_some() { + // The same `root+0xcfc` guard `StartMoving` carries (`0x776947`). + if model.moving.is_some() { return Ok(()); } - let p = point.unwrap_or_default().to_ascii_uppercase(); - let (left, right) = (p.contains("LEFT"), p.contains("RIGHT")); - let (top, bottom) = (p.contains("TOP"), p.contains("BOTTOM")); - // The raise and userPlaced belong to the drag entry `0x7652b0`, whatever the grip. - super::toplevel::raise(&mut model, h); - set_user_placed(&mut model, h); - let sample = model.cursor_pos; - // A CENTER grip is a move: the pump's case 4 shifts the anchor offsets and returns - // before the size clamp (`0x768bfb`). - if p == "CENTER" { - model.moving = Some(FrameMove { - frame: h, - sample, - // `StartSizing` enters as drag mode 3 (`0x7652b0`), which mouse-up never ends. - auto_stop: false, - }); - return Ok(()); + let grip = match &point { + Value::String(s) => s.to_str().ok().and_then(|t| point_from_str(&t)), + _ => None, } - // A name that is no point starts an inert drag: raised and userPlaced, moving nothing. - model.sizing = Some(FrameSizing { - frame: h, - left, - right, - top, - bottom, - sample, - }); + .unwrap_or(Point::BottomRight); + // Drag mode 3, which mouse-up never ends. + begin_drag(&mut model, h, grip, false); Ok(()) })?, )?; @@ -223,9 +195,6 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { lua.create_function(|lua, this: Table| { let h = frame_handle_of(lua, &this)?; let mut model = lua.app_data_mut::().expect("model"); - if model.sizing.is_some_and(|sz| sz.frame == h) { - model.sizing = None; - } if model.moving.is_some_and(|mv| mv.frame == h) { model.moving = None; } @@ -284,53 +253,109 @@ fn not_flagged(model: &Model, h: FrameHandle, want: &str) -> mlua::Error { } /// `StartMoving` (`0x776700` → `0x7652b0`): errors on a frame that is not movable (`0x77678b`), -/// ignores a second move, then raises the frame, sets userPlaced and takes the slot, in that order. +/// ignores a second drag, then starts a move, grip CENTER (`0x776819`). fn start_moving(model: &mut Model, h: FrameHandle) -> mlua::Result<()> { if !model.arena.frame(h).is_some_and(|f| f.movable) { return Err(not_flagged(model, h, "movable")); } // The `root+0xcfc != 0` guard (`0x7767e8`); what the reference does beyond not starting a - // second move is untraced. + // second drag is untraced. if model.moving.is_some() { return Ok(()); } - // The drag-start raise (`0x7652d7`); the worker applies the toplevel gate. - super::toplevel::raise(model, h); - set_user_placed(model, h); - model.moving = Some(FrameMove { - frame: h, - sample: model.cursor_pos, - // Mode 3: only `StopMovingOrSizing` ends it. - auto_stop: false, - }); + // Mode 3: only `StopMovingOrSizing` ends it. + begin_drag(model, h, Point::Center, false); Ok(()) } /// Begin a title-region move, drag mode 2 (`0x7662c0` → `0x765320` → `0x7652b0`), with no movable -/// check, since nothing on that path reads the bit. False when a move is already in flight. +/// 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. pub(crate) fn start_title_move(model: &mut Model, h: FrameHandle) -> bool { if model.moving.is_some() { return false; } - super::toplevel::raise(model, h); - set_user_placed(model, h); - model.moving = Some(FrameMove { - frame: h, - sample: model.cursor_pos, - auto_stop: true, - }); - true + begin_drag(model, h, Point::Center, true) } -/// Set userPlaced as the drag entry `0x7652b0` does; the layout cache is dirtied only on the -/// transition, since every title-region click runs this. -fn set_user_placed(model: &mut Model, h: FrameHandle) { +/// 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. +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; + }; + // The worker applies the toplevel gate. + super::toplevel::raise(model, h); + // The layout cache is dirtied on the bit's transition only, since every title-region click + // runs this, and again when the pin rewrote what a saved row holds. if let Some(f) = model.arena.frame_mut(h) { if !f.user_placed { f.user_placed = true; model.user_placed_changed = true; } } + if repinned { + mark_user_placed_change(model, h); + } + model.moving = Some(FrameMove { + frame: h, + grip, + sample: model.cursor_pos, + auto_stop, + }); + true +} + +/// `0x768430` against the screen root. With the layout settled first (`0x768464`–`0x76846f`), read +/// the frame's rect (`0x76847a`), clear its anchors (`0x767620`), set one TOPLEFT on the screen's +/// TOPLEFT at the rect's top-left moved by `travel` (`0x767c70`), and give it an explicit size, +/// `size` per axis or the rect's where that is 0 (`0x7684e1`, `0x768508`). Offsets and size are in +/// the frame's own units, screen over its effective scale (`[G+0x58]`). None when the frame has no +/// rect (`0x768481`); else whether its anchors or size changed. +fn pin_top_left( + model: &mut Model, + h: FrameHandle, + size: (f32, f32), + travel: (f32, f32), +) -> Option { + UiScript::resolve_layout(model); + let r = *model.resolved.get(&h)?; + let root = model.screen; + let scale = f64::from(eff_scale(model, h)); + let inv = 1.0 / scale; + let x = ((f64::from(r.left) + f64::from(travel.0)) - f64::from(root.left)) * inv; + let y = ((f64::from(r.top) + f64::from(travel.1)) - f64::from(root.top)) * inv; + let w = if size.0 == 0.0 { + ((f64::from(r.right) - f64::from(r.left)) / scale) as f32 + } else { + size.0 + }; + let ht = if size.1 == 0.0 { + ((f64::from(r.top) - f64::from(r.bottom)) / scale) as f32 + } else { + size.1 + }; + let pin = Anchors::from([Anchor::new( + Point::TopLeft, + SCREEN, + Point::TopLeft, + x as f32, + y as f32, + )]); + 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(); + input.anchors = pin; + input.width = w; + input.height = ht; + if changed { + model.touch_layout_frame(h); + } + // The size it now carries is explicit, as a `SetWidth`/`SetHeight` would leave it. + model.note_authored_size(h); + Some(changed) } /// A drag moved the frame: if it is user-placed, the layout cache owes its file a rewrite. @@ -340,32 +365,28 @@ fn mark_user_placed_change(model: &mut Model, h: FrameHandle) { } } -/// Apply the `SetMinResize`/`SetMaxResize` bounds to a proposed size per axis, as `0x768710` -/// does: only an exact 0.0 is unbounded, min applies before max so max wins a crossed pair, and -/// with no bound there is no floor, so a size can go negative. -fn clamp_resize(model: &Model, h: FrameHandle, w: f32, hgt: f32) -> (f32, f32) { - let (min, max) = model - .arena - .frame(h) - .map_or(((0.0, 0.0), (0.0, 0.0)), |f| (f.min_resize, f.max_resize)); - let axis = |v: f32, lo: f32, hi: f32| { - let v = if lo != 0.0 && v < lo { lo } else { v }; - if hi != 0.0 && v > hi { - hi - } else { - v - } - }; - (axis(w, min.0, max.0), axis(hgt, min.1, max.1)) -} - /// The hover walk's move/size branch (`0x766164`–`0x766193`): while the drag slot holds a target, /// the walk pumps it to `pos` (`0x7655b0`) and returns before any probe. True when one is in /// flight, a target whose frame is gone dropped first. pub(crate) fn pump_move_or_size(model: &mut Model, pos: (f32, f32)) -> bool { - advance_move(model, pos); - advance_size(model, pos); - model.moving.is_some() || model.sizing.is_some() + let Some(mv) = model.moving else { + return false; + }; + if model.arena.frame(mv.frame).is_none() { + model.moving = None; + return false; + } + let (dx, dy) = (pos.0 - mv.sample.0, pos.1 - mv.sample.1); + // A zero delta on both axes skips the step (`0x7655d6`–`0x7655f4`). + if dx != 0.0 || dy != 0.0 { + drag_step(model, mv, dx, dy); + } + // The sample takes the whole delta, whatever a bound kept of it (`0x765610`–`0x765628`). + model.moving = Some(FrameMove { + sample: (mv.sample.0 + dx, mv.sample.1 + dy), + ..mv + }); + true } /// A hide ends the move or size whose target is the frame going hidden: the hide's de-registration @@ -375,118 +396,141 @@ pub(crate) fn cancel_on_hide(model: &mut Model, h: FrameHandle) { if model.moving.is_some_and(|mv| mv.frame == h) { model.moving = None; } - if model.sizing.is_some_and(|sz| sz.frame == h) { - model.sizing = None; - } } -/// Pump an in-flight `StartSizing`: the gripped edges follow the cursor, the opposite ones stay. -fn advance_size(model: &mut Model, pos: (f32, f32)) { - let Some(sz) = model.sizing else { return }; - if model.arena.frame(sz.frame).is_none() { - model.sizing = None; - return; - } - let (dx, dy) = (pos.0 - sz.sample.0, pos.1 - sz.sample.1); - if dx == 0.0 && dy == 0.0 { - return; - } - let inv = 1.0 / eff_scale(model, sz.frame); - let (dx, dy) = (dx * inv, dy * inv); - let dw = if sz.right { - dx - } else if sz.left { - -dx - } else { - 0.0 - }; - let dh = if sz.top { - dy - } else if sz.bottom { - -dy - } else { - 0.0 - }; - let Some((w0, h0)) = model +/// One pump step, `0x768710` by way of `0x76a660`, for the cursor delta `(dx, dy)` in screen units. +/// The grips read the explicit size (`vtbl+0x1c`/`+0x20`, `G+0x50`/`+0x54`), size it by the travel +/// over the frame's scale, clamp it, and re-pin TOPLEFT shifted by the left and top travel only +/// (`0x768974` → `0x768430`); a move adds to the TOPLEFT anchor itself. +fn drag_step(model: &mut Model, mv: FrameMove, dx: f32, dy: f32) { + let h = mv.frame; + let scale = f64::from(eff_scale(model, h)); + let inv = 1.0 / scale; + let (mut w, mut ht) = model .layout_inputs - .get(&sz.frame) - .map(|i| (i.width, i.height)) - else { - return; - }; - // The resize bounds are read only here, as only `0x76a660` reads them in the reference. - let (new_w, new_h) = clamp_resize(model, sz.frame, w0 + dw, h0 + dh); - // The rebate (`0x768770`/`0x7687a2`): the residual a clamp swallowed comes back out of the - // drag delta, so the moving edge stops at the bound instead of the window walking. The delta - // is already in local units, and an ungripped axis is not rebated. - let rebate = |d: f32, want: f32, got: f32, grew: bool, gripped: bool| { - if !gripped { - return d; - } - let residual = got - want; - if grew { - d + residual - } else { - d - residual - } - }; - let dx = rebate(dx, w0 + dw, new_w, sz.right, sz.left || sz.right); - let dy = rebate(dy, h0 + dh, new_h, sz.top, sz.top || sz.bottom); - let Some(input) = model.layout_inputs.get_mut(&sz.frame) else { - return; - }; - input.width = new_w; - input.height = new_h; - let mut moved = input.width.to_bits() != w0.to_bits() || input.height.to_bits() != h0.to_bits(); - // A LEFT or BOTTOM grip also shifts the anchors on its axis, so a frame anchored by that side - // keeps its opposite edge. The reference instead re-anchors the frame to one TOPLEFT point at - // `StartSizing` (`0x768430`). - if !input.anchors.is_empty() && (sz.left || sz.bottom) { - for (x_off, y_off) in input.anchors.offsets_mut() { - if sz.left { - *x_off += dx; - } - if sz.bottom { - *y_off += dy; + .get(&h) + .map_or((0.0, 0.0), |i| (i.width, i.height)); + // `G+0x60`/`+0x68` bound the width and `G+0x5c`/`+0x64` the height. + let (min, max) = model + .arena + .frame(h) + .map_or(((0.0, 0.0), (0.0, 0.0)), |f| (f.min_resize, f.max_resize)); + let (mut dx, mut dy) = (dx, dy); + let add = |v: f32, by: f64| (f64::from(v) + by) as f32; + let x = Axis::new(min.0, max.0, scale); + let y = Axis::new(min.1, max.1, scale); + match mv.grip { + // Case 4: the delta over the scale goes straight into the TOPLEFT anchor's offsets + // (`0x768c15`–`0x768c2e`); with no TOPLEFT anchor it re-pins at the whole delta instead + // (`0x768c00`, `0x768c0f`). + Point::Center => { + let moved = model + .layout_inputs + .get_mut(&h) + .and_then(|i| i.anchors.offset_mut(Point::TopLeft)) + .map(|(x_off, y_off)| { + *x_off = add(*x_off, f64::from(dx) / scale); + *y_off = add(*y_off, f64::from(dy) / scale); + }) + .is_some(); + if moved { + model.touch_layout_frame(h); + mark_user_placed_change(model, h); + return; } } - moved |= (sz.left && dx != 0.0) || (sz.bottom && dy != 0.0); + Point::TopLeft => { + w = add(w, -f64::from(dx) * inv); + x.clamp_near(&mut w, &mut dx, -1.0); + ht = add(ht, f64::from(dy) * inv); + y.clamp_near(&mut ht, &mut dy, 1.0); + } + Point::Top => { + ht = add(ht, f64::from(dy) / scale); + y.clamp_near(&mut ht, &mut dy, 1.0); + dx = 0.0; + } + Point::TopRight => { + w = add(w, f64::from(dx) * inv); + x.clamp_far(&mut w); + dx = 0.0; + ht = add(ht, f64::from(dy) * inv); + y.clamp_near(&mut ht, &mut dy, 1.0); + } + Point::Left => { + w = add(w, -f64::from(dx) / scale); + x.clamp_near(&mut w, &mut dx, -1.0); + dy = 0.0; + } + Point::Right => { + w = add(w, f64::from(dx) / scale); + x.clamp_far(&mut w); + (dx, dy) = (0.0, 0.0); + } + Point::BottomLeft => { + w = add(w, -f64::from(dx) * inv); + x.clamp_near(&mut w, &mut dx, -1.0); + ht = add(ht, -f64::from(dy) * inv); + y.clamp_far(&mut ht); + dy = 0.0; + } + Point::Bottom => { + ht = add(ht, -f64::from(dy) / scale); + y.clamp_far(&mut ht); + (dx, dy) = (0.0, 0.0); + } + Point::BottomRight => { + w = add(w, f64::from(dx) * inv); + x.clamp_far(&mut w); + ht = add(ht, -f64::from(dy) * inv); + y.clamp_far(&mut ht); + (dx, dy) = (0.0, 0.0); + } } - // A nonzero cursor delta can still move nothing (a grip dragged across its axis, or held at a - // bound), so the layout is touched only on a real change. - if moved { - // No anchor is retargeted, so only this node is touched. - model.touch_layout_frame(sz.frame); - mark_user_placed_change(model, sz.frame); + if pin_top_left(model, h, (w, ht), (dx, dy)) == Some(true) { + mark_user_placed_change(model, h); } - model.sizing = Some(FrameSizing { sample: pos, ..sz }); } -/// Pump an in-flight move to the cursor at `pos` (`0x7655b0`). -fn advance_move(model: &mut Model, pos: (f32, f32)) { - let Some(mv) = model.moving else { return }; - if model.arena.frame(mv.frame).is_none() { - model.moving = None; - return; +/// One axis's `SetMinResize`/`SetMaxResize` pair, as `0x768710` applies it: only an exact 0.0 is +/// unbounded, a size under the minimum or over the maximum takes the bound, min before max so max +/// wins a crossed pair, and with no bound there is no floor, so a size can go negative. +struct Axis { + min: f32, + max: f32, + scale: f64, +} + +impl Axis { + fn new(min: f32, max: f32, scale: f64) -> Axis { + Axis { min, max, scale } } - let (dx, dy) = (pos.0 - mv.sample.0, pos.1 - mv.sample.1); - if dx == 0.0 && dy == 0.0 { - return; + + /// Clamp a size whose far edge moves (the right or the bottom), which the pin does not follow. + fn clamp_far(&self, v: &mut f32) { + if self.min != 0.0 && *v < self.min { + *v = self.min; + } + if self.max != 0.0 && *v > self.max { + *v = self.max; + } } - // Anchor offsets are in the frame's own units, so the screen delta divides by its scale, as - // `0x768710`'s case 4 does. - let inv = 1.0 / eff_scale(model, mv.frame); - let (dx, dy) = (dx * inv, dy * inv); - if let Some(input) = model.layout_inputs.get_mut(&mv.frame) { - if !input.anchors.is_empty() { - for (x_off, y_off) in input.anchors.offsets_mut() { - *x_off += dx; - *y_off += dy; - } - // No target moves, so only this node is touched; a zero delta already returned above. - model.touch_layout_frame(mv.frame); - mark_user_placed_change(model, mv.frame); + + /// Clamp a size whose anchored edge moves (the left or the top), taking the residual back out + /// of that edge's travel so it stops on the bound, as TOPLEFT's case does at `0x768770`, + /// `0x7687a2`, `0x7687dd` and `0x768817`. `grows` is the sign the travel sizes the frame with: + /// +1 for the top edge, -1 for the left. + fn clamp_near(&self, v: &mut f32, travel: &mut f32, grows: f64) { + let mut seat = |v: &mut f32, b: f32| { + *travel = + (f64::from(*travel) + grows * (f64::from(b) - f64::from(*v)) * self.scale) as f32; + *v = b; + }; + if self.min != 0.0 && *v < self.min { + seat(v, self.min); + } + if self.max != 0.0 && *v > self.max { + seat(v, self.max); } } - model.moving = Some(FrameMove { sample: pos, ..mv }); } diff --git a/crates/benilla-ui/src/script/tests/drag_pin.rs b/crates/benilla-ui/src/script/tests/drag_pin.rs new file mode 100644 index 00000000..f17aa0ee --- /dev/null +++ b/crates/benilla-ui/src/script/tests/drag_pin.rs @@ -0,0 +1,293 @@ +//! Every drag pins its frame to one TOPLEFT anchor against the screen at its current rect, with an +//! explicit size (`0x7652b0` → `0x768430`), and the pump (`0x768710`) moves or resizes that one +//! anchor and size. + +use super::common::script; +use crate::script::{FrameLayout, LayoutPoint, UiScript}; + +/// A 200×100 frame CENTER-anchored in a full-screen holder on an 800×600 screen: its rect is +/// left 300, right 500, bottom 250, top 350. +fn centred_box() -> UiScript { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + Holder = CreateFrame("Frame", "Holder") + Holder:SetPoint("BOTTOMLEFT", 0, 0); Holder:SetWidth(800); Holder:SetHeight(600) + Box = CreateFrame("Frame", "Box", Holder) + Box:SetPoint("CENTER", Holder, "CENTER", 0, 0) + Box:SetWidth(200); Box:SetHeight(100) + Box:SetMovable(true); Box:SetResizable(true) + "#, + ) + .unwrap(); + s.resolve(); + s.mouse_move(400.0, 300.0); + s +} + +/// `(left, right, bottom, top)` after a resolve. +fn edges(s: &mut UiScript) -> (f32, f32, f32, f32) { + s.resolve(); + let (l, r, b, t) = s + .eval::<(f64, f64, f64, f64)>( + "return Box:GetLeft(), Box:GetRight(), Box:GetBottom(), Box:GetTop()", + ) + .unwrap(); + (l as f32, r as f32, b as f32, t as f32) +} + +/// The one anchor: `(count, point, relativeTo is the screen, relativePoint, x, y)`. +fn anchor(s: &UiScript) -> (i64, String, bool, String, f32, f32) { + let (n, p, screen, rp, x, y) = s + .eval::<(i64, String, bool, String, f64, f64)>( + "local p, rel, rp, x, y = Box:GetPoint(1) \ + return Box:GetNumPoints(), p, rel == nil, rp, x, y", + ) + .unwrap(); + (n, p, screen, rp, x as f32, y as f32) +} + +fn pinned_at(x: f32, y: f32) -> (i64, String, bool, String, f32, f32) { + (1, "TOPLEFT".into(), true, "TOPLEFT".into(), x, y) +} + +#[test] +fn a_move_pins_one_topleft_anchor_against_the_screen() { + let mut s = centred_box(); + s.run("Box:StartMoving()").unwrap(); + assert_eq!( + anchor(&s), + pinned_at(300.0, -250.0), + "the drag entry pins before any cursor move: top 350 on a 600 screen is y -250" + ); + s.mouse_move(430.0, 280.0); + assert_eq!(edges(&mut s), (330.0, 530.0, 230.0, 330.0)); + assert_eq!( + anchor(&s), + pinned_at(330.0, -270.0), + "the move rides that anchor" + ); + s.run("Box:StopMovingOrSizing()").unwrap(); + assert_eq!( + anchor(&s), + pinned_at(330.0, -270.0), + "the stop writes nothing" + ); + assert_eq!( + s.eval::<(f64, f64)>("return Box:GetWidth(), Box:GetHeight()") + .unwrap(), + (200.0, 100.0), + "with the size made explicit" + ); + + // The old target no longer carries it. + s.run("Holder:SetPoint(\"BOTTOMLEFT\", 50, 0)").unwrap(); + assert_eq!(edges(&mut s), (330.0, 530.0, 230.0, 330.0)); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// `FCF_Set_NormalChat` (`FloatingChatFrame.lua:1422-1423`) pins a window with a bare +/// `StartMoving()`/`StopMovingOrSizing()` pair; a frame sized only by two anchors gets the size +/// its rect has. +#[test] +fn a_bare_start_and_stop_pins_a_stretched_frame_at_its_rect() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + Box = CreateFrame("Frame", "Box") + Box:SetPoint("BOTTOMLEFT", 100, 100) + Box:SetPoint("TOPRIGHT", UIParent, "BOTTOMLEFT", 300, 200) + Box:SetMovable(true) + Box:StartMoving() Box:StopMovingOrSizing() + "#, + ) + .unwrap(); + assert_eq!(anchor(&s), pinned_at(100.0, -400.0)); + assert_eq!( + s.eval::<(f64, f64)>("return Box:GetWidth(), Box:GetHeight()") + .unwrap(), + (200.0, 100.0) + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// Each grip moves only its own edges, whatever the frame was anchored by (`0x768710`'s eight +/// size cases re-pin TOPLEFT shifted by the left and top travel only, `0x76898a`). +#[test] +fn each_grip_moves_only_its_own_edges() { + for grip in [ + "TOPLEFT", + "TOP", + "TOPRIGHT", + "LEFT", + "RIGHT", + "BOTTOMLEFT", + "BOTTOM", + "BOTTOMRIGHT", + ] { + let mut s = centred_box(); + s.run(&format!("Box:StartSizing(\"{grip}\")")).unwrap(); + s.mouse_move(420.0, 290.0); + s.mouse_move(430.0, 285.0); // +30, -15 in all + let (dl, dr) = match (grip.contains("LEFT"), grip.contains("RIGHT")) { + (true, _) => (30.0, 0.0), + (_, true) => (0.0, 30.0), + _ => (0.0, 0.0), + }; + let (db, dt) = match (grip.contains("BOTTOM"), grip.contains("TOP")) { + (true, _) => (-15.0, 0.0), + (_, true) => (0.0, -15.0), + _ => (0.0, 0.0), + }; + let want = (300.0 + dl, 500.0 + dr, 250.0 + db, 350.0 + dt); + assert_eq!(edges(&mut s), want, "{grip}"); + assert_eq!( + anchor(&s), + pinned_at(want.0, want.3 - 600.0), + "{grip} leaves the one TOPLEFT pin" + ); + s.run("Box:StopMovingOrSizing()").unwrap(); + assert!(s.errors().is_empty(), "{grip}: {:?}", s.errors()); + } +} + +/// The bounds clamp each case. Where the grip moves the anchor (a LEFT or TOP edge), the residual +/// comes back out of the travel, so the edge stops on the bound (`0x768770`, `0x7687a2`, +/// `0x7687dd`, `0x768817`); the far edges just clamp. The cursor sample always advances by the +/// whole delta (`0x765610`), so a grip held past its bound answers the first move back. +#[test] +fn the_bounds_clamp_and_the_anchored_edge_stops_on_them() { + let bounded = || { + let s = centred_box(); + s.run("Box:SetMinResize(150, 80) Box:SetMaxResize(260, 120)") + .unwrap(); + s + }; + + let mut s = bounded(); + s.run("Box:StartSizing(\"LEFT\")").unwrap(); + s.mouse_move(480.0, 300.0); + assert_eq!( + edges(&mut s), + (350.0, 500.0, 250.0, 350.0), + "LEFT at the min" + ); + s.mouse_move(460.0, 300.0); + assert_eq!( + edges(&mut s), + (330.0, 500.0, 250.0, 350.0), + "and back at once" + ); + + let mut s = bounded(); + s.run("Box:StartSizing(\"TOP\")").unwrap(); + s.mouse_move(400.0, 350.0); + assert_eq!( + edges(&mut s), + (300.0, 500.0, 250.0, 370.0), + "TOP at the max" + ); + + let mut s = bounded(); + s.run("Box:StartSizing(\"TOPLEFT\")").unwrap(); + s.mouse_move(300.0, 400.0); + assert_eq!( + edges(&mut s), + (240.0, 500.0, 250.0, 370.0), + "TOPLEFT at both maxima" + ); + + let mut s = bounded(); + s.run("Box:StartSizing(\"BOTTOMRIGHT\")").unwrap(); + s.mouse_move(500.0, 360.0); + assert_eq!( + edges(&mut s), + (300.0, 560.0, 270.0, 350.0), + "BOTTOMRIGHT at the max width and the min height" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// `StartSizing` starts from BOTTOMRIGHT and takes a grip only from a string naming a point, +/// matched case-insensitively (`0x776913`, `0x6f1840`). +#[test] +fn a_missing_or_unknown_grip_is_bottomright() { + for call in [ + "Box:StartSizing()", + "Box:StartSizing(\"bogus\")", + "Box:StartSizing(7)", + "Box:StartSizing(\"bottomright\")", + ] { + let mut s = centred_box(); + s.run(call).unwrap(); + s.mouse_move(420.0, 290.0); + assert_eq!(edges(&mut s), (300.0, 520.0, 240.0, 350.0), "{call}"); + } + let mut s = centred_box(); + s.run("Box:StartSizing(\"left\")").unwrap(); + s.mouse_move(420.0, 290.0); + assert_eq!( + edges(&mut s), + (320.0, 500.0, 250.0, 350.0), + "a lower-case name" + ); +} + +/// The pin needs a rect (`0x768320` fails, `0x7652cd`), so a frame that has none starts no drag: +/// no userPlaced stamp and no slot. +#[test] +fn a_frame_with_no_rect_starts_no_drag() { + let mut s = centred_box(); + s.run( + r#" + Lost = CreateFrame("Frame", "Lost") + Lost:SetWidth(50); Lost:SetHeight(50) + Lost:SetMovable(true) + Lost:StartMoving() + "#, + ) + .unwrap(); + assert!(!s.eval::("return Lost:IsUserPlaced()").unwrap()); + assert_eq!(s.eval::("return Lost:GetNumPoints()").unwrap(), 0); + s.run("Box:StartMoving()").unwrap(); + s.mouse_move(410.0, 300.0); + assert_eq!( + edges(&mut s), + (310.0, 510.0, 250.0, 350.0), + "the slot was free" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// After a drag the layout cache holds the one TOPLEFT anchor and the size, the reference's `X:`, +/// `Y:`, `W:` and `H:` (`0x490e60`), and its apply seats that back. +#[test] +fn the_layout_cache_round_trips_the_pin() { + let mut s = centred_box(); + s.run("Box:StartMoving()").unwrap(); + s.mouse_move(430.0, 280.0); + s.run("Box:StopMovingOrSizing()").unwrap(); + let want = FrameLayout { + name: "Box".into(), + width: 200.0, + height: 100.0, + points: vec![LayoutPoint { + point: "TOPLEFT".into(), + relative_to: None, + relative_point: "TOPLEFT".into(), + x: 330.0, + y: -270.0, + }], + }; + let saved = s.user_placed_layouts(); + assert_eq!(saved, std::slice::from_ref(&want)); + + let mut fresh = centred_box(); + fresh.restore_user_placed_layouts(saved); + assert_eq!(edges(&mut fresh), (330.0, 530.0, 230.0, 330.0)); + assert_eq!(anchor(&fresh), pinned_at(330.0, -270.0)); + assert_eq!(fresh.user_placed_layouts(), [want]); + assert!(fresh.errors().is_empty(), "{:?}", fresh.errors()); +} diff --git a/crates/benilla-ui/src/script/tests/hover_walk.rs b/crates/benilla-ui/src/script/tests/hover_walk.rs index d6d5ee50..65deda38 100644 --- a/crates/benilla-ui/src/script/tests/hover_walk.rs +++ b/crates/benilla-ui/src/script/tests/hover_walk.rs @@ -232,13 +232,14 @@ fn a_frame_moving_when_the_pointer_leaves_keeps_its_offsets() { s.eval::<(f64, f64)>("local _, _, _, x, y = Far:GetPoint() return x, y") .unwrap() }; - assert_eq!(point(&s), (800.0, 500.0)); + // The drag pinned TOPLEFT against the screen (`0x768430`): top 600 on a 768 screen. + assert_eq!(point(&s), (800.0, -168.0)); let saved = s.user_placed_layouts(); s.pointer_left_hover(); s.pointer_left_window(); s.resolve(); - assert_eq!(point(&s), (800.0, 500.0), "the leave moves nothing"); + assert_eq!(point(&s), (800.0, -168.0), "the leave moves nothing"); assert_eq!(s.eval::("return Far:GetLeft()").unwrap(), 800.0); assert_eq!( s.user_placed_layouts(), @@ -251,7 +252,7 @@ fn a_frame_moving_when_the_pointer_leaves_keeps_its_offsets() { // next real event moves it by its delta from the last real one. s.mouse_move(260.0, 150.0); s.resolve(); - assert_eq!(point(&s), (810.0, 500.0)); + assert_eq!(point(&s), (810.0, -168.0)); assert!(s.errors().is_empty(), "{:?}", s.errors()); } @@ -286,6 +287,8 @@ fn a_drag_cut_short_by_the_pointer_leaving_keeps_the_last_real_position() { .iter() .find(|l| l.name == "Here") .expect("Here is saved"); - assert_eq!((here.points[0].x, here.points[0].y), (130.0, 110.0)); + // Saved as the drag's TOPLEFT pin (`0x768430`): top 210 on a 768 screen. + assert_eq!(here.points[0].point, "TOPLEFT"); + assert_eq!((here.points[0].x, here.points[0].y), (130.0, -558.0)); assert!(s.errors().is_empty(), "{:?}", s.errors()); } diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs index d88b02ad..11e0e635 100644 --- a/crates/benilla-ui/src/script/tests/mod.rs +++ b/crates/benilla-ui/src/script/tests/mod.rs @@ -17,6 +17,7 @@ mod cooldown; mod create_frame_template; mod cvar_bindings; mod dispatch_bench; +mod drag_pin; mod drag_threshold; mod end_to_end; mod engine_updates; diff --git a/crates/benilla-ui/src/script/tests/movable.rs b/crates/benilla-ui/src/script/tests/movable.rs index d3022547..07a14ac7 100644 --- a/crates/benilla-ui/src/script/tests/movable.rs +++ b/crates/benilla-ui/src/script/tests/movable.rs @@ -62,11 +62,12 @@ fn the_canonical_addon_idiom_moves_a_frame_and_the_position_survives_the_stop() assert_eq!(corner(&mut s), (200.0, 150.0), "no longer following"); assert!(s.errors().is_empty(), "{:?}", s.errors()); - // The reference moves the anchors in place, so GetPoint reads the frame's own point, dragged. + // The drag re-anchored it to one TOPLEFT against the screen (`0x768430`), so GetPoint reads + // that point, not the authored BOTTOMLEFT: top 230 on a 600 screen is y -370. let (point, x, y) = s .eval::<(String, f64, f64)>("local p, _, _, x, y = MovePanel:GetPoint() return p, x, y") .unwrap(); - assert_eq!((point.as_str(), x, y), ("BOTTOMLEFT", 200.0, 150.0)); + assert_eq!((point.as_str(), x, y), ("TOPLEFT", 200.0, -370.0)); } /// Each move applies only its own delta: the pump re-centers its sample after every step. @@ -163,7 +164,8 @@ fn stop_moving_or_sizing_is_harmless_with_nothing_moving_and_stops_only_its_own_ assert!(s.errors().is_empty(), "{:?}", s.errors()); } -/// A move translates every anchor, so a frame sized by two anchors keeps its size. +/// A frame sized by two anchors keeps its size: the drag pins it to one anchor with the size its +/// rect had (`0x768430`). #[test] fn a_frame_stretched_between_two_anchors_moves_rigidly() { let mut s = script(); @@ -194,7 +196,7 @@ fn a_frame_stretched_between_two_anchors_moves_rigidly() { let size_after = s .eval::<(f64, f64)>("return StretchPanel:GetWidth(), StretchPanel:GetHeight()") .unwrap(); - assert_eq!((left, bottom), (130.0, 140.0), "both anchors translated"); + assert_eq!((left, bottom), (130.0, 140.0), "the pinned anchor moved"); assert_eq!(size_after, (200.0, 100.0), "and the frame kept its size"); assert!(s.errors().is_empty(), "{:?}", s.errors()); } @@ -324,8 +326,7 @@ fn the_xml_movable_and_resizable_attributes_reach_the_methods() { } /// `StartSizing(grip)` (`0x776830`, called from `FloatingChatFrame.lua:600`) moves the gripped -/// edges and plants the opposite ones. Which edges a grip moves is untraced in the reference, whose -/// verb has no inline math; this takes the anchor point's plain meaning, as that caller does. +/// edges and plants the opposite ones (`0x768710`'s grip cases). #[test] fn start_sizing_moves_the_gripped_edge_and_plants_the_other() { let mut s = crate::script::UiScript::new().unwrap();