a chat link is one token now, and tooltips print true costs

- a chat link edits as one atomic token: the caret can't land inside it, its colors survive, one keypress removes it whole
- spell tooltips print the resolved mana cost through the reference's own table walk, and the "% of base mana" line dies
- the spirit healer's confirm comes back after a cancel and wears its warning icon
- the dressing room's pane is a live animating model, and a tried-on bow lands in the hand

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-07 16:02:39 +07:00
co-authored by Claude Fable 5
parent d99ad8bc7d
commit cd58f90bec
46 changed files with 4840 additions and 1695 deletions
@@ -58,8 +58,10 @@ pub(super) fn drive_script(
world_assets: Option<ResMut<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
mut font_atlas: Option<ResMut<UiFontAtlas>>,
// The token -> off-screen-baked-face bridge a `SetPortraitTexture`-bound region samples.
portraits: Res<crate::portrait::PortraitImages>,
// Both directions of the booth seam: the token -> off-screen-baked-face bridge a
// `SetPortraitTexture`-bound region samples, and the pane geometry this pass publishes back
// for the booths' projection aspect + render gate (decision 1069).
mut booths: crate::portrait::BoothBridge,
// Gates the held-cursor icon quad (decision 0216 §5): CAPTURE-ONLY, the same presence check
// every other capture-only system uses (`ui_script::capture_ui_active`'s sibling pattern).
capture: Option<Res<crate::capture::CaptureMode>>,
@@ -324,7 +326,7 @@ pub(super) fn drive_script(
let settled = capture.is_none()
&& prev.dims == Some(dims)
&& text_ui == prev.text_ui
&& portraits.0 == prev.portraits
&& booths.images.0 == prev.portraits
&& extracted == prev.extracted;
if settled {
drop(extract_span);
@@ -358,8 +360,13 @@ pub(super) fn drive_script(
}
prev.dims = Some(dims);
prev.text_ui = text_ui.clone();
prev.portraits = portraits.0.clone();
prev.portraits = booths.images.0.clone();
prev.extracted = extracted.clone();
// This frame's booth panes, refilled by the loop below (decision 1069). Cleared only HERE, on
// the un-skipped path: a settled frame draws exactly what the last one did, so the map it left
// is still this frame's truth — clearing it above the gate would make every quiet frame put the
// body panes' cameras to sleep and freeze their animation.
booths.panes.0.clear();
for eq in extracted {
let Some(r) = eq.rect else { continue };
// WoW UI space is y-up from the bottom-left in 768-virtual units; the quad pass is
@@ -419,7 +426,20 @@ pub(super) fn drive_script(
// booth yet) draws nothing rather than the run-splitter's white default.
if let Some(token) = &portrait_unit {
use crate::portrait::PortraitSource;
let handle = match portraits.0.get(token) {
// A **square** binding is a booth pane (`BenillaSetBoothTexture`, decision
// 0208 §5), not a round unit portrait: publish the rect's aspect so the booth
// can bake at the shape it will be stretched into, and know it is on screen at
// all (decision 1069). Recorded before the readiness `continue` below — a pane
// whose bake hasn't landed yet is still a pane being drawn. The region's rect
// is the whole answer because no pane crops its bake; a pane that grew
// `<TexCoords>` would have to fold that UV window in here too.
if !circular && rect.height() > 0.0 {
booths
.panes
.0
.insert(token.clone(), rect.width() / rect.height());
}
let handle = match booths.images.0.get(token) {
Some(PortraitSource::Live(h)) => Some(h.clone()),
Some(PortraitSource::File(p)) => assets
.as_mut()
@@ -820,6 +840,7 @@ mod clip_plumb_tests {
app.init_resource::<UiQuads>();
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::hover_log::UiFrameCost>();
app.init_resource::<Time>();
@@ -903,6 +924,7 @@ mod clip_plumb_tests {
app.init_resource::<UiQuads>();
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::hover_log::UiFrameCost>();
app.init_resource::<Time>();
@@ -959,6 +981,7 @@ mod extract_gate_tests {
app.init_resource::<UiQuads>();
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::hover_log::UiFrameCost>();
app.init_resource::<Time>();
@@ -183,14 +183,23 @@ pub(super) fn emit(
// 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, 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={:?} {:?}",
"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,
&draw_text[..draw_text.len().min(30)]
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