feat(D.2b): vitals numbers as UiText (widget-generalization Task 8)

The vitals cur/max numbers now render through the generic UiText widget — retail
gmVitalsUI uses UIElement_Text for them, not a meter-internal label. VitalsController
attaches a centered, non-interactive UiText child to each meter and stops the meter
drawing its own label (UiMeter.Label -> null). New UiText.Centered draws the first line
centered H+V with the SAME formula UiMeter's overlay used, so the numbers are
pixel-identical — user-confirmed in the live client.

This completes the D.2b widget-generalization pass: every chat + vitals widget is now
built generically and registered to its retail Type (Button/Field*/Menu/Meter/Scrollbar/
Text), with thin find-by-id controllers. (*Field is controller-placed; Type 3 stays
UiDatElement for chrome.)

Divergence register: AP-37 vitals-numbers-via-UiMeter.Label clause retired. Full suite:
404 passed, 2 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-16 18:52:42 +02:00
parent d7002552bc
commit 89626cd400
4 changed files with 83 additions and 10 deletions

View file

@ -1,4 +1,6 @@
using System;
using System.Numerics;
using AcDream.App.UI;
namespace AcDream.App.UI.Layout;
@ -53,16 +55,44 @@ public static class VitalsController
BindMeter(layout, Mana, manaPct, manaText);
}
/// <summary>White cur/max numbers — matches the former <c>UiMeter.LabelColor</c> default.</summary>
private static readonly Vector4 NumberColor = new(1f, 1f, 1f, 1f);
private static void BindMeter(
ImportedLayout layout, uint id,
Func<float> pct,
Func<string> text)
{
if (layout.FindElement(id) is UiMeter m)
// Silently skip if the id is absent — missing meters are not an error (partial layouts).
if (layout.FindElement(id) is not UiMeter m) return;
m.Fill = () => pct();
// Retail gmVitalsUI renders the cur/max as a real UIElement_Text centered over the
// bar — NOT a meter-internal label. Attach a centered UiText (non-interactive
// decoration) that fills + stretches with the meter, and stop the meter drawing its
// own label. UiText.Centered uses the SAME centering formula the meter's overlay did,
// so the numbers stay pixel-identical (locked by the visual gate).
m.Label = () => null;
var number = new UiText
{
m.Fill = () => pct();
m.Label = () => text();
}
// Silently skip if the id is absent — missing meters are not an error.
Left = 0f, Top = 0f, Width = m.Width, Height = m.Height,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
Centered = true,
DatFont = m.DatFont, // the same dat font the meter used for its label
ClickThrough = true, // decoration: no focus / selection / drag
AcceptsFocus = false,
IsEditControl = false,
CapturesPointerDrag = false,
LinesProvider = () =>
{
var s = text();
return string.IsNullOrEmpty(s)
? Array.Empty<UiText.Line>()
: new[] { new UiText.Line(s, NumberColor) };
},
};
m.AddChild(number);
}
}

View file

@ -63,6 +63,14 @@ public sealed class UiText : UiElement
/// <summary>Inner text inset from the view edges, px.</summary>
public float Padding { get; set; } = 4f;
/// <summary>Static centered single-line mode (retail <c>UIElement_Text</c> center
/// justification): draws the FIRST line centered horizontally AND vertically in the
/// element rect, with NO scroll/selection machinery. Used for static labels such as
/// the vitals cur/max numbers. The centering formula is IDENTICAL to
/// <see cref="UiMeter"/>'s former number overlay so those numbers stay pixel-identical
/// after the rewire. Pair with <c>ClickThrough = true</c> for non-interactive labels.</summary>
public bool Centered { get; set; }
/// <summary>The scroll model — also read by the linked UiScrollbar.</summary>
public UiScrollable Scroll { get; } = new();
@ -122,6 +130,29 @@ public sealed class UiText : UiElement
// submitted first → text on top.
ctx.DrawFill(0, 0, Width, Height, BackgroundColor);
// Static centered single-line mode (vitals cur/max numbers etc.): draw the first
// line centered H+V with the SAME formula UIElement_Meter used for its label, then
// skip the scroll/selection machinery entirely.
if (Centered)
{
var cLines = LinesProvider();
if (cLines.Count == 0) return;
var line0 = cLines[0];
if (DatFont is { } cdf)
{
float cx = (Width - cdf.MeasureWidth(line0.Text)) * 0.5f;
float cy = (Height - cdf.LineHeight) * 0.5f;
ctx.DrawStringDat(cdf, line0.Text, cx, cy, line0.Color);
}
else if ((Font ?? ctx.DefaultFont) is { } cbf)
{
float cx = (Width - cbf.MeasureWidth(line0.Text)) * 0.5f;
float cy = (Height - cbf.LineHeight) * 0.5f;
ctx.DrawString(line0.Text, cx, cy, line0.Color, cbf);
}
return;
}
// Prefer the retail dat font when set; fall back to BitmapFont.
var datFont = DatFont;
var bitmapFont = datFont is null ? (Font ?? ctx.DefaultFont) : null;