A tooltip line wrapped past 20 rows sizes to its pieces, as in 1.12.1 (#1933)
A tooltip line wrapped past 20 rows sized narrower than in 1.12.1. The sizer splits a long line into at most 20 pieces, the last running to the end of the text (`0x53082f`, `0x53088e`), and sizes the plate to the widest. Each piece runs from one row's start to the next (`0x530884`-`0x5308a8`), so it carries the whitespace the wrap skipped (`0x5c24fe`, past `0x5c7710`), and a space measures as a glyph. Pieces are now measured that way, so most wrapped tooltips are about a space wider. Tests pin the 20-piece cap, the kept space and a piece crossing a line break. Fixes #1880 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Sam <samuv.eth@gmail.com>
This commit is contained in:
@@ -185,6 +185,20 @@ fn an_editbox_row_after_a_code_starts_past_it() {
|
||||
assert_eq!(rows[..2], [0, 12], "{rows:?}");
|
||||
}
|
||||
|
||||
/// A LF that opens a line is a line start of its own (`0x5c24cd` records it and steps past it),
|
||||
/// and one that ends a line's run is consumed by it, the next line starting after it (`0x5c764d`).
|
||||
#[test]
|
||||
fn a_row_table_counts_a_line_that_opens_on_a_lf() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
let mut e = test_engine(1.0).expect("the client faces open");
|
||||
let (rows, _) = line_rows(&mut e, "ab\ncd", 60.0, spec(false, 0));
|
||||
assert_eq!(rows, [0, 3]);
|
||||
let (rows, _) = line_rows(&mut e, "ab\n\ncd", 60.0, spec(false, 0));
|
||||
assert_eq!(rows, [0, 3, 4], "the empty line starts on its LF");
|
||||
let (rows, _) = line_rows(&mut e, "\ncd", 60.0, spec(false, 0));
|
||||
assert_eq!(rows, [0, 1]);
|
||||
}
|
||||
|
||||
/// A text ending in LF gets its length as a last offset (`0x5c250b`) even when the wrap's skip
|
||||
/// ate the LF, and after a bail.
|
||||
#[test]
|
||||
|
||||
@@ -20,7 +20,29 @@ pub(crate) fn measure_request(
|
||||
r: &MeasureRequest,
|
||||
) -> (f32, f32, f32) {
|
||||
let rs = seam * r.scale;
|
||||
let spec = || super::FontSpec {
|
||||
let spec = request_spec(r, rs);
|
||||
let wrap = r.wrap_width.map(|w| w * rs);
|
||||
// `GetStringWidth` answers the unwrapped width (the extent measure `0x5c6940`), its line seeded
|
||||
// with the shadow's x offset at the seam alone: `0x772890` leaves `[fs+0x108]` unscaled.
|
||||
let natural = super::string_width(e, &r.text, spec, r.shadow_x * seam);
|
||||
// Unwrapped, the laid-out width is that same measure (`[fs+0xfc]`).
|
||||
let (w, h) = match wrap {
|
||||
Some(w) => {
|
||||
// `0x7729b0` measures the string `0x771ec0` returns: with `maxLines` the text is cut
|
||||
// to that many lines first, and a FontString with no height takes its own from it.
|
||||
let cut = (r.max_lines > 0)
|
||||
.then(|| super::ellipsize_text(e, &r.text, w, 0.0, spec))
|
||||
.flatten();
|
||||
super::measure_text(e, cut.as_deref().unwrap_or(&r.text), wrap, spec)
|
||||
}
|
||||
None => (natural, super::unwrapped_height(e, &r.text, spec)),
|
||||
};
|
||||
(w / rs, h / rs, natural / rs)
|
||||
}
|
||||
|
||||
/// `r`'s font at the drawn raster size `rs`, the seam times the owner's scale.
|
||||
fn request_spec(r: &MeasureRequest, rs: f32) -> super::FontSpec<'_> {
|
||||
super::FontSpec {
|
||||
path: r.font.as_deref(),
|
||||
// The render pass's drawn px: `GetStringWidth` and `GetStringHeight` report the drawn
|
||||
// size (`0x772890`).
|
||||
@@ -35,24 +57,35 @@ pub(crate) fn measure_request(
|
||||
alpha_gradient: None, // alpha never changes metrics
|
||||
non_space_wrap: r.non_space_wrap,
|
||||
max_lines: r.max_lines,
|
||||
};
|
||||
let wrap = r.wrap_width.map(|w| w * rs);
|
||||
// `GetStringWidth` answers the unwrapped width (the extent measure `0x5c6940`), its line seeded
|
||||
// with the shadow's x offset at the seam alone: `0x772890` leaves `[fs+0x108]` unscaled.
|
||||
let natural = super::string_width(e, &r.text, spec(), r.shadow_x * seam);
|
||||
// Unwrapped, the laid-out width is that same measure (`[fs+0xfc]`).
|
||||
let (w, h) = match wrap {
|
||||
Some(w) => {
|
||||
// `0x7729b0` measures the string `0x771ec0` returns: with `maxLines` the text is cut
|
||||
// to that many lines first, and a FontString with no height takes its own from it.
|
||||
let cut = (r.max_lines > 0)
|
||||
.then(|| super::ellipsize_text(e, &r.text, w, 0.0, spec()))
|
||||
.flatten();
|
||||
super::measure_text(e, cut.as_deref().unwrap_or(&r.text), wrap, spec())
|
||||
}
|
||||
}
|
||||
|
||||
/// The widest of the first `max_pieces` pieces of `r`'s text split at its wrap width, laid out at
|
||||
/// the size [`measure_request`] measures at. A piece runs from its row's start (`0x5c2430`) to the
|
||||
/// next one's, the last to the text's end (`0x530884`-`0x5308a8`), so it keeps the whitespace the
|
||||
/// wrap skipped (`0x5c24fe`); it is measured with `GetStringWidth`'s kernel and seed (`0x772ae0`).
|
||||
pub(crate) fn piece_width(
|
||||
e: &mut TextEngine,
|
||||
seam: f32,
|
||||
r: &MeasureRequest,
|
||||
max_pieces: usize,
|
||||
) -> f32 {
|
||||
let rs = seam * r.scale;
|
||||
let spec = request_spec(r, rs);
|
||||
let wrap = r.wrap_width.map_or(0.0, |w| w * rs);
|
||||
let mut starts = super::line_rows(e, &r.text, wrap, spec).0;
|
||||
starts.truncate(max_pieces);
|
||||
// Kept only when wider, from 0 (`0x53086d`, `0x5308ad`).
|
||||
let mut widest = 0.0f32;
|
||||
for (i, &at) in starts.iter().enumerate() {
|
||||
let end = starts.get(i + 1).copied().unwrap_or(r.text.len());
|
||||
let piece = r.text.get(at..end).unwrap_or("");
|
||||
let w = super::string_width(e, piece, spec, r.shadow_x * seam);
|
||||
if w > widest {
|
||||
widest = w;
|
||||
}
|
||||
None => (natural, super::unwrapped_height(e, &r.text, spec())),
|
||||
};
|
||||
(w / rs, h / rs, natural / rs)
|
||||
}
|
||||
widest / rs
|
||||
}
|
||||
|
||||
/// Answer one EditBox advance table as `(cumulative widths, row starts, row pitch)` in screen UI
|
||||
@@ -124,6 +157,14 @@ impl TextMeasure for AtlasMeasurer {
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
Some(editbox_advances(&mut e, self.seam, req))
|
||||
}
|
||||
|
||||
fn piece_width(&mut self, req: &MeasureRequest, max_pieces: usize) -> Option<f32> {
|
||||
let mut e = self
|
||||
.engine
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
Some(piece_width(&mut e, self.seam, req, max_pieces))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -270,6 +311,126 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A tooltip split's piece runs from its row's start to the next (`0x530884`-`0x5308a8`): it
|
||||
/// keeps the space its wrap skipped, a space being a glyph to the extent kernel (`0x5c6940`),
|
||||
/// and a piece crossing a LF is as wide as its widest line, the LF no glyph.
|
||||
#[test]
|
||||
fn a_split_piece_keeps_its_space_and_ends_its_line_at_a_lf() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
let mut e = super::super::engine::test_engine(1.0).expect("the client faces open");
|
||||
let req = |text: &str, wrap: Option<f32>| benilla_ui::script::MeasureRequest {
|
||||
id: 0,
|
||||
font: Some("Fonts\\FRIZQT__.TTF".into()),
|
||||
height: Some(12.0),
|
||||
text_height: None,
|
||||
wrap_width: wrap,
|
||||
outline: benilla_ui::script::Outline::None,
|
||||
spacing: 0.0,
|
||||
shadow_x: 0.0,
|
||||
non_space_wrap: false,
|
||||
max_lines: 0,
|
||||
scale: 1.0,
|
||||
text: text.into(),
|
||||
key: 0,
|
||||
};
|
||||
let natural = |e: &mut super::TextEngine, text: &str| {
|
||||
super::measure_request(e, 1.0, &req(text, None)).2
|
||||
};
|
||||
let (word, spaced) = (natural(&mut e, "abcd"), natural(&mut e, "abcd "));
|
||||
assert!(spaced > word, "a space is a glyph: {spaced} against {word}");
|
||||
// Wide enough for "abcd" and not for "abcd ef": rows "abcd", "ef" and, past the LF, "gh".
|
||||
let wrap = Some(word + 1.0);
|
||||
let text = "abcd ef\ngh";
|
||||
let r = req(text, wrap);
|
||||
let rows = super::super::line_rows(&mut e, text, word + 1.0, super::request_spec(&r, 1.0));
|
||||
assert_eq!(rows.0, [0, 5, 8]);
|
||||
let got = super::piece_width(&mut e, 1.0, &req(text, wrap), 20);
|
||||
assert_eq!(got, spaced, "the first piece, its space and all");
|
||||
// One piece: the whole text, as wide as its widest line.
|
||||
let got = super::piece_width(&mut e, 1.0, &req(text, wrap), 1);
|
||||
assert_eq!(got, natural(&mut e, "abcd ef"));
|
||||
// The first piece crosses the LF: "abcd\n" is as wide as "abcd".
|
||||
let got = super::piece_width(&mut e, 1.0, &req("abcd\nef", wrap), 20);
|
||||
assert_eq!(got, word);
|
||||
}
|
||||
|
||||
/// The stock GameTooltip's second line (`GameTooltipTemplate.xml:35`, `GameTooltipText`)
|
||||
/// wrapped past 20 rows at 230.4: the split stops at 20 pieces (`0x53083f`), the 20th running
|
||||
/// to the text's end unwrapped (`0x53088e`), so the plate is that tail's `GetStringWidth` and
|
||||
/// the pads.
|
||||
#[test]
|
||||
fn a_stock_tooltip_line_past_20_rows_sizes_to_its_20th_piece() {
|
||||
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();
|
||||
for file in [
|
||||
r"Interface\FrameXML\GlobalStrings.lua",
|
||||
r"Interface\FrameXML\Fonts.xml",
|
||||
r"Interface\FrameXML\BasicControls.xml",
|
||||
r"Interface\FrameXML\LocaleProperties.lua",
|
||||
r"Interface\FrameXML\UIParent.xml",
|
||||
r"Interface\FrameXML\MoneyFrame.lua",
|
||||
r"Interface\FrameXML\MoneyFrame.xml",
|
||||
r"Interface\FrameXML\GameTooltip.xml",
|
||||
] {
|
||||
crate::ui_script::load_ui_for_test(&s, file);
|
||||
}
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.set_text_measurer(Box::new(AtlasMeasurer::new(
|
||||
Arc::new(Mutex::new(engine)),
|
||||
1.0,
|
||||
)));
|
||||
s.resolve();
|
||||
// How many words of "word" a 230.4-wide row of the line's font holds.
|
||||
let per_row: usize = s
|
||||
.eval(
|
||||
r#"
|
||||
P = CreateFrame("Frame", nil, UIParent):CreateFontString(nil, "ARTWORK", "GameTooltipText")
|
||||
P:SetWidth(230.4)
|
||||
P:SetText("word")
|
||||
local one = P:GetHeight()
|
||||
local n = 1
|
||||
while P:GetHeight() == one do
|
||||
n = n + 1
|
||||
P:SetText(string.rep("word ", n - 1) .. "word")
|
||||
end
|
||||
return n - 1
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(per_row > 2, "{per_row} words a row");
|
||||
let words = |n: usize| vec!["word"; n].join(" ");
|
||||
let plate = |rows: usize| -> f32 {
|
||||
s.eval(&format!(
|
||||
r#"GameTooltip:SetOwner(UIParent, "ANCHOR_NONE")
|
||||
GameTooltip:AddLine("x") GameTooltip:AddLine("{}", 1, 1, 1, 1) GameTooltip:Show()
|
||||
return GameTooltip:GetWidth()"#,
|
||||
words(per_row * rows)
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
let natural = |n: usize| -> f32 {
|
||||
s.eval(&format!(
|
||||
r#"P:SetText("{}") return P:GetStringWidth()"#,
|
||||
words(n)
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
use benilla_ui::widget::TOOLTIP_PLATE_PAD;
|
||||
let pads = 2.0 * TOOLTIP_PLATE_PAD;
|
||||
let (nineteen, twenty) = (plate(19), plate(20));
|
||||
assert_eq!(twenty, nineteen, "20 rows: the 20th piece is the last row");
|
||||
// A piece before the last keeps the space its row's wrap skipped (`0x530884`).
|
||||
let row = pads + natural(per_row);
|
||||
assert!(twenty > row, "{twenty} against the row alone, {row}");
|
||||
let tail = pads + natural(2 * per_row);
|
||||
let got = plate(21);
|
||||
assert!(
|
||||
(got - tail).abs() < 1e-3,
|
||||
"21 rows: the plate is {got}, the last two rows unwrapped {tail}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `GetStringWidth` (`0x79e510` → `0x772890`) answers the measure kernel's width divided by the
|
||||
/// drawn scale, as it stands, fractions and all: no `ceil` and no added pixel. A frame sized
|
||||
/// from it, as `MoneyFrame.lua:202-208` sizes each coin (`SetWidth(GetTextWidth() + 13)`), gets
|
||||
|
||||
@@ -30,6 +30,15 @@ pub trait TextMeasure {
|
||||
let _ = req;
|
||||
None
|
||||
}
|
||||
|
||||
/// The tooltip sizer's split (`0x530837`-`0x5308c4`): `req`'s text cut at the row starts of its
|
||||
/// wrap width (`0x772b60` → `0x5c2430`), at most `max_pieces` of them, each piece running to
|
||||
/// the next start and the last to the text's end (`0x53088e`), measured unwrapped
|
||||
/// (`0x772ae0`); the widest, in frame-local units. `None`, the default, has no split to give.
|
||||
fn piece_width(&mut self, req: &MeasureRequest, max_pieces: usize) -> Option<f32> {
|
||||
let _ = (req, max_pieces);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed [`TextMeasure`], shared by a state and its forks ([`super::UiScript::fork`]): a
|
||||
@@ -48,6 +57,10 @@ impl TextMeasure for SharedMeasurer {
|
||||
) -> Option<(Vec<f32>, Vec<usize>, f32)> {
|
||||
self.0.borrow_mut().editbox_advances(req)
|
||||
}
|
||||
|
||||
fn piece_width(&mut self, req: &MeasureRequest, max_pieces: usize) -> Option<f32> {
|
||||
self.0.borrow_mut().piece_width(req, max_pieces)
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure region `rh` now if a measurer is installed and its stored measure is stale; a current
|
||||
|
||||
@@ -134,11 +134,12 @@ fn a_wrapped_description_sizes_in_the_show_and_survives_the_reenter_loop() {
|
||||
)
|
||||
.unwrap();
|
||||
// The stand-in engine's 5 per byte: the quoted description, 405 wide, splits at 230.4 into
|
||||
// rows of 230 and 170; name 14 + gap 2 + charges 12 + gap 2 + two rows of 12 + 2·10.24.
|
||||
// rows of 230 and 170, the first piece 235 with the space its wrap skipped (`0x530884`); name
|
||||
// 14 + gap 2 + charges 12 + gap 2 + two rows of 12 + 2·10.24.
|
||||
let size =
|
||||
|s: &UiScript| -> (f64, f64) { s.eval("return TT:GetWidth(), TT:GetHeight()").unwrap() };
|
||||
let want = (
|
||||
f64::from(2.0 * 10.24f32 + 0.0 + 230.0),
|
||||
f64::from(2.0 * 10.24f32 + 0.0 + 235.0),
|
||||
f64::from(2.0 * 10.24f32 + 54.0),
|
||||
);
|
||||
let near = |(w, h): (f64, f64)| (w - want.0).abs() < 1e-4 && (h - want.1).abs() < 1e-4;
|
||||
|
||||
@@ -11,31 +11,62 @@ use crate::script::{MeasureRequest, TextMeasure, UiScript};
|
||||
const ADVANCE: f32 = 5.0;
|
||||
|
||||
/// A stand-in font engine: each byte [`ADVANCE`] wide, each row as tall as the font, wrapping at
|
||||
/// spaces as the host's does; it counts the measures it answers.
|
||||
/// spaces as the host's does; it counts the measures and splits it answers.
|
||||
pub(in crate::script::tests) struct Mono(pub(in crate::script::tests) Rc<Cell<usize>>);
|
||||
|
||||
fn width(s: &str) -> f32 {
|
||||
s.len() as f32 * ADVANCE
|
||||
}
|
||||
|
||||
/// `text` wrapped at spaces within `wrap`: each row's start byte and its text.
|
||||
fn mono_rows(text: &str, wrap: f32) -> Vec<(usize, String)> {
|
||||
let mut rows: Vec<(usize, String)> = Vec::new();
|
||||
let mut at = 0;
|
||||
for word in text.split(' ') {
|
||||
match rows.last_mut() {
|
||||
Some((_, last)) if width(last) + width(" ") + width(word) <= wrap => {
|
||||
last.push(' ');
|
||||
last.push_str(word);
|
||||
}
|
||||
_ => rows.push((at, word.to_string())),
|
||||
}
|
||||
at += word.len() + 1;
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
impl TextMeasure for Mono {
|
||||
fn measure(&mut self, req: &MeasureRequest) -> (f32, f32, f32) {
|
||||
self.0.set(self.0.get() + 1);
|
||||
let row = req.text_height.or(req.height).unwrap_or(12.0);
|
||||
let width = |s: &str| s.len() as f32 * ADVANCE;
|
||||
let natural = width(&req.text);
|
||||
let Some(wrap) = req.wrap_width else {
|
||||
return (natural, row, natural);
|
||||
};
|
||||
let mut rows: Vec<String> = Vec::new();
|
||||
for word in req.text.split(' ') {
|
||||
match rows.last_mut() {
|
||||
Some(last) if width(last) + width(" ") + width(word) <= wrap => {
|
||||
last.push(' ');
|
||||
last.push_str(word);
|
||||
}
|
||||
_ => rows.push(word.to_string()),
|
||||
}
|
||||
}
|
||||
let widest = rows.iter().map(|r| width(r)).fold(0.0, f32::max);
|
||||
let rows = mono_rows(&req.text, wrap);
|
||||
let widest = rows.iter().map(|(_, r)| width(r)).fold(0.0, f32::max);
|
||||
(widest, row * rows.len() as f32, natural)
|
||||
}
|
||||
|
||||
/// Each piece from its row's start to the next, the space the wrap skipped and all, the last
|
||||
/// of `max_pieces` to the text's end.
|
||||
fn piece_width(&mut self, req: &MeasureRequest, max_pieces: usize) -> Option<f32> {
|
||||
self.0.set(self.0.get() + 1);
|
||||
let wrap = req.wrap_width?;
|
||||
let starts: Vec<usize> = mono_rows(&req.text, wrap)
|
||||
.into_iter()
|
||||
.map(|(at, _)| at)
|
||||
.take(max_pieces)
|
||||
.collect();
|
||||
let ends = starts.iter().skip(1).copied().chain([req.text.len()]);
|
||||
Some(
|
||||
starts
|
||||
.iter()
|
||||
.zip(ends)
|
||||
.map(|(&at, end)| width(&req.text[at..end]))
|
||||
.fold(0.0, f32::max),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A screen, the template, a counting engine, and an owner `O` with tooltip `TS` on it.
|
||||
@@ -216,7 +247,8 @@ fn a_wrapped_line_takes_the_widest_so_far() {
|
||||
"short: {lw}"
|
||||
);
|
||||
|
||||
// Long and alone: twelve 4-byte words, 295 wide, split at 230.4 into rows of 220 and 70.
|
||||
// Long and alone: twelve 4-byte words, 295 wide, split at 230.4 into rows of 220 and 70; the
|
||||
// first piece runs to the second row's start, its space included (`0x530884`), 225.
|
||||
let long = ["aaaa"; 12].join(" ");
|
||||
s.run(&format!(
|
||||
r#"TS:ClearLines() TS:AddLine("{long}", 1, 1, 1, 1) TS:Show()"#
|
||||
@@ -225,7 +257,7 @@ fn a_wrapped_line_takes_the_widest_so_far() {
|
||||
let lw: f64 = s.eval("return TSTextLeft1:GetWidth()").unwrap();
|
||||
let (w, h) = size(&s);
|
||||
assert!(
|
||||
close(lw, 220.0) && close(w, plate(220.0)) && close(h, plate(28.0)),
|
||||
close(lw, 225.0) && close(w, plate(225.0)) && close(h, plate(28.0)),
|
||||
"long: {lw}, {w}x{h}"
|
||||
);
|
||||
|
||||
@@ -243,6 +275,52 @@ fn a_wrapped_line_takes_the_widest_so_far() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A wrapped piece runs from its row's start to the next row's (`0x530884`-`0x5308a8`), and the
|
||||
/// next row starts past the whitespace the wrap skipped (`0x5c24fe`): a 225-wide word and a short
|
||||
/// one split at 230.4 into rows of 225 and 20, and the first piece is 230, its space a glyph.
|
||||
#[test]
|
||||
fn a_wrapped_piece_keeps_the_space_its_wrap_skipped() {
|
||||
let (s, _) = tooltip();
|
||||
let text = format!("{} bbbb", "a".repeat(45));
|
||||
let w: f64 = s
|
||||
.eval(&format!(
|
||||
r#"TS:AddLine("{text}", 1, 1, 1, 1) TS:Show() return TS:GetWidth()"#
|
||||
))
|
||||
.unwrap();
|
||||
let lw: f64 = s.eval("return TSTextLeft1:GetWidth()").unwrap();
|
||||
assert!(close(w, plate(230.0)) && close(lw, 230.0), "{w}, line {lw}");
|
||||
}
|
||||
|
||||
/// The split stops at 20 pieces (`0x53083f`): the 20th runs from its row's start to the text's
|
||||
/// end and is measured unwrapped (`0x53088e`), so a line of more than 20 rows sizes to its tail.
|
||||
#[test]
|
||||
fn a_line_past_20_rows_sizes_to_its_20th_piece() {
|
||||
let (s, _) = tooltip();
|
||||
// Nine 4-byte words make a 220-wide row at 230.4, its piece 225 with the space after it.
|
||||
let plate_for = |rows: usize| -> f64 {
|
||||
let text = vec!["aaaa"; 9 * rows].join(" ");
|
||||
s.eval(&format!(
|
||||
r#"TS:ClearLines() TS:AddLine("{text}", 1, 1, 1, 1) TS:Show() return TS:GetWidth()"#
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
assert!(close(plate_for(19), plate(225.0)), "19 rows");
|
||||
assert!(
|
||||
close(plate_for(20), plate(225.0)),
|
||||
"20 rows: the last row alone, narrower than the pieces before it"
|
||||
);
|
||||
// Rows 20 and 21: eighteen words, 89 bytes.
|
||||
let w = plate_for(21);
|
||||
assert!(close(w, plate(445.0)), "21 rows: {w}");
|
||||
// Rows 20 to 25: fifty-four words, 269 bytes.
|
||||
let w = plate_for(25);
|
||||
let lw: f64 = s.eval("return TSTextLeft1:GetWidth()").unwrap();
|
||||
assert!(
|
||||
close(w, plate(1345.0)) && close(lw, 1345.0),
|
||||
"25 rows: {w}, line {lw}"
|
||||
);
|
||||
}
|
||||
|
||||
/// With no owner the show hides and neither it nor `AppendText` sizes (`0x530a83`, `0x530649`).
|
||||
#[test]
|
||||
fn an_ownerless_show_hides_and_sizes_nothing() {
|
||||
@@ -321,16 +399,18 @@ fn minimum_width_and_padding_read_their_arguments_as_the_reference() {
|
||||
}
|
||||
|
||||
/// The hover loops clear, rebuild and show every frame (`ContainerFrameItemButton_OnUpdate`): past
|
||||
/// the first frame the sizer measures nothing, its wrapped line's three widths answered from the
|
||||
/// the first frame the sizer measures and splits nothing, its wrapped lines answered from the
|
||||
/// memo, and solves nothing, as the clear's width reset and the sizer's restore leave the line's
|
||||
/// node where it was.
|
||||
#[test]
|
||||
fn a_steady_hover_measures_nothing_after_its_first_frame() {
|
||||
let (mut s, calls) = tooltip();
|
||||
let long = ["aaaa"; 12].join(" ");
|
||||
let split = ["aaaa"; 225].join(" ");
|
||||
let frame = format!(
|
||||
r#"TS:SetOwner(O, "ANCHOR_RIGHT")
|
||||
TS:AddLine("Title") TS:AddLine("{long}", 1, 1, 1, 1) TS:AddDoubleLine("left", "right")
|
||||
TS:AddLine("{split}", 1, 1, 1, 1)
|
||||
TS:Show()"#
|
||||
);
|
||||
s.run(&frame).unwrap();
|
||||
|
||||
@@ -18,9 +18,12 @@ use crate::widget::{
|
||||
};
|
||||
|
||||
/// Entries the measure memo holds before it is emptied: thirty lines on each of a few plates,
|
||||
/// three widths a wrapped line.
|
||||
/// three widths and a split a wrapped line.
|
||||
const MEMO_CAP: usize = 1024;
|
||||
|
||||
/// The pieces a wrapped line is split into at most (`0x53082f`).
|
||||
const SPLIT_PIECES: usize = 20;
|
||||
|
||||
/// `GameTooltipSize` (`0x530640`) on tooltip `h`; nothing without an owner (`0x530649`).
|
||||
pub(in crate::script) fn size_tooltip(model: &mut Model, h: FrameHandle) {
|
||||
let Some(KindState::Tooltip(t)) = model.arena.frame(h).map(|f| &f.kind_state) else {
|
||||
@@ -68,9 +71,11 @@ pub(in crate::script) fn size_tooltip(model: &mut Model, h: FrameHandle) {
|
||||
}
|
||||
|
||||
// The wrapped lines in order, shown or not (`0x530790`): the text's width capped at 230.4
|
||||
// (`0x5307a9`-`0x5307ef`); wider than the widest so far, the text is split there and the widest
|
||||
// piece counts (`0x530837`-`0x5308be`). The line is then as wide as the widest so far
|
||||
// (`0x5308ef`), so a later, wider line leaves an earlier one narrower.
|
||||
// (`0x5307a9`-`0x5307ef`); wider than the widest so far, the text is split at its row starts
|
||||
// into at most 20 pieces, each measured to the next start (`0x530884`-`0x5308a8`) with the
|
||||
// whitespace its row's wrap skipped (`0x5c24fe`), and the widest counts. The line is then as
|
||||
// wide as the widest so far (`0x5308ef`), so a later, wider line leaves an earlier one
|
||||
// narrower.
|
||||
for &(left, _, wrapped) in &lines {
|
||||
if !wrapped {
|
||||
continue;
|
||||
@@ -86,13 +91,17 @@ pub(in crate::script) fn size_tooltip(model: &mut Model, h: FrameHandle) {
|
||||
};
|
||||
let mut piece = cap;
|
||||
if cap > widest {
|
||||
piece = match probe(model, left, Some(cap)) {
|
||||
Some(m) => m.w.max(0.0),
|
||||
// Headless, the cap stands in until the split's measure lands.
|
||||
None => {
|
||||
pending = true;
|
||||
cap
|
||||
}
|
||||
piece = match split(model, left, cap) {
|
||||
Some(w) => w,
|
||||
// An engine with no split stands in its wrapped width; headless, the cap stands in
|
||||
// until the measure lands.
|
||||
None => match probe(model, left, Some(cap)) {
|
||||
Some(m) => m.w.max(0.0),
|
||||
None => {
|
||||
pending = true;
|
||||
cap
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
// `fcomp; test ah,0x41; je` (`0x5308cc`): kept only when larger, so a NaN piece is taken.
|
||||
@@ -257,6 +266,48 @@ fn probe(model: &mut Model, rh: RegionHandle, wrap: Option<f32>) -> Option<Measu
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// The widest piece of `rh`'s text split at `wrap` (`0x530837`-`0x5308c4`), from the memo or the
|
||||
/// font engine. A string with no text or no font has no pieces (`0x530822`, `0x772b6e`). `None`
|
||||
/// is an engine with no split to give, or none installed.
|
||||
fn split(model: &mut Model, rh: RegionHandle, wrap: f32) -> Option<f32> {
|
||||
let scale = scale_of(model, rh);
|
||||
let non_space_wrap = model.non_space_wrap_of(rh);
|
||||
let d = model.region_data.get(&rh)?;
|
||||
let text = d.text.as_deref().unwrap_or("");
|
||||
if text.is_empty() {
|
||||
return Some(0.0);
|
||||
}
|
||||
let key = split_key(d.measure_key_at(text, Some(wrap), scale));
|
||||
if let Some(m) = model.tooltip_measures.get(&(key, non_space_wrap)) {
|
||||
return Some(m.w);
|
||||
}
|
||||
if !crate::script::font_block::has_font(model, rh) {
|
||||
return Some(0.0);
|
||||
}
|
||||
model.measurer.as_ref()?;
|
||||
let req = crate::script::layout::measure_request_at(model, rh, Some(wrap), key, scale)?;
|
||||
let mut engine = model.measurer.take().expect("checked above");
|
||||
let w = crate::script::TextMeasure::piece_width(&mut engine, &req, SPLIT_PIECES);
|
||||
model.measurer = Some(engine);
|
||||
let w = w?;
|
||||
let m = MeasuredText {
|
||||
w,
|
||||
h: 0.0,
|
||||
natural_w: w,
|
||||
key,
|
||||
};
|
||||
memo_insert(model, (key, non_space_wrap), m);
|
||||
Some(w)
|
||||
}
|
||||
|
||||
/// The memo key of the split of the text whose wrapped measure key is `key`.
|
||||
fn split_key(key: u64) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
(key, SPLIT_PIECES).hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
fn memo_insert(model: &mut Model, key: (u64, bool), m: MeasuredText) {
|
||||
if model.tooltip_measures.len() >= MEMO_CAP {
|
||||
model.tooltip_measures.clear();
|
||||
|
||||
Reference in New Issue
Block a user