- the justify verbs had been written three times and drifted three ways; one implementation now serves the region, font-object and message-frame tables, and a cleared axis paints centre, as the client's draw path really does - MessageFrame and ScrollingMessageFrame gain the ten-verb font block they were missing entirely, so font calls on message frames stop raising - EnableKeyboard and IsKeyboardEnabled land as real Frame methods — eight addons were dying on their absence - the harness's METHODS ranking states its own limits instead of reading as a build queue Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
699 lines
27 KiB
Rust
699 lines
27 KiB
Rust
//! The `ScrollingMessageFrame` method surface — the chat window's per-kind widget behavior
|
||
//! (`CSimpleMessageScrollFrame`, ctor `0x787670`).
|
||
//!
|
||
//! Grounded in wow-re's byte-verified runtime model (msgframe-runtime.md, §5 pair): a **true ring**
|
||
//! of `maxLines` (drop-oldest, `SetMaxLines` destructive), `AddMessage(text[,r,g,b[,id]])` with the
|
||
//! `trunc(x*255+0.5)` color quantization + forced-opaque alpha, per-line fade snapshots ticked only
|
||
//! while **AtBottom** (scrolled up freezes every alpha), and 1-slot scrollback. The heavy lifting
|
||
//! (the ring, the fade phases, the scroll clamps) lives on [`ScrollingMessageState`]
|
||
//! (`crate::widget`), unit-tested there; this module is the thin Lua binding over it, plus the
|
||
//! host-facing [`UiScript::add_chat_message`]/fade advance the app drives.
|
||
//!
|
||
//! The methods live in their own registry table, consulted by the frame `__index` dispatcher only
|
||
//! for ScrollingMessageFrame frames — so duck-typing addons (`if frame.AddMessage then …`) see `nil`
|
||
//! on every other kind, exactly as against the client's per-class method sets. **Its sibling class
|
||
//! `MessageFrame` has its own table next door** ([`super::plain`]) carrying a *different*
|
||
//! `AddMessage`: merging the two would hand a chat frame a `SetInsertMode` it does not have, and a
|
||
//! MessageFrame an id argument where its alpha belongs.
|
||
|
||
use mlua::{Lua, Table, Value};
|
||
|
||
use crate::script::object::frame_handle_of;
|
||
use crate::script::{Model, UiScript};
|
||
use crate::widget::{KindState, ScrollingMessageState};
|
||
|
||
/// Registry key of the ScrollingMessageFrame method table (the MAXCSTACK discipline: Lua-side root,
|
||
/// named key).
|
||
pub(crate) const REG_SCROLLINGMESSAGEFRAME_METHODS: &str =
|
||
"__benilla_scrollingmessageframe_methods";
|
||
|
||
/// Run `f` over a frame's message-frame state under one short write borrow. Errors if `this` is not a
|
||
/// live ScrollingMessageFrame (unreachable through the kind dispatcher, but the method table is a
|
||
/// plain Lua value — a caller could fish it out and misapply it).
|
||
fn with_smf<T>(
|
||
lua: &Lua,
|
||
this: &Table,
|
||
f: impl FnOnce(&mut ScrollingMessageState) -> T,
|
||
) -> mlua::Result<T> {
|
||
let h = frame_handle_of(lua, this)?;
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
let frame = model
|
||
.arena
|
||
.frame_mut(h)
|
||
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
|
||
match &mut frame.kind_state {
|
||
KindState::ScrollingMessage(smf) => Ok(f(smf)),
|
||
_ => Err(mlua::Error::runtime("not a ScrollingMessageFrame")),
|
||
}
|
||
}
|
||
|
||
/// A Lua number-ish → f32 (nil/other → 0.0), for the color args.
|
||
fn num_f32(v: &Value) -> f32 {
|
||
match v {
|
||
Value::Number(n) => *n as f32,
|
||
Value::Integer(i) => *i as f32,
|
||
_ => 0.0,
|
||
}
|
||
}
|
||
|
||
/// `PageUp`/`PageDown`: page by the currently-displayed message count minus one (one message of
|
||
/// overlap — msgframe-runtime.md's page law). The viewport's row budget comes from the frame's last
|
||
/// resolved rect at the ring font's pitch (the font-height line-step law, the emit pass's basis);
|
||
/// an unresolved frame pages by one message.
|
||
fn page(lua: &Lua, this: &Table, up: bool) -> mlua::Result<()> {
|
||
let h = frame_handle_of(lua, this)?;
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
let viewport_rows = UiScript::message_viewport_rows(&model, h);
|
||
let frame = model
|
||
.arena
|
||
.frame_mut(h)
|
||
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
|
||
match &mut frame.kind_state {
|
||
KindState::ScrollingMessage(smf) => {
|
||
if up {
|
||
smf.page_up(viewport_rows);
|
||
} else {
|
||
smf.page_down(viewport_rows);
|
||
}
|
||
Ok(())
|
||
}
|
||
_ => Err(mlua::Error::runtime("not a ScrollingMessageFrame")),
|
||
}
|
||
}
|
||
|
||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||
let m = lua.create_table()?;
|
||
|
||
// AddMessage(text [, r, g, b [, id]]) — the id (5th arg) is accepted and ignored (v1 has no
|
||
// per-line UpdateColorByID). rgb absent ⇒ white; the state quantizes round-half-up and forces
|
||
// the line opaque, then the fade drives its alpha.
|
||
m.set(
|
||
"AddMessage",
|
||
lua.create_function(
|
||
|lua, (this, text, r, g, b): (Table, String, Value, Value, Value)| {
|
||
let has_rgb = !matches!(
|
||
(&r, &g, &b),
|
||
(Value::Nil, _, _) | (_, Value::Nil, _) | (_, _, Value::Nil)
|
||
);
|
||
let (r, g, b) = if has_rgb {
|
||
(num_f32(&r), num_f32(&g), num_f32(&b))
|
||
} else {
|
||
(1.0, 1.0, 1.0)
|
||
};
|
||
with_smf(lua, &this, |smf| smf.add(text, r, g, b))
|
||
},
|
||
)?,
|
||
)?;
|
||
|
||
m.set(
|
||
"Clear",
|
||
lua.create_function(|lua, this: Table| with_smf(lua, &this, ScrollingMessageState::clear))?,
|
||
)?;
|
||
m.set(
|
||
"ScrollUp",
|
||
lua.create_function(|lua, this: Table| {
|
||
with_smf(lua, &this, ScrollingMessageState::scroll_up)
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"ScrollDown",
|
||
lua.create_function(|lua, this: Table| {
|
||
with_smf(lua, &this, ScrollingMessageState::scroll_down)
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"ScrollToTop",
|
||
lua.create_function(|lua, this: Table| {
|
||
with_smf(lua, &this, ScrollingMessageState::scroll_to_top)
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"ScrollToBottom",
|
||
lua.create_function(|lua, this: Table| {
|
||
with_smf(lua, &this, ScrollingMessageState::scroll_to_bottom)
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"PageUp",
|
||
lua.create_function(|lua, this: Table| page(lua, &this, true))?,
|
||
)?;
|
||
m.set(
|
||
"PageDown",
|
||
lua.create_function(|lua, this: Table| page(lua, &this, false))?,
|
||
)?;
|
||
m.set(
|
||
"AtTop",
|
||
lua.create_function(|lua, this: Table| with_smf(lua, &this, |smf| smf.at_top()))?,
|
||
)?;
|
||
m.set(
|
||
"AtBottom",
|
||
lua.create_function(|lua, this: Table| with_smf(lua, &this, |smf| smf.at_bottom()))?,
|
||
)?;
|
||
|
||
// SetMaxLines is destructive (msgframe-runtime.md) — the state handles the wipe.
|
||
m.set(
|
||
"SetMaxLines",
|
||
lua.create_function(|lua, (this, n): (Table, i64)| {
|
||
with_smf(lua, &this, |smf| smf.set_max_lines(n.max(1) as usize))
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"GetMaxLines",
|
||
lua.create_function(|lua, this: Table| with_smf(lua, &this, |smf| smf.max_lines as i64))?,
|
||
)?;
|
||
m.set(
|
||
"GetNumMessages",
|
||
lua.create_function(|lua, this: Table| with_smf(lua, &this, |smf| smf.lines.len() as i64))?,
|
||
)?;
|
||
|
||
m.set(
|
||
"SetFading",
|
||
lua.create_function(|lua, (this, on): (Table, Value)| {
|
||
let on = !matches!(on, Value::Nil | Value::Boolean(false));
|
||
with_smf(lua, &this, |smf| smf.fading_enabled = on)
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"GetFading",
|
||
lua.create_function(|lua, this: Table| with_smf(lua, &this, |smf| smf.fading_enabled))?,
|
||
)?;
|
||
// The XML attr is `displayDuration`; the Lua accessors call the same field `TimeVisible`
|
||
// (msgframe-runtime.md).
|
||
m.set(
|
||
"SetTimeVisible",
|
||
lua.create_function(|lua, (this, s): (Table, f32)| {
|
||
with_smf(lua, &this, |smf| smf.time_visible = s.max(0.0))
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"GetTimeVisible",
|
||
lua.create_function(|lua, this: Table| with_smf(lua, &this, |smf| smf.time_visible))?,
|
||
)?;
|
||
m.set(
|
||
"SetFadeDuration",
|
||
lua.create_function(|lua, (this, s): (Table, f32)| {
|
||
with_smf(lua, &this, |smf| smf.fade_duration = s.max(0.0))
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"GetFadeDuration",
|
||
lua.create_function(|lua, this: Table| with_smf(lua, &this, |smf| smf.fade_duration))?,
|
||
)?;
|
||
|
||
// ── the shared font block ───────────────────────────────────────────────────────────────
|
||
//
|
||
// `Set/GetFontObject · Set/GetFont · Set/GetTextColor · Set/GetShadowColor ·
|
||
// Set/GetShadowOffset` are real entries on this class's table, not a courtesy. wow-re's
|
||
// registrar carve is explicit about the membership — *"Exposed on: FontString, Font object,
|
||
// EditBox, MessageFrame, ScrollingMessageFrame, SimpleHTML. NOT on Button"* — and names this
|
||
// class's own shims calling the shared implementations (`GetShadowColor 0x792240`). We shipped the block on two
|
||
// of the six and this is the third and fourth; SimpleHTML is a widget kind we do not have at
|
||
// all.
|
||
//
|
||
// Demand is observed, not counted: `BigWigs/Plugins/Messages.lua:212` is
|
||
// `self.msgframe:SetFontObject(GameFontNormalLarge)` on a frame it has just given
|
||
// `SetInsertMode("TOP")`, and BigWigs dies there every session.
|
||
crate::script::font_block::install(
|
||
lua,
|
||
&m,
|
||
|lua, this| {
|
||
let h = frame_handle_of(lua, this)?;
|
||
super::ensure_font_region(lua, h)
|
||
.ok_or_else(|| mlua::Error::runtime("not a ScrollingMessageFrame"))
|
||
},
|
||
"ScrollingMessageFrame",
|
||
)?;
|
||
|
||
lua.set_named_registry_value(REG_SCROLLINGMESSAGEFRAME_METHODS, m)?;
|
||
|
||
// SubmitChatInput(text) — the chat input EditBox's OnEnterPressed hands the typed line here; it
|
||
// queues into the model for the app to parse into a chat command (the outbound Lua→app seam, the
|
||
// twin of loot's LootSlot). Empty/whitespace lines are queued as-is; the app treats them as a
|
||
// cancel. Not an EditBox method — a bare global, called with the line the handler read.
|
||
// BenillaChatTabPressed() — the chat edit box's OnTabPressed queues a flag the app's
|
||
// whisper-cycle system drains (UiScript::take_chat_tab), the SubmitChatInput pattern.
|
||
lua.globals().set(
|
||
"BenillaChatTabPressed",
|
||
lua.create_function(|lua, ()| {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model.chat_tab = true;
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
lua.globals().set(
|
||
"SubmitChatInput",
|
||
lua.create_function(|lua, text: Option<String>| {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model.chat_input.push(text.unwrap_or_default());
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use crate::script::UiScript;
|
||
use crate::widget::ScrollingMessageState;
|
||
|
||
fn white(text: &str) -> (String, f32, f32, f32) {
|
||
(text.to_string(), 1.0, 1.0, 1.0)
|
||
}
|
||
|
||
#[test]
|
||
fn ring_drops_oldest_past_max_lines() {
|
||
let mut s = ScrollingMessageState {
|
||
max_lines: 3,
|
||
..Default::default()
|
||
};
|
||
for n in 0..5 {
|
||
let (t, r, g, b) = white(&format!("line {n}"));
|
||
s.add(t, r, g, b);
|
||
}
|
||
// Only the last 3 survive, oldest→newest.
|
||
assert_eq!(s.lines.len(), 3);
|
||
let texts: Vec<&str> = s.lines.iter().map(|l| l.text.as_str()).collect();
|
||
assert_eq!(texts, ["line 2", "line 3", "line 4"]);
|
||
}
|
||
|
||
#[test]
|
||
fn set_max_lines_is_destructive() {
|
||
let mut s = ScrollingMessageState::default();
|
||
for n in 0..4 {
|
||
s.add(format!("m{n}"), 1.0, 1.0, 1.0);
|
||
}
|
||
assert_eq!(s.lines.len(), 4);
|
||
s.set_max_lines(128);
|
||
assert!(s.lines.is_empty(), "SetMaxLines wipes the ring");
|
||
assert_eq!(s.max_lines, 128);
|
||
assert!(s.at_bottom());
|
||
}
|
||
|
||
#[test]
|
||
fn color_is_quantized_round_half_up() {
|
||
let mut s = ScrollingMessageState::default();
|
||
// 0.5*255 = 127.5 → +0.5 → 128.0 → trunc 128 (round-half-up); 1.0 → 255; clamp past 1.0.
|
||
s.add("q".into(), 0.5, 1.0, 2.0);
|
||
assert_eq!(s.lines[0].color, [128, 255, 255]);
|
||
// FF8040 (EMOTE) from 1.0, 0.5019.., 0.2509.. → 255,128,64 (spot the table's byte round-trip).
|
||
s.add("e".into(), 1.0, 128.0 / 255.0, 64.0 / 255.0);
|
||
assert_eq!(s.lines[1].color, [255, 128, 64]);
|
||
}
|
||
|
||
#[test]
|
||
fn fade_holds_then_ramps_then_retires() {
|
||
let mut s = ScrollingMessageState {
|
||
time_visible: 1.0,
|
||
fade_duration: 2.0,
|
||
..Default::default()
|
||
};
|
||
s.add("x".into(), 1.0, 1.0, 1.0);
|
||
// Phase 1: full alpha while timeVisible remains.
|
||
s.tick(0.5);
|
||
assert_eq!(s.lines[0].alpha, 1.0);
|
||
// Cross into phase 2: 0.5 more spends the last of timeVisible; next ticks ramp fadeDuration.
|
||
s.tick(0.5);
|
||
assert_eq!(s.lines[0].alpha, 1.0, "phase 1 just expired, ramp not yet");
|
||
s.tick(1.0); // fade_left 2.0 → 1.0 → alpha = trunc(1.0/2.0*255)/255 = 127/255
|
||
assert!((s.lines[0].alpha - 127.0 / 255.0).abs() < 1e-6);
|
||
s.tick(1.0); // fade_left → 0 → retired
|
||
assert_eq!(s.lines[0].alpha, 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn fade_duration_zero_vanishes_instantly() {
|
||
let mut s = ScrollingMessageState {
|
||
time_visible: 0.5,
|
||
fade_duration: 0.0,
|
||
..Default::default()
|
||
};
|
||
s.add("x".into(), 1.0, 1.0, 1.0);
|
||
s.tick(0.6); // spends timeVisible (per-tick phase check: still full this tick)
|
||
assert_eq!(s.lines[0].alpha, 1.0);
|
||
s.tick(0.1); // now past phase 1, no ramp ⇒ straight to 0
|
||
assert_eq!(s.lines[0].alpha, 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn scrolled_up_freezes_the_fade() {
|
||
let mut s = ScrollingMessageState {
|
||
max_lines: 8,
|
||
time_visible: 0.0, // straight into phase 2
|
||
fade_duration: 4.0,
|
||
..Default::default()
|
||
};
|
||
s.add("a".into(), 1.0, 1.0, 1.0);
|
||
s.add("b".into(), 1.0, 1.0, 1.0);
|
||
// Scroll up one line — no longer AtBottom, so ticks must not advance any alpha.
|
||
s.scroll_up();
|
||
assert!(!s.at_bottom());
|
||
s.tick(2.0);
|
||
assert_eq!(s.lines[0].alpha, 1.0, "frozen while scrolled up");
|
||
assert_eq!(s.lines[1].alpha, 1.0);
|
||
// Back to bottom → the fade resumes.
|
||
s.scroll_to_bottom();
|
||
s.tick(2.0);
|
||
assert!(s.lines[1].alpha < 1.0);
|
||
}
|
||
|
||
#[test]
|
||
fn scroll_clamps_at_both_ends() {
|
||
let mut s = ScrollingMessageState {
|
||
max_lines: 8,
|
||
..Default::default()
|
||
};
|
||
for n in 0..3 {
|
||
s.add(format!("l{n}"), 1.0, 1.0, 1.0);
|
||
}
|
||
assert!(s.at_bottom());
|
||
s.scroll_down(); // already at bottom — no-op
|
||
assert!(s.at_bottom());
|
||
// 3 lines → max_scroll = 2; scroll past it clamps.
|
||
for _ in 0..10 {
|
||
s.scroll_up();
|
||
}
|
||
assert!(s.at_top());
|
||
assert_eq!(s.scroll_offset, 2);
|
||
s.scroll_to_bottom();
|
||
assert!(s.at_bottom());
|
||
}
|
||
|
||
#[test]
|
||
fn scrolled_view_stays_anchored_as_the_ring_grows() {
|
||
let mut s = ScrollingMessageState {
|
||
max_lines: 8,
|
||
..Default::default()
|
||
};
|
||
for n in 0..4 {
|
||
s.add(format!("l{n}"), 1.0, 1.0, 1.0);
|
||
}
|
||
s.scroll_up(); // viewing one line back (bottom row = l2)
|
||
let off = s.scroll_offset;
|
||
s.add("l4".into(), 1.0, 1.0, 1.0); // a new line arrives at the bottom
|
||
assert_eq!(
|
||
s.scroll_offset,
|
||
off + 1,
|
||
"offset tracks so the same lines stay in view"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn displayed_count_and_paging_respect_wrapped_rows() {
|
||
let mut s = ScrollingMessageState {
|
||
max_lines: 8,
|
||
..Default::default()
|
||
};
|
||
for n in 0..3 {
|
||
s.add(format!("l{n}"), 1.0, 1.0, 1.0);
|
||
}
|
||
// Middle line wraps into 3 rows.
|
||
s.lines[1].rows = 3;
|
||
// Viewport of 4 rows from the bottom: newest (1 row) + middle (3 rows) fill it exactly —
|
||
// the oldest is off the top. A partially-fitting message counts (it draws, clipped).
|
||
assert_eq!(s.displayed_count(4), 2);
|
||
assert_eq!(s.displayed_count(3), 2, "partial middle line still counts");
|
||
assert_eq!(s.displayed_count(1), 1);
|
||
// Page = displayed − 1 (one message of overlap), never 0.
|
||
s.page_up(4);
|
||
assert_eq!(s.scroll_offset, 1);
|
||
s.page_up(1); // displayed 1 → page clamps to 1 message
|
||
assert_eq!(s.scroll_offset, 2);
|
||
s.page_down(4);
|
||
assert_eq!(s.scroll_offset, 1);
|
||
s.page_down(4);
|
||
assert!(s.at_bottom());
|
||
}
|
||
|
||
#[test]
|
||
fn emit_stacks_wrapped_rows_and_clips_partial_top() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
"local f = CreateFrame('ScrollingMessageFrame', 'CF')\n\
|
||
f:SetPoint('BOTTOMLEFT', 32, 90)\n\
|
||
f:SetWidth(430)\n\
|
||
f:SetHeight(34)",
|
||
)
|
||
.unwrap();
|
||
s.run("CF:AddMessage('old', 1, 1, 1)").unwrap();
|
||
s.run("CF:AddMessage('wrapped', 1, 1, 1)").unwrap();
|
||
s.run("CF:AddMessage('new', 1, 1, 1)").unwrap();
|
||
s.resolve();
|
||
// The measure round-trip: every fresh line requests its row count at the resolved width.
|
||
let reqs = s.message_lines_needing_measure();
|
||
assert_eq!(reqs.len(), 3);
|
||
assert!(reqs.iter().all(|r| (r.wrap_width - 430.0).abs() < 0.5));
|
||
// Host answers: the middle line wraps to 2 rows.
|
||
let answers: Vec<(u32, u32, u16, u64)> = reqs
|
||
.iter()
|
||
.map(|r| (r.frame, r.index, if r.index == 1 { 2 } else { 1 }, r.key))
|
||
.collect();
|
||
s.set_message_line_rows(&answers);
|
||
assert!(
|
||
s.message_lines_needing_measure().is_empty(),
|
||
"answered keys satisfy the cache"
|
||
);
|
||
// Default font (no FontString child) ⇒ pitch 14. Frame [90, 124): 'new' band [90,104),
|
||
// 'wrapped' 2-row band [104,132) — starts inside, overflows the top, still emitted
|
||
// (clipped); 'old' would start at 132 ≥ 124 — not emitted.
|
||
let quads = s.extract();
|
||
let texts: Vec<(String, f32, f32)> = quads
|
||
.iter()
|
||
.filter_map(|q| match (&q.content, q.rect) {
|
||
(crate::script::QuadContent::Text { text: Some(t), .. }, Some(r)) => {
|
||
Some((t.clone(), r.bottom, r.top))
|
||
}
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert_eq!(
|
||
texts.len(),
|
||
2,
|
||
"partial top line draws, off-top line doesn't"
|
||
);
|
||
assert_eq!(texts[0].0, "new");
|
||
assert!((texts[0].1 - 90.0).abs() < 0.01 && (texts[0].2 - 104.0).abs() < 0.01);
|
||
assert_eq!(texts[1].0, "wrapped");
|
||
assert!((texts[1].1 - 104.0).abs() < 0.01 && (texts[1].2 - 132.0).abs() < 0.01);
|
||
// Both clip to the frame rect, so the overflow never inks outside it.
|
||
let clip = quads
|
||
.iter()
|
||
.find_map(|q| match &q.content {
|
||
crate::script::QuadContent::Text { text: Some(t), .. } if t == "wrapped" => q.clip,
|
||
_ => None,
|
||
})
|
||
.expect("wrapped line carries a clip");
|
||
assert!((clip.top - 124.0).abs() < 0.01 && (clip.bottom - 90.0).abs() < 0.01);
|
||
}
|
||
|
||
/// The ring lines are the frame's ARTWORK content, not its bare draw slot: a BACKGROUND
|
||
/// texture of the same frame draws BEHIND them, an OVERLAY one in front. The chat window's
|
||
/// hover box is that background texture — at the bare slot it painted over the messages and
|
||
/// dimmed them (director, 2026-07-26).
|
||
#[test]
|
||
fn ring_lines_draw_above_the_frames_background_and_below_its_overlay() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
"local f = CreateFrame('ScrollingMessageFrame', 'CF')\n\
|
||
f:SetPoint('BOTTOMLEFT', 32, 90)\n\
|
||
f:SetWidth(430)\n\
|
||
f:SetHeight(60)\n\
|
||
local bg = f:CreateTexture('CFBg', 'BACKGROUND')\n\
|
||
bg:SetTexture('Interface\\\\Bg')\n\
|
||
bg:SetAllPoints(f)\n\
|
||
local over = f:CreateTexture('CFOver', 'OVERLAY')\n\
|
||
over:SetTexture('Interface\\\\Over')\n\
|
||
over:SetAllPoints(f)\n\
|
||
f:AddMessage('hello', 1, 1, 1)",
|
||
)
|
||
.unwrap();
|
||
s.resolve();
|
||
let quads = s.extract();
|
||
let z_of = |path: &str| {
|
||
quads
|
||
.iter()
|
||
.find_map(|q| match &q.content {
|
||
crate::script::QuadContent::Texture { path: Some(p), .. } if p == path => {
|
||
Some(q.z)
|
||
}
|
||
_ => None,
|
||
})
|
||
.expect("texture drew")
|
||
};
|
||
let line_z = quads
|
||
.iter()
|
||
.find_map(|q| match &q.content {
|
||
crate::script::QuadContent::Text { text: Some(t), .. } if t == "hello" => Some(q.z),
|
||
_ => None,
|
||
})
|
||
.expect("the ring line drew");
|
||
let slot_z = quads
|
||
.iter()
|
||
.find(|q| matches!(q.content, crate::script::QuadContent::Frame))
|
||
.map(|q| q.z)
|
||
.expect("the frame slot drew");
|
||
let (bg_z, over_z) = (z_of("Interface\\Bg"), z_of("Interface\\Over"));
|
||
assert!(slot_z < bg_z, "the frame's own slot still leads");
|
||
assert!(
|
||
bg_z < line_z,
|
||
"a BACKGROUND texture draws behind the messages"
|
||
);
|
||
assert!(line_z < over_z, "an OVERLAY texture draws in front of them");
|
||
}
|
||
|
||
#[test]
|
||
fn width_change_invalidates_row_measures() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
"local f = CreateFrame('ScrollingMessageFrame', 'CF')\n\
|
||
f:SetPoint('BOTTOMLEFT', 0, 0)\n\
|
||
f:SetWidth(430)\n\
|
||
f:SetHeight(120)",
|
||
)
|
||
.unwrap();
|
||
s.run("CF:AddMessage('x', 1, 1, 1)").unwrap();
|
||
s.resolve();
|
||
let reqs = s.message_lines_needing_measure();
|
||
assert_eq!(reqs.len(), 1);
|
||
let answers: Vec<(u32, u32, u16, u64)> =
|
||
reqs.iter().map(|r| (r.frame, r.index, 1, r.key)).collect();
|
||
s.set_message_line_rows(&answers);
|
||
assert!(s.message_lines_needing_measure().is_empty());
|
||
s.run("CF:SetWidth(300)").unwrap();
|
||
s.resolve();
|
||
assert_eq!(
|
||
s.message_lines_needing_measure().len(),
|
||
1,
|
||
"a width change re-requests the row count"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn faded_line_holds_its_rows_without_drawing() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
"local f = CreateFrame('ScrollingMessageFrame', 'CF')\n\
|
||
f:SetPoint('BOTTOMLEFT', 0, 0)\n\
|
||
f:SetWidth(430)\n\
|
||
f:SetHeight(120)\n\
|
||
f:SetTimeVisible(0)\n\
|
||
f:SetFadeDuration(0)",
|
||
)
|
||
.unwrap();
|
||
s.run("CF:AddMessage('doomed', 1, 1, 1)").unwrap();
|
||
// Two ticks retire 'doomed' (phase-1 spend, then the no-ramp snap), then fresh lines land.
|
||
s.tick(0.1);
|
||
s.tick(0.1);
|
||
s.run("CF:SetTimeVisible(120)").unwrap();
|
||
s.run("CF:AddMessage('a', 1, 1, 1)").unwrap();
|
||
s.run("CF:AddMessage('b', 1, 1, 1)").unwrap();
|
||
s.resolve();
|
||
let quads = s.extract();
|
||
let texts: Vec<(String, f32)> = quads
|
||
.iter()
|
||
.filter_map(|q| match (&q.content, q.rect) {
|
||
(crate::script::QuadContent::Text { text: Some(t), .. }, Some(r)) => {
|
||
Some((t.clone(), r.bottom))
|
||
}
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
// 'doomed' draws nothing but its band (the oldest, topmost) still holds its place: the two
|
||
// live lines sit at the two bottom bands, nothing re-packs into the faded slot.
|
||
assert_eq!(texts.len(), 2);
|
||
assert_eq!(texts[0].0, "b");
|
||
assert!((texts[0].1 - 0.0).abs() < 0.01);
|
||
assert_eq!(texts[1].0, "a");
|
||
assert!((texts[1].1 - 14.0).abs() < 0.01);
|
||
}
|
||
|
||
#[test]
|
||
fn hyperlink_span_release_fires_on_hyperlink_click() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(800.0, 600.0);
|
||
s.run(
|
||
"local f = CreateFrame('ScrollingMessageFrame', 'CF')\n\
|
||
f:SetPoint('BOTTOMLEFT', 0, 0)\n\
|
||
f:SetWidth(430)\n\
|
||
f:SetHeight(120)\n\
|
||
f:SetScript('OnHyperlinkClick', function(self, link, text, button)\n\
|
||
CLICKED = link .. '#' .. text .. '#' .. button\n\
|
||
end)",
|
||
)
|
||
.unwrap();
|
||
s.run("CF:AddMessage('x', 1, 1, 1)").unwrap();
|
||
s.resolve();
|
||
// Find the frame handle the extract loop would hand the app (the message line's target).
|
||
let fh = s
|
||
.extract()
|
||
.iter()
|
||
.find_map(|q| match (&q.content, q.target) {
|
||
(
|
||
crate::script::QuadContent::Text { text: Some(t), .. },
|
||
crate::order::ZTarget::Frame(fh),
|
||
) if t == "x" => Some(fh),
|
||
_ => None,
|
||
})
|
||
.expect("the ring line extracts");
|
||
// The app feeds a span (y-up rect) covering [10..60]x[20..34]; a release inside fires the
|
||
// handler with (link, markup, button); one outside does not.
|
||
s.set_link_spans(vec![(
|
||
fh,
|
||
crate::layout::Rect::new(20.0, 10.0, 34.0, 60.0),
|
||
"item:7073".to_string(),
|
||
"|Hitem:7073|h[Broken Fang]|h".to_string(),
|
||
)]);
|
||
s.mouse_button(30.0, 25.0, "LeftButton", true);
|
||
s.mouse_button(30.0, 25.0, "LeftButton", false);
|
||
assert_eq!(
|
||
s.eval::<String>("return CLICKED or ''").unwrap(),
|
||
"item:7073#|Hitem:7073|h[Broken Fang]|h#LeftButton"
|
||
);
|
||
// Outside the span: no new fire.
|
||
s.run("CLICKED = nil").unwrap();
|
||
s.mouse_button(200.0, 100.0, "LeftButton", true);
|
||
s.mouse_button(200.0, 100.0, "LeftButton", false);
|
||
assert_eq!(
|
||
s.eval::<String>("return CLICKED or 'none'").unwrap(),
|
||
"none"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn add_chat_message_targets_the_named_scroll_frame() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.run("CreateFrame('ScrollingMessageFrame', 'ChatTest')")
|
||
.unwrap();
|
||
assert!(s.add_chat_message("ChatTest", "hello", 1.0, 1.0, 1.0));
|
||
assert_eq!(
|
||
s.eval::<i64>("return ChatTest:GetNumMessages()").unwrap(),
|
||
1
|
||
);
|
||
// A missing frame or a non-message frame is a no-op.
|
||
assert!(!s.add_chat_message("Nope", "x", 1.0, 1.0, 1.0));
|
||
s.run("CreateFrame('Frame', 'PlainFrame')").unwrap();
|
||
assert!(!s.add_chat_message("PlainFrame", "x", 1.0, 1.0, 1.0));
|
||
}
|
||
|
||
#[test]
|
||
fn lua_addmessage_and_scroll_surface() {
|
||
let s = UiScript::new().unwrap();
|
||
s.run("CreateFrame('ScrollingMessageFrame', 'CF')").unwrap();
|
||
s.run("CF:SetMaxLines(128)").unwrap();
|
||
assert_eq!(s.eval::<i64>("return CF:GetMaxLines()").unwrap(), 128);
|
||
s.run("CF:AddMessage('one', 1, 1, 1)").unwrap();
|
||
s.run("CF:AddMessage('two')").unwrap(); // rgb-less form defaults white
|
||
assert_eq!(s.eval::<i64>("return CF:GetNumMessages()").unwrap(), 2);
|
||
assert!(s.eval::<bool>("return CF:AtBottom()").unwrap());
|
||
s.run("CF:ScrollUp()").unwrap();
|
||
assert!(!s.eval::<bool>("return CF:AtBottom()").unwrap());
|
||
s.run("CF:ScrollToBottom()").unwrap();
|
||
assert!(s.eval::<bool>("return CF:AtBottom()").unwrap());
|
||
s.run("CF:Clear()").unwrap();
|
||
assert_eq!(s.eval::<i64>("return CF:GetNumMessages()").unwrap(), 0);
|
||
}
|
||
}
|