From 016f08b56f7d99fe5bc393270657f2288c452ffa Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:12:55 +0700 Subject: [PATCH] A texture handed to a widget's setter moves in on the setter's layer (#910) A texture object handed to a widget's texture setter stayed on its old frame and layer: Button and CheckButton slots only stored it, and StatusBar, Slider and ColorSelect ignored a texture object altogether. In the reference, each setter deletes the slot's old texture and re-links the new one into the widget: Button states on ARTWORK, the highlight on HIGHLIGHT with its blend, checked textures, thumbs and markers on OVERLAY, the bar on the given layer. The link writes the texture's shown bit, and the old parent's removal hook runs. Thumbs and markers lose their anchors. All these setters now go through one install that re-links through the region owner path and writes the shown bit; a non-current state texture answers `IsShown()` nil until its state comes, and the raise texts are the client's. Tests cover each setter, both shown-bit directions and the backdrop teardown. Fixes #902 Co-Authored-By: Claude Sonnet 5.5 --- crates/benilla-ui/src/script/button.rs | 132 ++--- crates/benilla-ui/src/script/colorselect.rs | 133 +++-- crates/benilla-ui/src/script/region.rs | 122 +++- crates/benilla-ui/src/script/region/layout.rs | 21 +- crates/benilla-ui/src/script/slider.rs | 118 ++-- crates/benilla-ui/src/script/statusbar.rs | 125 ++-- crates/benilla-ui/src/script/tests/adopt.rs | 547 ++++++++++++++++++ crates/benilla-ui/src/script/tests/mod.rs | 1 + crates/benilla-ui/src/widget/kinds/mod.rs | 14 + 9 files changed, 1021 insertions(+), 192 deletions(-) create mode 100644 crates/benilla-ui/src/script/tests/adopt.rs diff --git a/crates/benilla-ui/src/script/button.rs b/crates/benilla-ui/src/script/button.rs index ddf7db6c..8d7479bf 100644 --- a/crates/benilla-ui/src/script/button.rs +++ b/crates/benilla-ui/src/script/button.rs @@ -283,21 +283,7 @@ fn ensure_slot(lua: &Lua, this: &Table, slot: Slot) -> mlua::Result { .arena .create_region(h, kind, layer, 0) .ok_or_else(|| mlua::Error::runtime("stale frame handle"))?; - // A new highlight blends ADD, `SetHighlightTexture`'s default (`0x781d92`). The - // reference applies that mode, or a third argument's, on every call and to a handed-in - // Texture too (`0x7703f0` from `0x779050` and `0x779110`); here only a new region - // takes it. - model.region_data.insert( - rh, - RegionData { - blend: if slot == Slot::Highlight { - crate::script::BlendMode::Add - } else { - crate::script::BlendMode::default() - }, - ..Default::default() - }, - ); + model.region_data.insert(rh, RegionData::default()); model.touch_layout(); // a region entered the layout gate's read set if let Some(frame) = model.arena.frame_mut(h) { if let KindState::Button(bs) = &mut frame.kind_state { @@ -316,35 +302,40 @@ fn ensure_slot(lua: &Lua, this: &Table, slot: Slot) -> mlua::Result { Ok(model.region_id(rh)) } -/// Point `slot` at `rh` or at nothing, returning the different region it held for the caller to -/// free, as the slot store `0x778fd0` destroys the outgoing object. -fn swap_slot( - model: &mut Model, - h: FrameHandle, +/// Install `incoming` (a Texture object) into `slot`, or clear it, as the slot store `0x778fd0` +/// does: the same texture is a no-op (`0x778fe7`), the displaced one is destroyed (`0x77900a`), and +/// the incoming one moves into the button on the slot's own draw layer (`0x779017`, `0x779139` +/// for the highlight) before it is stored. +fn install_slot( + lua: &Lua, + this: &Table, slot: Slot, - rh: Option, -) -> mlua::Result> { - let frame = model - .arena - .frame_mut(h) - .ok_or_else(|| mlua::Error::runtime("stale frame handle"))?; - let KindState::Button(bs) = &mut frame.kind_state else { + incoming: Option, +) -> mlua::Result { + let h = frame_handle_of(lua, this)?; + let mut model = lua.app_data_mut::().expect("model app_data"); + if !matches!( + model.arena.frame(h).map(|f| &f.kind_state), + Some(KindState::Button(_)) + ) { return Err(mlua::Error::runtime("not a Button")); - }; - let outgoing = slot.get(bs).filter(|old| Some(*old) != rh); - slot.set(bs, rh); - model.touch_layout(); - Ok(outgoing) -} - -/// Free the region a slot gave up, never the one that moved in. -fn free_outgoing(lua: &Lua, outgoing: Option, incoming: RegionHandle) { - if let Some(old) = outgoing.filter(|old| *old != incoming) { - super::region::free_region( - &mut lua.app_data_mut::().expect("model app_data"), - old, - ); } + super::region::install_into_slot( + &mut model, + h, + incoming, + slot.shape().1, + false, + |ks| match ks { + KindState::Button(bs) => slot.get(bs), + _ => None, + }, + |ks, rh| { + if let KindState::Button(bs) = ks { + slot.set(bs, rh); + } + }, + ) } /// `SetTexture(texture | "path" | nil)`, forked on the argument's type (`0x781970`). The @@ -359,37 +350,40 @@ fn set_slot_texture( usage: &str, args: &MultiValue, ) -> mlua::Result<()> { + // The highlight's third argument names its blend, ADD without one or on a name that does not + // parse (`0x781d92`, `0x781db3` → `0x6f1920`); every other state has none. + let blend = (slot == Slot::Highlight).then(|| match args.get(1) { + Some(Value::String(n)) => n + .to_str() + .ok() + .and_then(|n| crate::script::BlendMode::parse(&n)) + .unwrap_or(crate::script::BlendMode::Add), + _ => crate::script::BlendMode::Add, + }); match args.front() { - // A Texture object is itself installed into the slot (`0x781b0b` → `0x778fd0`). + // A Texture object is itself installed into the slot (`0x781b0b` → `0x778fd0`; the + // highlight's `0x781eb7` → `0x779110`, which also sets its blend, `0x779144`, unless it + // is the same texture, `0x779122`). Any other widget raises (`0x781ab1`, message + // `0x87a1e0`; the highlight's `0x87a28c`). Some(Value::Table(t)) => { - let rh = super::region::region_handle_of(lua, t)?; - let h = frame_handle_of(lua, this)?; - let mut model = lua.app_data_mut::().expect("model app_data"); - // Any other widget raises (`0x781ab1`, message `0x87a1e0`). - if model.arena.region(rh).map(|r| r.kind) != Some(RegionKind::Texture) { - return Err(mlua::Error::runtime("Wrong object type, expected texture")); + let method = usage.split('(').next().unwrap_or(usage); + let rh = super::region::texture_object_arg(lua, this, t, method)?; + if install_slot(lua, this, slot, Some(rh))? { + if let Some(blend) = blend { + lua.app_data_mut::() + .expect("model app_data") + .region_data + .entry(rh) + .or_default() + .blend = blend; + } } - let outgoing = swap_slot(&mut model, h, slot, Some(rh))?; - drop(model); - free_outgoing(lua, outgoing, rh); return Ok(()); } // nil clears the slot and destroys the old region (`0x781b5a` → `0x778fd0`): unhooked but // alive, it would draw in every state. No argument is not nil (`0x781b4f`) and clears // nothing. - Some(Value::Nil) => { - let h = frame_handle_of(lua, this)?; - let mut model = lua.app_data_mut::().expect("model app_data"); - let outgoing = swap_slot(&mut model, h, slot, None)?; - drop(model); - if let Some(old) = outgoing { - super::region::free_region( - &mut lua.app_data_mut::().expect("model app_data"), - old, - ); - } - return Ok(()); - } + Some(Value::Nil) => return install_slot(lua, this, slot, None).map(|_| ()), _ => {} } @@ -419,6 +413,11 @@ fn set_slot_texture( // `""` clears the texture, which `QuestLogFrame.lua:165` relies on. data.texture = (!path.is_empty()).then_some(path); data.fill = None; + // The highlight applies its blend on every path call, to the old region as to a new one + // (`0x779082`, `0x7790e6`). + if let Some(blend) = blend { + data.blend = blend; + } Ok(()) } @@ -570,10 +569,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // Destroyed, not orphaned (`0x778d3c`). super::region::free_region(&mut model, dead); } - model.arena.set_region_owner(rh, Some(owner)); - if let Some(r) = model.arena.region_mut(rh) { - r.draw_layer = DrawLayer::Artwork; - } + super::region::link_region(&mut model, rh, owner, DrawLayer::Artwork); adopt_label(&mut model, owner, rh); // The layout's read set moved, and `GetTextWidth` reads the new label's extents. model.touch_layout(); diff --git a/crates/benilla-ui/src/script/colorselect.rs b/crates/benilla-ui/src/script/colorselect.rs index cabe4216..7d3ef164 100644 --- a/crates/benilla-ui/src/script/colorselect.rs +++ b/crates/benilla-ui/src/script/colorselect.rs @@ -10,7 +10,7 @@ use mlua::{Lua, Table, Value}; use super::object::{draw_layer_from_str, frame_handle_of}; use super::pointer::point_in_rect; -use super::region::region_wrapper; +use super::region::{install_into_slot, region_wrapper, texture_object_arg}; use super::{event, Model, RegionData}; use crate::layout::Rect; use crate::order::DrawLayer; @@ -68,6 +68,15 @@ impl Slot { *slot = Some(rh); } + fn set_opt(self, s: &mut ColorSelectState, rh: Option) { + match self { + Slot::Wheel => s.wheel = rh, + Slot::WheelThumb => s.wheel_thumb = rh, + Slot::ValueStrip => s.value_strip = rh, + Slot::ValueThumb => s.value_thumb = rh, + } + } + /// The layer a slot's region is born on: ARTWORK for the art (the wheel's layer 2, through /// `0x77fd10`), OVERLAY for the two markers above it. fn layer(self) -> DrawLayer { @@ -118,6 +127,48 @@ fn ensure_slot(lua: &Lua, this: &Table, slot: Slot, layer: Option) -> Ok(model.region_id(rh)) } +/// Install `incoming` into `slot`, or clear it, through the slot hooks: the wheel `0x78b580` and +/// the strip `0x78b8a0` link on ARTWORK and replace the texture's image with the generated one +/// (`0x770300`), so it ends file-less here, where the renderer paints it; the markers +/// `0x78b850`/`0x78ba90` link on OVERLAY and clear their anchors (`0x767ed0`). +fn install_slot( + lua: &Lua, + this: &Table, + slot: Slot, + incoming: Option, +) -> mlua::Result<()> { + let h = frame_handle_of(lua, this)?; + let mut model = lua.app_data_mut::().expect("model app_data"); + if !matches!( + model.arena.frame(h).map(|f| &f.kind_state), + Some(KindState::ColorSelect(_)) + ) { + return Err(mlua::Error::runtime("not a ColorSelect")); + } + let thumb = matches!(slot, Slot::WheelThumb | Slot::ValueThumb); + let changed = install_into_slot( + &mut model, + h, + incoming, + slot.layer(), + thumb, + |ks| match ks { + KindState::ColorSelect(s) => slot.get(s), + _ => None, + }, + |ks, rh| { + if let KindState::ColorSelect(s) = ks { + slot.set_opt(s, rh); + } + }, + )?; + // The same texture returned at the first compare (`0x78b598`) and keeps what it was set to. + if let (true, Some(rh), false) = (changed, incoming, thumb) { + super::region::clear_texture(&mut model, rh); + } + Ok(()) +} + /// `SetTexture([path [, drawLayer]] | r, g, b [, a])`, plus an empty form that only creates /// the region: what `` means. A file-less slot is where the app renderer paints /// the disc and strip the reference computes (`0x78b580`, `0x78b8a0`). @@ -128,44 +179,54 @@ fn install_slot_texture( setter: &str, getter: &str, ) -> mlua::Result<()> { + let method = setter.to_string(); m.set( setter, - lua.create_function( - move |lua, (this, a1, a2, a3, a4): (Table, Value, Value, Value, Value)| { - let layer = match &a2 { - Value::String(s) => s.to_str().ok().and_then(|l| draw_layer_from_str(&l)), - _ => None, - }; - let id = ensure_slot(lua, &this, slot, layer)?; - let rh = { - let model = lua.app_data_ref::().expect("model app_data"); - *model.id_to_region.get(&id).expect("colorselect region id") - }; - let mut model = lua.app_data_mut::().expect("model app_data"); - let data = model.region_data.entry(rh).or_default(); - match &a1 { - Value::String(s) => { - data.texture = Some(s.to_str()?.to_string()); - data.fill = None; - } - Value::Number(_) | Value::Integer(_) => { - data.texture = None; - data.fill = Some([ - num_f32(&a1), - num_f32(&a2), - num_f32(&a3), - match &a4 { - Value::Nil => 1.0, - v => num_f32(v), - }, - ]); - } - // The empty form: the region now exists and stays file-less. - _ => {} + lua.create_function(move |lua, (this, args): (Table, mlua::MultiValue)| { + let nil = Value::Nil; + let arg = |i: usize| args.get(i).unwrap_or(&nil); + let (a1, a2, a3, a4) = (arg(0), arg(1), arg(2), arg(3)); + let layer = match a2 { + Value::String(s) => s.to_str().ok().and_then(|l| draw_layer_from_str(&l)), + _ => None, + }; + match args.front() { + Some(Value::Table(t)) => { + let rh = texture_object_arg(lua, &this, t, &method)?; + return install_slot(lua, &this, slot, Some(rh)); } - Ok(()) - }, - )?, + Some(Value::Nil) => return install_slot(lua, &this, slot, None), + _ => {} + } + let id = ensure_slot(lua, &this, slot, layer)?; + let rh = { + let model = lua.app_data_ref::().expect("model app_data"); + *model.id_to_region.get(&id).expect("colorselect region id") + }; + let mut model = lua.app_data_mut::().expect("model app_data"); + let data = model.region_data.entry(rh).or_default(); + match a1 { + Value::String(s) => { + data.texture = Some(s.to_str()?.to_string()); + data.fill = None; + } + Value::Number(_) | Value::Integer(_) => { + data.texture = None; + data.fill = Some([ + num_f32(a1), + num_f32(a2), + num_f32(a3), + match a4 { + Value::Nil => 1.0, + v => num_f32(v), + }, + ]); + } + // The empty form: the region now exists and stays file-less. + _ => {} + } + Ok(()) + })?, )?; m.set( getter, diff --git a/crates/benilla-ui/src/script/region.rs b/crates/benilla-ui/src/script/region.rs index 7451f551..41ec4343 100644 --- a/crates/benilla-ui/src/script/region.rs +++ b/crates/benilla-ui/src/script/region.rs @@ -26,6 +26,124 @@ pub(super) fn region_handle_of(lua: &Lua, this: &Table) -> mlua::Result`. +pub(super) fn texture_object_arg( + lua: &Lua, + this: &Table, + arg: &Table, + method: &str, +) -> mlua::Result { + let who = { + let model = lua.app_data_ref::().expect("model app_data"); + let name = super::object::frame_handle_of(lua, this) + .ok() + .and_then(|h| model.arena.frame(h).and_then(|f| f.name.clone())); + name.unwrap_or_else(|| "".to_string()) + }; + if decode_id(arg).is_err() { + return Err(mlua::Error::runtime(format!( + "{who}:{method}(): Couldn't find 'this' in texture" + ))); + } + let model = lua.app_data_ref::().expect("model app_data"); + let rh = decode_id(arg) + .ok() + .and_then(|id| model.id_to_region.get(&id).copied()) + .filter(|&rh| model.arena.region(rh).map(|r| r.kind) == Some(RegionKind::Texture)); + rh.ok_or_else(|| { + mlua::Error::runtime(format!( + "{who}:{method}(): Wrong object type, expected texture" + )) + }) +} + +/// The client's re-link `0x77fd10(owner, layer, shown)` for a region a widget takes into a slot: +/// out of the old parent (its region-removed hook runs, `0x77fd55`) and into `owner` on `layer`, +/// the layer set whether or not the parent changed (`0x77fd9a`). +/// +/// The third argument is the region's own shown bit (`+0xc4`, what `IsShown` reads): leaving the +/// old parent clears it (`0x77fd3d`) and `shown` writes it back (`0x77fd85`, `0x77fdc8`). Every +/// widget that takes a texture calls with 1 (the bar, thumb, highlight, ColorSelect slots, the +/// label) except a Button's state and checked slots, whose 0 is followed by the state switch +/// writing the bit for the texture that shows (`0x779037`, `0x7797ea`, `0x785538`). benilla keeps +/// that state side in [`crate::widget::ButtonState::region_visible`], so those too end with the +/// script-side bit shown, and `IsShown` reads the state side beside it. +pub(super) fn link_region( + model: &mut Model, + rh: RegionHandle, + owner: crate::widget::FrameHandle, + layer: crate::order::DrawLayer, +) { + if model.arena.set_region_owner(rh, Some(owner)) { + // The owner supplies the region's scale to layout, so a re-link is a layout change. + model.touch_layout(); + } + if let Some(r) = model.arena.region_mut(rh) { + r.draw_layer = layer; + } + model.region_data.entry(rh).or_default().hidden = false; +} + +/// A widget's one-texture slot taking `incoming` (a Texture object, or nothing to clear), as every +/// slot setter does (`0x778fd0`, `0x7831f0`, `0x789860`, `0x785330`, `0x78b580`..): the same +/// texture is a no-op, the displaced one is deleted, the incoming one is linked in on `layer` +/// (a thumb marker then loses its anchors, `0x767ed0`, since the widget places it), then it is +/// stored. `get` and `set` read and write the slot in the widget's own state. Answers whether the +/// slot changed, for a caller whose hook does more once it has. +pub(super) fn install_into_slot( + model: &mut Model, + owner: crate::widget::FrameHandle, + incoming: Option, + layer: crate::order::DrawLayer, + clear_points: bool, + get: impl Fn(&crate::widget::KindState) -> Option, + set: impl Fn(&mut crate::widget::KindState, Option), +) -> mlua::Result { + let current = model + .arena + .frame(owner) + .map(|f| get(&f.kind_state)) + .ok_or_else(|| mlua::Error::runtime("stale frame handle"))?; + if incoming == current { + return Ok(false); + } + if let Some(old) = current { + free_region(model, old); + } + if let Some(rh) = incoming { + link_region(model, rh, owner, layer); + if clear_points { + layout::clear_points(model, rh); + } + } + if let Some(f) = model.arena.frame_mut(owner) { + set(&mut f.kind_state, incoming); + } + model.touch_layout(); + Ok(true) +} + +/// The region's shown bit as `IsShown` reads it (`0x79b9b0`, `[+0xc4]`): its own `Show`/`Hide`, +/// and for a Button's state and checked textures also the state side that writes the bit for the +/// one that shows. +fn region_shown(model: &Model, rh: RegionHandle) -> bool { + if model.region_data.get(&rh).is_some_and(|d| d.hidden) { + return false; + } + let owner = model.arena.region(rh).map(|r| r.owner); + match owner + .and_then(|o| model.arena.frame(o)) + .map(|f| &f.kind_state) + { + Some(crate::widget::KindState::Button(bs)) => bs.state_region_shown(rh).unwrap_or(true), + _ => true, + } +} + /// Argument 1 of a global that takes a Texture as its `this` (`SetLootPortrait` `0x4c2b40`, /// `SetInventoryPortaitTexture` `0x4c9150`), with the member prologue's three raises: a non-table /// (`0x847ef8`), a table with no object at `[0]` (`0x847ec0`), and any object but a Texture, the @@ -335,7 +453,7 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> { lua.create_function(|lua, this: Table| { let rh = region_handle_of(lua, &this)?; let model = lua.app_data_ref::().expect("model"); - let shown = !model.region_data.get(&rh).is_some_and(|d| d.hidden); + let shown = region_shown(&model, rh); Ok(if shown { Value::Integer(1) } else { Value::Nil }) })?, )?; @@ -345,7 +463,7 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> { lua.create_function(|lua, this: Table| { let rh = region_handle_of(lua, &this)?; let model = lua.app_data_ref::().expect("model"); - let shown = !model.region_data.get(&rh).is_some_and(|d| d.hidden); + let shown = region_shown(&model, rh); let owner_visible = model .arena .region(rh) diff --git a/crates/benilla-ui/src/script/region/layout.rs b/crates/benilla-ui/src/script/region/layout.rs index 9f27a217..c9e5292a 100644 --- a/crates/benilla-ui/src/script/region/layout.rs +++ b/crates/benilla-ui/src/script/region/layout.rs @@ -14,6 +14,18 @@ use super::{ }; /// Install the layout methods into `m`. +/// `ClearAllPoints` on a region (`0x767ed0`): a retarget onto no targets, so the cached graph +/// unlinks the edges with no re-derive. +pub(in crate::script) fn clear_points(model: &mut Model, rh: crate::widget::RegionHandle) { + let d = model.region_data.entry(rh).or_default(); + let old: Option> = + (!d.anchors.is_empty()).then(|| d.anchors.iter().map(|a| a.relative_to).collect()); + d.anchors.clear(); + if let Some(old) = old { + model.touch_layout_retarget_region(rh, &old, &[]); + } +} + pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { // The explicit size fills the axes the anchors do not pin, so SetAllPoints's two corners // leave it unread. @@ -110,14 +122,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { |lua, this: Table| { let rh = region_handle_of(lua, &this)?; let mut model = lua.app_data_mut::().expect("model"); - let d = model.region_data.entry(rh).or_default(); - // A retarget onto no targets: the cached graph unlinks the edges, no re-derive. - let old: Option> = - (!d.anchors.is_empty()).then(|| d.anchors.iter().map(|a| a.relative_to).collect()); - d.anchors.clear(); - if let Some(old) = old { - model.touch_layout_retarget_region(rh, &old, &[]); - } + clear_points(&mut model, rh); Ok(()) }, )?; diff --git a/crates/benilla-ui/src/script/slider.rs b/crates/benilla-ui/src/script/slider.rs index 59728383..d7365785 100644 --- a/crates/benilla-ui/src/script/slider.rs +++ b/crates/benilla-ui/src/script/slider.rs @@ -10,12 +10,12 @@ use mlua::{Lua, Table, Value}; use super::object::{draw_layer_from_str, frame_handle_of}; use super::pointer::point_in_rect; -use super::region::region_wrapper; +use super::region::{install_into_slot, region_wrapper, texture_object_arg}; use super::{event, Model, RegionData}; use crate::layout::Rect; use crate::order::DrawLayer; use crate::widget::{ - slider_fraction, slider_grab, FrameHandle, KindState, RegionKind, SliderState, + slider_fraction, slider_grab, FrameHandle, KindState, RegionHandle, RegionKind, SliderState, }; pub(super) const REG_SLIDER_METHODS: &str = "__benilla_slider_methods"; @@ -85,6 +85,35 @@ fn ensure_thumb(lua: &Lua, this: &Table, layer: Option) -> mlua::Resu Ok(model.region_id(rh)) } +/// Install `incoming` as the thumb, or clear it, through `0x789860`. +fn install_thumb(lua: &Lua, this: &Table, incoming: Option) -> mlua::Result<()> { + let h = frame_handle_of(lua, this)?; + let mut model = lua.app_data_mut::().expect("model app_data"); + if !matches!( + model.arena.frame(h).map(|f| &f.kind_state), + Some(KindState::Slider(_)) + ) { + return Err(mlua::Error::runtime("not a Slider")); + } + install_into_slot( + &mut model, + h, + incoming, + DrawLayer::Overlay, + true, + |ks| match ks { + KindState::Slider(sl) => sl.thumb, + _ => None, + }, + |ks, rh| { + if let KindState::Slider(sl) = ks { + sl.thumb = rh; + } + }, + ) + .map(|_| ()) +} + pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let m = lua.create_table()?; @@ -151,46 +180,57 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // A 1.12 Slider has no enabled state: `Enable`/`Disable`/`IsEnabled` exist only in the Button // table (`0x879d00`: `0x77fef0`, `0x77ffd0`, `0x7800b0`), so a Slider must not answer them. - // `SetThumbTexture(path [, layer])` or `(r, g, b [, a])`, a region's two `SetTexture` forms, on - // the thumb region, created on first use. + // `SetThumbTexture(texture)` installs the object as the thumb, linked on OVERLAY (the constant + // `0x790431` passes) with its anchors cleared (`0x789893`), displacing the old thumb; nil + // clears it (`0x790473`). Otherwise `(path [, layer])` or `(r, g, b [, a])`, a region's two + // `SetTexture` forms, on the thumb region, created on first use. m.set( "SetThumbTexture", - lua.create_function( - |lua, (this, a1, a2, a3, a4): (Table, Value, Value, Value, Value)| { - let layer = match &a2 { - Value::String(s) => s.to_str().ok().and_then(|l| draw_layer_from_str(&l)), - _ => None, - }; - let id = ensure_thumb(lua, &this, layer)?; - let rh = { - let model = lua.app_data_ref::().expect("model app_data"); - *model.id_to_region.get(&id).expect("thumb region id") - }; - let mut model = lua.app_data_mut::().expect("model app_data"); - let data = model.region_data.entry(rh).or_default(); - match &a1 { - Value::String(s) => { - data.texture = Some(s.to_str()?.to_string()); - data.fill = None; - } - // A solid thumb and a path share one slot; each clears the other. - Value::Number(_) | Value::Integer(_) => { - data.texture = None; - data.fill = Some([ - num_f32(&a1), - num_f32(&a2), - num_f32(&a3), - match &a4 { - Value::Nil => 1.0, - v => num_f32(v), - }, - ]); - } - _ => {} + lua.create_function(|lua, (this, args): (Table, mlua::MultiValue)| { + let nil = Value::Nil; + let arg = |i: usize| args.get(i).unwrap_or(&nil); + let (a1, a2, a3, a4) = (arg(0), arg(1), arg(2), arg(3)); + let layer = match a2 { + Value::String(s) => s.to_str().ok().and_then(|l| draw_layer_from_str(&l)), + _ => None, + }; + match args.front() { + Some(Value::Table(t)) => { + let rh = texture_object_arg(lua, &this, t, "SetThumbTexture")?; + return install_thumb(lua, &this, Some(rh)); } - Ok(()) - }, - )?, + Some(Value::Nil) => return install_thumb(lua, &this, None), + _ => {} + } + let id = ensure_thumb(lua, &this, layer)?; + let rh = { + let model = lua.app_data_ref::().expect("model app_data"); + *model.id_to_region.get(&id).expect("thumb region id") + }; + let mut model = lua.app_data_mut::().expect("model app_data"); + let data = model.region_data.entry(rh).or_default(); + match a1 { + Value::String(s) => { + data.texture = Some(s.to_str()?.to_string()); + data.fill = None; + } + // A solid thumb and a path share one slot; each clears the other. + Value::Number(_) | Value::Integer(_) => { + data.texture = None; + data.fill = Some([ + num_f32(a1), + num_f32(a2), + num_f32(a3), + match a4 { + Value::Nil => 1.0, + v => num_f32(v), + }, + ]); + } + _ => {} + } + Ok(()) + })?, )?; m.set( "GetThumbTexture", diff --git a/crates/benilla-ui/src/script/statusbar.rs b/crates/benilla-ui/src/script/statusbar.rs index c783cc47..c31a1659 100644 --- a/crates/benilla-ui/src/script/statusbar.rs +++ b/crates/benilla-ui/src/script/statusbar.rs @@ -7,10 +7,10 @@ use mlua::{Lua, Table, Value}; use super::object::{draw_layer_from_str, frame_handle_of}; -use super::region::region_wrapper; +use super::region::{install_into_slot, region_wrapper, texture_object_arg}; use super::{event, Model, RegionData}; use crate::order::DrawLayer; -use crate::widget::{KindState, RegionKind, StatusBarState}; +use crate::widget::{KindState, RegionHandle, RegionKind, StatusBarState}; /// Registry key of the StatusBar method table. pub(super) const REG_STATUSBAR_METHODS: &str = "__benilla_statusbar_methods"; @@ -90,6 +90,40 @@ fn ensure_bar(lua: &Lua, this: &Table, layer: Option) -> mlua::Result Ok(model.region_id(rh)) } +/// Install `incoming` as the bar texture, or clear it, through `0x7831f0`. +fn install_bar( + lua: &Lua, + this: &Table, + incoming: Option, + layer: DrawLayer, +) -> mlua::Result<()> { + let h = frame_handle_of(lua, this)?; + let mut model = lua.app_data_mut::().expect("model app_data"); + if !matches!( + model.arena.frame(h).map(|f| &f.kind_state), + Some(KindState::StatusBar(_)) + ) { + return Err(mlua::Error::runtime("not a StatusBar")); + } + install_into_slot( + &mut model, + h, + incoming, + layer, + false, + |ks| match ks { + KindState::StatusBar(sb) => sb.bar, + _ => None, + }, + |ks, rh| { + if let KindState::StatusBar(sb) = ks { + sb.bar = rh; + } + }, + ) + .map(|_| ()) +} + pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let m = lua.create_table()?; @@ -143,46 +177,59 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // A region's two `SetTexture` forms, `(path [, layer])` and `(r, g, b [, a])`, on the bar. + // `(texture [, layer])` installs the object as the bar, linked on `layer` (ARTWORK without + // one, `0x78f902`) and displacing the old bar (`0x7831f0`); nil clears it (`0x78fa6d`). Its + // raises print `SetCheckedTexture`, the client's copy-pasted format strings (`0x87b1e8`, + // `0x87b224`). Otherwise a region's two `SetTexture` forms, `(path [, layer])` and + // `(r, g, b [, a])`, on the bar. m.set( "SetStatusBarTexture", - lua.create_function( - |lua, (this, a1, a2, a3, a4): (Table, Value, Value, Value, Value)| { - let layer = match &a2 { - Value::String(s) => s.to_str().ok().and_then(|l| draw_layer_from_str(&l)), - _ => None, - }; - let id = ensure_bar(lua, &this, layer)?; - let rh = { - let model = lua.app_data_ref::().expect("model app_data"); - *model.id_to_region.get(&id).expect("bar region id") - }; - let mut model = lua.app_data_mut::().expect("model app_data"); - let data = model.region_data.entry(rh).or_default(); - match &a1 { - Value::String(s) => { - data.texture = Some(s.to_str()?.to_string()); - data.fill = None; - } - // A solid fill and a path clear each other; `SetStatusBarColor` is a separate - // tint that multiplies either. - Value::Number(_) | Value::Integer(_) => { - data.texture = None; - data.fill = Some([ - num_f32(&a1), - num_f32(&a2), - num_f32(&a3), - match &a4 { - Value::Nil => 1.0, - v => num_f32(v), - }, - ]); - } - _ => {} + lua.create_function(|lua, (this, args): (Table, mlua::MultiValue)| { + let nil = Value::Nil; + let arg = |i: usize| args.get(i).unwrap_or(&nil); + let (a1, a2, a3, a4) = (arg(0), arg(1), arg(2), arg(3)); + let layer = match a2 { + Value::String(s) => s.to_str().ok().and_then(|l| draw_layer_from_str(&l)), + _ => None, + }; + match args.front() { + Some(Value::Table(t)) => { + let rh = texture_object_arg(lua, &this, t, "SetCheckedTexture")?; + return install_bar(lua, &this, Some(rh), layer.unwrap_or(DrawLayer::Artwork)); } - Ok(()) - }, - )?, + Some(Value::Nil) => return install_bar(lua, &this, None, DrawLayer::Artwork), + _ => {} + } + let id = ensure_bar(lua, &this, layer)?; + let rh = { + let model = lua.app_data_ref::().expect("model app_data"); + *model.id_to_region.get(&id).expect("bar region id") + }; + let mut model = lua.app_data_mut::().expect("model app_data"); + let data = model.region_data.entry(rh).or_default(); + match a1 { + Value::String(s) => { + data.texture = Some(s.to_str()?.to_string()); + data.fill = None; + } + // A solid fill and a path clear each other; `SetStatusBarColor` is a separate + // tint that multiplies either. + Value::Number(_) | Value::Integer(_) => { + data.texture = None; + data.fill = Some([ + num_f32(a1), + num_f32(a2), + num_f32(a3), + match a4 { + Value::Nil => 1.0, + v => num_f32(v), + }, + ]); + } + _ => {} + } + Ok(()) + })?, )?; m.set( "GetStatusBarTexture", diff --git a/crates/benilla-ui/src/script/tests/adopt.rs b/crates/benilla-ui/src/script/tests/adopt.rs new file mode 100644 index 00000000..24851a37 --- /dev/null +++ b/crates/benilla-ui/src/script/tests/adopt.rs @@ -0,0 +1,547 @@ +//! A texture object handed to a widget's texture setter moves into that widget: unlinked from its +//! old parent (running the parent's region-removed hook), linked into the new one on the setter's +//! own draw layer (`0x77fd10`). + +use super::common::script; +use crate::script::*; + +fn drawn(s: &UiScript) -> Vec { + s.extract() + .iter() + .filter_map(|q| match &q.content { + QuadContent::Texture { path: Some(p), .. } => Some(p.clone()), + _ => None, + }) + .collect() +} + +/// `0x778fd0` → `0x77fd10(button, 2, 0)`: the adopted texture answers the button for its parent, +/// sits on ARTWORK whatever layer it was made on, and draws and hides with the button. +#[test] +fn a_state_slot_adopts_the_texture_into_the_button_on_artwork() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + a = CreateFrame("Frame", "AdoptA") + a:SetPoint("CENTER", 0, 0) a:SetWidth(50) a:SetHeight(50) + a:SetFrameStrata("LOW") + t = a:CreateTexture(nil, "OVERLAY") + t:SetTexture("adopted") t:SetAllPoints(a) + b = CreateFrame("Button", "AdoptB") + b:SetPoint("CENTER", 0, 0) b:SetWidth(50) b:SetHeight(50) + b:SetFrameStrata("DIALOG") + c = CreateFrame("Frame", "AdoptC") + c:SetPoint("CENTER", 0, 0) c:SetWidth(50) c:SetHeight(50) + c:SetFrameStrata("HIGH") + ct = c:CreateTexture(nil, "BACKGROUND") + ct:SetTexture("mid") ct:SetAllPoints(c) + assert(t:GetParent() == a) + b:SetNormalTexture(t) + "#, + ) + .unwrap(); + s.run( + r#" + assert(t:GetParent() == b, "the button is the parent") + assert(t:GetDrawLayer() == "ARTWORK", "layer 2: " .. t:GetDrawLayer()) + assert(b:GetNormalTexture() == t) + "#, + ) + .unwrap(); + s.resolve(); + assert_eq!( + drawn(&s), + vec!["mid".to_string(), "adopted".to_string()], + "it draws at the button's strata, over the HIGH frame's texture, not at LOW" + ); + + s.run("b:Hide()").unwrap(); + s.resolve(); + assert_eq!( + drawn(&s), + vec!["mid".to_string()], + "it hides with the button, not with its old frame" + ); +} + +/// The pushed and disabled slots link the same way (`0x778fd0` is one function over the slot index). +#[test] +fn the_pushed_and_disabled_slots_adopt_on_artwork_too() { + let s = script(); + s.run( + r#" + a = CreateFrame("Frame") + b = CreateFrame("Button") + p = a:CreateTexture(nil, "BORDER") + d = a:CreateTexture(nil, "OVERLAY") + b:SetPushedTexture(p) b:SetDisabledTexture(d) + assert(p:GetParent() == b and d:GetParent() == b) + assert(p:GetDrawLayer() == "ARTWORK" and d:GetDrawLayer() == "ARTWORK") + "#, + ) + .unwrap(); +} + +/// `0x779110` → `0x77fd10(button, 4, 1)`: the highlight links on HIGHLIGHT. +#[test] +fn the_highlight_slot_adopts_on_highlight() { + let s = script(); + s.run( + r#" + a = CreateFrame("Frame") + b = CreateFrame("Button") + t = a:CreateTexture(nil, "ARTWORK") + b:SetHighlightTexture(t) + assert(t:GetParent() == b, "reparented") + assert(t:GetDrawLayer() == "HIGHLIGHT", t:GetDrawLayer()) + "#, + ) + .unwrap(); +} + +/// `0x785330` / `0x785440` → `0x77fd10(button, 3, 0)`: the checked pair links on OVERLAY. +#[test] +fn the_checked_pair_adopts_on_overlay() { + let s = script(); + s.run( + r#" + a = CreateFrame("Frame") + b = CreateFrame("CheckButton") + c = a:CreateTexture(nil, "BACKGROUND") + dc = a:CreateTexture(nil, "ARTWORK") + b:SetCheckedTexture(c) b:SetDisabledCheckedTexture(dc) + assert(c:GetParent() == b and dc:GetParent() == b) + assert(c:GetDrawLayer() == "OVERLAY" and dc:GetDrawLayer() == "OVERLAY") + "#, + ) + .unwrap(); +} + +/// Leaving a parent runs its region-removed hook (`0x77fd55`): the button that gave the texture up +/// no longer holds it. +#[test] +fn the_old_parent_lets_go_of_the_texture_it_gave_up() { + let s = script(); + s.run( + r#" + a = CreateFrame("Button", "GiverBtn") + a:SetNormalTexture("own") + t = a:GetNormalTexture() + b = CreateFrame("Button", "TakerBtn") + b:SetNormalTexture(t) + assert(a:GetNormalTexture() == nil, "the giver's slot let go") + assert(b:GetNormalTexture() == t and t:GetParent() == b) + a:SetNormalTexture("again") + assert(a:GetNormalTexture() ~= t and b:GetNormalTexture() == t, "and rebuilds its own") + "#, + ) + .unwrap(); +} + +/// A backdrop deletes its nine pieces by the pointers it keeps (`0x77f4d0`), wherever they have +/// moved to; the piece dies with its current parent's hook (`0x77fd10` links it there), which +/// empties the button's slot, so the slot never dangles and takes a fresh texture. +#[test] +fn an_old_frames_backdrop_teardown_leaves_the_new_parents_slot_empty_not_dangling() { + let s = script(); + s.run( + r#" + f = CreateFrame("Frame") + f:SetBackdrop({ bgFile = "bg", edgeFile = "edge" }) + piece = ({ f:GetRegions() })[1] + b = CreateFrame("Button") + b:SetPushedTexture(piece) + assert(piece:GetParent() == b and b:GetPushedTexture() == piece) + f:SetBackdrop(nil) + assert(b:GetPushedTexture() == nil, "the slot let go of the deleted piece") + assert(not pcall(piece.GetTexture, piece), "the piece is gone") + b:SetPushedTexture("fresh") + assert(b:GetPushedTexture():GetTexture() == "fresh") + "#, + ) + .unwrap(); +} + +/// The slot compare comes first (`0x778fe5`): the same texture again changes nothing, not its +/// layer and not its life. +#[test] +fn adopting_the_same_texture_twice_is_a_no_op() { + let s = script(); + s.run( + r#" + a = CreateFrame("Frame") + b = CreateFrame("Button") + t = a:CreateTexture(nil, "OVERLAY") + t:SetTexture("keep") + b:SetNormalTexture(t) + t:SetDrawLayer("BORDER") + b:SetNormalTexture(t) + assert(b:GetNormalTexture() == t and t:GetTexture() == "keep", "still held, still alive") + assert(t:GetDrawLayer() == "BORDER", "the second call did not re-layer it") + "#, + ) + .unwrap(); +} + +/// `0x7831f0`: the bar texture is the object, linked on the layer argument (ARTWORK without one), +/// and the old bar is deleted; nil clears the bar (`0x78fa6d`). +#[test] +fn a_status_bar_takes_a_texture_object_and_a_nil() { + let s = script(); + s.run( + r#" + a = CreateFrame("Frame") + sb = CreateFrame("StatusBar", "AdoptSB") + sb:SetStatusBarTexture("old") + old = sb:GetStatusBarTexture() + t = a:CreateTexture(nil, "OVERLAY") + sb:SetStatusBarTexture(t) + assert(sb:GetStatusBarTexture() == t, "installed") + assert(t:GetParent() == sb, "reparented") + assert(t:GetDrawLayer() == "ARTWORK", "default layer 2") + assert(not pcall(old.GetTexture, old), "the old bar texture was deleted") + u = a:CreateTexture(nil, "BACKGROUND") + sb:SetStatusBarTexture(u, "BORDER") + assert(u:GetParent() == sb and u:GetDrawLayer() == "BORDER", "the layer argument") + assert(not pcall(t.GetTexture, t), "and t, displaced, was deleted") + sb:SetStatusBarTexture(nil) + assert(sb:GetStatusBarTexture() == nil, "nil clears") + assert(not pcall(u.GetTexture, u)) + "#, + ) + .unwrap(); +} + +/// `0x789860`: the thumb links on OVERLAY (the layer argument is constant 3), its anchors are +/// cleared (`0x767ed0`) since the slider places it, and the old thumb is deleted. +#[test] +fn a_slider_thumb_object_links_on_overlay_with_its_anchors_cleared() { + let s = script(); + s.run( + r#" + a = CreateFrame("Frame") + sl = CreateFrame("Slider", "AdoptSl") + sl:SetThumbTexture("old") + old = sl:GetThumbTexture() + t = a:CreateTexture(nil, "BACKGROUND") + t:SetAllPoints(a) + assert(t:GetNumPoints() > 0) + sl:SetThumbTexture(t) + assert(sl:GetThumbTexture() == t and t:GetParent() == sl) + assert(t:GetDrawLayer() == "OVERLAY", t:GetDrawLayer()) + assert(t:GetNumPoints() == 0, "anchors cleared") + assert(not pcall(old.GetTexture, old), "old thumb deleted") + sl:SetThumbTexture(nil) + assert(sl:GetThumbTexture() == nil and not pcall(t.GetTexture, t)) + "#, + ) + .unwrap(); +} + +/// `0x78b580` and `0x78b8a0` link the wheel and the strip on ARTWORK (layer 2); `0x78b850` and +/// `0x78ba90` link the two markers on OVERLAY and clear their anchors. +#[test] +fn a_color_select_adopts_its_four_textures_on_the_reference_layers() { + let s = script(); + s.run( + r#" + a = CreateFrame("Frame") + cs = CreateFrame("ColorSelect", "AdoptCS") + local function mk(layer) + local t = a:CreateTexture(nil, layer) + t:SetAllPoints(a) + return t + end + w, wt, v, vt = mk("OVERLAY"), mk("BACKGROUND"), mk("OVERLAY"), mk("BACKGROUND") + cs:SetColorWheelTexture(w) + cs:SetColorWheelThumbTexture(wt) + cs:SetColorValueTexture(v) + cs:SetColorValueThumbTexture(vt) + for _, t in ipairs({ w, wt, v, vt }) do assert(t:GetParent() == cs) end + assert(cs:GetColorWheelTexture() == w and cs:GetColorValueTexture() == v) + assert(cs:GetColorWheelThumbTexture() == wt and cs:GetColorValueThumbTexture() == vt) + assert(w:GetDrawLayer() == "ARTWORK" and v:GetDrawLayer() == "ARTWORK") + assert(wt:GetDrawLayer() == "OVERLAY" and vt:GetDrawLayer() == "OVERLAY") + assert(w:GetNumPoints() > 0 and v:GetNumPoints() > 0, "art keeps its anchors") + assert(wt:GetNumPoints() == 0 and vt:GetNumPoints() == 0, "markers lose theirs") + cs:SetColorWheelThumbTexture(nil) + assert(cs:GetColorWheelThumbTexture() == nil and not pcall(wt.GetTexture, wt)) + "#, + ) + .unwrap(); +} + +/// A table that is no texture raises with the setter's own line (`0x781ab1`, `0x78f9d4`). +#[test] +fn a_non_texture_object_raises_in_the_widget_setters() { + let s = script(); + s.run( + r#" + f = CreateFrame("Frame") + sb = CreateFrame("StatusBar", "RaiseSB") + sl = CreateFrame("Slider", "RaiseSl") + cs = CreateFrame("ColorSelect", "RaiseCS") + "#, + ) + .unwrap(); + for (call, want) in [ + ( + "sb:SetStatusBarTexture(f)", + "RaiseSB:SetCheckedTexture(): Wrong object type, expected texture", + ), + ( + "sb:SetStatusBarTexture({})", + "RaiseSB:SetCheckedTexture(): Couldn't find 'this' in texture", + ), + ( + "sl:SetThumbTexture(f)", + "RaiseSl:SetThumbTexture(): Wrong object type, expected texture", + ), + ( + "cs:SetColorWheelTexture(f)", + "RaiseCS:SetColorWheelTexture(): Wrong object type, expected texture", + ), + ( + "cs:SetColorValueThumbTexture({})", + "RaiseCS:SetColorValueThumbTexture(): Couldn't find 'this' in texture", + ), + ] { + let err = s.run(call).unwrap_err().to_string(); + assert!(err.contains(want), "`{call}` -> {err}"); + } +} + +/// The third argument of `0x77fd10` is the region's own shown bit (`+0xc4`): a slot that takes a +/// hidden texture shows it (`0x77fd85`; the label `0x778d49`, the highlight `0x779139`, the bar +/// `0x78321b`, the thumb `0x789885`, the ColorSelect slots, a current state `0x779037`), and it +/// draws. +#[test] +fn a_slot_shows_the_hidden_texture_it_takes() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + a = CreateFrame("Frame") + a:SetPoint("CENTER", 0, 0) a:SetWidth(50) a:SetHeight(50) + local function hidden(path) + local t = a:CreateTexture(nil, "ARTWORK") + t:SetTexture(path) t:SetAllPoints(a) t:Hide() + assert(not t:IsShown()) + return t + end + sb = CreateFrame("StatusBar", "ShowSB") + sb:SetPoint("CENTER", 0, 0) sb:SetWidth(50) sb:SetHeight(50) + bar = hidden("bar") sb:SetStatusBarTexture(bar) + assert(bar:IsShown(), "bar") + + b = CreateFrame("Button", "ShowBtn") + b:SetPoint("CENTER", 0, 0) b:SetWidth(50) b:SetHeight(50) + n = hidden("normal") b:SetNormalTexture(n) + assert(n:IsShown(), "the current state's texture") + h = hidden("hl") b:SetHighlightTexture(h) + assert(h:IsShown(), "highlight") + fs = a:CreateFontString(nil, "OVERLAY") fs:Hide() + b:SetFontString(fs) + assert(fs:IsShown(), "label") + + sl = CreateFrame("Slider", "ShowSl") + th = hidden("thumb") sl:SetThumbTexture(th) + assert(th:IsShown(), "thumb") + cs = CreateFrame("ColorSelect", "ShowCS") + w, wt = hidden("w"), hidden("wt") + cs:SetColorWheelTexture(w) cs:SetColorWheelThumbTexture(wt) + assert(w:IsShown() and wt:IsShown(), "ColorSelect slots") + "#, + ) + .unwrap(); + s.resolve(); + let d = drawn(&s); + for p in ["bar", "normal"] { + assert!(d.contains(&p.to_string()), "{p} draws: {d:?}"); + } +} + +/// With flag 0 (`0x77fdc8`..`0x77fddf`, and the state switch behind it) a Button's state texture +/// that is not the current one ends hidden, and shows when its state comes: a texture already on +/// the button at the same layer included, and a hidden one adopted into a later state too. The +/// checked pair follows `SetChecked` (`0x7854c0`). +#[test] +fn a_non_current_state_texture_is_hidden_until_its_state_comes() { + let s = script(); + s.run( + r#" + b = CreateFrame("CheckButton", "StateShownBtn") + b:SetNormalTexture("n") + own = b:CreateTexture(nil, "ARTWORK") + assert(own:IsShown(), "a fresh texture is shown") + b:SetPushedTexture(own) + assert(not own:IsShown(), "a same-parent same-layer re-slot with flag 0 hides it") + gone = b:CreateTexture(nil, "ARTWORK") gone:Hide() + b:SetDisabledTexture(gone) + assert(not gone:IsShown()) + b:SetButtonState("PUSHED") + assert(own:IsShown() and not b:GetNormalTexture():IsShown(), "the pushed state shows it") + b:SetButtonState("NORMAL") + assert(not own:IsShown() and b:GetNormalTexture():IsShown()) + b:Disable() + assert(gone:IsShown(), "a hidden texture adopted earlier shows in its state") + + c = b:CreateTexture(nil, "OVERLAY") + b:SetCheckedTexture(c) + assert(not c:IsShown(), "unchecked") + b:SetChecked(1) + assert(c:IsShown(), "checked") + "#, + ) + .unwrap(); +} + +/// `0x77fd9a`: a texture the button already owns still takes the slot's layer. +#[test] +fn a_texture_already_on_the_button_takes_the_slots_layer() { + let s = script(); + s.run( + r#" + b = CreateFrame("Button") + t = b:CreateTexture(nil, "BORDER") + b:SetNormalTexture(t) + assert(t:GetParent() == b and t:GetDrawLayer() == "ARTWORK", t:GetDrawLayer()) + h = b:CreateTexture(nil, "BACKGROUND") + b:SetHighlightTexture(h) + assert(h:GetDrawLayer() == "HIGHLIGHT") + "#, + ) + .unwrap(); +} + +/// `0x78b580` replaces the wheel's image with the generated one (`0x770300`) and `0x78b8a0` the +/// strip's, so the adopted art loses its file; the same texture again returns first +/// (`0x78b598`) and keeps what it was set to since. The markers keep their file. +#[test] +fn the_wheel_and_strip_lose_their_file_the_markers_keep_theirs() { + let s = script(); + s.run( + r#" + a = CreateFrame("Frame") + cs = CreateFrame("ColorSelect", "FileCS") + local function tex(path) local t = a:CreateTexture() t:SetTexture(path) return t end + w, v, wt = tex("wheel"), tex("strip"), tex("thumb") + cs:SetColorWheelTexture(w) cs:SetColorValueTexture(v) cs:SetColorWheelThumbTexture(wt) + assert(w:GetTexture() == nil and v:GetTexture() == nil, "art is generated") + assert(wt:GetTexture() == "thumb", "the marker keeps its file") + w:SetTexture("since") + cs:SetColorWheelTexture(w) + assert(w:GetTexture() == "since", "the same texture is a no-op") + "#, + ) + .unwrap(); +} + +/// `0x779110` and `0x779050` set the highlight's blend through `0x7703f0` on every call: ADD, or the +/// third argument's name (a name that does not parse leaves ADD, `0x6f1920`). The same texture +/// (`0x779122`) and nil (`0x779130`) skip it, and no other state has one. +#[test] +fn the_highlight_sets_its_blend_on_the_object_leg_and_the_path_leg() { + let s = script(); + s.run( + r#" + a = CreateFrame("Frame") + b = CreateFrame("Button") + t = a:CreateTexture() t:SetBlendMode("BLEND") + b:SetHighlightTexture(t) + assert(t:GetBlendMode() == "ADD", "object leg: " .. t:GetBlendMode()) + u = a:CreateTexture() + b:SetHighlightTexture(u, "MOD") + assert(u:GetBlendMode() == "MOD", "the third argument") + u:SetBlendMode("BLEND") + b:SetHighlightTexture(u, "MOD") + assert(u:GetBlendMode() == "BLEND", "the same texture skips it") + v = a:CreateTexture() v:SetBlendMode("BLEND") + b:SetHighlightTexture(v, "bogus") + assert(v:GetBlendMode() == "ADD", "an unparsed name leaves ADD") + b:SetHighlightTexture(nil) + assert(b:GetHighlightTexture() == nil) + + b:SetHighlightTexture("p") + hl = b:GetHighlightTexture() + assert(hl:GetBlendMode() == "ADD", "a new region") + hl:SetBlendMode("BLEND") + b:SetHighlightTexture("p2") + assert(hl:GetBlendMode() == "ADD", "path leg, every call") + b:SetHighlightTexture("p3", "MOD") + assert(hl:GetBlendMode() == "MOD" and hl:GetTexture() == "p3") + + n = a:CreateTexture() n:SetBlendMode("MOD") + b:SetNormalTexture(n) + assert(n:GetBlendMode() == "MOD", "a state texture's blend is its own") + "#, + ) + .unwrap(); +} + +/// The Button setters' raises (`0x87a210`/`0x87a1e0`, the highlight's `0x87a2cc`/`0x87a28c`, the +/// checked pair's `0x87b224`/`0x87b1e8` and `0x87c0e8`/`0x87c0a0`) name the receiver and the +/// method; a call with no argument raises Usage and clears nothing (`0x781b4f`). +#[test] +fn the_button_setters_raise_with_their_own_lines_and_no_argument_clears_nothing() { + let s = script(); + s.run( + r#" + f = CreateFrame("Frame") + b = CreateFrame("CheckButton", "RaiseBtn") + b:SetNormalTexture("n") b:SetHighlightTexture("h") b:SetCheckedTexture("c") + keep = { b:GetNormalTexture(), b:GetHighlightTexture(), b:GetCheckedTexture() } + "#, + ) + .unwrap(); + for (call, want) in [ + ( + "b:SetNormalTexture(f)", + "RaiseBtn:SetNormalTexture(): Wrong object type, expected texture", + ), + ( + "b:SetPushedTexture({})", + "RaiseBtn:SetPushedTexture(): Couldn't find 'this' in texture", + ), + ( + "b:SetHighlightTexture(f)", + "RaiseBtn:SetHighlightTexture(): Wrong object type, expected texture", + ), + ( + "b:SetHighlightTexture({})", + "RaiseBtn:SetHighlightTexture(): Couldn't find 'this' in texture", + ), + ( + "b:SetCheckedTexture(f)", + "RaiseBtn:SetCheckedTexture(): Wrong object type, expected texture", + ), + ( + "b:SetDisabledCheckedTexture({})", + "RaiseBtn:SetDisabledCheckedTexture(): Couldn't find 'this' in texture", + ), + ( + "b:SetNormalTexture()", + "Usage: RaiseBtn:SetNormalTexture(texture or \"texture\" or nil)", + ), + ( + "b:SetHighlightTexture()", + "Usage: RaiseBtn:SetHighlightTexture(texture or \"texture\" or nil [, \"blendmode\")", + ), + ( + "b:SetCheckedTexture()", + "Usage: RaiseBtn:SetCheckedTexture(texture or \"texture\" or nil)", + ), + ] { + let err = s.run(call).unwrap_err().to_string(); + assert!(err.contains(want), "`{call}` -> {err}"); + } + s.run( + r#" + assert(b:GetNormalTexture() == keep[1] and keep[1]:GetTexture() == "n") + assert(b:GetHighlightTexture() == keep[2] and keep[2]:GetTexture() == "h") + assert(b:GetCheckedTexture() == keep[3] and keep[3]:GetTexture() == "c") + "#, + ) + .unwrap(); +} diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs index ebf0190b..b5d0915b 100644 --- a/crates/benilla-ui/src/script/tests/mod.rs +++ b/crates/benilla-ui/src/script/tests/mod.rs @@ -3,6 +3,7 @@ mod addon_argument_abi; mod addon_index_space; +mod adopt; mod anchors; mod backdrop; mod button; diff --git a/crates/benilla-ui/src/widget/kinds/mod.rs b/crates/benilla-ui/src/widget/kinds/mod.rs index 5cad5b73..67cb406e 100644 --- a/crates/benilla-ui/src/widget/kinds/mod.rs +++ b/crates/benilla-ui/src/widget/kinds/mod.rs @@ -943,6 +943,20 @@ impl ButtonState { } } + /// The shown bit (`+0xc4`) of a state or checked texture, which the state switch writes for + /// the one that shows (`0x7797ea`, `0x785538`) and clears on the rest: `None` for every other + /// region, whose bit is its own `Show`/`Hide` alone. + pub fn state_region_shown(&self, rh: RegionHandle) -> Option { + let some = Some(rh); + if some == self.normal || some == self.pushed || some == self.disabled { + return Some(some == self.shown); + } + if some == self.checked_tex || some == self.disabled_checked { + return Some(self.region_visible(rh, false)); + } + None + } + /// Whether a region of this button draws: a state texture only when shown, the highlight /// while hovered or locked, the checked pair by their rule, anything else always. pub fn region_visible(&self, rh: RegionHandle, hovered: bool) -> bool {