From 75edfd7ca521c309228f49e213982e5fc4d70263 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Mon, 12 Oct 2026 05:13:09 +0700 Subject: [PATCH] SetFont gates as 1.12.1 does; SetSpacing on every text table (#946) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- crates/benilla-app/src/combat_text/mod.rs | 1 + crates/benilla-app/src/nameplates.rs | 1 + crates/benilla-app/src/perf/hud.rs | 1 + .../benilla-app/src/ui_script/extract/mod.rs | 3 + .../benilla-app/src/ui_script/extract/text.rs | 3 + .../benilla-app/src/ui_text/layout/measure.rs | 28 +- crates/benilla-app/src/ui_text/layout/mod.rs | 104 +++- .../src/ui_text/layout/overflow.rs | 66 ++- crates/benilla-app/src/ui_text/measurer.rs | 48 ++ crates/benilla-app/src/ui_text/mod.rs | 18 +- crates/benilla-ui/src/framexml.rs | 54 +++ crates/benilla-ui/src/loader/mod.rs | 6 + crates/benilla-ui/src/loader/regions.rs | 7 +- crates/benilla-ui/src/script/button.rs | 36 +- .../benilla-ui/src/script/editbox/methods.rs | 9 +- .../benilla-ui/src/script/editbox/relayout.rs | 2 + crates/benilla-ui/src/script/editbox/seam.rs | 4 + crates/benilla-ui/src/script/extract.rs | 2 + crates/benilla-ui/src/script/font.rs | 68 ++- crates/benilla-ui/src/script/font_block.rs | 258 +++++++--- crates/benilla-ui/src/script/layout.rs | 2 + .../benilla-ui/src/script/messageframe/mod.rs | 85 +++- .../src/script/messageframe/plain.rs | 2 +- .../src/script/messageframe/scrolling.rs | 2 +- crates/benilla-ui/src/script/mod.rs | 6 +- crates/benilla-ui/src/script/region/text.rs | 13 +- .../benilla-ui/src/script/simplehtml/mod.rs | 76 +-- .../benilla-ui/src/script/simplehtml/parse.rs | 52 +- .../src/script/tests/font_object.rs | 25 +- .../src/script/tests/line_spacing.rs | 459 ++++++++++++++++++ crates/benilla-ui/src/script/tests/mod.rs | 2 + .../src/script/tests/reference_surface.rs | 10 +- .../src/script/tests/set_font_args.rs | 223 +++++++++ .../benilla-ui/src/script/tests/simplehtml.rs | 5 +- .../src/script/tests/texcoord_font.rs | 3 + .../src/script/tests/widget_surface.rs | 11 - crates/benilla-ui/src/script/types.rs | 27 +- 37 files changed, 1434 insertions(+), 288 deletions(-) create mode 100644 crates/benilla-ui/src/script/tests/line_spacing.rs create mode 100644 crates/benilla-ui/src/script/tests/set_font_args.rs diff --git a/crates/benilla-app/src/combat_text/mod.rs b/crates/benilla-app/src/combat_text/mod.rs index 1b569d5f..b9ecef4c 100644 --- a/crates/benilla-app/src/combat_text/mod.rs +++ b/crates/benilla-app/src/combat_text/mod.rs @@ -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. diff --git a/crates/benilla-app/src/nameplates.rs b/crates/benilla-app/src/nameplates.rs index 007f6067..0bcc2c43 100644 --- a/crates/benilla-app/src/nameplates.rs +++ b/crates/benilla-app/src/nameplates.rs @@ -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. diff --git a/crates/benilla-app/src/perf/hud.rs b/crates/benilla-app/src/perf/hud.rs index d963a0a0..653fb4c2 100644 --- a/crates/benilla-app/src/perf/hud.rs +++ b/crates/benilla-app/src/perf/hud.rs @@ -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. diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index 4237c9dd..4bc1dd5e 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -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, }, diff --git a/crates/benilla-app/src/ui_script/extract/text.rs b/crates/benilla-app/src/ui_script/extract/text.rs index e7bd9312..88b331c1 100644 --- a/crates/benilla-app/src/ui_script/extract/text.rs +++ b/crates/benilla-app/src/ui_script/extract/text.rs @@ -25,6 +25,8 @@ pub(super) struct TextStyle { pub text_height: Option, pub shadow: Option, 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 diff --git a/crates/benilla-app/src/ui_text/layout/measure.rs b/crates/benilla-app/src/ui_text/layout/measure.rs index b86941f0..de65728b 100644 --- a/crates/benilla-app/src/ui_text/layout/measure.rs +++ b/crates/benilla-app/src/ui_text/layout/measure.rs @@ -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 diff --git a/crates/benilla-app/src/ui_text/layout/mod.rs b/crates/benilla-app/src/ui_text/layout/mod.rs index 12684773..98f40dd2 100644 --- a/crates/benilla-app/src/ui_text/layout/mod.rs +++ b/crates/benilla-app/src/ui_text/layout/mod.rs @@ -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 { + 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 = 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) diff --git a/crates/benilla-app/src/ui_text/layout/overflow.rs b/crates/benilla-app/src/ui_text/layout/overflow.rs index e6151487..de3dc400 100644 --- a/crates/benilla-app/src/ui_text/layout/overflow.rs +++ b/crates/benilla-app/src/ui_text/layout/overflow.rs @@ -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 usize>( text: &str, box_h: f32, - pitch: f32, + size: f32, + gap: f32, rows: F, ) -> Option { - 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...") ); } diff --git a/crates/benilla-app/src/ui_text/measurer.rs b/crates/benilla-app/src/ui_text/measurer.rs index 805c9c30..0dc22078 100644 --- a/crates/benilla-app/src/ui_text/measurer.rs +++ b/crates/benilla-app/src/ui_text/measurer.rs @@ -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::("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"); + } } diff --git a/crates/benilla-app/src/ui_text/mod.rs b/crates/benilla-app/src/ui_text/mod.rs index 4eaff0dd..3d31b78a 100644 --- a/crates/benilla-app/src/ui_text/mod.rs +++ b/crates/benilla-app/src/ui_text/mod.rs @@ -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, @@ -104,6 +105,8 @@ struct Remembered { font: Option, height: Option, 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, } @@ -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, } } diff --git a/crates/benilla-ui/src/framexml.rs b/crates/benilla-ui/src/framexml.rs index 882d7ab8..31c6ff8c 100644 --- a/crates/benilla-ui/src/framexml.rs +++ b/crates/benilla-ui/src/framexml.rs @@ -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::().unwrap_or(0.0) +} + #[cfg(test)] mod tests { use super::*; @@ -862,3 +900,19 @@ print(x) 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); + } +} diff --git a/crates/benilla-ui/src/loader/mod.rs b/crates/benilla-ui/src/loader/mod.rs index f49e98c3..76f90f82 100644 --- a/crates/benilla-ui/src/loader/mod.rs +++ b/crates/benilla-ui/src/loader/mod.rs @@ -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), } } diff --git a/crates/benilla-ui/src/loader/regions.rs b/crates/benilla-ui/src/loader/regions.rs index 1dc413e8..27d0d0da 100644 --- a/crates/benilla-ui/src/loader/regions.rs +++ b/crates/benilla-ui/src/loader/regions.rs @@ -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 `` (`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 `` 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); diff --git a/crates/benilla-ui/src/script/button.rs b/crates/benilla-ui/src/script/button.rs index 1ac16921..92a546ed 100644 --- a/crates/benilla-ui/src/script/button.rs +++ b/crates/benilla-ui/src/script/button.rs @@ -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)| { - let usage = || { - mlua::Error::runtime( - "Usage: