Files
benilla-octo/crates/benilla-app/src/ui_text/layout/mod.rs
T
SamandClaude Fable 5 924cdd903f glyphs at their true size — the ladder that scrambled text is gone
- text rasterizes every glyph at its exact on-screen size, on demand — the fixed size ladder and its rescale-after-snap are gone, the machinery that left names scrambled and lines off their baseline after a window resize
- the worldport ack now rides the load's completion the way the real client sends it, and the near-teleport echo stands alone as the whole handshake
- a floating combat number whose anchor leaves the screen is destroyed rather than dragged to the border, and a trailing line break ends its line instead of opening a new one
- entering the world, the loading cover provably presents before the UI builds behind it, holds the world's audio while it is up, and fails closed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 17:45:34 +07:00

769 lines
37 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! **The emit pass** — a laid-out string into [`UiQuad`]s.
//!
//! Its other half is [`measure`], which decides widths, wraps and fits. The two used to compute in
//! different spaces with a rescale between them; since decision 1342 the raster size is an exact
//! integer device-pixel size, so both walk the same per-character table and the pen here and the
//! sum there are the same arithmetic. See [`super::engine`] for why that matters more than it
//! sounds like it should.
use bevy::math::Rect;
use bevy::prelude::*;
use benilla_ui::script::{JustifyH, JustifyV, Outline};
use crate::ui_pass::{UiQuad, UvRect};
use super::engine::TextEngine;
use super::markup::{fontstring_lines, ColorRun};
mod measure;
mod overflow;
mod wrap;
/// The width law itself, for the differential test in [`super::engine`] that pins it against
/// shaping whole strings. Not a production seam — every caller goes through [`measure_text`].
#[cfg(test)]
pub(super) use measure::measure_line_width_for_test;
pub(crate) use measure::{
ellipsize_to_fit, line_advances, line_origin, line_rows, measure_text, measure_wrapped_rows,
};
/// The smallest resolved rect width (px) treated as a **pinned wrap constraint**. A `FontString`
/// with no explicit `<Size>`/`setAllPoints`/opposing anchor resolves through [`crate::ui_text`]'s
/// caller to a near-zero-width rect (the single-point case — the engine's layout derives
/// `left == right` from the pinned center, see `benilla-ui`'s `layout` resolver); such a rect is
/// "no width constraint" and text lays out on one line from its justify point, never wrapped. Only
/// a rect wider than this engages word wrap. No shipped wrappable FontString is this narrow (the
/// narrowest, a merchant row's price field, is tens of px); the degenerate titles are ~0.
const WRAP_MIN_WIDTH: f32 = 1.0;
/// The smallest resolved rect height (px) treated as a **pinned height limit** — the vertical twin
/// of [`WRAP_MIN_WIDTH`]: a degenerate (single-point / pre-measure) rect resolves to ~zero height,
/// which is "no height constraint", never "a zero-line box". Only a taller rect engages the
/// line-stack stop ([`overflow::lines_allowed`]) and the ellipsis gate ([`ellipsize_to_fit`]).
const HEIGHT_LIMIT_MIN: f32 = 1.0;
/// A `FontString`'s two justification axes (`justifyH`/`justifyV`), bundled — they arrive together
/// from the region's paint and travel together into [`layout_text_quads`].
#[derive(Clone, Copy)]
pub(crate) struct Justify {
pub h: JustifyH,
pub v: JustifyV,
}
/// The resolved face + size a `FontString` draws with (from its font object / `SetFont`) — bundled
/// so [`layout_text_quads`] stays within one argument budget. `None`s fall back to Friz Quadrata at
/// [`super::DEFAULT_FONT_SIZE`].
#[derive(Clone, Copy)]
pub(crate) struct FontSpec<'a> {
pub path: Option<&'a str>,
/// The **logical** height, seam already folded in ([`super::drawn_px`]). The engine rounds it
/// to whole device pixels ([`TextEngine::ppem`]) — there is no "between sizes" any more.
pub height: Option<f32>,
/// The font's TRUE glyph outline ([`Outline::None`] for the common case). It selects the
/// composite cell variant (ring+fill in one texture) AND, for THICK only, the step law's extra
/// `+1` (`GlyphStepBase` 0x5ca2b0 biases solely under the THICK flag — wow-re
/// `outline-bake-tint.md`), so [`measure_text`] must see it too: a THICK outline changes
/// measured/wrapped width.
pub outline: Outline,
/// The write-on reveal (`SetAlphaGradient` — per-FontString paint state, like `outline`):
/// [`layout_text_quads`] multiplies each glyph's alpha by the ramp at its character position.
/// Riding the spec means the drop-shadow pass (`..spec`) inherits it for free — a revealing
/// character's shadow fades with its fill. `None` = draw whole; [`measure_text`] ignores it
/// (alpha never changes metrics).
pub alpha_gradient: Option<(f32, f32)>,
}
/// The client's per-glyph **step law** (verified in wow-re, `system/font`): the rasterizer stores
/// each glyph's step base as `(FT_advance >> 6) + 1.0` — the FreeType advance floored to integer
/// pixels **plus one** (`rasterize_glyph` 0x5d1120, `out[5]`); an outlined font biases it another
/// `+1.0` (`GlyphStepBase` 0x5ca2b0); `ComputeStep` 0x5ca2d0 consumes exactly that (plus
/// only-negative kerning scaled by the face's advance-adjust, then rounds). This extra tracking is
/// look-defining — it is why real client text reads wider/denser than the raw font metrics.
///
/// **Applied in PHYSICAL device pixels** (the ppem the face is actually rasterized at), then divided
/// back to logical — because that is where the real client's law lives: `FT_advance >> 6` floors the
/// device-pixel advance and the `+1`/`+1` biases are whole *device* pixels. Flooring in logical px
/// instead (the pre-fix shortcut) over-tracks on any window where `dpi ≠ 1`: at retina a
/// 6.385px-logical (12.77px-physical) ARIALN digit steps `(⌊12.77⌋+2)/2 = 7.0px`, not the logical
/// `⌊6.385⌋+2 = 8.0px` — a full logical pixel of extra gap *per digit*, proportionally huge on small
/// outlined numbers (bag counts, money) and the cause of their "digits too wide apart" look.
///
/// Deliberate v1 simplification (module doc): kerning is dropped entirely (the client applies only
/// *negative* pair kerns and rounds the sum — at UI sizes the rounded contribution is almost always
/// 0).
fn client_step(raw_physical_advance: f32, step_extra: f32, dpi: f32) -> f32 {
(raw_physical_advance.floor() + step_extra) / dpi
}
/// The step-law bias for a font's outline flag: the base `+1` for everyone, and one more `+1` for
/// **THICK only** — `GlyphStepBase 0x5ca2b0` adds its extra pixel solely under the THICK font flag
/// (wow-re `outline-bake-tint.md`, §5 + difftests, commit `f80ce699`, CORRECTING the earlier "any
/// outline" reading this module shipped with: a NORMAL-outlined font steps exactly like a plain
/// one, its 1px ring riding the base `+1` tracking; mirroring the cell pad onto the advance was the
/// flagged trap). NumberFontNormal digits (money, counts, hotkeys) are the visible beneficiaries:
/// one less pixel of gap per glyph.
fn step_extra_of(outline: Outline) -> f32 {
match outline {
Outline::Thick => 2.0,
_ => 1.0,
}
}
/// The client's SINGLE vertical anchor snap (`anchor_justify_snap 0x5cdf70` @`0x5ce051`,
/// `fontstring-vertical-placement.md`): the composed block top — rect top plus the justify offset —
/// rounds ONCE to the integer pixel grid; the per-line ladder and the within-cell ascender are added
/// as exact integers after it, never re-snapped. The client's round runs in the y-UP frame
/// (`round(viewH·anchor.y)`), so a half-pixel tie pushes the block UP on screen — in y-down units
/// that is round-half-DOWN, `ceil(y − 0.5)` (the money row's H=13/S=14 MIDDLE tie: offset −1, not 0
/// — the note's Case 3). Applied in LOGICAL units: the byte law at the 768-tall design resolution
/// (the 0292 posture), so a retina window seats text exactly as the design-resolution layout,
/// scaled.
fn snap_block_top(y: f32) -> f32 {
snap_block_top_law(y) + UI_SEAT_NUDGE
}
/// The pure byte law of the snap ([`snap_block_top`] minus the taste nudge) — kept separate so the
/// tests pin the verified law itself, independent of the dial.
fn snap_block_top_law(y: f32) -> f32 {
(y - 0.5).ceil()
}
/// The director's seat nudge: every UI FontString's text block sits one px LOWER than the byte
/// law's row. A deliberate **taste deviation** (decision 0351 — the 0104/0235 precedent: the
/// director's eye outranks the byte reading): the law is triple-confirmed (0338/0346), yet UI text
/// consistently reads high on the director's display; this is their call, applied at the one seat
/// every UI FontString shares ([`snap_block_top`] — world text's degenerate rects skip it, keeping
/// nameplates/combat text on their own approved seats). Rendering only — measures, wrap, and the
/// Lua metric echoes are untouched.
///
/// Public to the crate because a scissor that means to admit a block's ink has to know the seat
/// pushed it down — see the message-band clip in [`crate::ui_script`]'s text arm.
pub(crate) const UI_SEAT_NUDGE: f32 = 1.0;
/// The `SetAlphaGradient` ramp at one character position: opaque before `start`, a linear 1→0 fade
/// across the next `length` characters, invisible beyond (hard edge when `length` ≤ 0).
fn gradient_alpha(index: usize, start: f32, length: f32) -> f32 {
let i = index as f32;
if i < start {
1.0
} else if length > 0.0 && i < start + length {
1.0 - (i - start) / length
} else {
0.0
}
}
/// A clickable hyperlink span the glyph layout produced: the union rect (y-down screen px, this
/// layout's own space) of one link's glyphs on ONE laid-out line — a wrapped link yields one span
/// per line — plus the link payload and its full `|H…|h…|h` markup for
/// `OnHyperlinkClick(link, markup, button)`.
pub(crate) struct LinkSpan {
pub(crate) rect: Rect,
pub(crate) link: String,
pub(crate) markup: String,
}
/// Lays out `text` within `rect` (screen px, **y-down** — the same space
/// [`crate::ui_script::extract::drive_script`] already flips frame rects into) and returns one
/// [`UiQuad`] per non-blank glyph, textured with the page its cell was packed into. `z_key` is
/// shared by every glyph — the owning `FontString` region's own [`crate::ui_pass`]-order key
/// (regions already sort after their frame and by draw layer/decl, so reusing it keeps text in the
/// client's total order with no extra bookkeeping; ties break by push order, i.e.
/// left-to-right/top-to-bottom, which is what a stable sort preserves). `base_color` is the region's
/// own color (`SetVertexColor`, default opaque white); `|cAARRGGBB`/`|r` runs in `text` override it
/// per the markup rules.
///
/// `justify_h` positions each line's runs horizontally *within* `rect`; `justify_v` positions the
/// line block vertically (default `Middle`, the client's FontString default; degenerate zero-height
/// rects keep top). Each line is measured once (a first pass laying glyphs at a zero line-origin),
/// then shifted by the justification offset.
pub(crate) fn layout_text_quads(
e: &mut TextEngine,
text: &str,
rect: Rect,
base_color: [f32; 4],
justify: Justify,
z_key: u64,
font: FontSpec,
) -> Vec<UiQuad> {
layout_text_quads_inner(e, text, rect, base_color, justify, z_key, font, None)
}
/// [`layout_text_quads`] that also collects the laid-out [`LinkSpan`]s — the message-frame path
/// (chat lines carry `|H` item/player links; the app feeds the spans back to the engine's click
/// hit-test, `benilla_ui::script::UiScript::set_link_spans`).
#[allow(clippy::too_many_arguments)] // the shared layout context, plus one out-param
pub(crate) fn layout_text_quads_links(
e: &mut TextEngine,
text: &str,
rect: Rect,
base_color: [f32; 4],
justify: Justify,
z_key: u64,
font: FontSpec,
links_out: &mut Vec<LinkSpan>,
) -> Vec<UiQuad> {
layout_text_quads_inner(
e,
text,
rect,
base_color,
justify,
z_key,
font,
Some(links_out),
)
}
#[allow(clippy::too_many_arguments)] // the public pair above is the real surface; this is their body
fn layout_text_quads_inner(
e: &mut TextEngine,
text: &str,
rect: Rect,
base_color: [f32; 4],
justify: Justify,
z_key: u64,
font: FontSpec,
mut links_out: Option<&mut Vec<LinkSpan>>,
) -> Vec<UiQuad> {
let gradient = font.alpha_gradient;
let r = measure::resolve(e, &font);
// **`AllGlyphsCached`** (`0x5c9fa0`): one pass to fill the caches, then the whole layout below
// is lookups. Markup bytes are ensured along with everything else, which costs a few cells for
// `|`/`c`/hex digits that a nearby run would have wanted anyway.
e.ensure_str(r.face, r.ppem, r.radius, text);
let e = &*e;
let dpi = e.dpi();
let sheet = e.sheet_image();
// Snap a logical coordinate to the nearest DEVICE pixel (a multiple of `1/dpi`), so glyph edges
// land on device pixels — the same integer-device-pixel snap the real client does, which keeps
// the cell crisp instead of resampled. A cell is an integer count of device texels, so the
// quad's far edge is then automatically device-aligned too.
//
// Note what this now buys that it could not before: every glyph on a line shares one `pen_y`
// and an integer physical `bearing_top`, so they all round by the same residual and the line is
// FLAT. Under the old size ladder a rescale ran after this and multiplied each already-rounded
// position by a non-integer factor, giving every letter its own sub-pixel phase — which is what
// "the letters aren't on a line" was (decision 1342).
let snap = |v: f32| (v * dpi).round() / dpi;
// The render lays the same lines the measure counted, or a MIDDLE-justified block seats
// against a height it does not have ([`fontstring_lines`], decision 1343).
let lines = fontstring_lines(text, base_color);
// The THICK ink rise (`fontstring-baseline-row.md`, §5 trio): the client's THICK quad shift
// (−2, `0x5cd0e1`) against its blit's baked seat nets the INK **1 row above** the plain
// baseline law; NORMAL cancels exactly (outline-invariant). Our composite cell reproduces the
// blit, so the quad carries the net: THICK cells rise 1 logical px.
let thick_rise = f32::from(u8::from(matches!(font.outline, Outline::Thick)));
// Word-wrap within the rect when it carries a pinned width; an unsized single-point FontString
// resolves to a ~zero-width rect (no constraint) and keeps its single-line behavior.
//
// The box is the box: `rect` is in the same space the pen lays out in, because there is only
// one space. Under the size ladder this pass measured a drawn-px rect against snapped-size
// glyphs unless it remembered to convert, and forgetting is what put the Main Menu's labels one
// character short (1339 — "Options" → "Option", with no ellipsis to show for it).
let mut render_lines: Vec<Vec<ColorRun>> = if rect.width() > WRAP_MIN_WIDTH {
lines
.iter()
.flat_map(|line| measure::wrap_line(e, r, line, rect.width()))
.collect()
} else {
lines
};
// The height-limit line stack (regime 2's vertical half, `CGxString+0x40`,
// `fontstring-overflow.md`): a height-pinned rect stops emitting wrapped lines when the
// accumulated pitch passes it. A FontString the ellipsis seam ([`ellipsize_to_fit`]) already
// truncated fits by construction, so this is the belt under any caller that skips that seam —
// in the client the same split: the ui truncate feeds the gx string, and the gx line stack
// clamps regardless.
if rect.height() > HEIGHT_LIMIT_MIN {
render_lines.truncate(overflow::lines_allowed(rect.height(), r.size));
}
let mut quads = Vec::new();
// `justifyV` positions the line *block* — N lines at the client's pitch, which IS the font
// height (line-step law: `lineStep = px(size) + spacing`, spacing 0 for all shipped UI;
// `LayoutLines` 0x5cdc20) — vertically within `rect`. The real FontString default is MIDDLE,
// which is what seats the money digits on their coin icons' centerline. A rect sized *by* the
// text (the host-measure round-trip's height-less FontStrings) has `height == block`, so the
// offset degenerates to 0 there — only explicitly-sized rects shift. A degenerate rect (unsized
// single-point anchor, pre-measure) keeps the v1 top placement rather than hoisting text above
// its anchor.
//
// NO outline pad in the pitch — the byte law (`fontstring-vertical-placement.md`, VERIFIED at
// `0x5cdc20`/`0x5cdf70`): the justify block is `h = N·S`, and the `+2r` lives ONLY in the atlas
// cell (`[font+0x178]`), which the layout never reads. (The pre-verdict `+2r` here seated every
// outlined MIDDLE string r px too high — the money-digit bug.)
let pitch = r.size;
let block_h = render_lines.len() as f32 * pitch;
let v_offset = if rect.height() > f32::EPSILON {
match justify.v {
JustifyV::Top => 0.0,
JustifyV::Middle => (rect.height() - block_h) * 0.5,
JustifyV::Bottom => rect.height() - block_h,
}
} else {
0.0
};
// Baseline of the first line: the row the client hangs ink from — the face's pixel ascender
// `[CGxFont+0x17c] = round(size · asc/(asc+|desc|))`, threaded unchanged into `glyph_vplace`
// 0x5d1360 as the operand that fixes `baseline = cellTop + ascender` (wow-re `system/font`,
// §5-verified 2026-07-09). NOT `asc/upem` ≈ 0.965 — that is the FreeType scaled hhea ascender,
// which appears nowhere in the placement path; seating with it dropped every line ~3px too low.
let ascent_ratio = e.ascent_ratio_of(r.face);
let baseline_in_cell = (f64::from(r.size) * f64::from(ascent_ratio) + 0.5).floor() as f32;
// The block top takes the client's ONE vertical snap ([`snap_block_top`]); a degenerate
// (single-point / world-text) rect keeps its exact fractional origin — those callers
// (nameplates/vplates/combat_text) own their own seating laws and re-seat by ink.
let block_top = if rect.height() > f32::EPSILON {
snap_block_top(rect.min.y + v_offset)
} else {
rect.min.y + v_offset
};
let mut pen_y = block_top + baseline_in_cell;
// One glyph laid at a line-relative x-origin of 0, pending the line's justification shift.
struct PendingGlyph {
x_rel: f32,
/// Vertical offset from the line baseline (`pen_y`).
y_rel: f32,
uv: Rect,
px_w: f32,
px_h: f32,
color: [f32; 4],
}
// The write-on gradient's character counter (`SetAlphaGradient`): a glyph's position is its char
// index in the emitted run stream — markup codes stripped, wrap-carried whitespace counted where
// it rides. That drifts from the source string by at most the newline count (the engine-side
// return condition counts every source char), which only pads the tail of the reveal by a frame
// or two — the ramp itself is exact per drawn character.
let mut char_index: usize = 0;
for line in &render_lines {
// First pass: lay the line's glyphs at line-origin 0 and measure its total width.
let mut pending: Vec<PendingGlyph> = Vec::new();
// This line's hyperlink x-ranges (line-origin space), one entry per distinct link — a
// link's runs are contiguous, so min/max of its runs' pen extents is its span.
let mut line_links: Vec<(std::sync::Arc<super::markup::LinkInfo>, f32, f32)> = Vec::new();
let mut pen_x = 0.0f32;
for run in line {
if run.text.is_empty() {
continue;
}
let run_x0 = pen_x;
// The pen walks CHARACTERS, not a shaped buffer. The client's law has no kerning and no
// neighbour term (`ComputeStep 0x5ca2d0`), so a run's glyph sequence is the
// concatenation of its characters' — which is exactly what the cache holds, and exactly
// what `measure_line_width` sums. The two are now the same walk over the same numbers
// rather than two computations kept in agreement.
for ch in run.text.chars() {
let Some(cc) = e.char_cell(r.face, r.ppem, ch) else {
// No face shapes it: it draws nothing and steps nothing, which is also what
// the measure says. (Under the ladder these disagreed — measure returned 0 and
// the pen stepped the shaped advance. One walk, one answer.)
char_index += 1;
continue;
};
let mut color = run.color;
if let Some((start, length)) = gradient {
color[3] *= gradient_alpha(char_index, start, length);
}
for g in &cc.glyphs {
if let Some(info) = e.cell(r.face, r.ppem, r.radius, g.glyph_id) {
pending.push(PendingGlyph {
x_rel: pen_x + info.bearing_x / dpi,
y_rel: g.y_off / dpi - info.bearing_top / dpi - thick_rise,
uv: info.uv,
px_w: info.px_w / dpi,
px_h: info.px_h / dpi,
color,
});
}
pen_x += client_step(g.advance, r.step_extra, dpi);
}
char_index += 1;
}
if let Some(info) = &run.link {
match line_links
.iter_mut()
.find(|(a, _, _)| std::sync::Arc::ptr_eq(a, info))
{
Some((_, _, x1)) => *x1 = pen_x,
None => line_links.push((info.clone(), run_x0, pen_x)),
}
}
}
// Second pass: shift the whole line by the justification offset and emit.
let line_width = pen_x;
let origin_x = match justify.h {
JustifyH::Left => rect.min.x,
JustifyH::Center => rect.min.x + (rect.width() - line_width) * 0.5,
JustifyH::Right => rect.max.x - line_width,
};
for g in &pending {
let gx = snap(origin_x + g.x_rel);
let gy = snap(pen_y + g.y_rel);
quads.push(UiQuad {
rect: Rect::new(gx, gy, gx + g.px_w, gy + g.px_h),
z_key,
texture: Some(sheet.clone()),
// Glyph cells are always normalized (never mirrored); pass them straight through.
uv: UvRect::from_rect(g.uv),
color: g.color,
..default()
});
}
// The line's hyperlink spans, shifted by the same justification origin the glyphs took
// (hit rects cover the glyph cells: cell top = baseline − ascender, one font-height tall).
if let Some(out) = links_out.as_deref_mut() {
let cell_top = pen_y - baseline_in_cell;
for (info, x0, x1) in line_links.drain(..) {
out.push(LinkSpan {
rect: Rect::new(origin_x + x0, cell_top, origin_x + x1, cell_top + r.size),
link: info.link.clone(),
markup: info.markup.clone(),
});
}
}
pen_y += pitch;
}
quads
}
#[cfg(test)]
mod seat_tests {
use super::*;
/// The composed MIDDLE seat for one line: block top from box top, per
/// `fontstring-vertical-placement.md` — `d = H − round_half_away((H+h)/2)` in the client's
/// y-up frame, which [`snap_block_top`] reproduces in y-down over the exact offset.
fn middle_d(box_h: f32, block_h: f32) -> f32 {
snap_block_top_law((box_h - block_h) * 0.5)
}
#[test]
fn middle_seat_matches_the_three_verified_cases() {
// Case 1 — Friz 12 in a 12-tall box (the bag title): d = 0.
assert_eq!(middle_d(12.0, 12.0), 0.0);
// Case 2 — Friz 10 in a 10-tall box (unit-frame name): d = 0.
assert_eq!(middle_d(10.0, 10.0), 0.0);
// Case 3 — arialn 14 (NO outline pad in the block!) in a 13-tall box (the money row):
// d = 13 − round(13.5) = −1 — the celltop one px ABOVE the box top, the S>H degenerate.
assert_eq!(middle_d(13.0, 14.0), -1.0);
}
#[test]
fn the_tie_rounds_up_on_screen() {
// The client's one snap runs in the y-up frame (round-half-away), so a .5 tie pushes
// the block UP on screen = down-rounds in y-down units.
assert_eq!(snap_block_top_law(10.5), 10.0);
assert_eq!(snap_block_top_law(-0.5), -1.0);
// Non-ties round normally.
assert_eq!(snap_block_top_law(10.4), 10.0);
assert_eq!(snap_block_top_law(10.6), 11.0);
}
#[test]
fn the_seat_is_the_law_plus_the_directors_nudge() {
// The drawn seat = the byte law + the 1px taste nudge (decision 0351, director's call).
assert_eq!(snap_block_top(10.5), snap_block_top_law(10.5) + 1.0);
assert_eq!(snap_block_top(0.0), 1.0);
}
}
#[cfg(test)]
mod gradient_tests {
use super::*;
#[test]
fn gradient_ramp_is_opaque_then_linear_then_invisible() {
// SetAlphaGradient(10, 30): chars 0..10 opaque, 10..40 ramp 1→0, 40+ invisible.
assert_eq!(gradient_alpha(0, 10.0, 30.0), 1.0);
assert_eq!(gradient_alpha(9, 10.0, 30.0), 1.0);
assert_eq!(gradient_alpha(10, 10.0, 30.0), 1.0); // ramp start = leading edge, fully lit
assert!((gradient_alpha(25, 10.0, 30.0) - 0.5).abs() < 1e-6);
assert_eq!(gradient_alpha(40, 10.0, 30.0), 0.0);
assert_eq!(gradient_alpha(1000, 10.0, 30.0), 0.0);
// Degenerate length: a hard reveal edge, never a divide-by-zero.
assert_eq!(gradient_alpha(5, 10.0, 0.0), 1.0);
assert_eq!(gradient_alpha(15, 10.0, 0.0), 0.0);
}
}
#[cfg(test)]
mod measure_fits_render {
use super::*;
use crate::ui_text::engine::test_engine;
use crate::ui_text::markup::parse_markup;
const FACE: &str = "Fonts\\FRIZQT__.TTF";
/// Every label the Main Menu draws — the shipped strings, so a regression reads as the
/// director's screenshot did (`Options` → `Option`, `Return to Game` → `Return to`).
const LABELS: &[&str] = &[
"Options",
"Support",
"Macros",
"Logout",
"Return to Game",
"Exit Game",
"Key Bindings",
"Edit",
"Main Menu",
];
/// `ERA_WINDOW_SCALE` — the Game Menu and the Options window carry `SetScale(0.78)`, so their
/// font heights land on nothing round. That was unrepresentable on a fixed size ladder BY
/// CONSTRUCTION, and it is the case the Main Menu report came from.
const ERA: f32 = 0.78;
fn spec(h: f32) -> FontSpec<'static> {
FontSpec {
path: Some(FACE),
height: Some(h),
outline: Outline::None,
alpha_gradient: None,
}
}
/// **A string fits the width its own measure reported.** An auto-sized FontString has no width
/// of its own: the engine's measure round-trip sizes its rect *from* `measure_text`, and the
/// emit pass then re-wraps inside that rect. If the two ever answer in different spaces the
/// string wraps inside its own box and the line stack drops the overflow row with no ellipsis
/// — which is what shipped (1339). Here they are one walk over one table, so this holds at
/// every size and DPI rather than at the ones somebody remembered to bake.
#[test]
fn a_string_fits_the_width_its_own_measure_reported() {
let Some(mut e) = test_engine(1.0) else {
eprintln!("skipping: no install / font chain");
return;
};
// Sizes that a ladder could not carry: the era scale against each shipped menu height,
// and both DPIs.
for dpi in [1.0f32, 2.0] {
e.set_dpi_for_test(dpi);
for base in [10.0f32, 12.0, 13.0, 14.0, 16.0, 18.0, 20.0] {
let h = base * ERA;
let s = spec(h);
for label in LABELS {
let measured = measure_text(&mut e, label, None, s).0;
let r = measure::resolve(&mut e, &s);
e.ensure_metrics(r.face, r.ppem, label);
let runs = parse_markup(label, [1.0, 1.0, 1.0, 1.0]);
let line = runs.first().expect("one line");
let rows = measure::wrap_line(&e, r, line, measured);
assert_eq!(
rows.len(),
1,
"{label:?} wrapped inside its own measured width \
({measured} px, height {h}, dpi {dpi})"
);
}
}
}
}
/// **The pen and the measure are the same number, to the bit.** The emit pass walks characters
/// and steps; `measure_line_width` sums the same per-character steps. Nothing rounds between
/// them, so this is equality, not a tolerance — and any future divergence (a re-introduced
/// rescale, a shaper creeping back into one side) fails here rather than on someone's screen.
#[test]
fn the_drawn_line_is_exactly_as_wide_as_the_measure_says() {
let Some(mut e) = test_engine(2.0) else {
eprintln!("skipping: no install / font chain");
return;
};
for label in LABELS {
let s = spec(16.0 * ERA);
let want = measure_text(&mut e, label, None, s).0 - 1.0; // less the headroom pixel
// The emit pass's own line width, read off the quads it produced: the ink of the last
// glyph never passes the pen, and the first starts at the origin.
let quads = layout_text_quads(
&mut e,
label,
Rect::new(0.0, 0.0, 0.0, 0.0),
[1.0; 4],
Justify {
h: JustifyH::Left,
v: JustifyV::Top,
},
0,
s,
);
let ink = quads.iter().map(|q| q.rect.max.x).fold(0.0f32, f32::max);
assert!(
ink <= want.ceil() + 1e-3,
"{label:?}: ink reaches {ink} but the measure promised {want}"
);
assert!(!quads.is_empty(), "{label:?} drew nothing");
}
}
/// **The letters sit on one line.** Every glyph of a single-line string shares a baseline, so
/// every quad's top edge is an exact device pixel and the whole row rounds by one residual.
/// This is the director's report in an assertion: under the size ladder the rescale ran after
/// the snap and multiplied each already-rounded position by a non-integer factor, giving every
/// letter its own sub-pixel phase.
#[test]
fn every_glyph_on_a_line_lands_on_the_device_pixel_grid() {
let Some(mut e) = test_engine(2.0) else {
eprintln!("skipping: no install / font chain");
return;
};
// Deliberately an awkward height (12.48 logical at dpi 2 → 25 device px) and a fractional
// rect origin, which is what a `SetScale`d frame actually resolves to.
let s = spec(16.0 * ERA);
let quads = layout_text_quads(
&mut e,
"Return to Game",
Rect::new(10.5, 20.0, 400.0, 40.0),
[1.0; 4],
Justify {
h: JustifyH::Left,
v: JustifyV::Middle,
},
0,
s,
);
assert!(quads.len() > 8, "the string drew");
let dpi = 2.0f32;
for q in &quads {
for v in [q.rect.min.x, q.rect.min.y, q.rect.max.x, q.rect.max.y] {
let device = v * dpi;
assert!(
(device - device.round()).abs() < 1e-3,
"a glyph edge at {v} logical is {device} device px — off the grid, which is \
the sub-pixel scatter that took the letters off their line"
);
}
}
}
}
#[cfg(test)]
mod ellipsis_cost {
use super::*;
use crate::ui_text::engine::test_engine;
use std::cell::Cell;
use std::time::Instant;
/// The reported page verbatim — vmangos `page_text` 2676, the *Alliance Military Ranks* plaque
/// in Stormwind's Old Town (`GameObject` 3011, the object in Goudy's screenshots). 647 bytes.
const PAGE: &str = concat!(
"<HTML>\n",
"<BODY>\n",
"<H1 align=\"center\">ALLIANCE MILITARY RANKS</H1><BR/>\n",
"<P align=\"center\">OFFICERS</P><BR/>\n",
"<P align=\"center\">Grand Marshal</P>\n",
"<P align=\"center\">Field Marshal</P>\n",
"<P align=\"center\">Marshal</P>\n",
"<P align=\"center\">Commander</P>\n",
"<P align=\"center\">Lieutenant Commander</P>\n",
"<P align=\"center\">Knight-Champion</P>\n",
"<P align=\"center\">Knight-Captain</P>\n",
"<P align=\"center\">Knight-Lieutenant</P>\n",
"<P align=\"center\">Knight</P><BR/>\n",
"<P align=\"center\">ENLISTED</P><BR/>\n",
"<P align=\"center\">Sergeant Major</P>\n",
"<P align=\"center\">Master Sergeant</P>\n",
"<P align=\"center\">Sergeant</P>\n",
"<P align=\"center\">Corporal</P>\n",
"<P align=\"center\">Private</P>\n",
"</BODY>\n",
"</HTML>",
);
/// The longest body vmangos actually ships (`page_text` 2880, a Hearthglen letter, 928 bytes)
/// — **plain prose, no markup at all**. It is here because the report's framing ("html text")
/// names the loudest case, not the boundary: the seam is armed by OVERFLOW, and the longest
/// pages in the world are plain. `$b` arrives expanded (`npc_text::substitute`, the feed).
const PLAIN: &str = concat!(
"Reuben,\n\nI write this letter knowing you may never see it; I simply can't remain idle, ",
"listening to the constant pounding against the Hearthglen walls. The undead are outside ",
"our village, unceasing in their assault, and we have been charged with defending the ",
"townsfolk until reinforcements arrive.\n\nMy leg was broken in the last charge, and so I ",
"sit, useless, with my sword at my side should there be a breach in our defenses. There is ",
"no idle banter... only the sounds of fighting and death. The air is thick with fear.\n\n",
"Prince Arthas is here, fighting on the front lines with the men. Were he not present we ",
"would have fallen long ago. His love for this land and its people is infectious; I gladly ",
"serve under him, and will to the end of my days.\n\nThe fighting grows more intense; ",
"broken leg or not, I cannot sit here. Every sword is needed. I hope these words find you ",
"in happier times.\n\nYour friend,\nLeagrem\n\n",
);
/// The reader's own wrapper — `ItemTextFrame.xml`'s READY handler frames an authorless page
/// with a leading and a trailing newline.
fn page_body(page: &str) -> String {
format!("\n{page}\n")
}
/// `ItemTextPageText`'s geometry and face: 270×304 at `ItemTextFontNormal`
/// (`Fonts\MORPHEUS.TTF`, height 15). At the 768-tall design window the seam scale is 1, so
/// these are also the drawn px.
const FACE: &str = "Fonts\\MORPHEUS.TTF";
const SIZE: f32 = 15.0;
const BOX_W: f32 = 270.0;
const BOX_H: f32 = 304.0;
#[test]
#[ignore = "rasterizes a real font from the install; run explicitly"]
fn a_page_that_overflows_its_box_costs_a_whole_back_off() {
let Some(mut e) = test_engine(1.0) else {
eprintln!("skipping: no install / font chain");
return;
};
let s = FontSpec {
path: Some(FACE),
height: Some(SIZE),
outline: Outline::None,
alpha_gradient: None,
};
let r = measure::resolve(&mut e, &s);
let fits = overflow::lines_fitting(BOX_H, r.size);
for (label, page) in [("html 2676", PAGE), ("plain 2880", PLAIN)] {
let text = page_body(page);
e.ensure_metrics(r.face, r.ppem, &text);
e.ensure_metrics(r.face, r.ppem, "...");
let full = measure::wrapped_rows_for_test(&e, r, &text, BOX_W);
eprintln!(
"[ellipsis-cost] {label:<11} {:>4} chars -> {full:>2} rows in a {fits}-row box",
text.chars().count(),
);
// Uncapped (what shipped) against the client's box-bounded fit walk. Both must return
// the same display string — the cap decides overflow, it does not decide the answer.
let mut out = Vec::new();
for (how, cap) in [("whole string", usize::MAX), ("box-bounded", fits + 1)] {
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| {
probes.set(probes.get() + 1);
chars.set(chars.get() + candidate.chars().count());
measure::wrapped_rows_capped_for_test(&e, r, candidate, BOX_W, cap)
});
let us = t.elapsed().as_secs_f64() * 1e6;
eprintln!(
"[ellipsis-cost] {how:<13} {:>4} probes, {:>6} candidate chars, {us:>7.0} us",
probes.get(),
chars.get(),
);
assert!(
got.is_some(),
"{label} overflows its box — the seam is armed"
);
out.push(got);
}
assert_eq!(
out[0], out[1],
"{label}: the row cap changed the display string"
);
}
}
}