diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index 4fb1ef09..76fc1595 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -97,7 +97,6 @@ fn content_kind(c: &QuadContent) -> &'static str { QuadContent::Texture { .. } => "texture", QuadContent::ColorWheel => "colorwheel", QuadContent::ColorValue { .. } => "colorvalue", - QuadContent::Backdrop { .. } => "backdrop", QuadContent::Text { .. } => "text", } } @@ -280,7 +279,6 @@ fn span_bounds(spans: &[u32], i: usize) -> (usize, usize) { fn splice_simple(eq: &benilla_ui::script::ExtractedQuad) -> bool { match &eq.content { QuadContent::Frame - | QuadContent::Backdrop { .. } // At most one quad and one idempotent tile request. | QuadContent::ModelPane { .. } | QuadContent::ColorWheel @@ -1404,44 +1402,6 @@ fn convert_entry( ..default() }); } - QuadContent::Backdrop { - path, - color, - uvs, - tile, - } => { - // A backdrop piece, the bg or one of the 8 border pieces, sampled `Repeat` when `tile`; - // its UVs are per-corner, as the TOP and BOTTOM edges are rotated. - let handle = assets.as_mut().and_then(|a| { - if tile { - a.sprite_texture_tiled(&path, images) - } else { - a.sprite_texture(&path, images) - } - }); - // A missing BLP draws nothing. - let Some(handle) = handle else { return }; - // The 8 border pieces share one atlas, so a `tile` piece is pulled half a texel in on - // its bounded axes; a stretched bg is a whole texture. - let uvs = match images.get(&handle) { - Some(img) if tile => { - let sz = img.texture_descriptor.size; - benilla_ui::script::inset_atlas_bleed(uvs, sz.width as f32, sz.height as f32) - } - _ => uvs, - }; - let mut color = color; - color[3] *= eq.alpha; - out.push(UiQuad { - rect, - z_key: eq.z, - texture: Some(handle), - uv: UvRect::from_corners(uvs), - color, - clip, - ..default() - }); - } QuadContent::Text { text, color, diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index 4b4917f7..c72f8c74 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -311,7 +311,7 @@ fn the_ground_dim_draws_over_the_tile_and_clear_of_the_rope() { let ground = quads .iter() .position(|q| match &q.content { - QuadContent::Backdrop { path, .. } => path.contains("UI-DialogBox-Background"), + QuadContent::Texture { path: Some(p), .. } => p.contains("UI-DialogBox-Background"), _ => false, }) .expect("the window still wears the 1.14 dialog ground"); diff --git a/crates/benilla-app/src/ui_script/questlog_tests.rs b/crates/benilla-app/src/ui_script/questlog_tests.rs index bb9ea892..0b55c519 100644 --- a/crates/benilla-app/src/ui_script/questlog_tests.rs +++ b/crates/benilla-app/src/ui_script/questlog_tests.rs @@ -1262,7 +1262,7 @@ fn popup_children_inherit_the_dialog_stratum() { let backdrop_z = quads .iter() .find_map(|q| match &q.content { - QuadContent::Backdrop { path, .. } if path.contains("UI-DialogBox-Background") => { + QuadContent::Texture { path: Some(p), .. } if p.contains("UI-DialogBox-Background") => { Some(q.z) } _ => None, diff --git a/crates/benilla-app/src/ui_script/tooltip_template_tests.rs b/crates/benilla-app/src/ui_script/tooltip_template_tests.rs index a83044e5..9cca379f 100644 --- a/crates/benilla-app/src/ui_script/tooltip_template_tests.rs +++ b/crates/benilla-app/src/ui_script/tooltip_template_tests.rs @@ -41,8 +41,7 @@ fn answer_measures(s: &mut UiScript) { s.set_measured_text_unwrapped(&answers); } -/// Every texture path drawn: `` regions and the `` plate, which is its own -/// quad kind (`QuadContent::Backdrop`). +/// Every texture path drawn: `` regions, the `` plate's nine pieces among them. fn drawn_textures(s: &mut UiScript) -> Vec { s.resolve(); answer_measures(s); @@ -51,7 +50,6 @@ fn drawn_textures(s: &mut UiScript) -> Vec { .into_iter() .filter_map(|q| match q.content { benilla_ui::script::QuadContent::Texture { path: Some(p), .. } => Some(p), - benilla_ui::script::QuadContent::Backdrop { path, .. } => Some(path), _ => None, }) .collect() diff --git a/crates/benilla-ui/src/loader/backdrop.rs b/crates/benilla-ui/src/loader/backdrop.rs index b7d18fad..632b5469 100644 --- a/crates/benilla-ui/src/loader/backdrop.rs +++ b/crates/benilla-ui/src/loader/backdrop.rs @@ -5,13 +5,11 @@ use crate::framexml::Element; use super::{abs_value, children_named, color_of, Loader}; impl Loader<'_> { - /// `` (LoadXML `0x77e6c0`): the table Lua `SetBackdrop` takes, then the two colours. - /// A present `` defaults a missing channel to black, alpha 1 (`0x6f23d0`), not the - /// white a table `SetBackdrop` leaves. - pub(super) fn apply_backdrop(&mut self, el: &Element, wrapper: &Table, dbg: &str) { - let Some(bd) = children_named(el, "Backdrop").next() else { - return; - }; + /// One `` (LoadXML `0x77e6c0`): the table Lua `SetBackdrop` takes, then the two + /// colours. Each element builds a fresh struct and installs it (`0x769c54`-`0x769c7a`), so a + /// frame's own `` replaces its template's. A present `` defaults a missing + /// channel to black, alpha 1 (`0x6f23d0`), not the white a table `SetBackdrop` leaves. + pub(super) fn apply_backdrop(&mut self, bd: &Element, wrapper: &Table, dbg: &str) { let table = match self.build_backdrop_table(bd) { Ok(t) => t, Err(e) => { diff --git a/crates/benilla-ui/src/loader/mod.rs b/crates/benilla-ui/src/loader/mod.rs index 26d508c2..d26b0978 100644 --- a/crates/benilla-ui/src/loader/mod.rs +++ b/crates/benilla-ui/src/loader/mod.rs @@ -690,12 +690,20 @@ impl Loader<'_> { // 3 · and 4 · (the CLayoutFrame geometry base, `0x767800`). self.apply_size(el, wrapper, dbg_name); self.apply_anchors(el, wrapper, parent_name, dbg_name); - // 5 · regions (`0x769d70`); `$parent` in a region name is this frame. - self.apply_layers(el, wrapper, self_name, dbg_name); + // 5 · regions (`0x769d70`) and the plate (`0x77e6c0`), whose nine + // pieces are regions too, in document order: both are arms of the one child loop + // (`0x769b1c`), so the order they link into `GetRegions` is the file's. A template's + // children come first, as its LoadXML runs first. `$parent` in a region name is this + // frame. + for child in &el.children { + if child.tag.eq_ignore_ascii_case("Layers") { + self.apply_layers(child, wrapper, self_name, dbg_name); + } else if child.tag.eq_ignore_ascii_case("Backdrop") { + self.apply_backdrop(child, wrapper, dbg_name); + } + } // 5·b · a direct-child ``: the widget's own text font (EditBox, message frame). self.apply_special_fontstrings(el, wrapper, self_name, dbg_name); - // 5a · plate (LoadXML `0x77e6c0`): the tiled bg + 8-piece border. - self.apply_backdrop(el, wrapper, dbg_name); // 5a' · : the drag handle, through the API's own CreateTitleRegion. self.apply_title_region(el, wrapper, self_name, dbg_name); // 5b · per-kind LoadXML extras (EditBox `0x779fb0`), each gated on the element's own tag, diff --git a/crates/benilla-ui/src/loader/regions.rs b/crates/benilla-ui/src/loader/regions.rs index a7e5ff47..ca3cd690 100644 --- a/crates/benilla-ui/src/loader/regions.rs +++ b/crates/benilla-ui/src/loader/regions.rs @@ -17,76 +17,68 @@ pub(super) enum FontAttrs { } impl Loader<'_> { - /// `` (`0x769d70`): each `` and ``, created on its layer. + /// One `` (`0x769d70`): each `` and ``, created on its layer. pub(super) fn apply_layers( &mut self, - el: &Element, + layers: &Element, wrapper: &Table, parent_name: &str, dbg: &str, ) { - for layers in children_named(el, "Layers") { - for layer in children_named(layers, "Layer") { - let level = layer.attr("level").unwrap_or("ARTWORK").to_string(); - for region in &layer.children { - // `inherits=` may name a region template, in the frames' one registry - // (`0x6ee500`); a font object's name passes on to `apply_fontstring_font`. - let region = &self.expand_region(region); - let is_texture = region.tag.eq_ignore_ascii_case("Texture"); - let is_fontstring = region.tag.eq_ignore_ascii_case("FontString"); - if !is_texture && !is_fontstring { - self.report.warnings.push(format!( - "{dbg}: child <{}> is not a Texture/FontString; skipped", - region.tag - )); - continue; - } - let rname: Option = region - .name() - .map(|raw| framexml::resolve_name(raw, parent_name)); - let method = if is_texture { - "CreateTexture" - } else { - "CreateFontString" - }; - let region_wrapper: Table = - match wrapper.call_method(method, (rname, Some(level.clone()))) { - Ok(w) => w, - Err(e) => { - self.report.fault(format!("{dbg}: {method}: {e}")); - continue; - } - }; - if region.attr_bool("hidden") { - if let Err(e) = region_wrapper.call_method::<()>("Hide", ()) { - self.report.fault(format!("{dbg}: region Hide: {e}")); + for layer in children_named(layers, "Layer") { + let level = layer.attr("level").unwrap_or("ARTWORK").to_string(); + for region in &layer.children { + // `inherits=` may name a region template, in the frames' one registry + // (`0x6ee500`); a font object's name passes on to `apply_fontstring_font`. + let region = &self.expand_region(region); + let is_texture = region.tag.eq_ignore_ascii_case("Texture"); + let is_fontstring = region.tag.eq_ignore_ascii_case("FontString"); + if !is_texture && !is_fontstring { + self.report.warnings.push(format!( + "{dbg}: child <{}> is not a Texture/FontString; skipped", + region.tag + )); + continue; + } + let rname: Option = region + .name() + .map(|raw| framexml::resolve_name(raw, parent_name)); + let method = if is_texture { + "CreateTexture" + } else { + "CreateFontString" + }; + let region_wrapper: Table = + match wrapper.call_method(method, (rname, Some(level.clone()))) { + Ok(w) => w, + Err(e) => { + self.report.fault(format!("{dbg}: {method}: {e}")); + continue; } + }; + if region.attr_bool("hidden") { + if let Err(e) = region_wrapper.call_method::<()>("Hide", ()) { + self.report.fault(format!("{dbg}: region Hide: {e}")); } - if let Some(a) = region - .attr("alpha") - .and_then(|v| v.trim().parse::().ok()) - { - self.call_region(®ion_wrapper, "SetAlpha", a, dbg); - } - self.apply_region_layout( - region, - ®ion_wrapper, - parent_name, - dbg, - FontAttrs::Own, - ); - if is_fontstring { - // Before the visual pass, so an explicit `` beats the font object's. - self.apply_fontstring_font(region, ®ion_wrapper, dbg); - } - self.apply_region_visual(region, ®ion_wrapper, is_texture, dbg); - // The implicit creation anchor, run after the region's LoadXML as the - // reference does (`0x7701c0`, `0x771480`); an anchored region is left alone. - if let Err(e) = - crate::script::implicit_creation_anchor_lua(self.lua, ®ion_wrapper) - { - self.report.fault(format!("{dbg}: implicit anchor: {e}")); - } + } + if let Some(a) = region + .attr("alpha") + .and_then(|v| v.trim().parse::().ok()) + { + self.call_region(®ion_wrapper, "SetAlpha", a, dbg); + } + self.apply_region_layout(region, ®ion_wrapper, parent_name, dbg, FontAttrs::Own); + if is_fontstring { + // Before the visual pass, so an explicit `` beats the font object's. + self.apply_fontstring_font(region, ®ion_wrapper, dbg); + } + self.apply_region_visual(region, ®ion_wrapper, is_texture, dbg); + // The implicit creation anchor, run after the region's LoadXML as the + // reference does (`0x7701c0`, `0x771480`); an anchored region is left alone. + if let Err(e) = + crate::script::implicit_creation_anchor_lua(self.lua, ®ion_wrapper) + { + self.report.fault(format!("{dbg}: implicit anchor: {e}")); } } } diff --git a/crates/benilla-ui/src/order.rs b/crates/benilla-ui/src/order.rs index ac29e455..bba11c5c 100644 --- a/crates/benilla-ui/src/order.rs +++ b/crates/benilla-ui/src/order.rs @@ -12,7 +12,7 @@ //! tail when shown and when a shown frame's strata or level changes, and the later link draws on //! top. [`crate::widget::WidgetArena`] keeps a counter bumped at those moments, which orders the //! same within a bucket. The is-region bit is benilla's: the client has no frame drawable, and the -//! frame's own slot (backdrop, scissor) must precede its regions. +//! frame's own slot (a minimap or model pane, scissor) must precede its regions. //! //! Deviation: textures in one layer keep link order where the client sorts them by texture handle //! (`0x7731a0`, comparator `0x7731c0`), because benilla allocates its handles differently; no @@ -127,6 +127,8 @@ const DECL_SHIFT: u32 = 0; /// The link-stamp width; the arena renumbers its stamps at the cap. pub(crate) const INSERTION_BITS: u32 = 18; const DECL_BITS: u32 = 12; +/// One past the largest declaration seq the key holds; a frame renumbers its regions there. +pub(crate) const DECL_CAP: u32 = 1 << DECL_BITS; /// Which of a layer batch's arrays an entry drains from, in `0x76fb00`'s order: quads at `+0x10` /// (a texture appends at `0x7706e0`), a `CGxStringBatch` at `+0x18` (a font string at `0x772e50`) @@ -283,7 +285,7 @@ pub const fn unpack(raw: u64) -> ZParts { /// What a [`ZKey`] entry points at. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum ZTarget { - /// The frame's own slot (backdrop, scissor). + /// The frame's own slot (a minimap or model pane, scissor). Frame(FrameHandle), Region(RegionHandle), } diff --git a/crates/benilla-ui/src/script/backdrop.rs b/crates/benilla-ui/src/script/backdrop.rs index 78ab70d8..189b57c8 100644 --- a/crates/benilla-ui/src/script/backdrop.rs +++ b/crates/benilla-ui/src/script/backdrop.rs @@ -1,8 +1,18 @@ -//! The frame backdrop as the 1.12 client builds it: the tiled background and 8-piece border that a -//! `` element or `SetBackdrop` installs. This module owns the data ([`Backdrop`]) and the -//! geometry ([`pieces`], screen-space quads with per-corner UVs); the app renders them. +//! The frame backdrop as the 1.12 client builds it: a `` element or `SetBackdrop` +//! installs a spec ([`Backdrop`], the 0x68-byte struct at `frame+0x1ac`) and builds up to nine +//! Texture regions on the frame from it (`0x77e8d0`), the background on BACKGROUND and the eight +//! border pieces on BORDER. They are ordinary regions: `GetRegions` returns them, the region draw +//! path paints them, and every Texture verb works on them. The spec drives them: the colour +//! setters tint them and a resize re-runs their texcoords (`0x77f0c0`). [`pieces`] is the same +//! geometry as screen quads, for a backdrop the app draws itself (the chat bubble). -use crate::layout::Rect; +use std::collections::HashMap; + +use crate::layout::{self, Anchor, Point, Rect}; +use crate::order::DrawLayer; +use crate::widget::{FrameHandle, RegionHandle, RegionKind}; + +use super::{Model, RegionData, TexCoords}; /// The default `edgeSize`, logical 32: the constructor writes device 0.025 (`DAT_0081c9b0`). pub const DEFAULT_EDGE_SIZE: f32 = 32.0; @@ -21,9 +31,9 @@ pub struct Insets { /// parser defaults a present but partial `` or `` to black. #[derive(Clone, Debug, PartialEq)] pub struct Backdrop { - /// The background texture (`+0x24`); none or empty draws no background. + /// The background texture (`+0x24`); none or empty builds no background. pub bg_file: Option, - /// The 8-slice border texture (`+0x30`); none or empty draws no border. + /// The 8-slice border texture (`+0x30`); none or empty builds no border. pub edge_file: Option, /// Tile the background, else stretch it (`+0x40`). pub tile: bool, @@ -64,9 +74,9 @@ impl Backdrop { } } -/// One drawable piece, the background or one of the 8 border pieces: explicit corners, not a rect, -/// so the bottom-right inset quirk can slant it, and per-corner UVs, since the top and bottom edges -/// are rotated 90° (`0x77f0c0`). Both arrays run `[TL, TR, BR, BL]`, y-up. +/// One drawable piece, the background or one of the 8 border pieces: its corners, axis-aligned, +/// and per-corner UVs, since the top and bottom edges are rotated 90° (`0x77f0c0`). Both arrays +/// run `[TL, TR, BR, BL]`, y-up. #[derive(Clone, Copy, Debug, PartialEq)] pub struct BackdropPiece { /// Corner positions in screen px. @@ -82,6 +92,126 @@ pub struct BackdropPiece { const SLICE: f32 = 0.125; +/// One piece's texcoords, `[TL, TR, BR, BL]`. +type Uvs = [[f32; 2]; 4]; + +/// The eight border pieces in the struct's order, `+0x04` to `+0x20`, which is their creation +/// order and their atlas slice (`0x77e8d0`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Edge { + Left, + Right, + Top, + Bottom, + TopLeft, + TopRight, + BottomLeft, + BottomRight, +} + +const EDGES: [Edge; 8] = [ + Edge::Left, + Edge::Right, + Edge::Top, + Edge::Bottom, + Edge::TopLeft, + Edge::TopRight, + Edge::BottomLeft, + Edge::BottomRight, +]; + +/// A piece's anchors on its frame, `(point, relativePoint, x, y)` in the frame's units, and its +/// `(width, height)`, 0 for an axis the anchors span (`0x77e8d0`'s `SetPoint 0x767c70` and +/// layout-vtable `+0x14`/`+0x18` calls). +type PieceLayout = ([Option<(Point, f32, f32)>; 4], (f32, f32)); + +/// The background's four anchors. Its BOTTOMRIGHT takes the TOP inset for its y, a bug in the +/// reference (`0x77e9ae` loads `+0x50`, top, not `+0x54`), kept: the resolver reads the bottom +/// edge from BOTTOMLEFT and the right edge from TOPRIGHT first (`0x7671a0`, `0x7671f0`), so the +/// bug shows only to `GetPoint`, never on screen. +fn bg_layout(bd: &Backdrop) -> PieceLayout { + let i = bd.insets; + ( + [ + Some((Point::TopLeft, i.left, -i.top)), + Some((Point::TopRight, -i.right, -i.top)), + Some((Point::BottomLeft, i.left, i.bottom)), + Some((Point::BottomRight, -i.right, i.top)), + ], + (0.0, 0.0), + ) +} + +/// A border piece's anchors: corners are `edgeSize` squares on their frame corner, edge strips run +/// between them, all flush inside the frame rect. +fn edge_layout(edge: Edge, e: f32) -> PieceLayout { + use Point::*; + let (a, b, size) = match edge { + Edge::Left => ((TopLeft, 0.0, -e), Some((BottomLeft, 0.0, e)), (e, 0.0)), + Edge::Right => ((TopRight, 0.0, -e), Some((BottomRight, 0.0, e)), (e, 0.0)), + Edge::Top => ((TopLeft, e, 0.0), Some((TopRight, -e, 0.0)), (0.0, e)), + Edge::Bottom => ((BottomLeft, e, 0.0), Some((BottomRight, -e, 0.0)), (0.0, e)), + Edge::TopLeft => ((TopLeft, 0.0, 0.0), None, (e, e)), + Edge::TopRight => ((TopRight, 0.0, 0.0), None, (e, e)), + Edge::BottomLeft => ((BottomLeft, 0.0, 0.0), None, (e, e)), + Edge::BottomRight => ((BottomRight, 0.0, 0.0), None, (e, e)), + }; + ([Some(a), b, None, None], size) +} + +/// A corner's texcoords, set once at creation (`0x77e8d0`): its slice, upright. +fn corner_uvs(edge: Edge) -> Uvs { + let i = edge as u8 as f32; + let (u0, u1) = (i * SLICE, (i + 1.0) * SLICE); + [[u0, 0.0], [u1, 0.0], [u1, 1.0], [u0, 1.0]] +} + +/// The texcoords `0x77f0c0` runs for a frame `rect` on screen: the tiled background's, `None` +/// when it stretches (no `SetTexCoord` call), and the four edge strips', which tile `run` periods +/// between the corners. The divisors are the stored, unscaled sizes (`0x77f101 fdiv [esi+0x48]`, +/// `0x77f195`), so a scaled frame repeats its art more or less often than at scale 1. All four +/// arrays run `[TL, TR, BR, BL]`. +fn run_uvs(bd: &Backdrop, rect: Rect, scale: f32) -> (Option, [Uvs; 4]) { + let (l, r, b, t) = (rect.left, rect.right, rect.bottom, rect.top); + let bg = (bd.has_bg() && bd.tile).then(|| { + let period = if bd.tile_size != 0.0 { + bd.tile_size + } else { + bd.edge_size + }; + let i = bd.insets; + let bg_w = (r - i.right * scale) - (l + i.left * scale); + let bg_h = (t - i.top * scale) - (b + i.bottom * scale); + let (wt, ht) = (bg_w / period, bg_h / period); + [[0.0, 0.0], [wt, 0.0], [wt, ht], [0.0, ht]] + }); + // Run lengths clamp at 0 below two edges and never above. + let w_run = ((r - l) / bd.edge_size - 2.0).max(0.0); + let h_run = ((t - b) / bd.edge_size - 2.0).max(0.0); + let s = SLICE; + let edges = [ + // LEFT, slice 0, upright. + [[0.0, 0.0], [s, 0.0], [s, h_run], [0.0, h_run]], + // RIGHT, slice 1, upright. + [[s, 0.0], [2.0 * s, 0.0], [2.0 * s, h_run], [s, h_run]], + // TOP, slice 2, rotated: atlas `u` runs down the screen, atlas `v` right to left. + [ + [2.0 * s, w_run], + [2.0 * s, 0.0], + [3.0 * s, 0.0], + [3.0 * s, w_run], + ], + // BOTTOM, slice 3, rotated as TOP. + [ + [3.0 * s, w_run], + [3.0 * s, 0.0], + [4.0 * s, 0.0], + [4.0 * s, w_run], + ], + ]; + (bg, edges) +} + /// Pull a piece's UVs half a texel inward on every axis that does not tile, so bilinear /// magnification cannot sample the neighbouring slice of the shared border atlas (in /// `ChatBubble-Backdrop`, a white alpha-0 column: a pale line through the speech bubble). A tiling @@ -122,134 +252,272 @@ fn aa_corners(x0: f32, x1: f32, y_bottom: f32, y_top: f32) -> [[f32; 2]; 4] { } /// The drawable pieces for a frame's resolved rect (y-up), in paint order: the background (layer -/// `BACKGROUND`), then the 8 border pieces (layer `BORDER`). Geometry `0x77e8d0`, UVs `0x77f0c0`. -/// The pieces are textures anchored and sized in the frame's own units, so the layout multiplies -/// their geometry by the frame's effective `scale` (`0x76ac90`), as it does any region's. The -/// texcoord repeats are not: `0x77f0c0` divides the scaled rect by the stored, unscaled -/// `edgeSize` and `tileSize` (`0x77f101 fdiv [esi+0x48]`, `0x77f195`), so a scaled frame's edge -/// and background repeat the art more or less often than at scale 1. +/// `BACKGROUND`), then the 8 border pieces (layer `BORDER`), as the regions `install` builds +/// would resolve and draw. Their geometry is in the frame's own units, so it is multiplied by the +/// frame's effective `scale` (`0x76ac90`), as any region's is. pub fn pieces(frame: Rect, bd: &Backdrop, scale: f32) -> Vec { let mut out = Vec::with_capacity(9); let e = bd.edge_size * scale; - // The texcoord divisor, unscaled. - let e_uv = bd.edge_size; let (l, r, b, t) = (frame.left, frame.right, frame.bottom, frame.top); + let (bg_uvs, edge_uvs) = run_uvs(bd, frame, scale); - // ── The background piece ── if bd.has_bg() { - let (il, ir, it, ib) = ( - bd.insets.left * scale, - bd.insets.right * scale, - bd.insets.top * scale, - bd.insets.bottom * scale, - ); - // The four anchors, y-up. Bottom-right takes the top inset, as the reference does - // (`0x77e9ae` loads `+0x50`, top, not `+0x54`); invisible when the two are equal. - let corners = [ - [l + il, t - it], - [r - ir, t - it], - [r - ir, b + it], // BR: the top inset - [l + il, b + ib], - ]; - // Untiled, no `SetTexCoord` runs and the background stretches over `[0,1]` (`0x77f0c0`). - let uvs = if bd.tile { - let period = if bd.tile_size != 0.0 { - bd.tile_size - } else { - e_uv - }; - let bg_w = (r - ir) - (l + il); - let bg_h = (t - it) - (b + ib); - let (wt, ht) = (bg_w / period, bg_h / period); - [[0.0, 0.0], [wt, 0.0], [wt, ht], [0.0, ht]] - } else { - [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]] - }; + // TOPLEFT, TOPRIGHT and BOTTOMLEFT place it; the BOTTOMRIGHT anchor is never read. + let i = bd.insets; out.push(BackdropPiece { - corners, - uvs, + corners: aa_corners( + l + i.left * scale, + r - i.right * scale, + b + i.bottom * scale, + t - i.top * scale, + ), + // Untiled, no `SetTexCoord` runs and the background stretches over `[0,1]`. + uvs: bg_uvs.unwrap_or([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]), tile: bd.tile, is_bg: true, }); } - // ── The 8 border pieces ── if bd.has_border() { - // Edge strips tile `run` periods between the corners, 0 below two edges (`0x77f0c0`). - let w_run = ((r - l) / e_uv - 2.0).max(0.0); - let h_run = ((t - b) / e_uv - 2.0).max(0.0); - - let mut edge = |x0: f32, x1: f32, y0: f32, y1: f32, uvs: [[f32; 2]; 4]| { + for (k, edge) in EDGES.into_iter().enumerate() { + let corners = match edge { + Edge::Left => aa_corners(l, l + e, b + e, t - e), + Edge::Right => aa_corners(r - e, r, b + e, t - e), + Edge::Top => aa_corners(l + e, r - e, t - e, t), + Edge::Bottom => aa_corners(l + e, r - e, b, b + e), + Edge::TopLeft => aa_corners(l, l + e, t - e, t), + Edge::TopRight => aa_corners(r - e, r, t - e, t), + Edge::BottomLeft => aa_corners(l, l + e, b, b + e), + Edge::BottomRight => aa_corners(r - e, r, b, b + e), + }; out.push(BackdropPiece { - corners: aa_corners(x0, x1, y0, y1), - uvs, + corners, + uvs: edge_uvs.get(k).copied().unwrap_or_else(|| corner_uvs(edge)), tile: true, is_bg: false, }); - }; - - // LEFT, slice 0, upright. - edge( - l, - l + e, - b + e, - t - e, - [[0.0, 0.0], [SLICE, 0.0], [SLICE, h_run], [0.0, h_run]], - ); - // RIGHT, slice 1, upright. - edge( - r - e, - r, - b + e, - t - e, - [ - [SLICE, 0.0], - [2.0 * SLICE, 0.0], - [2.0 * SLICE, h_run], - [SLICE, h_run], - ], - ); - // TOP, slice 2, rotated: atlas `u` runs down the screen, atlas `v` right to left. - edge( - l + e, - r - e, - t - e, - t, - [ - [2.0 * SLICE, w_run], - [2.0 * SLICE, 0.0], - [3.0 * SLICE, 0.0], - [3.0 * SLICE, w_run], - ], - ); - // BOTTOM, slice 3, rotated as TOP. - edge( - l + e, - r - e, - b, - b + e, - [ - [3.0 * SLICE, w_run], - [3.0 * SLICE, 0.0], - [4.0 * SLICE, 0.0], - [4.0 * SLICE, w_run], - ], - ); - // The corners, slices 4 to 7, upright. - let corner_uv = |i: f32| { - let u0 = i * SLICE; - let u1 = (i + 1.0) * SLICE; - [[u0, 0.0], [u1, 0.0], [u1, 1.0], [u0, 1.0]] - }; - edge(l, l + e, t - e, t, corner_uv(4.0)); // TOPLEFT - edge(r - e, r, t - e, t, corner_uv(5.0)); // TOPRIGHT - edge(l, l + e, b, b + e, corner_uv(6.0)); // BOTTOMLEFT - edge(r - e, r, b, b + e, corner_uv(7.0)); // BOTTOMRIGHT + } } out } +// ── The installed backdrop: the spec and its nine regions ─────────────────────────────────── + +/// A frame's installed backdrop, the struct's spec and its nine piece slots (`+0x00` the +/// background, `+0x04..+0x20` the edges in `EDGES` order), each `None` where no piece was built. +#[derive(Clone, Debug)] +pub(crate) struct Installed { + pub(crate) spec: Backdrop, + pieces: [Option; 9], + /// The frame rect the texcoords last ran for; `ApplyRect 0x76b580` re-runs them only when the + /// size moves past [`layout::size_changed`]. + ran_for: Option, +} + +impl Installed { + fn bg(&self) -> Option { + self.pieces[0] + } + + fn edges(&self) -> impl Iterator + '_ { + self.pieces[1..].iter().flatten().copied() + } +} + +/// `CSimpleFrame::SetBackdrop 0x76a5d0`: release the frame's nine pieces and drop its spec +/// (`0x77f4d0`), then, for a spec, build the pieces (`0x77e8d0`) and run their texcoords against +/// the frame's rect if it has one, as Lua `SetBackdrop` does at its end (`0x7776e0`); otherwise +/// the next resolve runs them. Each piece is a fresh Texture linked at the tail of the region +/// list, so a second backdrop's pieces come after any region made in between. +pub(crate) fn install(model: &mut Model, fh: FrameHandle, spec: Option) { + // One layout touch for the whole swap, the pieces it frees and the ones it builds: the next + // resolve re-derives the graph once either way. + let old = model.backdrops.remove(&fh); + for rh in old.iter().flat_map(|o| o.pieces).flatten() { + super::region::release_region(model, rh); + } + let Some(spec) = spec.filter(|_| model.arena.frame(fh).is_some()) else { + if old.is_some() { + model.touch_layout(); + } + return; + }; + let owner = model.frame_id(fh); + // `SetTexture 0x770200`: a path that does not load leaves the piece without art. + let loads = |model: &Model, path: &Option| -> Option { + let path = path.as_deref().filter(|p| !p.is_empty())?; + let ok = model.texture_probe.as_ref().is_none_or(|probe| probe(path)); + ok.then(|| path.to_string()) + }; + let bg_art = loads(model, &spec.bg_file); + let edge_art = loads(model, &spec.edge_file); + let mut pieces = [None; 9]; + let build = |model: &mut Model, + layer: DrawLayer, + (anchors, size): PieceLayout, + texture: Option, + tex_coords: Option| + -> Option { + // `0x76fc40(frame, layer, 1)`: a Texture on the frame, linked at the tail (`0x76a750`). + let rh = model + .arena + .create_region(fh, RegionKind::Texture, layer, 0)?; + let anchors = anchors + .into_iter() + .flatten() + .map(|(p, x, y)| Anchor::new(p, owner, p, x, y)) + .collect(); + let size = (size != (0.0, 0.0)).then_some(size); + model.region_data.insert( + rh, + RegionData { + texture, + anchors, + size, + tex_coords, + ..RegionData::default() + }, + ); + Some(rh) + }; + if spec.has_bg() { + pieces[0] = build(model, DrawLayer::Background, bg_layout(&spec), bg_art, None); + } + if spec.has_border() { + for (k, edge) in EDGES.into_iter().enumerate() { + let corner = matches!( + edge, + Edge::TopLeft | Edge::TopRight | Edge::BottomLeft | Edge::BottomRight + ); + pieces[k + 1] = build( + model, + DrawLayer::Border, + edge_layout(edge, spec.edge_size), + edge_art.clone(), + corner.then(|| TexCoords::Corners(corner_uvs(edge))), + ); + } + } + model.backdrops.insert( + fh, + Installed { + spec, + pieces, + ran_for: None, + }, + ); + model.touch_layout(); + if let Some(rect) = model.resolved.get(&fh).copied() { + run_texcoords(model, fh, rect); + } +} + +/// `0x77f0c0` for frame `fh` at `rect`, from `install`. +fn run_texcoords(model: &mut Model, fh: FrameHandle, rect: Rect) { + let scale = super::object::eff_scale(model, fh); + let Model { + backdrops, + region_data, + .. + } = model; + if let Some(inst) = backdrops.get_mut(&fh) { + rerun(inst, region_data, rect, scale); + } +} + +/// `0x77f0c0`: the tiled background's and the edge strips' texcoords for a frame at `rect`, the +/// corners' left as created, then both colours re-applied (`0x77f410`, `0x77f440`). +fn rerun( + inst: &mut Installed, + region_data: &mut HashMap, + rect: Rect, + scale: f32, +) { + inst.ran_for = Some(rect); + let (bg, edges) = run_uvs(&inst.spec, rect, scale); + if let (Some(rh), Some(uvs)) = (inst.bg(), bg) { + if let Some(d) = region_data.get_mut(&rh) { + d.tex_coords = Some(TexCoords::Corners(uvs)); + } + } + for (slot, uvs) in inst.pieces[1..5].iter().zip(edges) { + if let Some(d) = slot.and_then(|rh| region_data.get_mut(&rh)) { + d.tex_coords = Some(TexCoords::Corners(uvs)); + } + } + paint(inst, region_data); +} + +/// Each piece's single vertex colour (`0x77f750`): the backdrop colour on the background only +/// (`0x77f410`), the border colour on the eight edges only (`0x77f440`). +fn paint(inst: &Installed, region_data: &mut HashMap) { + if let Some(d) = inst.bg().and_then(|rh| region_data.get_mut(&rh)) { + d.vertex_color = Some(inst.spec.bg_color); + } + for rh in inst.edges() { + if let Some(d) = region_data.get_mut(&rh) { + d.vertex_color = Some(inst.spec.border_color); + } + } +} + +/// `SetBackdropColor` (`0x77f410`) or, with `border`, `SetBackdropBorderColor` (`0x77f440`): store +/// the colour in the spec and tint that colour's pieces. +pub(crate) fn set_color(model: &mut Model, fh: FrameHandle, border: bool, color: [f32; 4]) { + let Model { + backdrops, + region_data, + .. + } = model; + let Some(inst) = backdrops.get_mut(&fh) else { + return; + }; + if border { + inst.spec.border_color = color; + for rh in inst.edges() { + if let Some(d) = region_data.get_mut(&rh) { + d.vertex_color = Some(color); + } + } + } else { + inst.spec.bg_color = color; + if let Some(d) = inst.bg().and_then(|rh| region_data.get_mut(&rh)) { + d.vertex_color = Some(color); + } + } +} + +/// After a resolve: re-run the texcoords of every backdrop whose frame rect changed size, as the +/// rect application `0x76b580` calls `0x77f0c0` (at `0x76b61e`); a move alone re-runs nothing. +pub(crate) fn after_resolve(model: &mut Model) { + let Model { + backdrops, + resolved, + arena, + region_data, + .. + } = model; + for (fh, inst) in backdrops.iter_mut() { + let Some(&rect) = resolved.get(fh) else { + continue; + }; + if inst + .ran_for + .is_some_and(|prev| !layout::size_changed(prev, rect)) + { + continue; + } + // `eff_scale`'s guard: a zero scale lays out at 1. + let scale = arena + .frame(*fh) + .map(|f| f.effective_scale) + .filter(|s| s.abs() >= 1e-6) + .unwrap_or(1.0); + rerun(inst, region_data, rect, scale); + } +} + #[cfg(test)] mod tests { use super::*; @@ -364,8 +632,11 @@ mod tests { assert_eq!(br.uvs[2], [1.0, 1.0]); } + /// The background's BOTTOMRIGHT anchor takes the top inset (`0x77e9ae`), but the resolver + /// reads the bottom edge from BOTTOMLEFT and the right from TOPRIGHT, so the rect is the + /// insets' own, top 7 and bottom 9. #[test] - fn bg_bottomright_rides_top_inset_bug() { + fn the_bg_bottomright_quirk_never_reaches_the_rect() { let frame = Rect::new(0.0, 0.0, 100.0, 200.0); let bd = Backdrop { insets: Insets { @@ -377,11 +648,16 @@ mod tests { ..tooltip_backdrop(16.0, 0.0) }; let bg = pieces(frame, &bd, 1.0)[0]; - assert_eq!(bg.corners[3], [3.0, 9.0]); - // BR takes the top inset, 7, not the bottom's 9. - assert_eq!(bg.corners[2], [196.0, 7.0]); - assert_eq!(bg.corners[0], [3.0, 93.0]); // TL - assert_eq!(bg.corners[1], [196.0, 93.0]); // TR + assert_eq!( + bg.corners, + [[3.0, 93.0], [196.0, 93.0], [196.0, 9.0], [3.0, 9.0]] + ); + let (anchors, _) = bg_layout(&bd); + assert_eq!( + anchors[3], + Some((Point::BottomRight, -4.0, 7.0)), + "the quirk is kept" + ); } #[test] diff --git a/crates/benilla-ui/src/script/extract.rs b/crates/benilla-ui/src/script/extract.rs index dd55f163..f7fab792 100644 --- a/crates/benilla-ui/src/script/extract.rs +++ b/crates/benilla-ui/src/script/extract.rs @@ -389,10 +389,9 @@ impl UiScript { // A ScrollingMessageFrame adds one Text quad per visible ring line, stacked bottom-up // with each line's fade alpha; the ring lives in its kind state, not in FontStrings. if let (ZTarget::Frame(fh), Some(fr)) = (target, rect) { - // The Backdrop draws at the frame slot; the lines take the ARTWORK content key, - // where the client's message strings live, over the BACKGROUND (hover box) art. + // The lines take the ARTWORK content key, where the client's message strings + // live, over the BACKGROUND (hover box) art. let paint = super::layout::FramePaint { alpha, scale }; - Self::emit_backdrop(&model, fh, fr, zkey.raw(), paint, clip, &mut out); Self::emit_message_lines( &model, fh, diff --git a/crates/benilla-ui/src/script/layout.rs b/crates/benilla-ui/src/script/layout.rs index 23115462..f738b5a2 100644 --- a/crates/benilla-ui/src/script/layout.rs +++ b/crates/benilla-ui/src/script/layout.rs @@ -1,12 +1,10 @@ use std::collections::HashMap; use crate::layout::{self, Anchor, LayoutInput, Point, Rect}; -use crate::order::ZTarget; use crate::widget::{FrameHandle, FrameKind, KindState, RegionHandle}; -use super::backdrop; use super::types::RegionData; -use super::{ExtractedQuad, MeasureRequest, Model, QuadContent, UiScript, SCREEN}; +use super::{MeasureRequest, Model, UiScript, SCREEN}; /// A frame's `effective_alpha` and `effective_scale`, worn by the quads it emits itself. #[derive(Clone, Copy)] @@ -174,8 +172,6 @@ const REGION_TAG: u32 = 0x8000_0000; pub(crate) struct LayoutScope { /// Per id: the hash the last converged resolve saw, or [`NO_NODE`]; a mismatch seeds dirty. last: Vec, - /// How many ids `last` holds a hash for; a pass matching fewer lost a node: full scope. - last_count: usize, /// The screen `last` was hashed under, every chain's root, so a move is full scope. last_screen: Option, /// This pass's hash per id (parallel to `last`); swapped into `last` on convergence. @@ -214,7 +210,11 @@ struct RegionRow { impl LayoutScope { /// Size the per-id arrays for `n` ids and clear all but `last`, ahead of a full derivation. fn begin_full(&mut self, n: usize) { - self.last.resize(n, NO_NODE); + // `last` only grows: a dead node past the new count keeps its hash, so the scope still + // seeds what anchored to it. + if self.last.len() < n { + self.last.resize(n, NO_NODE); + } self.now.clear(); self.now.resize(n, NO_NODE); self.node_of.clear(); @@ -337,7 +337,7 @@ impl LayoutScope { } } - /// Mark every node dirty, for what a per-node diff cannot see (a death, a screen move). + /// Mark every node dirty, for what a per-node diff cannot see (no memory, a screen move). fn dirty_all(&mut self) { for (id, &n) in self.node_of.iter().enumerate() { if n != u32::MAX { @@ -347,14 +347,13 @@ impl LayoutScope { } /// Adopt this pass's hashes as the converged memory. - fn commit(&mut self, count: usize, screen: Rect) { + fn commit(&mut self, screen: Rect) { std::mem::swap(&mut self.last, &mut self.now); - self.last_count = count; self.last_screen = Some(screen); } - /// [`Self::commit`] for an incremental pass: only the re-hashed slots. `last_count` and - /// `last_screen` stay, since a structural change always takes the full path. + /// [`Self::commit`] for an incremental pass: only the re-hashed slots. `last_screen` stays, + /// since a structural change always takes the derivation. fn commit_incremental(&mut self) { for &(id, h) in &self.staged { self.last[id as usize] = h; @@ -364,7 +363,6 @@ impl LayoutScope { /// Forget the memory, so the next resolve derives the graph in full scope. pub(crate) fn invalidate(&mut self) { - self.last_count = 0; self.last_screen = None; self.last.clear(); // The roster and `node_of` too: `has_node` must not vouch for a node the memory forgot. @@ -497,7 +495,12 @@ impl UiScript { /// [`UiScript::resolve`]'s body on a bare `Model`, so a Lua binding can force a fresh resolve, /// as `UpdateScrollChildRect` must report a range reflecting a same-tick `SetHeight`. pub(super) fn resolve_layout(model: &mut Model) { + let solves = model.layout_solves; Self::resolve_layout_inner(model); + // A solve may have resized a frame: its backdrop re-runs its texcoords (`0x76b580`). + if model.layout_solves != solves { + super::backdrop::after_resolve(model); + } if !model.layout_verify_recheck { return; } @@ -602,7 +605,6 @@ impl UiScript { // ── The incremental seed ───────────────────────────────────────────── // Re-hash only the named nodes and seed those whose hash moved. It runs before the gate // because on this path it is the verdict: an empty seed set means nothing moved. - let mut scope_nodes = 0usize; let mut full_scope = false; if let Some(touched) = &touched { scope.begin_incremental(); @@ -673,23 +675,19 @@ impl UiScript { // ── The scope: which nodes this solve may touch ──── // A node with unchanged inputs and no moved dependency recomputes to the rect it holds, so // the solve runs over the dirty closure and feeds other rects in as externals. Full scope - // when there is no memory yet, the screen moved, a node died (fewer nodes match `last` - // than `last_count`), or under verify. The incremental pass seeded its closure above. + // when there is no memory yet, the screen moved, or under verify. The incremental pass + // seeded its closure above. if touched.is_none() { - let mut matched = 0usize; - for (id, &n) in scope.node_of.iter().enumerate() { - if n == u32::MAX { - continue; - } - scope_nodes += 1; - if scope.last.get(id).copied().unwrap_or(NO_NODE) != NO_NODE { - matched += 1; - } - } - let a_node_died = matched != scope.last_count; + // A node that died past the new id count has no edges recorded (the derivation sized + // them to the count), so only a full scope reaches what anchored to it. + let died_past_end = scope + .last + .iter() + .skip(scope.node_of.len()) + .any(|&h| h != NO_NODE); full_scope = scope.last.is_empty() || scope.last_screen != Some(*screen) - || a_node_died + || died_past_end || gate_skips; // the verify path's re-run: it must reproduce the whole graph if full_scope { scope.dirty_all(); @@ -700,6 +698,18 @@ impl UiScript { scope.dirty[id] = true; } } + // A node that died has no hash left to differ, so its id is seeded instead: the + // derivation recorded the edges of whatever still anchors to it, and the closure + // re-resolves those against its missing rect, as a full scope would. + for id in 0..scope.node_of.len() { + if scope.last[id] != NO_NODE + && scope.node_of[id] == u32::MAX + && !scope.dirty[id] + { + scope.stack.push(id as u32); + scope.dirty[id] = true; + } + } scope.close(); } } @@ -936,7 +946,7 @@ impl UiScript { if touched.is_some() { scope.commit_incremental(); } else { - scope.commit(scope_nodes, *screen); + scope.commit(*screen); } // ── Re-arm the ledger ───────────────────────────────────────────────────────── // The graph now describes a converged model, so writes may name nodes against it. @@ -1224,62 +1234,6 @@ impl UiScript { } out } - - /// Push one [`QuadContent::Backdrop`] per piece of frame `fh`'s backdrop, at the frame slot's - /// `z`: behind its regions, and background before border by the stable sort. - pub(super) fn emit_backdrop( - model: &Model, - fh: FrameHandle, - fr: Rect, - z: u64, - paint: FramePaint, - clip: Option, - out: &mut Vec, - ) { - let Some(bd) = model.backdrops.get(&fh) else { - return; - }; - let scale = crate::script::object::eff_scale(model, fh); - for piece in backdrop::pieces(fr, bd, scale) { - // The piece's screen bounding box: the render is axis-aligned, so the reference's slant - // under unequal top and bottom insets is not drawn (stock insets are symmetric). - let xs = piece.corners.map(|c| c[0]); - let ys = piece.corners.map(|c| c[1]); - let (left, right) = ( - xs.iter().copied().fold(f32::MAX, f32::min), - xs.iter().copied().fold(f32::MIN, f32::max), - ); - let (bottom, top) = ( - ys.iter().copied().fold(f32::MAX, f32::min), - ys.iter().copied().fold(f32::MIN, f32::max), - ); - let path = if piece.is_bg { - bd.bg_file.clone() - } else { - bd.edge_file.clone() - }; - let Some(path) = path else { continue }; - let color = if piece.is_bg { - bd.bg_color - } else { - bd.border_color - }; - out.push(ExtractedQuad { - target: ZTarget::Frame(fh), - z, - rect: Some(Rect::new(bottom, left, top, right)), - alpha: paint.alpha, - content: QuadContent::Backdrop { - path, - color, - uvs: piece.uvs, - tile: piece.tile, - }, - clip, - scale: paint.scale, - }); - } - } } /// The [`MeasureRequest`] region `rh` needs now, if any; shared with the synchronous measure diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index de900de0..3692fe04 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -155,8 +155,9 @@ pub(crate) struct Model { pub(crate) link_spans: HashMap>, /// Region visuals (texture, colour, text) and layout (anchors, size, justify). pub(crate) region_data: HashMap, - /// Each frame's backdrop plate (`` or `SetBackdrop`, the client's `frame+0x1ac`). - pub(crate) backdrops: HashMap, + /// Each frame's backdrop plate (`` or `SetBackdrop`, the client's `frame+0x1ac`): + /// its spec and the nine Texture regions it built. + pub(crate) backdrops: HashMap, /// `CSimpleHTML`'s state: element fonts `+0x350`, link format `+0x360`, content nodes `+0x340`. pub(crate) simple_html: simplehtml::SimpleHtmlStates, /// Named `` objects by lowercased name, as 1.12 matches them with `SStrCmpI` (`0x783870`, diff --git a/crates/benilla-ui/src/script/object/frame_state.rs b/crates/benilla-ui/src/script/object/frame_state.rs index b0f2ff43..598d7d52 100644 --- a/crates/benilla-ui/src/script/object/frame_state.rs +++ b/crates/benilla-ui/src/script/object/frame_state.rs @@ -453,8 +453,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { })?, )?; // `SetBackdrop(table|nil)` (`0x7776e0` → `0x76a5d0`) installs or removes the tiled background - // and 8-piece border, both white until tinted; the two colour setters tint the background and - // the border respectively (`0x77f410`/`0x77f440`). + // and 8-piece border, nine Texture regions on the frame (`script::backdrop::install`), both + // white until tinted; the two colour setters tint the background and the border respectively + // (`0x77f410`/`0x77f440`). m.set( "SetBackdrop", lua.create_function(|lua, (this, arg): (Table, Value)| { @@ -470,14 +471,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { } }; let mut model = lua.app_data_mut::().expect("model"); - match bd { - Some(b) => { - model.backdrops.insert(h, b); - } - None => { - model.backdrops.remove(&h); - } - } + crate::script::backdrop::install(&mut model, h, bd); Ok(()) })?, )?; @@ -493,7 +487,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { // re-enters and would panic on the app-data borrow. let bd = { let model = lua.app_data_ref::().expect("model"); - model.backdrops.get(&h).cloned() + model.backdrops.get(&h).map(|i| i.spec.clone()) }; let Some(bd) = bd else { return Ok(mlua::MultiValue::new()); @@ -535,9 +529,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { let color = backdrop_color(lua, args); let h = frame_handle_of(lua, &this)?; let mut model = lua.app_data_mut::().expect("model"); - if let Some(bd) = model.backdrops.get_mut(&h) { - bd.bg_color = color; - } + crate::script::backdrop::set_color(&mut model, h, false, color); Ok(()) })?, )?; @@ -547,9 +539,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { let color = backdrop_color(lua, args); let h = frame_handle_of(lua, &this)?; let mut model = lua.app_data_mut::().expect("model"); - if let Some(bd) = model.backdrops.get_mut(&h) { - bd.border_color = color; - } + crate::script::backdrop::set_color(&mut model, h, true, color); Ok(()) })?, )?; @@ -558,7 +548,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { lua.create_function(|lua, this: Table| { let h = frame_handle_of(lua, &this)?; let model = lua.app_data_ref::().expect("model"); - match model.backdrops.get(&h) { + match model.backdrops.get(&h).map(|i| &i.spec) { Some(bd) => Ok(( bd.bg_color[0], bd.bg_color[1], @@ -574,7 +564,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { lua.create_function(|lua, this: Table| { let h = frame_handle_of(lua, &this)?; let model = lua.app_data_ref::().expect("model"); - match model.backdrops.get(&h) { + match model.backdrops.get(&h).map(|i| &i.spec) { Some(bd) => Ok(( bd.border_color[0], bd.border_color[1], diff --git a/crates/benilla-ui/src/script/region.rs b/crates/benilla-ui/src/script/region.rs index 0b6a01c5..7451f551 100644 --- a/crates/benilla-ui/src/script/region.rs +++ b/crates/benilla-ui/src/script/region.rs @@ -448,6 +448,13 @@ pub(super) fn region_type_name(model: &Model, rh: RegionHandle) -> &'static str /// wrapper raises as stale; the reference frees too, as `CSimpleButton::SetFontString 0x778d20` /// deletes the label it replaces. pub(crate) fn free_region(model: &mut Model, rh: RegionHandle) { + release_region(model, rh); + // A removed node takes its edges with it: a structural change, so the full touch. + model.touch_layout(); +} + +/// [`free_region`] without its layout touch, for a caller that frees several and touches once. +pub(crate) fn release_region(model: &mut Model, rh: RegionHandle) { model.region_data.remove(&rh); model.region_resolved.remove(&rh); if let Some(id) = model.region_to_id.remove(&rh) { @@ -455,8 +462,6 @@ pub(crate) fn free_region(model: &mut Model, rh: RegionHandle) { model.region_names.retain(|_, v| *v != id); } model.arena.destroy_region(rh); - // A removed node takes its edges with it: a structural change, so the full touch. - model.touch_layout(); } pub(super) fn region_owner_id(model: &mut Model, rh: RegionHandle) -> u32 { diff --git a/crates/benilla-ui/src/script/tests/backdrop.rs b/crates/benilla-ui/src/script/tests/backdrop.rs index 31442e89..576e4fb6 100644 --- a/crates/benilla-ui/src/script/tests/backdrop.rs +++ b/crates/benilla-ui/src/script/tests/backdrop.rs @@ -1,4 +1,5 @@ -//! The backdrop Lua verbs and extract emission (the reference's Backdrop object, `0x77e5f0`). +//! The backdrop Lua verbs, its nine piece regions and their draw (the reference's Backdrop object, +//! `0x77e5f0`, and its pieces, `0x77e8d0`). use super::common::script; use crate::script::*; @@ -28,7 +29,11 @@ fn backdrop_installs_and_extracts_pieces_with_colors() { .extract() .into_iter() .filter_map(|q| match q.content { - QuadContent::Backdrop { path, color, .. } => Some((path, color)), + QuadContent::Texture { + path: Some(path), + color: Some(color), + .. + } => Some((path, color)), _ => None, }) .collect(); @@ -146,7 +151,7 @@ fn get_backdrop_round_trips_through_set_backdrop() { assert_eq!( s.extract() .iter() - .filter(|q| matches!(q.content, QuadContent::Backdrop { .. })) + .filter(|q| matches!(q.content, QuadContent::Texture { .. })) .count(), 9 ); @@ -170,7 +175,7 @@ fn set_backdrop_nil_tears_down() { assert!(s .extract() .iter() - .all(|q| !matches!(q.content, QuadContent::Backdrop { .. }))); + .all(|q| !matches!(q.content, QuadContent::Texture { .. }))); } /// The colour setters (`0x777d30`, `0x7780d0`) read r/g/b with a bare `lua_tonumber` (nil is 0.0) @@ -241,7 +246,7 @@ fn a_scaled_frames_backdrop_scales_with_it() { let rects: Vec = s .extract() .into_iter() - .filter(|q| matches!(q.content, QuadContent::Backdrop { .. })) + .filter(|q| matches!(q.content, QuadContent::Texture { .. })) .map(|q| q.rect.expect("piece rect")) .collect(); assert_eq!(rects.len(), 9); @@ -261,3 +266,590 @@ fn a_scaled_frames_backdrop_scales_with_it() { ); } } + +// ── The pieces are regions (`0x77e8d0`) ────────────────────────────────────────────────────── + +/// Installing a backdrop builds nine Textures through the texture constructor and links each into +/// the frame's region list (`0x76fc40` → `0x77fd10` → `0x76a750`): the background on BACKGROUND, +/// then LEFT, RIGHT, TOP, BOTTOM, TOPLEFT, TOPRIGHT, BOTTOMLEFT, BOTTOMRIGHT on BORDER, unnamed, +/// shown, parented to the frame. +#[test] +fn a_backdrop_is_nine_texture_regions_in_the_reference_order() { + let s = script(); + s.run( + r#" + local f = CreateFrame("Frame", "NinePlate") + f:SetBackdrop({ bgFile = "Interface\\bg", edgeFile = "Interface\\edge" }) + assert(f:GetNumRegions() == 9, "nine regions, got " .. f:GetNumRegions()) + local r = { f:GetRegions() } + assert(table.getn(r) == 9, "GetRegions returns the nine") + for i = 1, 9 do + local p = r[i] + assert(p:GetObjectType() == "Texture", "piece " .. i .. " is a Texture") + assert(p:GetName() == nil, "piece " .. i .. " is unnamed") + assert(p:GetParent() == f, "piece " .. i .. " belongs to the frame") + assert(p:IsShown(), "piece " .. i .. " is shown") + end + assert(r[1]:GetTexture() == "Interface\\bg", "the first is the background") + assert(r[1]:GetDrawLayer() == "BACKGROUND", "the background is on BACKGROUND") + for i = 2, 9 do + assert(r[i]:GetTexture() == "Interface\\edge", "piece " .. i .. " is an edge") + assert(r[i]:GetDrawLayer() == "BORDER", "piece " .. i .. " is on BORDER") + end + "#, + ) + .unwrap(); +} + +/// Each piece is gated on its file (`0x77e91f` for the background, the `+0x3c` mask for the edges). +#[test] +fn a_background_alone_is_one_region_and_a_border_alone_eight() { + let s = script(); + s.run( + r#" + local f = CreateFrame("Frame") + f:SetBackdrop({ bgFile = "bg" }) + assert(f:GetNumRegions() == 1, "bg only: " .. f:GetNumRegions()) + f:SetBackdrop({ edgeFile = "edge" }) + assert(f:GetNumRegions() == 8, "border only: " .. f:GetNumRegions()) + f:SetBackdrop({ bgFile = "", edgeFile = "" }) + assert(f:GetNumRegions() == 0, "empty files: " .. f:GetNumRegions()) + "#, + ) + .unwrap(); +} + +/// The region list is in link order (`0x76a750` appends), so a texture created after the backdrop +/// is the tenth region; one created before stays first. +#[test] +fn a_region_created_after_the_backdrop_comes_tenth() { + let s = script(); + s.run( + r#" + local f = CreateFrame("Frame") + local before = f:CreateTexture(nil, "ARTWORK") + f:SetBackdrop({ bgFile = "bg", edgeFile = "edge" }) + local after = f:CreateTexture(nil, "ARTWORK") + assert(f:GetNumRegions() == 11, "11 regions, got " .. f:GetNumRegions()) + local r = { f:GetRegions() } + assert(r[1] == before, "the earlier texture stays first") + assert(r[2]:GetTexture() == "bg", "then the background") + assert(r[11] == after, "the later texture comes after the nine pieces") + "#, + ) + .unwrap(); +} + +/// `SetBackdrop(nil)` releases all nine (`0x76a5d0` → `0x77f4d0`), and a second `SetBackdrop` +/// releases the old nine before building new ones, which link at the end of the list. +#[test] +fn set_backdrop_nil_leaves_no_pieces_and_a_second_one_relinks_at_the_end() { + let s = script(); + s.run( + r#" + local f = CreateFrame("Frame") + f:SetBackdrop({ bgFile = "bg", edgeFile = "edge" }) + f:SetBackdrop(nil) + assert(f:GetNumRegions() == 0, "nil leaves none, got " .. f:GetNumRegions()) + + f:SetBackdrop({ bgFile = "old", edgeFile = "edge" }) + local mine = f:CreateTexture(nil, "ARTWORK") + f:SetBackdrop({ bgFile = "new", edgeFile = "edge" }) + assert(f:GetNumRegions() == 10, "nine pieces and one texture, got " .. f:GetNumRegions()) + local r = { f:GetRegions() } + assert(r[1] == mine, "the texture made between the two is now first") + assert(r[2]:GetTexture() == "new", "the new background follows it") + "#, + ) + .unwrap(); +} + +/// The colours are each piece's vertex colour (`0x77f750`): `SetBackdropColor` writes the +/// background only (`0x77f410`), `SetBackdropBorderColor` the eight edges only (`0x77f440`). +#[test] +fn the_border_colour_tints_the_eight_edges_and_never_the_background() { + let s = script(); + s.run( + r#" + local f = CreateFrame("Frame") + f:SetBackdrop({ bgFile = "bg", edgeFile = "edge" }) + f:SetBackdropBorderColor(1, 0, 0, 0.6) + local r = { f:GetRegions() } + local function is(p, cr, cg, cb, ca, what) + local pr, pg, pb, pa = p:GetVertexColor() + assert(math.abs(pr - cr) < 0.01 and math.abs(pg - cg) < 0.01 + and math.abs(pb - cb) < 0.01 and math.abs(pa - ca) < 0.01, + what .. ": " .. pr .. "," .. pg .. "," .. pb .. "," .. pa) + end + is(r[1], 1, 1, 1, 1, "the background keeps white") + for i = 2, 9 do is(r[i], 1, 0, 0, 0.6, "edge " .. i) end + f:SetBackdropColor(0, 0, 1) + is(r[1], 0, 0, 1, 1, "the background takes the backdrop colour") + for i = 2, 9 do is(r[i], 1, 0, 0, 0.6, "edge " .. i .. " after SetBackdropColor") end + "#, + ) + .unwrap(); +} + +/// A piece is an ordinary texture: an addon may hide it or swap its art, and that is what draws. +#[test] +fn an_addon_can_hide_or_retexture_a_piece() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + local f = CreateFrame("Frame") + f:SetPoint("CENTER", 0, 0) + f:SetWidth(100); f:SetHeight(100) + f:SetBackdrop({ bgFile = "bg", edgeFile = "edge" }) + local r = { f:GetRegions() } + r[1]:SetTexture("mine") + r[2]:Hide() + "#, + ) + .unwrap(); + s.resolve(); + let paths: Vec = s + .extract() + .into_iter() + .filter_map(|q| match q.content { + QuadContent::Texture { path: Some(p), .. } => Some(p), + _ => None, + }) + .collect(); + assert_eq!( + paths.len(), + 8, + "the hidden LEFT edge draws nothing: {paths:?}" + ); + assert_eq!(paths[0], "mine", "the background draws its new art"); +} + +/// The edges tile along their run, `dim / edgeSize - 2` (`0x77f0c0`), and a resize re-runs the +/// texcoords (`0x76b580`), which `GetTexCoord` reads back in `SetTexCoord`'s UL, LL, UR, LR order. +#[test] +fn a_resize_re_runs_the_edge_texcoords() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + f = CreateFrame("Frame") + f:SetPoint("CENTER", 0, 0) + f:SetWidth(200); f:SetHeight(100) + f:SetBackdrop({ bgFile = "bg", edgeFile = "edge", edgeSize = 16 }) + "#, + ) + .unwrap(); + s.resolve(); + let left = "local r = { f:GetRegions() } return r[2]:GetTexCoord()"; + let uv = s + .eval::<(f32, f32, f32, f32, f32, f32, f32, f32)>(left) + .unwrap(); + // 100 / 16 - 2 = 4.25 periods down the LEFT edge, slice 0. + assert_eq!(uv, (0.0, 0.0, 0.0, 4.25, 0.125, 0.0, 0.125, 4.25)); + s.run("f:SetHeight(64)").unwrap(); + s.resolve(); + let uv = s + .eval::<(f32, f32, f32, f32, f32, f32, f32, f32)>(left) + .unwrap(); + assert_eq!(uv, (0.0, 0.0, 0.0, 2.0, 0.125, 0.0, 0.125, 2.0)); +} + +/// `` is one more child of the frame's LoadXML loop (`0x769b1c`), handled in document +/// order with `` (`0x769c38`, `0x769cdb`): a backdrop before the layers links its pieces +/// first, one after them links them after. +#[test] +fn xml_backdrop_and_layers_link_in_document_order() { + let s = script(); + let doc = crate::framexml::parse( + r#" + + + + + + + + + "#, + ) + .expect("valid FrameXML"); + let report = crate::loader::load(&s, &doc, &|_| None); + assert!(report.errors.is_empty(), "{:?}", report.errors); + s.run( + r#" + local a = { BackdropFirst:GetRegions() } + assert(table.getn(a) == 10, "ten regions") + assert(a[1]:GetTexture() == "bg" and a[10] == BackdropFirstArt, "pieces, then the art") + local b = { LayersFirst:GetRegions() } + assert(b[1] == LayersFirstArt and b[2]:GetTexture() == "bg", "the art, then the pieces") + "#, + ) + .unwrap(); +} + +/// The regression check on the draw: each piece's quad (rect, texcoords, colour and order) is the +/// one the backdrop geometry (`pieces`, `0x77e8d0` and `0x77f0c0`) gives for the frame, on the +/// stock plates' shapes, at scale 1 and below, after a resize too. +#[test] +fn the_piece_quads_are_the_backdrop_geometry() { + use crate::layout::Rect; + // The tooltip, the dialog box (`UI-DialogBox-Border`, asymmetric 11/12 insets), an untiled + // stretch, and a top inset over the bottom one. + let shapes = [ + "{ bgFile = 'bg', edgeFile = 'edge', tile = true, tileSize = 16, edgeSize = 16, \ + insets = { left = 5, right = 5, top = 5, bottom = 5 } }", + "{ bgFile = 'bg', edgeFile = 'edge', tile = true, tileSize = 32, edgeSize = 32, \ + insets = { left = 11, right = 12, top = 12, bottom = 11 } }", + "{ bgFile = 'bg', edgeFile = 'edge', tile = false, edgeSize = 16, \ + insets = { left = 4, right = 4, top = 4, bottom = 4 } }", + ]; + for (i, shape) in shapes.iter().enumerate() { + for scale in [1.0f32, 0.75] { + let mut s = script(); + s.set_screen_size(1024.0, 768.0); + s.run(&format!( + r#" + f = CreateFrame("Frame") + f:SetScale({scale}) + f:SetPoint("TOPLEFT", nil, "TOPLEFT", 100, -100) + f:SetWidth(301); f:SetHeight(157) + f:SetAlpha(0.5) + f:SetBackdrop({shape}) + f:SetBackdropColor(0.1, 0.2, 0.3, 0.9) + f:SetBackdropBorderColor(0.4, 0.5, 0.6) + "# + )) + .unwrap(); + for (w, h) in [(301.0f32, 157.0f32), (90.0, 40.0)] { + s.run(&format!("f:SetWidth({w}); f:SetHeight({h})")) + .unwrap(); + s.resolve(); + // The one frame with a backdrop. + let (fr, eff) = { + let model = s.model_ref(); + let fh = *model.backdrops.keys().next().unwrap(); + let fr = model.resolved[&fh]; + (fr, crate::script::object::eff_scale(&model, fh)) + }; + let quads: Vec<_> = s + .extract() + .into_iter() + .filter_map(|q| match q.content { + QuadContent::Texture { + path: Some(path), + color, + tex_coords, + .. + } => Some((path, q.rect.unwrap(), color, tex_coords, q.alpha)), + _ => None, + }) + .collect(); + let spec = { + let mut b: Backdrop = s + .eval::("return f:GetBackdrop()") + .map(|t| Backdrop { + bg_file: t.get("bgFile").ok(), + edge_file: t.get("edgeFile").ok(), + tile: t.get::>("tile").ok().flatten().is_some(), + tile_size: t.get("tileSize").unwrap(), + edge_size: t.get("edgeSize").unwrap(), + insets: { + let ins: mlua::Table = t.get("insets").unwrap(); + Insets { + left: ins.get("left").unwrap(), + right: ins.get("right").unwrap(), + top: ins.get("top").unwrap(), + bottom: ins.get("bottom").unwrap(), + } + }, + ..Default::default() + }) + .unwrap(); + let c = s + .eval::<(f32, f32, f32, f32)>("return f:GetBackdropColor()") + .unwrap(); + b.bg_color = [c.0, c.1, c.2, c.3]; + let c = s + .eval::<(f32, f32, f32, f32)>("return f:GetBackdropBorderColor()") + .unwrap(); + b.border_color = [c.0, c.1, c.2, c.3]; + b + }; + let want = pieces(fr, &spec, eff); + let ctx = format!("shape {i} scale {scale} size {w}x{h}"); + assert_eq!(quads.len(), want.len(), "{ctx}"); + for (k, (q, p)) in quads.iter().zip(&want).enumerate() { + let (path, rect, color, tc, alpha) = q; + let xs = p.corners.map(|c| c[0]); + let ys = p.corners.map(|c| c[1]); + let bb = Rect::new( + ys.iter().copied().fold(f32::MAX, f32::min), + xs.iter().copied().fold(f32::MAX, f32::min), + ys.iter().copied().fold(f32::MIN, f32::max), + xs.iter().copied().fold(f32::MIN, f32::max), + ); + let close = |a: f32, b: f32| (a - b).abs() < 1e-3; + // The draw normalizes a rect, so an edge shorter than its two corners, whose + // anchors cross, draws as the same box either way. + let (lo_y, hi_y) = (rect.bottom.min(rect.top), rect.bottom.max(rect.top)); + let (lo_x, hi_x) = (rect.left.min(rect.right), rect.left.max(rect.right)); + assert!( + close(lo_x, bb.left) + && close(hi_x, bb.right) + && close(hi_y, bb.top) + && close(lo_y, bb.bottom), + "{ctx} piece {k}: rect {rect:?} want {bb:?}" + ); + let want_path = if p.is_bg { "bg" } else { "edge" }; + assert_eq!(path, want_path, "{ctx} piece {k}"); + let want_color = if p.is_bg { + spec.bg_color + } else { + spec.border_color + }; + assert_eq!(*color, Some(want_color), "{ctx} piece {k} colour"); + assert_eq!(*alpha, 0.5, "{ctx} piece {k}: the frame's alpha"); + // An untiled background keeps the whole texture (no `SetTexCoord`). + let uvs = match tc { + Some(TexCoords::Corners(c)) => *c, + None => [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], + Some(other) => panic!("{ctx} piece {k}: {other:?}"), + }; + for (a, b) in uvs.iter().zip(p.uvs.iter()) { + assert!( + close(a[0], b[0]) && close(a[1], b[1]), + "{ctx} piece {k}: uvs {uvs:?} want {:?}", + p.uvs + ); + } + } + } + } + } +} + +/// Each `SetBackdrop` links nine fresh regions, so a frame re-set on every show runs its link +/// positions past the draw key's field; they renumber in link order and the draw keeps it. +#[test] +fn a_backdrop_set_many_times_keeps_its_frames_draw_order() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + f = CreateFrame("Frame") + f:SetPoint("CENTER", 0, 0) + f:SetWidth(100); f:SetHeight(100) + local first = f:CreateTexture(nil, "ARTWORK") + first:SetAllPoints(f); first:SetTexture("first") + for i = 1, 600 do f:SetBackdrop({ bgFile = "bg", edgeFile = "edge" }) end + local last = f:CreateTexture(nil, "ARTWORK") + last:SetAllPoints(f); last:SetTexture("last") + local r = { f:GetRegions() } + assert(table.getn(r) == 11 and r[1] == first and r[11] == last, "link order kept") + "#, + ) + .unwrap(); + s.resolve(); + let paths: Vec = s + .extract() + .into_iter() + .filter_map(|q| match q.content { + QuadContent::Texture { path: Some(p), .. } => Some(p), + _ => None, + }) + .collect(); + let mut want = vec!["bg".to_string()]; + want.extend(std::iter::repeat_n("edge".to_string(), 8)); + want.extend(["first".to_string(), "last".to_string()]); + assert_eq!(paths, want); +} + +/// A piece a Button adopts into a slot is still the backdrop's (`+0x00..+0x20`), and its teardown +/// deletes it; the region-removed hook (`0x778cb0`) empties the slot, so the next +/// `SetNormalTexture("path")` builds a fresh texture that draws, not a write into the dead one. +#[test] +fn a_button_slot_holding_a_piece_lets_go_when_the_backdrop_does() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + b = CreateFrame("Button") + b:SetPoint("CENTER", 0, 0) + b:SetWidth(100); b:SetHeight(40) + b:SetBackdrop({ bgFile = "bg", edgeFile = "edge" }) + local piece = ({ b:GetRegions() })[1] + b:SetNormalTexture(piece) + assert(b:GetNormalTexture() == piece, "the slot holds the piece") + b:SetBackdrop(nil) + assert(b:GetNormalTexture() == nil, "the slot let go of the freed piece") + b:SetNormalTexture("fresh") + assert(b:GetNormalTexture():GetTexture() == "fresh", "a new texture answers") + "#, + ) + .unwrap(); + s.resolve(); + let drawn = s + .extract() + .into_iter() + .any(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == "fresh")); + assert!(drawn, "the fresh normal texture draws"); +} + +/// The border is on BORDER (`0x77e8d0`), and the draw layer outranks the frame within a level +/// (`0x765920`): a backdrop's edge draws over a later-linked frame's BACKGROUND texture at the +/// same strata and level, where the frame's own slot drew it under. +#[test] +fn a_border_draws_over_another_frames_background_at_its_level() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + local a = CreateFrame("Frame") + a:SetPoint("CENTER", 0, 0) + a:SetWidth(100); a:SetHeight(100) + a:SetBackdrop({ edgeFile = "edge" }) + local b = CreateFrame("Frame") + b:SetAllPoints(a) + local t = b:CreateTexture(nil, "BACKGROUND") + t:SetAllPoints(b); t:SetTexture("under") + assert(a:GetFrameLevel() == b:GetFrameLevel(), "one level") + "#, + ) + .unwrap(); + s.resolve(); + let paths: Vec = s + .extract() + .into_iter() + .filter_map(|q| match q.content { + QuadContent::Texture { path: Some(p), .. } => Some(p), + _ => None, + }) + .collect(); + let under = paths.iter().position(|p| p == "under").expect("drawn"); + let first_edge = paths.iter().position(|p| p == "edge").expect("drawn"); + assert!(first_edge > under, "the border draws over it: {paths:?}"); +} + +/// `0x77f0c0` re-applies both colours after the texcoords (`0x77f3e9`, `0x77f3f4`), so a resize +/// repaints a piece an addon tinted itself. +#[test] +fn a_resize_repaints_a_piece_over_its_own_vertex_colour() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + f = CreateFrame("Frame") + f:SetPoint("CENTER", 0, 0) + f:SetWidth(100); f:SetHeight(100) + f:SetBackdrop({ bgFile = "bg", edgeFile = "edge" }) + f:SetBackdropBorderColor(1, 0, 0) + "#, + ) + .unwrap(); + s.resolve(); + let left = "local r = { f:GetRegions() } return r[2]:GetVertexColor()"; + s.run("local r = { f:GetRegions() } r[2]:SetVertexColor(0, 1, 0)") + .unwrap(); + assert_eq!( + s.eval::<(f32, f32, f32, f32)>(left).unwrap(), + (0.0, 1.0, 0.0, 1.0) + ); + s.run("f:SetWidth(120)").unwrap(); + s.resolve(); + assert_eq!( + s.eval::<(f32, f32, f32, f32)>(left).unwrap(), + (1.0, 0.0, 0.0, 1.0), + "the resize put the border colour back" + ); +} + +/// The background's BOTTOMRIGHT anchor carries the top inset as its y (`0x77e9ae` loads `+0x50`), +/// which `GetPoint` shows though the drawn rect never does. +#[test] +fn the_bg_bottomright_anchor_answers_the_top_inset() { + let s = script(); + s.run( + r#" + local f = CreateFrame("Frame") + f:SetBackdrop({ bgFile = "bg", insets = { left = 3, right = 4, top = 7, bottom = 9 } }) + local bg = f:GetRegions() + local found + for i = 1, bg:GetNumPoints() do + local point, rel, relPoint, x, y = bg:GetPoint(i) + if point == "BOTTOMRIGHT" then + assert(rel == f and relPoint == "BOTTOMRIGHT", "on the frame's corner") + assert(x == -4 and y == 7, "x " .. x .. ", y " .. y .. ": the top inset") + found = true + elseif point == "BOTTOMLEFT" then + assert(x == 3 and y == 9, "the bottom-left takes the bottom inset") + end + end + assert(found, "a BOTTOMRIGHT anchor") + "#, + ) + .unwrap(); +} + +/// A teardown kills the pieces' layout nodes; whatever anchored to one re-resolves against the +/// missing rect in the dirty-scope solve, exactly as a from-scratch solve of the same model does. +#[test] +fn a_teardown_re_resolves_what_anchored_to_a_piece() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + f = CreateFrame("Frame") + f:SetPoint("CENTER", 0, 0) + f:SetWidth(100); f:SetHeight(100) + f:SetBackdrop({ bgFile = "bg", edgeFile = "edge" }) + local g = CreateFrame("Frame") + dep = g:CreateTexture(nil, "ARTWORK") + dep:SetTexture("dep") + dep:SetWidth(10); dep:SetHeight(10) + dep:SetPoint("TOPLEFT", ({ f:GetRegions() })[1], "BOTTOMLEFT", 0, 0) + "#, + ) + .unwrap(); + s.resolve(); + let drawn = |s: &UiScript| { + s.extract().into_iter().any(|q| { + q.rect.is_some() + && matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == "dep") + }) + }; + assert!(drawn(&s), "anchored to the live background"); + s.run("f:SetBackdrop(nil)").unwrap(); + s.resolve(); + let incremental = { + let m = s.model_ref(); + (m.resolved.clone(), m.region_resolved.clone()) + }; + { + let mut m = s.model_mut(); + m.layout_scope.invalidate(); + m.touch_layout(); + } + s.resolve(); + let full = { + let m = s.model_ref(); + (m.resolved.clone(), m.region_resolved.clone()) + }; + assert!( + incremental == full, + "the dirty-scope solve matches a full one" + ); + eprintln!( + "DBG {:?}", + s.eval::<(Option, Option, i64)>( + "return dep:GetLeft(), dep:GetTop(), dep:GetNumPoints()" + ) + ); + eprintln!( + "DBG2 {:?}", + s.eval::<(Option, Option)>( + "local p, r = dep:GetPoint(1) return p, r and r:GetObjectType()" + ) + ); + assert!(!drawn(&s), "its anchor target is gone"); +} diff --git a/crates/benilla-ui/src/script/types.rs b/crates/benilla-ui/src/script/types.rs index c773370a..74624ca4 100644 --- a/crates/benilla-ui/src/script/types.rs +++ b/crates/benilla-ui/src/script/types.rs @@ -129,17 +129,6 @@ pub enum QuadContent { /// `0..=1`; the widget's value moves only its marker, never the strip. sat: f32, }, - /// One `Backdrop` piece, the tiled background or one of the 8 border pieces, drawn in the - /// frame's slot behind its regions; per-corner UVs, since the top and bottom edges turn 90°. - Backdrop { - path: String, - /// The `SetBackdropColor` or `SetBackdropBorderColor` tint, pre-alpha. - color: [f32; 4], - /// `[TL, TR, BR, BL]`, screen order. - uvs: [[f32; 2]; 4], - /// Sample with repeat (wrap) addressing. - tile: bool, - }, /// A `FontString` region with its resolved font; the app measures and draws it. Text { text: Option, diff --git a/crates/benilla-ui/src/widget/kinds/mod.rs b/crates/benilla-ui/src/widget/kinds/mod.rs index 50578072..4e6973b1 100644 --- a/crates/benilla-ui/src/widget/kinds/mod.rs +++ b/crates/benilla-ui/src/widget/kinds/mod.rs @@ -124,6 +124,34 @@ pub enum KindState { } impl KindState { + /// The owner's region-removed hook, frame vtable `+0x2c`, which `0x77fd10` runs on the old + /// parent whenever a region leaves it, freed, reparented or detached (`0x77fd55`): a kind + /// that keeps a region in a slot drops it there, so no slot names a dead region. Button + /// `0x778cb0`, CheckButton `0x785230`, StatusBar `0x783110`, Slider `0x789790` and ColorSelect + /// `0x78b530` override it; every other kind keeps the base unlink `0x76a7f0` alone. + pub fn region_removed(&mut self, rh: RegionHandle) { + let clear = |slot: &mut Option| { + if *slot == Some(rh) { + *slot = None; + } + }; + match self { + KindState::Button(bs) => bs.region_removed(rh), + // The bar texture, `+0x324`. + KindState::StatusBar(sb) => clear(&mut sb.bar), + // The thumb, `+0x328`. + KindState::Slider(sl) => clear(&mut sl.thumb), + // The four art slots, `+0x318` to `+0x324`. + KindState::ColorSelect(cs) => { + clear(&mut cs.wheel); + clear(&mut cs.wheel_thumb); + clear(&mut cs.value_strip); + clear(&mut cs.value_thumb); + } + _ => {} + } + } + /// The display lines of either message-frame class, which share the [`MessageLine`] record. pub fn message_lines(&self) -> Option<&VecDeque> { match self { @@ -890,6 +918,26 @@ impl ButtonState { } } + /// The region-removed hook: a CheckButton drops its checked pair (`0x785230`, `+0x4e0` and + /// `+0x4e4`), then every button its label (`+0x338`), its three state slots (`+0x4b8` to + /// `+0x4c0`), the shown one (`+0x4c4`) and its highlight (`+0x4c8`) (`0x778cb0`). + fn region_removed(&mut self, rh: RegionHandle) { + for slot in [ + &mut self.checked_tex, + &mut self.disabled_checked, + &mut self.text, + &mut self.disabled, + &mut self.normal, + &mut self.pushed, + &mut self.shown, + &mut self.highlight, + ] { + if *slot == Some(rh) { + *slot = 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 { diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index 3e532730..588226d1 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -671,12 +671,7 @@ impl WidgetArena { draw_layer: DrawLayer, sub_level: i8, ) -> Option { - let decl_seq = { - let f = self.frame_mut(owner)?; - let d = f.next_decl; - f.next_decl += 1; - d - }; + let decl_seq = self.take_decl(owner)?; let (index, generation) = self.regions.insert(Region { kind, owner, @@ -693,15 +688,38 @@ impl WidgetArena { Some(handle) } - /// Free one region, as only `CSimpleHTML::SetText` does in the client, pool-freeing the last - /// parse's blocks (`0x78adc6`). The caller drops the region's script-model state too, and never - /// frees a region some state holds by handle. + /// The next declaration seq on `owner`, the region's link position. At the draw key's cap the + /// frame's regions renumber from 0 in link order (`regions` ascends by seq), so a frame that + /// rebuilds regions for ever, a backdrop set on every show, keeps its order and never + /// outgrows the key. + pub(crate) fn take_decl(&mut self, owner: FrameHandle) -> Option { + if self.frame(owner)?.next_decl >= crate::order::DECL_CAP { + let regions = self.frame(owner)?.regions.clone(); + let mut next = 0; + for rh in regions { + if let Some(r) = self.region_mut(rh) { + r.decl_seq = next; + next += 1; + } + } + self.frame_mut(owner)?.next_decl = next; + } + let f = self.frame_mut(owner)?; + let d = f.next_decl; + f.next_decl += 1; + Some(d) + } + + /// Free one region, as `CSimpleHTML::SetText` pool-frees the last parse's blocks (`0x78adc6`) + /// and a backdrop's teardown its nine pieces (`0x77f4d0`). The caller drops the region's + /// script-model state too, and never frees a region some state holds by handle. pub fn destroy_region(&mut self, h: RegionHandle) -> bool { let Some(region) = self.region(h) else { return false; }; let owner = region.owner; if let Some(f) = self.frame_mut(owner) { + f.kind_state.region_removed(h); f.regions.retain(|&r| r != h); if f.title_region == Some(h) { f.title_region = None; diff --git a/crates/benilla-ui/src/widget/propagation.rs b/crates/benilla-ui/src/widget/propagation.rs index 5ea0fcef..e42e5d14 100644 --- a/crates/benilla-ui/src/widget/propagation.rs +++ b/crates/benilla-ui/src/widget/propagation.rs @@ -345,6 +345,13 @@ impl WidgetArena { None if was_detached => return false, _ => {} } + // Leaving its parent runs the parent's region-removed hook (`0x77fd55`); a detached + // region already left. + if !was_detached { + if let Some(of) = self.frame_mut(old_owner) { + of.kind_state.region_removed(rh); + } + } let Some(new_owner) = new_owner else { if let Some(r) = self.region_mut(rh) { r.detached = true; @@ -354,12 +361,7 @@ impl WidgetArena { if let Some(of) = self.frame_mut(old_owner) { of.regions.retain(|&r| r != rh); } - let decl_seq = { - let f = self.frame_mut(new_owner).expect("live new owner"); - let d = f.next_decl; - f.next_decl += 1; - d - }; + let decl_seq = self.take_decl(new_owner).expect("live new owner"); if let Some(r) = self.region_mut(rh) { r.owner = new_owner; r.decl_seq = decl_seq; diff --git a/crates/benilla-ui/src/widget/tests.rs b/crates/benilla-ui/src/widget/tests.rs index e60ad823..37511cf0 100644 --- a/crates/benilla-ui/src/widget/tests.rs +++ b/crates/benilla-ui/src/widget/tests.rs @@ -324,3 +324,66 @@ fn the_tooltip_registry_tracks_live_gametooltips() { a.destroy(loose); assert!(a.tooltip_kinds().is_empty()); } + +// ── The region-removed hook (frame vtable `+0x2c`) ─────────────────────────────────────────── + +/// Freeing, reparenting or detaching a region runs its parent's hook, and each kind that keeps a +/// region in a slot lets go of it: StatusBar `0x783110`, Slider `0x789790`, ColorSelect +/// `0x78b530`, CheckButton `0x785230` over Button `0x778cb0`. +#[test] +fn a_region_leaving_its_parent_empties_the_kind_slot_that_held_it() { + let mut a = arena(); + let other = a.create(FrameKind::Frame, None, None); + let bar = a.create(FrameKind::StatusBar, None, None); + let slider = a.create(FrameKind::Slider, None, None); + let wheel = a.create(FrameKind::ColorSelect, None, None); + let check = a.create(FrameKind::CheckButton, None, None); + let tex = |a: &mut WidgetArena, f| { + a.create_region(f, RegionKind::Texture, DrawLayer::Artwork, 0) + .unwrap() + }; + let (b, t, w, c, n) = ( + tex(&mut a, bar), + tex(&mut a, slider), + tex(&mut a, wheel), + tex(&mut a, check), + tex(&mut a, check), + ); + match &mut a.frame_mut(bar).unwrap().kind_state { + KindState::StatusBar(s) => s.bar = Some(b), + _ => unreachable!(), + } + match &mut a.frame_mut(slider).unwrap().kind_state { + KindState::Slider(s) => s.thumb = Some(t), + _ => unreachable!(), + } + match &mut a.frame_mut(wheel).unwrap().kind_state { + KindState::ColorSelect(s) => s.value_thumb = Some(w), + _ => unreachable!(), + } + match &mut a.frame_mut(check).unwrap().kind_state { + KindState::Button(s) => { + s.checked_tex = Some(c); + s.set_state_slot(ButtonVisualState::Normal, Some(n)); + } + _ => unreachable!(), + } + // Freed, reparented, detached, freed, and the shown normal texture freed. + assert!(a.destroy_region(b)); + assert!(a.set_region_owner(t, Some(other))); + assert!(a.set_region_owner(w, None)); + assert!(a.destroy_region(c)); + assert!(a.destroy_region(n)); + assert!( + matches!(&a.frame(bar).unwrap().kind_state, KindState::StatusBar(s) if s.bar.is_none()) + ); + assert!( + matches!(&a.frame(slider).unwrap().kind_state, KindState::Slider(s) if s.thumb.is_none()) + ); + assert!(matches!(&a.frame(wheel).unwrap().kind_state, + KindState::ColorSelect(s) if s.value_thumb.is_none())); + let KindState::Button(s) = &a.frame(check).unwrap().kind_state else { + unreachable!() + }; + assert_eq!((s.checked_tex, s.normal), (None, None)); +}