the colour wheel draws, chat windows move, and the minimap ping returns

- the colour picker can pick a colour: the hue disc and the value strip are engine-generated pixels (no BLP in the chain is a wheel), reproduced from the reference's own lattice — a hard-cut circle at a literal V = 1.0, the brightness marker moving over a disc that never dims
- chat windows move and resize once unlocked (Lock/Unlock Window on the tab menu; the reference's own 296×75 to 608×400 bounds), the border textures doubling as the grips, and the placement is remembered per character in benilla-config/layout; both windows ship locked, as 1.12's reset leaves them
- the minimap ping is back and pinned: one world point the engine draws, the click converted in the right unit (the old path was ~27% off at 1080p), and no 10-yard auto-clear — that was the gossip marker's rule, and walking to your own ping was deleting it
- the Lightwell can be clicked: a billboard card is a world root with no parent hop, so the hit resolved to nothing and the whole shaft published a null mouseover; casting a hunter shot chains Auto Shot through `Spell.dbc`'s modalNextSpell column (the mechanism nobody had read — the next snapshot carries the seam that makes it fire in play); tradeskill list rows stop tooltipping (that hover was ours, not Blizzard's), and OnMouseUp goes to the frame that took the press

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-25 18:49:16 +07:00
co-authored by Claude Fable 5
parent 7f0ba00cea
commit 26fb25a3f8
53 changed files with 5035 additions and 280 deletions
+89 -8
View File
@@ -188,17 +188,21 @@ impl UiScript {
editbox::drag_update(&self.lua, x, y);
let new_id = self.hit_test(x, y);
#[allow(clippy::type_complexity)]
let (old_id, drag_start, boundary_crossed, slider_change): (
let (old_id, drag_start, boundary_crossed, slider_change, color_change): (
Option<u32>,
Option<(u32, String)>,
bool,
Option<(u32, f32)>,
Option<(u32, f64, f64, f64)>,
) = {
let mut model = self.model_mut();
model.cursor_pos = (x, y);
// Advance an in-flight Slider thumb drag first — dragging within one frame crosses no
// hover boundary, so this must run before the early return below (decision 0250 §5).
let slider_change = super::slider::drag_move(&mut model, x, y);
// The colour picker's wheel/strip drag, for the identical reason — and it fires on
// every move, not only on a change (the widget has no change-gate).
let color_change = super::colorselect::drag_move(&mut model, x, y);
// A frame in flight from `StartMoving()` follows the cursor here for the same reason
// (`object::movable`): the frame moves *under* the cursor, so the hover boundary never
// crosses and a pump behind the early return would only ever advance when the cursor
@@ -208,7 +212,8 @@ impl UiScript {
let drag_start = cursor::maybe_start_drag(&mut model, (x, y));
let new_handle = new_id.and_then(|id| model.id_to_frame.get(&id).copied());
if new_handle == model.mouseover {
(None, drag_start, false, slider_change) // no boundary crossed
// no boundary crossed
(None, drag_start, false, slider_change, color_change)
} else {
// Fire OnLeave only if the frame we're leaving is still live.
let old_id = model.mouseover.and_then(|h| {
@@ -220,7 +225,7 @@ impl UiScript {
.flatten()
});
model.mouseover = new_handle;
(old_id, drag_start, true, slider_change)
(old_id, drag_start, true, slider_change, color_change)
}
};
// The thumb-drag value change fires OnValueChanged (outside the borrow) — the scrollbar's
@@ -235,6 +240,11 @@ impl UiScript {
self.push_error(e);
}
}
if let Some((id, r, g, b)) = color_change {
if let Err(e) = super::colorselect::fire_by_id(&self.lua, id, r, g, b) {
self.push_error(e);
}
}
if let Some((id, button)) = drag_start {
let btn = match self.lua.create_string(&button) {
Ok(s) => Value::String(s),
@@ -285,7 +295,9 @@ impl UiScript {
/// A mouse button transition at `(x, y)`: `button` is the WoW name (`"LeftButton"`,
/// `"RightButton"`, …), `down` is press vs release. Fires `OnMouseDown(self, button)` on press or
/// `OnMouseUp(self, button)` on release, on the captured frame.
/// `OnMouseUp(self, button)` on release. **The press fires on the hit frame and the release on
/// the frame that took the press** — the client's mouse capture (`root+0x80`); see the dispatch
/// itself for the bytes and for the stuck-resize it prevents.
///
/// Separately, `OnClick` fires per the hit frame's `RegisterForClicks` set
/// ([`button::wants_click`]): a **press** fires immediately when the frame registered
@@ -352,6 +364,18 @@ impl UiScript {
{
self.model_mut().moving = None;
}
// The capture this release belongs to — read HERE because the borrow below *drains*
// `mouse_down_on` for this button. See the `OnMouseUp` dispatch further down for why the
// press target rather than the hit frame.
let captured_id: Option<u32> = if down {
None
} else {
let model = self.model_ref();
model
.mouse_down_on
.get(button)
.and_then(|h| model.frame_to_id.get(h).copied())
};
// The session clock, read before the borrow below — the double-click detector's only input
// beyond the hit ([`Model::last_click`]). Same `GetTime()` seconds every script sees.
let now = self.now();
@@ -368,13 +392,22 @@ impl UiScript {
// "<Button>ButtonDown"; a release fires it when press+release landed on the same frame AND
// it registered "<Button>ButtonUp" — UNLESS a started drag is being resolved instead.
#[allow(clippy::type_complexity)]
let (click_id, drag_release, world_dropped, slider_jump, double_id, abandoned): (
let (
click_id,
drag_release,
world_dropped,
slider_jump,
double_id,
abandoned,
color_jump,
): (
Option<u32>,
Option<cursor::DragRelease>,
bool,
Option<(u32, f32)>,
Option<u32>,
Option<crate::widget::FrameHandle>,
Option<(u32, f64, f64, f64)>,
) = {
let mut model = self.model_mut();
let hit_handle = hit_id.and_then(|id| model.id_to_frame.get(&id).copied());
@@ -401,16 +434,27 @@ impl UiScript {
} else {
None
};
// A left press inside a ColorSelect's wheel or value strip captures the colour
// drag AND jumps the colour to the click point in the same call — the widget's
// press handler invokes its own cursor-position handler before returning
// (`0x78bf70 call [eax+0x3c]`), so there is no such thing as a press that only
// arms.
let color_jump = if button == "LeftButton" {
super::colorselect::begin_drag(&mut model, hit_handle, x, y)
} else {
None
};
let wants = format!("{button}Down");
let click = hit_handle
.filter(|&h| button::wants_click(&model, h, &wants))
.and(hit_id);
(click, None, false, jump, None, abandoned)
(click, None, false, jump, None, abandoned, color_jump)
} else {
let pressed = model.mouse_down_on.remove(button);
// A left release ends any in-flight thumb drag (decision 0250 §5).
if button == "LeftButton" {
super::slider::end_drag(&mut model);
super::colorselect::end_drag(&mut model);
}
let same_frame = matches!(
(pressed, hit_handle),
@@ -478,7 +522,7 @@ impl UiScript {
let double_id = double.and_then(|h| model.frame_to_id.get(&h).copied());
// Exclusive, not additive — the double leg is taken *instead of* the single one.
let click = if double_id.is_some() { None } else { click };
(click, release, dropped, None, double_id, None)
(click, release, dropped, None, double_id, None, None)
}
};
// A track press's value jump fires OnValueChanged first (outside the borrow), the same
@@ -493,8 +537,45 @@ impl UiScript {
self.push_error(e);
}
}
// The press's colour jump fires OnColorSelect the same way.
if let Some((id, r, g, b)) = color_jump {
if let Err(e) = super::colorselect::fire_by_id(&self.lua, id, r, g, b) {
self.push_error(e);
}
}
// ── `OnMouseUp` goes to the frame that took the PRESS, not to whatever is under the
// cursor now — the mouse CAPTURE (`root+0x80`).
//
// The client's mouse-DOWN handler `0x7662c0` "resolves target = root+0x80 (existing
// capture) else root+0x7c (hover frame) … then title-region drag or **capture**+OnMouseDown"
// (wow-re ledger `0x7662c0`, VERIFIED), so a press takes the pointer.
//
// ⚠ **The release half is INFERRED, not carved.** That the capture is what the *UP*
// handler dispatches through is an extrapolation from the DOWN handler: the sibling
// `0x766420` (category `0xe`) carries only a bulk wave-classify label in wow-re's ledger,
// not a derivation. The inference is strong — a capture nothing consults on release would
// be pointless, and something has to clear `root+0x80` — but it is an inference, and this
// is a whole-UI change (every press-on-A-release-on-B in the client now delivers to A). A
// §5 is dispatched for `0x766420`'s target resolution (decision 1594); when it lands this
// comment says VERIFIED or this code changes.
//
// We already track exactly the capture
// — [`Model::mouse_down_on`], the press target per button, which `OnClick`'s same-frame
// rule reads — and firing `OnMouseUp` at the *hit* frame instead was the one place it went
// unused. This function's own doc has always said "on the captured frame"; the code did
// not.
//
// It is not a nicety: a chat window's resize grip is a 16×16 button that the drag pulls
// out from under the cursor the moment the size clamps at `SetMinResize`/`SetMaxResize`
// (the rebate plants the edge on the bound while the cursor keeps going). Its
// `OnMouseUp → FCF_StopResize` then never ran, and `Model::sizing` stayed held for the
// rest of the session — the stuck-drag failure `cursor::abandon_drag` exists to prevent on
// the other path.
//
// A press that captured NOTHING (over the world, or swallowed by a title region) falls
// back to the hit frame, which is `root+0x7c`'s arm of the same resolve.
let script = if down { "OnMouseDown" } else { "OnMouseUp" };
if let Some(id) = hit_id {
if let Some(id) = if down { hit_id } else { captured_id.or(hit_id) } {
if let Err(e) = event::fire_widget_handler(&self.lua, id, script, vec![btn.clone()]) {
self.push_error(e);
}