diff --git a/crates/benilla-ui/src/script/button.rs b/crates/benilla-ui/src/script/button.rs index 693b6bca..05faeb8a 100644 --- a/crates/benilla-ui/src/script/button.rs +++ b/crates/benilla-ui/src/script/button.rs @@ -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( } } +/// 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, 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 `` -/// (`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 `` (`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::().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::().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::().expect("model app_data"); + if model.mouseover != Some(h) { + set_highlight_layer(&mut model, h, false); + } + Ok(()) })?, )?; diff --git a/crates/benilla-ui/src/script/event.rs b/crates/benilla-ui/src/script/event.rs index 75a37a3a..2de9d87e 100644 --- a/crates/benilla-ui/src/script/event.rs +++ b/crates/benilla-ui/src/script/event.rs @@ -138,6 +138,9 @@ pub(super) fn fire_visibility_changes(lua: &Lua, changed: Vec) { 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; } diff --git a/crates/benilla-ui/src/script/nameplate.rs b/crates/benilla-ui/src/script/nameplate.rs index 0f9a0b18..270aca57 100644 --- a/crates/benilla-ui/src/script/nameplate.rs +++ b/crates/benilla-ui/src/script/nameplate.rs @@ -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| { diff --git a/crates/benilla-ui/src/script/pointer.rs b/crates/benilla-ui/src/script/pointer.rs index 0f1c8869..973b6047 100644 --- a/crates/benilla-ui/src/script/pointer.rs +++ b/crates/benilla-ui/src/script/pointer.rs @@ -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 + // `` 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) } }; diff --git a/crates/benilla-ui/src/script/tests/button.rs b/crates/benilla-ui/src/script/tests/button.rs index 38e2048d..9847a573 100644 --- a/crates/benilla-ui/src/script/tests/button.rs +++ b/crates/benilla-ui/src/script/tests/button.rs @@ -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 { + 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::(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 `` 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] diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index 622a5a5f..184c3703 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -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, @@ -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,