His screenshot shows two plates: a rounded grey one with pfUI's square box laid across the middle of it. pfUI's bubble module blanks every Texture it finds in the bubble's regions and lays its own backdrop over the result (modules/bubbles.lua:30-48). Our bubble held its two art paths only in the Backdrop struct, so the loop found the tail and nothing else. The note in this file reasoned that pfUI's own box covers the plate anyway — it does not: pfUI anchors that box 10 px wider and 10 px *shorter* than the bubble and fills it at 75% alpha, so the stock plate goes on showing above and below, through a translucent square. That is exactly the photograph. The background and the border are now also Texture regions, listed first as the reference lists a frame's engine regions first. They are anchorless and draw nothing themselves; the backdrop still paints. drive() reads them back each frame, and a region that has been blanked or hidden clears that half of the backdrop — never restored, since a skin blanks a pool slot once and keeps managing that frame for the session. This is the reference's own shape rather than a deviation from it: a 1.12 backdrop's pieces are CSimpleTexture objects the frame owns, which GetRegions (0x773f60) walks. Keeping the geometry in one struct is our shortcut; these put the handles back. The transcribed-pfUI test now expects three textures instead of one, and goes on to assert the thing that actually matters — after the blank, the extract carries none of the bubble's own art. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
820 lines
34 KiB
Rust
820 lines
34 KiB
Rust
//! The chat bubbles as **widgets**: the reference's `CGChatBubbleFrame` (ctor `0x4b0940`), an
|
|
//! anonymous `Frame` parented to the `WorldFrame` with a backdrop (`0x4b0a35` into `0x76a5d0`,
|
|
//! which is `SetBackdrop`), one tail texture and one `FontString`.
|
|
//!
|
|
//! **Why this exists at all.** `benilla-app`'s `chat_bubble.rs` drew bubbles straight into the
|
|
//! overlay quad lane: faithful on screen and invisible to Lua, which cost two addon-facing things
|
|
//! at once. ClassicAPI's `C_ChatBubbles.GetAllChatBubbles` could only answer an empty list — pfUI
|
|
//! walks that list, blanks each bubble's regions and draws its own backdrop and text over it
|
|
//! (`modules/bubbles.lua:30-48`), so its whole bubble skin was dead — and SuperWoW's
|
|
//! `CREATE_CHATBUBBLE` had no frame to carry, so it could not honestly be fired. One pool of real
|
|
//! widgets closes both, and it is also what the reference has: its bubbles are `WorldFrame`
|
|
//! children, which is how addons of the era found them before SuperWoW published the event.
|
|
//!
|
|
//! The app keeps everything a bubble's *existence* depends on — the spawn gate, the lifetime, the
|
|
//! fade, the 20 yd re-test, the stacking sort and the text measurement that sizes the frame — and
|
|
//! hands the result over as [`BubbleState`] per live bubble. This file owns the widgets alone, the
|
|
//! same split [`super::nameplate`] already makes for the V-plates, and for the same reason: the
|
|
//! engine decides what is on screen, the widget layer decides what Lua sees, and neither can
|
|
//! disagree with the other about a bubble because there is one feed.
|
|
//!
|
|
//! **The pool is never shrunk or reordered**, as the plate pool is not: an addon that remembers
|
|
//! "I have already skinned this frame" (pfUI's `if not f.frame then`) must keep being right about
|
|
//! a frame it saw, and a compacted list would hand it a different bubble under the same identity.
|
|
//!
|
|
//! Two deviations worth naming, both in the direction of the reference:
|
|
//! - The bubbles now sit at frame **levels 2, 3, 4…** over the V-plates at level 1, which is the
|
|
//! reference's own order (`0x4b12d5`-`0x4b1312`). The quad lane had them *under* the plates,
|
|
//! which was a deviation the band layout (`ui_pass::overlay_z`) carried in a comment.
|
|
//! - The art's **geometry** is a backdrop, which is what `0x4b0a35` makes it, but its two paths
|
|
//! are also carried as Texture regions ([`Bubble::bg`]) so `GetRegions` lists them. A skin that
|
|
//! blanks the bubble's textures — pfUI's does — clears the backdrop with them.
|
|
//!
|
|
//! The regions are the reference's shape, not a deviation from it: a 1.12 backdrop's pieces are
|
|
//! `CSimpleTexture` objects the frame owns and `GetRegions` (`0x773f60`) walks. Holding the
|
|
//! geometry in one struct is benilla's shortcut, and these put the handles back.
|
|
//!
|
|
//! The earlier reading — that pfUI's own backdrop covers the plate anyway — was wrong, and
|
|
//! grom's screenshot is why: pfUI anchors its box 10 px *wider* and 10 px *shorter* than the
|
|
//! bubble and fills it at 75% alpha, so the stock rounded plate went on showing above and below
|
|
//! it, with pfUI's square one laid across the middle.
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use crate::layout::{Anchor, Point};
|
|
use crate::order::DrawLayer;
|
|
use crate::script::backdrop::{Backdrop, Insets};
|
|
use crate::widget::{FrameHandle, FrameKind, RegionHandle, RegionKind};
|
|
|
|
use super::model::Model;
|
|
use super::UiScript;
|
|
use super::{JustifyH, JustifyV, ScriptValue};
|
|
|
|
/// The bubble art (`0x4b0940`), the same three paths the quad lane drew.
|
|
const BG_TEXTURE: &str = "Interface\\Tooltips\\ChatBubble-Background";
|
|
const EDGE_TEXTURE: &str = "Interface\\Tooltips\\ChatBubble-Backdrop";
|
|
const TAIL_TEXTURE: &str = "Interface\\Tooltips\\ChatBubble-Tail";
|
|
/// `NAMEPLATE_FONT`: Friz Quadrata, the face the bubble text has always been shaped in.
|
|
const BUBBLE_FONT: &str = "Fonts\\FRIZQT__.TTF";
|
|
|
|
/// The first frame level a bubble takes (`0x4b12d5`: `2 + i` over the plates' 1).
|
|
const FIRST_LEVEL: u16 = 2;
|
|
|
|
/// One live bubble's whole per-frame state, in **FrameXML units** (the app divides the seam out,
|
|
/// exactly as it does for a plate's geometry).
|
|
///
|
|
/// Everything here is decided by the app: this file writes it onto widgets and nothing more.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct BubbleState {
|
|
/// The speaker's GUID. A bubble stays bound to its speaker for its whole life, as the
|
|
/// reference's `[unit+0xe64]` does, so an addon can cache per bubble on it.
|
|
pub key: u64,
|
|
/// The bubble's **bottom centre**, from the `WorldFrame`'s BOTTOMLEFT: it hangs from the seat
|
|
/// and grows upward (`0x4b0c30`).
|
|
pub seat: (f32, f32),
|
|
/// The frame's size, the text layout plus the margin on each side.
|
|
pub width: f32,
|
|
pub height: f32,
|
|
/// The sanitized display text, as the window shows it.
|
|
pub text: String,
|
|
/// The chat-type colour, sRGB 0..1.
|
|
pub color: [f32; 3],
|
|
/// The fade (`0x4b0ea0`/`0x4b0ee0`), 0..1; the frame's own alpha, so an addon's child frames
|
|
/// fade with it.
|
|
pub alpha: f32,
|
|
/// The backdrop's edge size, which is also all four insets: the reference feeds one value to
|
|
/// both (`0x4b0a35`).
|
|
pub border: f32,
|
|
/// The `FontString`'s height, and the width it wraps at — the same numbers the app measured
|
|
/// the box with, so the region's own measure cannot disagree with the frame's size.
|
|
pub font_height: f32,
|
|
pub wrap_width: f32,
|
|
}
|
|
|
|
/// One pooled bubble's widgets.
|
|
#[derive(Debug)]
|
|
struct Bubble {
|
|
frame: FrameHandle,
|
|
/// The backdrop's two art paths, as the Texture regions an addon can reach.
|
|
///
|
|
/// They are anchorless and sized nowhere, so they draw nothing themselves — the backdrop
|
|
/// paints both. They exist so `GetRegions` lists the bubble's art, which is the only handle a
|
|
/// skin has on it: pfUI's bubble module walks `C_ChatBubbles.GetAllChatBubbles()`, blanks
|
|
/// every Texture it finds and lays its own backdrop over the result
|
|
/// (`modules/bubbles.lua:30-48`). With the art reachable only as a `Backdrop` struct it found
|
|
/// the tail and nothing else, so the stock rounded plate kept drawing under pfUI's own
|
|
/// 75%-alpha box — which is what grom photographed.
|
|
///
|
|
/// This is closer to the reference, not further from it: there a backdrop's pieces *are*
|
|
/// `CSimpleTexture` objects the frame owns, and `GetRegions` (`0x773f60`) walks that list.
|
|
/// benilla keeps the backdrop's geometry in one struct instead, so these two stand in for the
|
|
/// objects. [`drive`] reads them back: blanked or hidden art clears that half of the backdrop.
|
|
bg: RegionHandle,
|
|
edge: RegionHandle,
|
|
/// The tail (`0x4b0af1`), a border-unit square hanging under the bubble's bottom centre.
|
|
tail: RegionHandle,
|
|
text: RegionHandle,
|
|
/// Last frame's state, so [`Bubble::drive`] writes only what moved; `None` while retired.
|
|
last: Option<BubbleState>,
|
|
}
|
|
|
|
impl Bubble {
|
|
fn key(&self) -> Option<u64> {
|
|
self.last.as_ref().map(|s| s.key)
|
|
}
|
|
}
|
|
|
|
/// The bubble pool, grown on demand and never shrunk or reordered (see the module header).
|
|
#[derive(Debug, Default)]
|
|
pub(crate) struct ChatBubbles {
|
|
bubbles: Vec<Bubble>,
|
|
/// Each live speaker's pool slot, the reference's `[unit+0xe64]`.
|
|
assigned: HashMap<u64, usize>,
|
|
/// Frame to pool slot, never removed: bubbles are recycled, never destroyed.
|
|
by_frame: HashMap<FrameHandle, usize>,
|
|
}
|
|
|
|
impl ChatBubbles {
|
|
/// Every live bubble's frame, in pool order: ClassicAPI's `C_ChatBubbles.GetAllChatBubbles`.
|
|
/// A retired slot has no state and is skipped, so the list is the bubbles actually on screen.
|
|
pub(crate) fn live(&self) -> Vec<FrameHandle> {
|
|
self.bubbles
|
|
.iter()
|
|
.filter_map(|b| b.key().map(|_| b.frame))
|
|
.collect()
|
|
}
|
|
|
|
/// Whether `frame` is one of the pool's, live or retired. For the text layout's world-seat
|
|
/// rule, which a bubble shares with a plate.
|
|
pub(crate) fn holds(&self, frame: FrameHandle) -> bool {
|
|
self.by_frame.contains_key(&frame)
|
|
}
|
|
}
|
|
|
|
/// What a sync owes once the model borrow is released: `OnShow`/`OnHide` for frames whose
|
|
/// visibility flipped, and one `CREATE_CHATBUBBLE` per bubble that appeared. Queue-and-drain, for
|
|
/// the reason every other queue here exists — a handler re-entering the VM must not run under a
|
|
/// live [`Model`] borrow.
|
|
#[derive(Default)]
|
|
struct SyncEffects {
|
|
visibility: Vec<FrameHandle>,
|
|
/// `CREATE_CHATBUBBLE`, `arg1` the bubble's frame.
|
|
created: Vec<FrameHandle>,
|
|
}
|
|
|
|
impl UiScript {
|
|
/// Drive the pool from the app's per-frame feed, one entry per live bubble; a speaker with no
|
|
/// entry has its bubble retired (hidden, never destroyed).
|
|
///
|
|
/// Retire before bind, so a bubble that just expired frees its slot for one appearing in the
|
|
/// same tick — the reference's one free list behaves that way, and the pool is the only thing
|
|
/// a bubble's identity rests on.
|
|
pub fn sync_chat_bubbles(&mut self, bubbles: &[BubbleState]) {
|
|
let effects = {
|
|
let lua = self.lua();
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
// No WorldFrame (no interface loaded) means no addon is walking an invented root, and
|
|
// the app's own draw is gone — so there is nothing to do and nothing to owe.
|
|
let Some(world) = model.arena.lookup("WorldFrame") else {
|
|
return;
|
|
};
|
|
sync(&mut model, world, bubbles)
|
|
};
|
|
self.fire_effects(effects);
|
|
}
|
|
|
|
/// Retire every live bubble: the app's early returns owe this, since a widget stays up until
|
|
/// something hides it. No `CREATE_CHATBUBBLE` has a removal twin — SuperWoW publishes none,
|
|
/// and an addon learns a bubble is gone the way it always has, from `IsShown()`.
|
|
pub fn retire_chat_bubbles(&mut self) {
|
|
let effects = {
|
|
let lua = self.lua();
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
let mut effects = SyncEffects::default();
|
|
model.chat_bubbles.assigned.clear();
|
|
for i in 0..model.chat_bubbles.bubbles.len() {
|
|
retire(&mut model, i, &mut effects);
|
|
}
|
|
effects
|
|
};
|
|
self.fire_effects(effects);
|
|
}
|
|
|
|
fn fire_effects(&mut self, effects: SyncEffects) {
|
|
let lua = self.lua();
|
|
super::event::fire_visibility_changes(lua, effects.visibility);
|
|
for frame in effects.created {
|
|
let id = lua
|
|
.app_data_mut::<Model>()
|
|
.expect("model app_data")
|
|
.frame_id(frame);
|
|
super::event::fire_global(lua, "CREATE_CHATBUBBLE", &[ScriptValue::Frame(id)]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Bind, lay out and write the pool for this frame's feed.
|
|
fn sync(model: &mut Model, world: FrameHandle, bubbles: &[BubbleState]) -> SyncEffects {
|
|
let mut effects = SyncEffects::default();
|
|
|
|
let gone: Vec<u64> = model
|
|
.chat_bubbles
|
|
.assigned
|
|
.keys()
|
|
.copied()
|
|
.filter(|k| !bubbles.iter().any(|b| b.key == *k))
|
|
.collect();
|
|
for key in gone {
|
|
if let Some(i) = model.chat_bubbles.assigned.remove(&key) {
|
|
retire(model, i, &mut effects);
|
|
}
|
|
}
|
|
|
|
for (rank, state) in bubbles.iter().enumerate() {
|
|
let slot = match model.chat_bubbles.assigned.get(&state.key) {
|
|
Some(&i) => i,
|
|
None => {
|
|
let free = (0..model.chat_bubbles.bubbles.len())
|
|
.find(|i| model.chat_bubbles.bubbles[*i].last.is_none());
|
|
let i = free.unwrap_or_else(|| {
|
|
let bubble = create(model, world);
|
|
model.chat_bubbles.bubbles.push(bubble);
|
|
model.chat_bubbles.bubbles.len() - 1
|
|
});
|
|
model.chat_bubbles.assigned.insert(state.key, i);
|
|
// Per **appearance**, not per pool slot ever built, which is the opposite of
|
|
// `NAME_PLATE_CREATED`'s rule and deliberate: what an addon wants from this event
|
|
// is "here is a bubble to skin or move, now", and a recycled frame has been
|
|
// re-anchored, re-sized and re-texted since it was last seen. pfUI's own guard
|
|
// (`if not f.frame then`) makes the repeat free for the skinning case.
|
|
effects.created.push(model.chat_bubbles.bubbles[i].frame);
|
|
i
|
|
}
|
|
};
|
|
// The stacking level: farthest-first rank, as the app sorted it (`0x4b1309`).
|
|
drive(model, slot, world, state, rank, &mut effects);
|
|
}
|
|
effects
|
|
}
|
|
|
|
/// Build one bubble: the Frame, its backdrop, the tail and the text.
|
|
fn create(model: &mut Model, world: FrameHandle) -> Bubble {
|
|
// Anonymous and `WorldFrame`-parented, as the reference's is: bubble addons find it by walking
|
|
// the child list, so a name would be a surface the reference does not have.
|
|
let frame = model.arena.create(FrameKind::Frame, None, Some(world));
|
|
let strata = model
|
|
.arena
|
|
.frame(world)
|
|
.map(|f| f.strata)
|
|
.unwrap_or_default();
|
|
model.arena.set_frame_strata(frame, strata);
|
|
|
|
// The backdrop (`0x4b0a35` into `0x76a5d0`): one value feeds the edge size and all four
|
|
// insets. The sizes are written per frame in `drive`, since the border scales with the window.
|
|
model.backdrops.insert(
|
|
frame,
|
|
Backdrop {
|
|
bg_file: Some(BG_TEXTURE.to_string()),
|
|
edge_file: Some(EDGE_TEXTURE.to_string()),
|
|
tile: false,
|
|
tile_size: 0.0,
|
|
edge_size: 0.0,
|
|
insets: Insets::default(),
|
|
bg_color: [1.0; 4],
|
|
border_color: [1.0; 4],
|
|
},
|
|
);
|
|
|
|
// The art's two stand-in regions, before any other: the reference lists a frame's engine
|
|
// regions first (as `CSimpleEditBox`'s four are listed before its authored ones), and an
|
|
// addon that blanks "the first textures it finds" must find the plate, not the tail.
|
|
let [bg, edge] = [BG_TEXTURE, EDGE_TEXTURE].map(|path| {
|
|
let rh = model
|
|
.arena
|
|
.create_region(frame, RegionKind::Texture, DrawLayer::Background, 0)
|
|
.expect("live bubble");
|
|
model.region_data.entry(rh).or_default().texture = Some(path.to_string());
|
|
rh
|
|
});
|
|
|
|
let tail = model
|
|
.arena
|
|
.create_region(frame, RegionKind::Texture, DrawLayer::Artwork, 0)
|
|
.expect("live bubble");
|
|
model.region_data.entry(tail).or_default().texture = Some(TAIL_TEXTURE.to_string());
|
|
|
|
let text = model
|
|
.arena
|
|
.create_region(frame, RegionKind::FontString, DrawLayer::Overlay, 0)
|
|
.expect("live bubble");
|
|
{
|
|
let data = model.region_data.entry(text).or_default();
|
|
data.font_path = Some(BUBBLE_FONT.to_string());
|
|
data.font_explicit.face = true;
|
|
data.justify.set_h(JustifyH::Center);
|
|
data.justify.set_v(JustifyV::Middle);
|
|
}
|
|
|
|
model
|
|
.chat_bubbles
|
|
.by_frame
|
|
.insert(frame, model.chat_bubbles.bubbles.len());
|
|
|
|
Bubble {
|
|
frame,
|
|
bg,
|
|
edge,
|
|
tail,
|
|
text,
|
|
last: None,
|
|
}
|
|
}
|
|
|
|
/// Hide a bubble and drop its state; the widgets stay, as the reference recycles rather than
|
|
/// destroys.
|
|
fn retire(model: &mut Model, i: usize, effects: &mut SyncEffects) {
|
|
let b = &mut model.chat_bubbles.bubbles[i];
|
|
if b.last.take().is_none() {
|
|
return;
|
|
}
|
|
let frame = b.frame;
|
|
effects
|
|
.visibility
|
|
.extend(model.arena.set_shown(frame, false));
|
|
}
|
|
|
|
/// Write one bubble's state, touching only what moved.
|
|
fn drive(
|
|
model: &mut Model,
|
|
i: usize,
|
|
world: FrameHandle,
|
|
state: &BubbleState,
|
|
rank: usize,
|
|
effects: &mut SyncEffects,
|
|
) {
|
|
let b = &model.chat_bubbles.bubbles[i];
|
|
let (frame, tail, text) = (b.frame, b.tail, b.text);
|
|
let (b_bg, b_edge) = (b.bg, b.edge);
|
|
let last = b.last.clone();
|
|
let world_id = model.frame_id(world);
|
|
let frame_id = model.frame_id(frame);
|
|
|
|
// Re-seated whenever it moves. A fresh seat adds a graph edge, so it retargets; a moved offset
|
|
// keeps the edges, so the cheaper touch — the same split `nameplate::Plate::drive` makes.
|
|
if last.as_ref().is_none_or(|l| l.seat != state.seat) {
|
|
let fresh = model
|
|
.layout_inputs
|
|
.get(&frame)
|
|
.is_none_or(|i| i.anchors.is_empty());
|
|
let input = model.layout_inputs.entry(frame).or_default();
|
|
input.anchors = vec![Anchor::new(
|
|
Point::Bottom,
|
|
world_id,
|
|
Point::BottomLeft,
|
|
state.seat.0,
|
|
state.seat.1,
|
|
)];
|
|
if fresh {
|
|
model.touch_layout_retarget_frame(frame, &[], &[world_id]);
|
|
} else {
|
|
model.touch_layout_frame(frame);
|
|
}
|
|
}
|
|
|
|
if last
|
|
.as_ref()
|
|
.is_none_or(|l| l.width != state.width || l.height != state.height)
|
|
{
|
|
let input = model.layout_inputs.entry(frame).or_default();
|
|
input.width = state.width;
|
|
input.height = state.height;
|
|
model.touch_layout_frame(frame);
|
|
}
|
|
|
|
// An addon's blank of the art, read back onto the backdrop: `SetTexture("")`/`SetTexture(nil)`
|
|
// clears the region's texture, `Hide()` sets `hidden`, and either means "do not draw this".
|
|
// Checked every frame rather than on a change, because the write comes from Lua and this file
|
|
// has no event for it; it is two map lookups.
|
|
//
|
|
// Never restored once cleared. A skin blanks a pool slot once (pfUI's `if not f.frame then`)
|
|
// and keeps managing that same frame for the rest of the session, so a later speaker reusing
|
|
// the slot must not get the stock plate back under the skin.
|
|
let blanked = |model: &Model, rh: RegionHandle| {
|
|
model
|
|
.region_data
|
|
.get(&rh)
|
|
.is_none_or(|d| d.hidden || d.texture.is_none())
|
|
};
|
|
let (bg_blank, edge_blank) = (blanked(model, b_bg), blanked(model, b_edge));
|
|
if let Some(bd) = model.backdrops.get_mut(&frame) {
|
|
bd.bg_file = (!bg_blank).then(|| BG_TEXTURE.to_string());
|
|
bd.edge_file = (!edge_blank).then(|| EDGE_TEXTURE.to_string());
|
|
}
|
|
|
|
// The border scales with the window, so it rides the state rather than the creation.
|
|
if last.as_ref().is_none_or(|l| l.border != state.border) {
|
|
if let Some(bd) = model.backdrops.get_mut(&frame) {
|
|
bd.edge_size = state.border;
|
|
bd.insets = Insets {
|
|
left: state.border,
|
|
right: state.border,
|
|
top: state.border,
|
|
bottom: state.border,
|
|
};
|
|
}
|
|
// The tail (`0x4b0af1`): a border-unit square, its TOPRIGHT on the bubble's bottom centre
|
|
// lifted a quarter border, so it overlaps the frame's own bottom edge.
|
|
model.region_data.entry(tail).or_default().size = Some((state.border, state.border));
|
|
model.region_data.entry(tail).or_default().anchors = vec![Anchor::new(
|
|
Point::TopRight,
|
|
frame_id,
|
|
Point::Bottom,
|
|
0.0,
|
|
state.border * 0.25,
|
|
)];
|
|
model.touch_layout_region(tail);
|
|
}
|
|
|
|
if last
|
|
.as_ref()
|
|
.is_none_or(|l| l.font_height != state.font_height || l.wrap_width != state.wrap_width)
|
|
{
|
|
let data = model.region_data.entry(text).or_default();
|
|
data.font_height = Some(state.font_height);
|
|
data.font_explicit.height = true;
|
|
// A pinned width is what makes the FontString wrap (`MeasureRequest::wrap_width`), at the
|
|
// cap the app already measured the box against.
|
|
data.size = Some((state.wrap_width, 0.0));
|
|
data.anchors = vec![Anchor::new(
|
|
Point::Center,
|
|
frame_id,
|
|
Point::Center,
|
|
0.0,
|
|
0.0,
|
|
)];
|
|
model.touch_layout_region(text);
|
|
}
|
|
|
|
if last.as_ref().is_none_or(|l| l.text != state.text) {
|
|
model.region_data.entry(text).or_default().text = Some(state.text.clone());
|
|
model.touch_layout_region(text);
|
|
}
|
|
if last.as_ref().is_none_or(|l| l.color != state.color) {
|
|
model.region_data.entry(text).or_default().vertex_color =
|
|
Some([state.color[0], state.color[1], state.color[2], 1.0]);
|
|
}
|
|
|
|
if last.is_none() {
|
|
effects
|
|
.visibility
|
|
.extend(model.arena.set_shown(frame, true));
|
|
}
|
|
if last.as_ref().is_none_or(|l| l.alpha != state.alpha) {
|
|
model.arena.set_alpha(frame, state.alpha);
|
|
}
|
|
|
|
// Farthest first, levels stamped head to tail: the app hands them over in that order, so the
|
|
// rank IS the level. Over the V-plates' level 1, as the reference's are.
|
|
let level = FIRST_LEVEL.saturating_add(u16::try_from(rank).unwrap_or(u16::MAX - FIRST_LEVEL));
|
|
if last.is_none() || model.arena.frame(frame).is_some_and(|f| f.level != level) {
|
|
model.arena.set_frame_level(frame, level, true);
|
|
}
|
|
|
|
model.chat_bubbles.bubbles[i].last = Some(state.clone());
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::script::UiScript;
|
|
|
|
/// A bubble over a speaker, in the units the app hands over.
|
|
fn bubble(key: u64, text: &str) -> BubbleState {
|
|
BubbleState {
|
|
key,
|
|
seat: (400.0, 300.0),
|
|
width: 120.0,
|
|
height: 40.0,
|
|
text: text.to_string(),
|
|
color: [1.0, 1.0, 0.0],
|
|
alpha: 1.0,
|
|
border: 16.0,
|
|
font_height: 12.0,
|
|
wrap_width: 100.0,
|
|
}
|
|
}
|
|
|
|
/// The `WorldFrame` the pool hangs off, as `script/tests/nameplate.rs` builds it.
|
|
fn script() -> UiScript {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_screen_size(1024.0, 768.0);
|
|
s.run(r#"WorldFrame = CreateFrame("WorldFrame", "WorldFrame") WorldFrame:SetAllPoints()"#)
|
|
.unwrap();
|
|
s.resolve();
|
|
s
|
|
}
|
|
|
|
/// **pfUI's own `ScanBubbles` loop, transcribed** (`modules/bubbles.lua:30-48`), which is the
|
|
/// whole reason this file exists: it walks `C_ChatBubbles.GetAllChatBubbles()`, blanks every
|
|
/// Texture it finds in the bubble's regions, keeps the `FontString` as `f.text`, and parents
|
|
/// its own frame to the bubble. Every one of those steps has to work on our widgets.
|
|
///
|
|
/// This once asserted that it found **one** texture — the tail — on the reasoning that the
|
|
/// plate is a `Backdrop` and pfUI's own box covers it anyway. It does not: pfUI anchors its
|
|
/// backdrop 10 px *wider* and 10 px *shorter* than the bubble and fills it at 75% alpha, so
|
|
/// the stock rounded plate goes on showing through, above and below. That is the screenshot
|
|
/// grom sent. The art is reachable now, and the second half of this test is the claim that
|
|
/// matters — blanking it stops it drawing.
|
|
#[test]
|
|
fn pfuis_own_bubble_scan_finds_the_text_and_the_textures() {
|
|
let mut s = script();
|
|
s.sync_chat_bubbles(&[bubble(0x42, "Hail, traveller")]);
|
|
s.resolve();
|
|
|
|
assert_eq!(
|
|
s.eval::<i64>("return table.getn(C_ChatBubbles.GetAllChatBubbles())")
|
|
.unwrap(),
|
|
1,
|
|
"one live bubble, one frame — this answered an empty list before the pool existed"
|
|
);
|
|
s.run(
|
|
r#"
|
|
Found, Blanked, Parented = nil, 0, nil
|
|
for _, f in ipairs(C_ChatBubbles.GetAllChatBubbles()) do
|
|
local regions = {f:GetRegions()}
|
|
for _, object in pairs(regions) do
|
|
if object:GetObjectType() == "Texture" then
|
|
object:SetTexture('')
|
|
Blanked = Blanked + 1
|
|
elseif object:GetObjectType() == "FontString" then
|
|
f.text = object
|
|
PfBubbleText = object
|
|
end
|
|
end
|
|
Found = f.text and f.text:GetText()
|
|
f.frame = CreateFrame("Frame", nil, f)
|
|
f.frame:SetAllPoints(f)
|
|
Parented = f.frame:GetParent() == f
|
|
end
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
s.eval::<String>("return Found").unwrap(),
|
|
"Hail, traveller",
|
|
"the FontString carries the line, which is what pfUI re-fonts and re-colours"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<i64>("return Blanked").unwrap(),
|
|
3,
|
|
"the plate's background, its border and the tail — all three reachable, which is the \
|
|
whole point: with the art held only as a `Backdrop` struct this found the tail alone \
|
|
and the stock rounded plate kept drawing under pfUI's own 75%-alpha box"
|
|
);
|
|
// And the blank reached the art: the plate stops drawing, the way it does on the client
|
|
// this skin was written for.
|
|
s.sync_chat_bubbles(&[bubble(0x42, "Hail, traveller")]);
|
|
s.resolve();
|
|
let after = s.extract();
|
|
let painted: Vec<&str> = after
|
|
.iter()
|
|
.filter_map(|q| match &q.content {
|
|
crate::script::QuadContent::Texture { path: Some(p), .. } => Some(p.as_str()),
|
|
_ => None,
|
|
})
|
|
.filter(|p| p.contains("ChatBubble"))
|
|
.collect();
|
|
assert!(
|
|
painted.is_empty(),
|
|
"a blanked bubble must draw none of its own art; still painting {painted:?}"
|
|
);
|
|
assert!(
|
|
s.eval::<bool>("return Parented").unwrap(),
|
|
"an addon frame parents to the bubble, which is how it follows it and fades with it"
|
|
);
|
|
// The colour is the chat type's, and pfUI reads it off the string with `GetTextColor`
|
|
// before re-applying it to its own (`modules/bubbles.lua:22-24`).
|
|
assert_eq!(
|
|
s.eval::<(f64, f64, f64)>("local r, g, b = PfBubbleText:GetTextColor() return r, g, b")
|
|
.unwrap(),
|
|
(1.0, 1.0, 0.0),
|
|
"the chat-type colour, which is the thing pfUI carries over to its own string"
|
|
);
|
|
assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors());
|
|
}
|
|
|
|
/// The whole chain, which is the claim that matters: a bubble handed over as state comes out
|
|
/// of the extract as **drawn quads** — the backdrop's own two art paths, the tail, and the
|
|
/// line in the chat type's colour at the em the app measured it with.
|
|
///
|
|
/// Before the pool this art came off the overlay quad lane, so an extract carried none of it;
|
|
/// a test that only checked the widgets would pass with nothing on screen.
|
|
#[test]
|
|
fn a_bubble_reaches_the_extract_as_backdrop_tail_and_text() {
|
|
use crate::script::QuadContent;
|
|
|
|
let mut s = script();
|
|
s.sync_chat_bubbles(&[bubble(0x42, "Hail, traveller")]);
|
|
s.resolve();
|
|
let quads = s.extract();
|
|
|
|
let text = quads
|
|
.iter()
|
|
.find_map(|q| match &q.content {
|
|
QuadContent::Text {
|
|
text: Some(t),
|
|
color: Some(c),
|
|
font_height,
|
|
..
|
|
} if t == "Hail, traveller" => Some((*c, *font_height)),
|
|
_ => None,
|
|
})
|
|
.expect("the bubble's line reaches the extract");
|
|
assert_eq!(
|
|
(text.0[0], text.0[1], text.0[2]),
|
|
(1.0, 1.0, 0.0),
|
|
"in the chat type's colour"
|
|
);
|
|
assert_eq!(
|
|
text.1,
|
|
Some(12.0),
|
|
"at the em the app measured the box with"
|
|
);
|
|
|
|
let backdrops: Vec<&str> = quads
|
|
.iter()
|
|
.filter_map(|q| match &q.content {
|
|
QuadContent::Backdrop { path, .. } => Some(path.as_str()),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert!(
|
|
backdrops.contains(&BG_TEXTURE) && backdrops.contains(&EDGE_TEXTURE),
|
|
"both halves of the reference's backdrop draw, got {backdrops:?}"
|
|
);
|
|
assert!(
|
|
quads.iter().any(|q| matches!(
|
|
&q.content,
|
|
QuadContent::Texture { path: Some(p), .. } if p == TAIL_TEXTURE
|
|
)),
|
|
"and the tail, which is a region rather than part of the backdrop"
|
|
);
|
|
|
|
// Nothing is left on screen once the speaker stops: the retire hides the frame, and a
|
|
// hidden frame emits no quads at all.
|
|
s.sync_chat_bubbles(&[]);
|
|
s.resolve();
|
|
assert!(
|
|
!s.extract().iter().any(|q| matches!(
|
|
&q.content,
|
|
QuadContent::Backdrop { path, .. } if path == BG_TEXTURE
|
|
)),
|
|
"a retired bubble draws nothing"
|
|
);
|
|
}
|
|
|
|
/// `CREATE_CHATBUBBLE` fires with the bubble's frame, per appearance — SuperWoW's third and
|
|
/// last event, which could not honestly be fired while there was no frame to carry.
|
|
#[test]
|
|
fn create_chatbubble_fires_with_the_frame_per_appearance() {
|
|
let mut s = script();
|
|
s.run(
|
|
r#"
|
|
N, Same = 0, nil
|
|
Spy = CreateFrame("Frame", "BenillaBubbleSpy")
|
|
Spy:SetScript("OnEvent", function()
|
|
N = N + 1
|
|
Same = arg1 == C_ChatBubbles.GetAllChatBubbles()[1]
|
|
end)
|
|
Spy:RegisterEvent("CREATE_CHATBUBBLE")
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
s.sync_chat_bubbles(&[bubble(0x42, "one")]);
|
|
assert_eq!(s.eval::<i64>("return N").unwrap(), 1);
|
|
assert!(
|
|
s.eval::<bool>("return Same").unwrap(),
|
|
"arg1 is the frame, and the list already holds it when the handler runs"
|
|
);
|
|
|
|
// The same bubble, moved and re-texted: no second event, the frame is already theirs.
|
|
let mut moved = bubble(0x42, "one");
|
|
moved.seat = (401.0, 302.0);
|
|
s.sync_chat_bubbles(&[moved]);
|
|
assert_eq!(s.eval::<i64>("return N").unwrap(), 1);
|
|
|
|
// It expires, and a new speaker takes the recycled frame: that IS an appearance.
|
|
s.sync_chat_bubbles(&[]);
|
|
s.sync_chat_bubbles(&[bubble(0x99, "two")]);
|
|
assert_eq!(
|
|
s.eval::<i64>("return N").unwrap(),
|
|
2,
|
|
"per appearance, not per pool slot ever built: a recycled frame has been re-anchored \
|
|
and re-texted, so an addon has work to do on it again"
|
|
);
|
|
assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors());
|
|
}
|
|
|
|
/// The pool recycles rather than destroys, keeps its order, and an expired bubble leaves the
|
|
/// list while its frame stays (hidden) for the next speaker.
|
|
#[test]
|
|
fn the_pool_recycles_and_keeps_its_order() {
|
|
let mut s = script();
|
|
s.sync_chat_bubbles(&[bubble(0x10, "a"), bubble(0x20, "b")]);
|
|
s.resolve();
|
|
let frames = |s: &UiScript| {
|
|
s.eval::<i64>("return table.getn(C_ChatBubbles.GetAllChatBubbles())")
|
|
.unwrap()
|
|
};
|
|
assert_eq!(frames(&s), 2);
|
|
// Both shown, at the levels the rank gives (2 and 3), over the V-plates' 1.
|
|
assert!(s
|
|
.eval::<bool>(
|
|
"local l = C_ChatBubbles.GetAllChatBubbles()
|
|
return l[1]:IsShown() and l[2]:IsShown()
|
|
and l[1]:GetFrameLevel() == 2 and l[2]:GetFrameLevel() == 3"
|
|
)
|
|
.unwrap());
|
|
|
|
// The first speaker stops talking: one bubble left, and the retired frame is hidden but
|
|
// still a `WorldFrame` child, as the reference recycles rather than destroys.
|
|
s.run("Kept = C_ChatBubbles.GetAllChatBubbles()[1]")
|
|
.unwrap();
|
|
s.sync_chat_bubbles(&[bubble(0x20, "b")]);
|
|
s.resolve();
|
|
assert_eq!(frames(&s), 1, "the retired slot leaves the list");
|
|
assert!(
|
|
!s.eval::<bool>("return Kept:IsShown()").unwrap(),
|
|
"and its frame is hidden rather than destroyed"
|
|
);
|
|
assert!(
|
|
s.eval::<bool>(r#"return Kept:GetParent() == WorldFrame"#)
|
|
.unwrap(),
|
|
"still a WorldFrame child: an addon that cached it keeps a live handle"
|
|
);
|
|
// A third speaker takes the free slot: the pool is two frames, not three.
|
|
s.sync_chat_bubbles(&[bubble(0x20, "b"), bubble(0x30, "c")]);
|
|
s.resolve();
|
|
assert_eq!(frames(&s), 2);
|
|
assert!(
|
|
s.eval::<bool>("return C_ChatBubbles.GetAllChatBubbles()[1] == Kept")
|
|
.unwrap(),
|
|
"the recycled frame is the one that was free, in its own pool position"
|
|
);
|
|
assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors());
|
|
}
|
|
|
|
/// The widgets carry what the app measured: the frame's size, the fade as the frame's alpha
|
|
/// (so an addon's child frames fade with it) and the text's own wrap width.
|
|
#[test]
|
|
fn the_widgets_carry_the_apps_own_numbers() {
|
|
let mut s = script();
|
|
let mut b = bubble(0x42, "a longer line that wraps");
|
|
b.alpha = 0.5;
|
|
s.sync_chat_bubbles(&[b]);
|
|
s.resolve();
|
|
assert!(s
|
|
.eval::<bool>(
|
|
"local f = C_ChatBubbles.GetAllChatBubbles()[1]
|
|
return f:GetWidth() == 120 and f:GetHeight() == 40 and f:GetAlpha() == 0.5"
|
|
)
|
|
.unwrap());
|
|
// **The seat, which is the one thing most easily wrong**: the bubble hangs from its bottom
|
|
// centre on the projected point and grows UPWARD (`0x4b0c30`). An anchor on the wrong
|
|
// point would still size and draw, just in the wrong place — and nothing else here would
|
|
// notice.
|
|
assert_eq!(
|
|
s.eval::<(f64, f64)>(
|
|
"local f = C_ChatBubbles.GetAllChatBubbles()[1]
|
|
local cx = f:GetCenter()
|
|
return cx, f:GetBottom()"
|
|
)
|
|
.unwrap(),
|
|
(400.0, 300.0),
|
|
"bottom centre on the seat the app handed over"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<f64>("return C_ChatBubbles.GetAllChatBubbles()[1]:GetTop()")
|
|
.unwrap(),
|
|
340.0,
|
|
"and the height is added upward, not downward"
|
|
);
|
|
assert!(
|
|
s.eval::<bool>(
|
|
"local f = C_ChatBubbles.GetAllChatBubbles()[1]
|
|
for _, r in pairs({f:GetRegions()}) do
|
|
if r:GetObjectType() == \"FontString\" then
|
|
return r:GetWidth() == 100
|
|
end
|
|
end
|
|
return false"
|
|
)
|
|
.unwrap(),
|
|
"the FontString's pinned width is the wrap cap the app measured the box against — a \
|
|
pinned width is also what makes it wrap at all (`MeasureRequest::wrap_width`)"
|
|
);
|
|
assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors());
|
|
}
|
|
}
|