diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index 787622da..e686c481 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -140,11 +140,12 @@ public static class CharacterStatController private static readonly Vector4 HighlightBg = new(1f, 0.75f, 0.2f, 0.25f); // ── Row layout constants ───────────────────────────────────────────────── - // RowHeight reduced from 44 to 30px to match retail's tighter attribute list. - // The larger row font (0x40000001, MaxCharHeight=18) fits in 30px with 6px total vertical - // padding (3px above/below). Retail spec (2026-06-26 ref): "rows tighter, text ≈ icon height". - private const float RowHeight = 30f; - private const float IconSize = 24f; + // RowHeight 22px + IconSize 16px: retail spec (2026-06-26) says icons ~icon-height + // and rows tighter. 16px icon fits inside 22px row with 3px vertical padding each side. + // The larger row font (0x40000001, MaxCharHeight=18) is clipped to the 22px height which + // gives a tight-but-readable line. Retail spec (2026-06-26 ref): "rows tighter, text ≈ icon height". + private const float RowHeight = 22f; + private const float IconSize = 16f; private const float RowPadX = 4f; private const float IconGap = 6f; @@ -242,7 +243,22 @@ public static class CharacterStatController { if (datFont is not null) xpLabel.DatFont = datFont; 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 + + // 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) }; } if (layout.FindElement(XpNextValueId) is UiText xpValue) @@ -605,13 +621,14 @@ public static class CharacterStatController { var row = new UiClickablePanel { - Left = left, - Top = top, - Width = width, - Height = height, - BackgroundColor = Vector4.Zero, // transparent until selected - BorderColor = Vector4.Zero, - Anchors = AnchorEdges.Left | AnchorEdges.Top, + Left = left, + Top = top, + Width = width, + Height = height, + BackgroundColor = Vector4.Zero, // transparent until selected + BorderColor = Vector4.Zero, + UseSelectionBars = true, // Item 3: draw sprite as top+bottom bars, not full stretch + Anchors = AnchorEdges.Left | AnchorEdges.Top, }; float iconY = (height - IconSize) * 0.5f; diff --git a/src/AcDream.App/UI/UiPanel.cs b/src/AcDream.App/UI/UiPanel.cs index a927ec57..de31ecda 100644 --- a/src/AcDream.App/UI/UiPanel.cs +++ b/src/AcDream.App/UI/UiPanel.cs @@ -124,12 +124,30 @@ public class UiSimpleButton : UiPanel /// catches UIEvent_LeftClick (0x01) and calls SetSelectedAttribute on the /// parent window. In acdream we wire the equivalent via this action callback instead of /// the retail message bus. +/// +/// When is true and +/// 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 pixels +/// (default 3px). /// public class UiClickablePanel : UiPanel { /// Called when the user releases the left mouse button over this panel. public Action? OnClick { get; set; } + /// When true and 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). + public bool UseSelectionBars { get; set; } + + /// Height in pixels of each selection bar (top and bottom). Default 3px. + /// Ignored when is false. + public float SelectionBarHeight { get; set; } = 3f; + public UiClickablePanel() { // Rows must receive pointer events — override the UiPanel default (ClickThrough=false, @@ -152,4 +170,33 @@ public class UiClickablePanel : UiPanel } 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 = 1−barH/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); + } + } }