diff --git a/crates/benilla-app/src/ui_text/layout/wrap_laws.rs b/crates/benilla-app/src/ui_text/layout/wrap_laws.rs index 78e4335e1..4d61dc06f 100644 --- a/crates/benilla-app/src/ui_text/layout/wrap_laws.rs +++ b/crates/benilla-app/src/ui_text/layout/wrap_laws.rs @@ -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] diff --git a/crates/benilla-app/src/ui_text/measurer.rs b/crates/benilla-app/src/ui_text/measurer.rs index a74696141..9a87cc730 100644 --- a/crates/benilla-app/src/ui_text/measurer.rs +++ b/crates/benilla-app/src/ui_text/measurer.rs @@ -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 { + 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| 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 diff --git a/crates/benilla-ui/src/script/measure.rs b/crates/benilla-ui/src/script/measure.rs index 002422bca..4bc591c80 100644 --- a/crates/benilla-ui/src/script/measure.rs +++ b/crates/benilla-ui/src/script/measure.rs @@ -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 { + 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, Vec, f32)> { self.0.borrow_mut().editbox_advances(req) } + + fn piece_width(&mut self, req: &MeasureRequest, max_pieces: usize) -> Option { + 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 diff --git a/crates/benilla-ui/src/script/tests/tooltip_item/mechanics.rs b/crates/benilla-ui/src/script/tests/tooltip_item/mechanics.rs index 0353f2215..a6d79fff0 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/mechanics.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/mechanics.rs @@ -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; diff --git a/crates/benilla-ui/src/script/tests/tooltip_size.rs b/crates/benilla-ui/src/script/tests/tooltip_size.rs index 6119130bc..c22c287f8 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_size.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_size.rs @@ -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>); +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 = 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 { + self.0.set(self.0.get() + 1); + let wrap = req.wrap_width?; + let starts: Vec = 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(); diff --git a/crates/benilla-ui/src/script/tooltip/size.rs b/crates/benilla-ui/src/script/tooltip/size.rs index 647071df7..b7845618f 100644 --- a/crates/benilla-ui/src/script/tooltip/size.rs +++ b/crates/benilla-ui/src/script/tooltip/size.rs @@ -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) -> Option Option { + 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();