diff --git a/src/AcDream.App/UI/UiChannelMenu.cs b/src/AcDream.App/UI/UiChannelMenu.cs index b9e01f62..a64e1aa4 100644 --- a/src/AcDream.App/UI/UiChannelMenu.cs +++ b/src/AcDream.App/UI/UiChannelMenu.cs @@ -43,6 +43,15 @@ public sealed class UiChannelMenu : UiElement private const int Rows = 7; // items per column private const float ItemH = 17f; // row height (dat item template 0x1000001E H=17) private const float ColW = 191f; // column width (dat item template W=191) + private const int Border = RetailChromeSprites.Border; // 8-piece bevel thickness (5px) + // The row sprites 0x0600124E/4D bake a checkbox/checkmark into the leftmost ~17px + // square; the label starts just past it (box width + small gap) so text aligns with + // the box instead of overlapping it. + private const float TextIndent = 19f; + // The button face sprite (0x06004D65/66) bakes a status LED (red→green) into its + // left socket (~x4–20 of the 46px button); the caption starts past it so it doesn't + // render over the LED. + private const float ButtonTextIndent = 20f; /// The channel the player's typed text currently goes to. public ChatChannelKind Selected { get; private set; } = ChatChannelKind.Say; @@ -65,14 +74,21 @@ public sealed class UiChannelMenu : UiElement public uint ItemHighlightSprite { get; set; } // 0x0600124D — the active channel's row public Vector4 TextColor { get; set; } = new(1f, 0.92f, 0.72f, 1f); - /// Available item text (retail white #FFFFFF). + /// Available item text — retail white #FFFFFF (gmMainChatUI talk-focus + /// enabled state). Confirmed via decomp: enabled items render white. public Vector4 TextColorAvailable { get; set; } = new(1f, 1f, 1f, 1f); - /// Greyed/unavailable item text (retail colorPink salmon, acclient 0x81c528). - public Vector4 TextColorGhosted { get; set; } = new(1f, 0.588f, 0.588f, 1f); + /// Disabled/unavailable item text — retail GREYS these (UIElement state 0xd + /// disabled StateDesc colour). NOT the salmon colorPink (0x81c528) we had before — that + /// belongs to the chat-MESSAGE palette and was misapplied. Exact float lives in the dat + /// StateDesc (not a code symbol); ~0.5 neutral grey here pending a live cdb dump. + public Vector4 TextColorGhosted { get; set; } = new(0.5f, 0.5f, 0.5f, 1f); private bool _open; - private static float PopupW => 2 * ColW; - private static float PopupH => Rows * ItemH; + // Interior = the row content; Outer = interior + the 8-piece bevel ring. + private static float InteriorW => 2 * ColW; // 382 + private static float InteriorH => Rows * ItemH; // 119 + private static float OuterW => InteriorW + 2 * Border; + private static float OuterH => InteriorH + 2 * Border; public UiChannelMenu() { CapturesPointerDrag = true; } @@ -108,44 +124,104 @@ public sealed class UiChannelMenu : UiElement { var resolve = SpriteResolve; - // Button face + the active-target label. + // Button face (3-sliced so it can widen to fit the label) + the active-target label. if (resolve is not null) { - var (tex, _, _) = resolve(_open ? PressedSprite : NormalSprite); - if (tex != 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); + var (tex, tw, _) = resolve(_open ? PressedSprite : NormalSprite); + if (tex != 0 && tw > 0) DrawButtonFace(ctx, tex, tw); } - DrawLabel(ctx, ButtonText, 4f, (Height - LineH()) * 0.5f, TextColor); + DrawLabel(ctx, ButtonText, ButtonTextIndent, (Height - LineH()) * 0.5f, TextColor); + } + // 3-slice caps for the 46px LED-arrow button face (0x06004D65): a LEFT cap holding the + // round LED socket, a stretchable plain-gold MIDDLE, and a RIGHT cap holding the arrow + // point. Slicing keeps the LED + arrow undistorted when the button widens to its label. + private const float FaceCapL = 20f, FaceCapR = 12f; + + private void DrawButtonFace(UiRenderContext ctx, uint tex, float tw) + { + float uL = FaceCapL / tw, uR = (tw - FaceCapR) / tw; + float midDest = Width - FaceCapL - FaceCapR; + ctx.DrawSprite(tex, 0f, 0f, FaceCapL, Height, 0f, 0f, uL, 1f, Vector4.One); // LED cap + if (midDest > 0f) + ctx.DrawSprite(tex, FaceCapL, 0f, midDest, Height, uL, 0f, uR, 1f, Vector4.One); // gold body (stretched) + ctx.DrawSprite(tex, Width - FaceCapR, 0f, FaceCapR, Height, uR, 0f, 1f, 1f, Vector4.One); // arrow cap + } + + /// The button width that fits "LED cap + channel label + arrow cap" — retail + /// sizes the talk-focus button to its selected label. The controller widens the button + /// to this and reflows the input field to start after it. + public float NaturalButtonWidth() + { + float textW = DatFont?.MeasureWidth(ButtonText) ?? Font?.MeasureWidth(ButtonText) ?? ButtonText.Length * 7f; + return ButtonTextIndent + textW + 4f + FaceCapR; // text start (clears LED) + text + gap + arrow cap + } + + /// The open popup draws in the OVERLAY pass so it sits on top of the whole + /// UI — otherwise the translucent chat panel (drawn after this element in the main + /// pass) greys out the part of the popup that overlaps it. + protected override void OnDrawOverlay(UiRenderContext ctx) + { + var resolve = SpriteResolve; if (!_open || resolve is null) return; - // Two-column popup opening UPWARD from the button. Force OPAQUE (a menu reads - // solid even though the chat window is translucent). Draw the dat row sprites - // first, then the labels — both go through the sprite bucket in submission order, - // so the labels land on top (a DrawRect bg would composite over the text instead). + // Two-column popup opening UPWARD from the button, wrapped in the universal + // 8-piece window bevel (retail UIElement_Menu::MakePopup spawns the popup as a + // bevelled floating window). Force OPAQUE (a menu reads solid even though the + // chat window is translucent). Draw bevel → panel fill → row sprites → labels, + // all through the sprite bucket in submission order so labels land on top. ctx.PushAlphaAbsolute(1f); try { - float top = -PopupH; - DrawSprite(ctx, resolve, PopupBgSprite, 0f, top, PopupW, PopupH); // panel base + float outerTop = -OuterH; // popup bottom sits at the button top (y=0) + float inX = Border, inY = outerTop + Border; // interior origin (inside the bevel) + + DrawBevel(ctx, resolve, 0f, outerTop, OuterW, OuterH); + DrawSprite(ctx, resolve, PopupBgSprite, inX, inY, InteriorW, InteriorH); // panel fill behind rows + for (int i = 0; i < Items.Length; i++) { int col = i / Rows, row = i % Rows; - float x = col * ColW, y = top + row * ItemH; + float x = inX + col * ColW, y = inY + row * ItemH; bool selected = Items[i].Channel is { } c && c == Selected; DrawSprite(ctx, resolve, selected ? ItemHighlightSprite : ItemNormalSprite, x, y, ColW, ItemH); } + float textY = (ItemH - LineH()) * 0.5f; // center the label in its row for (int i = 0; i < Items.Length; i++) { int col = i / Rows, row = i % Rows; - bool avail = Items[i].Channel is { } c && IsAvailable(c); - DrawLabel(ctx, Items[i].Label, 4f + col * ColW, top + row * ItemH + textY, + // Channel items grey out when unavailable; the special items (Squelch / + // Tell-to-Selected, null channel) are normal white items in retail. + bool avail = Items[i].Channel is not { } c || IsAvailable(c); + DrawLabel(ctx, Items[i].Label, inX + col * ColW + TextIndent, inY + row * ItemH + textY, avail ? TextColorAvailable : TextColorGhosted); } } finally { ctx.PopAlpha(); } } + /// Draw the universal 8-piece retail window bevel (corners + tiled edges + + /// tiled centre fill) framing the rect (,, + /// ,). Reuses the same geometry + + /// ids as ; no resize + /// grips (a menu popup is not resizable). + private void DrawBevel(UiRenderContext ctx, Func resolve, + float x, float y, float w, float h) + { + var r = UiNineSlicePanel.ComputeFrameRects(w, h, Border); + void P(uint id, in UiNineSlicePanel.Rect d) => DrawSprite(ctx, resolve, id, x + d.X, y + d.Y, d.W, d.H); + P(RetailChromeSprites.CenterFill, r.Center); + P(RetailChromeSprites.TopEdge, r.Top); + P(RetailChromeSprites.BottomEdge, r.Bottom); + P(RetailChromeSprites.LeftEdge, r.Left); + P(RetailChromeSprites.RightEdge, r.Right); + P(RetailChromeSprites.CornerTL, r.TL); + P(RetailChromeSprites.CornerTR, r.TR); + P(RetailChromeSprites.CornerBL, r.BL); + P(RetailChromeSprites.CornerBR, r.BR); + } + private float LineH() => DatFont?.LineHeight ?? Font?.LineHeight ?? 14f; private void DrawSprite(UiRenderContext ctx, Func resolve, @@ -165,7 +241,7 @@ public sealed class UiChannelMenu : UiElement } protected override bool OnHitTest(float lx, float ly) - => _open ? (lx >= 0 && lx < PopupW && ly >= -PopupH && ly < Height) + => _open ? (lx >= 0 && lx < OuterW && ly >= -OuterH && ly < Height) : base.OnHitTest(lx, ly); public override bool OnEvent(in UiEvent e) @@ -173,17 +249,23 @@ public sealed class UiChannelMenu : UiElement if (e.Type != UiEventType.MouseDown) return false; float lx = e.Data1, ly = e.Data2; - if (_open && ly < 0) // clicked an item in the upward popup + if (_open && ly < 0) // clicked inside the upward popup { - int col = lx < ColW ? 0 : 1; - int row = (int)((ly + PopupH) / ItemH); - int idx = col * Rows + row; - // Only pick available channel items (special + greyed items are inert). - if (row >= 0 && row < Rows && idx >= 0 && idx < Items.Length - && Items[idx].Channel is { } ch && IsAvailable(ch)) + // Map into the bevel interior, then to (col,row). Clicks in the bevel ring + // (outside the interior) just close the menu. + float ix = lx - Border, iy = ly - (-OuterH + Border); + if (ix >= 0 && ix < InteriorW && iy >= 0 && iy < InteriorH) { - Selected = ch; - OnChannelChanged?.Invoke(ch); + int col = ix < ColW ? 0 : 1; + int row = (int)(iy / ItemH); + int idx = col * Rows + row; + // Only pick available channel items (special + greyed items are inert). + if (row >= 0 && row < Rows && idx >= 0 && idx < Items.Length + && Items[idx].Channel is { } ch && IsAvailable(ch)) + { + Selected = ch; + OnChannelChanged?.Invoke(ch); + } } _open = false; return true;