Files
benilla-octo/crates/benilla-app/src/ui_text/mod.rs
T
gromandClaude Opus 5 8c6fb4e425 Every <OnLoad> in the interface read text widths as 0
`seat_text_measurer`'s own doc says what it is for: *"so `GetStringWidth` right after `SetText`
measures, as the reference answers it inline (`0x79e510` → `0x772890`). Also seated before the
manifest loads, for every `<OnLoad>`."* It was not seated. `UiFontAtlas` is inserted by an
`Update` system through `Commands`, so it appears at the next command flush — and the interface
load runs on the `InWorld` state transition, which comes first. `seat_raster_seam_for_load` then
took its `else { return }` and the VM went through the whole load with **no text measurer at
all**, which `ensure_measured` answers by returning at its first line.

So for the entire UI load — all of FrameXML and every addon — `FontString:GetWidth()`,
`GetHeight()` and `GetStringWidth()` answered **0** for a string that plainly had text. Measured
on grom's install: dozens of them, including every unit-popup entry, every `StaticPopup` button
and the four radio labels this was found through.

What it costs is any `<OnLoad>` that lays something out from a label's width, which is an ordinary
thing for an addon to do. `AdvancedTradeSkillWindow2.lua:1642` puts its Name radio button at
`ATSWDifficultySortCheckboxText2:GetWidth() + 5` to the right of the Difficulty one: the stored
offset came out **5.0** where it should be 53.8, so "Difficulty" was drawn across "Category" 37 px
to its left. grom, 2026-11-05: *"its loading now, but barely functioning."* Re-running
`ATSW_OnLoad()` by hand from a live session gave 53.8 — the same code, the same frame, a measurer
away.

`ensure_font_atlas(&mut World)` is `init`'s body for a caller that holds the world, called from
`seat_raster_seam_for_load`; `init` keeps its own path for every later frame. A world that still
cannot build one (no chain, no window) now warns instead of loading the interface in silence.

Verified: the ATSW2 offset is 53.8 at load, and a walk of the whole loaded interface finds **0**
FontStrings that have text and measure 0 (dozens before). benilla-app 4243 pass / 12 fail, the
recorded baseline with an identical name set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-11-05 11:48:29 -07:00

342 lines
12 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.
//! Text rendering for `QuadContent::Text`: an on-demand glyph cache over the client's own TTFs,
//! shaped through `cosmic-text` 0.16 and emitted as [`crate::ui_pass::UiQuad`]s.
//!
//! Glyphs are shaped one character at a time ([`engine::TextEngine::ensure_char`]) and the pen
//! steps by the reference's law: `floor(advance) + 1` px per glyph (`(FT_advance>>6)+1.0` at
//! `0x5d1120`), plus 1 more under a THICK outline (`0x5ca2b0`). The four client faces have no
//! ligature or contextual substitution, which `engine::differential_tests` asserts.
//!
//! Kerning is dropped, where the reference applies negative pair kerns only, rounded up
//! (`ComputeStep`, `0x5ca2d0`), about 0 px at UI sizes. A width is then a pure function of its
//! characters, so one table answers a measure and a draw, even inside a Lua call.
//!
//! Glyph bitmaps are swash's unhinted coverage, where the reference runs FreeType with hinting
//! (load flags `0x208a`/`0x20c2`), so stems are slightly slimmer at small sizes.
mod engine;
mod layout;
mod markup;
mod measurer;
mod outline;
mod pack;
pub(crate) use engine::{ensure_font_atlas, UiFontAtlas, UiTextPlugin};
use benilla_ui::script::Outline;
use benilla_ui::widget::RegionHandle;
pub(crate) use measurer::{measure_request, AtlasMeasurer};
pub(crate) use layout::{
ellipsize_to_fit, layout_text_quads, layout_text_quads_links, line_advances, line_origin,
line_rows, measure_text, measure_wrapped_rows, FontSpec, Justify, TextSeat, UI_SEAT_NUDGE,
};
/// The text size in logical px of a FontString with no font object: `GameFontNormal`'s 12.
const DEFAULT_FONT_SIZE: f32 = 12.0;
/// The one-to-one raster cap. Every `CSimpleFontString` sets the one-to-one bit (`+0x120` bit
/// `0x200`, ctor `0x770d30` default `0x212`), which only `SetTextHeight` clears (`0x771600`), so it
/// draws at its raster size (getter `0x7727b0(1)`), which is
/// `min(32, max(2, round((height/768)·deviceH)))` (`0x5ca030` → `[CGxFont+0x24c]`): a FontHeight
/// of 32 or more draws 32 px, and nothing in the text pipeline scales to fit.
///
/// Deviation: the cap is 32 logical units, not 32 device px, because the reference's raster-memory
/// ceiling draws all big text at the same 32 device px on a modern display; at the 768-tall design
/// height the two agree. UI FontStrings only: world text ([`crate::combat_text`], the nameplates)
/// renders crisp above it, where the reference stretched a 32 px raster.
pub(crate) const FONTSTRING_EM_CAP: f32 = 32.0;
/// Apply [`FONTSTRING_EM_CAP`] to a UI FontString's requested height. Render and measure both cap,
/// so `GetStringWidth` reports the width at the capped size, as the reference does (`0x772890`).
pub(crate) fn fontstring_em(height: Option<f32>) -> Option<f32> {
height.map(|h| h.min(FONTSTRING_EM_CAP))
}
/// The drawn pixel height of a UI FontString times the seam scale `s = windowH/768 × uiScale`
/// (`crate::ui_script::seam_scale`), under the reference's two size regimes (getter `0x7727b0`):
/// one-to-one, the default, draws the raster size ([`FONTSTRING_EM_CAP`] applied in units), and a
/// `SetTextHeight` size (`text_height`) draws uncapped, the reference magnifying the raster to it
/// (`0x771600` clears bit `0x200`; the drawn em is `round(size/768·deviceH)`).
pub(crate) fn drawn_px(font_height: Option<f32>, text_height: Option<f32>, s: f32) -> Option<f32> {
match text_height {
Some(t) => Some(t * s),
None => Some(fontstring_em(font_height).unwrap_or(DEFAULT_FONT_SIZE) * s),
}
}
#[cfg(test)]
mod cap_tests {
use super::*;
#[test]
fn one_to_one_cap_matches_the_byte_law() {
// `ZoneTextFont` and `WorldMapTextFont` are 102 in the stock `Fonts.xml`.
assert_eq!(fontstring_em(Some(102.0)), Some(32.0));
assert_eq!(fontstring_em(Some(33.0)), Some(32.0));
// At-cap and under-cap sizes pass through untouched (SubZoneTextFont 26, chat 14).
assert_eq!(fontstring_em(Some(32.0)), Some(32.0));
assert_eq!(fontstring_em(Some(26.0)), Some(26.0));
assert_eq!(fontstring_em(Some(14.0)), Some(14.0));
assert_eq!(fontstring_em(None), None);
}
}
// 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
// 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.
#[derive(Default)]
pub(crate) struct EllipsisMemo {
entries: std::collections::HashMap<RegionHandle, Remembered>,
}
/// Every input [`layout::ellipsize_to_fit`] reads, and its answer.
struct Remembered {
text: String,
box_w: f32,
box_h: f32,
font: Option<String>,
height: Option<f32>,
outline: Outline,
/// The answer; `None` means the text fits and draws raw, also worth remembering.
display: Option<String>,
}
/// The reference's rect-change tolerance (`0x768d20`): edges moving less than this rebuild nothing.
const RECT_EPS: f32 = 1e-5;
/// Entries past which the map is dropped whole, for handle churn: a `/reload` builds a new frame
/// tree, and the old regions' handles never come back.
const MEMO_CAP: usize = 4096;
impl EllipsisMemo {
/// The remembered display string for `region`, if none of these inputs moved.
fn get(
&self,
region: RegionHandle,
text: &str,
box_w: f32,
box_h: f32,
font: &FontSpec,
) -> Option<&Option<String>> {
let e = self.entries.get(&region)?;
(e.text == text
&& (e.box_w - box_w).abs() < RECT_EPS
&& (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)
}
fn put(
&mut self,
region: RegionHandle,
text: &str,
box_w: f32,
box_h: f32,
font: &FontSpec,
display: Option<String>,
) {
if self.entries.len() >= MEMO_CAP {
self.entries.clear();
}
self.entries.insert(
region,
Remembered {
text: text.to_string(),
box_w,
box_h,
font: font.path.map(str::to_string),
height: font.height,
outline: font.outline,
display,
},
);
}
}
/// [`EllipsisMemo`]'s invalidation set, the reference's: a hit after an input moved would draw a
/// stale string, so each input gets its own test.
#[cfg(test)]
mod ellipsis_memo_tests {
use super::*;
use benilla_ui::widget::RegionHandle;
fn spec(
path: Option<&'static str>,
height: Option<f32>,
outline: Outline,
) -> FontSpec<'static> {
FontSpec {
path,
height,
outline,
alpha_gradient: None,
}
}
fn base() -> FontSpec<'static> {
spec(Some("Fonts\\MORPHEUS.TTF"), Some(15.0), Outline::None)
}
/// Real `RegionHandle`s, built as regions and read off `extract()` in draw order.
fn regions(n: usize) -> Vec<RegionHandle> {
use benilla_ui::order::ZTarget;
let mut script = benilla_ui::script::UiScript::new().expect("a VM");
script.set_screen_size(1024.0, 768.0);
script
.run(
r#"
local f = CreateFrame("Frame", "MemoHost")
f:SetPoint("TOPLEFT", 0, 0)
f:SetWidth(100); f:SetHeight(100)
for i = 1, 8 do
local t = f:CreateTexture(nil, "ARTWORK")
t:SetTexture(1, 0, 0)
t:SetAllPoints()
end
"#,
)
.expect("the fixture loads");
script.resolve();
let out: Vec<RegionHandle> = script
.extract()
.into_iter()
.filter_map(|q| match q.target {
ZTarget::Region(r) => Some(r),
ZTarget::Frame(_) => None,
})
.collect();
assert!(out.len() >= n, "the fixture makes enough regions");
out.into_iter().take(n).collect()
}
/// A stored answer comes back under identical inputs, `None` included.
#[test]
fn identical_inputs_hit() {
let mut m = EllipsisMemo::default();
let r = regions(1)[0];
m.put(
r,
"a long page body",
270.0,
304.0,
&base(),
Some("a lo...".into()),
);
assert_eq!(
m.get(r, "a long page body", 270.0, 304.0, &base()),
Some(&Some("a lo...".into()))
);
m.put(r, "short", 270.0, 304.0, &base(), None);
assert_eq!(m.get(r, "short", 270.0, 304.0, &base()), Some(&None));
}
/// `SetText`, `SetFont`, `SetTextHeight` and a resize each miss, as each rebuilds in the
/// reference; so does an outline change.
#[test]
fn a_changed_input_misses() {
let mut m = EllipsisMemo::default();
let r = regions(1)[0];
m.put(r, "body", 270.0, 304.0, &base(), Some("bo...".into()));
assert!(
m.get(r, "other", 270.0, 304.0, &base()).is_none(),
"SetText"
);
assert!(
m.get(r, "body", 271.0, 304.0, &base()).is_none(),
"wider box"
);
assert!(
m.get(r, "body", 270.0, 305.0, &base()).is_none(),
"taller box"
);
assert!(
m.get(
r,
"body",
270.0,
304.0,
&spec(Some("Fonts\\FRIZQT__.TTF"), Some(15.0), Outline::None)
)
.is_none(),
"SetFont"
);
assert!(
m.get(
r,
"body",
270.0,
304.0,
&spec(Some("Fonts\\MORPHEUS.TTF"), Some(16.0), Outline::None)
)
.is_none(),
"SetTextHeight"
);
assert!(
m.get(
r,
"body",
270.0,
304.0,
&spec(Some("Fonts\\MORPHEUS.TTF"), Some(15.0), Outline::Thick)
)
.is_none(),
"the outline biases the step law, so it changes where the wrap breaks"
);
}
/// A size change under the reference's `1e-5` still hits; a real resize misses.
#[test]
fn sub_epsilon_rect_dust_still_hits() {
let mut m = EllipsisMemo::default();
let r = regions(1)[0];
m.put(r, "body", 270.0, 304.0, &base(), Some("bo...".into()));
assert!(m
.get(r, "body", 270.0 + 1e-7, 304.0 - 1e-7, &base())
.is_some());
assert!(m.get(r, "body", 270.001, 304.0, &base()).is_none());
}
#[test]
fn entries_are_per_region() {
let mut m = EllipsisMemo::default();
let r = regions(3);
m.put(r[0], "body", 270.0, 304.0, &base(), Some("one...".into()));
m.put(r[1], "body", 100.0, 40.0, &base(), Some("two...".into()));
assert_eq!(
m.get(r[0], "body", 270.0, 304.0, &base()),
Some(&Some("one...".into()))
);
assert_eq!(
m.get(r[1], "body", 100.0, 40.0, &base()),
Some(&Some("two...".into()))
);
assert!(m.get(r[2], "body", 270.0, 304.0, &base()).is_none());
}
#[test]
fn the_map_is_capped() {
let mut m = EllipsisMemo::default();
// The fixture yields few handles, so they are re-put under distinct texts.
let r = regions(8);
for i in 0..=MEMO_CAP {
m.put(
r[i % r.len()],
&format!("body {i}"),
270.0,
304.0,
&base(),
None,
);
}
assert!(m.entries.len() <= MEMO_CAP);
}
}