Files
benilla-octo/crates/benilla-ui/src/script/messageframe/mod.rs
T
SamandClaude Fable 5 a5c44f367e deleting a good item asks in the reference's words
- deleting a rare-or-better item forks to the reference's typed confirm — "Type \"DELETE\" into the field to confirm": Yes starts disabled, the box takes focus, the button enables only while the field spells DELETE, and a reflexive Enter over a half-typed word is inert; the missing engine piece was one hook, the edit box's on-text-changed; beside it, the loot bind-on-pickup confirm — bind-on-pickup AND uncommon-or-better, two conjuncts and no others, so a white BoP drop is taken with no dialog and a bind-on-equip blue never asks here — with the reference's one-pending-slot latch, and the take split into two verbs the way 1.12 actually has it: the row click stays C-side, and LootSlot is only the confirmation's continuation
- the message frames get their justify quartet, read out of the binary's method tables (with the Button table re-derived as the control that lacks it); Frame:EnableDrawLayer/DisableDrawLayer and Region:GetDrawLayer land — the disabled layers are a five-bit mask on the frame, so re-enabling a layer restores exactly what was showing underneath; and the Skills tab's children take the reference's names, with SKILLS_TO_DISPLAY published — pfUI loops over it and got "'for' limit must be a number"
- the README's roadmap line now names what is genuinely big — battlegrounds and meeting stones, both verified still missing — plus the long tail, because the old line named four features that have since landed and small features go stale on a roadmap within a week

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 20:15:38 +07:00

515 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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).
/// Install `SetJustifyH`/`GetJustifyH`/`SetJustifyV`/`GetJustifyV` on a message-frame method
/// table. Both classes carry all four (see [`crate::widget::ScrollingMessageState::justify`] for
/// the method-table bytes that settled it), and the law they obey is
/// [`crate::justify`]'s — shared with FontString, `<Font>` and EditBox since 1237, so the two
/// verified traps cannot drift apart per widget:
///
/// · an **unrecognised token RAISES** `Usage: %s:SetJustifyH("justify")` rather than defaulting;
/// · a token from the **other axis** parses and then masks to nothing — `SetJustifyH("TOP")`
/// CLEARS the horizontal axis, and `GetJustifyH()` answers `"UNKNOWN"` while the glyphs keep
/// drawing at the paint fallback.
///
/// `GetJustify*` reports the EFFECTIVE value — the frame's own once set, else the font object's —
/// so an untouched frame answers what it actually draws with rather than a ctor constant its
/// `<FontString>` may have overridden.
use crate::script::object::frame_handle_of;
pub(super) fn install_justify(
lua: &mlua::Lua,
m: &mlua::Table,
widget: &'static str,
) -> mlua::Result<()> {
use crate::justify::{self, Justify};
fn slot(ks: &mut KindState) -> Option<&mut Option<Justify>> {
match ks {
KindState::ScrollingMessage(s) => Some(&mut s.justify),
KindState::Message(s) => Some(&mut s.justify),
_ => None,
}
}
fn own(ks: &KindState) -> Option<Justify> {
match ks {
KindState::ScrollingMessage(s) => s.justify,
KindState::Message(s) => s.justify,
_ => None,
}
}
for (name, mask, horizontal) in [
("SetJustifyH", justify::H_MASK, true),
("SetJustifyV", justify::V_MASK, false),
] {
m.set(
name,
lua.create_function(move |lua, (this, token): (mlua::Table, mlua::Value)| {
let parsed = token
.as_string()
.and_then(|t| t.to_str().ok().and_then(|t| justify::parse_bits(&t)))
.ok_or_else(|| {
if horizontal {
justify::usage_h(widget)
} else {
justify::usage_v(widget)
}
})?;
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"))?;
let cur = own(&frame.kind_state).unwrap_or_default();
let next = Justify(justify::set_axis(cur.0, mask, parsed));
*slot(&mut frame.kind_state)
.ok_or_else(|| mlua::Error::runtime("not a message frame"))? = Some(next);
Ok(())
})?,
)?;
}
// The two getters differ ONLY in what an untouched frame falls back to, and the asymmetry is
// real rather than an oversight: horizontally there IS another source — the font object the
// lines actually draw with — so reporting anything else would have `GetJustifyH` disagree with
// the glyphs on our own chat window, which declares `justifyH="LEFT"` on its `<FontString>`.
// Vertically there is no such source: `emit_message_lines` pins each line to the top of its own
// band deliberately (the band, not a FontString rect, owns the vertical rhythm), so an untouched
// frame reports the ctor default rather than a value invented from our band math.
m.set(
"GetJustifyH",
lua.create_function(move |lua, this: mlua::Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let bits = match model.arena.frame(h).and_then(|f| own(&f.kind_state)) {
Some(j) => j.0,
None => {
let font = UiScript::message_frame_font(&model, h);
justify::parse_bits(justify::name_h(font.justify_h)).unwrap_or(0)
}
};
Ok(justify::name_of(bits, justify::H_MASK).to_string())
})?,
)?;
m.set(
"GetJustifyV",
lua.create_function(move |lua, this: mlua::Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let j = model
.arena
.frame(h)
.and_then(|f| own(&f.kind_state))
.unwrap_or_default();
Ok(justify::name_of(j.0, justify::V_MASK).to_string())
})?,
)?;
Ok(())
}
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,
}
/// The FontString region a message frame's **style** lives on — found, or created on demand.
///
/// Both classes draw their lines through one font instance, and [`UiScript::message_frame_font`]
/// reads it off the frame's first `FontString` region. That region normally comes from the
/// declared direct-child `<FontString>` (LoadXML's "special" font string — our ChatFrame has one).
/// A frame built by `CreateFrame("MessageFrame")` has none, so the ten shared font-block verbs
/// need somewhere to write: this is that somewhere, and it is created **only** when one of them is
/// actually called, so a frame nobody styles never grows a region.
///
/// **It is seated LEFT deliberately.** A frame with no declared FontString falls back to LEFT (the
/// chat shape) per [`MessageFont::justify_h`]'s law — but a freshly created `RegionData` defaults
/// to the *FontString* default of CENTER. Creating the region at the default would silently
/// re-justify a frame the moment an addon touched its font, which is a visible change nobody
/// asked for.
pub(super) fn ensure_font_region(
lua: &mlua::Lua,
fh: FrameHandle,
) -> Option<crate::widget::RegionHandle> {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let existing = 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)
)
})
.copied()
});
if existing.is_some() {
return existing;
}
let rh = model.arena.create_region(
fh,
crate::widget::RegionKind::FontString,
crate::order::DrawLayer::Overlay,
0,
)?;
let mut data = crate::script::RegionData::default();
data.justify.set_h(JustifyH::Left);
model.region_data.insert(rh, data);
model.touch_layout();
Some(rh)
}
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.paint_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();
// A plain reborrow so the arena borrow inside the loop and the sweep-token field writes
// split by FIELD instead of fighting over the RefMut.
let model = &mut *model;
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;
};
// The skip token (the W4 fix, 1410's lane one door over): a frame whose line set
// (lines_gen) and measure environment (font/wrap/scale/outline) both match its last
// CLEAN sweep can produce nothing — every line's rows_key was minted under exactly
// these inputs and matched then. Environment changes (a resize, a font swap, a
// SetScale) miss the env hash; text changes miss the generation. The token is only
// stored by a zero-request sweep (below), so an unanswered request keeps
// re-requesting — the same rule the region ledger runs under.
let lines_gen = frame.kind_state.lines_gen().unwrap_or(0);
let env = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
font.path.hash(&mut h);
font.height.map(f32::to_bits).hash(&mut h);
wrap_width.to_bits().hash(&mut h);
(font.outline as u8).hash(&mut h);
scale.to_bits().hash(&mut h);
h.finish()
};
if model.msg_swept.get(&fh) == Some(&(lines_gen, env)) {
continue;
}
let requests_before = out.len();
for (index, line) in lines.iter().enumerate() {
model.msg_lines_hashed += 1;
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,
});
}
if out.len() == requests_before {
model.msg_swept.insert(fh, (lines_gen, env));
} else {
model.msg_swept.remove(&fh);
}
}
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);
let own_justify = match &frame.kind_state {
KindState::ScrollingMessage(s) => s.justify,
KindState::Message(s) => s.justify,
_ => None,
};
// 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,
]),
// The frame's own justify wins once SetJustifyH has been called; until then
// the font object's governs, which is what `justifyH=` on the frame's
// `<FontString>` set and what this drew with before the verbs existed.
justify_h: match own_justify {
Some(j) => j.paint_h(),
None => 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,
});
}
}
}