From 1da697ec2a2d8cd7cd5c49545ef154a832919ccf Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 09:37:40 +0200 Subject: [PATCH] =?UTF-8?q?@=20feat(D.2b):=20chat=20polish=20=E2=80=94=20t?= =?UTF-8?q?yping=20fix,=20opacity,=20scrollbar=203-slice,=20retail=20chann?= =?UTF-8?q?el=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visual-iteration batch (decomp-grounded), each fix verified against the retail screenshots: - typing: UiElement.HitTest aborted on ClickThrough BEFORE walking children, so the ClickThrough UiDatElement panels blocked hit-testing to the input/transcript inside them. Check ClickThrough AFTER the child walk (it only gates whether THIS element claims the hit). Restores input focus + typing. - opacity: UiElement.Opacity + a UiRenderContext alpha stack applied to sprite/rect draws (text bypasses it, stays sharp); chat frame Opacity=0.75 → translucent chat. - brown sliver: grow the transcript panel up 9px to cover the dropped resize-bar strip. - scrollbar: real 3-slice thumb (caps 0x06004C60/66 + tiled mid) + tiled track. - max/min: shifted one button-width left of the scrollbar (dat right-anchors collide). - system text now green (retail ChatMessageType 5; was yellow). - word-wrap: transcript lines wrap to the panel width (greedy, ports GlyphList::Recalculate). - channel menu reworked to retail gmMainChatUI::InitTalkFocusMenu: "Chat" button + a TWO-COLUMN popup of the 14 talk-focus items (Squelch, Tell to Selected, Chat to All, Tell to Fellows, ...) on a tan panel; channel items set the active outbound channel. Build + 392 App tests green. Visual confirmation in progress. Co-Authored-By: Claude Opus 4.8 (1M context) @ --- src/AcDream.App/Rendering/GameWindow.cs | 41 +++++-- .../UI/Layout/ChatWindowController.cs | 111 ++++++++++++++--- src/AcDream.App/UI/UiChannelMenu.cs | 114 ++++++++++-------- src/AcDream.App/UI/UiChatScrollbar.cs | 49 ++++++-- src/AcDream.App/UI/UiElement.cs | 20 ++- src/AcDream.App/UI/UiRenderContext.cs | 28 ++++- .../UI/UiChannelMenuTests.cs | 92 ++++++++------ 7 files changed, 329 insertions(+), 126 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 25edfc17..6951c28e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1857,19 +1857,36 @@ public sealed class GameWindow : IDisposable // Ctrl+C / Ctrl+A on the transcript need the keyboard for clipboard + modifiers. // _uiHost.Keyboard is set by WireKeyboard above — it is non-null here. chatController.Transcript.Keyboard = _uiHost.Keyboard; - // Top-level retail window: user-positioned at the bottom-left, movable + resizable. - // KEEP the dat-authored size (do NOT override Width/Height) so the child anchors - // capture their dat margins on the first layout — the same reason the vitals root - // keeps its dat size. The user resizes/moves from there. + // Wrap the dat content in the universal 8-piece beveled window chrome — + // the SAME UiNineSlicePanel the vitals window uses. The chat's own dat + // layout only carries flat background sprites, so without this the window + // has no retail-style border (the user asked for the vitals border). The + // nine-slice IS the movable/resizable window; the dat content fills its + // interior, inset by the border. The gmMainChatUI content is authored 490 + // wide (its transcript/input panels) — KEEP that width + the dat-authored + // HEIGHT so the content's child anchors (input-bar-at-bottom, transcript- + // fills) capture correct margins on first layout; resizing the frame reflows + // them correctly from there. + const int chatBorder = AcDream.App.UI.RetailChromeSprites.Border; var chatRoot = chatController.Root; - chatRoot.Left = 10; - chatRoot.Top = 460; // bottom-left default; pending the user's visual review - chatRoot.Anchors = AcDream.App.UI.AnchorEdges.None; - chatRoot.Draggable = true; - chatRoot.Resizable = true; - chatRoot.MinWidth = 200f; - chatRoot.MinHeight = 80f; - _uiHost.Root.AddChild(chatRoot); + float contentW = 490f, contentH = chatRoot.Height; // dat-authored height + var chatFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome) + { + Left = 10, Top = 440, + Width = contentW + 2 * chatBorder, Height = contentH + 2 * chatBorder, + MinWidth = 200f, MinHeight = 90f, + // Retail chat is translucent — fade the window's backgrounds/chrome + // (text stays opaque). Configurable opacity is a later step; 0.75 reads + // as see-through-but-readable. (retail SetDefaultOpacity ~0.5 / active 1.0) + Opacity = 0.75f, + }; + chatRoot.Left = chatBorder; chatRoot.Top = chatBorder; + chatRoot.Width = contentW; chatRoot.Height = contentH; + chatRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top + | AcDream.App.UI.AnchorEdges.Right | AcDream.App.UI.AnchorEdges.Bottom; + chatRoot.Draggable = false; chatRoot.Resizable = false; + chatFrame.AddChild(chatRoot); + _uiHost.Root.AddChild(chatFrame); Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x21000006)."); } else Console.WriteLine("[D.2b] chat: required role elements missing in 0x21000006."); diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index dc75d1ac..cc7b676b 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -31,6 +31,7 @@ public sealed class ChatWindowController // Element ids from chat LayoutDesc 0x21000006 (confirmed in Task D/G1). private const uint RootId = 0x1000000Eu; + private const uint ResizeBarId = 0x1000000Fu; // dat top resize bar (800px — dropped; nine-slice grips replace it) private const uint TranscriptPanelId = 0x10000010u; private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory private const uint TrackId = 0x10000012u; @@ -41,10 +42,12 @@ public sealed class ChatWindowController private const uint MaxMinId = 0x1000046Fu; // Scrollbar sprite ids from base layout 0x2100003E (confirmed in Task D). - private const uint TrackSprite = 0x06004C5Fu; - private const uint ThumbSprite = 0x06004C63u; - private const uint UpSprite = 0x06004C69u; - private const uint DownSprite = 0x06004C6Cu; + private const uint TrackSprite = 0x06004C5Fu; + private const uint ThumbSprite = 0x06004C63u; // 3-slice middle tile + private const uint ThumbTopSprite = 0x06004C60u; // 3-slice top cap + private const uint ThumbBotSprite = 0x06004C66u; // 3-slice bottom cap + private const uint UpSprite = 0x06004C69u; + private const uint DownSprite = 0x06004C6Cu; // Channel menu sprite ids (confirmed in chat element dump). private const uint MenuNormal = 0x06004D65u; @@ -130,7 +133,29 @@ public sealed class ChatWindowController return null; } - var c = new ChatWindowController { Root = layout.Root }; + // LayoutDesc 0x21000006 has SEVERAL top-level elements: the gmMainChatUI window + // (RootId 0x1000000E) PLUS stray auxiliary elements that are NOT part of the docked + // window — a separate Field+ListBox (0x1000001C/1D, the floaty scrollback), the + // talk-focus highlight strip (0x1000001E), and a scroll-button prototype (0x10000526). + // LayoutImporter.ImportInfos wraps all top-level elements in a synthetic Type-3 root, + // so using layout.Root would render the strays overlapping the real window (the + // red-striped garbage in the first live render). Use the gmMainChatUI window itself: + // GameWindow adds this to the host, which re-parents it out of the synthetic wrapper, + // orphaning the strays so they never draw. + var window = layout.FindElement(RootId) ?? layout.Root; + var c = new ChatWindowController { Root = window }; + + // Drop the dat top resize bar (0x1000000F): it is authored 800px wide and + // juts out of the content-width window. The host wraps this content in the + // universal nine-slice chrome, whose grips provide the resize affordance. + if (layout.FindElement(ResizeBarId) is { Parent: { } rbParent } resizeBar) + rbParent.RemoveChild(resizeBar); + + // Reclaim the 9px strip the dropped resize bar occupied (rows 0-8 of the root): + // grow the transcript panel up to the window top so its dark bg fills the strip. + // Otherwise the root element's brown bg shows through as a sliver along the top. + transcriptPanel.Top = 0f; + transcriptPanel.Height += 9f; // dat resize-bar height (0x1000000F H=9) // ── Transcript ─────────────────────────────────────────────────── // Place the behavioral transcript widget inside the transcript panel at the @@ -144,7 +169,7 @@ public sealed class ChatWindowController Anchors = ElementReader.ToAnchors(tInfo.Left, tInfo.Top, tInfo.Right, tInfo.Bottom), DatFont = datFont, Font = debugFont, - LinesProvider = () => BuildLines(vm), + LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont), }; transcriptPanel.AddChild(c.Transcript); @@ -178,10 +203,12 @@ public sealed class ChatWindowController Anchors = track.Anchors, Model = c.Transcript.Scroll, SpriteResolve = resolve, - TrackSprite = TrackSprite, - ThumbSprite = ThumbSprite, - UpSprite = UpSprite, - DownSprite = DownSprite, + TrackSprite = TrackSprite, + ThumbSprite = ThumbSprite, + ThumbTopSprite = ThumbTopSprite, + ThumbBotSprite = ThumbBotSprite, + UpSprite = UpSprite, + DownSprite = DownSprite, }; trackParent.RemoveChild(track); trackParent.AddChild(c.Scrollbar); @@ -220,6 +247,11 @@ public sealed class ChatWindowController // ── Max/min toggle — simplified gmMainChatUI::HandleMaximizeButton ── if (layout.FindElement(MaxMinId) is UiDatElement maxMinEl) { + // The dat puts max/min and the scrollbar up-button at the SAME X (both + // right-anchored), so at content width they overlap. Retail shows max/min + // just LEFT of the scrollbar column — shift it one button-width left. + if (track is not null) + maxMinEl.Left = track.Left - maxMinEl.Width; maxMinEl.ClickThrough = false; maxMinEl.OnClick = c.ToggleMaximize; } @@ -276,17 +308,66 @@ public sealed class ChatWindowController /// record format, applying retail-faithful /// per- colors. /// - private static IReadOnlyList BuildLines(ChatVM vm) + private static IReadOnlyList BuildLines( + ChatVM vm, UiChatView view, UiDatFont? datFont, BitmapFont? debugFont) { var detailed = vm.RecentLinesDetailed(); if (detailed.Count == 0) return Array.Empty(); - var result = new UiChatView.Line[detailed.Count]; - for (int i = 0; i < detailed.Count; i++) - result[i] = new UiChatView.Line(detailed[i].Text, RetailChatColor(detailed[i].Kind)); + // Word-wrap each message to the transcript's current pixel width (ports retail + // GlyphList::Recalculate @0x473800 — break at word boundaries when the line would + // exceed wrapWidth). Re-evaluated each frame so wrapping follows window resize. + float maxW = view.Width - 2f * view.Padding; + Func measure = + datFont is { } df ? s => df.MeasureWidth(s) + : debugFont is { } bf ? s => bf.MeasureWidth(s) + : s => s.Length * 7f; + + var result = new List(detailed.Count); + foreach (var d in detailed) + { + var color = RetailChatColor(d.Kind); + foreach (var frag in WrapText(d.Text, maxW, measure)) + result.Add(new UiChatView.Line(frag, color)); + } return result; } + /// + /// Greedy word-wrap: split into fragments that each fit in + /// pixels (per ), breaking at spaces. + /// A single word longer than the width overflows its own line (retail does not + /// hyphenate chat). Mirrors retail GlyphList::Recalculate's per-GlyphLine emission. + /// + public static IEnumerable WrapText(string text, float maxW, Func measure) + { + if (string.IsNullOrEmpty(text) || maxW <= 0f || measure(text) <= maxW) + { + yield return text; + yield break; + } + + var line = new System.Text.StringBuilder(); + foreach (var word in text.Split(' ')) + { + if (line.Length == 0) + { + line.Append(word); + } + else if (measure(line.ToString() + " " + word) > maxW) + { + yield return line.ToString(); + line.Clear(); + line.Append(word); + } + else + { + line.Append(' ').Append(word); + } + } + if (line.Length > 0) yield return line.ToString(); + } + /// /// Per- text color matching retail AC's channel coloring /// (observed from retail client screenshots and holtburger's chat.rs coloring). @@ -297,7 +378,7 @@ public sealed class ChatWindowController ChatKind.RangedSpeech => new(1f, 0.95f, 0.8f, 1f), // warm white — shout ChatKind.Channel => new(0.6f, 0.8f, 1f, 1f), // light blue — channel text ChatKind.Tell => new(1f, 0.5f, 1f, 1f), // magenta — whisper - ChatKind.System => new(1f, 1f, 0.45f, 1f), // yellow — system messages + ChatKind.System => new(0f, 1f, 0f, 1f), // green — system messages (retail ChatMessageType 5; AC2D eGreen {0,255,0}) ChatKind.Popup => new(1f, 0.85f, 0.4f, 1f), // amber — popup/broadcast ChatKind.Emote => new(0.8f, 0.8f, 0.7f, 1f), // off-white — emote ChatKind.SoulEmote => new(0.8f, 0.8f, 0.7f, 1f), // off-white — soul emote diff --git a/src/AcDream.App/UI/UiChannelMenu.cs b/src/AcDream.App/UI/UiChannelMenu.cs index 9726eb08..0d7445c8 100644 --- a/src/AcDream.App/UI/UiChannelMenu.cs +++ b/src/AcDream.App/UI/UiChannelMenu.cs @@ -5,32 +5,44 @@ using AcDream.UI.Abstractions; namespace AcDream.App.UI; /// -/// Chat channel selector (the "Chat ▸" button). Port of retail -/// UIElement_Menu as used by gmMainChatUI::InitTalkFocusMenu @0x4cdc50: -/// a button whose label is the active channel; clicking opens a popup of channels; -/// selecting one calls SetTalkFocus (here: ). +/// Chat channel selector — the "Chat" button + its talk-focus popup. Mirrors retail +/// gmMainChatUI::InitTalkFocusMenu @0x4cdc50: the button is labelled "Chat"; +/// clicking opens a TWO-COLUMN popup of 14 talk-focus items (Squelch, Tell to Selected, +/// Chat to All, Tell to Fellows, …). Selecting a channel item sets the active outbound +/// channel (retail SetTalkFocus; here ). The items +/// are code-populated exactly as retail populates them, not a dat-layout port. /// public sealed class UiChannelMenu : UiElement { - public readonly record struct Item(string Label, ChatChannelKind Channel); + /// One menu row: its label + the channel it selects (null = special/no-op + /// item such as Squelch or Tell-to-Selected, deferred). + public readonly record struct Item(string Label, ChatChannelKind? Channel); - /// Retail talk-focus channels (subset acdream's ChatInputParser routes). - public static readonly Item[] Channels = + /// The 14 retail talk-focus items in retail order — left column rows 0–6, + /// right column rows 7–13 (matching the live retail menu). + public static readonly Item[] Items = { - new("Say", ChatChannelKind.Say), - new("General", ChatChannelKind.General), - new("Trade", ChatChannelKind.Trade), - new("LFG", ChatChannelKind.Lfg), - new("Fellowship", ChatChannelKind.Fellowship), - new("Allegiance", ChatChannelKind.Allegiance), - new("Patron", ChatChannelKind.Patron), - new("Vassals", ChatChannelKind.Vassals), - new("Monarch", ChatChannelKind.Monarch), - new("Roleplay", ChatChannelKind.Roleplay), - new("Society", ChatChannelKind.Society), - new("Olthoi", ChatChannelKind.Olthoi), + new("Squelch (ignore)", null), // 0 special (squelch — deferred) + new("Tell to Selected", null), // 1 special (selected target — deferred) + new("Chat to All", ChatChannelKind.Say), // 2 + new("Tell to Fellows", ChatChannelKind.Fellowship), // 3 + new("Tell to General Chat", ChatChannelKind.General), // 4 + new("Tell to LFG Chat", ChatChannelKind.Lfg), // 5 + new("Tell to Society Chat", ChatChannelKind.Society), // 6 + new("Tell to Monarch", ChatChannelKind.Monarch), // 7 + new("Tell to Patron", ChatChannelKind.Patron), // 8 + new("Tell to Vassals", ChatChannelKind.Vassals), // 9 + new("Tell to Allegiance", ChatChannelKind.Allegiance), // 10 + new("Tell to Trade Chat", ChatChannelKind.Trade), // 11 + new("Tell to Roleplay Chat", ChatChannelKind.Roleplay), // 12 + new("Tell to Olthoi Chat", ChatChannelKind.Olthoi), // 13 }; + private const int Rows = 7; // items per column + private const float ItemH = 16f; // row height + private const float ColW = 150f; // column width (fits "Tell to Roleplay Chat") + + /// The channel the player's typed text currently goes to. public ChatChannelKind Selected { get; private set; } = ChatChannelKind.Say; public Action? OnChannelChanged { get; set; } @@ -39,41 +51,40 @@ public sealed class UiChannelMenu : UiElement public Func? SpriteResolve { get; set; } public uint NormalSprite { get; set; } public uint PressedSprite { get; set; } - public Vector4 TextColor { get; set; } = new(1f, 0.85f, 0.4f, 1f); + public Vector4 TextColor { get; set; } = new(1f, 0.92f, 0.72f, 1f); + /// Popup panel fill — the retail talk-focus menu is a warm tan/orange. + public Vector4 PopupColor { get; set; } = new(0.56f, 0.40f, 0.18f, 0.97f); private bool _open; - private const float ItemH = 16f; - private const float PopupW = 90f; + private static float PopupW => 2 * ColW; + private static float PopupH => Rows * ItemH; public UiChannelMenu() { CapturesPointerDrag = true; } - private string Label => FindLabel(Selected); - private static string FindLabel(ChatChannelKind k) - { - foreach (var it in Channels) if (it.Channel == k) return it.Label; - return "Chat"; - } - protected override void OnDraw(UiRenderContext ctx) { + // Button face + the "Chat" label (retail labels the talk-focus button "Chat"). if (SpriteResolve is { } resolve) { var (tex, _, _) = resolve(_open ? PressedSprite : NormalSprite); if (tex != 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); } - DrawLabel(ctx, Label + " >", 2f, (Height - LineH()) * 0.5f); + DrawLabel(ctx, "Chat", 4f, (Height - LineH()) * 0.5f); - if (_open) + if (!_open) return; + + // Two-column popup opening UPWARD from the button (chat sits at screen bottom). + float top = -PopupH; + ctx.DrawRect(0, top, PopupW, PopupH, PopupColor); + for (int i = 0; i < Items.Length; i++) { - float h = Channels.Length * ItemH; - float top = -h; // popup opens UPWARD (chat sits at screen bottom) - ctx.DrawRect(0, top, MathF.Max(Width, PopupW), h, new(0f, 0f, 0f, 0.85f)); - for (int i = 0; i < Channels.Length; i++) - DrawLabel(ctx, Channels[i].Label, 2f, top + i * ItemH); + int col = i / Rows, row = i % Rows; + DrawLabel(ctx, Items[i].Label, 4f + col * ColW, top + row * ItemH); } } private float LineH() => DatFont?.LineHeight ?? Font?.LineHeight ?? 14f; + private void DrawLabel(UiRenderContext ctx, string s, float x, float y) { if (DatFont is { } df) ctx.DrawStringDat(df, s, x, y, TextColor); @@ -81,29 +92,30 @@ public sealed class UiChannelMenu : UiElement } protected override bool OnHitTest(float lx, float ly) - => _open ? (lx >= 0 && lx < MathF.Max(Width, PopupW) - && ly >= -Channels.Length * ItemH && ly < Height) + => _open ? (lx >= 0 && lx < PopupW && ly >= -PopupH && ly < Height) : base.OnHitTest(lx, ly); public override bool OnEvent(in UiEvent e) { - if (e.Type == UiEventType.MouseDown) + 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 { - float ly = e.Data2; - if (_open && ly < 0) + int col = lx < ColW ? 0 : 1; + int row = (int)((ly + PopupH) / ItemH); + int idx = col * Rows + row; + if (row >= 0 && row < Rows && idx >= 0 && idx < Items.Length + && Items[idx].Channel is { } ch) { - int idx = (int)((ly + Channels.Length * ItemH) / ItemH); - if (idx >= 0 && idx < Channels.Length) - { - Selected = Channels[idx].Channel; - OnChannelChanged?.Invoke(Selected); - } - _open = false; - return true; + Selected = ch; + OnChannelChanged?.Invoke(ch); } - _open = !_open; + _open = false; return true; } - return false; + + _open = !_open; // toggle on button click + return true; } } diff --git a/src/AcDream.App/UI/UiChatScrollbar.cs b/src/AcDream.App/UI/UiChatScrollbar.cs index 6274f7b4..8c59f286 100644 --- a/src/AcDream.App/UI/UiChatScrollbar.cs +++ b/src/AcDream.App/UI/UiChatScrollbar.cs @@ -33,10 +33,15 @@ public sealed class UiChatScrollbar : UiElement /// Track background sprite id (0x06004C5F from layout 0x2100003E element 0x10000455). public uint TrackSprite { get; set; } - /// Thumb sprite id (3-slice middle tile: 0x06004C63; the widget draws - /// a single stretched sprite for simplicity — Task H can upgrade to 3-slice). + /// Thumb 3-slice MIDDLE tile sprite id (0x06004C63), tiled between the caps. public uint ThumbSprite { get; set; } + /// Thumb 3-slice TOP cap sprite id (0x06004C60, 3px tall). + public uint ThumbTopSprite { get; set; } + + /// Thumb 3-slice BOTTOM cap sprite id (0x06004C66, 3px tall). + public uint ThumbBotSprite { get; set; } + /// Up-arrow button sprite id (0x06004C69 Normal state, element 0x10000071). public uint UpSprite { get; set; } @@ -46,6 +51,9 @@ public sealed class UiChatScrollbar : UiElement /// Retail attribute 0x89 floor: minimum thumb height in pixels. private const float MinThumb = 8f; + /// Thumb cap height (native sprite height from base layout 0x2100003E). + private const float CapH = 3f; + /// Up/down button height in pixels. Matches element height 16px from /// the up/down button children in base layout 0x2100003E. private const float ButtonH = 16f; @@ -77,34 +85,59 @@ public sealed class UiChatScrollbar : UiElement { if (Model is not { } m || SpriteResolve is not { } resolve) return; - // Track background, full element bounds. - DrawSprite(ctx, resolve, TrackSprite, 0f, 0f, Width, Height); + // Track background — TILED vertically (retail DrawMode=Normal). The native track + // sprite (~16×32) repeats to fill the element height instead of stretch-distorting. + DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height); - // Up button — top ButtonH rows. + // Up button — top ButtonH rows (directional arrow art, drawn 1:1). DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH); // Down button — bottom ButtonH rows. DrawSprite(ctx, resolve, DownSprite, 0f, Height - ButtonH, Width, ButtonH); - // Thumb — only when content overflows the view. + // Thumb — only when content overflows the view. Retail 3-slice: top cap + + // tiled middle + bottom cap (base layout 0x2100003E thumb sub-elements + // 0x10000364/65/66). Falls back to a single tiled middle if the caps are unset + // or the thumb is too short to hold both caps. if (m.HasOverflow) { float trackTop = ButtonH; float trackLen = Height - 2f * ButtonH; var (ty, th) = ThumbRect(m, trackTop, trackLen); - DrawSprite(ctx, resolve, ThumbSprite, 0f, ty, Width, th); + if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && th >= 2f * CapH) + { + DrawSprite(ctx, resolve, ThumbTopSprite, 0f, ty, Width, CapH); + DrawTiled(ctx, resolve, ThumbSprite, 0f, ty + CapH, Width, th - 2f * CapH); + DrawSprite(ctx, resolve, ThumbBotSprite, 0f, ty + th - CapH, Width, CapH); + } + else + { + DrawTiled(ctx, resolve, ThumbSprite, 0f, ty, Width, th); + } } } + /// Draw a sprite stretched 1:1 to the dest rect. private void DrawSprite(UiRenderContext ctx, Func resolve, uint id, float x, float y, float w, float h) { - if (id == 0) return; + if (id == 0 || w <= 0f || h <= 0f) return; var (tex, _, _) = resolve(id); if (tex == 0) return; ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One); } + /// Draw a sprite TILED to fill the dest rect (UV-repeat at native size on + /// both axes — the UI texture is GL_REPEAT-wrapped). A native-width axis gives 1:1. + private void DrawTiled(UiRenderContext ctx, Func resolve, + uint id, float x, float y, float w, float h) + { + if (id == 0 || w <= 0f || h <= 0f) return; + var (tex, tw, th) = resolve(id); + if (tex == 0 || tw == 0 || th == 0) return; + ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, w / tw, h / th, Vector4.One); + } + public override bool OnEvent(in UiEvent e) { if (Model is not { } m) return false; diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index 937a52b2..a1c5f4ab 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -93,6 +93,11 @@ public abstract class UiElement /// Painter's-algorithm z-order within siblings. Higher = on top. public int ZOrder { get; set; } + /// Window opacity (0..1) multiplied into this element's and its + /// descendants' background + sprite draws (text stays opaque). 1 = fully opaque. + /// Set on a top-level window (e.g. the chat frame) for retail's translucent chat. + public float Opacity { get; set; } = 1f; + /// If true, a left-drag on this element (or a non-draggable child of /// it) repositions it as a movable window. Intended for top-level panels, /// whose Left/Top are screen coordinates (Root sits at the origin). @@ -179,8 +184,10 @@ public abstract class UiElement { if (!Visible) return; - // Translate into our local space. + // Translate into our local space + push this window's opacity (multiplies into + // descendants' sprite/rect draws; text bypasses the alpha so it stays sharp). ctx.PushTransform(Left, Top); + ctx.PushAlpha(Opacity); try { OnDraw(ctx); @@ -201,6 +208,7 @@ public abstract class UiElement } finally { + ctx.PopAlpha(); ctx.PopTransform(); } } @@ -220,9 +228,14 @@ public abstract class UiElement /// internal UiElement? HitTest(float localX, float localY) { - if (!Visible || !Enabled || ClickThrough) return null; + if (!Visible || !Enabled) return null; - // Children first, in reverse Z-order (topmost first). + // Children first, in reverse Z-order (topmost first). ClickThrough means + // THIS element is transparent to the pointer — but its children are NOT. + // A ClickThrough container (e.g. a UiDatElement panel that hosts the chat + // input / transcript) must still let the pointer reach its behavioral + // children, so the ClickThrough check happens AFTER the child walk, gating + // only whether THIS element claims the hit. if (_children.Count > 0) { var ordered = _children.ToArray(); @@ -235,6 +248,7 @@ public abstract class UiElement } } + if (ClickThrough) return null; return OnHitTest(localX, localY) ? this : null; } diff --git a/src/AcDream.App/UI/UiRenderContext.cs b/src/AcDream.App/UI/UiRenderContext.cs index db23174d..ecda1c1c 100644 --- a/src/AcDream.App/UI/UiRenderContext.cs +++ b/src/AcDream.App/UI/UiRenderContext.cs @@ -22,6 +22,25 @@ public sealed class UiRenderContext private readonly System.Collections.Generic.List _stack = new(); private Vector2 _current; + // Alpha (opacity) stack — a window pushes its Opacity so its background/sprite + // draws fade (retail's translucent-chat effect). Text draws bypass this (they go + // straight to TextRenderer), so text stays sharp over a translucent background. + private readonly System.Collections.Generic.List _alphaStack = new(); + private float _alpha = 1f; + + /// Current cumulative opacity multiplier applied to sprite + rect draws. + public float AlphaMod => _alpha; + + /// Multiply into the running opacity. Pair with . + public void PushAlpha(float a) { _alphaStack.Add(_alpha); _alpha *= a; } + + public void PopAlpha() + { + if (_alphaStack.Count == 0) return; + _alpha = _alphaStack[^1]; + _alphaStack.RemoveAt(_alphaStack.Count - 1); + } + public UiRenderContext(TextRenderer tr, Vector2 screenSize, BitmapFont? defaultFont = null) { TextRenderer = tr; @@ -48,15 +67,18 @@ public sealed class UiRenderContext // ── Pass-through draw helpers (add current translate) ────────────── public void DrawRect(float x, float y, float w, float h, Vector4 color) - => TextRenderer.DrawRect(_current.X + x, _current.Y + y, w, h, color); + => TextRenderer.DrawRect(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color)); public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f) - => TextRenderer.DrawRectOutline(_current.X + x, _current.Y + y, w, h, color, thickness); + => TextRenderer.DrawRectOutline(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color), thickness); public void DrawSprite(uint texture, float x, float y, float w, float h, float u0, float v0, float u1, float v1, Vector4 tint) => TextRenderer.DrawSprite(texture, - _current.X + x, _current.Y + y, w, h, u0, v0, u1, v1, tint); + _current.X + x, _current.Y + y, w, h, u0, v0, u1, v1, ApplyAlpha(tint)); + + /// Multiply the current window opacity into a draw color's alpha. + private Vector4 ApplyAlpha(Vector4 c) => _alpha >= 1f ? c : new Vector4(c.X, c.Y, c.Z, c.W * _alpha); public void DrawString(string text, float x, float y, Vector4 color, BitmapFont? font = null) { diff --git a/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs b/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs index c9f7b73b..59fe18f9 100644 --- a/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs +++ b/tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using AcDream.App.UI; using AcDream.UI.Abstractions; @@ -6,42 +7,40 @@ namespace AcDream.App.Tests.UI; public class UiChannelMenuTests { [Fact] - public void Channels_HasExpected12Entries() + public void Items_HasExpected14Entries() { - Assert.Equal(12, UiChannelMenu.Channels.Length); + // Retail gmMainChatUI::InitTalkFocusMenu: squelch + tell-selected + 12 channels. + Assert.Equal(14, UiChannelMenu.Items.Length); } [Fact] - public void Channels_FirstEntry_IsSay() + public void Items_FirstEntry_IsSquelch_Special() { - Assert.Equal(ChatChannelKind.Say, UiChannelMenu.Channels[0].Channel); - Assert.Equal("Say", UiChannelMenu.Channels[0].Label); + Assert.Equal("Squelch (ignore)", UiChannelMenu.Items[0].Label); + Assert.Null(UiChannelMenu.Items[0].Channel); // special item, no channel } [Fact] - public void Channels_LastEntry_IsOlthoi() + public void Items_LastEntry_IsOlthoi() { - var last = UiChannelMenu.Channels[^1]; + var last = UiChannelMenu.Items[^1]; + Assert.Equal("Tell to Olthoi Chat", last.Label); Assert.Equal(ChatChannelKind.Olthoi, last.Channel); - Assert.Equal("Olthoi", last.Label); } [Fact] - public void Channels_ContainsAllExpectedKinds() + public void Items_ContainAll12ChannelKinds() { - var kinds = new HashSet(UiChannelMenu.Channels.Select(c => c.Channel)); - Assert.Contains(ChatChannelKind.Say, kinds); - Assert.Contains(ChatChannelKind.General, kinds); - Assert.Contains(ChatChannelKind.Trade, kinds); - Assert.Contains(ChatChannelKind.Lfg, kinds); - Assert.Contains(ChatChannelKind.Fellowship, kinds); - Assert.Contains(ChatChannelKind.Allegiance, kinds); - Assert.Contains(ChatChannelKind.Patron, kinds); - Assert.Contains(ChatChannelKind.Vassals, kinds); - Assert.Contains(ChatChannelKind.Monarch, kinds); - Assert.Contains(ChatChannelKind.Roleplay, kinds); - Assert.Contains(ChatChannelKind.Society, kinds); - Assert.Contains(ChatChannelKind.Olthoi, kinds); + var kinds = new HashSet( + UiChannelMenu.Items.Where(i => i.Channel is not null).Select(i => i.Channel!.Value)); + foreach (var k in new[] + { + ChatChannelKind.Say, ChatChannelKind.General, ChatChannelKind.Trade, ChatChannelKind.Lfg, + ChatChannelKind.Fellowship, ChatChannelKind.Allegiance, ChatChannelKind.Patron, + ChatChannelKind.Vassals, ChatChannelKind.Monarch, ChatChannelKind.Roleplay, + ChatChannelKind.Society, ChatChannelKind.Olthoi, + }) + Assert.Contains(k, kinds); } [Fact] @@ -52,25 +51,50 @@ public class UiChannelMenuTests } [Fact] - public void OnChannelChanged_FiredWhenSelectionMadeViaEvent() + public void Select_LeftColumnItem_FiresChannel() { var menu = new UiChannelMenu { Width = 80f, Height = 18f }; - - // Open the popup (click inside the button area — Data2 >= 0). - var openEvt = new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5); + var openEvt = new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5); // open Assert.True(menu.OnEvent(openEvt)); - // Click on the second item (General) in the upward popup. - // Popup renders UPWARD: top = -(12 * 16) = -192. - // Item i=1 (General) occupies y in [-192 + 16, -192 + 32) = [-176, -160). - // A click at ly = -176 + 8 = -168 hits item index = (int)((-168 + 192) / 16) = (int)(24/16) = 1. ChatChannelKind? fired = null; menu.OnChannelChanged = k => fired = k; - var selectEvt = new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -168); - Assert.True(menu.OnEvent(selectEvt)); + // PopupH = 7*16 = 112, top = -112. "Chat to All" (Say) is index 2 = left col, row 2: + // y in [-112+32, -112+48) = [-80,-64). Click (lx=10 < ColW, ly=-72) → idx 2 → Say. + var selEvt = new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -72); + Assert.True(menu.OnEvent(selEvt)); + Assert.Equal(ChatChannelKind.Say, fired); + Assert.Equal(ChatChannelKind.Say, menu.Selected); + } - Assert.Equal(ChatChannelKind.General, fired); - Assert.Equal(ChatChannelKind.General, menu.Selected); + [Fact] + public void Select_RightColumnItem_FiresChannel() + { + var menu = new UiChannelMenu { Width = 80f, Height = 18f }; + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open + + ChatChannelKind? fired = null; + menu.OnChannelChanged = k => fired = k; + + // "Tell to Monarch" is index 7 = right col (lx >= ColW 150), row 0: + // y in [-112, -96). Click (lx=160, ly=-104) → col 1, row 0 → idx 7 → Monarch. + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 160, -104))); + Assert.Equal(ChatChannelKind.Monarch, fired); + Assert.Equal(ChatChannelKind.Monarch, menu.Selected); + } + + [Fact] + public void Select_SpecialItem_DoesNotFire() + { + var menu = new UiChannelMenu { Width = 80f, Height = 18f }; + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open + + int fired = 0; + menu.OnChannelChanged = _ => fired++; + + // "Squelch (ignore)" is index 0 = left col, row 0: y in [-112, -96). No channel. + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -104))); + Assert.Equal(0, fired); // special item is a no-op } }