From 20abc7464398a259abd7b66a39ae1caa78c19fd8 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:53:25 +0700 Subject: [PATCH] GameTooltip sizes in the show, as 1.12.1 does (#1884) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GameTooltip re-sized itself on every resolve from its lines, at 10/40/260 units, counting a taller right text. So `GetWidth` after `Show` read stale, a script's `SetHeight` after `AddLine` was overwritten, and descriptions wrapped wide. 1.12.1 sizes only in the show (`0x530aad`), `AppendText` (`0x530615`) and the cache answers, never in `AddLine` (`0x531630`). `GameTooltipSize` (`0x530640`) takes the widest unwrapped line plus a 38.4 gap, wraps the rest at the running widest (capped at 230.4), sums shown left heights with 2.0 gaps, pads 10.24 a side, then re-anchors without clearing. Now the sizer is that function, called from those sites, and the engine no longer anchors line cells; the stock templates declare them. A measure memo keeps a steady hover from measuring again. Tests pin each loop, constant and caller. Found in Thorsten42/manila@50452d3fa5. Fixes #1206 Fixes #1182 Fixes #1540 ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 --- .../src/ui_script/resolve_bench.rs | 59 ++- crates/benilla-app/src/ui_script/test_ui.rs | 24 +- crates/benilla-ui/src/script/layout.rs | 26 +- crates/benilla-ui/src/script/measure.rs | 9 +- crates/benilla-ui/src/script/mod.rs | 1 + crates/benilla-ui/src/script/model.rs | 54 +-- crates/benilla-ui/src/script/tests/common.rs | 28 +- .../benilla-ui/src/script/tests/flag_args.rs | 33 +- crates/benilla-ui/src/script/tests/mod.rs | 1 + crates/benilla-ui/src/script/tests/tooltip.rs | 166 ++++---- .../script/tests/tooltip_item/mechanics.rs | 65 ++-- .../src/script/tests/tooltip_size.rs | 353 ++++++++++++++++++ .../src/script/tests/tooltip_unit.rs | 2 +- crates/benilla-ui/src/script/tooltip/mod.rs | 291 +++------------ crates/benilla-ui/src/script/tooltip/size.rs | 340 +++++++++++++++++ crates/benilla-ui/src/script/tooltip/verbs.rs | 28 +- .../src/script/tooltip_item/build.rs | 4 + crates/benilla-ui/src/script/tooltip_spell.rs | 7 +- crates/benilla-ui/src/script/types.rs | 11 +- crates/benilla-ui/src/widget/kinds/mod.rs | 39 +- crates/benilla-ui/src/widget/mod.rs | 7 +- docs/DEVIATIONS.md | 7 +- 22 files changed, 1070 insertions(+), 485 deletions(-) create mode 100644 crates/benilla-ui/src/script/tests/tooltip_size.rs create mode 100644 crates/benilla-ui/src/script/tooltip/size.rs diff --git a/crates/benilla-app/src/ui_script/resolve_bench.rs b/crates/benilla-app/src/ui_script/resolve_bench.rs index 42c313915..3a8056ad0 100644 --- a/crates/benilla-app/src/ui_script/resolve_bench.rs +++ b/crates/benilla-app/src/ui_script/resolve_bench.rs @@ -7,13 +7,24 @@ use std::time::Instant; -use benilla_ui::script::UiScript; +use benilla_ui::script::{MeasureRequest, TextMeasure, UiScript}; fn ms(t: Instant) -> f64 { t.elapsed().as_secs_f64() * 1000.0 } /// A deterministic font engine: 6 units a character, so only a real text change moves the layout. +/// The laid-out width and height of `r`. +fn six_wide(r: &MeasureRequest) -> (f32, f32) { + #[allow(clippy::cast_precision_loss)] + let w = r.text.chars().count() as f32 * 6.0; + match r.wrap_width { + Some(ww) if w > ww => (ww, 12.0 * (w / ww).ceil()), + _ => (w, 12.0), + } +} + +/// [`six_wide`] answering the host's measure round trip. fn answer_measures(s: &mut UiScript) -> usize { let reqs = s.fontstrings_needing_measure(); let n = reqs.len(); @@ -21,12 +32,8 @@ fn answer_measures(s: &mut UiScript) -> usize { let answers: Vec<(u32, f32, f32, u64)> = reqs .iter() .map(|r| { - #[allow(clippy::cast_precision_loss)] - let w = r.text.chars().count() as f32 * 6.0; - match r.wrap_width { - Some(ww) if w > ww => (r.id, ww, 12.0 * (w / ww).ceil(), r.key), - _ => (r.id, w, 12.0, r.key), - } + let (w, h) = six_wide(r); + (r.id, w, h, r.key) }) .collect(); s.set_measured_text_unwrapped(&answers); @@ -34,9 +41,35 @@ fn answer_measures(s: &mut UiScript) -> usize { n } +/// [`six_wide`] installed in the VM, as the app installs its font engine +/// (`extract::seat_text_measurer`), so a tooltip is sized in the call that shows it (`0x530640`) +/// rather than over the round trip's frames. +struct SixWide; + +impl TextMeasure for SixWide { + fn measure(&mut self, r: &MeasureRequest) -> (f32, f32, f32) { + let (w, h) = six_wide(r); + #[allow(clippy::cast_precision_loss)] + let natural = r.text.chars().count() as f32 * 6.0; + (w, h, natural) + } +} + /// The full shipped UI, loaded and settled. fn settled_default_ui() -> UiScript { + settled_ui(false) +} + +/// [`settled_default_ui`] with [`SixWide`] installed. +fn settled_default_ui_measured() -> UiScript { + settled_ui(true) +} + +fn settled_ui(measurer: bool) -> UiScript { let mut s = UiScript::new().unwrap(); + if measurer { + s.set_text_measurer(Box::new(SixWide)); + } s.set_screen_size(1600.0, 900.0); // The UI loads on world entry, so a player always exists by then. s.set_unit( @@ -237,12 +270,12 @@ fn hidden_frames_cost() { ); } -/// Measuring before resolving makes a tooltip content change cost one layout solve; measured -/// after, the answers would force a second whole-UI solve. +/// A tooltip content change costs one layout solve: the sizer measures in the show through the +/// engine the VM holds, as the app's does, so no answer lands after the resolve to force a second. #[test] fn a_tooltip_content_change_costs_exactly_one_layout_solve() { benilla_formats::wow_data_or_skip!(); - let mut s = settled_default_ui(); + let mut s = settled_default_ui_measured(); install_changing_tooltip(&s, "GateOwner", "gate_change"); for _ in 0..4 { @@ -258,8 +291,8 @@ fn a_tooltip_content_change_costs_exactly_one_layout_solve() { let solves = s.layout_solves() - before; assert_eq!( solves, 10, - "10 content changes must cost 10 solves โ€” one each. Two per change means the measure \ - round-trip is running AFTER the resolve again (extract::tick_script's order)." + "10 content changes must cost 10 solves โ€” one each. Two per change means a measure \ + landed after the resolve (extract::tick_script's order)." ); // Nor may they derive the graph: a content change writes measure answers and re-anchors // pooled line regions, nothing structural. @@ -468,7 +501,7 @@ fn a_tooltip_content_change_solves_a_tooltip_sized_scope() { #[test] fn a_scoped_resolve_reproduces_the_whole_graph_solve() { benilla_formats::wow_data_or_skip!(); - let mut s = settled_default_ui(); + let mut s = settled_default_ui_measured(); install_changing_tooltip(&s, "ScopeOwner", "scope_change"); for step in 0..12 { diff --git a/crates/benilla-app/src/ui_script/test_ui.rs b/crates/benilla-app/src/ui_script/test_ui.rs index c6ee47572..1d3cfb347 100644 --- a/crates/benilla-app/src/ui_script/test_ui.rs +++ b/crates/benilla-app/src/ui_script/test_ui.rs @@ -12,19 +12,27 @@ pub(crate) fn load_ui(s: &UiScript, entry: &str) -> usize { load_entry(s, entry, false, false) } -/// Declares `TestTooltipTemplate`, the stock template's 30 `TextLeft`/`TextRight` pairs, for a test -/// with no install. A tooltip made without the template holds no line, as on 1.12.1; a test makes -/// one with `CreateFrame("GameTooltip", name, parent, "TestTooltipTemplate")`. +/// Declares `TestTooltipTemplate`, the stock template's 30 `TextLeft`/`TextRight` pairs with their +/// anchors (`GameTooltipTemplate.xml`), for a test with no install. A tooltip made without the +/// template holds no line, as on 1.12.1; a test makes one with +/// `CreateFrame("GameTooltip", name, parent, "TestTooltipTemplate")`. pub(crate) fn load_tooltip_template(s: &UiScript) { let mut xml = String::from( r#""#, ); for i in 1..=30 { - for side in ["Left", "Right"] { - xml.push_str(&format!( - r#""); let doc = benilla_ui::framexml::parse(&xml).expect("tooltip template parses"); diff --git a/crates/benilla-ui/src/script/layout.rs b/crates/benilla-ui/src/script/layout.rs index 2d5247626..5308152a9 100644 --- a/crates/benilla-ui/src/script/layout.rs +++ b/crates/benilla-ui/src/script/layout.rs @@ -738,7 +738,7 @@ pub(super) fn layout_verify_enabled() -> bool { struct PreambleProf { on: bool, last: Option, - /// The GameTooltip auto-size pre-pass (`layout_tooltips`). + /// The GameTooltip cursor pre-pass (`anchor_cursor_tooltips`). tooltip: u128, /// `OnSizeChanged`'s "before" snapshot. watched: u128, @@ -839,6 +839,8 @@ impl UiScript { /// [`UiScript::resolve`]'s body on a bare `Model`, so a Lua binding can force a fresh resolve, /// as a scroll frame's range recompute must reflect a same-tick `SetHeight`. pub(super) fn resolve_layout(model: &mut Model) { + // A tooltip sized before its lines were measured, in a VM with no font engine. + super::tooltip::size_owed(model); let solves = model.layout_solves; Self::resolve_layout_inner(model); // A solve may have resized a frame: its backdrop re-runs its texcoords (`0x76b580`). @@ -943,8 +945,8 @@ impl UiScript { fn resolve_layout_inner(model: &mut Model) { // Text that emptied or filled since the last resolve: named now where its node's hash // moved, ahead of the epoch's read, as if named at the write. - if !model.text_flips.is_empty() { - for rh in std::mem::take(&mut model.text_flips) { + if !model.content_writes.is_empty() { + for rh in std::mem::take(&mut model.content_writes) { model.touch_layout_content(rh); } } @@ -960,8 +962,8 @@ impl UiScript { // Every call past tier 1 counts, verify's re-walks included: the walk is the cost. model.layout_gate_walks = model.layout_gate_walks.wrapping_add(1); let mut pre = PreambleProf::new(layout_prof_enabled()); - // The GameTooltip pre-pass writes tooltip sizes and offsets from cached measures. - super::tooltip::layout_tooltips(model); + // The GameTooltip pre-pass places each cursor-anchored plate. + super::tooltip::anchor_cursor_tooltips(model); pre.tooltip = pre.lap(); // The invalidations since the last resolve, the pre-pass's included, each answered by this // one, as the reference drains its whole queue (`0x768ed0`). @@ -2083,9 +2085,21 @@ pub(super) fn measure_request_for(model: &mut Model, rh: RegionHandle) -> Option super::measure::store_measured(model, rh, id, zero); return None; } + let wrap_width = model.region_data.get(&rh)?.wrap_width(); + measure_request_at(model, rh, wrap_width, key, scale) +} + +/// The [`MeasureRequest`] for `rh`'s text laid out in `wrap_width` (`None`, unwrapped) under +/// `key`, whatever measure it holds; `None` for no text. +pub(super) fn measure_request_at( + model: &mut Model, + rh: RegionHandle, + wrap_width: Option, + key: u64, + scale: f32, +) -> Option { let d = model.region_data.get(&rh)?; let text = d.text.clone().filter(|t| !t.is_empty())?; - let wrap_width = d.size.map(|s| s.0).filter(|w| *w > 0.0); let font = d.font_path.clone(); let height = d.font_height; let text_height = d.text_height; diff --git a/crates/benilla-ui/src/script/measure.rs b/crates/benilla-ui/src/script/measure.rs index 17cd0339c..002422bca 100644 --- a/crates/benilla-ui/src/script/measure.rs +++ b/crates/benilla-ui/src/script/measure.rs @@ -100,9 +100,12 @@ impl super::UiScript { /// it whenever the window size or `uiScale` changes: glyph advances snap to whole physical /// pixels. pub fn set_text_measurer(&mut self, measurer: Box) { - self.model_mut().measurer = Some(SharedMeasurer(std::rc::Rc::new( - std::cell::RefCell::new(measurer), - ))); + let mut model = self.model_mut(); + model.measurer = Some(SharedMeasurer(std::rc::Rc::new(std::cell::RefCell::new( + measurer, + )))); + // A new engine answers under a new raster: no measure of the old one stands. + model.tooltip_measures.clear(); } /// Whether a [`TextMeasure`] is installed; the host then skips its batch measure pass. diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 5ab4b6421..2d1108261 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -1126,6 +1126,7 @@ impl UiScript { /// measure can fail the ellipsis fit and truncate text that fits. pub fn invalidate_text_measures(&mut self) { let mut model = self.model_mut(); + model.tooltip_measures.clear(); for d in model.region_data.values_mut() { d.measured = None; } diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index f6dc43a24..38eb56980 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -130,6 +130,10 @@ pub(crate) struct Model { pub(crate) addons_chain_reader: Option, /// The host's font engine, so a metric answers inside the asking call, not a frame later. pub(crate) measurer: Option, + /// The tooltip sizer's measures by key ([`super::tooltip`]): the clear's width reset and the + /// sizer's probes key a wrapped line three ways a frame, so the hover loops' per-frame rebuild + /// asks the font engine nothing after the first. Emptied with the engine's metrics. + pub(crate) tooltip_measures: HashMap<(u64, bool), super::MeasuredText>, /// Whether a texture path resolves (patch chain or loose addon file), so the path form of /// `SetTexture` returns the reference's 1 or nil inline (`0x79bb40`). `None` answers nil. pub(crate) texture_probe: Option, @@ -341,9 +345,10 @@ pub(crate) struct Model { /// Frames the last resolve resized, `(id, width, height)`, owed `OnSizeChanged` outside it. pub(crate) pending_size_changed: Vec<(u32, f32, f32)>, - /// Regions whose text emptied or filled since the last resolve, which names each one whose - /// node hash moved ([`Model::touch_layout_text`]). - pub(crate) text_flips: Vec, + /// Regions whose text emptied or filled ([`Model::touch_layout_text`]), or whose tooltip line + /// width the clear or the sizer wrote ([`Model::note_line_width`]), since the last resolve, + /// which names each one whose node hash moved. + pub(crate) content_writes: Vec, /// Script errors caught from `pcall`'d handlers, drained by the host each frame. pub(crate) errors: Vec, @@ -1361,6 +1366,7 @@ impl Model { addons_root: None, addons_chain_reader: None, measurer: None, + tooltip_measures: HashMap::new(), texture_probe: None, world_loc_projector: None, texture_size_probe: None, @@ -1425,7 +1431,7 @@ impl Model { mouse_capture: None, last_click: HashMap::new(), pending_size_changed: Vec::new(), - text_flips: Vec::new(), + content_writes: Vec::new(), layout_force: Vec::new(), layout_pending: Vec::new(), layout_unsettled: HashSet::new(), @@ -1954,10 +1960,17 @@ impl Model { .get(&rh) .is_some_and(|d| d.text.as_deref().is_some_and(|t| !t.is_empty())); if shown != was_shown { - self.text_flips.push(rh); + self.content_writes.push(rh); } } + /// A tooltip line's width written by the clear (`0x5301f0`) or the sizer (`0x5308ef`), each a + /// `SetWidth` that invalidates (`0x7683d0`): noted as a text flip is, as the hover loops reset + /// and restore it every frame, so the next resolve names it only if it moved the node's hash. + pub(crate) fn note_line_width(&mut self, rh: RegionHandle) { + self.content_writes.push(rh); + } + /// Text that emptied or filled on anchored region `rh`, which moves the measure its resolve /// reads: a new string clears the valid bit and invalidates (`0x771ea4`), unless the node /// still holds its converged hash, as when a clear and rebuild restored the same text. @@ -2608,37 +2621,6 @@ impl Model { self.measure_dirty = None; } - /// Names a frame without bumping the epoch, for the resolve's own tooltip pre-pass: a bump - /// inside a resolve would hold the gate open for good, yet an unnamed write could go stale. - pub(crate) fn note_layout_frame_write(&mut self, h: FrameHandle) { - if self.layout_touched.is_some() { - match self.frame_to_id.get(&h) { - Some(&id) => { - self.layout_touched - .as_mut() - .expect("checked above") - .push(id); - } - _ => self.give_up_naming(), - } - } - } - - /// `note_layout_frame_write` for a region. - pub(crate) fn note_layout_region_write(&mut self, rh: RegionHandle) { - if self.layout_touched.is_some() { - match self.region_to_id.get(&rh) { - Some(&id) => { - self.layout_touched - .as_mut() - .expect("checked above") - .push(id); - } - _ => self.give_up_naming(), - } - } - } - /// Names layout id `id` as what this write moved, while naming is still on, whether or not /// the cached graph holds it yet: the resolve re-derives it either way. pub(crate) fn name_layout_node(&mut self, id: u32) { diff --git a/crates/benilla-ui/src/script/tests/common.rs b/crates/benilla-ui/src/script/tests/common.rs index 72d2e4528..bf404fc48 100644 --- a/crates/benilla-ui/src/script/tests/common.rs +++ b/crates/benilla-ui/src/script/tests/common.rs @@ -9,10 +9,11 @@ const TOOLTIP_PAIRS: usize = 30; /// Declares `TestTooltipTemplate`, the stock template's line ladder: a tooltip made without it holds /// no line, as on 1.12.1. A test makes one with /// `CreateFrame("GameTooltip", name, parent, "TestTooltipTemplate")`. Its lines' two font objects -/// are declared first, face and height only, as a line with no font draws nothing. +/// are declared first, face, height and justification only (`Fonts.xml`), as a line with no font +/// draws nothing; the lines' anchors are the stock template's (`GameTooltipTemplate.xml`). pub(in crate::script) fn load_tooltip_template(s: &UiScript) { let mut xml = String::from( - r#""#, + r#""#, ); for i in 1..=TOOLTIP_PAIRS { let font = if i == 1 { @@ -20,11 +21,7 @@ pub(in crate::script) fn load_tooltip_template(s: &UiScript) { } else { "GameTooltipText" }; - for side in ["Left", "Right"] { - xml.push_str(&format!( - r#""); let doc = crate::framexml::parse(&xml).expect("tooltip template parses"); @@ -32,6 +29,23 @@ pub(in crate::script) fn load_tooltip_template(s: &UiScript) { assert!(report.errors.is_empty(), "template: {:?}", report.errors); } +/// Line `i`'s pair as `GameTooltipTemplate.xml` declares it: the left text at the plate's TOPLEFT +/// (10, -10) or under the line above (0, -2), the right text's RIGHT on its left text's LEFT (40, 0). +fn tooltip_line_xml(i: usize, font: &str) -> String { + let left_anchor = if i == 1 { + r#""# + .to_string() + } else { + format!( + r#""#, + i - 1 + ) + }; + format!( + r#""# + ) +} + pub(super) fn script() -> UiScript { let s = UiScript::new().expect("construct UiScript"); load_tooltip_template(&s); diff --git a/crates/benilla-ui/src/script/tests/flag_args.rs b/crates/benilla-ui/src/script/tests/flag_args.rs index 2b295dea3..9e7f12466 100644 --- a/crates/benilla-ui/src/script/tests/flag_args.rs +++ b/crates/benilla-ui/src/script/tests/flag_args.rs @@ -405,25 +405,26 @@ fn macro_flags_read_get_bool_or_default_with_default_zero() { } } -/// The GameTooltip wrap flag (`0x531811`, `0x531dd3`, `0x531b60`), default 0: a wrapped line pins -/// its left cell to the wrap width. A double line wraps only with an empty right side, as the line -/// core clears the flag otherwise (`0x530284`). +/// The GameTooltip wrap flag (`0x531811`, `0x531dd3`, `0x531b60`), default 0, stored per line +/// (`0x53036f`). A double line wraps only with an empty right side, as the line core clears the +/// flag otherwise (`0x530284`). #[test] fn tooltip_wrap_flags_read_get_bool_or_default_with_default_zero() { let s = script(); - s.run(r#"TT = CreateFrame("GameTooltip", "TT", nil, "TestTooltipTemplate")"#) - .unwrap(); - let pinned = |line: u32| { - let t: mlua::Table = s - .lua() - .globals() - .get(format!("TTTextLeft{line}")) - .expect("left line"); - let rh = crate::script::region::region_handle_of(s.lua(), &t).unwrap(); - s.model_ref() - .region_data - .get(&rh) - .is_some_and(|d| d.size.is_some()) + // Owned, so `SetText`'s show keeps the line: an ownerless one hides, and the hide's clear + // zeroes the flags (`0x530a60`). + s.run( + r#"TT = CreateFrame("GameTooltip", "TT", nil, "TestTooltipTemplate") + TT:SetOwner(CreateFrame("Frame"), "ANCHOR_NONE")"#, + ) + .unwrap(); + let pinned = |line: usize| { + let model = s.model_ref(); + let h = model.arena.lookup("TT").expect("the tooltip"); + match model.arena.frame(h).map(|f| &f.kind_state) { + Some(crate::widget::KindState::Tooltip(t)) => t.wrapped[line - 1], + _ => panic!("not a tooltip"), + } }; for (arg, want) in cases(false) { s.run("TT:ClearLines()").unwrap(); diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs index f9a2c9ee8..b5cc29e38 100644 --- a/crates/benilla-ui/src/script/tests/mod.rs +++ b/crates/benilla-ui/src/script/tests/mod.rs @@ -80,6 +80,7 @@ mod texture_art; mod tooltip; mod tooltip_item; mod tooltip_setter_flags; +mod tooltip_size; mod tooltip_spell; mod tooltip_unit; mod toplevel; diff --git a/crates/benilla-ui/src/script/tests/tooltip.rs b/crates/benilla-ui/src/script/tests/tooltip.rs index 7bde09d9e..3b6e52992 100644 --- a/crates/benilla-ui/src/script/tests/tooltip.rs +++ b/crates/benilla-ui/src/script/tests/tooltip.rs @@ -1,4 +1,4 @@ -//! The GameTooltip widget's engine mechanics: the line stack and its auto-size, `SetOwner`'s +//! The GameTooltip widget's engine mechanics: the line stack and its sizing, `SetOwner`'s //! anchor modes, the implicit and gated shows, and the fade. use super::common::script; @@ -20,6 +20,11 @@ fn measure_all(s: &mut UiScript, sizes: &[(&str, f32, f32)]) { s.resolve(); } +/// Whether `got` is `want` to a ten-thousandth: the plate pads are 10.24, which f32 rounds. +fn near(got: f64, want: f64) -> bool { + (got - want).abs() < 1e-4 +} + #[test] fn line_stack_autosize_and_right_flush() { let mut s = script(); @@ -50,15 +55,10 @@ fn line_stack_autosize_and_right_flush() { ("5 - 9 Damage", 70.0, 12.0), ], ); - // maxw = max(80, 50+40+30, 70) = 120 โ‡’ width 140; totalh = 14+2+12+2+12 = 42 โ‡’ height 62. - // ANCHOR_RIGHT: tooltip BOTTOMLEFT at owner TOPRIGHT (140, 500). - s.run( - r#" - assert(TT:GetWidth() == 140, "auto width, got " .. TT:GetWidth()) - assert(TT:GetHeight() == 62, "auto height, got " .. TT:GetHeight()) - "#, - ) - .unwrap(); + // widest = max(80, 50 + 38.4 + 30, 70) = 118.4 โ‡’ width 138.88; height 14+2+12+2+12 = 42 โ‡’ + // 62.48. ANCHOR_RIGHT: tooltip BOTTOMLEFT at owner TOPRIGHT (140, 500). + let (w, h): (f64, f64) = s.eval("return TT:GetWidth(), TT:GetHeight()").unwrap(); + assert!(near(w, 138.88) && near(h, 62.48), "auto size, got {w}x{h}"); let quads = s.extract(); let rect_of = |needle: &str| { quads @@ -70,19 +70,22 @@ fn line_stack_autosize_and_right_flush() { .unwrap_or_else(|| panic!("no quad for {needle}")) }; let l1 = rect_of("Tough Jerky"); - // Frame: left 140, bottom 500 โ‡’ text inset TOPLEFT (150, 552). - assert_eq!((l1.left, l1.top), (150.0, 552.0)); + // Frame: left 140, top 562.48 โ‡’ the template's TOPLEFT (10, โˆ’10) at (150, 552.48). + assert!( + l1.left == 150.0 && near(f64::from(l1.top), 552.48), + "{l1:?}" + ); let r2 = rect_of("Sword"); - // Right-flushed: right edge at frame.right โˆ’ pad = 280 โˆ’ 10 = 270. - assert_eq!(r2.right, 270.0); - // Seated on line 2's band (left2 top = 552 โˆ’ 14 โˆ’ 2 = 536). + // The right text's RIGHT at its left text's LEFT + the widest line: 150 + 118.4. + assert!(near(f64::from(r2.right), 268.4), "{r2:?}"); + // Seated on line 2's band (left2 top = 552.48 โˆ’ 14 โˆ’ 2). let l2 = rect_of("One-Hand"); - assert_eq!(l2.top, 536.0); + assert!(near(f64::from(l2.top), 536.48), "{l2:?}"); } -/// The layout re-points each shown right cell by its RIGHT slot alone, `SetPoint(RIGHT, leftCell, +/// The sizer re-points each shown right cell by its RIGHT slot alone, `SetPoint(RIGHT, leftCell, /// LEFT, width, 0)` (`0x53094f`): a point an addon added beside it keeps its offset, and after a -/// `ClearAllPoints` the RIGHT point comes back. +/// `ClearAllPoints` the next sizing sets RIGHT again. #[test] fn the_right_column_write_sets_the_right_slot_only() { let mut s = script(); @@ -108,7 +111,7 @@ fn the_right_column_write_sets_the_right_slot_only() { ("Sword", 30.0, 12.0), ], ); - // maxw = max(80, 50 + 40 + 30) = 120. + // widest = max(80, 50 + 38.4 + 30) = 118.4. let right_anchor = r#" local p, rel, rp, x, y = TTRTextRight2:GetPoint(N) return p, rel == TTRTextLeft2, rp, x, y @@ -130,12 +133,12 @@ fn the_right_column_write_sets_the_right_slot_only() { let (p, rel, rp, x, y): (String, bool, String, f64, f64) = s.eval(&right_anchor.replace('N', "2")).unwrap(); assert_eq!( - (p.as_str(), rel, rp.as_str(), x, y), - ("RIGHT", true, "LEFT", 120.0, 0.0) + (p.as_str(), rel, rp.as_str(), x as f32, y), + ("RIGHT", true, "LEFT", 118.4, 0.0) ); - s.run("TTRTextRight2:ClearAllPoints()").unwrap(); - s.resolve(); + // The next sizing, the show's, sets RIGHT again. + s.run("TTRTextRight2:ClearAllPoints() TTR:Show()").unwrap(); s.resolve(); assert_eq!( s.eval::("return TTRTextRight2:GetNumPoints()") @@ -146,14 +149,14 @@ fn the_right_column_write_sets_the_right_slot_only() { let (p, rel, rp, x, y): (String, bool, String, f64, f64) = s.eval(&right_anchor.replace('N', "1")).unwrap(); assert_eq!( - (p.as_str(), rel, rp.as_str(), x, y), - ("RIGHT", true, "LEFT", 120.0, 0.0) + (p.as_str(), rel, rp.as_str(), x as f32, y), + ("RIGHT", true, "LEFT", 118.4, 0.0) ); } /// An empty left cell is a one-unit row plus its 2 px gap in the anchor chain, as the reference's -/// size getters floor a FontString at one FrameXML unit. The plate's height is not asserted: the -/// client sums shown left cells only, so this row's share of it is the open tooltip-height issue's to settle. +/// size getters floor a FontString at one FrameXML unit; the sizer counts it in neither size +/// (`0x530983`). #[test] fn empty_line_is_a_one_unit_row_and_the_chain_stays_contiguous() { let mut s = script(); @@ -183,10 +186,12 @@ fn empty_line_is_a_one_unit_row_and_the_chain_stays_contiguous() { ("PvP", 24.0, 12.0), ], ); - // maxw 90 (the name; the double line is 0 + 40 + 6) โ‡’ width 110. + // widest 90 (the name; the double line's hidden left counts no gap: 6) โ‡’ width 110.48, and + // height 14+2+12+2+12 โ‡’ 62.48. + let (w, h): (f64, f64) = s.eval("return TTE:GetWidth(), TTE:GetHeight()").unwrap(); + assert!(near(w, 110.48) && near(h, 62.48), "auto size, got {w}x{h}"); s.run( r#" - assert(TTE:GetWidth() == 110, "auto width, got " .. tostring(TTE:GetWidth())) -- The chain stays contiguous through the blank row: Level 20 sits gap+1+gap under the name. assert(TTETextLeft3:GetTop() == TTETextLeft1:GetBottom() - 5, "chain contiguous through the empty row, got " .. tostring(TTETextLeft3:GetTop()) @@ -238,16 +243,18 @@ fn double_line_wraps_only_with_an_empty_right_text() { tt:AddDoubleLine("plain", "R", 1, 1, 1, 1, 1, 1, true) tt:AddDoubleLine("unflagged", "", 1, 1, 1, 1, 1, 1) tt:AddDoubleLine("", "right only", 1, 1, 1, 1, 1, 1, true) - W1, W2, W3, W4 = TTWTextLeft1:GetWidth(), TTWTextLeft2:GetWidth(), TTWTextLeft3:GetWidth(), TTWTextLeft4:GetWidth() "#, ) .unwrap(); - let (w1, w2, w3, w4): (f64, f64, f64, f64) = s.eval("return W1, W2, W3, W4").unwrap(); - let pin = crate::widget::TOOLTIP_WRAP_WIDTH as f64; - assert!( - w1 == pin && w2 < pin && w3 < pin && w4 < pin, - "{w1} {w2} {w3} {w4}" - ); + let wrapped = { + let model = s.model_ref(); + let h = model.arena.lookup("TTW").expect("the tooltip"); + match model.arena.frame(h).map(|f| &f.kind_state) { + Some(crate::widget::KindState::Tooltip(t)) => t.wrapped[..4].to_vec(), + _ => panic!("not a tooltip"), + } + }; + assert_eq!(wrapped, [true, false, false, false]); assert!(s.take_errors().is_empty()); } @@ -304,7 +311,8 @@ fn the_clear_blanks_the_left_text_and_keeps_the_right() { } /// The post-load pass (`0x529650`) fixes the line limit when the tooltip is made, finding the -/// pairs by global name (`0x76c760`); only `AddFontStrings` (`0x529940`) raises it, by one. +/// pairs by global name (`0x76c760`); only `AddFontStrings` (`0x529940`) raises it, by one, and it +/// leaves the pair's points and shown bit as the caller made them. #[test] fn the_line_limit_is_fixed_at_creation_and_only_add_font_strings_raises_it() { let mut s = script(); @@ -316,14 +324,14 @@ fn the_line_limit_is_fixed_at_creation_and_only_add_font_strings_raises_it() { c:AddLine("x") assert(c:NumLines() == 0, "made after the tooltip: " .. c:NumLines()) - -- (a) handed over, the pair is the one line, seated once. + -- (a) handed over, the pair is the one line, as the caller made it. local a = CreateFrame("GameTooltip", "TTA") local al, ar = a:CreateFontString("TTATextLeft1"), a:CreateFontString("TTATextRight1") a:AddFontStrings(al, ar) + assert(ar:IsShown(), "the pair keeps its shown bit") a:AddLine("one"); a:AddLine("two") assert(a:NumLines() == 1, "one pair, one line: " .. a:NumLines()) - local _, arel = al:GetPoint(1) - assert(al:GetNumPoints() == 1 and arel == a, "seated once, on the plate") + assert(al:GetNumPoints() == 0, "no point set on the pair") -- (b) a 31st pair on a templated tooltip raises the limit to 31. local b = CreateFrame("GameTooltip", "TTB", nil, "TestTooltipTemplate") @@ -332,8 +340,7 @@ fn the_line_limit_is_fixed_at_creation_and_only_add_font_strings_raises_it() { for i = 1, 40 do b:AddLine("line " .. i) end assert(b:NumLines() == 31, "30 declared and 1 handed over: " .. b:NumLines()) assert(bl:GetText() == "line 31") - local _, brel = bl:GetPoint(1) - assert(bl:GetNumPoints() == 1 and brel == TTBTextLeft30, "seated once, after line 30") + assert(bl:GetNumPoints() == 0, "no point set on the 31st pair") "#, ) .unwrap(); @@ -458,7 +465,8 @@ fn settext_shows_and_both_addline_shapes() { assert!(s.take_errors().is_empty()); } -/// Until its lines are measured, a tooltip keeps its declared size rather than a gaps-only one. +/// With no font engine, a tooltip keeps its declared size until its lines are measured, and is +/// sized when the measures land: benilla's headless fallback, as the reference measures inline. #[test] fn unmeasured_lines_hold_declared_size() { let mut s = script(); @@ -497,8 +505,8 @@ fn unmeasured_lines_hold_declared_size() { ("(3.7 damage per second)", 110.0, 12.0), ], ); - s.run(r#"assert(TT6:GetWidth() == 140, "auto width after measures, got " .. TT6:GetWidth())"#) - .unwrap(); + let w: f64 = s.eval("return TT6:GetWidth()").unwrap(); + assert!(near(w, 138.88), "auto width after measures, got {w}"); } #[test] @@ -518,9 +526,9 @@ fn minimum_width_floors_autosize() { .unwrap(); s.resolve(); measure_all(&mut s, &[("tiny", 20.0, 12.0)]); - // floor 90 beats content 20 โ‡’ width 110. - s.run(r#"assert(TT4:GetWidth() == 110, "floored width, got " .. TT4:GetWidth())"#) - .unwrap(); + // floor 90 beats content 20 โ‡’ width 110.48. + let w: f64 = s.eval("return TT4:GetWidth()").unwrap(); + assert!(near(w, 110.48), "floored width, got {w}"); } /// Fresh content mid-fade cancels the ramp at full alpha. @@ -616,27 +624,26 @@ fn owner_anchored_tooltip_clamps_to_screen() { ("Alliance Territory", 120.0, 12.0), ], ); - // Auto-size: maxw 120 โ‡’ width 140; totalh 14+2+12 โ‡’ height 48. Unclamped, the plate's - // BOTTOMRIGHT at the owner's TOPLEFT (610, 600) puts its top at 648. - s.run( - r#" - assert(TTC:GetTop() == 600, "top clamped to the screen top, got " .. TTC:GetTop()) - assert(TTC:GetBottom() == 552, "size preserved, got " .. TTC:GetBottom()) - assert(TTC:GetRight() == 610, "X untouched (inside), got " .. TTC:GetRight()) - TTC:SetClampedToScreen(false) - "#, - ) - .unwrap(); + // The sizer: widest 120 โ‡’ width 140.48; 14+2+12 โ‡’ height 48.48. Unclamped, the plate's + // BOTTOMRIGHT at the owner's TOPLEFT (610, 600) puts its top at 648.48. + let (top, bottom, right): (f64, f64, f64) = s + .eval("return TTC:GetTop(), TTC:GetBottom(), TTC:GetRight()") + .unwrap(); + assert_eq!(top, 600.0, "top clamped to the screen top"); + assert!(near(bottom, 551.52), "size preserved, got {bottom}"); + assert_eq!(right, 610.0, "X untouched (inside)"); + s.run("TTC:SetClampedToScreen(false)").unwrap(); s.resolve(); // Unclamped, the plate goes back above the window. - s.run( - r#" - assert(not TTC:IsClampedToScreen(), "flag readable") - assert(TTC:GetBottom() == 600, "unclamped: bottom back at the owner's top") - assert(TTC:GetTop() == 648, "unclamped: off the window again") - "#, - ) - .unwrap(); + let (clamped, top, bottom): (bool, f64, f64) = s + .eval("return TTC:IsClampedToScreen() == 1, TTC:GetTop(), TTC:GetBottom()") + .unwrap(); + assert!(!clamped, "flag readable"); + assert_eq!(bottom, 600.0, "unclamped: bottom back at the owner's top"); + assert!( + near(top, 648.48), + "unclamped: off the window again, got {top}" + ); assert!(s.take_errors().is_empty()); } @@ -969,9 +976,9 @@ fn anchor_cursor_follows_the_cursor_every_frame() { Host = CreateFrame("Frame", "Host") Tip:SetOwner(Host, "ANCHOR_CURSOR") Tip:AddLine("Copper Ore") - -- An explicit size, because this VM has no text measurer: the auto-size pre-pass leaves a - -- plate with no measured extents at its declared size, and the placement is what is under - -- test, not the width. + -- An explicit size, because this VM has no text measurer: the sizer leaves a plate with + -- unmeasured lines at its declared size, and the placement is what is under test, not + -- the width. Tip:SetWidth(120) Tip:SetHeight(40) Tip:Show() "#, @@ -1312,11 +1319,12 @@ fn the_fade_end_restores_full_alpha_before_its_hide() { assert!(s.take_errors().is_empty()); } -/// The tooltip is invalidated by its relayout (`0x530640`, ending in `0x7680e0(1)` at `0x530a52`), -/// which only Show and the line writers run (`0x530aad`, `0x52ad59`, ..): a cleared plate keeps its -/// rect through an unrelated resolve, and the next line's relayout resolves it without points. +/// The tooltip is invalidated by its sizer (`0x530640`, ending in `0x7680e0(1)` at `0x530a52`), +/// which the show runs and a line writer does not (`0x530aad`; `0x531630`): a cleared plate keeps +/// its rect through an unrelated resolve and an `AddLine`, and the next show resolves it without +/// points, as `ANCHOR_NONE` sets none (`0x52fe90` with 0, `0x530a49`). #[test] -fn only_the_relayout_invalidates_the_tooltip() { +fn only_the_sizer_invalidates_the_tooltip() { let mut s = script(); s.set_screen_size(800.0, 600.0); s.run( @@ -1324,7 +1332,8 @@ fn only_the_relayout_invalidates_the_tooltip() { local owner = CreateFrame("Button", "Slot") owner:SetPoint("TOPLEFT", 100, -100); owner:SetWidth(40); owner:SetHeight(40) local tt = CreateFrame("GameTooltip", "TT", nil, "TestTooltipTemplate") - tt:SetOwner(owner, "ANCHOR_RIGHT") + tt:SetOwner(owner, "ANCHOR_NONE") + tt:SetPoint("TOPLEFT", 140, -100) tt:AddLine("Tough Jerky", 1, 1, 1) tt:Show() "#, @@ -1342,13 +1351,16 @@ fn only_the_relayout_invalidates_the_tooltip() { .unwrap(); s.resolve(); s.resolve(); - assert_eq!(left(&mut s), Some(140.0), "nothing ran the relayout"); + assert_eq!(left(&mut s), Some(140.0), "nothing ran the sizer"); s.run(r#"TT:AddLine("Soulbound")"#).unwrap(); s.resolve(); + assert_eq!(left(&mut s), Some(140.0), "AddLine sizes nothing"); + s.run("TT:Show()").unwrap(); + s.resolve(); assert_eq!( left(&mut s), None, - "the relayout invalidated a plate with no points" + "the sizer invalidated a plate with no points" ); assert!(s.errors().is_empty(), "{:?}", s.errors()); } 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 6bfcd4d24..0353f2215 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/mechanics.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/mechanics.rs @@ -101,12 +101,17 @@ fn bag_item_money_law_and_fallback() { assert!(s.take_errors().is_empty()); } -/// A wrap line's first measure asks with the wrap column, so one pass answers the wrapped size, -/// and the size holds while the hover clears and rebuilds the content every frame. +/// A wrapped description is sized in the builder's show (`0x52e15c` โ†’ `0x530640`): split at 230.4 +/// into its widest piece, and the size holds while the hover clears and rebuilds the content every +/// frame, measuring nothing past the first. #[test] -fn wrap_lines_measure_wrapped_in_one_pass_and_survive_the_reenter_loop() { +fn a_wrapped_description_sizes_in_the_show_and_survives_the_reenter_loop() { let mut s = script(); s.set_screen_size(800.0, 600.0); + let calls = std::rc::Rc::new(std::cell::Cell::new(0)); + s.set_text_measurer(Box::new(crate::script::tests::tooltip_size::Mono( + calls.clone(), + ))); s.set_item_template( 7, ItemTemplateView { @@ -128,38 +133,19 @@ fn wrap_lines_measure_wrapped_in_one_pass_and_survive_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. + 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 + 54.0), + ); + let near = |(w, h): (f64, f64)| (w - want.0).abs() < 1e-4 && (h - want.1).abs() < 1e-4; + assert!(near(size(&s)), "sized in the show: {:?}", size(&s)); + s.tick(0.016); s.resolve(); - // One measure pass: the wrap-flagged description asks with the wrap column, others without. - let answer = |s: &mut UiScript| { - let reqs = s.fontstrings_needing_measure(); - let answers: Vec<(u32, f32, f32, u64)> = reqs - .iter() - .map(|r| { - if r.text.starts_with('"') { - assert_eq!( - r.wrap_width, - Some(crate::widget::TOOLTIP_WRAP_WIDTH), - "a wrap line's FIRST ask carries the wrap column" - ); - (r.id, 250.0, 36.0, r.key) // wrapped: 3 rows tall, inside the column - } else { - (r.id, 60.0, 14.0, r.key) - } - }) - .collect(); - s.set_measured_text_unwrapped(&answers); - s.resolve(); - }; - answer(&mut s); - // name 14 + gap 2 + the charges line 14 + gap 2 + description 36 + 2ยทpad 20 = 88. - s.run( - r#" - assert(TT:GetWidth() == 270, "wrap column + padding, got " .. TT:GetWidth()) - assert(TT:GetHeight() == 88, "wrap rows counted, got " .. TT:GetHeight()) - "#, - ) - .unwrap(); - // Clear, rebuild and measure once per frame for three frames: the size holds. + let measured = calls.get(); for _ in 0..3 { s.run( r#" @@ -168,16 +154,11 @@ fn wrap_lines_measure_wrapped_in_one_pass_and_survive_the_reenter_loop() { "#, ) .unwrap(); + s.tick(0.016); s.resolve(); - answer(&mut s); - s.run( - r#" - assert(TT:GetWidth() == 270 and TT:GetHeight() == 88, - "re-enter holds the wrapped size, got " .. TT:GetWidth() .. "x" .. TT:GetHeight()) - "#, - ) - .unwrap(); + assert!(near(size(&s)), "re-enter holds the size: {:?}", size(&s)); } + assert_eq!(calls.get(), measured, "the re-enter loop re-measured"); assert!(s.take_errors().is_empty()); } diff --git a/crates/benilla-ui/src/script/tests/tooltip_size.rs b/crates/benilla-ui/src/script/tests/tooltip_size.rs new file mode 100644 index 000000000..6119130bc --- /dev/null +++ b/crates/benilla-ui/src/script/tests/tooltip_size.rs @@ -0,0 +1,353 @@ +//! The tooltip sizer (`0x530640`) as the show, `AppendText` and the clear reach it, measured in the +//! call through a stand-in font engine. + +use std::cell::Cell; +use std::rc::Rc; + +use super::common::script; +use crate::script::{MeasureRequest, TextMeasure, UiScript}; + +/// Each byte's advance, whatever the font. +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. +pub(in crate::script::tests) struct Mono(pub(in crate::script::tests) Rc>); + +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); + (widest, row * rows.len() as f32, natural) + } +} + +/// A screen, the template, a counting engine, and an owner `O` with tooltip `TS` on it. +fn tooltip() -> (UiScript, Rc>) { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + let calls = Rc::new(Cell::new(0)); + s.set_text_measurer(Box::new(Mono(calls.clone()))); + s.run( + r#" + O = CreateFrame("Button", "O") + O:SetPoint("TOPLEFT", 100, -100); O:SetWidth(40); O:SetHeight(40) + TS = CreateFrame("GameTooltip", "TS", nil, "TestTooltipTemplate") + TS:SetOwner(O, "ANCHOR_RIGHT") + "#, + ) + .unwrap(); + (s, calls) +} + +/// The plate's width for a widest line of `widest`: `2ยท10.24 + padding + widest` (`0x5309f4`). +fn plate(widest: f32) -> f64 { + f64::from(2.0 * 10.24f32 + 0.0 + widest) +} + +fn close(got: f64, want: f64) -> bool { + (got - want).abs() < 1e-4 +} + +fn size(s: &UiScript) -> (f64, f64) { + s.eval("return TS:GetWidth(), TS:GetHeight()").unwrap() +} + +/// The constants are the reference's floats (`0x808e6c`-`0x808e80`). +#[test] +fn the_sizer_constants_are_the_reference_floats() { + use crate::widget::*; + assert_eq!(TOOLTIP_PLATE_PAD.to_bits(), 0x4123_d70a); + assert_eq!(TOOLTIP_COLUMN_GAP.to_bits(), 0x4219_999a); + assert_eq!(TOOLTIP_WRAP_WIDTH.to_bits(), 0x4366_6666); + assert_eq!(TOOLTIP_LINE_GAP.to_bits(), 0x4000_0000); +} + +/// The show sizes the plate before it returns (`0x530aad`), and so do `SetText` (its show, +/// `0x531e08`) and `AppendText` (`0x530615`): the same chunk reads the new size. +#[test] +fn show_set_text_and_append_text_size_the_plate_in_the_call() { + let (s, _) = tooltip(); + let (w, h): (f64, f64) = s + .eval(r#"TS:AddLine("abcdefghij") TS:Show() return TS:GetWidth(), TS:GetHeight()"#) + .unwrap(); + // The header font is 14 tall. + assert!(close(w, plate(50.0)) && close(h, plate(14.0)), "{w}x{h}"); + let w: f64 = s + .eval(r#"TS:SetText("abcde") return TS:GetWidth()"#) + .unwrap(); + assert!(close(w, plate(25.0)), "SetText: {w}"); + let w: f64 = s + .eval(r#"TS:AppendText("fghijklmno") return TS:GetWidth()"#) + .unwrap(); + assert!(close(w, plate(75.0)), "AppendText: {w}"); +} + +/// `AddLine` sizes nothing (`0x531630` โ†’ `0x5303b0`): the plate keeps the last sizing until the next +/// show, through a resolve, and a script's size written after the line stands. +#[test] +fn add_line_keeps_the_last_size_until_the_next_show() { + let (mut s, _) = tooltip(); + s.run(r#"TS:AddLine("abcde") TS:Show()"#).unwrap(); + let before = size(&s); + s.run(r#"TS:AddLine("a much longer second line")"#).unwrap(); + assert_eq!(size(&s), before, "AddLine sized the plate"); + s.resolve(); + assert_eq!(size(&s), before, "the resolve sized the plate"); + // The addons' pattern: AddLine, then room for it by hand (`CT_MailMod.lua:494`). + s.run("TS:SetHeight(TS:GetHeight() + 14)").unwrap(); + s.resolve(); + s.tick(0.016); + s.resolve(); + assert!(close(size(&s).1, before.1 + 14.0), "{:?}", size(&s)); + s.run("TS:Show()").unwrap(); + let (w, h) = size(&s); + // 25 bytes; the second line is 12 tall, 2 under the first. + assert!( + close(w, plate(125.0)) && close(h, plate(14.0 + 2.0 + 12.0)), + "{w}x{h}" + ); +} + +/// A double line is its two texts and the 38.4 gap (`0x5306cf`); its right text sits at the +/// widest line's width from its left text's LEFT (`0x53094f`). +#[test] +fn a_double_line_counts_the_column_gap() { + let (s, _) = tooltip(); + s.run(r#"TS:AddDoubleLine("aaaaaaaaaa", "bbbbbb") TS:Show()"#) + .unwrap(); + let (w, _) = size(&s); + assert!(close(w, plate(50.0 + 38.4 + 30.0)), "{w}"); + let x: f64 = s + .eval("local _, _, _, x = TSTextRight1:GetPoint(1) return x") + .unwrap(); + assert!(close(x, f64::from(50.0f32 + 38.4 + 30.0)), "{x}"); +} + +/// The height sums the shown left texts (`0x530983`, `0x5309c4`): a row with only a right text, a +/// taller right text (#1182) and a hidden left text add nothing, but a right text's width counts. +#[test] +fn the_height_sums_the_shown_left_texts_alone() { + let (s, _) = tooltip(); + s.run(r#"TS:AddLine("abcdefghij") TS:AddDoubleLine("", "bbbbbbbbbbbbbbbb") TS:Show()"#) + .unwrap(); + let (w, h) = size(&s); + assert!( + close(w, plate(80.0)) && close(h, plate(14.0)), + "right only: {w}x{h}" + ); + + s.run( + r#"TS:ClearLines() + TSTextRight1:SetFont("Fonts\\FRIZQT__.TTF", 30) + TS:AddDoubleLine("aaaa", "bbbb") TS:Show()"#, + ) + .unwrap(); + assert!( + close(size(&s).1, plate(14.0)), + "taller right: {:?}", + size(&s) + ); + + s.run( + r#"TS:ClearLines() + TS:AddLine("a") TS:AddLine("b") TS:AddLine("c") + TSTextLeft2:Hide() TS:Show()"#, + ) + .unwrap(); + assert!( + close(size(&s).1, plate(14.0 + 2.0 + 12.0)), + "hidden left: {:?}", + size(&s) + ); +} + +/// A left text's authored height is its `GetHeight` (`0x772a60`), which the sum reads; the clear +/// keeps it, as it resets the width alone (`0x5301f0`). +#[test] +fn an_authored_line_height_counts_and_the_clear_keeps_it() { + let (s, _) = tooltip(); + s.run( + r#"TS:AddLine("a") TS:AddLine("b") + TSTextLeft2:SetWidth(100); TSTextLeft2:SetHeight(24) + TS:ClearLines()"#, + ) + .unwrap(); + let (w, h): (f64, f64) = s + .eval("return TSTextLeft2:GetWidth(), TSTextLeft2:GetHeight()") + .unwrap(); + assert_eq!((w, h), (1.0, 24.0), "the clear zeroes the width alone"); + s.run(r#"TS:AddLine("a") TS:AddLine("b") TS:Show()"#) + .unwrap(); + assert!( + close(size(&s).1, plate(14.0 + 2.0 + 24.0)), + "{:?}", + size(&s) + ); +} + +/// A wrapped line (`0x530790`): its text's width capped at 230.4; wider than the widest line so +/// far, the widest piece of the text split there; then the line is as wide as the widest so far. +#[test] +fn a_wrapped_line_takes_the_widest_so_far() { + let (s, _) = tooltip(); + // Short and alone: as wide as its text. + s.run(r#"TS:AddLine("abcd", 1, 1, 1, 1) TS:Show()"#) + .unwrap(); + let lw: f64 = s.eval("return TSTextLeft1:GetWidth()").unwrap(); + assert!( + close(lw, 20.0) && close(size(&s).0, plate(20.0)), + "short: {lw}" + ); + + // Long and alone: twelve 4-byte words, 295 wide, split at 230.4 into rows of 220 and 70. + let long = ["aaaa"; 12].join(" "); + s.run(&format!( + r#"TS:ClearLines() TS:AddLine("{long}", 1, 1, 1, 1) TS:Show()"# + )) + .unwrap(); + 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)), + "long: {lw}, {w}x{h}" + ); + + // Under a 300-wide line: no split, the line takes 300 and fits on one row. + s.run(&format!( + r#"TS:ClearLines() TS:AddLine("{}") TS:AddLine("{long}", 1, 1, 1, 1) TS:Show()"#, + "x".repeat(60) + )) + .unwrap(); + let lw: f64 = s.eval("return TSTextLeft2:GetWidth()").unwrap(); + let (w, h) = size(&s); + assert!( + close(lw, 300.0) && close(w, plate(300.0)) && close(h, plate(14.0 + 2.0 + 12.0)), + "under a wider line: {lw}, {w}x{h}" + ); +} + +/// With no owner the show hides and neither it nor `AppendText` sizes (`0x530a83`, `0x530649`). +#[test] +fn an_ownerless_show_hides_and_sizes_nothing() { + let (mut s, _) = tooltip(); + s.run( + r#"Bare = CreateFrame("GameTooltip", "Bare", nil, "TestTooltipTemplate") + Bare:SetWidth(77); Bare:SetHeight(33) + Bare:AddLine("abcdefghij") Bare:Show() Bare:AppendText("klmno")"#, + ) + .unwrap(); + s.resolve(); + let (shown, w, h): (bool, f64, f64) = s + .eval("return Bare:IsShown() == 1, Bare:GetWidth(), Bare:GetHeight()") + .unwrap(); + assert_eq!((shown, w, h), (false, 77.0, 33.0)); +} + +/// The sizer re-applies the anchor without its clear (`0x530a49` โ†’ `0x52fe90(0)`): an owner mode +/// sets its point again at the stored offsets, `ANCHOR_NONE` keeps the script's points and +/// `ANCHOR_PRESERVE` keeps the plate as it was. +#[test] +fn the_show_re_anchors_the_owner_modes_only() { + let (s, _) = tooltip(); + s.run( + r#"TS:SetOwner(O, "ANCHOR_RIGHT", 3, 4) TS:AddLine("x") TS:Show() + TS:ClearAllPoints() TS:Show()"#, + ) + .unwrap(); + let (n, p, rel, rp, x, y): (i64, String, bool, String, f64, f64) = s + .eval( + "local p, rel, rp, x, y = TS:GetPoint(1) + return TS:GetNumPoints(), p, rel == O, rp, x, y", + ) + .unwrap(); + assert_eq!( + (n, p.as_str(), rel, rp.as_str(), x, y), + (1, "BOTTOMLEFT", true, "TOPRIGHT", 3.0, 4.0) + ); + + for mode in ["ANCHOR_NONE", "ANCHOR_PRESERVE"] { + s.run(&format!( + r#"TS:SetOwner(O, "{mode}") TS:ClearAllPoints() + TS:SetPoint("TOPLEFT", 5, -5) TS:AddLine("x") TS:Show()"# + )) + .unwrap(); + let (n, p): (i64, String) = s + .eval("local p = TS:GetPoint(1) return TS:GetNumPoints(), p") + .unwrap(); + assert_eq!((n, p.as_str()), (1, "TOPLEFT"), "{mode}"); + } +} + +/// `SetMinimumWidth` stores any number (`0x530f05`) and raises its usage on anything else +/// (`0x530edc`); `SetPadding` reads `lua_tonumber` untested (`0x530fa0`). Neither sizes: the next +/// show reads them. +#[test] +fn minimum_width_and_padding_read_their_arguments_as_the_reference() { + let (s, _) = tooltip(); + s.run(r#"TS:AddLine("abcd") TS:Show()"#).unwrap(); + let err = s + .run(r#"TS:SetMinimumWidth("wide")"#) + .expect_err("a non-number width raises"); + assert!( + err.to_string().contains("Usage: SetMinimumWidth(width)"), + "{err}" + ); + s.run(r#"TS:SetMinimumWidth("90") TS:SetPadding(nil)"#) + .unwrap(); + assert!(close(size(&s).0, plate(20.0)), "no sizing yet"); + s.run("TS:Show()").unwrap(); + assert!(close(size(&s).0, plate(90.0)), "{:?}", size(&s)); + s.run("TS:SetMinimumWidth(-5) TS:SetPadding(16) TS:Show()") + .unwrap(); + let want = f64::from(2.0 * 10.24f32 + 16.0 + 20.0); + assert!(close(size(&s).0, want), "{:?}", size(&s)); +} + +/// 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 +/// 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 frame = format!( + r#"TS:SetOwner(O, "ANCHOR_RIGHT") + TS:AddLine("Title") TS:AddLine("{long}", 1, 1, 1, 1) TS:AddDoubleLine("left", "right") + TS:Show()"# + ); + s.run(&frame).unwrap(); + s.tick(0.016); + s.resolve(); + s.run(&frame).unwrap(); + s.tick(0.016); + s.resolve(); + let first = size(&s); + let (after_first, solves) = (calls.get(), s.layout_solves()); + assert!(after_first > 0); + for _ in 0..4 { + s.run(&frame).unwrap(); + s.tick(0.016); + s.resolve(); + assert_eq!(size(&s), first); + } + assert_eq!(calls.get(), after_first, "a steady hover re-measured"); + assert_eq!(s.layout_solves(), solves, "a steady hover re-solved"); +} diff --git a/crates/benilla-ui/src/script/tests/tooltip_unit.rs b/crates/benilla-ui/src/script/tests/tooltip_unit.rs index 2e5d97f9d..f12d29602 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_unit.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_unit.rs @@ -653,7 +653,7 @@ fn world_hover_drive_and_health_watcher() { "#, ) .unwrap(); - // The auto-size needs the line measures before the plate has a rect. + // With no font engine the sizer waits on the line measures before the plate has a rect. let answers: Vec<(u32, f32, f32, u64)> = s .fontstrings_needing_measure() .iter() diff --git a/crates/benilla-ui/src/script/tooltip/mod.rs b/crates/benilla-ui/src/script/tooltip/mod.rs index 618f36213..3ef9f56eb 100644 --- a/crates/benilla-ui/src/script/tooltip/mod.rs +++ b/crates/benilla-ui/src/script/tooltip/mod.rs @@ -1,14 +1,13 @@ -//! The `GameTooltip` method surface and engine behaviour: the line stack, owner and anchor, -//! auto-size and the fade. +//! The `GameTooltip` method surface and engine behaviour: the line stack, owner and anchor, the +//! sizer and the fade. //! //! Lines are named FontString regions published as Lua globals (`TextLeft1` โ€ฆ), since //! FrameXML addresses them by name (`GameTooltipTextLeft1:SetTextColor`, `UnitFrame.lua:76`). The //! post-load pass ([`post_load`]) fixes the pairs a tooltip holds, the template's 30 in the stock -//! one, and only `AddFontStrings` adds to them; a tooltip with none holds no line. +//! one, and only `AddFontStrings` adds to them; a tooltip with none holds no line. Neither touches +//! a pair's points, justification or shown bit: those are the template's. //! -//! Layout is a pre-pass of every `resolve` ([`layout_tooltips`]): the width is the widest line (a -//! double line adds the column gap) plus padding, floored by `SetMinimumWidth`; the height is the -//! summed line heights and gaps; each right column sits flush with the text inset. +//! The plate is sized by the show, `AppendText` and the cache answers ([`size::size_tooltip`]). use mlua::{Lua, Table, Value}; @@ -17,10 +16,7 @@ use super::object::frame_handle_of; use super::visibility; use super::Model; use crate::layout::{Anchor, Anchors, Point}; -use crate::widget::{ - FrameHandle, KindState, TooltipAnchor, TooltipState, TOOLTIP_DOUBLE_GAP, TOOLTIP_FADE_SECS, - TOOLTIP_LINE_GAP, TOOLTIP_PAD, -}; +use crate::widget::{FrameHandle, KindState, TooltipAnchor, TooltipState, TOOLTIP_FADE_SECS}; /// Registry key of the GameTooltip method table, a named registry root (the MAXCSTACK discipline). pub(super) const REG_TOOLTIP_METHODS: &str = "__benilla_tooltip_methods"; @@ -103,12 +99,14 @@ pub(crate) fn post_load(lua: &Lua, wrapper: &Table) { if let Ok(t) = tip_mut(&mut model, h) { t.left_lines = pairs.iter().map(|p| p.0).collect(); t.right_lines = pairs.iter().map(|p| p.1).collect(); - t.seated = 0; + // The second pass stores 0 in each wrap flag (`0x529830`). + t.wrapped = vec![false; pairs.len()]; } } /// `AddFontStrings` (`0x530c40` into `0x529940`): the caller's pair becomes the last of the line -/// arrays, raising the line limit by one. The pairs before it are seated first, in order. +/// arrays with a clear wrap flag (`0x529a26`), raising the line limit by one. The pair keeps its own +/// points, justification and shown bit. pub(super) fn add_font_strings( lua: &Lua, this: &Table, @@ -118,83 +116,8 @@ pub(super) fn add_font_strings( with_tip(lua, this, |t| { t.left_lines.push(left); t.right_lines.push(right); - })?; - let n = with_tip(lua, this, |t| t.left_lines.len())?; - ensure_lines(lua, this, n) -} - -/// Seats line pairs up to `n`, each cell keeping its own faces: the engine owns their justify, -/// visibility and anchors. `n` never passes the line limit. -fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> { - let h = frame_handle_of(lua, this)?; - let mut model = lua.app_data_mut::().expect("model app_data"); - let frame_id = model.frame_id(h); - loop { - let (i, left, right, prev_left) = match model.arena.frame(h).map(|f| &f.kind_state) { - Some(KindState::Tooltip(t)) => { - let i = t.seated; - if i >= n { - return Ok(()); - } - let (Some(&left), Some(&right)) = (t.left_lines.get(i), t.right_lines.get(i)) - else { - return Err(mlua::Error::runtime("a tooltip line past its limit")); - }; - (i, left, right, i.checked_sub(1).map(|p| t.left_lines[p])) - } - _ => return Err(mlua::Error::runtime("not a GameTooltip")), - }; - - let left_drew = model.region_draws(left); - { - let mut d = model.region_data.remove(&left).unwrap_or_default(); - d.hidden = true; - // Left-justified as in the template; it shows once a wrap line pins its width. - d.justify.set_h(super::JustifyH::Left); - d.anchors = Anchors::from([match prev_left { - None => Anchor::new( - Point::TopLeft, - frame_id, - Point::TopLeft, - TOOLTIP_PAD, - -TOOLTIP_PAD, - ), - Some(prev) => { - let prev_id = model.region_id(prev); - Anchor::new( - Point::TopLeft, - prev_id, - Point::BottomLeft, - 0.0, - -TOOLTIP_LINE_GAP, - ) - } - }]); - model.region_data.insert(left, d); - model.note_region_draw(left, left_drew); - model.touch_measure(left); // a cell can arrive text-in-hand - // A line row's points are set with the invalidate flag (`0x530197`). - model.invalidate_region(left, true); - } - let left_id = model.region_id(left); - - let right_drew = model.region_draws(right); - { - let mut d = model.region_data.remove(&right).unwrap_or_default(); - d.hidden = true; - d.justify.set_h(super::JustifyH::Right); - // RIGHT on the left cell's LEFT, the point the layout re-sets with the plate - // width as x (`0x53094f`), as the template's `$parentTextRight1` declares it. - d.anchors = Anchors::from([Anchor::new(Point::Right, left_id, Point::Left, 0.0, 0.0)]); - model.region_data.insert(right, d); - model.note_region_draw(right, right_drew); - model.touch_measure(right); - model.invalidate_region(right, true); - } - model.region_id(right); - - tip_mut(&mut model, h)?.seated = i + 1; - } + t.wrapped.push(false); + }) } /// Writes a line cell's text and colour and shows it. The text goes through `SetText` with its @@ -220,7 +143,8 @@ pub(super) fn write_cell( } /// The model half of the clear ([`clear_silent`]), `0x530050`'s loop over the used lines: each left -/// text is set to "" and both cells are hidden (`0x530203`-`0x530225`); a right text is left as it +/// text's width is set to 0, its height kept (`0x5301f0`), its text to "" and both cells are hidden +/// (`0x530203`-`0x530225`), and the wrap flag is zeroed (`0x530236`); a right text is left as it /// was, so it reads back until the line is written again. fn clear_content(model: &mut Model, h: FrameHandle) { let (lefts, rights) = match model.arena.frame(h).map(|f| &f.kind_state) { @@ -238,6 +162,12 @@ fn clear_content(model: &mut Model, h: FrameHandle) { ), _ => return, }; + // A line with no authored size already reads width 0. + for &rh in &lefts { + if model.region_data.get(&rh).is_some_and(|d| d.size.is_some()) { + size::set_line_width(model, rh, 0.0); + } + } for (rh, left) in lefts .into_iter() .map(|rh| (rh, true)) @@ -251,16 +181,18 @@ fn clear_content(model: &mut Model, h: FrameHandle) { } d.hidden = true; model.note_region_draw(rh, drew); - // `measured` and the wrap pin (`size`) stay: the measure cache is keyed by content - // ([`RegionData::measure_key`]) and `append_line` rewrites the pin, so the hover - // loop's per-frame clear and rebuild (`ContainerFrameItemButton_OnUpdate`) re-measures - // nothing, and its lines' layout re-hashes to the converged node and solves nothing. + // `measured` stays: the measure cache is keyed by content + // ([`RegionData::measure_key`]), and the sizer's memo holds the other widths a wrapped + // line is measured at, so the hover loop's per-frame clear and rebuild + // (`ContainerFrameItemButton_OnUpdate`) re-measures nothing. if left { model.touch_layout_text(rh, was_shown); } } } if let Ok(t) = tip_mut(model, h) { + let used = t.num_lines.min(t.wrapped.len()); + t.wrapped[..used].fill(false); t.num_lines = 0; t.min_width = 0.0; // Clear drops the subject and unregisters its health watcher (`0x5300ad`, `0x53007c`). @@ -339,16 +271,17 @@ pub(super) fn show_or_hide_empty(lua: &Lua, h: FrameHandle) { } /// The tooltip's show (`0x530a80`, slot `+0x88`), however the show comes: with an owner and a line -/// (`0x530a83`โ€“`0x530a95`) it restores full alpha, disarms the fade (`0x530aa3`) and runs the base -/// show; else it clears its shown bit and hides through its own hide (`0x530abe`, `0x530ac8`). -/// So a parent's show brings back only a tooltip that still has an owner. +/// (`0x530a83`โ€“`0x530a95`) it restores full alpha, disarms the fade (`0x530aa3`), sizes itself +/// (`0x530aad`), so a shown tooltip's `Show` sizes it again, and runs the base show, whose `OnShow` +/// reads the new size; else it clears its shown bit and hides through its own hide (`0x530abe`, +/// `0x530ac8`). So a parent's show brings back only a tooltip that still has an owner. pub(in crate::script) fn show_override(lua: &Lua, h: FrameHandle) { let live = { let mut model = lua.app_data_mut::().expect("model app_data"); let live = tip_mut(&mut model, h).is_ok_and(|t| t.owner.is_some() && t.num_lines > 0); if live { full_alpha(&mut model, h); - owe_relayout(&mut model, h); + size::size_tooltip(&mut model, h); } live }; @@ -396,15 +329,17 @@ pub(in crate::script) fn set_owner_core( }; t.owner = owner; t.anchor = TooltipAnchor::None; + t.owner_offset = (x, y); if let Some(owner) = owner { t.anchor = anchor; - apply_anchor(&mut model, h, owner, anchor, x, y); + apply_anchor(&mut model, h, owner, anchor, x, y, true); } } -/// The anchor apply (`0x52fe90`, with the core's 1): `ANCHOR_PRESERVE` returns first -/// (`0x52fead`); every other mode clears all points (`0x767ed0` at `0x52fec2`); modes 0..5 then -/// set one point on the owner (`0x52ffbc`), and 6 and 7 stop there. The clear lets +/// The anchor apply (`0x52fe90`): `ANCHOR_PRESERVE` returns first (`0x52fead`); every other mode +/// clears all points (`0x767ed0` at `0x52fec2`) but `ANCHOR_NONE` without `clear`: the sizer +/// passes 0 (`0x530a45`), the SetOwner core 1 (`0x53002d`); modes 0..5 then set one point +/// on the owner (`0x52ffbc`), and 6 and 7 stop there. The core's clear lets /// `GameTooltip_SetDefaultAnchor` (GameTooltip.lua:76-77) set one point with no stale owner /// anchor; mode 6 is then placed per frame by `cursor_anchor`. fn apply_anchor( @@ -414,6 +349,7 @@ fn apply_anchor( anchor: TooltipAnchor, x: f32, y: f32, + clear: bool, ) { let pts = match anchor { TooltipAnchor::Right => Some((Point::BottomLeft, Point::TopRight)), @@ -424,7 +360,7 @@ fn apply_anchor( TooltipAnchor::BottomLeft => Some((Point::TopRight, Point::BottomLeft)), TooltipAnchor::Cursor | TooltipAnchor::None | TooltipAnchor::Preserve => None, }; - if matches!(anchor, TooltipAnchor::Cursor | TooltipAnchor::None) { + if anchor == TooltipAnchor::Cursor || (anchor == TooltipAnchor::None && clear) { let dropped = model .layout_inputs .get_mut(&h) @@ -615,7 +551,6 @@ pub(super) fn append_line( return Ok(()); } let n = have + 1; - ensure_lines(lua, this, n)?; let mut model = lua.app_data_mut::().expect("model app_data"); let (lh, rh) = match model.arena.frame(h).map(|f| &f.kind_state) { Some(KindState::Tooltip(t)) => (t.left_lines[n - 1], t.right_lines[n - 1]), @@ -624,64 +559,24 @@ pub(super) fn append_line( if !left.0.is_empty() { write_cell(&mut model, lh, &left.0, left.1); } - // The wrap width is pinned at append, so the first measure already comes back wrapped. It is - // written from this line's own flag every time, and layout is touched only on a change. - let pin = wrap.then_some((crate::widget::TOOLTIP_WRAP_WIDTH, 0.0)); - if let Some(d) = model.region_data.get_mut(&lh) { - if d.size != pin { - d.size = pin; - // `SetWidth`, which invalidates (`0x7683d0`), named: a conservative touch would - // re-derive the whole layout graph on every hover that turns a line wrapped or plain. - model.invalidate_region(lh, true); - // The wrap pin is the measure key's wrap-width input. - model.touch_measure(lh); - } - } if let Some((text, color)) = right.filter(|(text, _)| !text.is_empty()) { write_cell(&mut model, rh, &text, color); } if let Ok(t) = tip_mut(&mut model, h) { + // The wrap flag (`0x53036f`), read by the sizer alone; the line is not sized here. + if let Some(flag) = t.wrapped.get_mut(n - 1) { + *flag = wrap; + } t.num_lines = n; } - owe_relayout(&mut model, h); Ok(()) } -/// A line writer or the show ran the relayout (`0x530640`, from `0x52ad59`, `0x52b029`, -/// `0x52e5ae`, `0x52f869`, `0x530615`, `0x530aad`), which does nothing without an owner -/// (`0x530651`) and else ends in an unconditional invalidate (`0x530a52`). -pub(super) fn owe_relayout(model: &mut Model, h: FrameHandle) { - if let Ok(t) = tip_mut(model, h) { - if t.owner.is_some() { - t.relayout_owed = true; - } - } -} - +mod size; mod verbs; +pub(in crate::script) use size::{size_owed, size_tooltip}; pub(super) use verbs::install; -/// A line cell's measured extent; `None` for a hidden or unmeasured cell, which adds no size and -/// no gap, so a fresh tooltip keeps its declared size until its lines are measured. -type Cell = Option<(f32, f32)>; - -fn cell(model: &Model, rh: crate::widget::RegionHandle) -> Cell { - let d = model.region_data.get(&rh)?; - let text = d.text.as_deref()?; - if d.hidden { - return None; - } - if text.is_empty() { - // One unit each way, the floor of the line's own rect: the reference's `GetHeight` - // (`0x772a60`) clamps to one unit, so no FontString reads back 0. - return Some(( - super::layout::FONTSTRING_MIN_SPAN, - super::layout::FONTSTRING_MIN_SPAN, - )); - } - d.measured.map(|m| (m.w, m.h)) -} - /// `ANCHOR_CURSOR` (mode 6), re-anchored every frame by the update override `0x530b20`: one /// `SetPoint` (`0x767c70`) pins the plate's BOTTOM to the screen root's BOTTOMLEFT (`0xcf0bd8`) /// at the cursor position divided by the tooltip's own effective scale, so it sits centred above @@ -708,7 +603,9 @@ fn cursor_anchor(model: &mut Model, h: FrameHandle) { } } -pub(super) fn layout_tooltips(model: &mut Model) { +/// The resolve's tooltip pre-pass: each shown `ANCHOR_CURSOR` plate is placed on the cursor +/// ([`cursor_anchor`]), lines or not. +pub(super) fn anchor_cursor_tooltips(model: &mut Model) { // Only tooltips in the resolve's roster: one outside it would mint an id here and take the // layout ledger's conservative branch. let tips: Vec = model @@ -719,7 +616,6 @@ pub(super) fn layout_tooltips(model: &mut Model) { .filter(|h| model.frame_to_id.contains_key(h)) .collect(); for h in tips { - // Mode 6 runs before the line gate: the reference re-anchors any shown plate, lines or not. let cursor_mode = match model.arena.frame(h).map(|f| (&f.kind_state, f.shown)) { Some((KindState::Tooltip(t), shown)) => shown && t.anchor == TooltipAnchor::Cursor, _ => false, @@ -727,95 +623,6 @@ pub(super) fn layout_tooltips(model: &mut Model) { if cursor_mode { cursor_anchor(model, h); } - // The relayout's closing invalidate (`0x530a52`), whatever it computed. Its own writes - // below push when they move the plate; past those, a re-resolve gives back the rect the - // plate holds unless that rect is stale, so only then is the invalidate paid here. - let owed = tip_mut(model, h).is_ok_and(|t| std::mem::take(&mut t.relayout_owed)); - if owed && model.frame_holds_stale(h) { - model.note_layout_frame_write(h); - if let Some(&id) = model.frame_to_id.get(&h) { - model.queue_invalidate(id); - } - } - let (num, lefts, rights, min_w, pad_w) = match model.arena.frame(h).map(|f| &f.kind_state) { - Some(KindState::Tooltip(t)) => ( - t.num_lines, - t.left_lines.clone(), - t.right_lines.clone(), - t.min_width, - t.padding, - ), - _ => continue, - }; - if num == 0 { - continue; - } - let n = num.min(lefts.len()).min(rights.len()); - let rows: Vec<(Cell, Cell)> = (0..n) - .map(|i| (cell(model, lefts[i]), cell(model, rights[i]))) - .collect(); - let mut maxw: f32 = 0.0; - let mut totalh: f32 = 0.0; - let mut counted = 0usize; - for (l, r) in &rows { - if l.is_none() && r.is_none() { - continue; - } - let (lw, lh) = l.unwrap_or((0.0, 0.0)); - let (rw, rh_h) = r.map_or((0.0, 0.0), |(w, hgt)| (TOOLTIP_DOUBLE_GAP + w, hgt)); - if counted > 0 { - totalh += TOOLTIP_LINE_GAP; - } - totalh += lh.max(rh_h); - counted += 1; - maxw = maxw.max(lw + rw); - } - if counted == 0 { - continue; // nothing measured yet: the declared size holds - } - maxw = maxw.max(min_w); - if maxw <= 0.0 || totalh <= 0.0 { - continue; - } - let input = model.layout_inputs.entry(h).or_default(); - // `pad_w` is `SetPadding`'s extra width (ItemRefTooltip's room for its close button). - let (w, ht) = (maxw + 2.0 * TOOLTIP_PAD + pad_w, totalh + 2.0 * TOOLTIP_PAD); - let moved = input.width.to_bits() != w.to_bits() || input.height.to_bits() != ht.to_bits(); - input.width = w; - input.height = ht; - // Inside the resolve, so a note and not a touch; the incremental pass still has to hear - // that these inputs changed. The size goes through `SetWidth`/`SetHeight` (`0x530a2e`, - // `0x530a39`), which invalidate the tooltip. - model.note_layout_frame_write(h); - // A size the measures moved since the relayout ran: benilla's measures land a tick after - // the line write, where the reference's relayout measured in the call. - if moved { - if let Some(&id) = model.frame_to_id.get(&h) { - model.queue_invalidate(id); - } - } - // Right-flush each shown right cell: one `SetPoint(RIGHT, leftCell, LEFT, width, 0)` - // (`0x530910`..`0x53094f`), which leaves any other point the cell holds alone. - let units = model.layout_units(); - for (i, (_, r)) in rows.iter().enumerate() { - if r.is_none() { - continue; - } - let Some(&left_id) = model.region_to_id.get(&lefts[i]) else { - continue; - }; - let new = Anchor::new(Point::Right, left_id, Point::Left, maxw, 0.0); - let stored = model - .region_data - .get_mut(&rights[i]) - .is_some_and(|d| d.anchors.set(new, units)); - if stored { - model.note_layout_region_write(rights[i]); - if let Some(&id) = model.region_to_id.get(&rights[i]) { - model.queue_invalidate(id); - } - } - } } } @@ -828,7 +635,7 @@ pub(super) fn layout_tooltips(model: &mut Model) { pub(in crate::script) fn update(lua: &Lua, h: FrameHandle, elapsed: f32) { let done = { let mut model = lua.app_data_mut::().expect("model app_data"); - // Roster-gated as in `layout_tooltips`, since the hide writes layout. + // Roster-gated as in `anchor_cursor_tooltips`, since the hide writes layout. if !model.frame_to_id.contains_key(&h) { return; } diff --git a/crates/benilla-ui/src/script/tooltip/size.rs b/crates/benilla-ui/src/script/tooltip/size.rs new file mode 100644 index 000000000..647071df7 --- /dev/null +++ b/crates/benilla-ui/src/script/tooltip/size.rs @@ -0,0 +1,340 @@ +//! The tooltip sizer, `GameTooltipSize` (`0x530640`), which only the show (`0x530aad`), +//! `AppendText`'s helper (`0x530615`) and the cache answers (`0x52ad59`, `0x52b029`, `0x52e5ae`, +//! `0x52f869`) run: a line writer sizes nothing, so `GetWidth` after `AddLine` answers the last +//! sizing. It measures in the call, as the reference's font strings do. +//! +//! In a VM with no font engine installed a measure is answered a frame later +//! ([`crate::script::MeasureRequest`]). There the sizer leaves the plate's size alone while a +//! measure is owed and runs again at the next resolve ([`size_owed`]); this fallback is benilla's +//! own, as the reference always measures inline. + +use super::{apply_anchor, tip_mut}; +use crate::layout::{Anchor, Point}; +use crate::script::layout::FONTSTRING_MIN_SPAN; +use crate::script::{MeasuredText, Model}; +use crate::widget::{ + FrameHandle, KindState, RegionHandle, TooltipAnchor, TOOLTIP_COLUMN_GAP, TOOLTIP_LINE_GAP, + TOOLTIP_PLATE_PAD, TOOLTIP_WRAP_WIDTH, +}; + +/// Entries the measure memo holds before it is emptied: thirty lines on each of a few plates, +/// three widths a wrapped line. +const MEMO_CAP: usize = 1024; + +/// `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 { + return; + }; + let Some(owner) = t.owner else { + if let Ok(t) = tip_mut(model, h) { + t.relayout_owed = false; + } + return; + }; + let n = t.num_lines.min(t.left_lines.len()).min(t.right_lines.len()); + let lines: Vec<(RegionHandle, RegionHandle, bool)> = (0..n) + .map(|i| { + let wrapped = t.wrapped.get(i).copied().unwrap_or(false); + (t.left_lines[i], t.right_lines[i], wrapped) + }) + .collect(); + let (min_width, padding, anchor, (x, y)) = (t.min_width, t.padding, t.anchor, t.owner_offset); + let mut pending = false; + + // The unwrapped lines (`0x530686`): the left and right widths of the shown cells, and the + // column gap when both show (`0x5306cf`). The minimum width is the floor (`0x530665`). + let mut widest = min_width; + for &(left, right, wrapped) in &lines { + if wrapped { + continue; + } + let (left_shown, right_shown) = (shown(model, left), shown(model, right)); + let mut w = if left_shown && right_shown { + TOOLTIP_COLUMN_GAP + } else { + 0.0 + }; + if left_shown { + w += get_width(model, left, &mut pending); + } + if right_shown { + w += get_width(model, right, &mut pending); + } + // `fcomp; test ah,5; jp` (`0x53075b`): only a larger number replaces, never a NaN. + if widest < w { + widest = w; + } + } + + // 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. + for &(left, _, wrapped) in &lines { + if !wrapped { + continue; + } + let Some(natural) = cached_width(model, left) else { + pending = true; + continue; + }; + let cap = if TOOLTIP_WRAP_WIDTH > natural { + natural + } else { + TOOLTIP_WRAP_WIDTH + }; + 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 + } + }; + } + // `fcomp; test ah,0x41; je` (`0x5308cc`): kept only when larger, so a NaN piece is taken. + if widest.partial_cmp(&piece) != Some(std::cmp::Ordering::Greater) { + widest = piece; + } + set_line_width(model, left, widest); + } + + // Each shown right text at the widest line's width from its left text's LEFT (`0x53094f`). + let units = model.layout_units(); + for &(left, right, _) in &lines { + if !shown(model, right) { + continue; + } + let left_id = model.region_id(left); + let new = Anchor::new(Point::Right, left_id, Point::Left, widest, 0.0); + let stored = model + .region_data + .entry(right) + .or_default() + .anchors + .set(new, units); + if stored { + model.invalidate_region(right, true); + } + } + + // The height: the shown left texts and a 2.0 gap after the first (`0x530972`-`0x5309ca`); a + // right text's height never counts. + let mut height = 0.0f32; + for &(left, _, _) in &lines { + if !shown(model, left) { + continue; + } + if height != 0.0 { + height += TOOLTIP_LINE_GAP; + } + height += get_height(model, left, &mut pending); + } + + if let Ok(t) = tip_mut(model, h) { + t.relayout_owed = pending; + } + if !pending { + // `2ยทpad + padding + widest` by `2ยทpad + height` (`0x5309f4`, `0x530a0c`), through the + // plate's `SetWidth` and `SetHeight` (`0x530a2e`, `0x530a39`). + let w = 2.0 * TOOLTIP_PLATE_PAD + padding + widest; + let ht = 2.0 * TOOLTIP_PLATE_PAD + height; + let input = model.layout_inputs.entry(h).or_default(); + let changed = input.width.to_bits() != w.to_bits(); + input.width = w; + model.invalidate_frame(h, changed); + let input = model.layout_inputs.entry(h).or_default(); + let changed = input.height.to_bits() != ht.to_bits(); + input.height = ht; + model.invalidate_frame(h, changed); + } + // The anchor again, without its clear (`0x530a49`), except for the cursor mode, which its + // update places (`0x530a3c`); then the plate is invalidated for the next resolve. Deviation: + // `0x530a52` lays it out at once; getters resolve first, so only OnSizeChanged fires later. + if anchor != TooltipAnchor::Cursor { + apply_anchor(model, h, owner, anchor, x, y, false); + } + model.invalidate_frame(h, false); +} + +/// A line's `SetWidth`, its height kept, through [`Model::note_line_width`]; an unmoved width +/// invalidates only a line whose rect a resolve would not give back. +pub(super) fn set_line_width(model: &mut Model, rh: RegionHandle, w: f32) { + let d = model.region_data.entry(rh).or_default(); + let new = Some((w, d.size.map_or(0.0, |s| s.1))); + if crate::script::region::size_bits_eq(d.size, new) { + model.invalidate_region(rh, false); + return; + } + d.size = new; + model.touch_measure(rh); + model.note_line_width(rh); +} + +/// The headless fallback: each tooltip whose sizing waited on a measure is sized again, from the +/// top of every resolve. +pub(in crate::script) fn size_owed(model: &mut Model) { + let owed: Vec = model + .arena + .tooltip_kinds() + .iter() + .copied() + .filter(|&h| { + matches!( + model.arena.frame(h).map(|f| &f.kind_state), + Some(KindState::Tooltip(t)) if t.relayout_owed + ) + }) + .collect(); + for h in owed { + size_tooltip(model, h); + } +} + +/// A font string's own shown bit (`+0xc4`), the one the sizer tests. +fn shown(model: &Model, rh: RegionHandle) -> bool { + model.region_data.get(&rh).is_none_or(|d| !d.hidden) +} + +/// The owner's effective scale, which a font string's measure key carries. +fn scale_of(model: &Model, rh: RegionHandle) -> f32 { + model + .arena + .region(rh) + .and_then(|r| model.arena.frame(r.owner)) + .map_or(1.0, |f| f.effective_scale) +} + +/// `rh`'s text laid out in `wrap` (`None`, unwrapped), not stored as the region's own: the region's +/// own measure when it was taken that way, the memo, or the font engine. A string with no text or +/// no font measures 0 (`0x7728b6`). `None` is a measure still owed, which only a VM with no font +/// engine has. +fn probe(model: &mut Model, rh: RegionHandle, wrap: Option) -> Option { + let scale = scale_of(model, rh); + let non_space_wrap = model.non_space_wrap_of(rh); + let zero = |key| MeasuredText { + w: 0.0, + h: 0.0, + natural_w: 0.0, + key, + }; + let Some(d) = model.region_data.get(&rh) else { + return Some(zero(0)); + }; + let text = d.text.as_deref().unwrap_or(""); + let key = d.measure_key_at(text, wrap, scale); + if text.is_empty() { + return Some(zero(key)); + } + let memo_key = (key, non_space_wrap); + if let Some(m) = d.measured.filter(|m| m.key == key) { + if !model.tooltip_measures.contains_key(&memo_key) { + memo_insert(model, memo_key, m); + } + return Some(m); + } + if let Some(&m) = model.tooltip_measures.get(&memo_key) { + return Some(m); + } + if !crate::script::font_block::has_font(model, rh) { + return Some(zero(key)); + } + model.measurer.as_ref()?; + let req = crate::script::layout::measure_request_at(model, rh, wrap, key, scale)?; + let mut engine = model.measurer.take().expect("checked above"); + let (w, h, natural_w) = crate::script::TextMeasure::measure(&mut engine, &req); + model.measurer = Some(engine); + let m = MeasuredText { + w, + h, + natural_w, + key, + }; + memo_insert(model, memo_key, m); + Some(m) +} + +fn memo_insert(model: &mut Model, key: (u64, bool), m: MeasuredText) { + if model.tooltip_measures.len() >= MEMO_CAP { + model.tooltip_measures.clear(); + } + model.tooltip_measures.insert(key, m); +} + +/// `rh`'s measure at its own width, stored as its own, as a font string's getters measure in +/// place (`0x772890`, `0x7729b0`). +fn current(model: &mut Model, rh: RegionHandle) -> Option { + let wrap = model.region_data.get(&rh)?.wrap_width(); + let m = probe(model, rh, wrap)?; + let stale = model + .region_data + .get(&rh) + .is_some_and(|d| d.measured.map(|s| s.key) != Some(m.key)); + let has_text = model + .region_data + .get(&rh) + .is_some_and(|d| d.text.as_deref().is_some_and(|t| !t.is_empty())); + if stale && has_text { + let id = model.region_id(rh); + crate::script::measure::store_measured(model, rh, id, m); + } + Some(m) +} + +/// The authored size of `rh` and whether its text is empty. +fn authored(model: &Model, rh: RegionHandle) -> ((f32, f32), bool) { + model.region_data.get(&rh).map_or(((0.0, 0.0), true), |d| { + ( + d.size.unwrap_or((0.0, 0.0)), + d.text.as_deref().is_none_or(str::is_empty), + ) + }) +} + +/// A font string's `GetWidth` (`0x772930`): its authored width when non-zero, else its text's +/// unwrapped width, never below one unit (`0x772957`-`0x772983`, which a NaN takes too). +fn get_width(model: &mut Model, rh: RegionHandle, pending: &mut bool) -> f32 { + let ((w, _), empty) = authored(model, rh); + if w != 0.0 { + return w.max(FONTSTRING_MIN_SPAN); + } + if empty { + return FONTSTRING_MIN_SPAN; + } + match current(model, rh) { + Some(m) => m.natural_w.max(FONTSTRING_MIN_SPAN), + None => { + *pending = true; + 0.0 + } + } +} + +/// A font string's `GetHeight` (`0x772a60`): its authored height when non-zero, else its text's +/// height laid out in its width, never below one unit. +fn get_height(model: &mut Model, rh: RegionHandle, pending: &mut bool) -> f32 { + let ((_, h), empty) = authored(model, rh); + if h != 0.0 { + return h.max(FONTSTRING_MIN_SPAN); + } + if empty { + return FONTSTRING_MIN_SPAN; + } + match current(model, rh) { + Some(m) => m.h.max(FONTSTRING_MIN_SPAN), + None => { + *pending = true; + 0.0 + } + } +} + +/// A font string's cached text width (`0x772890`): the unwrapped width, 0 with no text or font. +/// `None` while owed. +fn cached_width(model: &mut Model, rh: RegionHandle) -> Option { + let wrap = model.region_data.get(&rh).and_then(|d| d.wrap_width()); + probe(model, rh, wrap).map(|m| m.natural_w) +} diff --git a/crates/benilla-ui/src/script/tooltip/verbs.rs b/crates/benilla-ui/src/script/tooltip/verbs.rs index fb6be32fe..1f43f784e 100644 --- a/crates/benilla-ui/src/script/tooltip/verbs.rs +++ b/crates/benilla-ui/src/script/tooltip/verbs.rs @@ -149,7 +149,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { None => (String::new(), [1.0, 1.0, 1.0, 1.0]), }; write_cell(&mut model, lh, &(current + &extra), color); - super::owe_relayout(&mut model, h); + // `0x5305b0` sizes the tooltip again (`0x530615`). + super::size_tooltip(&mut model, h); Ok(()) })?, )?; @@ -205,20 +206,29 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { super::add_font_strings(lua, &this, lh, rh) })?, )?; - // SetMinimumWidth(w): a floor on the auto-sized width (`SetTooltipMoney` passes the money - // row's, GameTooltip.lua:91). + // SetMinimumWidth(width) (`0x530e50`): the sizer's floor (`SetTooltipMoney` passes the money + // row's, GameTooltip.lua:91), stored as given (`0x530f05`) and read at the next sizing. A + // width `lua_isnumber` refuses raises the usage error (`0x530edc`, `0x8550a0`). m.set( "SetMinimumWidth", - lua.create_function(|lua, (this, w): (Table, f32)| { - with_tip(lua, &this, |t| t.min_width = w.max(0.0)) + lua.create_function(|lua, (this, w): (Table, Value)| { + let Some(w) = lua.coerce_number(w).ok().flatten() else { + return Err(mlua::Error::RuntimeError( + "Usage: SetMinimumWidth(width)".into(), + )); + }; + with_tip(lua, &this, |t| t.min_width = w as f32) })?, )?; - // SetPadding(w): extra auto-size width that survives a content clear (ItemRefTooltip's 16 - // keeps its close button off the text, ItemRef.xml:40). + // SetPadding(width) (`0x530f10`): extra plate width that survives a clear (ItemRefTooltip's 16 + // keeps its close button off the text, ItemRef.xml:40), read by `lua_tonumber` with no type + // test, so a non-number is 0 (`0x530fa0`), stored as given (`0x530630`) and read at the next + // sizing. m.set( "SetPadding", - lua.create_function(|lua, (this, w): (Table, f32)| { - with_tip(lua, &this, |t| t.padding = w.max(0.0)) + lua.create_function(|lua, (this, w): (Table, Value)| { + let w = lua.coerce_number(w).ok().flatten().unwrap_or(0.0); + with_tip(lua, &this, |t| t.padding = w as f32) })?, )?; // FadeOut() (`0x531f00`): the fade request ([`super::fade_out`]). diff --git a/crates/benilla-ui/src/script/tooltip_item/build.rs b/crates/benilla-ui/src/script/tooltip_item/build.rs index baa2c9aac..e805b057f 100644 --- a/crates/benilla-ui/src/script/tooltip_item/build.rs +++ b/crates/benilla-ui/src/script/tooltip_item/build.rs @@ -313,6 +313,10 @@ pub(in crate::script) fn answer_item_caches(lua: &Lua) -> Vec { if let Err(e) = rebuilt { errors.push(e); } + // The callback sizes the plate after its builder's show (`0x52e5ae`), so lines an + // `OnShow` added count. + let mut model = lua.app_data_mut::().expect("model app_data"); + crate::script::tooltip::size_tooltip(&mut model, h); } } errors diff --git a/crates/benilla-ui/src/script/tooltip_spell.rs b/crates/benilla-ui/src/script/tooltip_spell.rs index fb68a4a9c..60a27b1d4 100644 --- a/crates/benilla-ui/src/script/tooltip_spell.rs +++ b/crates/benilla-ui/src/script/tooltip_spell.rs @@ -247,8 +247,11 @@ fn answer_wait(lua: &Lua, h: FrameHandle, spell_id: u32, alt_caster: bool) -> ml wait.fallback_name, wait.opts, wait.remaining, - ) - .map(|_| ()) + )?; + // As the reference's spell callback sizes after its builder's show (`0x52f869`). + let mut model = lua.app_data_mut::().expect("model app_data"); + super::tooltip::size_tooltip(&mut model, h); + Ok(()) } /// The view store and its asks for the unit a render is built against: the pet's when the diff --git a/crates/benilla-ui/src/script/types.rs b/crates/benilla-ui/src/script/types.rs index b66972d5f..d21321970 100644 --- a/crates/benilla-ui/src/script/types.rs +++ b/crates/benilla-ui/src/script/types.rs @@ -545,13 +545,22 @@ impl RegionData { /// [`Self::measure_key`] with `text` in place of the region's own. pub(crate) fn measure_key_of(&self, text: &str, scale: f32) -> u64 { + self.measure_key_at(text, self.wrap_width(), scale) + } + + /// The width the text wraps in: the authored width when positive, else none. + pub(crate) fn wrap_width(&self) -> Option { + self.size.map(|s| s.0).filter(|w| *w > 0.0) + } + + /// [`Self::measure_key_of`] with `wrap_width` in place of the region's own. + pub(crate) fn measure_key_at(&self, text: &str, wrap_width: Option, scale: f32) -> u64 { use std::hash::{Hash, Hasher}; let mut hasher = std::collections::hash_map::DefaultHasher::new(); text.hash(&mut hasher); self.font_path.hash(&mut hasher); self.font_height.map(f32::to_bits).hash(&mut hasher); self.text_height.map(f32::to_bits).hash(&mut hasher); - let wrap_width = self.size.map(|s| s.0).filter(|w| *w > 0.0); wrap_width.map(f32::to_bits).hash(&mut hasher); // The outline changes the measure: a THICK one steps each glyph 1 px further // (`GlyphStepBase 0x5ca2b0`); so does the shadow's x offset, which seeds the line. diff --git a/crates/benilla-ui/src/widget/kinds/mod.rs b/crates/benilla-ui/src/widget/kinds/mod.rs index 7d493dd94..a99b893c2 100644 --- a/crates/benilla-ui/src/widget/kinds/mod.rs +++ b/crates/benilla-ui/src/widget/kinds/mod.rs @@ -209,7 +209,8 @@ impl KindState { /// A tooltip's anchor mode (`+0x318`), set by `SetOwner` (`0x5310d0`), which reads a missing or /// unknown mode as [`TooltipAnchor::Left`] (`0x53120d`). The ids follow `GetAnchorType`'s table /// (`0x5313e0`, `0x531530`), not the setter's compare order. Placement (`0x52fe90`): modes 0..5 -/// clear the anchors and point, 6 and 7 only clear, 8 does neither. +/// clear the anchors and point, 6 and 7 only clear (7 not when the sizer places, `0x530a45`), 8 +/// does neither. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum TooltipAnchor { /// `ANCHOR_LEFT`: the plate's right edge on the owner's left. @@ -249,8 +250,8 @@ impl TooltipAnchor { } } -/// A `GameTooltip`'s state. Its lines are FontString regions named `TextLeftN`/`TextRightN`, -/// made on demand and published as Lua globals, as the stock template's 30 pairs are. +/// A `GameTooltip`'s state. Its lines are the FontString pairs named `TextLeftN`/`TextRightN` +/// that its template declares, Lua globals as the stock template's 30 pairs are. #[derive(Clone, Debug, Default, PartialEq)] pub struct TooltipState { /// Lines currently filled (`NumLines`). Regions past this index exist but are hidden. @@ -263,10 +264,17 @@ pub struct TooltipState { pub right_lines: Vec, /// `SetOwner`'s frame, dropped on hide. pub owner: Option, + /// `SetOwner`'s x and y offsets (`+0x3c4`, `+0x3c8`, stored by the core at `0x53001c`), which + /// every anchor apply reads. + pub owner_offset: (f32, f32), /// The anchor mode (`+0x318`), read back by `GetAnchorType()`: the last `SetOwner`'s while /// owned, `None` (mode 7) since a hide. pub anchor: TooltipAnchor, - /// `SetMinimumWidth`: a floor on the auto-sized width, cleared with the content. + /// Each line's wrap flag (`+0x344`), parallel to `left_lines`: the line core stores it + /// (`0x53036f`) and the clear zeroes it (`0x530236`). + pub wrapped: Vec, + /// `SetMinimumWidth` (`+0x3c0`): the sizer's starting widest (`0x530665`), zeroed by the clear + /// (`0x530258`). pub min_width: f32, /// `FadeOut()`'s countdown in seconds (`[+0x3b4]` armed, `[+0x3b8]` left): the tooltip's own /// update counts it down by its elapsed, so it stands still while the tooltip is not walked. @@ -281,26 +289,25 @@ pub struct TooltipState { /// (`[arg+0x18]`, not the compact `[arg+0x14]`), surviving the stock caller's `SetOwner` in /// between (`MerchantFrame.xml:67-72`); the reference's route to `0x52b650` is untraced. pub equipped_header_armed: bool, - /// `SetPadding`: extra width past the content (`ItemRef.xml:40` sets 16 for the close button). + /// `SetPadding` (`+0x3bc`): extra plate width past the content (`0x5309f4`), which the clear + /// keeps (`ItemRef.xml:40` sets 16 for the close button). pub padding: f32, - /// How many leading pairs the line writers have seated (anchored, justified, hidden). - pub seated: usize, - /// A relayout (`0x530640`) is owed: Show and the line writers run it with an owner, and it - /// ends by invalidating the plate (`0x530a52`). The layout pre-pass pays it. + /// Benilla-only: a sizing ran in a VM with no font engine before its lines were measured, so + /// the next resolve runs it again ([`crate::script::tooltip`]'s headless fallback). pub relayout_owed: bool, } -/// The plate's text inset: the stock template seats `TextLeft1` at TOPLEFT (10, โˆ’10). -pub const TOOLTIP_PAD: f32 = 10.0; -/// The line gap: each `TextLeftN` hangs at the previous line's BOTTOMLEFT (0, โˆ’2). +/// The plate's pad on each side of its content, `0x808e74` and `0x808e78` = 10.24 (`0x4123d70a`). +pub const TOOLTIP_PLATE_PAD: f32 = 10.24; +/// The gap the sizer counts between shown lines, `0x808e6c` = 2.0. pub const TOOLTIP_LINE_GAP: f32 = 2.0; -/// The least double-line column gap, the stock `TextRightN` offset; the reference's is untraced. -pub const TOOLTIP_DOUBLE_GAP: f32 = 40.0; +/// The gap the sizer counts between a double line's texts, `0x808e70` = 38.4 (`0x4219999a`). +pub const TOOLTIP_COLUMN_GAP: f32 = 38.4; /// `FadeOut`'s duration in seconds, `[0x808e7c]` = 2.0: the plate holds full alpha for the first /// half and ramps over the second. pub const TOOLTIP_FADE_SECS: f32 = 2.0; -/// A wrap-flagged line's width, pinned when it is appended; the reference's column is untraced. -pub const TOOLTIP_WRAP_WIDTH: f32 = 260.0; +/// The width a wrapped line is split at when its text is wider, `0x808e80` = 230.4 (`0x43666666`). +pub const TOOLTIP_WRAP_WIDTH: f32 = 230.4; /// One frame's advance of a model pane's scene clock in whole milliseconds: `elapsed ยท 1000` /// truncated toward zero with no `+0.5` (`0x76d846`, then `__ftol` `0x40a2b0`), where the world's diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index 3fec3cc1f..78f1618bc 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -136,8 +136,8 @@ pub use kinds::{ ScrollingMessageState, SequenceFacts, ShownWrites, SliderState, StatusBarState, TooltipAnchor, TooltipState, Viewport, ANIM_SHUFFLE_LEFT, ANIM_SHUFFLE_RIGHT, ANIM_STAND, MINIMAP_DEFAULT_ARROW_MODEL, MINIMAP_DEFAULT_MASK, MINIMAP_DEFAULT_PLAYER_MODEL, - MINIMAP_DEFAULT_ZOOM, MINIMAP_ENGINE_CHILDREN, MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP, - TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD, TOOLTIP_WRAP_WIDTH, TURN_HOLD_MS, + MINIMAP_DEFAULT_ZOOM, MINIMAP_ENGINE_CHILDREN, MINIMAP_ZOOM_LEVELS, TOOLTIP_COLUMN_GAP, + TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PLATE_PAD, TOOLTIP_WRAP_WIDTH, TURN_HOLD_MS, }; // โ”€โ”€ Frame and Region nodes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -334,7 +334,8 @@ pub struct WidgetArena { /// The live frames whose kind has a [`KindUpdate`], kept the same way: the update walk reaches /// each one shown, with an `OnUpdate` or without. walked_kinds: Vec, - /// The live GameTooltips, kept the same way, for the layout pre-pass and unit push. + /// The live GameTooltips, kept the same way, for the layout's cursor pre-pass, the headless + /// sizing and the unit push. tooltip_kinds: Vec, /// The live Minimaps, kept the same way, for the containment, zoom and arrow-facing feeds. minimap_kinds: Vec, diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 78e0e52a0..4719a49cb 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -36,7 +36,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it - [benilla-lua](#benilla-lua): 11 - [benilla-protocol](#benilla-protocol): 22 - [benilla-srp](#benilla-srp): 2 -- [benilla-ui](#benilla-ui): 114 +- [benilla-ui](#benilla-ui): 115 - [benilla-world](#benilla-world): 49 ### benilla-app @@ -728,6 +728,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it - The reference reads uninitialised fields before the seed; we answer the first legal design because that value is undefined. ([`src/script/tabard.rs:103`](../crates/benilla-ui/src/script/tabard.rs#L103)) - Returns nothing. The handler ends `mov eax,1` with nothing pushed (`0x4dcd7c`), so Lua 5.0 hands back the stack top, the node argument: an accident of the handler, not an API, and copying a stack-top leak would copy a bug. ([`src/script/taxi.rs:138`](../crates/benilla-ui/src/script/taxi.rs#L138)) - A handler that unregisters the saved next ends the dispatch, because the reference then reads a freed node. ([`src/script/tick.rs:30`](../crates/benilla-ui/src/script/tick.rs#L30)) +- `0x530a52` lays it out at once; getters resolve first, so only OnSizeChanged fires later. ([`src/script/tooltip/size.rs:155`](../crates/benilla-ui/src/script/tooltip/size.rs#L155)) - The reference's builder hides and clears on a missing record (`0x52b72e`..`0x52b73f`), where a source that knows the item's name shows that, because a blank plate under an on-screen name reads as broken; the template's arrival then builds it whole. ([`src/script/tooltip_item/build.rs:53`](../crates/benilla-ui/src/script/tooltip_item/build.rs#L53)) - An object no longer held, or a source that keeps no guid (the player's own trade slot), rebuilds from the instance the build read, where the reference's lookup finds no object and builds from the record and the block alone; benilla's views carry no object to read once it is gone, and the kept instance is what the plate showed. ([`src/script/tooltip_item/build.rs:154`](../crates/benilla-ui/src/script/tooltip_item/build.rs#L154)) - The reference's padding repeats a mana stat once per zero entry; not emulated, since only the internal test ring 6674 has one. ([`src/script/tooltip_item/render.rs:416`](../crates/benilla-ui/src/script/tooltip_item/render.rs#L416)) @@ -755,8 +756,8 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it - A code point past U+FFFF is no space, where the client tests its low 16 bits, so U+10020 counts as a space there. ([`src/widget/kinds/editbox.rs:1127`](../crates/benilla-ui/src/widget/kinds/editbox.rs#L1127)) - The reference has one field, the font's, but our font is read off the first `` region (`message_frame_font`), which a `CreateFrame` frame lacks; so that FontString's own `GetJustifyH` does not see this. ([`src/widget/kinds/messageframe.rs:33`](../crates/benilla-ui/src/widget/kinds/messageframe.rs#L33)) - Kept apart from the font's, as `ScrollingMessageState::justify` is. ([`src/widget/kinds/messageframe.rs:532`](../crates/benilla-ui/src/widget/kinds/messageframe.rs#L532)) -- `None` until then, not the ctor's 0, so that an unset row takes nothing rather than slot 0. ([`src/widget/kinds/mod.rs:861`](../crates/benilla-ui/src/widget/kinds/mod.rs#L861)) -- On the thumb it is the grabbed point, where `CSimpleSlider` (`0x789ba0`/`0x789ca0`) always centers it, because that feels less surprising and a stock-sized thumb hides the difference. ([`src/widget/kinds/mod.rs:1205`](../crates/benilla-ui/src/widget/kinds/mod.rs#L1205)) +- `None` until then, not the ctor's 0, so that an unset row takes nothing rather than slot 0. ([`src/widget/kinds/mod.rs:868`](../crates/benilla-ui/src/widget/kinds/mod.rs#L868)) +- On the thumb it is the grabbed point, where `CSimpleSlider` (`0x789ba0`/`0x789ca0`) always centers it, because that feels less surprising and a stock-sized thumb hides the difference. ([`src/widget/kinds/mod.rs:1212`](../crates/benilla-ui/src/widget/kinds/mod.rs#L1212)) - Levels pack contiguously, because the reference's resuming cursor (`0x764fd3`/`0x764fd8`) leaves a hole past a wide gap ({0, 4, 6} at bound 7 gives {0, 1, 3} at bound 4). ([`src/widget/propagation.rs:180`](../crates/benilla-ui/src/widget/propagation.rs#L180)) - The frame is still in the old parent's child list during `OnHide`, unlike the reference (`0x76ab34` unlinks first), because a handler that itself reparents must not find the lists half-spliced. ([`src/widget/propagation.rs:280`](../crates/benilla-ui/src/widget/propagation.rs#L280)) ### benilla-world