ui: the HIGHLIGHT draw layer is the highlight state, and starts off

A texture an addon made with `CreateTexture(nil, "HIGHLIGHT")` drew all the
time. Only the region `SetHighlightTexture` installs was hover-gated, through
`ButtonState::region_visible`; anything else an addon left in layer 4 was an
ordinary region that nothing ever hid.

In the reference that layer is the highlight *state*, not a place to keep
highlight art, and four things move it:

  OnEnter  0x76b6a0  test [frame+0xf8]; push 4; call 0x76a710  EnableDrawLayer
  OnLeave  0x76b6f0  test [frame+0xf8]; push 4; call 0x76a730  DisableDrawLayer
  Lock     0x76c2e0  writes [frame+0xf8], then enables; on an unlock disables
                     only when this frame is not the mouse focus ([root+0x7c])
  Disable  0x7791bb  disables it outright

`0x77983c` settles what the layer means: a Button picks its label's font
instance by reading that layer's own enabled flag at [frame+0x1a8], which is
[frame+0x198] + 4*4, and takes the highlight font object at +0x3b8 when it is
set. So the layer starts off, and every path that can highlight a frame moves
it -- taking whatever the addon put there along.

What this was costing: hand-built column headers and list rows, which are
everywhere. The <Below Average> guild market's Browse page puts
UI-Character-Tab-Highlight in that layer on each of its nine sort headers, so
every header wore a permanent blue glow bar, and its Statistics page showed all
six filter buttons lit at once instead of the two that were selected.

The nameplate glow lives in layer 4 too, and its hover feed drove only the
region's shown flag. Its own OnEnter override chains back into the base notify
(0x7cb8a5 -> 0x76b6a0), so it now moves the layer with it; addons still read
mouseover from glow:IsShown().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-11-07 13:29:12 -07:00
co-authored by Claude Opus 5
parent 50a229c202
commit ff2cbdde67
6 changed files with 256 additions and 13 deletions
+68 -12
View File
@@ -13,7 +13,7 @@ use crate::justify::Justify;
use crate::order::DrawLayer;
use crate::widget::{
ButtonFont, ButtonState, ButtonVisualState, FrameHandle, FrameKind, KindState, RegionHandle,
RegionKind,
RegionKind, HIGHLIGHT_OFF,
};
pub(in crate::script) const REG_BUTTON_METHODS: &str = "__benilla_button_methods";
@@ -192,27 +192,70 @@ fn with_button<T>(
}
}
/// Draw layer 4, HIGHLIGHT, as a whole -- not only the region `SetHighlightTexture` made.
///
/// The reference keeps the highlight *state* in that layer's own enabled flag
/// (`[frame+0x198] + 4*4`), which is why `0x77983c` reads it to decide whether a Button's label
/// takes the highlight font object. Everything that can highlight a frame therefore moves the
/// layer, and anything an addon left in it moves with them. See [`HIGHLIGHT_OFF`].
pub(super) fn set_highlight_layer(model: &mut Model, h: FrameHandle, on: bool) {
if let Some(frame) = model.arena.frame_mut(h) {
if on {
frame.disabled_layers &= !HIGHLIGHT_OFF;
} else {
frame.disabled_layers |= HIGHLIGHT_OFF;
}
}
}
/// Whether `LockHighlight()` is holding the layer on for `h`. Only a Button has the method, so
/// anything else answers false.
fn highlight_locked(model: &Model, h: FrameHandle) -> bool {
matches!(
model.arena.frame(h).map(|f| &f.kind_state),
Some(KindState::Button(bs)) if bs.locked_highlight
)
}
/// The highlight half of a hover edge: `OnEnter` (`0x76b6a0`) enables the layer and `OnLeave`
/// (`0x76b6f0`) disables it, each after `test [frame+0xf8]` skips it while `LockHighlight` holds.
/// A disabled Button reaches neither notify (`0x779490`/`0x7794e0` guard on `[+0x328]`), so the
/// layer does not move there either -- and it is already off, from `Disable()`.
pub(super) fn hover_highlight(model: &mut Model, h: Option<FrameHandle>, entering: bool) {
let Some(h) = h else { return };
if !hover_notify_runs(model, Some(h)) || highlight_locked(model, h) {
return;
}
set_highlight_layer(model, h, entering);
}
/// `Enable()`/`Disable()`. `Disable` (`0x77ffd0`) reaches `SetEnabled` (`0x779160`), which also
/// turns draw layer 4, HIGHLIGHT, off (`0x7791bb`) in the array `DisableDrawLayer` writes, so every
/// region in that layer goes dark, not only the HighlightTexture. `Enable()` here turns it back
/// on; the reference restores it only on a hovered button, by re-running its `<OnEnter>`
/// (`0x779183`), and never while `LockHighlight` holds (`[frame+0xf8]`).
/// region in that layer goes dark, not only the HighlightTexture. `Enable()` restores it only on a
/// hovered button, by re-running its `<OnEnter>` (`0x779183`), and never while `LockHighlight`
/// holds (`[frame+0xf8]`) -- a button enabled under the cursor lights up, one enabled anywhere
/// else does not.
fn set_enabled(lua: &Lua, this: &Table, on: bool) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let hovered = model.mouseover == Some(h);
let frame = model
.arena
.frame_mut(h)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
match &mut frame.kind_state {
KindState::Button(bs) => bs.set_enabled(on),
let locked = match &mut frame.kind_state {
KindState::Button(bs) => {
bs.set_enabled(on);
bs.locked_highlight
}
_ => return Err(mlua::Error::runtime("not a Button")),
};
let bit = 1u8 << DrawLayer::Highlight.index();
if on {
frame.disabled_layers &= !bit;
if hovered || locked {
frame.disabled_layers &= !HIGHLIGHT_OFF;
}
} else {
frame.disabled_layers |= bit;
frame.disabled_layers |= HIGHLIGHT_OFF;
}
Ok(())
}
@@ -796,17 +839,30 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?,
)?;
// LockHighlight (`0x782840`) and UnlockHighlight: the highlight shows regardless of hover.
// LockHighlight (`0x782840`) and UnlockHighlight (`0x782920`), both through `0x76c2e0`: the
// highlight shows regardless of hover. That function writes `[frame+0xf8]` and then moves draw
// layer 4 with it -- enabling it on a lock, and on an unlock disabling it *unless this frame is
// the mouse focus* (`cmp [root+0x7c], ecx`), so unlocking under the cursor leaves it lit.
m.set(
"LockHighlight",
lua.create_function(|lua, this: Table| {
with_button(lua, &this, |bs| bs.locked_highlight = true)
let h = frame_handle_of(lua, &this)?;
with_button(lua, &this, |bs| bs.locked_highlight = true)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
set_highlight_layer(&mut model, h, true);
Ok(())
})?,
)?;
m.set(
"UnlockHighlight",
lua.create_function(|lua, this: Table| {
with_button(lua, &this, |bs| bs.locked_highlight = false)
let h = frame_handle_of(lua, &this)?;
with_button(lua, &this, |bs| bs.locked_highlight = false)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if model.mouseover != Some(h) {
set_highlight_layer(&mut model, h, false);
}
Ok(())
})?,
)?;
+3
View File
@@ -138,6 +138,9 @@ pub(super) fn fire_visibility_changes(lua: &Lua, changed: Vec<FrameHandle>) {
let notified = super::button::hover_notify_runs(&model, hidden_hover);
if let Some(m) = hidden_hover {
model.mouseover = None;
// The leave carries its half of the highlight with it: the layer must not stay enabled
// on a frame that is no longer under the cursor, or it comes back lit on the next show.
super::button::hover_highlight(&mut model, Some(m), false);
if model.drag.as_ref().is_some_and(|d| d.source == m) {
model.drag = None;
}
@@ -893,6 +893,11 @@ impl Plate {
model.region_data.entry(name).or_default().vertex_color = Some(c);
// The glow really shows, as addons read mouseover from it; it only paints nothing.
model.region_data.entry(glow).or_default().hidden = !state.hovered;
// The glow lives in draw layer 4, HIGHLIGHT, which is off until something highlights
// the frame. The plate's own `OnEnter` override chains back into the base notify
// (`0x7cb8a5` -> `0x76b6a0`), so the hover that shows the region enables the layer
// under it; the region's shown flag alone would leave it dark.
super::button::set_highlight_layer(model, frame, state.hovered);
}
if last.as_ref().is_none_or(|l| {
+4
View File
@@ -237,6 +237,10 @@ impl UiScript {
// is still the hover: the reference moves `[root+0x7c]` before either notify.
let old_id = old_id.filter(|_| button::hover_notify_runs(&model, old_handle));
let enter_id = new_id.filter(|_| button::hover_notify_runs(&model, new_handle));
// Both notifies move draw layer 4, HIGHLIGHT, before the Lua handler runs, so an
// `<OnEnter>` that reads the frame already sees the highlighted state.
button::hover_highlight(&mut model, old_handle, false);
button::hover_highlight(&mut model, new_handle, true);
(old_id, drag_start, enter_id, slider_change, color_change)
}
};
@@ -223,6 +223,164 @@ fn disabling_a_button_takes_its_whole_highlight_layer() {
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// Draw layer 4, HIGHLIGHT, is the highlight *state*, not a place for art that is always on: the
/// base hover notifies toggle the whole layer (`0x76b6a0` enables, `0x76b6f0` disables), which is
/// why a Button picks its highlight font by reading the layer's own flag (`0x77983c` tests
/// `[frame+0x1a8]`). So it starts off, and a region an addon creates in it stays dark until the
/// cursor arrives. Hand-built column headers and list rows all over the addon world depend on it:
/// EGM's guild market puts `UI-Character-Tab-Highlight` there and never hides it itself.
#[test]
fn a_highlight_layer_region_is_dark_until_the_cursor_is_on_the_frame() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local f = CreateFrame("Frame", "HiLayer")
f:SetPoint("BOTTOMLEFT", 0, 0); f:SetWidth(100); f:SetHeight(100)
f:EnableMouse(true)
local base = f:CreateTexture(nil, "ARTWORK")
base:SetAllPoints(f); base:SetTexture("Interface\\Base.blp")
local hi = f:CreateTexture(nil, "HIGHLIGHT")
hi:SetAllPoints(f); hi:SetTexture("Interface\\Hi.blp")
"#,
)
.unwrap();
s.resolve();
let art = |s: &UiScript| -> Vec<String> {
s.extract()
.iter()
.filter(|q| s.quad_owner_name(q.target).as_deref() == Some("HiLayer"))
.filter_map(|q| match &q.content {
QuadContent::Texture { path: Some(p), .. } => Some(p.clone()),
_ => None,
})
.collect()
};
s.mouse_move(400.0, 400.0);
assert_eq!(
art(&s),
vec!["Interface\\Base.blp".to_string()],
"the layer is off at rest, so only the ARTWORK region draws"
);
assert_eq!(
s.eval::<i64>(r#"return HiLayer:IsShown()"#).unwrap(),
1,
"the frame itself is shown; it is the layer that is off"
);
s.mouse_move(50.0, 50.0);
let v = art(&s);
assert!(
v.contains(&"Interface\\Hi.blp".to_string()),
"the cursor on the frame enables the layer: {v:?}"
);
s.mouse_move(400.0, 400.0);
assert_eq!(
art(&s),
vec!["Interface\\Base.blp".to_string()],
"and leaving disables it again"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `LockHighlight`/`UnlockHighlight` (`0x782840`/`0x782920`) both reach `0x76c2e0`, which writes
/// `[frame+0xf8]` and then moves layer 4 with it -- enabling it on a lock, and on an unlock
/// disabling it *unless this frame is the mouse focus* (`cmp [root+0x7c], ecx`). That last branch
/// is what keeps a selected trade-skill row lit when the cursor happens to be on it.
#[test]
fn lock_highlight_drives_the_layer_and_unlocking_under_the_cursor_leaves_it_lit() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local b = CreateFrame("Button", "LockBtn")
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(100); b:SetHeight(100)
local hi = b:CreateTexture(nil, "HIGHLIGHT")
hi:SetAllPoints(b); hi:SetTexture("Interface\\Hi.blp")
"#,
)
.unwrap();
s.resolve();
let lit = |s: &UiScript| -> bool {
s.extract()
.iter()
.filter(|q| s.quad_owner_name(q.target).as_deref() == Some("LockBtn"))
.any(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p == "Interface\\Hi.blp")
})
};
s.mouse_move(400.0, 400.0);
assert!(!lit(&s), "off at rest");
s.run("LockBtn:LockHighlight()").unwrap();
assert!(lit(&s), "a lock lights it with the cursor elsewhere");
s.mouse_move(50.0, 50.0);
s.run("LockBtn:UnlockHighlight()").unwrap();
assert!(
lit(&s),
"unlocking under the cursor leaves it lit: the hover owns it now"
);
s.mouse_move(400.0, 400.0);
assert!(!lit(&s), "and leaving finally takes it");
s.run("LockBtn:LockHighlight() LockBtn:UnlockHighlight()")
.unwrap();
assert!(!lit(&s), "unlocking away from the cursor disables it");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `Enable()` does not light a button on its own: the reference restores the layer only by
/// re-running `<OnEnter>` on a hovered button (`0x779183`), so a form that enables its Submit
/// button when the fields are filled does not leave it glowing from across the screen.
#[test]
fn enable_lights_the_highlight_only_under_the_cursor() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local b = CreateFrame("Button", "EnBtn")
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(100); b:SetHeight(100)
local hi = b:CreateTexture(nil, "HIGHLIGHT")
hi:SetAllPoints(b); hi:SetTexture("Interface\\Hi.blp")
b:Disable()
"#,
)
.unwrap();
s.resolve();
let lit = |s: &UiScript| -> bool {
s.extract()
.iter()
.filter(|q| s.quad_owner_name(q.target).as_deref() == Some("EnBtn"))
.any(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p == "Interface\\Hi.blp")
})
};
s.mouse_move(400.0, 400.0);
s.run("EnBtn:Enable()").unwrap();
assert!(!lit(&s), "enabled away from the cursor stays dark");
s.mouse_move(50.0, 50.0);
assert!(lit(&s), "and the cursor arriving lights it");
s.run("EnBtn:Disable() EnBtn:Enable()").unwrap();
assert!(
lit(&s),
"enabled under the cursor comes back lit, as the re-run OnEnter would"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `CButton::OnMouseDown` shows the pushed texture when the button is registered for that mouse
/// button, up or down (`0x77924b`: `[this+0x330] & (m | m << 8)`), whether or not a click fires.
#[test]
+18 -1
View File
@@ -136,6 +136,20 @@ pub use kinds::{
// ── Frame and Region nodes ───────────────────────────────────────────────────────────────────
/// [`Frame::disabled_layers`] with layer 4, HIGHLIGHT, off: a frame's resting state.
///
/// In the reference that layer is the highlight state itself, not merely where highlight art
/// lives. The base hover notifies toggle the whole layer -- `OnEnter` (`0x76b6a0`) enables it
/// (`0x76a710`), `OnLeave` (`0x76b6f0`) disables it (`0x76a730`), both skipped while
/// `LockHighlight` holds (`[frame+0xf8]`) -- `LockHighlight` drives it directly (`0x76c2e0`), and
/// `SetEnabled(false)` turns it off outright (`0x7791bb`). A Button even picks its label's font
/// instance by reading the layer's own flag: `0x77983c` tests `[frame+0x1a8]`, which is
/// `[frame+0x198] + 4*4`, and takes the highlight font object at `+0x3b8` when it is set.
///
/// So it has to start off. An addon writing `CreateTexture(nil, "HIGHLIGHT")` is relying on that
/// -- a hand-built column header or list row puts its hover art there and never hides it itself.
pub const HIGHLIGHT_OFF: u8 = 1 << DrawLayer::Highlight.index();
/// A frame node, the modelled subset of `CSimpleFrame`. The `effective_*` fields and the tree
/// links belong to the [`WidgetArena`] mutators: change state through them so it propagates.
#[derive(Clone, Debug, PartialEq)]
@@ -144,6 +158,9 @@ pub struct Frame {
/// Layers switched off by `DisableDrawLayer` (`0x775680`) and back on by `EnableDrawLayer`
/// (`0x7755b0`), bit `n` for [`DrawLayer::index`] `n`: a frame-level mask, so each region
/// keeps its own shown state underneath.
///
/// Layer 4, HIGHLIGHT, starts here set -- that layer is the client's highlight *state*, not a
/// place to put art that is always on. See [`HIGHLIGHT_OFF`].
pub disabled_layers: u8,
/// The global name, which a later duplicate carries without owning ([`WidgetArena::lookup`]).
pub name: Option<String>,
@@ -468,7 +485,7 @@ impl WidgetArena {
let effective_alpha = alpha;
let frame = Frame {
disabled_layers: 0,
disabled_layers: HIGHLIGHT_OFF,
kind,
name: name.clone(),
parent,