Files
benilla-octo/crates/benilla-ui/src/script/measure.rs
T
gromandClaude Opus 5 75f04b4f28 compat: FontString:GetStringHeight, which AceGUI calls unguarded
1.12 registers no `GetStringHeight` (`script/button.rs`: `0x782390` reads the label's height
instead) and ClassicAPI backports it, so `compat/classic_api/frame.rs` is its home. That file had
it on its own not-built list under "needs per-line layout facts the host measurer is not asked for
today" — true of `GetNumLines` and `GetWrappedWidth`, and not of this one: the whole-string height
is the other half of the number `GetStringWidth` already reads. `measure::current_measure` now owns
the staleness rule for both, so the two cannot disagree about when a measure has gone stale, and a
changed string still reads 0 rather than the previous string's height.

It is not cosmetic. AceGUI-3.0 calls it **unguarded** — `AceGUIWidget-Label.lua:54` is
`height = label:GetStringHeight()` and `AceGUIWidget-CheckBox.lua:90` is
`self:SetHeight(28 + self.desc:GetStringHeight())` — so every Ace options panel on this install
drew its labels and checkbox descriptions at the library's own "avoid zero-height labels" fallback
of one pixel: a window with all of its prose invisible. `addon_harness --probe AI_VoiceOver` builds
that panel's full 32-widget tree with zero errors and every `Label` at `624x1`, which is what
"can't see the actual options in VoiceOver" was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-16 18:45:25 -06:00

117 lines
4.9 KiB
Rust

//! The synchronous text measure: the host's font engine installed into the VM, so
//! `GetStringWidth` answers inside the Lua call that asked, as the reference's does (`0x79e510`
//! calls its font engine, `0x772890`). UI code measures in the tick it sets text
//! (`MoneyFrame.lua:202`). With no measurer installed, metrics stay 0 until the
//! [`MeasureRequest`](super::MeasureRequest) round-trip fills them a frame later.
use mlua::Lua;
use super::{MeasureRequest, MeasuredText, Model};
use crate::widget::RegionHandle;
/// The host's font engine as the VM sees it. It must answer with the same code as the batch
/// round-trip, or a string's width depends on when it was asked for.
pub trait TextMeasure {
/// `(laid_out_w, laid_out_h, natural_w)` for `req` in frame-local units; `natural_w`, the
/// unwrapped width, is what `GetStringWidth` reports. It runs with the model mutably borrowed,
/// so it must not re-enter Lua.
fn measure(&mut self, req: &MeasureRequest) -> (f32, f32, f32);
}
/// This FontString's measure as it stands *right now*, measuring first when a host engine is
/// installed ([`ensure_measured`]); `None` when nothing has been measured under the key the
/// region's current text, font and width make — which every metric read answers as 0, never as
/// the previous string's number.
///
/// One owner for that staleness rule, because two readers want the same measure for different
/// halves of it: the stock `GetStringWidth` takes `natural_w` (`region/text.rs`) and ClassicAPI's
/// `GetStringHeight` takes `h` (`compat/classic_api/frame.rs`).
pub(super) fn current_measure(lua: &Lua, rh: RegionHandle) -> Option<MeasuredText> {
ensure_measured(lua, rh);
let model = lua.app_data_ref::<Model>().expect("model app_data");
let d = model.region_data.get(&rh)?;
// The measure is keyed by the drawn size, so the owner's scale is part of the key.
let scale = model
.arena
.region(rh)
.and_then(|r| model.arena.frame(r.owner))
.map(|f| f.effective_scale)
.unwrap_or(1.0);
d.measured.filter(|m| m.key == d.measure_key(scale))
}
/// Measure region `rh` now if a measurer is installed and its stored measure is stale; a current
/// one is left alone, so a per-frame poll does not re-measure.
pub(super) fn ensure_measured(lua: &Lua, rh: RegionHandle) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if model.measurer.is_none() {
return;
}
let Some(req) = super::layout::measure_request_for(&mut model, rh) else {
return; // not a FontString, no text, or already current
};
// Taken out and put back: the measurer needs `&mut self` while the model is borrowed, and it
// cannot re-enter the VM, so nothing sees the gap.
let mut engine = model.measurer.take().expect("checked above");
let (w, h, natural_w) = engine.measure(&req);
model.measurer = Some(engine);
let new = MeasuredText {
w,
h,
natural_w,
key: req.key,
};
let mut moved = false;
if let Some(d) = model.region_data.get_mut(&rh) {
moved = super::types::MeasuredText::layout_moved(d.measured, new);
// The key always lands: a stale key re-requests forever.
d.measured = Some(new);
}
if moved {
// Only a box that moved touches the layout, as in `set_measured_text`: a same-width
// re-measure (a countdown tick) must not cost a whole-roster hash.
model.touch_layout_region(rh);
}
}
impl super::UiScript {
/// Install the host's font engine, so metric reads answer inside the tick that asked. Replace
/// it whenever the window size or `uiScale` changes: glyph advances snap to whole physical
/// pixels.
pub fn set_text_measurer(&mut self, measurer: Box<dyn TextMeasure>) {
self.model_mut().measurer = Some(measurer);
}
/// Whether a [`TextMeasure`] is installed; the host then skips its batch measure pass.
pub fn has_text_measurer(&self) -> bool {
self.model_mut().measurer.is_some()
}
/// Answer every pending FontString measure inline, so a box is right in the frame its text
/// was set; returns whether anything changed.
pub(super) fn fill_measures(&mut self) -> bool {
if !self.has_text_measurer() {
return false;
}
let requests = self.fontstrings_needing_measure();
if requests.is_empty() {
return false;
}
let answers: Vec<(u32, f32, f32, f32, u64)> = {
let mut model = self.model_mut();
let mut engine = model.measurer.take().expect("checked above");
let answers = requests
.iter()
.map(|r| {
let (w, h, natural_w) = engine.measure(r);
(r.id, w, h, natural_w, r.key)
})
.collect();
model.measurer = Some(engine);
answers
};
self.set_measured_text(&answers);
true
}
}