fix(studio): Character window polish — XP-label left-align, smaller icons/tighter rows, selection bar full-width + top/bottom

Item 1 — XP-next label left-alignment: the "XP for next level:" label (child of the
XP meter) was not left-aligned with the "Total Experience (XP):" caption above it.
Fixed by computing the meter's x-offset at bind time and setting xpLabel.Left =
TotalXpLabel.Left - meter.Left, plus Centered=false/RightAligned=false.

Item 2 — icon size / row height: attribute-row icons reduced from 24px → 16px,
row height from 30px → 22px. The 9 rows are now compact and tightly packed matching
the retail reference (2026-06-26). Row font (18px dat) still fits the 22px row.

Item 3 — selection bar: UiClickablePanel gains UseSelectionBars (default false) and
SelectionBarHeight (default 3px). When UseSelectionBars=true and BackgroundSprite is
set, OnDraw draws the sprite as a thin horizontal bar at the TOP edge (y=0) and BOTTOM
edge (y=H-barH) of the row — full panel width, no left/right end-caps, UV-tiled
horizontally (u1=Width/nativeW). Falls back to the base UiPanel fill (BackgroundColor
or full-stretch sprite) when UseSelectionBars=false. AddRow sets UseSelectionBars=true
on all attribute/vital rows so the selection highlight shows as retail-style bars.
Sprite 0x06001397 is 300×32 px; at 3px bar height the UV crop shows the sprite's
top 3px (top bar) and bottom 3px (bottom bar). Temp pre-select for screenshot
verification was added then removed before this commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-26 12:01:42 +02:00
parent ad4ed51d6b
commit 4b5961ec04
2 changed files with 76 additions and 12 deletions

View file

@ -140,11 +140,12 @@ public static class CharacterStatController
private static readonly Vector4 HighlightBg = new(1f, 0.75f, 0.2f, 0.25f); private static readonly Vector4 HighlightBg = new(1f, 0.75f, 0.2f, 0.25f);
// ── Row layout constants ───────────────────────────────────────────────── // ── Row layout constants ─────────────────────────────────────────────────
// RowHeight reduced from 44 to 30px to match retail's tighter attribute list. // RowHeight 22px + IconSize 16px: retail spec (2026-06-26) says icons ~icon-height
// The larger row font (0x40000001, MaxCharHeight=18) fits in 30px with 6px total vertical // and rows tighter. 16px icon fits inside 22px row with 3px vertical padding each side.
// padding (3px above/below). Retail spec (2026-06-26 ref): "rows tighter, text ≈ icon height". // The larger row font (0x40000001, MaxCharHeight=18) is clipped to the 22px height which
private const float RowHeight = 30f; // gives a tight-but-readable line. Retail spec (2026-06-26 ref): "rows tighter, text ≈ icon height".
private const float IconSize = 24f; private const float RowHeight = 22f;
private const float IconSize = 16f;
private const float RowPadX = 4f; private const float RowPadX = 4f;
private const float IconGap = 6f; private const float IconGap = 6f;
@ -242,7 +243,22 @@ public static class CharacterStatController
{ {
if (datFont is not null) xpLabel.DatFont = datFont; if (datFont is not null) xpLabel.DatFont = datFont;
xpLabel.ClickThrough = true; xpLabel.ClickThrough = true;
xpLabel.Centered = false; // left-align (retail: aligns with Total XP label above)
xpLabel.RightAligned = false;
xpLabel.Padding = 0f; // avoid scroll clip — meter bar is ~13px tall xpLabel.Padding = 0f; // avoid scroll clip — meter bar is ~13px tall
// Item 1: align the XP-next label's left edge to match the TotalXpLabel's
// absolute left edge. The XP-next label is a child of the meter (local coords),
// so its Left = TotalXpLabel.Left meter.Left. This accounts for the meter's
// horizontal offset within the panel (the meter starts to the right of the
// "Total Experience (XP):" caption row). Source: retail spec §State 1 (the
// "XP for next level:" caption left-aligns with "Total Experience (XP):" above).
if (layout.FindElement(TotalXpLabelId) is { } totalXpLbl)
{
float xpNextLeft = totalXpLbl.Left - meter.Left;
xpLabel.Left = xpNextLeft >= 0f ? xpNextLeft : 0f;
}
xpLabel.LinesProvider = static () => new[] { new UiText.Line("XP for next level:", Body) }; xpLabel.LinesProvider = static () => new[] { new UiText.Line("XP for next level:", Body) };
} }
if (layout.FindElement(XpNextValueId) is UiText xpValue) if (layout.FindElement(XpNextValueId) is UiText xpValue)
@ -611,6 +627,7 @@ public static class CharacterStatController
Height = height, Height = height,
BackgroundColor = Vector4.Zero, // transparent until selected BackgroundColor = Vector4.Zero, // transparent until selected
BorderColor = Vector4.Zero, BorderColor = Vector4.Zero,
UseSelectionBars = true, // Item 3: draw sprite as top+bottom bars, not full stretch
Anchors = AnchorEdges.Left | AnchorEdges.Top, Anchors = AnchorEdges.Left | AnchorEdges.Top,
}; };

View file

@ -124,12 +124,30 @@ public class UiSimpleButton : UiPanel
/// catches <c>UIEvent_LeftClick</c> (0x01) and calls <c>SetSelectedAttribute</c> on the /// catches <c>UIEvent_LeftClick</c> (0x01) and calls <c>SetSelectedAttribute</c> on the
/// parent window. In acdream we wire the equivalent via this action callback instead of /// parent window. In acdream we wire the equivalent via this action callback instead of
/// the retail message bus.</para> /// the retail message bus.</para>
///
/// <para>When <see cref="UseSelectionBars"/> is true and <see cref="UiPanel.BackgroundSprite"/>
/// is non-zero, draws the sprite as a thin full-width bar at the TOP and BOTTOM edges of
/// the row (not stretched to fill). This matches retail's selection highlight which shows
/// a horizontal dark bar on both the top and bottom edge of the selected attribute row,
/// with NO left/right end-caps. Bar height is <see cref="SelectionBarHeight"/> pixels
/// (default 3px).</para>
/// </summary> /// </summary>
public class UiClickablePanel : UiPanel public class UiClickablePanel : UiPanel
{ {
/// <summary>Called when the user releases the left mouse button over this panel.</summary> /// <summary>Called when the user releases the left mouse button over this panel.</summary>
public Action? OnClick { get; set; } public Action? OnClick { get; set; }
/// <summary>When true and <see cref="UiPanel.BackgroundSprite"/> is non-zero, draws
/// the sprite as a thin horizontal bar at the top AND bottom edges of the panel,
/// NOT as a full-height stretched fill. Matches retail's selected-row highlight
/// (sprite 0x06001397 — 300×32 px — shown as bars, not a block fill).
/// Default false (preserves legacy full-stretch behavior).</summary>
public bool UseSelectionBars { get; set; }
/// <summary>Height in pixels of each selection bar (top and bottom). Default 3px.
/// Ignored when <see cref="UseSelectionBars"/> is false.</summary>
public float SelectionBarHeight { get; set; } = 3f;
public UiClickablePanel() public UiClickablePanel()
{ {
// Rows must receive pointer events — override the UiPanel default (ClickThrough=false, // Rows must receive pointer events — override the UiPanel default (ClickThrough=false,
@ -152,4 +170,33 @@ public class UiClickablePanel : UiPanel
} }
return false; return false;
} }
protected override void OnDraw(UiRenderContext ctx)
{
if (UseSelectionBars && BackgroundSprite != 0 && SpriteResolve is { } sr)
{
// Draw the selection highlight as a thin bar at the TOP and BOTTOM of the row.
// The sprite (0x06001397) is 300×32 px — we draw it as horizontal strips at
// native height (SelectionBarHeight), stretched to full panel width (UV tile
// horizontally). No left/right end-caps: u0=0, u1=Width/nativeW (UV repeat).
var (tex, tw, th) = sr(BackgroundSprite);
if (tex != 0 && tw > 0 && th > 0)
{
float barH = SelectionBarHeight;
float uTile = tw > 0 ? Width / tw : 1f;
// Top bar: shows the top barH px of the sprite (v = 0 → barH/th).
float vBot = th > 0 ? barH / th : 1f;
ctx.DrawSprite(tex, 0f, 0f, Width, barH, 0f, 0f, uTile, vBot, Vector4.One);
// Bottom bar: shows the bottom barH px of the sprite (v = 1barH/th → 1).
float vTop2 = th > 0 ? 1f - barH / th : 0f;
ctx.DrawSprite(tex, 0f, Height - barH, Width, barH, 0f, vTop2, uTile, 1f, Vector4.One);
}
// Selection-bar mode draws no border (rows have BorderColor=Zero by design).
}
else
{
// Default UiPanel draw: handles BackgroundSprite, BackgroundColor, AND border.
base.OnDraw(ctx);
}
}
} }