SetFont gates as 1.12.1 does; SetSpacing on every text table (#946)

`SetFont` raised on a `false` flags argument and wrote the height even when the load failed, and only SimpleHTML had `SetSpacing`/`GetSpacing`, so SpellAlert's message frame raised in its OnLoad.

In 1.12.1 `SetFont` (`0x79f210`, the FontString's `0x79d4f0`) reads flags only when `lua_isstring` (`0x79f287`), writes them on every successful load (`0x79f334`), and writes nothing on a failed load or a height ≤ 0 (`0x79f361`, `0x79f2f4`, `0x771571`). Every text table has the spacing pair (`0x79fb40`/`0x79fbe0`; the FontString's clamps at `0x772240`); the line step adds the gap rounded up to a device pixel (`0x5c2070`, `0x5cdc20`), as do the message frames (`0x78899e`, `0x785f06`) and EditBox rows (`0x77d133`).

All seven SetFont bindings share these gates and ask the host's font probe; spacing is stored, inherited, measured and drawn on all six tables.

Tests bite on the parent for each.

Fixes #873
Fixes #692
Fixes #945

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-12 05:13:09 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 87bf5904ff
commit 75edfd7ca5
37 changed files with 1434 additions and 288 deletions
@@ -259,6 +259,7 @@ pub(crate) fn float_combat_text(
path: font.as_ref().and_then(|f| f.0.as_deref()),
height: Some(target_px),
outline: Outline::None,
spacing: 0.0, // one line
alpha_gradient: None,
},
// A world overlay with its own seat; the UI grid does not apply.
+1
View File
@@ -309,6 +309,7 @@ fn build_name_mesh(atlas: &mut UiFontAtlas, lines: &[String]) -> Mesh {
path: None, // UNIT_NAME_FONT = Friz Quadrata, the default face
height: Some(BAKE_PX),
outline: Outline::None,
spacing: 0.0, // one line
alpha_gradient: None,
},
// A world billboard: the glyphs are re-seated in pitch units below, off the UI grid.
+1
View File
@@ -200,6 +200,7 @@ pub(super) fn pill_quads(
path: None,
height: Some(PILL_QUAD_PX),
outline: Outline::None,
spacing: 0.0,
alpha_gradient: None,
},
// Our own dev overlay: measured off a degenerate rect, then shifted into the pill.
@@ -550,6 +550,7 @@ pub(super) fn tick_script(
path: r.font.as_deref(),
height: crate::ui_text::drawn_px(r.height, None, s * r.scale),
outline: r.outline, // the outline biases the stepped width
spacing: 0.0, // the gap moves rows, never counts them
alpha_gradient: None,
},
);
@@ -1414,6 +1415,7 @@ fn convert_entry(
text_height,
shadow,
outline,
spacing,
alpha_gradient,
world_seat,
} => {
@@ -1433,6 +1435,7 @@ fn convert_entry(
text_height,
shadow,
outline,
spacing,
alpha_gradient,
world_seat,
},
@@ -25,6 +25,8 @@ pub(super) struct TextStyle {
pub text_height: Option<f32>,
pub shadow: Option<FontShadow>,
pub outline: Outline,
/// The gap between lines, frame-local, scaled like the font height.
pub spacing: f32,
pub alpha_gradient: Option<(f32, f32)>,
/// The V-plate's name and level, seated on the plate's device-snapped overlay, not the UI grid.
pub world_seat: bool,
@@ -76,6 +78,7 @@ pub(super) fn emit(
host.scale * host.font_scale,
),
outline: style.outline,
spacing: style.spacing * host.scale * host.font_scale,
alpha_gradient: style.alpha_gradient,
};
// The focused editbox draws windowed (`0x77da80`): the scroll window's substring from the
@@ -24,6 +24,16 @@ pub(super) struct Resolved {
pub(super) radius: u8,
/// The step law's per-glyph bias ([`super::step_extra_of`]).
pub(super) step_extra: f32,
/// The line gap, whole device pixels in logical px ([`super::line_gap`]).
pub(super) gap: f32,
}
/// `n` lines' height, `n·S + (n−1)·gap` (`0x5c2070`); 0 for none.
pub(super) fn block_height(n: usize, r: Resolved) -> f32 {
if n == 0 {
return 0.0;
}
n as f32 * r.size + (n - 1) as f32 * r.gap
}
/// Resolve a spec without touching the caches ([`TextEngine::ensure_metrics`] covers the text).
@@ -36,6 +46,7 @@ pub(super) fn resolve(e: &mut TextEngine, font: &FontSpec) -> Resolved {
size: e.logical_size(ppem),
radius: super::super::outline::radius_of(font.outline),
step_extra: super::step_extra_of(font.outline),
gap: super::line_gap(font.spacing, e.dpi()),
}
}
@@ -70,6 +81,7 @@ pub(crate) fn measure_line_width_for_test(
size: e.logical_size(ppem),
radius: 0,
step_extra,
gap: 0.0,
};
measure_line_width(e, r, step_extra, text)
}
@@ -131,9 +143,9 @@ pub(crate) fn measure_text(
}
// Deviation: one pixel of headroom over the reference's measured width, kept because dropping
// it narrows every auto-sized FontString, a look change.
// Height: N lines at the font em with no outline pad (`0x5c2070`: `N·S + (N-1)·gap`, gap 0 in
// shipped UI); an outlined ring pokes past it, as in the reference.
(max_w.ceil() + 1.0, render_lines.len() as f32 * r.size)
// Height: N lines at the font em with no outline pad, the gap between them (`0x5c2070`:
// `N·S + (N-1)·gap`); an outlined ring pokes past it, as in the reference.
(max_w.ceil() + 1.0, block_height(render_lines.len(), r))
}
/// How many rows `text` wraps into at `wrap_width`, by the render's own [`wrap_line`] pass: the
@@ -232,8 +244,8 @@ pub(crate) fn ellipsize_to_fit(
e.ensure_metrics(r.face, r.ppem, "...");
let e = &*e;
// Overflow is decided one row past the box, the reference's bound (`wrapped_rows_capped`).
let cap = overflow::lines_fitting(box_h.max(r.size), r.size) + 1;
overflow::ellipsize_in_box(text, box_h, r.size, |candidate| {
let cap = overflow::lines_fitting(box_h.max(r.size + r.gap), r.size, r.gap) + 1;
overflow::ellipsize_in_box(text, box_h, r.size, r.gap, |candidate| {
wrapped_rows_capped(e, r, candidate, box_w, cap)
})
};
@@ -323,8 +335,8 @@ pub(crate) fn line_advances(e: &mut TextEngine, text: &str, font: FontSpec) -> V
}
/// The multiline EditBox row table: the raw byte where each wrapped row of `text` starts at
/// `wrap_width`, by the render's own [`wrap_line`] pass, and the row pitch (the font em). Never
/// empty (`[0]` for empty text).
/// `wrap_width`, by the render's own [`wrap_line`] pass, and the row pitch, the font em plus the
/// line gap (`0x77d133`-`0x77d161`). Never empty (`[0]` for empty text).
pub(crate) fn line_rows(
e: &mut TextEngine,
text: &str,
@@ -352,7 +364,7 @@ pub(crate) fn line_rows(
if rows.is_empty() {
rows.push(0);
}
(rows, r.size)
(rows, r.size + r.gap)
}
/// [`line_rows`]'s walk for one `\n` segment at `base`: each row starts past the previous row's
+91 -13
View File
@@ -57,6 +57,9 @@ pub(crate) struct FontSpec<'a> {
/// The glyph outline: it picks the ring-and-fill cell and, for THICK only, the step law's
/// extra `+1` (`GlyphStepBase` `0x5ca2b0`), so it changes measured width too.
pub outline: Outline,
/// The gap between lines in logical px, scaled like [`Self::height`]; the line step adds it
/// rounded up to a whole device pixel ([`line_gap`]).
pub spacing: f32,
/// The `SetAlphaGradient` reveal `(start, length)`; on the spec, shadows fade with their fill.
pub alpha_gradient: Option<(f32, f32)>,
}
@@ -80,6 +83,17 @@ fn step_extra_of(outline: Outline) -> f32 {
}
}
/// The line gap a `spacing` of logical px adds to the line step: whole device pixels, rounded up by
/// the reference's `+0.99999` truncation (`LayoutLines` `0x5cdce4`, the measure `0x5c214b`, the fit
/// walk `0x5c225a`), and none for a spacing of 0 or less.
fn line_gap(spacing: f32, dpi: f32) -> f32 {
if spacing > 0.0 {
(spacing * dpi + 0.999_99).trunc() / dpi
} else {
0.0
}
}
/// The block top a UI `FontString` takes: the reference's snap plus [`UI_SEAT_NUDGE`].
fn snap_block_top(y: f32) -> f32 {
snap_block_top_law(y) + UI_SEAT_NUDGE
@@ -196,16 +210,17 @@ fn layout_text_quads_inner(
};
// The line stack (`CGxString+0x40` in `0x5cdc20`): a height-pinned rect stops emitting lines
// once the pitch sum passes it, even for a caller that skips the ellipsis seam.
// The line step (`LayoutLines` `0x5cdc20`): `px(size) + px(spacing)`.
let pitch = r.size + r.gap;
if rect.height() > HEIGHT_LIMIT_MIN {
render_lines.truncate(overflow::lines_allowed(rect.height(), r.size));
render_lines.truncate(overflow::lines_allowed(rect.height(), pitch));
}
let mut quads = Vec::new();
// `justifyV` seats the block in `rect` at a pitch of the font height (`LayoutLines` `0x5cdc20`:
// `px(size) + spacing`, spacing 0 in shipped UI), so `h = N·S`: the outline's `+2r` is only in
// the atlas cell (`[font+0x178]`), never the block. A degenerate rect keeps the top.
let pitch = r.size;
let block_h = render_lines.len() as f32 * pitch;
// `justifyV` seats the block in `rect` (`0x5cdf70`): `h = N·S + (N−1)·gap`, the outline's
// `+2r` only in the atlas cell (`[font+0x178]`), never the block. A degenerate rect keeps the
// top.
let block_h = measure::block_height(render_lines.len(), r);
let v_offset = if rect.height() > f32::EPSILON {
match justify.v {
JustifyV::Top => 0.0,
@@ -415,6 +430,7 @@ mod measure_fits_render {
path: Some(FACE),
height: Some(h),
outline: Outline::None,
spacing: 0.0,
alpha_gradient: None,
}
}
@@ -493,11 +509,12 @@ mod measure_fits_render {
"the row's 10-tall title box stacks exactly one line"
);
// The ellipsis: the drawn string is a prefix plus "..." that fits that line.
let cap_rows = overflow::lines_fitting(BOX_H.max(r.size), r.size) + 1;
let display = overflow::ellipsize_in_box(title, BOX_H, r.size, |candidate| {
measure::wrapped_rows_capped_for_test(&e, r, candidate, cap, cap_rows)
})
.unwrap_or_else(|| panic!("{title:?} at cap {cap} must ellipsize"));
let cap_rows = overflow::lines_fitting(BOX_H.max(r.size), r.size, r.gap) + 1;
let display =
overflow::ellipsize_in_box(title, BOX_H, r.size, r.gap, |candidate| {
measure::wrapped_rows_capped_for_test(&e, r, candidate, cap, cap_rows)
})
.unwrap_or_else(|| panic!("{title:?} at cap {cap} must ellipsize"));
assert!(
display.ends_with("..."),
"the truncation marker is three dots, got {display:?}"
@@ -563,6 +580,66 @@ mod measure_fits_render {
);
}
/// The draw steps each line by the font height plus the gap rounded up to a whole device pixel
/// (`LayoutLines` `0x5cdc20`), and a MIDDLE block seats `N·S + (N−1)·gap` (`0x5cdf70`).
#[test]
fn the_line_gap_steps_each_drawn_line_by_whole_device_pixels() {
let Some(mut e) = test_engine(1.0) else {
eprintln!("skipping: no install / font chain");
return;
};
// Each line's ink top: the topmost quad among those of its own glyphs.
let tops = |e: &mut TextEngine, spacing: f32, v: JustifyV| -> Vec<f32> {
let s = FontSpec {
spacing,
..spec(12.0)
};
let rect = Rect::new(10.0, 20.0, 400.0, 220.0);
let quads = layout_text_quads(
e,
"H\nH\nH",
rect,
[1.0; 4],
Justify {
h: JustifyH::Left,
v,
},
0,
s,
TextSeat::UiGrid,
);
let mut y: Vec<f32> = quads.iter().map(|q| q.rect.min.y).collect();
y.sort_by(f32::total_cmp);
y.dedup_by(|a, b| (*a - *b).abs() < 1e-3);
y
};
for (dpi, spacing, gap) in [(1.0f32, 4.0f32, 4.0f32), (1.0, 2.5, 3.0), (2.0, 2.25, 2.5)] {
e.set_dpi_for_test(dpi);
let flush = tops(&mut e, 0.0, JustifyV::Top);
let spaced = tops(&mut e, spacing, JustifyV::Top);
assert_eq!(flush.len(), 3, "three lines at dpi {dpi}: {flush:?}");
assert_eq!(spaced.len(), 3, "three lines at dpi {dpi}: {spaced:?}");
for (i, (a, b)) in flush.iter().zip(&spaced).enumerate() {
let want = i as f32 * gap;
assert!(
(b - a - want).abs() < 1e-3,
"dpi {dpi}, spacing {spacing}: line {i} moved {} not {want}",
b - a
);
}
// A negative spacing draws flush.
assert_eq!(tops(&mut e, -6.0, JustifyV::Top), flush, "dpi {dpi}");
}
// MIDDLE: two gaps more of block height lift the first line by one gap.
e.set_dpi_for_test(1.0);
let flush = tops(&mut e, 0.0, JustifyV::Middle);
let spaced = tops(&mut e, 4.0, JustifyV::Middle);
assert!(
(flush[0] - spaced[0] - 4.0).abs() < 1e-3,
"{flush:?} {spaced:?}"
);
}
/// One line is one baseline at every DPI, size and seat, where rounding each glyph's own sum
/// splits half-pixel ties by bearing at a fractional DPI. FRIZQT's hinted cells all end on the
/// baseline (`px_h == bearing_top` at every ppem 8..64), so one baseline is one `rect.max.y`.
@@ -767,10 +844,11 @@ mod ellipsis_cost {
path: Some(FACE),
height: Some(SIZE),
outline: Outline::None,
spacing: 0.0,
alpha_gradient: None,
};
let r = measure::resolve(&mut e, &s);
let fits = overflow::lines_fitting(BOX_H, r.size);
let fits = overflow::lines_fitting(BOX_H, r.size, r.gap);
for (label, page) in [("html 2676", PAGE), ("plain 2880", PLAIN)] {
let text = page_body(page);
@@ -789,7 +867,7 @@ mod ellipsis_cost {
let probes = Cell::new(0usize);
let chars = Cell::new(0usize);
let t = Instant::now();
let got = overflow::ellipsize_in_box(&text, BOX_H, r.size, |candidate| {
let got = overflow::ellipsize_in_box(&text, BOX_H, r.size, r.gap, |candidate| {
probes.set(probes.get() + 1);
chars.set(chars.get() + candidate.chars().count());
measure::wrapped_rows_capped_for_test(&e, r, candidate, BOX_W, cap)
@@ -10,34 +10,35 @@ const ELLIPSIS: &str = "...";
const HEIGHT_EPS: f32 = 0.25;
/// How many wrapped lines a `box_h`-tall box draws, the reference's line stack (`0x5cdc20`: stop
/// at `accum ≥ +0x40`): the smallest `n` with `n·pitch ≥ box_h`, never 0, `pitch` being the font
/// height. The render law: fitting by it lets a four-line name overflow a three-line box.
/// at `accum ≥ +0x40`, `accum` gaining `size + gap` a line): the smallest `n` with `n·pitch ≥
/// box_h`, never 0, `pitch` being the font height plus the line gap. The render law: fitting by it
/// lets a four-line name overflow a three-line box.
pub(super) fn lines_allowed(box_h: f32, pitch: f32) -> usize {
(((box_h - HEIGHT_EPS) / pitch).ceil() as usize).max(1)
}
/// How many wrapped lines fit wholly inside a `box_h`-tall box, the ellipsis's law:
/// `GxuFont_GetMaxCharsWithinHeight` (`0x5c21c0`, via `0x44d960`) stops when
/// `boxH + 2⁻²⁰ < accumH + lineH`, the largest `n` with `n·pitch ≤ box_h`, so it floors where
/// [`lines_allowed`] ceils. Its 0 under one line is never acted on ([`ellipsize_in_box`]). The
/// slack is [`HEIGHT_EPS`], not `2⁻²⁰`: `box_h` is anchor-graph arithmetic, and `12.0 - 1e-6`
/// must not lose a line.
pub(super) fn lines_fitting(box_h: f32, pitch: f32) -> usize {
((box_h + HEIGHT_EPS) / pitch).floor().max(0.0) as usize
/// `boxH + 2⁻²⁰ < accumH + lineH`, `accumH` gaining `lineH + gap` a line (`0x5c22da`), so the
/// largest `n` with `n·size + (n−1)·gap ≤ box_h`: it floors where [`lines_allowed`] ceils. Its 0
/// under one line is never acted on ([`ellipsize_in_box`]). The slack is [`HEIGHT_EPS`], not
/// `2⁻²⁰`: `box_h` is anchor-graph arithmetic, and `12.0 - 1e-6` must not lose a line.
pub(super) fn lines_fitting(box_h: f32, size: f32, gap: f32) -> usize {
((box_h + gap + HEIGHT_EPS) / (size + gap)).floor().max(0.0) as usize
}
/// [`ellipsize`] by the fit law ([`lines_fitting`]), chosen here under test as
/// [`super::ellipsize_to_fit`] needs a real atlas. The box first clamps up to one pitch, as
/// `0x771ec0` does with `maxLines` 0 (`boxH := max(boxH, lineH+gap)`, `0x771f9e..0x771faa`, gap 0
/// in shipped UI), so a box under one line still draws it, like the 36×10 action-button hotkey
/// under a 12 px font.
/// `0x771ec0` does with `maxLines` 0 (`boxH := max(boxH, lineH+gap)`, `0x771f9e..0x771faa`), so a
/// box under one line still draws it, like the 36×10 action-button hotkey under a 12 px font.
pub(super) fn ellipsize_in_box<F: FnMut(&str) -> usize>(
text: &str,
box_h: f32,
pitch: f32,
size: f32,
gap: f32,
rows: F,
) -> Option<String> {
ellipsize(text, lines_fitting(box_h.max(pitch), pitch), rows)
ellipsize(text, lines_fitting(box_h.max(size + gap), size, gap), rows)
}
/// The height-gated ellipsis truncate (`0x771ec0`): past `allowed` rows, back off one char at a
@@ -84,22 +85,45 @@ mod overflow_tests {
#[test]
fn lines_fitting_is_the_height_fit_law() {
// The loot row's 38-tall item name: 4 lines drawn, 3 fit.
assert_eq!(lines_fitting(38.0, 12.0), 3);
assert_eq!(lines_fitting(38.0, 12.0, 0.0), 3);
assert_eq!(lines_allowed(38.0, 12.0), 4, "the render law, for contrast");
// The laws agree on every exact multiple.
for n in 1..=6 {
let h = 12.0 * n as f32;
assert_eq!(lines_fitting(h, 12.0), n, "exact multiple {h}");
assert_eq!(lines_fitting(h, 12.0, 0.0), n, "exact multiple {h}");
assert_eq!(lines_allowed(h, 12.0), n, "exact multiple {h}");
}
// Under one line: fit 0, which `ellipsize_in_box` clamps away, render 1.
assert_eq!(lines_fitting(6.0, 12.0), 0);
assert_eq!(lines_fitting(6.0, 12.0, 0.0), 0);
assert_eq!(lines_allowed(6.0, 12.0), 1);
// Float noise on a multiple costs no line.
assert_eq!(lines_fitting(24.0 - 1e-4, 12.0), 2);
assert_eq!(lines_fitting(24.0 - 1e-4, 12.0, 0.0), 2);
}
/// A line gap counts between lines only: `n·size + (n−1)·gap` against the box for the fit,
/// `n·(size + gap)` for the render stack.
#[test]
fn the_line_gap_sits_between_lines() {
// 12 px lines 4 apart: 2 lines are 28 tall, 3 are 44.
assert_eq!(lines_fitting(28.0, 12.0, 4.0), 2);
assert_eq!(lines_fitting(43.0, 12.0, 4.0), 2);
assert_eq!(lines_fitting(44.0, 12.0, 4.0), 3);
// The stack starts line `k` while `k·16 < box_h`.
assert_eq!(lines_allowed(32.0, 16.0), 2);
assert_eq!(lines_allowed(33.0, 16.0), 3);
// The clamp is one pitch, gap included, which still fits one line.
assert_eq!(
ellipsize_in_box("Small Brown Pouch", 10.0, 12.0, 4.0, rows10).as_deref(),
Some("Small B...")
);
assert_eq!(
ellipsize_in_box("Small Brown Pouch", 28.0, 12.0, 4.0, rows10),
None,
"two rows fit a 28 px box at 12 px lines 4 apart"
);
}
/// A stub row count: a 10-char-wide box, `ceil(chars / 10)`.
@@ -115,7 +139,7 @@ mod overflow_tests {
assert_eq!(rows10(LONG), 4, "the string that started this");
assert_eq!(
ellipsize_in_box(LONG, 38.0, 12.0, rows10).as_deref(),
ellipsize_in_box(LONG, 38.0, 12.0, 0.0, rows10).as_deref(),
Some("Schematic: Small Seaforium ..."),
"the fit law allows 3 lines, so it truncates"
);
@@ -156,12 +180,12 @@ mod overflow_tests {
#[test]
fn sub_one_line_boxes_render_their_single_line() {
// The money purse number: "145" in the 20×13 box at NumberFontNormal's 14px pitch.
assert_eq!(ellipsize_in_box("145", 13.0, 14.0, rows10), None);
assert_eq!(ellipsize_in_box("145", 13.0, 14.0, 0.0, rows10), None);
// The action-button hotkey: "1" in the 36×10 box at NumberFontNormalSmallGray's 12px.
assert_eq!(ellipsize_in_box("1", 10.0, 12.0, rows10), None);
assert_eq!(ellipsize_in_box("1", 10.0, 12.0, 0.0, rows10), None);
// The clamp admits one line only: longer text truncates to a one-line prefix.
assert_eq!(
ellipsize_in_box("Small Brown Pouch", 10.0, 12.0, rows10).as_deref(),
ellipsize_in_box("Small Brown Pouch", 10.0, 12.0, 0.0, rows10).as_deref(),
Some("Small B...")
);
}
@@ -27,6 +27,8 @@ pub(crate) fn measure_request(
height: super::drawn_px(r.height, r.text_height, rs),
// THICK adds 1 px per glyph to the step law (`0x5ca2b0`), so the measure needs it.
outline: r.outline,
// The gap between wrapped lines, scaled as the height is (`0x5c2070`).
spacing: r.spacing * rs,
alpha_gradient: None, // alpha never changes metrics
};
let wrap = r.wrap_width.map(|w| w * rs);
@@ -54,6 +56,8 @@ pub(crate) fn editbox_advances(
path: req.font.as_deref(),
height: super::drawn_px(req.height, None, seam * req.scale),
outline: req.outline,
// A multiline box's row pitch adds the gap (`0x77d161`).
spacing: req.spacing * seam * req.scale,
alpha_gradient: None, // alpha never changes metrics
};
let cum = super::line_advances(e, &req.text, spec)
@@ -151,4 +155,48 @@ mod tests {
"the first character on the new line: {two} against {one}"
);
}
/// A wrapped FontString is `N·S + (N−1)·gap` tall (`0x5c2070`), the gap being its spacing
/// rounded up to a whole device pixel (`0x5c2146`-`0x5c215c`), and a negative spacing 0, as the
/// FontString's setter clamps it (`0x772240`).
#[test]
fn a_wrapped_fontstring_is_its_lines_plus_the_gaps_between_them() {
benilla_formats::wow_data_or_skip!();
let engine = super::super::engine::test_engine(1.0).expect("the client faces open");
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.set_text_measurer(Box::new(AtlasMeasurer::new(
Arc::new(Mutex::new(engine)),
1.0,
)));
s.run(
r#"
FS = CreateFrame("Frame", "GapHost"):CreateFontString("GapText")
FS:SetPoint("TOPLEFT", 10, -10)
FS:SetWidth(60)
FS:SetFont("Fonts\\FRIZQT__.TTF", 12)
FS:SetText("one two three four five six")
"#,
)
.unwrap();
let mut height = |spacing: f32| {
s.run(&format!("FS:SetSpacing({spacing})")).unwrap();
s.tick(0.016);
s.resolve();
s.eval::<f32>("return FS:GetHeight()").unwrap()
};
let flush = height(0.0);
let lines = (flush / 12.0).round();
assert!(
lines >= 3.0 && (flush - lines * 12.0).abs() < 1e-4,
"the text wraps to whole 12 px lines: {flush}"
);
let gaps = lines - 1.0;
assert!((height(4.0) - (flush + gaps * 4.0)).abs() < 1e-4);
assert!(
(height(2.5) - (flush + gaps * 3.0)).abs() < 1e-4,
"the gap rounds up to the device pixel"
);
assert!((height(-5.0) - flush).abs() < 1e-4, "a negative gap is 0");
}
}
+12 -6
View File
@@ -82,15 +82,16 @@ mod cap_tests {
}
}
// Line pitch has no line-height factor: the reference's `LayoutLines` (`0x5cdc20`) steps
// `px(size) + spacing`, XML `spacing` defaulting to 0. Cosmic buffers take `size` as their line
// `px(size) + px(spacing)`, the spacing defaulting to 0. Cosmic buffers take `size` as their line
// height too, unused: every run shapes single-line (`Wrap::None`).
/// The FontString display string, the ellipsis seam's answer, remembered per region. The reference
/// builds it once (`0x771ec0`, into `CGxString+0xf8`) and rebuilds it only when the guard
/// `[fontstring+0x60] & 1` is cleared: by `SetFont` (`0x7715e0`), `SetTextHeight` (`0x771666`),
/// `SetText` (`0x771ea4`) or a resolved-rect size change (`0x768d20`); nothing per frame reaches
/// `0x771ec0` or `0x7724a0`. This compares those inputs instead of keeping a dirty flag, sizes at
/// the reference's `1e-5`, and not position, as a move rebuilds nothing.
/// `SetText` (`0x771ea4`) or a resolved-rect size change (`0x768d20`), and a spacing change
/// rebuilds at once (`0x77228f` → `0x7724a0`); nothing per frame reaches `0x771ec0` or `0x7724a0`.
/// This compares those inputs instead of keeping a dirty flag, sizes at the reference's `1e-5`, and
/// not position, as a move rebuilds nothing.
#[derive(Default)]
pub(crate) struct EllipsisMemo {
entries: std::collections::HashMap<RegionHandle, Remembered>,
@@ -104,6 +105,8 @@ struct Remembered {
font: Option<String>,
height: Option<f32>,
outline: Outline,
/// The line gap moves the fit, so it moves the answer.
spacing: f32,
/// The answer; `None` means the text fits and draws raw, also worth remembering.
display: Option<String>,
}
@@ -131,8 +134,9 @@ impl EllipsisMemo {
&& (e.box_h - box_h).abs() < RECT_EPS
&& e.font.as_deref() == font.path
&& e.height == font.height
&& e.outline == font.outline)
.then_some(&e.display)
&& e.outline == font.outline
&& e.spacing.to_bits() == font.spacing.to_bits())
.then_some(&e.display)
}
fn put(
@@ -156,6 +160,7 @@ impl EllipsisMemo {
font: font.path.map(str::to_string),
height: font.height,
outline: font.outline,
spacing: font.spacing,
display,
},
);
@@ -178,6 +183,7 @@ mod ellipsis_memo_tests {
path,
height,
outline,
spacing: 0.0,
alpha_gradient: None,
}
}
+54
View File
@@ -545,6 +545,44 @@ pub fn resolve_name(raw: &str, parent_name: &str) -> String {
}
}
/// C's `atof` (`0x64aaa0`), which the reference reads numeric attributes with (SimpleHTML's
/// `width=` and `height=`, `spacing=`): the longest numeric prefix after leading whitespace, 0 when
/// there is none, so `"12px"` is 12.
pub(crate) fn atof(s: &str) -> f32 {
let b = s.as_bytes();
let mut i = 0;
while i < b.len() && (b[i] as char).is_ascii_whitespace() {
i += 1;
}
let start = i;
if i < b.len() && (b[i] == b'+' || b[i] == b'-') {
i += 1;
}
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
if i < b.len() && b[i] == b'.' {
i += 1;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
}
// An exponent counts only when complete: `"1e"` is 1, as `strtod` backtracks.
if i < b.len() && (b[i] | 0x20) == b'e' {
let mut j = i + 1;
if j < b.len() && (b[j] == b'+' || b[j] == b'-') {
j += 1;
}
if j < b.len() && b[j].is_ascii_digit() {
while j < b.len() && b[j].is_ascii_digit() {
j += 1;
}
i = j;
}
}
s[start..i].parse::<f32>().unwrap_or(0.0)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -862,3 +900,19 @@ print(x)</Script>
assert_eq!(grandchild_name, "PlayerFrameHealthBarText");
}
}
#[cfg(test)]
mod atof_tests {
use super::atof;
#[test]
fn atof_takes_the_numeric_prefix() {
assert_eq!(atof("64"), 64.0);
assert_eq!(atof(" 12.5px"), 12.5);
assert_eq!(atof("-3"), -3.0);
assert_eq!(atof("px"), 0.0);
assert_eq!(atof(""), 0.0);
assert_eq!(atof("1e2"), 100.0);
assert_eq!(atof("1e"), 1.0);
}
}
+6
View File
@@ -933,6 +933,12 @@ pub(super) fn font_object_from_element(el: &Element) -> FontObject {
justify_h,
justify_v,
shadow,
// `spacing=` (`0x783ee0`, read with `atof` when not empty), inherited down the flattened
// chain like the other attributes.
spacing: el
.attr("spacing")
.filter(|v| !v.is_empty())
.map(crate::framexml::atof),
}
}
+6 -1
View File
@@ -7,7 +7,7 @@ use super::{abs_dim, abs_value, children_named, color_of, tex_coords_of, Loader}
/// Whether a region reads its own font attributes, the reference's `FONTSTRING+0x12c`: set by the
/// ctor (`0x770de7`) and cleared only on a Button's `<NormalText>` (`0x778b7b`), whose string then
/// skips them (`0x7710e1`-`0x771467`) for the button's Normal font to take (`0x783c30`). Here it
/// gates only justify, the one font attribute the button's label pass applies.
/// gates justify and spacing, the font attributes the button's label pass applies.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum FontAttrs {
/// Every other region, a Button's `<ButtonText>` included.
@@ -213,6 +213,11 @@ impl Loader<'_> {
if let Some(j) = region.attr("justifyV") {
self.call_region(wrapper, "SetJustifyV", j.to_string(), dbg);
}
// `spacing=` (`0x771257`) goes through the setter and clears inherit bit `0x10`
// (`0x771298`), so it outlasts the font object's.
if let Some(sp) = region.attr("spacing").filter(|v| !v.is_empty()) {
self.call_region(wrapper, "SetSpacing", crate::framexml::atof(sp), dbg);
}
}
if region.attr_bool("setAllPoints") {
self.call_region(wrapper, "SetAllPoints", (), dbg);
+14 -22
View File
@@ -6,7 +6,7 @@
use mlua::{Lua, MultiValue, ObjectLike, Table, Value};
use super::object::{as_f32, frame_handle_of};
use super::object::frame_handle_of;
use super::region::region_wrapper;
use super::{event, JustifyH, Model, RegionData};
use crate::justify::Justify;
@@ -697,30 +697,22 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// SetFont(file, height [, flags]) (`0x780880`) sets all three embedded fonts and returns
// nothing, discarding the shared `0x79f210`'s load result. It never touches the label
// (`+0x338`), so creates none: extract applies the stored font to whatever label exists. Its
// checks are 5.0's coercing `lua_isstring`/`lua_isnumber`, else a usage error (`0x87c69c`).
// (`+0x338`), so creates none: extract applies the stored font to whatever label exists. The
// arguments pass the shared gate, which names the button in its usage line.
m.set(
"SetFont",
lua.create_function(
|lua, (this, file, height, flags): (Table, Value, Value, Option<String>)| {
let usage = || {
mlua::Error::runtime(
"Usage: <Button>:SetFont(\"font\", fontHeight [, flags])".to_string(),
)
};
let path = match &file {
Value::String(s) => s.to_str()?.to_string(),
Value::Number(_) | Value::Integer(_) => as_f32(&file).to_string(),
_ => return Err(usage()),
};
let height = match &height {
Value::Number(_) | Value::Integer(_) => as_f32(&height),
Value::String(s) => s.to_str()?.parse::<f32>().map_err(|_| usage())?,
_ => return Err(usage()),
};
let flags = super::Outline::flags(flags.as_deref().unwrap_or(""))
.as_str()
.to_string();
|lua, (this, file, height, flags): (Table, Value, Value, Value)| {
let super::font_block::FontArgs {
path,
height,
flags,
} = super::font_block::set_font_args(lua, &this, &file, &height, &flags)?;
// Each embedded font's load fails alike, writing nothing (`0x79f30f` → `0x79f361`).
if !super::font_block::instance_loads(lua, &path, height) {
return Ok(());
}
let flags = flags.as_str().to_string();
with_button(lua, &this, |bs| {
bs.font = Some(ButtonFont {
path,
@@ -314,10 +314,9 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
}
/// The font block, entries #0-#15 of the EditBox's own 48-entry method table (`0x87bb68`, count
/// at `0x799ab5`); 1.12 has no `FontInstance` class, so each text-bearing kind declares it. Ten
/// come from [`super::super::font_block`]. `SetSpacing`/`GetSpacing` (#10-#11) are not installed:
/// nothing renders line spacing, and a missing method raises by name. The justify four (#12-#15)
/// act on the box's own [`EditBoxState::justify`] word, not its text region.
/// at `0x799ab5`); 1.12 has no `FontInstance` class, so each text-bearing kind declares it. Twelve
/// come from [`super::super::font_block`], `SetSpacing`/`GetSpacing` (#10-#11) among them. The
/// justify four (#12-#15) act on the box's own [`EditBoxState::justify`] word, not its text region.
fn install_font_block(lua: &Lua, m: &Table) -> mlua::Result<()> {
// Every font method acts on the box's implicit FontString (`[this+0x324]`, our
// `text_region`), created on demand.
@@ -328,7 +327,7 @@ fn install_font_block(lua: &Lua, m: &Table) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
super::ensure_text_region(lua, h).ok_or_else(|| mlua::Error::runtime("not an EditBox"))
},
"EditBox",
crate::script::font_block::FontKind::Instance,
// The box listens on its font (vtable `0x81c8dc` slot 0, `0x77e2a0`): a change of face or
// spacing (mask `0x11`) re-seats its text region (`0x77e2b5` → `0x77b8c0`).
Some(|lua, this| {
@@ -191,6 +191,8 @@ fn line_height(lua: &Lua, h: FrameHandle, rh: RegionHandle) -> Option<f32> {
text_height: d.text_height,
wrap_width: None,
outline: d.outline,
// One line has no gap: `0x5c2070` charges `(N−1)·gap`.
spacing: d.line_gap(),
scale,
text: ONE_LINE.to_string(),
key,
@@ -165,6 +165,8 @@ fn stale_advances(
let font = d.and_then(|d| d.font_path.clone());
let height = d.and_then(|d| d.font_height);
let outline = d.map(|d| d.outline).unwrap_or_default();
// A multi-line box steps its rows by the font height plus the gap (`0x77d133`-`0x77d161`).
let spacing = d.map_or(0.0, super::super::RegionData::line_gap);
// The host measures at the drawn raster size, so the advances land on the drawn glyphs.
let scale = model
.arena
@@ -187,6 +189,7 @@ fn stale_advances(
font.hash(&mut hasher);
height.map(f32::to_bits).hash(&mut hasher);
(outline as u8).hash(&mut hasher);
spacing.to_bits().hash(&mut hasher);
wrap_width.map(f32::to_bits).hash(&mut hasher);
scale.to_bits().hash(&mut hasher);
let key = hasher.finish();
@@ -207,6 +210,7 @@ fn stale_advances(
font,
height,
outline,
spacing,
scale,
text: display,
wrap_width,
+2
View File
@@ -322,6 +322,7 @@ impl UiScript {
{
art
} else if is_text {
let spacing = data.line_gap();
QuadContent::Text {
text: data.text,
color: data.vertex_color,
@@ -334,6 +335,7 @@ impl UiScript {
text_height: data.text_height,
shadow: data.font_shadow,
outline: data.outline,
spacing,
alpha_gradient: data.alpha_gradient,
// The owner's property: an addon's strings on a plate slide with it.
world_seat: owner
+42 -26
View File
@@ -3,8 +3,7 @@
//!
//! The reference's class is `CSimpleFont` (`0x87a454`). Its method table (`0x87c7c8`) has 22
//! entries (`mov edx,0x16` at `0x7a10d5`), and its lookup `0x7a1100` has no base-class fallback.
//! All are here but the `SetSpacing` pair ([`install`]); `CopyFontObject` is the one a FontString
//! lacks. A named font is published by `0x783870` through `SetName 0x784150` and `CreateLuaHandle
//! All are here; `CopyFontObject` is the one a FontString lacks. A named font is published by `0x783870` through `SetName 0x784150` and `CreateLuaHandle
//! 0x701bd0`, which leaves a non-nil `_G[name]` alone (`0x701cb8`).
//!
//! A setter repaints every FontString that inherits the font. The reference links dependents into
@@ -27,7 +26,7 @@ use super::binding_abi;
use super::object::publish_global;
use crate::justify;
use super::{FontObject, FontShadow, Model, Outline, RegionData};
use super::{FontObject, FontShadow, Model, RegionData};
/// The shared metatable of every font-object handle.
const REG_FONT_META: &str = "__benilla_font_meta";
@@ -108,8 +107,8 @@ fn resolve_required(verb: &str, v: &Value) -> mlua::Result<String> {
/// on the source's held-value bits (`+0x38`, the merge `0x770910`), so an empty `CreateFont` font
/// changes nothing.
///
/// The outline is always copied, as [`Outline`] has no unset state; only an empty `CreateFont` font
/// on an outlined FontString differs from the reference, clearing the outline.
/// The outline is always copied, as [`Outline`](super::Outline) has no unset state; only an empty
/// `CreateFont` font on an outlined FontString differs from the reference, clearing the outline.
pub(crate) fn repaint(d: &mut RegionData, fo: &FontObject) {
let ex = d.font_explicit;
if !ex.face {
@@ -145,6 +144,13 @@ pub(crate) fn repaint(d: &mut RegionData, fo: &FontObject) {
d.justify.set_v(j);
}
}
// Copied as given: a FontString's reader clamps it, as its setter `0x772240` does at the merge
// (`0x7708fd`).
if !ex.spacing {
if let Some(sp) = fo.spacing {
d.spacing = sp;
}
}
}
/// Repaint every region that inherits `name`, as the reference's live parent link does. Linear in
@@ -185,10 +191,6 @@ fn read(lua: &Lua, this: &Table) -> mlua::Result<FontObject> {
// ── install: the method table, the metatable and CreateFont ──────────────────────────────────
/// Build the font-object method table and metatable, and register `CreateFont`.
///
/// Deviation: `SetSpacing`/`GetSpacing` are absent, because nothing renders line spacing (the line
/// pitch is the font height): a missing method raises and names itself, where a stored, undrawn
/// value would fail silently.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.set_named_registry_value(REG_FONT_WRAPPERS, lua.create_table()?)?;
@@ -239,29 +241,28 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// ── face ────────────────────────────────────────────────────────────────────────────────
// SetFont(path, height [, flags]) → 1, or nil when the font file fails to load, which does not
// raise. Deviation: any non-empty path answers 1, because the atlas falls back per face and no
// load fails; an empty one answers nil.
// SetFont(path, height [, flags]) → 1, or nil when the font fails to load, which does not raise
// and writes nothing: an empty path, one the host's probe refuses, or a height of 0 or less.
m.set(
"SetFont",
lua.create_function(
|lua, (this, file, height, flags): (Table, Value, Value, Option<String>)| {
// The argument gate the FontString and EditBox tables share (`0x79f210`); a
// missing argument raises (`0x87c69c`).
let (path, height) = super::font_block::set_font_args(&file, &height, "Font")?;
let ok = !path.is_empty();
|lua, (this, file, height, flags): (Table, Value, Value, Value)| {
// The argument gate every kind shares (`0x79f210`); a bad path or height raises
// (`0x87c69c`), and anything but a string or a number is no flags.
let super::font_block::FontArgs {
path,
height,
flags,
} = super::font_block::set_font_args(lua, &this, &file, &height, &flags)?;
if !super::font_block::instance_loads(lua, &path, height) {
return Ok(Value::Nil);
}
edit(lua, &this, |fo| {
if ok {
fo.font = Some(path);
}
fo.font = Some(path);
fo.outline = flags;
fo.height = Some(height);
if let Some(f) = flags {
// The Lua flag spelling (`OUTLINE`, `THICKOUTLINE`), not the XML
// attribute's (`NORMAL`, `THICK`).
fo.outline = Outline::flags(&f);
}
})?;
Ok(if ok { Value::Number(1.0) } else { Value::Nil })
Ok(Value::Number(1.0))
},
)?,
)?;
@@ -370,6 +371,21 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// ── line spacing ────────────────────────────────────────────────────────────────────────
// The shared pair (`0x7a09f0` → `0x79fb40`, `0x7a0ab0` → `0x79fbe0`): `+0x50` as given, a
// negative included; a FontString that inherits it clamps it.
m.set(
"SetSpacing",
lua.create_function(|lua, (this, v): (Table, Value)| {
let sp = super::font_block::spacing_arg(lua, &this, &v)?;
edit(lua, &this, |fo| fo.spacing = Some(sp))
})?,
)?;
m.set(
"GetSpacing",
lua.create_function(|lua, this: Table| Ok(read(lua, &this)?.spacing.unwrap_or(0.0)))?,
)?;
// ── justification ───────────────────────────────────────────────────────────────────────
// Parsing is [`crate::justify`]'s, shared with the FontString pair: `SStrCmpI` over the whole
// string, no trim. `None` means the font does not specify the axis (inheritance), not a
+200 -58
View File
@@ -1,22 +1,21 @@
//! The font block: the ten font methods a text-bearing widget's own method table declares,
//! The font block: the twelve font methods a text-bearing widget's own method table declares,
//! implemented once over the [`RegionData`](super::RegionData) its glyphs paint from.
//!
//! The 1.12 client has no `FontInstance` class: each text type re-declares the font names in its
//! own flat method table, and each binding is a shim that tail-calls one shared implementation.
//! This module is that shared layer, installed by the FontString, EditBox, MessageFrame and
//! ScrollingMessageFrame tables; the `<Font>` object and `SimpleHTML` write their own records and
//! share only [`set_font_args`].
//! share only the argument gates ([`set_font_args`], [`spacing_arg`]).
//!
//! `EditBox`'s table (`0x87bb68`, 48 entries) opens with these ten, then `Set/GetSpacing` and the
//! four justify methods. Its shims (`0x797090` on) leave `eax` alone, so each returns what the
//! shared implementation pushes, and act on `[this+0x324]`, the box's implicit FontString
//! ([`EditBoxState::text_region`](crate::widget::EditBoxState::text_region)). The justify four are
//! installed by `script::editbox::methods`. `SetSpacing`/`GetSpacing` (`0x79fb40`/`0x79fbe0`) are
//! not installed: no line spacing is modelled, so a call raises.
//! `EditBox`'s table (`0x87bb68`, 48 entries) opens with these twelve, then the four justify
//! methods. Its shims (`0x797090` on) leave `eax` alone, so each returns what the shared
//! implementation pushes, and act on `[this+0x324]`, the box's font, which here is its implicit
//! FontString ([`EditBoxState::text_region`](crate::widget::EditBoxState::text_region)). The
//! justify four are installed by `script::editbox::methods`.
use mlua::{Lua, Table, Value};
use super::object::as_f32;
use super::binding_abi;
use super::{FontShadow, Model, Outline};
use crate::widget::RegionHandle;
@@ -29,40 +28,127 @@ pub(super) type ResolveRegion = fn(&Lua, &Table) -> mlua::Result<RegionHandle>;
/// `0x77e4d7` call, which the EditBox answers (`0x77e2a0`).
pub(super) type FontChanged = fn(&Lua, &Table) -> mlua::Result<()>;
/// `SetFont`'s shared argument gate (`0x79f210`, reached from Font `0x7a0270`, FontString
/// `0x79d4f0` and EditBox `0x797210`): arg 2 must pass `lua_isstring` and arg 3 `lua_isnumber`,
/// both coercing, else it raises `Usage: %s:SetFont("font", fontHeight [, flags])` (`0x87c69c`).
/// An empty path is not an argument error but a failed load, which the caller answers with nil.
pub(super) fn set_font_args(
file: &Value,
height: &Value,
widget: &str,
) -> mlua::Result<(String, f32)> {
let usage = || {
mlua::Error::runtime(format!(
"Usage: <{widget}>:SetFont(\"font\", fontHeight [, flags])"
))
};
let path = match file {
Value::String(s) => s.to_str()?.to_string(),
Value::Number(_) | Value::Integer(_) => as_f32(file).to_string(),
_ => return Err(usage()),
};
let height = match height {
Value::Number(_) | Value::Integer(_) => as_f32(height),
Value::String(s) => s.to_str()?.parse::<f32>().map_err(|_| usage())?,
_ => return Err(usage()),
};
Ok((path, height))
/// The `%s` of a font verb's `Usage:` line: every shim passes the receiver's `GetName()`, or
/// `<unnamed>` (`0x84c7f0`) when it has none (`0x79d652`, `0x7a0315`, `0x780919`). Runs Lua, so a
/// caller asks before it borrows the model.
pub(super) fn usage_name(this: &Table) -> String {
this.get::<mlua::Function>("GetName")
.and_then(|f| f.call::<Option<String>>(this.clone()))
.ok()
.flatten()
.unwrap_or_else(|| "<unnamed>".to_string())
}
/// Install the ten font-block methods onto `m`, reading `this` through `resolve`; only for a
/// widget whose reference method table carries all ten.
/// `SetFont`'s three arguments as the shared implementation reads them.
pub(super) struct FontArgs {
pub(super) path: String,
pub(super) height: f32,
/// The flags word written on every load (`0x79f334`): zero, no outline, when the gate fails.
pub(super) flags: Outline,
}
/// `SetFont`'s argument gate: `0x79f210`, behind the Font (`0x7a0270`), EditBox (`0x797210`),
/// message-frame (`0x794450`, `0x791e80`), SimpleHTML (`0x795f30`) and Button (`0x780880`) shims,
/// and inlined in the FontString's (`0x79d582`-`0x79d5fb`). The path must pass `lua_isstring`
/// (`0x79f225`) and the height `lua_isnumber` (`0x79f239`), both coercing, else `Usage:
/// %s:SetFont("font", fontHeight [, flags])` (`0x87c69c`). The flags are read only when
/// `lua_isstring` passes (`0x79f287`), so a string or a number; `false`, nil or anything else is no
/// flags. An empty path is not an argument error but a failed load, which the caller answers with
/// nil.
pub(super) fn set_font_args(
lua: &Lua,
this: &Table,
file: &Value,
height: &Value,
flags: &Value,
) -> mlua::Result<FontArgs> {
let usage = || {
mlua::Error::runtime(format!(
"Usage: {}:SetFont(\"font\", fontHeight [, flags])",
usage_name(this)
))
};
let Some(path) = binding_abi::optional_string(lua, file) else {
return Err(usage());
};
let Some(height) = lua.coerce_number(height.clone())? else {
return Err(usage());
};
// The Lua spelling (`OUTLINE`, `THICKOUTLINE`), not the XML one, parsed like `0x6f1a90`: a
// case-insensitive substring scan, so `THICKOUTLINE` sets both.
let flags =
binding_abi::optional_string(lua, flags).map_or(Outline::None, |f| Outline::flags(&f));
Ok(FontArgs {
path,
height: height as f32,
flags,
})
}
/// `SetSpacing`'s argument gate, the same on all six tables (`0x79fb54`, FontString `0x79e340`):
/// `lua_isnumber`, coercing, else `Usage: %s:SetSpacing(spacing)` (`0x87c708`).
pub(super) fn spacing_arg(lua: &Lua, this: &Table, v: &Value) -> mlua::Result<f32> {
match lua.coerce_number(v.clone())? {
Some(n) => Ok(n as f32),
None => Err(mlua::Error::runtime(format!(
"Usage: {}:SetSpacing(spacing)",
usage_name(this)
))),
}
}
/// Which implementations a table's font verbs reach: the FontString has its own `SetFont` load
/// (`0x79d4f0` → `0x7714f0`) and spacing pair (`0x79e2b0`, `0x79e1d0`, through the clamping
/// `0x772240`); the message frames' and the EditBox's font reach the shared ones (`0x79f210`,
/// `0x79fb40`, `0x79fbe0`), which keep a spacing as given.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum FontKind {
/// A FontString: its `GetSpacing` reads [`RegionData::line_gap`](super::RegionData::line_gap).
FontString,
/// A font instance: its `GetSpacing` reads the number as set.
Instance,
}
/// A `SetFont` load's outcome.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Load {
Loaded,
/// A FontString's height of 0: no load, its font freed, still a success.
Freed,
Failed,
}
impl Load {
fn verdict(ok: bool) -> Load {
if ok {
Load::Loaded
} else {
Load::Failed
}
}
}
/// Whether the font load (`0x44d040`, the factory `0x5c1ae0`) finds `path`: never for an empty one
/// (`0x44d059`), else the host's probe, as only the store knows the files; with no probe, any
/// non-empty path loads.
pub(super) fn face_loads(model: &Model, path: &str) -> bool {
!path.is_empty() && model.font_probe.as_ref().is_none_or(|probe| probe(path))
}
/// The shared path's verdict (`0x79f210`): no load for a height of 0 or less
/// (`0x79f2f1`-`0x79f2ff`), else [`face_loads`]. A failure writes nothing (`0x79f30f` →
/// `0x79f361`). Borrows the model, so a caller asks before it holds it.
pub(super) fn instance_loads(lua: &Lua, path: &str, height: f32) -> bool {
height > 0.0 && face_loads(&lua.app_data_ref::<Model>().expect("model"), path)
}
/// Install the twelve font-block methods onto `m`, reading `this` through `resolve`; only for a
/// widget whose reference method table carries all twelve.
pub(super) fn install(
lua: &Lua,
m: &Table,
resolve: ResolveRegion,
widget: &'static str,
kind: FontKind,
on_font_change: Option<FontChanged>,
) -> mlua::Result<()> {
// ── the font object ──
@@ -130,48 +216,63 @@ pub(super) fn install(
// ── the face ──
// SetFont(path, height [, flags]) → the number 1 (`0x79f345`), or nil on a failed load
// (`0x79f361`), never a boolean. The EditBox shim passes it through (`0x7972b2` leaves `eax`
// alone), unlike `Button:SetFont` (`0x780880`), which returns nothing.
// alone), unlike `Button:SetFont` (`0x780880`), which returns nothing. A failed load writes
// nothing: the shared path jumps past its stores (`0x79f30f` → `0x79f361`), the FontString's
// returns before its own (`0x771571`-`0x77157b`).
m.set(
"SetFont",
lua.create_function(
move |lua, (this, file, height, flags): (Table, Value, Value, Option<String>)| {
let (path, height) = set_font_args(&file, &height, widget)?;
move |lua, (this, file, height, flags): (Table, Value, Value, Value)| {
let FontArgs {
path,
height,
flags,
} = set_font_args(lua, &this, &file, &height, &flags)?;
let rh = resolve(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let before = model
.region_data
.get(&rh)
.map(|d| (d.font_path.clone(), d.font_height, d.outline));
// A failed load (`0x5c1ae0` under the `0x44d040` font cache) is the host's probe to
// judge, as only the store knows the files; with no probe, a non-empty path loads.
let ok =
!path.is_empty() && model.font_probe.as_ref().is_none_or(|probe| probe(&path));
let load = match kind {
// A FontString loads for any height but 0 (`0x77154d`-`0x77155b`); 0 skips the
// load and frees its font (`0x7715b3`), which still answers 1. What a negative
// height does inside the load (`0x44d040`) is untraced: the probe judges it.
FontKind::FontString if height == 0.0 => Load::Freed,
FontKind::FontString => Load::verdict(face_loads(&model, &path)),
// The shared path answers nil for a height of 0 or less without loading
// (`0x79f2f1`-`0x79f2ff`).
FontKind::Instance => Load::verdict(height > 0.0 && face_loads(&model, &path)),
};
if load == Load::Failed {
return Ok(Value::Nil);
}
let d = model.region_data.entry(rh).or_default();
// Each supplied argument is an explicit set, kept through a later font-object
// change; the height applies even when the face fails to load.
if ok {
// Each argument is an explicit set, kept through a later font-object change
// (`0x79f33a`, `0x79d625`). The flags are part of the font the load picks
// (`0x44d040`), none included, and a freed font has none.
if load == Load::Loaded {
d.font_path = Some(path);
d.font_explicit.face = true;
d.outline = flags;
} else {
d.font_path = None;
d.outline = Outline::None;
}
d.font_height = Some(height);
d.font_explicit.face = true;
d.font_explicit.outline = true;
d.font_explicit.height = true;
if let Some(f) = flags {
// The Lua spelling (`OUTLINE`, `THICKOUTLINE`), not the XML one, parsed like
// `0x6f1a90`: a case-insensitive substring scan, so `THICKOUTLINE` sets both.
d.outline = Outline::flags(&f);
d.font_explicit.outline = true;
}
let after = (d.font_path.clone(), d.font_height, d.outline);
model.touch_measure(rh);
drop(model);
// A loaded face that changed the path, height or flags notifies (`0x79f32f` sets
// the change bits, `0x79f342` calls the notify); a failed load does not.
if ok && before.as_ref() != Some(&after) {
// A load that changed the path, height or flags notifies (`0x79f32f` sets the
// change bits, `0x79f342` calls the notify).
if before.as_ref() != Some(&after) {
if let Some(changed) = on_font_change {
changed(lua, &this)?;
}
}
Ok(if ok { Value::Number(1.0) } else { Value::Nil })
Ok(Value::Number(1.0))
},
)?,
)?;
@@ -305,5 +406,46 @@ pub(super) fn install(
})?,
)?;
// ── the line spacing ──
// SetSpacing(spacing) → nothing (`0x79fbbe`). The reference notifies on every call
// (`0x79fbb1`-`0x79fbb9`) and the EditBox acts only on a set change bit (mask `0x11`,
// `0x77e2b5`); here the notify runs only when the value changed.
m.set(
"SetSpacing",
lua.create_function(move |lua, (this, v): (Table, Value)| {
let spacing = spacing_arg(lua, &this, &v)?;
let rh = resolve(lua, &this)?;
let changed = {
let mut model = lua.app_data_mut::<Model>().expect("model");
let d = model.region_data.entry(rh).or_default();
let changed = d.spacing.to_bits() != spacing.to_bits();
d.spacing = spacing;
// Clears inherit bit `0x10` (`0x79e37b`, `0x79fbb1`).
d.font_explicit.spacing = true;
model.touch_measure(rh);
changed
};
if changed {
if let Some(notify) = on_font_change {
notify(lua, &this)?;
}
}
Ok(())
})?,
)?;
// GetSpacing() → one number (`0x79fbe0`, FontString `0x79e1d0`).
m.set(
"GetSpacing",
lua.create_function(move |lua, this: Table| {
let rh = resolve(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let d = model.region_data.get(&rh);
Ok(match kind {
FontKind::FontString => d.map_or(0.0, super::RegionData::line_gap),
FontKind::Instance => d.map_or(0.0, |d| d.spacing),
})
})?,
)?;
Ok(())
}
+2
View File
@@ -1794,6 +1794,7 @@ pub(super) fn measure_request_for(model: &mut Model, rh: RegionHandle) -> Option
let height = d.font_height;
let text_height = d.text_height;
let outline = d.outline;
let spacing = d.line_gap();
let id = model.region_id(rh);
Some(MeasureRequest {
id,
@@ -1802,6 +1803,7 @@ pub(super) fn measure_request_for(model: &mut Model, rh: RegionHandle) -> Option
text_height,
wrap_width,
outline,
spacing,
scale,
text,
key,
@@ -160,6 +160,39 @@ pub(super) struct MessageFont {
/// The child's `justifyH`, which centres `UIErrorsFrame`'s lines and keeps chat flush left;
/// LEFT without a declared FontString, not the FontString default of CENTER.
pub(super) justify_h: JustifyH,
/// The font's spacing as set, a negative included (`+0x50`).
pub(super) spacing: f32,
}
/// `0x766750`: a length snapped to whole pixels, here of the 768-tall root grid, the device pixel
/// of a 768-tall window: a positive one rounds up (`+0.99999`, then truncation), a negative one
/// truncates toward zero.
fn snap_px(v: f32) -> f32 {
if v > 0.0 {
(v + 0.999_99).trunc()
} else {
v.trunc()
}
}
impl MessageFont {
/// The font height, the default 14 when none is set.
fn height_or_default(&self) -> f32 {
self.height.unwrap_or(14.0)
}
/// The gap between a line string's rows and, in the scrolling class, between messages: each
/// line string inherits the frame's font (`0x7888fd`) through the clamping setter (`0x7708fd`
/// → `0x772240`), and the stack snaps its field (`0x78899e` → `0x766750`).
fn line_gap(&self, scale: f32) -> f32 {
snap_px(self.spacing.max(0.0) * scale)
}
/// The plain class's pitch, its fit and its slot step (`0x785f03`-`0x785f2a`, `0x786184`): the
/// font's own spacing, unclamped (`0x785f06`), and the height, each snapped.
fn plain_pitch(&self, scale: f32) -> f32 {
snap_px(self.height_or_default() * scale) + snap_px(self.spacing * scale)
}
}
/// The FontString region holding a message frame's font, made on the first font-block call when
@@ -219,6 +252,7 @@ impl UiScript {
shadow: d.font_shadow,
outline: d.outline,
justify_h: d.justify.paint_h(),
spacing: d.spacing,
})
})
.unwrap_or(MessageFont {
@@ -227,16 +261,27 @@ impl UiScript {
shadow: None,
outline: Outline::default(),
justify_h: JustifyH::Left,
spacing: 0.0,
})
}
/// How many rows frame `fh`'s resolved rect holds at its font's pitch, the client's
/// `numLinesDisplayed`: the scrolling class's page size and the plain class's capacity.
/// `numLinesDisplayed`: the scrolling class's page size and the plain class's capacity. Both
/// the rect and the pitch are scaled (`0x785f0a`, `0x785f1f`); a pitch of 0 or less, which a
/// negative spacing can make on the plain class, holds nothing (`0x785f8f`).
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);
let font = Self::message_frame_font(model, fh);
let Some(frame) = model.arena.frame(fh) else {
return 0;
};
let scale = frame.effective_scale;
let pitch = match frame.kind_state {
KindState::Message(_) => font.plain_pitch(scale),
_ => font.height_or_default() * scale + font.line_gap(scale),
};
if pitch <= 0.0 {
return 0;
}
model.resolved.get(&fh).map_or(0, |r| {
((r.top - r.bottom) / pitch).floor().max(0.0) as usize
})
@@ -352,11 +397,12 @@ impl UiScript {
}
}
/// Push one [`QuadContent::Text`] per visible message of either class, each `rows × pitch`
/// tall. The pitch is the font height, the client's line step (`LayoutLines` `0x5cdc20`: size
/// plus a spacing of 0); the message frame's own relayout (`0x788750`, `0x788c00`) is not fully
/// traced. A scrolling frame stacks up from the bottom, `scroll_offset` choosing the bottom
/// message. A MessageFrame does too for `insertMode` BOTTOM, and for TOP hangs the newest off
/// Push one [`QuadContent::Text`] per visible message of either class, each `rows·S +
/// (rows−1)·gap` tall, its wrapped block (`0x5c2070`). The scrolling class anchors each line
/// string on the previous one's top at its snapped spacing (`0x788954`-`0x788975`) and charges
/// `spacing + height` per message (`0x78899e`-`0x7889ad`); the plain class steps each row by its
/// own pitch, the raw spacing included ([`MessageFont::plain_pitch`]). A scrolling frame stacks
/// up from the bottom, `scroll_offset` choosing the bottom message. A MessageFrame does too for `insertMode` BOTTOM, and for TOP hangs the newest off
/// the top edge (`UIErrorsFrame.xml:4`); the reference's anchor per mode is untraced, and this
/// reading reproduces both stock uses. A message that partly fits draws clipped; a faded
/// scrolling line keeps its rows, as the reference never re-packs chat.
@@ -396,9 +442,15 @@ impl UiScript {
KindState::Message(s) => s.justify,
_ => None,
};
// The resolved rect is scaled and the font height is frame-local, so the pitch scales.
let pitch = font.height.unwrap_or(14.0) * paint.scale;
if pitch <= 0.0 || fr.top <= fr.bottom {
// The resolved rect is scaled and the font is frame-local, so the height and gap scale.
let line_h = font.height_or_default() * paint.scale;
let gap = font.line_gap(paint.scale);
// A row's advance: the scrolling class's height plus gap, the plain class's own pitch.
let row_step = match &frame.kind_state {
KindState::Message(_) => font.plain_pitch(paint.scale),
_ => line_h + gap,
};
if line_h <= 0.0 || row_step <= 0.0 || fr.top <= fr.bottom {
return;
}
// Clip to the frame, intersected with any ScrollFrame ancestor's clip.
@@ -412,13 +464,14 @@ impl UiScript {
break; // the next band starts outside the frame
}
let line = &lines[idx];
let band_h = f32::from(line.rows.max(1)) * pitch;
let rows = f32::from(line.rows.max(1));
let band_h = rows * line_h + (rows - 1.0) * gap;
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;
used += rows * row_step;
if line.alpha <= 0.0 {
continue; // faded: holds its rows, draws nothing
}
@@ -449,6 +502,8 @@ impl UiScript {
font_height: font.height,
text_height: None, // message lines have no SetTextHeight
outline: font.outline,
// The line string's own field, clamped; the host snaps it.
spacing: font.spacing.max(0.0),
alpha_gradient: None,
world_seat: false, // interface, not world
},
@@ -163,7 +163,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
super::ensure_font_region(lua, h)
.ok_or_else(|| mlua::Error::runtime("not a MessageFrame"))
},
"MessageFrame",
crate::script::font_block::FontKind::Instance,
None,
)?;
@@ -242,7 +242,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
super::ensure_font_region(lua, h)
.ok_or_else(|| mlua::Error::runtime("not a ScrollingMessageFrame"))
},
"ScrollingMessageFrame",
crate::script::font_block::FontKind::Instance,
None,
)?;
+4 -2
View File
@@ -343,9 +343,9 @@ pub(crate) const TEXTURE_ONLY_METHODS: [&str; 12] = [
"GetVertexColor",
];
/// FontString-only: the font, text, justify and shadow block and the string metrics.
/// FontString-only: the font, text, justify, shadow and spacing block and the string metrics.
/// `GetStringHeight`, `SetFormattedText` and `SetSize` are not 1.12 FontString methods.
pub(crate) const FONTSTRING_ONLY_METHODS: [&str; 21] = [
pub(crate) const FONTSTRING_ONLY_METHODS: [&str; 23] = [
"SetFont",
"GetFont",
"SetFontObject",
@@ -356,6 +356,8 @@ pub(crate) const FONTSTRING_ONLY_METHODS: [&str; 21] = [
"GetShadowColor",
"SetShadowOffset",
"GetShadowOffset",
"SetSpacing",
"GetSpacing",
"SetJustifyH",
"GetJustifyH",
"SetJustifyV",
+10 -3
View File
@@ -200,7 +200,14 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// ── the shared font block ───────────────────────────────────────────────────────────────
//
// The font object, font, text colour and shadow getters and setters are type-guard shims over
// one implementation each (`0x79f210` SetFont, `0x79f3b0` GetFont), shared with EditBox.
super::super::font_block::install(lua, m, region_handle_of, "FontString", None)
// The font object, font, text colour, shadow and spacing getters and setters are type-guard
// shims over one implementation each (`0x79f210` SetFont, `0x79f3b0` GetFont), shared with
// EditBox; the spacing pair is the FontString's own (`0x79e2b0`, `0x79e1d0`), which clamps.
super::super::font_block::install(
lua,
m,
region_handle_of,
super::super::font_block::FontKind::FontString,
None,
)
}
+47 -29
View File
@@ -11,10 +11,10 @@
//! ScrollFrame measures the blocks as regions (`0x786e30`).
//!
//! Deviation: the block step is not pixel-snapped (`0x766750`), because layout here is in logical
//! units; the two differ by under a pixel, and not at all at the default spacing of 0. Line spacing
//! inside a block is not applied, as nothing here models line spacing; `SetSpacing` sets the step
//! between blocks. Deviation: a block of width 0, in a frame sized by anchors, gets its text's
//! natural width where the reference keeps 0, because every width-less FontString here does.
//! units; the two differ by under a pixel, and not at all at the default spacing of 0. The spacing
//! is also each block's line gap, as the block FontString inherits it. Deviation: a block of width
//! 0, in a frame sized by anchors, gets its text's natural width where the reference keeps 0,
//! because every width-less FontString here does.
//!
//! The method table (`0x87ba80`) has 19 entries, all installed; the first sixteen take an optional
//! leading element name (`0x795d80`). Build 5875 has no `GetText`.
@@ -62,7 +62,8 @@ pub(crate) struct ElementFont {
pub(crate) shadow: Option<FontShadow>,
/// `CSimpleFont+0x54`, ctor `0x212` (`0x783a98`): CENTER, MIDDLE and the one-to-one bit.
pub(crate) justify: Justify,
/// `+0x50`, ctor 0 (`0x783a81`): the step between blocks and, in the reference, the line gap.
/// `+0x50`, ctor 0 (`0x783a81`), as set, a negative included: the step between blocks and each
/// block's line gap, both read through the block FontString's clamp (`0x772240`).
pub(crate) spacing: f32,
}
@@ -129,6 +130,12 @@ fn resolve_font(model: &Model, ef: &ElementFont) -> BlockPaint {
if let Some(j) = fo.justify_v {
p.justify.set_v(j);
}
if let Some(sp) = fo.spacing {
p.spacing = sp;
}
}
if ef.explicit.spacing {
p.spacing = ef.spacing;
}
if ef.explicit.face {
p.font_path = ef.font_path.clone();
@@ -251,6 +258,7 @@ fn build(model: &mut Model, fh: FrameHandle, blocks: &[Block]) {
outline: paint.outline,
vertex_color: paint.color,
font_shadow: paint.shadow,
spacing: paint.spacing,
..RegionData::default()
};
// The tag's `align` is written after `SetFontObject` (`0x78ae78`), so it beats
@@ -263,7 +271,8 @@ fn build(model: &mut Model, fh: FrameHandle, blocks: &[Block]) {
model.region_data.insert(rh, d);
model.touch_measure(rh); // a text block arrives with its text
prev_block = Some(id);
next_y = -paint.spacing;
// The block FontString's field, which its setter clamped (`0x78aeaf`).
next_y = -paint.spacing.max(0.0);
made.push(rh);
}
Block::Image {
@@ -493,6 +502,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
ef.color = p.color;
ef.shadow = p.shadow;
ef.justify = p.justify;
ef.spacing = p.spacing;
ef.explicit = super::FontExplicit {
face: ef.font_path.is_some(),
height: ef.font_height.is_some(),
@@ -501,6 +511,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
shadow: ef.shadow.is_some(),
justify_h: true,
justify_v: true,
spacing: true,
};
}
// A re-point keeps the severance mask, as on a region: each local setter clears
@@ -530,33 +541,36 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// ── 2/3 · the face ──────────────────────────────────────────────────────────────────────
// SetFont([element,] file, height [, flags]) returns 1, or nil on an empty path; the shared
// gate (`0x79f210`) takes numeric strings and raises `0x87c69c`'s usage line on anything else.
// SetFont([element,] file, height [, flags]) returns 1, or nil, writing nothing, on a failed
// load; the shared gate (`0x79f210`) takes numeric strings and raises `0x87c69c`'s usage line
// on anything else.
m.set(
"SetFont",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let (path, height) =
super::font_block::set_font_args(&arg(&rest, 0), &arg(&rest, 1), "SimpleHTML")?;
let flags = match arg(&rest, 2) {
Value::String(s) => Some(s.to_str()?.to_string()),
_ => None,
};
let ok = !path.is_empty();
let super::font_block::FontArgs {
path,
height,
flags,
} = super::font_block::set_font_args(
lua,
&this,
&arg(&rest, 0),
&arg(&rest, 1),
&arg(&rest, 2),
)?;
if !super::font_block::instance_loads(lua, &path, height) {
return Ok(Value::Nil);
}
edit_font(lua, &this, elem, |ef| {
if ok {
ef.font_path = Some(path);
ef.explicit.face = true;
}
ef.font_path = Some(path);
ef.explicit.face = true;
ef.outline = flags;
ef.explicit.outline = true;
ef.font_height = Some(height);
ef.explicit.height = true;
if let Some(f) = flags {
// The Lua flags spelling, not XML's `outline=` ([`Outline::flags`]).
ef.outline = Outline::flags(&f);
ef.explicit.outline = true;
}
})?;
Ok(if ok { Value::Number(1.0) } else { Value::Nil })
Ok(Value::Number(1.0))
})?,
)?;
// GetFont([element]): path, height and a flags string, `""` rather than nil.
@@ -650,14 +664,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// ── 10/11 · spacing ─────────────────────────────────────────────────────────────────────
// SetSpacing([element,] n) clamps a negative to 0 (`0x772240`, `0x772246`); it is the only gap
// between blocks, which stack flush at the ctor default of 0.
// SetSpacing([element,] n) is the shared setter (`0x7965b0` → `0x79fb40`): the number as given,
// which the blocks read through their FontString's clamp. It is the only gap between blocks,
// which stack flush at the ctor default of 0.
m.set(
"SetSpacing",
lua.create_function(|lua, (this, mut rest): (Table, MultiValue)| {
let elem = take_element(&mut rest);
let n = f32::from_lua(arg(&rest, 0), lua)?.max(0.0);
edit_font(lua, &this, elem, |ef| ef.spacing = n)
let n = super::font_block::spacing_arg(lua, &this, &arg(&rest, 0))?;
edit_font(lua, &this, elem, |ef| {
ef.spacing = n;
ef.explicit.spacing = true;
})
})?,
)?;
m.set(
@@ -207,8 +207,8 @@ fn image(node: roxmltree::Node) -> Block {
}
Block::Image {
src: attr_ci(node, "src").map(str::to_string),
width: atof(attr_ci(node, "width").unwrap_or("")),
height: atof(attr_ci(node, "height").unwrap_or("")),
width: crate::framexml::atof(attr_ci(node, "width").unwrap_or("")),
height: crate::framexml::atof(attr_ci(node, "height").unwrap_or("")),
align,
floated,
}
@@ -273,43 +273,6 @@ fn sprintf_two(fmt: &str, a: &str, b: &str) -> String {
out
}
/// C's `atof` (`0x64aaa0`) for `width=` and `height=`: the longest numeric prefix after leading
/// whitespace, 0 when there is none, so `"12px"` is 12.
fn atof(s: &str) -> f32 {
let b = s.as_bytes();
let mut i = 0;
while i < b.len() && (b[i] as char).is_ascii_whitespace() {
i += 1;
}
let start = i;
if i < b.len() && (b[i] == b'+' || b[i] == b'-') {
i += 1;
}
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
if i < b.len() && b[i] == b'.' {
i += 1;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
}
// An exponent counts only when complete: `"1e"` is 1, as `strtod` backtracks.
if i < b.len() && (b[i] | 0x20) == b'e' {
let mut j = i + 1;
if j < b.len() && (b[j] == b'+' || b[j] == b'-') {
j += 1;
}
if j < b.len() && b[j].is_ascii_digit() {
while j < b.len() && b[j].is_ascii_digit() {
j += 1;
}
i = j;
}
}
s[start..i].parse::<f32>().unwrap_or(0.0)
}
/// The whitespace collapse of markup blocks (`0x78a8af`-`0x78aa87`; the fallback at `0x78a501`
/// skips it): leading and trailing whitespace dropped, each run of tabs, newlines, CRs and spaces
/// made one space, and whitespace on either side of `|n` removed. Its arms: whitespace
@@ -383,17 +346,6 @@ mod tests {
assert_eq!(collapse(" "), "");
}
#[test]
fn atof_takes_the_numeric_prefix() {
assert_eq!(atof("64"), 64.0);
assert_eq!(atof(" 12.5px"), 12.5);
assert_eq!(atof("-3"), -3.0);
assert_eq!(atof("px"), 0.0);
assert_eq!(atof(""), 0.0);
assert_eq!(atof("1e2"), 100.0);
assert_eq!(atof("1e"), 1.0);
}
#[test]
fn hyperlink_format_takes_exactly_two_strings() {
assert_eq!(
@@ -223,6 +223,7 @@ fn set_font_object_takes_the_object_or_the_name() {
justify_h: None,
justify_v: None,
shadow: None,
spacing: None,
},
);
s.run(
@@ -283,6 +284,7 @@ fn dewdrop_recolors_a_row_from_its_own_font_object() {
justify_h: None,
justify_v: None,
shadow: None,
spacing: None,
},
);
s.run(
@@ -318,6 +320,7 @@ fn mutating_a_font_object_repaints_everything_that_inherits_it() {
justify_h: None,
justify_v: None,
shadow: None,
spacing: None,
},
);
s.run(
@@ -504,6 +507,7 @@ fn fonz_appraiser_copies_a_shipped_object_then_overrides_the_face() {
justify_h: Some(JustifyH::Right),
justify_v: None,
shadow: None,
spacing: None,
},
);
s.run(
@@ -536,14 +540,18 @@ fn fonz_appraiser_copies_a_shipped_object_then_overrides_the_face() {
);
}
/// Line spacing is not built: the reference's `SetSpacing` is absent here, so a call raises rather
/// than store a number nothing draws.
/// A registered font object answers the spacing pair (`0x7a09f0`, `0x7a0ab0`), 0 until set, and a
/// set reaches the FontStrings that inherit it.
#[test]
fn the_unmodelled_spacing_pair_fails_loudly() {
fn a_font_objects_spacing_reaches_its_fontstrings() {
let s = script();
s.register_font_object("SomeFont", FontObject::default());
assert!(s.run("SomeFont:SetSpacing(4)").is_err());
assert!(s.eval::<bool>("return SomeFont.SetSpacing == nil").unwrap());
s.run("FS = CreateFrame('Frame'):CreateFontString() FS:SetFontObject(SomeFont)")
.unwrap();
assert_eq!(s.eval::<f32>("return SomeFont:GetSpacing()").unwrap(), 0.0);
s.run("SomeFont:SetSpacing(4)").unwrap();
assert_eq!(s.eval::<f32>("return SomeFont:GetSpacing()").unwrap(), 4.0);
assert_eq!(s.eval::<f32>("return FS:GetSpacing()").unwrap(), 4.0);
}
/// A button's state fonts take the object or the name; they are stored as names and re-resolved at
@@ -945,7 +953,7 @@ fn set_font_answers_the_hosts_load_verdict_when_there_is_a_host() {
"an addon's own face, spelled in its own case, must load"
);
assert!(
s.eval::<bool>(r"return FS:SetFont('Interface\\AddOns\\MSBT\\Fonts\\gone.ttf', 18) == nil")
s.eval::<bool>(r"return FS:SetFont('Interface\\AddOns\\MSBT\\Fonts\\gone.ttf', 30) == nil")
.unwrap(),
"a path the store does not hold is the reference's falsey load failure"
);
@@ -956,7 +964,10 @@ fn set_font_answers_the_hosts_load_verdict_when_there_is_a_host() {
face, "Interface\\AddOns\\MSBT\\Fonts\\porky.ttf",
"the refused face must not replace the one that loaded"
);
assert_eq!(height, 18.0, "…while the height, which never fails, is set");
assert_eq!(
height, 18.0,
"a refused load writes nothing, the height included (`0x771571`)"
);
}
/// MSBT's per-event sequence over the stock `MasterFont` (`Fonts.xml:55`, a `<Shadow>` only): its
@@ -0,0 +1,459 @@
//! `SetSpacing`/`GetSpacing`, the line gap every text-bearing table carries: one shared
//! implementation (`0x79fb40`, `0x79fbe0`) for the Font object, EditBox, the two message frames and
//! SimpleHTML, and the FontString's own pair (`0x79e2b0`, `0x79e1d0`), which clamps.
use super::common::script;
use crate::script::{QuadContent, UiScript};
/// The six receivers, each named so the usage line can name it, as `(global, kind)`.
const KINDS: &[(&str, &str)] = &[
("SFont", "Font"),
("SFS", "FontString"),
("SEB", "EditBox"),
("SMF", "MessageFrame"),
("SSMF", "ScrollingMessageFrame"),
("SHTML", "SimpleHTML"),
];
fn receivers() -> UiScript {
let s = script();
s.run(
r#"
CreateFont("SFont")
CreateFrame("Frame", "SHost"):CreateFontString("SFS")
CreateFrame("EditBox", "SEB")
CreateFrame("MessageFrame", "SMF")
CreateFrame("ScrollingMessageFrame", "SSMF")
CreateFrame("SimpleHTML", "SHTML")
"#,
)
.unwrap();
s
}
fn spacing(s: &UiScript, obj: &str) -> f32 {
s.eval::<f32>(&format!("return {obj}:GetSpacing()"))
.unwrap_or_else(|e| panic!("{obj}:GetSpacing raised {e}"))
}
/// Every table carries the pair (`reference/1.12-shapes.tsv`, the `SetSpacing` rows), starting at
/// the constructors' 0 (`0x783a81`, `0x770d89`), and a number round-trips.
#[test]
fn every_text_table_round_trips_its_spacing() {
let s = receivers();
for &(obj, kind) in KINDS {
assert_eq!(spacing(&s, obj), 0.0, "{kind}: the constructor's 0");
s.run(&format!("{obj}:SetSpacing(4)"))
.unwrap_or_else(|e| panic!("{kind}: SetSpacing(4) raised {e}"));
assert_eq!(spacing(&s, obj), 4.0, "{kind}");
// The gate is `lua_isnumber` (`0x79fb54`, `0x79e340`): a numeric string passes.
s.run(&format!("{obj}:SetSpacing(\"2.5\")")).unwrap();
assert_eq!(spacing(&s, obj), 2.5, "{kind}");
// Nothing is returned (`0x79fbbe xor eax,eax`).
assert_eq!(
s.eval::<i64>(&format!(
"return (function(...) return arg.n end)({obj}:SetSpacing(1))"
))
.unwrap(),
0,
"{kind}"
);
}
}
/// Anything `lua_isnumber` refuses raises `Usage: %s:SetSpacing(spacing)` (`0x87c708`), naming the
/// receiver or `<unnamed>`.
#[test]
fn a_non_number_raises_the_usage_line() {
let s = receivers();
for &(obj, kind) in KINDS {
let want = format!("Usage: {obj}:SetSpacing(spacing)");
for bad in ["", "nil", "false", "\"wide\"", "{}"] {
let err = s
.run(&format!("{obj}:SetSpacing({bad})"))
.expect_err("a non-number raises");
assert!(
err.to_string().contains(&want),
"{kind}: SetSpacing({bad}) raised {err}, not {want:?}"
);
}
}
s.run("U = CreateFrame('Frame'):CreateFontString()")
.unwrap();
let err = s.run("U:SetSpacing()").unwrap_err();
assert!(
err.to_string()
.contains("Usage: <unnamed>:SetSpacing(spacing)"),
"{err}"
);
}
/// Only the FontString clamps: its setter `0x772240` stores 0 for a negative and its getter reads
/// that field, where the shared setter stores the number as given (`0x79fba2`).
#[test]
fn only_the_fontstring_clamps_a_negative_spacing() {
let s = receivers();
for &(obj, kind) in KINDS {
s.run(&format!("{obj}:SetSpacing(-3)")).unwrap();
let want = if kind == "FontString" { 0.0 } else { -3.0 };
assert_eq!(spacing(&s, obj), want, "{kind}");
}
}
/// A FontString inherits its font object's spacing like the other font fields (the merge
/// `0x770800` at `0x7708fd`), live, until it sets its own, which clears inherit bit `0x10`
/// (`0x79e37b`).
#[test]
fn a_fontstring_inherits_spacing_from_its_font_object_until_it_sets_its_own() {
let s = script();
s.run(
r#"
CreateFont("GapFont")
GapFont:SetSpacing(5)
local f = CreateFrame("Frame")
A = f:CreateFontString()
B = f:CreateFontString()
A:SetFontObject(GapFont)
B:SetFontObject("GapFont")
B:SetSpacing(2)
"#,
)
.unwrap();
assert_eq!(spacing(&s, "A"), 5.0);
assert_eq!(spacing(&s, "B"), 2.0);
s.run("GapFont:SetSpacing(6)").unwrap();
assert_eq!(spacing(&s, "A"), 6.0, "the link is live");
assert_eq!(
spacing(&s, "B"),
2.0,
"a local set survives the font object's change"
);
s.run("GapFont:SetSpacing(-1)").unwrap();
assert_eq!(
spacing(&s, "A"),
0.0,
"inheriting runs through the clamping setter"
);
}
/// `spacing=` is an attribute of `<Font>` (`0x783ee0`) and of `<FontString>` (`0x771257`); the
/// FontString's beats the one its font object passes down.
#[test]
fn the_spacing_attribute_reaches_fonts_and_fontstrings() {
let s = script();
let doc = crate::framexml::parse(
r#"<Ui>
<Font name="XGapFont" font="Fonts\FRIZQT__.TTF" spacing="3">
<FontHeight><AbsValue val="12"/></FontHeight>
</Font>
<Frame name="XGapHost">
<Layers><Layer level="ARTWORK">
<FontString name="XInherit" inherits="XGapFont"/>
<FontString name="XOwn" inherits="XGapFont" spacing="7"/>
<FontString name="XPlain" spacing="2"/>
</Layer></Layers>
</Frame>
</Ui>"#,
)
.expect("valid FrameXML");
let report = crate::loader::load(&s, &doc, &|_| None);
assert!(report.errors.is_empty(), "{:?}", report.errors);
assert_eq!(spacing(&s, "XGapFont"), 3.0);
assert_eq!(spacing(&s, "XInherit"), 3.0);
assert_eq!(spacing(&s, "XOwn"), 7.0);
assert_eq!(spacing(&s, "XPlain"), 2.0);
}
/// SpellAlert's alert frame is a ScrollingMessageFrame whose `OnLoad` calls `SetSpacing` before the
/// rest of its setup; the call no longer aborts the handler.
#[test]
fn a_scrolling_message_frames_onload_runs_past_set_spacing() {
let s = script();
let doc = crate::framexml::parse(
r#"<Ui>
<ScrollingMessageFrame name="AlertProbe">
<Scripts><OnLoad>
this:SetSpacing(2)
this:SetMaxLines(3)
this:SetTimeVisible(5)
this:SetFadeDuration(1.5)
this:SetAlpha(0.5)
ALERT_PROBE_DONE = true
</OnLoad></Scripts>
</ScrollingMessageFrame>
</Ui>"#,
)
.expect("valid FrameXML");
let report = crate::loader::load(&s, &doc, &|_| None);
assert!(report.errors.is_empty(), "{:?}", report.errors);
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert!(s.eval::<bool>("return ALERT_PROBE_DONE == true").unwrap());
assert_eq!(spacing(&s, "AlertProbe"), 2.0);
assert_eq!(s.eval::<i64>("return AlertProbe:GetMaxLines()").unwrap(), 3);
assert_eq!(
s.eval::<f32>("return AlertProbe:GetFadeDuration()")
.unwrap(),
1.5
);
}
/// The `(text, bottom, top)` of every message band on screen.
fn bands(s: &UiScript) -> Vec<(String, f32, f32)> {
s.extract()
.iter()
.filter_map(|q| match (&q.content, q.rect) {
(QuadContent::Text { text: Some(t), .. }, Some(r)) => {
Some((t.clone(), r.bottom, r.top))
}
_ => None,
})
.collect()
}
/// A scrolling frame stacks each message's line string on the previous one's top `spacing` higher
/// (`0x788954`-`0x788975`), and a wrapped message is `rows·S + (rows−1)·gap` tall (`0x5c2070`), so
/// every row costs `S + gap` and the newest sits on the bottom edge.
#[test]
fn a_scrolling_frame_steps_its_messages_and_rows_by_the_spacing() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
"local f = CreateFrame('ScrollingMessageFrame', 'GapChat')\n\
f:SetPoint('BOTTOMLEFT', 0, 90)\n\
f:SetWidth(400)\n\
f:SetHeight(100)\n\
f:SetFont('Fonts\\\\FRIZQT__.TTF', 14)\n\
f:SetSpacing(4)\n\
f:AddMessage('old', 1, 1, 1)\n\
f:AddMessage('wrapped', 1, 1, 1)\n\
f:AddMessage('new', 1, 1, 1)",
)
.unwrap();
s.resolve();
let answers: Vec<(u32, u32, u16, u64)> = s
.message_lines_needing_measure()
.iter()
.map(|r| (r.frame, r.index, if r.index == 1 { 2 } else { 1 }, r.key))
.collect();
s.set_message_line_rows(&answers);
let got = bands(&s);
let want = [
("new", 90.0, 104.0),
("wrapped", 108.0, 140.0),
("old", 144.0, 158.0),
];
assert_eq!(got.len(), want.len(), "{got:?}");
for ((t, b, top), (wt, wb, wtop)) in got.iter().zip(want) {
assert_eq!(t, wt);
assert!(
(b - wb).abs() < 0.01 && (top - wtop).abs() < 0.01,
"{t}: [{b}, {top}] against [{wb}, {wtop}]"
);
}
}
/// The plain class fits `h / (S + gap)` lines (`0x785f03`-`0x785f61`).
#[test]
fn a_plain_message_frame_fits_rows_at_the_spaced_pitch() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
"local f = CreateFrame('MessageFrame', 'GapErrors')\n\
f:SetPoint('BOTTOMLEFT', 0, 0)\n\
f:SetWidth(400)\n\
f:SetHeight(42)\n\
f:SetFading(false)\n\
f:SetSpacing(4)",
)
.unwrap();
for n in 0..9 {
s.run(&format!("GapErrors:AddMessage('m{n}', 1, 1, 1)"))
.unwrap();
}
s.resolve();
s.tick(0.1);
let kept = {
let m = s.model_ref();
let h = m.arena.lookup("GapErrors").expect("named frame");
m.arena
.frame(h)
.and_then(|f| f.kind_state.message_lines())
.map_or(0, |l| l.len())
};
assert_eq!(kept, 2, "42 / (14 + 4) holds two rows");
}
/// SimpleHTML's element font is a font instance: its getter answers what was set, negative
/// included, while the block step reads the block FontString's clamped field (`0x78aeaf`).
#[test]
fn simplehtml_keeps_a_negative_spacing_but_steps_its_blocks_by_zero() {
let s = script();
s.run(
r#"
local f = CreateFrame("SimpleHTML", "GapPage")
f:SetWidth(200)
f:SetFont("Fonts\\FRIZQT__.TTF", 12)
f:SetSpacing(-9)
f:SetText("<HTML><BODY><P>one</P><P>two</P></BODY></HTML>")
"#,
)
.unwrap();
assert_eq!(spacing(&s, "GapPage"), -9.0);
let m = s.model_ref();
let fh = m.arena.lookup("GapPage").expect("frame");
let blocks = &m.simple_html.get(&fh).expect("state").blocks;
let second = m.region_data.get(&blocks[1]).expect("block 1");
let y = second
.anchors
.get(crate::layout::Point::TopLeft)
.expect("the TOPLEFT anchor")
.y_off;
assert_eq!(y, 0.0, "the step is the clamped spacing");
}
/// How many messages a plain frame keeps after a tick: its capacity.
fn plain_capacity(height: f32, spacing: &str) -> usize {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(&format!(
"local f = CreateFrame('MessageFrame', 'CapFrame')\n\
f:SetPoint('BOTTOMLEFT', 0, 0)\n\
f:SetWidth(400)\n\
f:SetHeight({height})\n\
f:SetFading(false)\n\
f:SetFont('Fonts\\\\FRIZQT__.TTF', 14)\n\
f:SetSpacing({spacing})"
))
.unwrap();
for n in 0..12 {
s.run(&format!("CapFrame:AddMessage('m{n}', 1, 1, 1)"))
.unwrap();
}
s.resolve();
s.tick(0.1);
let m = s.model_ref();
let h = m.arena.lookup("CapFrame").expect("named frame");
m.arena
.frame(h)
.and_then(|f| f.kind_state.message_lines())
.map_or(0, |l| l.len())
}
/// The plain class's pitch reads the font's spacing unclamped (`0x785f06`) and pixel-snaps both
/// terms (`0x766750`, `0x785f13`/`0x785f25`): a negative one truncates toward zero and shortens the
/// pitch, a fractional positive one rounds up, and a pitch of 0 or less fits nothing (`0x785f8f`).
#[test]
fn a_plain_message_frames_pitch_is_the_snapped_raw_spacing() {
assert_eq!(plain_capacity(42.0, "-4"), 4, "42 / (14 - 4)");
assert_eq!(plain_capacity(100.0, "2.5"), 5, "100 / (14 + 3)");
assert_eq!(plain_capacity(42.0, "-2.5"), 3, "42 / (14 - 2)");
// A zero pitch fits nothing, so no slot draws.
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
"local f = CreateFrame('MessageFrame', 'NoPitch')\n\
f:SetPoint('BOTTOMLEFT', 0, 0)\n\
f:SetWidth(400)\n\
f:SetHeight(42)\n\
f:SetFont('Fonts\\\\FRIZQT__.TTF', 14)\n\
f:SetSpacing(-14)\n\
f:AddMessage('m', 1, 1, 1)",
)
.unwrap();
s.resolve();
s.tick(0.1);
assert!(bands(&s).is_empty(), "{:?}", bands(&s));
}
/// The plain class steps its slots by the same raw, snapped pitch (`0x786184`), so a negative
/// spacing overlaps them.
#[test]
fn a_plain_message_frames_slots_step_by_the_raw_pitch() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
"local f = CreateFrame('MessageFrame', 'SlotFrame')\n\
f:SetPoint('BOTTOMLEFT', 0, 100)\n\
f:SetWidth(400)\n\
f:SetHeight(60)\n\
f:SetInsertMode('BOTTOM')\n\
f:SetFont('Fonts\\\\FRIZQT__.TTF', 14)\n\
f:SetSpacing(-4)\n\
f:AddMessage('one', 1, 1, 1)\n\
f:AddMessage('two', 1, 1, 1)",
)
.unwrap();
s.resolve();
let mut got = bands(&s);
got.sort_by(|a, b| a.1.total_cmp(&b.1));
assert_eq!(got.len(), 2, "{got:?}");
assert_eq!(got[0].0, "two");
assert!((got[0].1 - 100.0).abs() < 0.01, "{got:?}");
assert_eq!(got[1].0, "one");
assert!(
(got[1].1 - 110.0).abs() < 0.01,
"one slot up is 10: {got:?}"
);
}
/// A scrolling frame charges each message its height plus the line string's spacing rounded up to
/// a whole pixel (`0x78899e` → `0x766750`), and a wrapped message's inner gap likewise.
#[test]
fn a_scrolling_frame_rounds_a_fractional_spacing_up() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
"local f = CreateFrame('ScrollingMessageFrame', 'GapChat2')\n\
f:SetPoint('BOTTOMLEFT', 0, 90)\n\
f:SetWidth(400)\n\
f:SetHeight(100)\n\
f:SetFont('Fonts\\\\FRIZQT__.TTF', 14)\n\
f:SetSpacing(2.5)\n\
f:AddMessage('old', 1, 1, 1)\n\
f:AddMessage('wrapped', 1, 1, 1)\n\
f:AddMessage('new', 1, 1, 1)",
)
.unwrap();
s.resolve();
let answers: Vec<(u32, u32, u16, u64)> = s
.message_lines_needing_measure()
.iter()
.map(|r| (r.frame, r.index, if r.index == 1 { 2 } else { 1 }, r.key))
.collect();
s.set_message_line_rows(&answers);
let got = bands(&s);
let want = [
("new", 90.0, 104.0),
("wrapped", 107.0, 138.0),
("old", 141.0, 155.0),
];
assert_eq!(got.len(), want.len(), "{got:?}");
for ((t, b, top), (wt, wb, wtop)) in got.iter().zip(want) {
assert_eq!(t, wt);
assert!(
(b - wb).abs() < 0.01 && (top - wtop).abs() < 0.01,
"{t}: [{b}, {top}] against [{wb}, {wtop}]"
);
}
}
/// `spacing=` is read with `atof` (`0x64aaa0`): the numeric prefix, 0 for none.
#[test]
fn the_spacing_attribute_is_read_with_atof() {
let s = script();
let doc = crate::framexml::parse(
r#"<Ui>
<Font name="AtofFont" font="Fonts\FRIZQT__.TTF" spacing=" 3px"/>
<Frame name="AtofHost">
<Layers><Layer level="ARTWORK">
<FontString name="AtofOwn" spacing="2.5em"/>
</Layer></Layers>
</Frame>
</Ui>"#,
)
.expect("valid FrameXML");
let report = crate::loader::load(&s, &doc, &|_| None);
assert!(report.errors.is_empty(), "{:?}", report.errors);
assert_eq!(spacing(&s, "AtofFont"), 3.0);
assert_eq!(spacing(&s, "AtofOwn"), 2.5);
}
@@ -33,6 +33,7 @@ mod keep_rect;
mod keyboard;
mod layout_gate;
mod layout_structure;
mod line_spacing;
mod measure;
mod minimap;
mod model_clock;
@@ -47,6 +48,7 @@ mod region_map;
mod regions;
mod resize_bounds;
mod scrollframe;
mod set_font_args;
mod setparent;
mod shown_bit;
mod simplehtml;
@@ -575,9 +575,8 @@ fn non_space_wrap_defaults_on_and_answers_one_or_nil() {
}
/// EditBox's registrar table (`.data 0x87bb68`, 48 entries, `mov edx,0x30` at `0x799ab5`) opens
/// with the font block each text-bearing type re-declares over the shared impl `0x79f210`: its
/// fourteen font verbs answer, its `SetSpacing`/`GetSpacing` (#10, #11) are not installed as
/// nothing models line spacing, and other tables' names do not leak onto it.
/// with the font block each text-bearing type re-declares over the shared impls (`0x79f210`,
/// `0x79fb40`): its sixteen font verbs answer, and other tables' names do not leak onto it.
#[test]
fn editbox_carries_the_font_block_its_registrar_table_declares() {
let s = crate::script::UiScript::new().unwrap();
@@ -594,6 +593,8 @@ fn editbox_carries_the_font_block_its_registrar_table_declares() {
"GetShadowColor",
"SetShadowOffset",
"GetShadowOffset",
"SetSpacing",
"GetSpacing",
"SetJustifyH",
"GetJustifyH",
"SetJustifyV",
@@ -608,9 +609,6 @@ fn editbox_carries_the_font_block_its_registrar_table_declares() {
}
for name in [
// On EditBox's table, not installed: nothing models line spacing.
"SetSpacing",
"GetSpacing",
// Not on EditBox's table.
"CopyFontObject",
"SetNonSpaceWrap",
@@ -0,0 +1,223 @@
//! `SetFont`'s argument gate, one implementation (`0x79f210`) behind every kind's binding: the
//! Font object, FontString, EditBox, the two message frames, SimpleHTML and Button.
use super::common::script;
use crate::script::UiScript;
/// The seven receivers, each named so the usage line can name it, as `(global, kind)`.
const KINDS: &[(&str, &str)] = &[
("TFont", "Font"),
("TFS", "FontString"),
("TEB", "EditBox"),
("TMF", "MessageFrame"),
("TSMF", "ScrollingMessageFrame"),
("THTML", "SimpleHTML"),
("TB", "Button"),
];
fn receivers() -> UiScript {
let s = script();
s.run(
r#"
CreateFont("TFont")
CreateFrame("Frame", "THost"):CreateFontString("TFS")
CreateFrame("EditBox", "TEB")
CreateFrame("MessageFrame", "TMF")
CreateFrame("ScrollingMessageFrame", "TSMF")
CreateFrame("SimpleHTML", "THTML")
CreateFrame("Button", "TB")
"#,
)
.unwrap();
s
}
fn flags_of(s: &UiScript, obj: &str) -> String {
s.eval::<(Option<String>, f32, String)>(&format!("return {obj}:GetFont()"))
.unwrap()
.2
}
/// The fourth argument is read only when `lua_isstring` (`0x79f287`) passes, a string or a number;
/// `false`, `nil` or anything else is no flags. pfUI's `SetFont(path, 13, outline and "OUTLINE")`
/// passes `false` when the option is off.
#[test]
fn set_font_reads_flags_only_from_a_string_or_a_number() {
let s = receivers();
for &(obj, kind) in KINDS {
for (arg, want) in [
("\"OUTLINE\"", "OUTLINE"),
("\"THICKOUTLINE\"", "THICKOUTLINE"),
("false", ""),
("nil", ""),
("7", ""),
("{}", ""),
] {
s.run(&format!(
"{obj}:SetFont(\"Fonts\\\\FRIZQT__.TTF\", 13, \"OUTLINE\")"
))
.unwrap_or_else(|e| panic!("{kind}: SetFont with OUTLINE raised {e}"));
s.run(&format!(
"{obj}:SetFont(\"Fonts\\\\FRIZQT__.TTF\", 13, {arg})"
))
.unwrap_or_else(|e| panic!("{kind}: SetFont(path, 13, {arg}) raised {e}"));
// A flags word is written on every load (`0x79f334`), zero when the gate fails, so no
// flags clears an outline the instance had.
assert_eq!(flags_of(&s, obj), want, "{kind}: SetFont(path, 13, {arg})");
}
}
}
/// A missing third argument is the same as `nil`: no flags.
#[test]
fn set_font_without_flags_clears_the_outline() {
let s = receivers();
for &(obj, kind) in KINDS {
s.run(&format!(
"{obj}:SetFont(\"Fonts\\\\FRIZQT__.TTF\", 13, \"OUTLINE\")
{obj}:SetFont(\"Fonts\\\\FRIZQT__.TTF\", 13)"
))
.unwrap();
assert_eq!(flags_of(&s, obj), "", "{kind}");
}
}
/// Only the path (`lua_isstring`, `0x79f225`) and the height (`lua_isnumber`, `0x79f239`) raise,
/// with `Usage: %s:SetFont("font", fontHeight [, flags])` (`0x87c69c`), `%s` being the receiver's
/// name or `<unnamed>` (`0x84c7f0`), as every shim passes it.
#[test]
fn a_bad_path_or_height_raises_the_usage_line_naming_the_receiver() {
let s = receivers();
for &(obj, kind) in KINDS {
let want = format!("Usage: {obj}:SetFont(\"font\", fontHeight [, flags])");
for bad in [
"",
"\"Fonts\\\\FRIZQT__.TTF\"",
"nil, 12",
"{}, 12",
"false, 12",
"\"Fonts\\\\FRIZQT__.TTF\", \"tall\"",
"\"Fonts\\\\FRIZQT__.TTF\", false",
] {
let err = s
.run(&format!("{obj}:SetFont({bad})"))
.expect_err("a bad path or height raises");
assert!(
err.to_string().contains(&want),
"{kind}: SetFont({bad}) raised {err}, not {want:?}"
);
}
}
s.run(
r#"
U1 = CreateFrame("Frame"):CreateFontString()
U2 = CreateFrame("EditBox")
U3 = CreateFrame("Button")
"#,
)
.unwrap();
for obj in ["U1", "U2", "U3"] {
let err = s.run(&format!("{obj}:SetFont()")).unwrap_err();
assert!(
err.to_string()
.contains("Usage: <unnamed>:SetFont(\"font\", fontHeight [, flags])"),
"{obj}: {err}"
);
}
}
/// Both gates coerce: a number path is its string form and a numeric string height passes,
/// whitespace and all, as `lua_isnumber`'s string conversion allows.
#[test]
fn the_path_and_height_gates_coerce() {
let s = receivers();
for &(obj, kind) in KINDS {
s.run(&format!(
"{obj}:SetFont(\"Fonts\\\\FRIZQT__.TTF\", \" 14 \")"
))
.unwrap_or_else(|e| panic!("{kind}: a padded numeric string height raised {e}"));
let (_, h, _) = s
.eval::<(Option<String>, f32, String)>(&format!("return {obj}:GetFont()"))
.unwrap();
assert_eq!(h, 14.0, "{kind}");
}
}
/// The kinds behind the shared implementation (`0x79f210`), which answers nil and writes nothing
/// on a failed load (`0x79f30f` → `0x79f361`, before the stores at `0x79f321`-`0x79f337`) or for a
/// height of 0 or less, without loading (`0x79f2f1`-`0x79f2ff`).
#[test]
fn a_refused_load_or_a_non_positive_height_changes_nothing_on_the_shared_path() {
let mut s = receivers();
s.set_font_probe(Box::new(|path| {
path.eq_ignore_ascii_case("fonts\\frizqt__.ttf")
}));
for &(obj, kind) in KINDS.iter().filter(|(_, k)| *k != "FontString") {
s.run(&format!(
"{obj}:SetFont(\"Fonts\\\\FRIZQT__.TTF\", 12, \"OUTLINE\")"
))
.unwrap();
for bad in [
"\"Interface\\\\AddOns\\\\X\\\\gone.ttf\", 30",
"\"\", 30",
"\"Fonts\\\\FRIZQT__.TTF\", 0",
"\"Fonts\\\\FRIZQT__.TTF\", -3",
] {
let answer = s
.eval::<mlua::MultiValue>(&format!("return {obj}:SetFont({bad})"))
.unwrap_or_else(|e| panic!("{kind}: SetFont({bad}) raised {e}"));
// Button's binding discards the answer (`0x780880`); the others push nil.
if kind != "Button" {
assert!(
matches!(answer.iter().next(), Some(mlua::Value::Nil)),
"{kind}: SetFont({bad}) answered {answer:?}"
);
}
let got = s
.eval::<(Option<String>, f32, String)>(&format!("return {obj}:GetFont()"))
.unwrap();
assert_eq!(
got,
(
Some("Fonts\\FRIZQT__.TTF".to_string()),
12.0,
"OUTLINE".to_string()
),
"{kind}: SetFont({bad}) must change nothing"
);
}
}
}
/// The FontString's own load (`0x7714f0`) returns before its height store (`0x771571`-`0x77157b`)
/// when the face fails; a height of exactly 0 skips the load, stores 0, frees the font (`0x7715b3`)
/// and answers 1.
#[test]
fn a_fontstrings_refused_load_changes_nothing_and_a_zero_height_frees_its_font() {
let mut s = receivers();
s.set_font_probe(Box::new(|path| {
path.eq_ignore_ascii_case("fonts\\frizqt__.ttf")
}));
s.run("TFS:SetFont(\"Fonts\\\\FRIZQT__.TTF\", 12, \"OUTLINE\")")
.unwrap();
assert!(s
.eval::<bool>("return TFS:SetFont(\"Interface\\\\AddOns\\\\X\\\\gone.ttf\", 30) == nil")
.unwrap());
assert_eq!(
s.eval::<(Option<String>, f32, String)>("return TFS:GetFont()")
.unwrap(),
(
Some("Fonts\\FRIZQT__.TTF".to_string()),
12.0,
"OUTLINE".to_string()
)
);
assert!(s
.eval::<bool>("return TFS:SetFont(\"Interface\\\\AddOns\\\\X\\\\gone.ttf\", 0) == 1")
.unwrap());
assert_eq!(
s.eval::<(Option<String>, f32, String)>("return TFS:GetFont()")
.unwrap(),
(None, 0.0, String::new())
);
}
@@ -226,9 +226,10 @@ fn spacing_steps_the_blocks_apart() {
"block N+1 sits at block N's BOTTOMLEFT displaced by -spacing"
);
assert_eq!(s.eval::<f32>("return Page:GetSpacing()").unwrap(), 4.0);
// A negative spacing is clamped to 0 before it is stored (`0x772246`).
// The element font keeps a negative as given (`0x79fba2`); only the block FontString's
// setter clamps it (`0x772246`), for the next `SetText`.
s.run("Page:SetSpacing(-9)").unwrap();
assert_eq!(s.eval::<f32>("return Page:GetSpacing()").unwrap(), 0.0);
assert_eq!(s.eval::<f32>("return Page:GetSpacing()").unwrap(), -9.0);
measure_at_16px(&mut s);
assert_eq!(
@@ -82,6 +82,7 @@ fn set_font_object_repoints_fontstring() {
justify_h: None,
justify_v: None,
shadow: None,
spacing: None,
},
);
s.register_font_object(
@@ -94,6 +95,7 @@ fn set_font_object_repoints_fontstring() {
justify_h: None,
justify_v: None,
shadow: None,
spacing: None,
},
);
s.run(
@@ -193,6 +195,7 @@ fn justify_v_defaults_middle_and_overrides() {
justify_h: None,
justify_v: Some(JustifyV::Top),
shadow: None,
spacing: None,
},
);
s.run("fs:SetFontObject('TopFont')").unwrap();
@@ -114,17 +114,6 @@ const NOT_YET_ANSWERED: &[(&str, &str, &str)] = &[
"Button CheckButton LootButton",
"the same family",
),
// ── Line spacing, on the five classes that carry text ──
(
"GetSpacing",
"EditBox Font FontString MessageFrame ScrollingMessageFrame",
"line spacing is not modelled by `ui_text`'s layout; no caller anywhere measured",
),
(
"SetSpacing",
"EditBox Font FontString MessageFrame ScrollingMessageFrame",
"the same — the setter is a layout change, not a stored value",
),
// ── EditBox mode read-backs ──
(
"IsAutoFocus",
+26 -1
View File
@@ -144,6 +144,9 @@ pub enum QuadContent {
/// 1.12's `MasterFont` sets `(1,-1)` black, so nearly every stock font has one.
shadow: Option<FontShadow>,
outline: Outline,
/// The gap between lines in logical px, never negative: the line step is the font height
/// plus this, rounded up to a whole device pixel (`LayoutLines` `0x5cdc20`).
spacing: f32,
/// `SetAlphaGradient`: before `start` opaque, the next `length` characters fading from 1
/// to 0, the rest invisible.
alpha_gradient: Option<(f32, f32)>,
@@ -283,6 +286,8 @@ pub struct FontObject {
pub outline: Outline,
pub justify_h: Option<JustifyH>,
pub justify_v: Option<JustifyV>,
/// The gap between lines (`spacing=`, `SetSpacing`), stored as given (`+0x50`).
pub spacing: Option<f32>,
}
/// One entry of [`UiScript::extract`](super::UiScript::extract)'s render list, in the client's
@@ -405,6 +410,11 @@ pub(crate) struct RegionData {
pub(crate) outline: Outline,
/// Resolved drop shadow (font object `<Shadow>`), drawn behind the glyphs.
pub(crate) font_shadow: Option<FontShadow>,
/// The gap between lines, as set (`SetSpacing`, XML `spacing=` or the font object): a
/// FontString's own setter clamps it (`0x772240`), the message frames' and the EditBox's font
/// stores it raw (`0x79fb40`). A line string reads it through [`Self::line_gap`]; the plain
/// message frame's pitch reads it raw (`0x785f06`).
pub(crate) spacing: f32,
/// The host-measured size of a FontString with no explicit height.
pub(crate) measured: Option<MeasuredText>,
}
@@ -429,6 +439,8 @@ pub(crate) struct FontExplicit {
pub(crate) justify_h: bool,
/// `SetJustifyV`, or XML `justifyV=`.
pub(crate) justify_v: bool,
/// `SetSpacing`, or XML `spacing=` (inherit bit `0x10`).
pub(crate) spacing: bool,
}
/// A cached host measurement of a FontString's laid-out text.
@@ -456,7 +468,13 @@ impl MeasuredText {
}
impl RegionData {
/// The measure-cache key of the text, font, wrap width, outline and owner scale. A Lua metric
/// The gap a line string lays its lines out by: [`Self::spacing`] clamped at 0, as a line
/// string stores it through `0x772240`.
pub(crate) fn line_gap(&self) -> f32 {
self.spacing.max(0.0)
}
/// The measure-cache key of the text, font, wrap width, outline, line gap and owner scale. A Lua metric
/// read treats a measure under another key as absent; the layout keeps the old box until the
/// new one lands, except for empty text, which is never measured.
pub(crate) fn measure_key(&self, scale: f32) -> u64 {
@@ -478,6 +496,7 @@ impl RegionData {
// The outline changes the measure: a THICK one steps each glyph 1 px further
// (`GlyphStepBase 0x5ca2b0`).
(self.outline as u8).hash(&mut hasher);
self.line_gap().to_bits().hash(&mut hasher);
scale.to_bits().hash(&mut hasher);
hasher.finish()
}
@@ -495,6 +514,9 @@ pub struct EditBoxAdvanceRequest {
pub height: Option<f32>,
/// A THICK outline adds 1 px to every glyph step (`GlyphStepBase 0x5ca2b0`).
pub outline: Outline,
/// The gap between a multiline box's rows, in frame-local units: the row pitch is the height
/// plus it (`0x77d133`-`0x77d161`).
pub spacing: f32,
/// The box's `effective_scale`. The host measures at `height × scale × seam` but divides by
/// the seam alone, so advances come back in screen UI units, like the mouse feed and the
/// box's rect; a [`MeasureRequest`] answer is frame-local instead.
@@ -548,6 +570,9 @@ pub struct MeasureRequest {
pub wrap_width: Option<f32>,
/// A THICK outline adds 1 px to every glyph step (`GlyphStepBase 0x5ca2b0`).
pub outline: Outline,
/// The gap between wrapped lines in frame-local units ([`RegionData::line_gap`]), scaled like
/// the height: the measure is `N·S + (N−1)·gap` (`0x5c2070`).
pub spacing: f32,
/// The owner's `effective_scale`. The host measures at `font_height × scale × seam` and
/// divides by that whole product: advances step to whole pixels, so a measure at the
/// unscaled size is not proportional to the drawn one.