`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
560 lines
22 KiB
Rust
560 lines
22 KiB
Rust
//! 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:?}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|