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:
co-authored by
Claude Opus 5.5
parent
87bf5904ff
commit
75edfd7ca5
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)?;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user