Files
benilla-octo/crates/benilla-app/src/ui_script/extract/text.rs
T
SamandClaude Opus 5 8d3d805ec8 the guild window opens: roster, ranks, notes and the message of the day
- the social window's third tab is live — the roster in both its views, the member card with its promote/demote/remove verbs, the rank editor's thirteen permissions, the guild information board, and the invite, leave, kick and note dialogs behind them
- setting your hearthstone at an innkeeper now asks before it takes and says so afterwards, and the innkeeper's gossip lines get their own icons back
- unavailable talents grey out, and every greyed icon in the client now goes through the same luminance fold the original used
- the buff tooltip's remaining-time line reads gold with the client's own hour/minute ladder, the macro editor takes a shift-click to paste a spell, trainer rows repaint after a purchase, and a long book page stops flickering its ellipsis

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 17:38:54 +07:00

454 lines
21 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 rasterization: one `QuadContent::Text` quad — a region FontString, a
//! message-frame ring line, or the focused editbox's windowed text — into glyph [`UiQuad`]s:
//! the editbox window + selection/caret, the ellipsis-truncate seam, the drop shadow, and the
//! hyperlink span collection. Split out of the extraction pass ([`super`]) when it crossed the
//! size budget; the arm's host-loop context arrives bundled as [`TextHost`].
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, UiFontAtlas};
/// The `QuadContent::Text` payload minus the text itself — the region's resolved style, passed
/// verbatim from the destructure at the [`super::drive_script`] call site.
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 host-loop context one Text quad draws under: the extracted quad's identity (z/alpha/
/// target), its y-flipped rect + clip, the focused editbox's text-UI geometry (matched to this
/// quad inside), and the screen height for the link-span flip back to engine space.
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 geometry, unfiltered — [`emit`] matches it to this quad
/// by [`EditBoxTextUi::target`].
pub ebox: Option<&'a EditBoxTextUi>,
pub screen_h: f32,
/// The 768-virtual scale `s = windowH/768` (decision 0582): rects arrive pre-scaled (px);
/// this converts the remaining unit-space inputs — font heights, shadow offsets, the
/// engine's editbox caret/selection x-offsets — into the same px space.
pub scale: f32,
/// The owning frame's `effective_scale` ([`benilla_ui::script::ExtractedQuad::scale`]): the
/// rect already carries it; the FONT metrics — glyph raster size and shadow offset, both
/// frame-local — multiply by it here (the real client's text rides `SetScale`; 0219 §2's
/// divergence, closed). NOT applied to the editbox caret/selection/advance x-offsets: those
/// arrive in screen UI units (the advance measure already rode the scale — see
/// [`benilla_ui::script::EditBoxAdvanceRequest`]'s `scale` doc).
pub font_scale: f32,
/// Captures pin the caret blink ON (deterministic pixels); live, the engine's phase decides.
pub caret_pinned: bool,
}
/// Rasterize one Text quad into `out` (and its hyperlink spans into `link_spans`, message-frame
/// lines only). The glyph quads share the owning region's `z` — see [`layout_text_quads`]'s doc
/// for why that's already the correct total-order slot.
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]);
let spec = crate::ui_text::FontSpec {
path: style.font.as_deref(),
// The drawn px under the two size regimes × the 768-virtual scale × the owner's frame
// scale (`drawn_px`, decision 0582): one-to-one text unit-caps at 32 (a frame-LOCAL cap,
// like the seam scale it precedes) then scales; a SetTextHeight override scales uncapped.
// The shadow twin below inherits the spec (`..spec`).
height: crate::ui_text::drawn_px(
style.font_height,
style.text_height,
host.scale * host.font_scale,
),
outline: style.outline,
paint_halo: true,
alpha_gradient: style.alpha_gradient,
};
// The focused edit box's text draws WINDOWED (`0x77da80`): the scroll window's substring,
// left-anchored at the line origin in an unbounded rect (the window never wraps — the box
// edge clips instead), the selection highlight behind everything, the white caret after.
// `line_origin` gives the origin + the line cell; the x-offsets inside it are the engine's
// advance-derived geometry.
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;
// The shadow's whole-pixel displacement ([`shadow_offset_px`]), hoisted: the band scissor
// below has to admit the ink it 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-frame ring line's scissor follows the seat, at the bottom edge only.
//
// The band ladder stacks UP from the frame's bottom edge, so the newest line is flush with it
// by construction and can never overflow there — the scissor's real job is the OTHER end, the
// half-fitting scrollback line at the frame's top, and that edge is untouched. What the bottom
// edge did cut was ink the renderer had deliberately placed below the band: the seat nudge
// (0351) lowers every UI text block one px, and the drop shadow sits one further px under the
// fill. Two of our own dials against a scissor that predates them — and the newest chat line
// lost the tails of its descenders and the feet of its brackets (director, 2026-07-26).
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(atlas, drawn, host.rect, draw_justify, spec);
ebox_geom = Some((x0, top, cell_h));
text_clip = Some(host.clip.map_or(host.rect, |c| c.intersect(host.rect)));
if !ui.multi_line {
// Single-line: the windowed draw — the scroll window's substring, left-anchored in
// an unbounded rect (the box edge clips instead of wrapping).
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,
};
}
// Multiline draws the whole block through the ordinary wrapped path below (the text
// region's justify is already the editbox law: TOP/LEFT) — only the caret/selection are
// seated by `(row, x)` at the same row pitch the wrap answered.
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 (its advance table was fed ÷scale).
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,
clip: text_clip,
rotation: 0.0,
mask: None,
corners: None,
});
}
}
// The height-gated ellipsis-truncate (`ellipsize_to_fit` — CSimpleFontString `0x771ec0`,
// decision 0292's residue landed): a region FontString whose wrapped text needs more lines
// than its height-pinned rect allows draws `prefix + "..."` instead — the bag title, the
// unit-frame names, the minimap zone text. Region FontStrings only, exactly the client's
// seam: the editbox windows (never truncates), message-frame ring lines size their band FROM
// the text, and auto-height FontStrings fit by construction (the measure round-trip), so the
// gate inside is geometric and needs no fixed-vs-measured flag. Computed before the shadow
// so the shadow pass draws the same display string (the client's shadow is a second draw of
// the same truncated CGxString).
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 DISPLAY string (post-ellipsis, post-editbox-window) — what this pass
// actually draws, which is what a truncation report needs to show.
let probe = std::env::var("WOW_TEXT_PROBE").as_deref() == Ok("1");
if probe {
// For the focused edit box, also the two numbers that must agree: where the engine puts the
// caret (`caret=`, advance-table-derived) and how wide the text this pass actually draws is
// (`ink=`). They diverge by the width of the markup when the advance table is measured over
// the raw buffer — the caret-out-in-space report (decision 1075) as a pair of numbers.
let ebox_geom = ebox.map(|ui| {
let ink = crate::ui_text::measure_text(&atlas.metrics, draw_text, None, spec).0;
format!(" caret={:.1} ink={ink:.1}", ui.caret_x * host.scale)
});
info!(
"text probe: [{:.0},{:.0} {:.0}x{:.0}] h={:?}{} {:?}",
draw_rect.min.x,
draw_rect.min.y,
host.rect.width(),
host.rect.height(),
style.font_height,
ebox_geom.unwrap_or_default(),
&draw_text[..draw_text.len().min(60)]
);
}
// The drop shadow (font object `<Shadow>` — MasterFont's (1,-1) black covers the whole
// GameFont* family): the same layout at an offset rect in the shadow color, pushed FIRST so
// the stable z-sort keeps it behind the glyph pass. Offset is WoW y-up (`y="-1"` = down) →
// y-down screen dy = −y. Markup color codes inside the text tint the shadow run too (v1
// corner, invisible for solid-color strings). The shadow is a single flat offset copy —
// never itself outlined (an outlined shadow would be a muddy black blob), so it suppresses
// the halo PAINT only: it keeps the font's true outline for the step law, laying out
// identically to its fill (a shadow with different steps would smear under long strings).
if let (Some(sh), Some((dx, dy))) = (style.shadow, shadow_delta) {
// WHOLE pixels (`shadow_offset_px`, computed above): the shadow is a rigid copy of the
// fill, so its displacement must be an integer in the rect's own space — see that fn for
// why a fractional one makes the offset itself wobble line to line.
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 {
paint_halo: false,
..spec
};
let mut sq = layout_text_quads(
atlas,
draw_text,
srect,
sh.color,
draw_justify,
host.z,
shadow_spec,
);
for q in &mut sq {
// Flatten rgb to the shadow color (markup tints ride the fill only), but multiply
// the layout's own alpha — it carries the shadow's base alpha AND the write-on
// gradient's per-glyph ramp (a revealing char's shadow fades with its fill).
q.color = [
sh.color[0],
sh.color[1],
sh.color[2],
q.color[3] * host.alpha,
];
// Every glyph quad inherits the owning Text quad's ScrollFrame clip — the
// FontString's own extract-time clip, not a per-glyph concept.
q.clip = text_clip;
}
out.extend(sq);
}
// `font`/`font_height` come from the region's resolved font object (`Fonts.xml`).
let mut glyphs = if let ZTarget::Frame(fh) = host.target {
// A frame-targeted Text quad is a message-frame ring line: collect its hyperlink spans
// for the engine's click hit-test (y-down → y-up flip).
let mut spans = Vec::new();
let g = layout_text_quads_links(
atlas,
text,
host.rect,
base_color,
crate::ui_text::Justify {
h: style.justify_h,
v: style.justify_v,
},
host.z,
spec,
&mut spans,
);
for sp in spans {
// px → the engine's y-up UI-unit space (÷scale after the flip).
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(
atlas,
draw_text,
draw_rect,
base_color,
draw_justify,
host.z,
spec,
)
};
for q in &mut glyphs {
q.color[3] *= host.alpha;
// Every glyph quad inherits the Text quad's ScrollFrame clip (decision 0112) —
// `ui_pass`'s CPU clip already applies uniformly to any `UiQuad`, glyph or not.
q.clip = text_clip;
}
// The probe's seat line: the drawn INK rows (glyph-quad union, logical px, relative to the
// rect top) — the measurable half of the vertical-seat law
// (`fontstring-vertical-placement.md`): compare `ink` against the law's `d + ascender` seat
// when hunting a vertical offset. Fill quads only (the shadow pass above would smear the
// bounds one px down-right).
if probe && !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);
}
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[..draw_text.len().min(20)]
);
}
out.extend(glyphs);
// The focused box's caret: a 1-px WHITE bar (the client's ctor `0xffffffff` caret texture —
// never the text color) one line cell tall at the engine's advance-derived x, pushed after
// the glyphs so the stable sort draws it on top.
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 engine UI units (÷scale advances); the 1-px bar width stays device-thin
// (the client's 4-unit caret is a named residual, ui.md's caret law).
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,
clip: text_clip,
rotation: 0.0,
mask: None,
corners: None,
});
}
}
}
/// One axis of the drop shadow's displacement, in the rect's own px space: the font object's
/// unit offset times the seam scale, **rounded to a whole pixel** (never to zero — a shadow the
/// scale shrank below half a pixel still draws one, the reference's look at any window size).
///
/// It must be a whole pixel because the shadow is laid out as a second, offset copy of the fill,
/// and each copy independently takes the client's single vertical anchor snap (`snap_block_top`
/// — `ceil(y − 0.5)`). A snap commutes with an integer translate and *only* with an integer
/// translate: at a fractional offset `d`, `ceil(y + d − 0.5) − ceil(y − 0.5)` is `⌈d⌉` for some
/// fractional parts of `y` and `⌊d⌋` for others — so the shadow's distance from its own glyphs
/// changed with where the string happened to land. The chat window made that visible and
/// permanent: its newest line's band is pinned to the frame's bottom edge, one fixed fractional
/// position, which sat inside the wide-by-one window — so the most recent message, and only it,
/// wore a shadow a pixel further out than every line above it (director, 2026-07-26; measured at
/// 1600×900 ×0.9 UI scale, seam scale 1.055 → the fill/shadow gap alternated 1 px and 2 px).
///
/// The world's floating combat text reached the same conclusion from its own law
/// (`combat_text::law::shadow_offset_px` rounds `0.002·viewport`); this is the UI's half.
/// How far below its band a message-frame line's ink can reach, in the rect's own px space — the
/// slack its scissor's bottom edge has to give back.
///
/// Two renderer offsets put ink under the geometric band, and neither existed when the band model
/// was written: the seat nudge ([`crate::ui_text::UI_SEAT_NUDGE`]) drops every UI text block one
/// px, and the drop shadow ([`shadow_offset_px`]) draws a copy one further px down. A shadow that
/// rises (`dy < 0`) adds nothing at the bottom.
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))
}
fn shadow_offset_px(scaled: f32) -> f32 {
let rounded = scaled.round();
if rounded == 0.0 && scaled != 0.0 {
scaled.signum()
} else {
rounded
}
}
#[cfg(test)]
mod tests {
use super::{band_clip_slack, shadow_offset_px};
/// The newest chat line's band bottom IS the frame's bottom edge, so every px the renderer
/// adds below the band is a px the scissor would otherwise eat — the descender tails and
/// bracket feet the director saw sliced off. The slack is the sum of the two offsets that put
/// ink there, and nothing else.
#[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);
// A font object with no shadow still owes the seat.
assert_eq!(band_clip_slack(None), 1.0);
// A shadow that rises adds nothing under the line.
assert_eq!(band_clip_slack(Some(-1.0)), 1.0);
// A bigger window scales the shadow, and the slack with it.
assert_eq!(band_clip_slack(Some(2.0)), 3.0);
}
/// The whole-pixel law, at the three scales that matter: the 1:1 seam (offset 1 px), the
/// director's 1600×900 ×0.9 (1.055 → 1 px, the fix's own case), and a small capture window
/// whose scale would otherwise round the shadow away entirely.
#[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);
// A font object with no offset on an axis keeps none.
assert_eq!(shadow_offset_px(0.0), 0.0);
}
/// The property the fix exists for: an integer translate commutes with the client's single
/// vertical snap (`ceil(y − 0.5)`), so the fill→shadow gap is the same at every fractional
/// position a string can land on. A fractional offset does not — the sweep below is exactly
/// the wobble the chat window's newest line wore.
#[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}"
);
}
// …and the unrounded offset provably does not (both gaps occur 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());
}
}