addons come alive: Bagnon draws, hovers and clicks
- Lua 5.0's generic-for and nested long strings are restored in a forked VM — 60 corpus addons stopped dying on one deleted opcode - a day of demand-driven carving takes the 218-addon corpus from 24 addons surviving a session start to 114, with 144 loading without a single error - Bagnon draws a real bag and answers real gestures — hover tooltip, pickup, use, drag — because our frames wear the reference's names, and the reference's own ContainerFrame.lua runs off the player's install instead of being transcribed - the physics broad phase leaves: nothing in the client reads a contact, and it was measured at up to 41% of main-thread samples in real sessions Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
//! The two message-frame widget classes, and the display machinery they share.
|
||||
//!
|
||||
//! 1.12 ships **two** of them, and msgframe-runtime.md is emphatic that they are *siblings, not
|
||||
//! parent and child* (different ctors, different vtables, different Lua type tags, offsets that do
|
||||
//! not transfer):
|
||||
//!
|
||||
//! - [`scrolling`] — `CSimpleMessageScrollFrame` (ctor `0x787670`), the chat window's class: a true
|
||||
//! ring of `maxLines`, a scrollback cursor, `AddMessage(text[,r,g,b[,id]])` with alpha forced
|
||||
//! opaque.
|
||||
//! - [`plain`] — `CSimpleMessageFrame` (ctor `0x785640`), `UIErrorsFrame`'s class: no ring, no
|
||||
//! scrollback, `insertMode`, and `AddMessage(text[,r,g,b[,a]])` whose fourth numeric is a real
|
||||
//! **alpha**.
|
||||
//!
|
||||
//! Each gets its **own** Lua method table, consulted by the frame `__index` dispatcher only for its
|
||||
//! own [`FrameKind`](crate::widget::FrameKind) — so a duck-typing addon (`if f.SetInsertMode then`)
|
||||
//! sees `nil` everywhere else, exactly as against the client's per-class registrar tables.
|
||||
//!
|
||||
//! What *is* shared sits here: both classes display a stack of [`MessageLine`] records, so the
|
||||
//! wrapped-row measure round-trip, the band emit, and the font/justification read are one code path
|
||||
//! over [`KindState::message_lines`]. Everything that actually differs stays on the two states.
|
||||
|
||||
use crate::layout::Rect;
|
||||
use crate::order::ZTarget;
|
||||
use crate::widget::{FrameHandle, InsertMode, KindState, MessageLine};
|
||||
|
||||
use super::layout::FramePaint;
|
||||
use super::{
|
||||
ExtractedQuad, FontShadow, JustifyH, JustifyV, LineMeasureRequest, Model, Outline, QuadContent,
|
||||
UiScript,
|
||||
};
|
||||
|
||||
mod plain;
|
||||
mod scrolling;
|
||||
|
||||
pub(super) use plain::REG_MESSAGEFRAME_METHODS;
|
||||
pub(super) use scrolling::REG_SCROLLINGMESSAGEFRAME_METHODS;
|
||||
|
||||
/// Install both classes' method tables (and the chat input globals the scrolling one carries).
|
||||
pub(super) fn install(lua: &mlua::Lua) -> mlua::Result<()> {
|
||||
scrolling::install(lua)?;
|
||||
plain::install(lua)
|
||||
}
|
||||
|
||||
/// The font a message frame's lines draw with — its declared `<FontString>` child, which is exactly
|
||||
/// how both reference frames spell it (`<FontString inherits="ChatFontNormal" justifyH="LEFT"/>` on
|
||||
/// a chat frame, `<FontString inherits="ErrorFont" justifyH="CENTER"/>` on `UIErrorsFrame.xml`).
|
||||
/// Shared by the emit and the measure round-trip so bands, measures and glyphs agree. Missing ⇒ the
|
||||
/// renderer's default face at the ~14px chat default.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct MessageFont {
|
||||
pub(super) path: Option<String>,
|
||||
pub(super) height: Option<f32>,
|
||||
pub(super) shadow: Option<FontShadow>,
|
||||
pub(super) outline: Outline,
|
||||
/// The child's `justifyH`. **Load-bearing, not decoration**: it is the only reason the
|
||||
/// reference's error toasts sit centred in their 512-wide frame while chat lines run flush
|
||||
/// left, and the class draws its lines through that font instance. A frame with no declared
|
||||
/// FontString falls back to LEFT (the chat shape), never to the FontString default of CENTER —
|
||||
/// there is no region here whose own default could apply.
|
||||
pub(super) justify_h: JustifyH,
|
||||
}
|
||||
|
||||
impl UiScript {
|
||||
pub(super) fn message_frame_font(model: &Model, fh: FrameHandle) -> MessageFont {
|
||||
model
|
||||
.arena
|
||||
.frame(fh)
|
||||
.and_then(|frame| {
|
||||
frame
|
||||
.regions
|
||||
.iter()
|
||||
.find(|&&rh| {
|
||||
matches!(
|
||||
model.arena.region(rh).map(|r| r.kind),
|
||||
Some(crate::widget::RegionKind::FontString)
|
||||
)
|
||||
})
|
||||
.and_then(|rh| model.region_data.get(rh))
|
||||
.map(|d| MessageFont {
|
||||
path: d.font_path.clone(),
|
||||
height: d.font_height,
|
||||
shadow: d.font_shadow,
|
||||
outline: d.outline,
|
||||
justify_h: d.justify_h,
|
||||
})
|
||||
})
|
||||
.unwrap_or(MessageFont {
|
||||
path: None,
|
||||
height: None,
|
||||
shadow: None,
|
||||
outline: Outline::default(),
|
||||
justify_h: JustifyH::Left,
|
||||
})
|
||||
}
|
||||
|
||||
/// How many display **rows** frame `fh`'s resolved rect holds at its line font's pitch — the
|
||||
/// client's layout-derived `numLinesDisplayed`. `0` when the frame has no resolved rect.
|
||||
///
|
||||
/// Both classes need it and neither can compute it from its own state: it is the page size for
|
||||
/// the scrolling class's `PageUp`/`PageDown` and the whole capacity law for the plain one.
|
||||
pub(super) fn message_viewport_rows(model: &Model, fh: FrameHandle) -> usize {
|
||||
let pitch = Self::message_frame_font(model, fh)
|
||||
.height
|
||||
.unwrap_or(14.0)
|
||||
.max(1.0);
|
||||
model.resolved.get(&fh).map_or(0, |r| {
|
||||
((r.top - r.bottom) / pitch).floor().max(0.0) as usize
|
||||
})
|
||||
}
|
||||
|
||||
/// Message lines whose wrapped **row count** needs a host measurement (a new line, or the
|
||||
/// frame's width/font changed under it) — the message-frame half of the measure round-trip.
|
||||
/// Call after [`UiScript::resolve`] (widths must be resolved); answer with
|
||||
/// [`UiScript::set_message_line_rows`] before extract. Cache keys keep this empty on quiet
|
||||
/// frames.
|
||||
pub fn message_lines_needing_measure(&mut self) -> Vec<LineMeasureRequest> {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut model = self.model_mut();
|
||||
let mut out = Vec::new();
|
||||
let frames: Vec<(FrameHandle, Rect)> = model
|
||||
.resolved
|
||||
.iter()
|
||||
.filter(|(&fh, _)| {
|
||||
model
|
||||
.arena
|
||||
.frame(fh)
|
||||
.is_some_and(|f| f.kind_state.message_lines().is_some() && f.effective_visible)
|
||||
})
|
||||
.map(|(&fh, &fr)| (fh, fr))
|
||||
.collect();
|
||||
for (fh, fr) in frames {
|
||||
let wrap_width = fr.right - fr.left;
|
||||
if wrap_width <= 1.0 {
|
||||
continue; // unresolved/degenerate width — nothing meaningful to wrap against
|
||||
}
|
||||
let scale = model
|
||||
.arena
|
||||
.frame(fh)
|
||||
.map(|f| f.effective_scale)
|
||||
.unwrap_or(1.0);
|
||||
let font = Self::message_frame_font(&model, fh);
|
||||
let frame_id = model.frame_id(fh);
|
||||
let Some(frame) = model.arena.frame(fh) else {
|
||||
continue;
|
||||
};
|
||||
let Some(lines) = frame.kind_state.message_lines() else {
|
||||
continue;
|
||||
};
|
||||
for (index, line) in lines.iter().enumerate() {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
line.text.hash(&mut hasher);
|
||||
font.path.hash(&mut hasher);
|
||||
font.height.map(f32::to_bits).hash(&mut hasher);
|
||||
wrap_width.to_bits().hash(&mut hasher);
|
||||
(font.outline as u8).hash(&mut hasher);
|
||||
scale.to_bits().hash(&mut hasher);
|
||||
let key = hasher.finish();
|
||||
if line.rows_key == key {
|
||||
continue;
|
||||
}
|
||||
out.push(LineMeasureRequest {
|
||||
frame: frame_id,
|
||||
index: index as u32,
|
||||
font: font.path.clone(),
|
||||
height: font.height,
|
||||
wrap_width,
|
||||
outline: font.outline,
|
||||
scale,
|
||||
text: line.text.clone(),
|
||||
key,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Store host row-count answers for [`LineMeasureRequest`]s (`(frame, index, rows, key)` —
|
||||
/// frame/index/key verbatim from the request). The key is stored beside the rows, so a line
|
||||
/// whose width/font changed again since the request simply re-requests next frame.
|
||||
pub fn set_message_line_rows(&mut self, rows: &[(u32, u32, u16, u64)]) {
|
||||
let mut model = self.model_mut();
|
||||
for &(frame_id, index, n, key) in rows {
|
||||
let Some(&fh) = model.id_to_frame.get(&frame_id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(frame) = model.arena.frame_mut(fh) else {
|
||||
continue;
|
||||
};
|
||||
let Some(lines) = frame.kind_state.message_lines_mut() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(line) = lines.get_mut(index as usize) {
|
||||
line.rows = n.max(1);
|
||||
line.rows_key = key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Push one [`QuadContent::Text`] per visible message of either message-frame class (no-op for
|
||||
/// any other kind). Each message occupies `rows × pitch` (its host-measured wrapped row count —
|
||||
/// the message-line measure round-trip), so a long line pushes its neighbours by its real
|
||||
/// height. The pitch is the **font height itself** — the client's own line-step law
|
||||
/// (`LayoutLines` 0x5cdc20: step = px(size) + spacing, spacing 0), the same law the app's text
|
||||
/// renderer lays wrapped rows at, so band grid and glyph rows coincide by construction. (The
|
||||
/// msgframe's own relayout `0x788750/0x788c00` is only partially read in wow-re — if the look
|
||||
/// pass ever shows the ref spacing chat lines wider than the font height, that residual is the
|
||||
/// place to pin.)
|
||||
///
|
||||
/// **Which edge the stack grows from is the one place the two classes diverge visibly.** A
|
||||
/// ScrollingMessageFrame always stacks bottom-up with `scroll_offset` picking which message
|
||||
/// sits on the bottom row. A MessageFrame has no scrollback and instead honours `insertMode`:
|
||||
/// BOTTOM (the ctor default) is the same bottom-up stack, TOP hangs the newest message off the
|
||||
/// frame's top edge with older ones stepping *down* — the shape `UIErrorsFrame.xml:4` asks for,
|
||||
/// and the one every corpus `SetInsertMode` caller asks for. The growth *anchor* per mode is
|
||||
/// wow-re's own named residual (it lives in the unwalked wrap/layout pass), so this is the
|
||||
/// INFERRED half: it is the reading that reproduces both shipped usages, and the place to
|
||||
/// re-pin if a reference A/B ever disagrees.
|
||||
///
|
||||
/// A message that only partially fits at the far edge still draws — clipped to the frame rect,
|
||||
/// so its inner wrapped rows show, never inking outside the frame. A fully-faded scrolling line
|
||||
/// draws nothing but still holds its rows (the reference's chat never re-packs as old lines
|
||||
/// fade); a faded MessageFrame line is gone from the state entirely by then, because that class
|
||||
/// frees its lines instead of holding slots.
|
||||
pub(super) fn emit_message_lines(
|
||||
model: &Model,
|
||||
fh: FrameHandle,
|
||||
fr: Rect,
|
||||
z: u64,
|
||||
paint: FramePaint,
|
||||
clip: Option<Rect>,
|
||||
out: &mut Vec<ExtractedQuad>,
|
||||
) {
|
||||
let Some(frame) = model.arena.frame(fh) else {
|
||||
return;
|
||||
};
|
||||
// (lines, index of the message on the anchored row, which edge it hangs off).
|
||||
let (lines, top_index, from_top): (&std::collections::VecDeque<MessageLine>, usize, bool) =
|
||||
match &frame.kind_state {
|
||||
KindState::ScrollingMessage(smf) => (
|
||||
&smf.lines,
|
||||
smf.lines.len().saturating_sub(1 + smf.scroll_offset),
|
||||
false,
|
||||
),
|
||||
KindState::Message(mf) => (
|
||||
&mf.lines,
|
||||
mf.lines.len().saturating_sub(1),
|
||||
matches!(mf.insert_mode, InsertMode::Top),
|
||||
),
|
||||
_ => return,
|
||||
};
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
let font = Self::message_frame_font(model, fh);
|
||||
// The band grid lives in the frame's RESOLVED (scale-multiplied) rect, so the row pitch
|
||||
// rides the frame scale exactly like the glyphs it must coincide with (the font height
|
||||
// itself is frame-local).
|
||||
let pitch = font.height.unwrap_or(14.0) * paint.scale;
|
||||
if pitch <= 0.0 || fr.top <= fr.bottom {
|
||||
return;
|
||||
}
|
||||
// Message text never inks outside its frame: a partially-fitting outermost message is
|
||||
// scissored at the frame edge (intersected with any ScrollFrame ancestor clip).
|
||||
let line_clip = Some(match clip {
|
||||
Some(c) => super::clip::intersect_rect(c, fr),
|
||||
None => fr,
|
||||
});
|
||||
let mut used = 0.0f32; // rows already consumed, in px in from the anchored edge
|
||||
for idx in (0..=top_index).rev() {
|
||||
if used >= fr.top - fr.bottom {
|
||||
break; // the next band would start outside the frame — nothing more can show
|
||||
}
|
||||
let line = &lines[idx];
|
||||
let band_h = f32::from(line.rows.max(1)) * pitch;
|
||||
let (bottom, top) = if from_top {
|
||||
(fr.top - used - band_h, fr.top - used)
|
||||
} else {
|
||||
(fr.bottom + used, fr.bottom + used + band_h)
|
||||
};
|
||||
used += band_h;
|
||||
if line.alpha <= 0.0 {
|
||||
continue; // fully faded — holds its place, draws nothing
|
||||
}
|
||||
out.push(ExtractedQuad {
|
||||
target: ZTarget::Frame(fh),
|
||||
z,
|
||||
rect: Some(Rect::new(bottom, fr.left, top, fr.right)),
|
||||
alpha: paint.alpha,
|
||||
clip: line_clip,
|
||||
content: QuadContent::Text {
|
||||
text: Some(line.text.clone()),
|
||||
color: Some([
|
||||
f32::from(line.color[0]) / 255.0,
|
||||
f32::from(line.color[1]) / 255.0,
|
||||
f32::from(line.color[2]) / 255.0,
|
||||
line.alpha,
|
||||
]),
|
||||
justify_h: font.justify_h,
|
||||
// The band is our own per-message construct (rows × pitch tall, not a
|
||||
// FontString rect) — keep the block at its top so the band math, not MIDDLE
|
||||
// centering, owns the vertical rhythm; the band height equals the wrapped
|
||||
// block height exactly, so Top means "fills the band".
|
||||
justify_v: JustifyV::Top,
|
||||
font: font.path.clone(),
|
||||
// The font object's own drop shadow (ChatFontNormal's `(1,-1)` black) — what
|
||||
// makes the lines read against the world behind the transparent frame.
|
||||
shadow: font.shadow,
|
||||
font_height: font.height,
|
||||
text_height: None, // message lines are never SetTextHeight'd
|
||||
outline: font.outline,
|
||||
alpha_gradient: None,
|
||||
},
|
||||
scale: paint.scale,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
//! The `MessageFrame` method surface — `CSimpleMessageFrame` (ctor `0x785640`), the class
|
||||
//! `UIErrorsFrame` is and the one `CreateFrame("MessageFrame")` makes.
|
||||
//!
|
||||
//! Grounded in wow-re's byte-verified reads (msgframe-runtime.md's §5 pair, re-confirmed at the
|
||||
//! registrar tables in `widget-api-batch-benilla.md` Q4). Three facts decide this whole file, and
|
||||
//! each is one an implementation would otherwise get wrong in a way nothing would catch:
|
||||
//!
|
||||
//! 1. **`AddMessage(text [, r, g, b [, a]])` — the fifth argument is ALPHA and there is no sixth.**
|
||||
//! Three corpus addons pass six (`EasyCopy.lua:12`, `QuestHistory.lua:1678`, `QuestItem.lua:300`,
|
||||
//! all `UIErrorsFrame:AddMessage(msg, r, g, b, a, holdTime)`), and the real 1.12.1 binding
|
||||
//! `0x795590` reads five and stops. Honouring that trailing number as a per-message hold time
|
||||
//! would be **unfaithful, not generous** — the client shows the message for the *frame's*
|
||||
//! `displayDuration` and nothing else. It is ignored here, deliberately.
|
||||
//! 2. **This `AddMessage` is not the scrolling class's.** `0x792900` takes an **id** in the same
|
||||
//! slot and forces alpha `0xFF` (`792add: or edi,0xffffff00`); `0x795590` packs a real
|
||||
//! `0xAARRGGBB` with a default of 1.0. Two bindings, two tables, no sharing.
|
||||
//! 3. **`SetInsertMode`/`GetInsertMode` (`0x794ed0`/`0x794ff0`) live on this class only** — the
|
||||
//! scrolling class has neither, and no `insertMode` XML attribute either.
|
||||
//!
|
||||
//! The state (display lines, fade phases, the vertical cap) is
|
||||
//! [`MessageFrameState`](crate::widget::MessageFrameState) in `crate::widget`; this is the thin Lua
|
||||
//! binding over it. The band emit and the wrapped-row measure round-trip are shared with the
|
||||
//! scrolling class and live in [`super`].
|
||||
|
||||
use mlua::{Lua, Table, Value};
|
||||
|
||||
use crate::script::object::frame_handle_of;
|
||||
use crate::script::Model;
|
||||
use crate::widget::{InsertMode, KindState, MessageFrameState};
|
||||
|
||||
/// Registry key of the MessageFrame method table (the MAXCSTACK discipline: Lua-side root, named
|
||||
/// key).
|
||||
pub(crate) const REG_MESSAGEFRAME_METHODS: &str = "__benilla_plain_messageframe_methods";
|
||||
|
||||
/// Run `f` over a frame's MessageFrame state under one short write borrow. Errors if `this` is not
|
||||
/// a live MessageFrame (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_mf<T>(
|
||||
lua: &Lua,
|
||||
this: &Table,
|
||||
f: impl FnOnce(&mut MessageFrameState) -> 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::Message(mf) => Ok(f(mf)),
|
||||
_ => Err(mlua::Error::runtime("not a MessageFrame")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A Lua number-ish → f32 (nil/other → 0.0), for the colour args.
|
||||
fn num_f32(v: &Value) -> f32 {
|
||||
match v {
|
||||
Value::Number(n) => *n as f32,
|
||||
Value::Integer(i) => *i as f32,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let m = lua.create_table()?;
|
||||
|
||||
// AddMessage(text [, r, g, b [, a]]) — binding 0x795590.
|
||||
//
|
||||
// r/g/b are required **as a trio** (three presence checks ANDed at 0x7956xx); absent ⇒ hard
|
||||
// 0xFFFFFFFF white. The fourth numeric is a real alpha, `lua_isnumber`-gated with a default of
|
||||
// 1.0 (`795752: mov [ebp-0x18],0x3f800000`).
|
||||
//
|
||||
// **The signature stops there, and the closure's arity is the enforcement.** The corpus's three
|
||||
// blocked callers all pass a sixth argument they believe is a hold time; mlua hands us the
|
||||
// first five and drops the rest, exactly as the real binding does by reading five stack slots.
|
||||
// Adding a `holdTime` parameter here would make benilla show those messages for a duration no
|
||||
// 1.12 client ever showed them for.
|
||||
m.set(
|
||||
"AddMessage",
|
||||
lua.create_function(
|
||||
|lua, (this, text, r, g, b, a): (Table, String, Value, 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)
|
||||
};
|
||||
// Alpha only counts when rgb came with it — the client reads arg 5 off the same
|
||||
// parse that required the trio, and a lone `AddMessage(text, nil, nil, nil, 0.5)`
|
||||
// has no colour to apply it to.
|
||||
let a = match (&a, has_rgb) {
|
||||
(Value::Number(_) | Value::Integer(_), true) => num_f32(&a),
|
||||
_ => 1.0,
|
||||
};
|
||||
with_mf(lua, &this, |mf| mf.add(text, r, g, b, a))
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
|
||||
m.set(
|
||||
"Clear",
|
||||
lua.create_function(|lua, this: Table| with_mf(lua, &this, MessageFrameState::clear))?,
|
||||
)?;
|
||||
|
||||
// SetInsertMode("TOP"|"BOTTOM") / GetInsertMode — 0x794ed0 / 0x794ff0, THIS CLASS ONLY. The
|
||||
// client compares the string against "BOTTOM" (0x871404) and stores `streq-BOTTOM`, so anything
|
||||
// that is not literally BOTTOM lands on TOP; the ctor default is 1 = BOTTOM.
|
||||
m.set(
|
||||
"SetInsertMode",
|
||||
lua.create_function(|lua, (this, mode): (Table, Value)| {
|
||||
let mode = match &mode {
|
||||
Value::String(s) => {
|
||||
if s.to_str()?.trim().eq_ignore_ascii_case("BOTTOM") {
|
||||
InsertMode::Bottom
|
||||
} else {
|
||||
InsertMode::Top
|
||||
}
|
||||
}
|
||||
// A non-string argument fails the streq and therefore reads as TOP, like the
|
||||
// client's own comparison against a string it never got.
|
||||
_ => InsertMode::Top,
|
||||
};
|
||||
with_mf(lua, &this, |mf| mf.insert_mode = mode)
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"GetInsertMode",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
with_mf(lua, &this, |mf| match mf.insert_mode {
|
||||
InsertMode::Top => "TOP",
|
||||
InsertMode::Bottom => "BOTTOM",
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// The fade trio — the same names and the same field the scrolling class exposes (the XML attr
|
||||
// is `displayDuration`, the Lua accessors call it `TimeVisible`), over this class's own state.
|
||||
m.set(
|
||||
"SetFading",
|
||||
lua.create_function(|lua, (this, on): (Table, Value)| {
|
||||
let on = !matches!(on, Value::Nil | Value::Boolean(false));
|
||||
with_mf(lua, &this, |mf| mf.fading_enabled = on)
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"GetFading",
|
||||
lua.create_function(|lua, this: Table| with_mf(lua, &this, |mf| mf.fading_enabled))?,
|
||||
)?;
|
||||
m.set(
|
||||
"SetTimeVisible",
|
||||
lua.create_function(|lua, (this, s): (Table, f32)| {
|
||||
with_mf(lua, &this, |mf| mf.time_visible = s.max(0.0))
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"GetTimeVisible",
|
||||
lua.create_function(|lua, this: Table| with_mf(lua, &this, |mf| mf.time_visible))?,
|
||||
)?;
|
||||
m.set(
|
||||
"SetFadeDuration",
|
||||
lua.create_function(|lua, (this, s): (Table, f32)| {
|
||||
with_mf(lua, &this, |mf| mf.fade_duration = s.max(0.0))
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"GetFadeDuration",
|
||||
lua.create_function(|lua, this: Table| with_mf(lua, &this, |mf| mf.fade_duration))?,
|
||||
)?;
|
||||
|
||||
lua.set_named_registry_value(REG_MESSAGEFRAME_METHODS, m)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::script::UiScript;
|
||||
|
||||
/// How many display lines a named message frame holds. Read off the state rather than through
|
||||
/// Lua on purpose: `GetNumMessages` is a **ScrollingMessageFrame** binding, and inventing one
|
||||
/// on this class to make its tests convenient is exactly the drift these tests are pinning
|
||||
/// against.
|
||||
fn num_messages(s: &UiScript, name: &str) -> usize {
|
||||
let m = s.model_ref();
|
||||
let h = m.arena.lookup(name).expect("named frame");
|
||||
m.arena
|
||||
.frame(h)
|
||||
.and_then(|f| f.kind_state.message_lines())
|
||||
.map_or(0, |l| l.len())
|
||||
}
|
||||
|
||||
/// The whole point of the arity, pinned: the sixth argument three corpus addons pass is a hold
|
||||
/// time the real client never had, and it must change **nothing**. The frame's own
|
||||
/// `displayDuration` is what governs, so the message with a "hold time" of 999 fades on exactly
|
||||
/// the same tick as the one without.
|
||||
#[test]
|
||||
fn the_sixth_addmessage_argument_is_ignored_not_a_holdtime() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.run("CreateFrame('MessageFrame', 'MF')").unwrap();
|
||||
s.run("MF:SetTimeVisible(1)").unwrap();
|
||||
s.run("MF:SetFadeDuration(0)").unwrap();
|
||||
// The exact shape of EasyCopy.lua:12 / QuestItem.lua:300 / QuestHistory.lua:1678.
|
||||
s.run("MF:AddMessage('held', 1.0, 1.0, 1.0, 1.0, 999)")
|
||||
.unwrap();
|
||||
s.run("MF:AddMessage('plain', 1.0, 1.0, 1.0, 1.0)").unwrap();
|
||||
assert_eq!(num_messages(&s, "MF"), 2);
|
||||
// Past timeVisible with no ramp: both retire together. A honoured holdTime would keep the
|
||||
// first alive here, which is exactly the divergence this test exists to catch.
|
||||
s.tick(1.1);
|
||||
s.tick(0.1);
|
||||
assert_eq!(
|
||||
num_messages(&s, "MF"),
|
||||
0,
|
||||
"the 6th arg must not extend a message's life"
|
||||
);
|
||||
}
|
||||
|
||||
/// `SetInsertMode` is a MessageFrame method and **only** a MessageFrame method — a plain Frame
|
||||
/// and a ScrollingMessageFrame both resolve it to nil, which is what a duck-typing addon reads.
|
||||
#[test]
|
||||
fn set_insert_mode_is_messageframe_only() {
|
||||
let s = UiScript::new().unwrap();
|
||||
s.run(
|
||||
"CreateFrame('MessageFrame', 'MF')\n\
|
||||
CreateFrame('Frame', 'PlainF')\n\
|
||||
CreateFrame('ScrollingMessageFrame', 'SMF')",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return type(MF.SetInsertMode)").unwrap(),
|
||||
"function"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return type(PlainF.SetInsertMode)")
|
||||
.unwrap(),
|
||||
"nil",
|
||||
"a plain Frame must not quack like a MessageFrame"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return type(SMF.SetInsertMode)").unwrap(),
|
||||
"nil",
|
||||
"the scrolling class has no SetInsertMode binding at all"
|
||||
);
|
||||
// Default BOTTOM (ctor 1), and the round trip.
|
||||
assert_eq!(
|
||||
s.eval::<String>("return MF:GetInsertMode()").unwrap(),
|
||||
"BOTTOM"
|
||||
);
|
||||
s.run("MF:SetInsertMode('TOP')").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return MF:GetInsertMode()").unwrap(),
|
||||
"TOP"
|
||||
);
|
||||
}
|
||||
|
||||
/// The two `AddMessage`s are different functions, and the observable tell is the fifth argument:
|
||||
/// on a MessageFrame it is alpha (0.5 ⇒ a half-transparent line), on a ScrollingMessageFrame it
|
||||
/// is an id and the line stays opaque.
|
||||
#[test]
|
||||
fn messageframe_addmessage_is_not_the_scrolling_ones() {
|
||||
let s = UiScript::new().unwrap();
|
||||
s.run(
|
||||
"CreateFrame('MessageFrame', 'MF')\n\
|
||||
CreateFrame('ScrollingMessageFrame', 'SMF')",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!s.eval::<bool>("return MF.AddMessage == SMF.AddMessage")
|
||||
.unwrap(),
|
||||
"one shared AddMessage would give the wrong meaning to the 5th arg on one of them"
|
||||
);
|
||||
s.run("MF:AddMessage('half', 1, 1, 1, 0.5)").unwrap();
|
||||
s.run("SMF:AddMessage('ident', 1, 1, 1, 42)").unwrap();
|
||||
let alphas = {
|
||||
let m = s.model_ref();
|
||||
let pick = |name: &str| {
|
||||
let h = m.arena.lookup(name).unwrap();
|
||||
m.arena
|
||||
.frame(h)
|
||||
.unwrap()
|
||||
.kind_state
|
||||
.message_lines()
|
||||
.unwrap()[0]
|
||||
.alpha
|
||||
};
|
||||
(pick("MF"), pick("SMF"))
|
||||
};
|
||||
assert!(
|
||||
(alphas.0 - 128.0 / 255.0).abs() < 1e-6,
|
||||
"MessageFrame's 5th arg is alpha, quantized round-half-up: {alphas:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
alphas.1, 1.0,
|
||||
"ScrollingMessageFrame's 5th arg is an id; its alpha is forced opaque"
|
||||
);
|
||||
}
|
||||
|
||||
/// `insertMode` decides which edge the stack grows from: TOP hangs the newest message off the
|
||||
/// frame's top, BOTTOM (the default) stacks up from its bottom. Same three messages, mirrored.
|
||||
#[test]
|
||||
fn insert_mode_picks_the_growth_edge() {
|
||||
let bands = |mode: &str| {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
"local f = CreateFrame('MessageFrame', 'MF')\n\
|
||||
f:SetPoint('BOTTOMLEFT', 0, 100)\n\
|
||||
f:SetWidth(400)\n\
|
||||
f:SetHeight(48)",
|
||||
)
|
||||
.unwrap();
|
||||
s.run(&format!("MF:SetInsertMode('{mode}')")).unwrap();
|
||||
for t in ["one", "two", "three"] {
|
||||
s.run(&format!("MF:AddMessage('{t}', 1, 1, 1)")).unwrap();
|
||||
}
|
||||
s.resolve();
|
||||
let mut v: Vec<(String, f32)> = s
|
||||
.extract()
|
||||
.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();
|
||||
v.sort_by(|a, b| b.1.total_cmp(&a.1)); // top row first
|
||||
v
|
||||
};
|
||||
// Default pitch 14, frame [100, 148).
|
||||
let top = bands("TOP");
|
||||
assert_eq!(
|
||||
top.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(),
|
||||
["three", "two", "one"],
|
||||
"TOP: newest on the frame's top row, older stepping down"
|
||||
);
|
||||
assert!((top[0].1 - 134.0).abs() < 0.01, "newest hangs off fr.top");
|
||||
let bottom = bands("BOTTOM");
|
||||
assert_eq!(
|
||||
bottom.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(),
|
||||
["one", "two", "three"],
|
||||
"BOTTOM: newest on the frame's bottom row, older stepping up"
|
||||
);
|
||||
assert!(
|
||||
(bottom[2].1 - 100.0).abs() < 0.01,
|
||||
"newest sits on fr.bottom"
|
||||
);
|
||||
}
|
||||
|
||||
/// The class has no `maxLines` — its cap is what fits vertically, applied at the tick. A frame
|
||||
/// 3 rows tall holds 3 messages however many arrive.
|
||||
#[test]
|
||||
fn the_cap_is_what_fits_vertically() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
"local f = CreateFrame('MessageFrame', 'MF')\n\
|
||||
f:SetPoint('BOTTOMLEFT', 0, 0)\n\
|
||||
f:SetWidth(400)\n\
|
||||
f:SetHeight(42)\n\
|
||||
f:SetFading(false)",
|
||||
)
|
||||
.unwrap();
|
||||
for n in 0..9 {
|
||||
s.run(&format!("MF:AddMessage('m{n}', 1, 1, 1)")).unwrap();
|
||||
}
|
||||
s.resolve();
|
||||
s.tick(0.1);
|
||||
assert_eq!(num_messages(&s, "MF"), 3, "42px / 14px pitch = 3 rows");
|
||||
// ...and it is the OLDEST that went.
|
||||
s.resolve();
|
||||
let texts: Vec<String> = s
|
||||
.extract()
|
||||
.iter()
|
||||
.filter_map(|q| match &q.content {
|
||||
crate::script::QuadContent::Text { text: Some(t), .. } => Some(t.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(texts.contains(&"m8".to_string()) && !texts.contains(&"m0".to_string()));
|
||||
}
|
||||
|
||||
/// A faded line on this class is **freed**, not left blank holding its rows — the difference
|
||||
/// from the scrolling class, whose ring slots persist. `Clear` is the immediate form.
|
||||
#[test]
|
||||
fn a_finished_line_is_retired_and_clear_empties_now() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.run("CreateFrame('MessageFrame', 'MF')").unwrap();
|
||||
s.run("MF:SetTimeVisible(0.5)").unwrap();
|
||||
s.run("MF:SetFadeDuration(2)").unwrap();
|
||||
s.run("MF:AddMessage('x', 1, 1, 1)").unwrap();
|
||||
s.tick(0.6); // spends phase 1
|
||||
s.tick(1.0); // mid-ramp: still alive, alpha down
|
||||
assert_eq!(num_messages(&s, "MF"), 1);
|
||||
s.tick(1.2); // ramp done → retired
|
||||
assert_eq!(num_messages(&s, "MF"), 0);
|
||||
|
||||
s.run("MF:AddMessage('a', 1, 1, 1)").unwrap();
|
||||
s.run("MF:AddMessage('b', 1, 1, 1)").unwrap();
|
||||
assert_eq!(num_messages(&s, "MF"), 2);
|
||||
s.run("MF:Clear()").unwrap();
|
||||
assert_eq!(num_messages(&s, "MF"), 0);
|
||||
}
|
||||
|
||||
/// The ctor defaults, straight off msgframe-runtime.md's shared-defaults section, plus the
|
||||
/// setters' round trip.
|
||||
#[test]
|
||||
fn ctor_defaults_and_the_fade_accessors() {
|
||||
let s = UiScript::new().unwrap();
|
||||
s.run("CreateFrame('MessageFrame', 'MF')").unwrap();
|
||||
assert!(s.eval::<bool>("return MF:GetFading()").unwrap());
|
||||
assert_eq!(s.eval::<f32>("return MF:GetTimeVisible()").unwrap(), 10.0);
|
||||
assert_eq!(s.eval::<f32>("return MF:GetFadeDuration()").unwrap(), 3.0);
|
||||
s.run("MF:SetTimeVisible(5) MF:SetFadeDuration(1) MF:SetFading(false)")
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<f32>("return MF:GetTimeVisible()").unwrap(), 5.0);
|
||||
assert_eq!(s.eval::<f32>("return MF:GetFadeDuration()").unwrap(), 1.0);
|
||||
assert!(!s.eval::<bool>("return MF:GetFading()").unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
//! 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))?,
|
||||
)?;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user