Files
benilla-octo/crates/benilla-app/src/ui_script/extract/text.rs
T
gromandClaude Opus 5 7fef604615 portrait: a unit pane honours Model:SetPosition
`SetPosition` is the widget's model-root translate and the extract spent it only on the file-pane
path. The booth path dropped the value and posed `R(yaw) · S(s)` alone, so Tmog's wheel zoom, its
right-drag pan and the per-slot framing of every browse card moved nothing at all.

It shows on a `<PlayerModel>` and not on a plain `<Model>` for one reason: this widget's camera is
frozen (`0x7acf10`) where `<Model>`'s perspective leg republishes its authored camera through the
very root being moved (`0x718960`), which cancels. `capture::probe_model_camera`'s `position` leg
pins that half; this is the other.

The extract publishes the translate in bevy's axes into a new `PanePositions`, keyed by slot so the
four stock window panes and the stock dressing room are served by the same channel rather than a
second path; a slot that is not drawn keeps the translation it has rather than snapping back to the
origin. The booths apply it as one `RootPose { yaw, scale, pos }`, latched together — keyed on
`(yaw, scale)` alone a position-only change compares equal and writes nothing, so a wheel would
zoom once and then stop. `WOW_BOOTH_LOG` gains `pane pose slot=… yaw=… scale=… pos=[…]`, one line
per change.

One model unit per `SetPosition` unit, and deliberately not the reference's `pos · layoutScale`:
that factor belongs to the group the reference's root scales with it, and this booth's root carries
only the renormalizer (`framing::pane_root_scale_at` drops `modelScale` and `layoutScale` both), so
taking the translate's share alone would move a `scale="4"` pane's model four times its own size.
The magnitude is the one part nothing in the tree pins — the only two calibrated `SetPosition`
sites are the ortho leg's pixel ladder and the `<Model>` leg where it cancels — and the extract's
arm says what to revisit if a wheel notch turns out to zoom too hard.

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

560 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The Text arm: one `QuadContent::Text` quad (a region FontString, a message-frame line or the
//! focused editbox's text) into glyph [`UiQuad`]s, with the editbox's selection and caret, the
//! ellipsis, the drop shadow and the hyperlink spans.
use bevy::prelude::*;
use benilla_ui::order::ZTarget;
use benilla_ui::script::{EditBoxTextUi, FontShadow, JustifyH, JustifyV, Outline};
use benilla_ui::widget::FrameHandle;
use crate::ui_pass::{UiQuad, UvRect};
use crate::ui_text::{layout_text_quads, layout_text_quads_links, TextSeat, UiFontAtlas};
/// `WOW_TEXT_PROBE=1`: log each Text quad's drawn string, font and ink rows; read once.
static TEXT_PROBE: std::sync::LazyLock<bool> =
std::sync::LazyLock::new(|| std::env::var("WOW_TEXT_PROBE").as_deref() == Ok("1"));
/// The `QuadContent::Text` payload minus the text: the region's resolved style.
pub(super) struct TextStyle {
pub color: Option<[f32; 4]>,
pub justify_h: JustifyH,
pub justify_v: JustifyV,
pub font: Option<String>,
pub font_height: Option<f32>,
pub text_height: Option<f32>,
pub shadow: Option<FontShadow>,
pub outline: Outline,
pub alpha_gradient: Option<(f32, f32)>,
/// The V-plate's name and level, seated on the plate's device-snapped overlay, not the UI grid.
pub world_seat: bool,
}
/// The extraction loop's context for one Text quad.
pub(super) struct TextHost<'a> {
pub z: u64,
pub alpha: f32,
pub target: ZTarget,
pub rect: Rect,
pub clip: Option<Rect>,
/// The focused editbox's text UI, unfiltered: [`emit`] matches it to this quad by `target`.
pub ebox: Option<&'a EditBoxTextUi>,
pub screen_h: f32,
/// The seam scale `windowH/768 × uiScale`, for the unit-space inputs; rects arrive in px.
pub scale: f32,
/// The frame's effective scale, already in the rect: glyph size and shadow offset ride it, as
/// the 1.12 client's text rides `SetScale`; the editbox x-offsets arrive in screen UI units.
pub font_scale: f32,
/// Captures pin the caret on for deterministic pixels; live, the engine's blink decides.
pub caret_pinned: bool,
}
/// Rasterize one Text quad into `out`, and a message-frame line's hyperlink spans into
/// `link_spans`. Glyph quads take the region's `z` ([`layout_text_quads`]).
pub(super) fn emit(
atlas: &mut UiFontAtlas,
text: &str,
style: TextStyle,
host: TextHost<'_>,
out: &mut Vec<UiQuad>,
link_spans: &mut Vec<(FrameHandle, benilla_ui::layout::Rect, String, String)>,
) {
let base_color = style.color.unwrap_or([1.0, 1.0, 1.0, 1.0]);
// The block's top snaps to the UI grid, except on a world overlay, which has its own seat.
let seat = if style.world_seat {
TextSeat::Exact
} else {
TextSeat::UiGrid
};
let spec = crate::ui_text::FontSpec {
path: style.font.as_deref(),
// One-to-one text caps at 32 frame units, then scales; a `SetTextHeight` size is uncapped.
height: crate::ui_text::drawn_px(
style.font_height,
style.text_height,
host.scale * host.font_scale,
),
outline: style.outline,
alpha_gradient: style.alpha_gradient,
};
// The focused editbox draws windowed (`0x77da80`): the scroll window's substring from the
// line origin, unwrapped and clipped at the box edge, the selection behind, the caret after.
let ebox = host.ebox.filter(|u| u.target == host.target);
let mut draw_text: &str = text;
let mut draw_rect = host.rect;
let mut draw_justify = crate::ui_text::Justify {
h: style.justify_h,
v: style.justify_v,
};
let mut text_clip = host.clip;
// Hoisted: the band scissor below admits the ink the shadow puts under the fill.
let shadow_delta = style.shadow.map(|sh| {
(
shadow_offset_px(sh.offset[0] * host.scale * host.font_scale),
shadow_offset_px(-sh.offset[1] * host.scale * host.font_scale),
)
});
// A message line's scissor gives back the ink the seat nudge and the shadow put below its
// band, at the bottom edge only: the top edge still clips a half-fitting scrollback line.
if matches!(host.target, ZTarget::Frame(_)) {
if let Some(c) = text_clip.as_mut() {
c.max.y += band_clip_slack(shadow_delta.map(|(_, dy)| dy));
}
}
let mut ebox_geom = None;
if let Some(ui) = ebox {
let drawn = &text[ui.display_from.min(text.len())..];
let (x0, top, cell_h) =
crate::ui_text::line_origin(&mut atlas.lock(), drawn, host.rect, draw_justify, spec);
ebox_geom = Some((x0, top, cell_h));
// A single-line box shows only what fits its text region (`0x77d858` sets the window's
// substring), so the whole string drawn here is cut at the region. A multi-line region is
// only as tall as its text, and the reference clips neither it nor the caret texture
// (`E+0x368`), which after a trailing break stands a line below it: only a ScrollFrame's
// clip applies.
if !ui.multi_line {
text_clip = Some(host.clip.map_or(host.rect, |c| c.intersect(host.rect)));
draw_text = drawn;
draw_rect = Rect::new(x0, host.rect.min.y, x0 + 100_000.0, host.rect.max.y);
draw_justify = crate::ui_text::Justify {
h: JustifyH::Left,
v: style.justify_v,
};
}
// A multiline box draws wrapped (its region is TOP/LEFT already); the caret and the
// selection seat by `(row, x)` at the wrap's row pitch.
for &(row, sx0, sx1) in &ui.selection {
let hc = ui.highlight_color;
#[allow(clippy::cast_precision_loss)]
let ry = top + row as f32 * cell_h;
// The engine's selection x-span is in UI units.
out.push(UiQuad {
rect: Rect::new(
x0 + sx0 * host.scale,
ry,
x0 + sx1 * host.scale,
ry + cell_h,
),
z_key: host.z,
texture: None,
uv: UvRect::FULL,
color: [hc[0], hc[1], hc[2], hc[3] * host.alpha],
additive: false,
circular: false,
desaturated: false,
premultiplied: false,
gamma_texel: false,
alpha_test: None,
uv_clamp: None,
clip: text_clip,
rotation: 0.0,
mask: None,
corners: None,
});
}
}
// A region FontString whose wrapped text needs more lines than its rect holds draws
// `prefix + "..."` (`CSimpleFontString` `0x771ec0`); an editbox never truncates and a message
// line sizes to its text. Before the shadow, which the client draws from the same string.
let ellipsized = match host.target {
ZTarget::Region(region) if ebox.is_none() => {
crate::ui_text::ellipsize_to_fit(atlas, region, draw_text, draw_rect, spec)
}
_ => None,
};
if let Some(display) = ellipsized.as_deref() {
draw_text = display;
}
// The probe prints the displayed string, after the ellipsis and the editbox window.
let probe = *TEXT_PROBE;
if probe {
// For the focused editbox, the engine's caret x and the drawn width, which must agree.
let ebox_geom = ebox.map(|ui| {
let ink = crate::ui_text::measure_text(&mut atlas.lock(), draw_text, None, spec).0;
format!(" caret={:.1} ink={ink:.1}", ui.caret_x * host.scale)
});
// `px` is the drawn height, after the cap, the seam and the frame scale.
info!(
"text probe: [{:.0},{:.0} {:.0}x{:.0}] h={:?} px={:?} flags={:?} face={:?}{} {:?}",
draw_rect.min.x,
draw_rect.min.y,
host.rect.width(),
host.rect.height(),
style.font_height,
spec.height,
style.outline.as_str(),
spec.path.unwrap_or("<none — the fallback face>"),
ebox_geom.unwrap_or_default(),
&draw_text[..draw_text.len().min(60)]
);
}
// The font object's `<Shadow>` (`MasterFont`'s `(1, -1)` black, `Fonts.xml:55`, which most
// GameFonts inherit): the fill's layout and cells again at the offset in the shadow colour,
// pushed first so the stable sort draws it behind. WoW's `y="-1"` is down: screen `dy = −y`.
if let (Some(sh), Some((dx, dy))) = (style.shadow, shadow_delta) {
let srect = Rect::new(
draw_rect.min.x + dx,
draw_rect.min.y + dy,
draw_rect.max.x + dx,
draw_rect.max.y + dy,
);
let shadow_spec = crate::ui_text::FontSpec { ..spec };
let mut sq = layout_text_quads(
&mut atlas.lock(),
draw_text,
srect,
sh.color,
draw_justify,
host.z,
shadow_spec,
seat,
);
for q in &mut sq {
// Markup tints ride the fill only; the alpha is [`shadow_alpha`].
q.color = [
sh.color[0],
sh.color[1],
sh.color[2],
shadow_alpha(q.color[3], base_color[3], host.alpha),
];
q.clip = text_clip;
}
out.extend(sq);
}
let mut glyphs = if let ZTarget::Frame(fh) = host.target {
// A frame-targeted Text quad is a message line: collect its links for the click hit-test.
let mut spans = Vec::new();
let g = layout_text_quads_links(
&mut atlas.lock(),
text,
host.rect,
base_color,
crate::ui_text::Justify {
h: style.justify_h,
v: style.justify_v,
},
host.z,
spec,
seat,
&mut spans,
);
for sp in spans {
// Back to the engine's y-up UI units.
link_spans.push((
fh,
benilla_ui::layout::Rect::new(
(host.screen_h - sp.rect.max.y) / host.scale,
sp.rect.min.x / host.scale,
(host.screen_h - sp.rect.min.y) / host.scale,
sp.rect.max.x / host.scale,
),
sp.link,
sp.markup,
));
}
g
} else {
layout_text_quads(
&mut atlas.lock(),
draw_text,
draw_rect,
base_color,
draw_justify,
host.z,
spec,
seat,
)
};
for q in &mut glyphs {
q.color[3] *= host.alpha;
q.clip = text_clip;
}
// The seat probe: the fill's ink rows relative to the rect top, to compare with the
// vertical-seat law's `d + ascender` (`0x5d1360`); the shadow's quads are not in `glyphs`.
let vpl = style.world_seat && benilla_assets::trace::enabled_for("vpl");
if (probe || vpl) && !glyphs.is_empty() {
let (mut y0, mut y1) = (f32::MAX, f32::MIN);
for q in &glyphs {
y0 = y0.min(q.rect.min.y);
y1 = y1.max(q.rect.max.y);
}
// Where the plate's text inked, beside the `vpl` tag's `plate=` and `paint=` lines.
if vpl {
benilla_assets::trace::line(
"vpl",
&format!(
"text x={:.3} rect={:.3} ink={:.3} rel={:.3} txt={}",
host.rect.min.x,
host.rect.min.y,
y0,
y0 - host.rect.min.y,
draw_text.chars().take(16).collect::<String>()
),
);
}
if probe {
info!(
"seat probe: top={:.2} ink=[{:.2}..{:.2}] (rel {:.2}..{:.2}) {:?}",
host.rect.min.y,
y0,
y1,
y0 - host.rect.min.y,
y1 - host.rect.min.y,
draw_text.chars().take(20).collect::<String>()
);
}
}
out.extend(glyphs);
// The caret: a 1 px bar in the constructor's white (`0xffffffff`), one line cell tall at the
// engine's x, pushed after the glyphs to draw on top. The reference's is 4 UI units wide
// (`0x77b8c0`) and takes the edit box's text colour on a font change (`0x77e2a0`).
if let (Some(ui), Some((x0, top, cell_h))) = (ebox, ebox_geom) {
if host.caret_pinned || ui.caret_on {
#[allow(clippy::cast_precision_loss)]
let top = top + ui.caret_row as f32 * cell_h;
// `caret_x` is in engine UI units.
let cx = x0 + ui.caret_x * host.scale;
out.push(UiQuad {
rect: Rect::new(cx, top, cx + 1.0, top + cell_h),
z_key: host.z,
texture: None,
uv: UvRect::FULL,
color: [1.0, 1.0, 1.0, host.alpha],
additive: false,
circular: false,
desaturated: false,
premultiplied: false,
gamma_texel: false,
alpha_test: None,
uv_clamp: None,
clip: text_clip,
rotation: 0.0,
mask: None,
corners: None,
});
}
}
}
/// How far below its band a message-frame line's ink reaches, in the rect's px space: the seat
/// nudge ([`crate::ui_text::UI_SEAT_NUDGE`]) plus a falling shadow's offset.
fn band_clip_slack(shadow_dy: Option<f32>) -> f32 {
crate::ui_text::UI_SEAT_NUDGE + shadow_dy.map_or(0.0, |dy| dy.max(0.0))
}
/// One axis of the drop shadow's offset in the rect's px space, rounded to a whole pixel but
/// never to zero, as the reference's shadow shows at any window size. Fill and shadow each take
/// the client's vertical snap, `ceil(y − 0.5)`, which commutes only with an integer translate: a
/// fractional offset makes the gap between them alternate between 1 and 2 px.
fn shadow_offset_px(scaled: f32) -> f32 {
let rounded = scaled.round();
if rounded == 0.0 && scaled != 0.0 {
scaled.signum()
} else {
rounded
}
}
/// A shadow glyph's alpha, every scalar the fill rides plus its own: `layout` (the `<Shadow>`
/// colour's alpha times the write-on gradient), `fill` (the fill colour's alpha, which
/// `SetTextColor` and a MessageFrame line's fade move) and `host` (the effective frame alpha).
fn shadow_alpha(layout: f32, fill: f32, host: f32) -> f32 {
layout * fill * host
}
#[cfg(test)]
mod tests {
use super::{band_clip_slack, shadow_alpha, shadow_offset_px};
#[test]
fn a_drop_shadow_fades_through_the_colour_lane_as_well_as_the_frame_lane() {
assert_eq!(shadow_alpha(1.0, 1.0, 1.0), 1.0);
// The colour lane alone: a message-frame line mid-fade.
assert_eq!(shadow_alpha(1.0, 0.25, 1.0), 0.25);
// The frame lane alone (`UIFrameFadeOut`).
assert_eq!(shadow_alpha(1.0, 1.0, 0.25), 0.25);
// Both lanes, under a font object's own semi-transparent shadow.
assert_eq!(shadow_alpha(0.8, 0.5, 0.5), 0.2);
assert_eq!(shadow_alpha(1.0, 0.0, 1.0), 0.0);
}
#[test]
fn the_band_scissor_gives_back_exactly_the_seat_and_the_shadow() {
// The shipped GameFont case: seat nudge 1 + a 1px drop shadow.
assert_eq!(band_clip_slack(Some(1.0)), 2.0);
assert_eq!(band_clip_slack(None), 1.0);
assert_eq!(band_clip_slack(Some(-1.0)), 1.0);
assert_eq!(band_clip_slack(Some(2.0)), 3.0);
}
/// `1.0546875` is the seam scale of a 1600×900 window at uiScale 0.9.
#[test]
fn a_shadow_offset_is_a_whole_pixel_and_never_vanishes() {
assert_eq!(shadow_offset_px(1.0), 1.0);
assert_eq!(shadow_offset_px(1.0546875), 1.0);
assert_eq!(shadow_offset_px(-1.0546875), -1.0);
assert_eq!(shadow_offset_px(2.109375), 2.0);
// 377-tall capture window × 0.9: 0.44 px would round to nothing.
assert_eq!(shadow_offset_px(0.4418), 1.0);
assert_eq!(shadow_offset_px(-0.4418), -1.0);
assert_eq!(shadow_offset_px(0.0), 0.0);
}
/// An integer offset commutes with the client's vertical snap, `ceil(y − 0.5)`.
#[test]
fn a_whole_pixel_offset_survives_the_anchor_snap_at_every_fraction() {
let snap = |y: f32| (y - 0.5).ceil();
let d = shadow_offset_px(1.0546875);
for step in 0..1000 {
let y = 100.0 + step as f32 / 1000.0;
assert_eq!(
snap(y + d) - snap(y),
d,
"whole-px offset must translate the snapped block exactly, y={y}"
);
}
// The unrounded offset gives both a 1 and a 2 px gap over one unit of y.
let raw = 1.0546875f32;
let gaps: std::collections::BTreeSet<i32> = (0..1000)
.map(|step| {
let y = 100.0 + step as f32 / 1000.0;
(snap(y + raw) - snap(y)) as i32
})
.collect();
assert_eq!(gaps, [1, 2].into_iter().collect());
}
}
/// The focused multi-line box's caret through the whole UI pass, on the client faces: the region
/// that draws its text is only as tall as that text, and the caret must not be cut to it.
#[cfg(test)]
mod caret_tests {
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use benilla_ui::script::UiScript;
use super::super::{paint_script, tick_script, UiQuad, UiQuads};
use crate::portrait::PortraitImages;
use crate::ui_text::UiFontAtlas;
/// A focused 270-wide multi-line box at 14 px on a 1024×768 window (`s = 1`), holding `text`.
fn app_with_focused_box(text: &str) -> App {
let script = UiScript::new().unwrap();
script
.run(&format!(
r#"
E = CreateFrame("EditBox", "E")
E:SetPoint("TOPLEFT", 100, -100)
E:SetWidth(270); E:SetHeight(200)
E:SetFont("Fonts\\FRIZQT__.TTF", 14)
E:SetMultiLine(true)
E:SetFocus()
E:SetText({text:?})
"#
))
.unwrap();
let mut app = App::new();
app.insert_non_send_resource(script);
app.insert_resource(UiFontAtlas::for_test(1.0).expect("the client faces open"));
app.init_resource::<UiQuads>();
app.init_resource::<crate::ui_script::UiPassState>();
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::portrait::PaneBakeSizes>();
app.init_resource::<crate::portrait::PanePositions>();
app.init_resource::<crate::portrait::UnitPaneBooths>();
app.init_resource::<crate::ui_models::UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::ui_script::UiFrameCost>();
app.init_resource::<crate::ui_script::UiCostWanted>();
app.init_resource::<Time>();
app.init_resource::<Time<Real>>();
app.init_resource::<crate::ui_script::UiClock>();
app.init_resource::<crate::ui_script::UiScaleCvar>();
app.world_mut().spawn((
Window {
resolution: UVec2::new(1024, 768).into(),
..default()
},
PrimaryWindow,
));
app.add_systems(Update, (tick_script, paint_script).chain());
// The box seats and sizes itself, the host answers the advances, then the caret draws.
for _ in 0..4 {
app.update();
}
app
}
/// The caret: the one untextured white quad.
fn caret(app: &App) -> UiQuad {
let quads = &app.world().resource::<UiQuads>().quads;
let mut carets = quads
.iter()
.filter(|q| q.texture.is_none() && q.color[..3] == [1.0, 1.0, 1.0]);
let c = carets
.next()
.expect("the focused box draws its caret")
.clone();
assert!(carets.next().is_none(), "one caret");
c
}
/// Whole and visible: a line tall, and no clip cuts any of it.
fn assert_whole(c: &UiQuad) {
assert!(c.rect.height() >= 10.0, "a line tall: {:?}", c.rect);
if let Some(clip) = c.clip {
assert_eq!(
clip.intersect(c.rect),
c.rect,
"the clip cuts the caret: {clip:?}"
);
}
}
#[test]
fn an_empty_focused_multi_line_box_draws_its_whole_caret() {
let _data = benilla_formats::wow_data_or_skip!();
let app = app_with_focused_box("");
assert_whole(&caret(&app));
}
/// After a trailing break the caret stands on the line the break opened, below the text.
#[test]
fn a_caret_after_a_trailing_newline_is_drawn_whole_below_the_text() {
let _data = benilla_formats::wow_data_or_skip!();
let one = caret(&app_with_focused_box("abc"));
let after = caret(&app_with_focused_box("abc\n"));
assert_whole(&after);
assert_eq!(
after.rect.min.y,
one.rect.min.y + one.rect.height(),
"one line down: {:?} against {:?}",
after.rect,
one.rect
);
}
/// The last line's descenders hang below its measured line, as a drop shadow would: no clip
/// cuts them.
#[test]
fn a_focused_multi_line_boxs_last_line_keeps_its_descenders() {
let _data = benilla_formats::wow_data_or_skip!();
let app = app_with_focused_box("gjpqy");
let quads = &app.world().resource::<UiQuads>().quads;
let glyphs: Vec<&UiQuad> = quads.iter().filter(|q| q.texture.is_some()).collect();
assert_eq!(glyphs.len(), 5, "the five glyphs draw");
for g in glyphs {
if let Some(clip) = g.clip {
assert_eq!(
clip.intersect(g.rect),
g.rect,
"a clip cuts a glyph: {clip:?}"
);
}
}
}
}