feat(D.2b): chat polish — typing fix, opacity, scrollbar 3-slice, retail channel menu

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) <noreply@anthropic.com>
@
This commit is contained in:
Erik 2026-06-16 09:37:40 +02:00
parent 0ec36f6197
commit 1da697ec2a
7 changed files with 329 additions and 126 deletions

View file

@ -1857,19 +1857,36 @@ public sealed class GameWindow : IDisposable
// Ctrl+C / Ctrl+A on the transcript need the keyboard for clipboard + modifiers. // 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. // _uiHost.Keyboard is set by WireKeyboard above — it is non-null here.
chatController.Transcript.Keyboard = _uiHost.Keyboard; chatController.Transcript.Keyboard = _uiHost.Keyboard;
// Top-level retail window: user-positioned at the bottom-left, movable + resizable. // Wrap the dat content in the universal 8-piece beveled window chrome —
// KEEP the dat-authored size (do NOT override Width/Height) so the child anchors // the SAME UiNineSlicePanel the vitals window uses. The chat's own dat
// capture their dat margins on the first layout — the same reason the vitals root // layout only carries flat background sprites, so without this the window
// keeps its dat size. The user resizes/moves from there. // 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; var chatRoot = chatController.Root;
chatRoot.Left = 10; float contentW = 490f, contentH = chatRoot.Height; // dat-authored height
chatRoot.Top = 460; // bottom-left default; pending the user's visual review var chatFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
chatRoot.Anchors = AcDream.App.UI.AnchorEdges.None; {
chatRoot.Draggable = true; Left = 10, Top = 440,
chatRoot.Resizable = true; Width = contentW + 2 * chatBorder, Height = contentH + 2 * chatBorder,
chatRoot.MinWidth = 200f; MinWidth = 200f, MinHeight = 90f,
chatRoot.MinHeight = 80f; // Retail chat is translucent — fade the window's backgrounds/chrome
_uiHost.Root.AddChild(chatRoot); // (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)."); Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x21000006).");
} }
else Console.WriteLine("[D.2b] chat: required role elements missing in 0x21000006."); else Console.WriteLine("[D.2b] chat: required role elements missing in 0x21000006.");

View file

@ -31,6 +31,7 @@ public sealed class ChatWindowController
// Element ids from chat LayoutDesc 0x21000006 (confirmed in Task D/G1). // Element ids from chat LayoutDesc 0x21000006 (confirmed in Task D/G1).
private const uint RootId = 0x1000000Eu; 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 TranscriptPanelId = 0x10000010u;
private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory
private const uint TrackId = 0x10000012u; private const uint TrackId = 0x10000012u;
@ -41,10 +42,12 @@ public sealed class ChatWindowController
private const uint MaxMinId = 0x1000046Fu; private const uint MaxMinId = 0x1000046Fu;
// Scrollbar sprite ids from base layout 0x2100003E (confirmed in Task D). // Scrollbar sprite ids from base layout 0x2100003E (confirmed in Task D).
private const uint TrackSprite = 0x06004C5Fu; private const uint TrackSprite = 0x06004C5Fu;
private const uint ThumbSprite = 0x06004C63u; private const uint ThumbSprite = 0x06004C63u; // 3-slice middle tile
private const uint UpSprite = 0x06004C69u; private const uint ThumbTopSprite = 0x06004C60u; // 3-slice top cap
private const uint DownSprite = 0x06004C6Cu; 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). // Channel menu sprite ids (confirmed in chat element dump).
private const uint MenuNormal = 0x06004D65u; private const uint MenuNormal = 0x06004D65u;
@ -130,7 +133,29 @@ public sealed class ChatWindowController
return null; 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 ─────────────────────────────────────────────────── // ── Transcript ───────────────────────────────────────────────────
// Place the behavioral transcript widget inside the transcript panel at the // 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), Anchors = ElementReader.ToAnchors(tInfo.Left, tInfo.Top, tInfo.Right, tInfo.Bottom),
DatFont = datFont, DatFont = datFont,
Font = debugFont, Font = debugFont,
LinesProvider = () => BuildLines(vm), LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont),
}; };
transcriptPanel.AddChild(c.Transcript); transcriptPanel.AddChild(c.Transcript);
@ -178,10 +203,12 @@ public sealed class ChatWindowController
Anchors = track.Anchors, Anchors = track.Anchors,
Model = c.Transcript.Scroll, Model = c.Transcript.Scroll,
SpriteResolve = resolve, SpriteResolve = resolve,
TrackSprite = TrackSprite, TrackSprite = TrackSprite,
ThumbSprite = ThumbSprite, ThumbSprite = ThumbSprite,
UpSprite = UpSprite, ThumbTopSprite = ThumbTopSprite,
DownSprite = DownSprite, ThumbBotSprite = ThumbBotSprite,
UpSprite = UpSprite,
DownSprite = DownSprite,
}; };
trackParent.RemoveChild(track); trackParent.RemoveChild(track);
trackParent.AddChild(c.Scrollbar); trackParent.AddChild(c.Scrollbar);
@ -220,6 +247,11 @@ public sealed class ChatWindowController
// ── Max/min toggle — simplified gmMainChatUI::HandleMaximizeButton ── // ── Max/min toggle — simplified gmMainChatUI::HandleMaximizeButton ──
if (layout.FindElement(MaxMinId) is UiDatElement maxMinEl) 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.ClickThrough = false;
maxMinEl.OnClick = c.ToggleMaximize; maxMinEl.OnClick = c.ToggleMaximize;
} }
@ -276,17 +308,66 @@ public sealed class ChatWindowController
/// <see cref="UiChatView.Line"/> record format, applying retail-faithful /// <see cref="UiChatView.Line"/> record format, applying retail-faithful
/// per-<see cref="ChatKind"/> colors. /// per-<see cref="ChatKind"/> colors.
/// </summary> /// </summary>
private static IReadOnlyList<UiChatView.Line> BuildLines(ChatVM vm) private static IReadOnlyList<UiChatView.Line> BuildLines(
ChatVM vm, UiChatView view, UiDatFont? datFont, BitmapFont? debugFont)
{ {
var detailed = vm.RecentLinesDetailed(); var detailed = vm.RecentLinesDetailed();
if (detailed.Count == 0) return Array.Empty<UiChatView.Line>(); if (detailed.Count == 0) return Array.Empty<UiChatView.Line>();
var result = new UiChatView.Line[detailed.Count]; // Word-wrap each message to the transcript's current pixel width (ports retail
for (int i = 0; i < detailed.Count; i++) // GlyphList::Recalculate @0x473800 — break at word boundaries when the line would
result[i] = new UiChatView.Line(detailed[i].Text, RetailChatColor(detailed[i].Kind)); // exceed wrapWidth). Re-evaluated each frame so wrapping follows window resize.
float maxW = view.Width - 2f * view.Padding;
Func<string, float> measure =
datFont is { } df ? s => df.MeasureWidth(s)
: debugFont is { } bf ? s => bf.MeasureWidth(s)
: s => s.Length * 7f;
var result = new List<UiChatView.Line>(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; return result;
} }
/// <summary>
/// Greedy word-wrap: split <paramref name="text"/> into fragments that each fit in
/// <paramref name="maxW"/> pixels (per <paramref name="measure"/>), 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.
/// </summary>
public static IEnumerable<string> WrapText(string text, float maxW, Func<string, float> 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();
}
/// <summary> /// <summary>
/// Per-<see cref="ChatKind"/> text color matching retail AC's channel coloring /// Per-<see cref="ChatKind"/> text color matching retail AC's channel coloring
/// (observed from retail client screenshots and holtburger's chat.rs 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.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.Channel => new(0.6f, 0.8f, 1f, 1f), // light blue — channel text
ChatKind.Tell => new(1f, 0.5f, 1f, 1f), // magenta — whisper 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.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.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 ChatKind.SoulEmote => new(0.8f, 0.8f, 0.7f, 1f), // off-white — soul emote

View file

@ -5,32 +5,44 @@ using AcDream.UI.Abstractions;
namespace AcDream.App.UI; namespace AcDream.App.UI;
/// <summary> /// <summary>
/// Chat channel selector (the "Chat ▸" button). Port of retail /// Chat channel selector — the "Chat" button + its talk-focus popup. Mirrors retail
/// <c>UIElement_Menu</c> as used by <c>gmMainChatUI::InitTalkFocusMenu @0x4cdc50</c>: /// <c>gmMainChatUI::InitTalkFocusMenu @0x4cdc50</c>: the button is labelled "Chat";
/// a button whose label is the active channel; clicking opens a popup of channels; /// clicking opens a TWO-COLUMN popup of 14 talk-focus items (Squelch, Tell to Selected,
/// selecting one calls <c>SetTalkFocus</c> (here: <see cref="OnChannelChanged"/>). /// Chat to All, Tell to Fellows, …). Selecting a channel item sets the active outbound
/// channel (retail <c>SetTalkFocus</c>; here <see cref="OnChannelChanged"/>). The items
/// are code-populated exactly as retail populates them, not a dat-layout port.
/// </summary> /// </summary>
public sealed class UiChannelMenu : UiElement public sealed class UiChannelMenu : UiElement
{ {
public readonly record struct Item(string Label, ChatChannelKind Channel); /// <summary>One menu row: its label + the channel it selects (null = special/no-op
/// item such as Squelch or Tell-to-Selected, deferred).</summary>
public readonly record struct Item(string Label, ChatChannelKind? Channel);
/// <summary>Retail talk-focus channels (subset acdream's ChatInputParser routes).</summary> /// <summary>The 14 retail talk-focus items in retail order — left column rows 06,
public static readonly Item[] Channels = /// right column rows 713 (matching the live retail menu).</summary>
public static readonly Item[] Items =
{ {
new("Say", ChatChannelKind.Say), new("Squelch (ignore)", null), // 0 special (squelch — deferred)
new("General", ChatChannelKind.General), new("Tell to Selected", null), // 1 special (selected target — deferred)
new("Trade", ChatChannelKind.Trade), new("Chat to All", ChatChannelKind.Say), // 2
new("LFG", ChatChannelKind.Lfg), new("Tell to Fellows", ChatChannelKind.Fellowship), // 3
new("Fellowship", ChatChannelKind.Fellowship), new("Tell to General Chat", ChatChannelKind.General), // 4
new("Allegiance", ChatChannelKind.Allegiance), new("Tell to LFG Chat", ChatChannelKind.Lfg), // 5
new("Patron", ChatChannelKind.Patron), new("Tell to Society Chat", ChatChannelKind.Society), // 6
new("Vassals", ChatChannelKind.Vassals), new("Tell to Monarch", ChatChannelKind.Monarch), // 7
new("Monarch", ChatChannelKind.Monarch), new("Tell to Patron", ChatChannelKind.Patron), // 8
new("Roleplay", ChatChannelKind.Roleplay), new("Tell to Vassals", ChatChannelKind.Vassals), // 9
new("Society", ChatChannelKind.Society), new("Tell to Allegiance", ChatChannelKind.Allegiance), // 10
new("Olthoi", ChatChannelKind.Olthoi), 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")
/// <summary>The channel the player's typed text currently goes to.</summary>
public ChatChannelKind Selected { get; private set; } = ChatChannelKind.Say; public ChatChannelKind Selected { get; private set; } = ChatChannelKind.Say;
public Action<ChatChannelKind>? OnChannelChanged { get; set; } public Action<ChatChannelKind>? OnChannelChanged { get; set; }
@ -39,41 +51,40 @@ public sealed class UiChannelMenu : UiElement
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; } public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
public uint NormalSprite { get; set; } public uint NormalSprite { get; set; }
public uint PressedSprite { 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);
/// <summary>Popup panel fill — the retail talk-focus menu is a warm tan/orange.</summary>
public Vector4 PopupColor { get; set; } = new(0.56f, 0.40f, 0.18f, 0.97f);
private bool _open; private bool _open;
private const float ItemH = 16f; private static float PopupW => 2 * ColW;
private const float PopupW = 90f; private static float PopupH => Rows * ItemH;
public UiChannelMenu() { CapturesPointerDrag = true; } 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) protected override void OnDraw(UiRenderContext ctx)
{ {
// Button face + the "Chat" label (retail labels the talk-focus button "Chat").
if (SpriteResolve is { } resolve) if (SpriteResolve is { } resolve)
{ {
var (tex, _, _) = resolve(_open ? PressedSprite : NormalSprite); var (tex, _, _) = resolve(_open ? PressedSprite : NormalSprite);
if (tex != 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); 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; int col = i / Rows, row = i % Rows;
float top = -h; // popup opens UPWARD (chat sits at screen bottom) DrawLabel(ctx, Items[i].Label, 4f + col * ColW, top + row * ItemH);
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);
} }
} }
private float LineH() => DatFont?.LineHeight ?? Font?.LineHeight ?? 14f; private float LineH() => DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
private void DrawLabel(UiRenderContext ctx, string s, float x, float y) private void DrawLabel(UiRenderContext ctx, string s, float x, float y)
{ {
if (DatFont is { } df) ctx.DrawStringDat(df, s, x, y, TextColor); 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) protected override bool OnHitTest(float lx, float ly)
=> _open ? (lx >= 0 && lx < MathF.Max(Width, PopupW) => _open ? (lx >= 0 && lx < PopupW && ly >= -PopupH && ly < Height)
&& ly >= -Channels.Length * ItemH && ly < Height)
: base.OnHitTest(lx, ly); : base.OnHitTest(lx, ly);
public override bool OnEvent(in UiEvent e) 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; int col = lx < ColW ? 0 : 1;
if (_open && ly < 0) 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); Selected = ch;
if (idx >= 0 && idx < Channels.Length) OnChannelChanged?.Invoke(ch);
{
Selected = Channels[idx].Channel;
OnChannelChanged?.Invoke(Selected);
}
_open = false;
return true;
} }
_open = !_open; _open = false;
return true; return true;
} }
return false;
_open = !_open; // toggle on button click
return true;
} }
} }

View file

@ -33,10 +33,15 @@ public sealed class UiChatScrollbar : UiElement
/// <summary>Track background sprite id (0x06004C5F from layout 0x2100003E element 0x10000455).</summary> /// <summary>Track background sprite id (0x06004C5F from layout 0x2100003E element 0x10000455).</summary>
public uint TrackSprite { get; set; } public uint TrackSprite { get; set; }
/// <summary>Thumb sprite id (3-slice middle tile: 0x06004C63; the widget draws /// <summary>Thumb 3-slice MIDDLE tile sprite id (0x06004C63), tiled between the caps.</summary>
/// a single stretched sprite for simplicity — Task H can upgrade to 3-slice).</summary>
public uint ThumbSprite { get; set; } public uint ThumbSprite { get; set; }
/// <summary>Thumb 3-slice TOP cap sprite id (0x06004C60, 3px tall).</summary>
public uint ThumbTopSprite { get; set; }
/// <summary>Thumb 3-slice BOTTOM cap sprite id (0x06004C66, 3px tall).</summary>
public uint ThumbBotSprite { get; set; }
/// <summary>Up-arrow button sprite id (0x06004C69 Normal state, element 0x10000071).</summary> /// <summary>Up-arrow button sprite id (0x06004C69 Normal state, element 0x10000071).</summary>
public uint UpSprite { get; set; } public uint UpSprite { get; set; }
@ -46,6 +51,9 @@ public sealed class UiChatScrollbar : UiElement
/// <summary>Retail attribute 0x89 floor: minimum thumb height in pixels.</summary> /// <summary>Retail attribute 0x89 floor: minimum thumb height in pixels.</summary>
private const float MinThumb = 8f; private const float MinThumb = 8f;
/// <summary>Thumb cap height (native sprite height from base layout 0x2100003E).</summary>
private const float CapH = 3f;
/// <summary>Up/down button height in pixels. Matches element height 16px from /// <summary>Up/down button height in pixels. Matches element height 16px from
/// the up/down button children in base layout 0x2100003E.</summary> /// the up/down button children in base layout 0x2100003E.</summary>
private const float ButtonH = 16f; private const float ButtonH = 16f;
@ -77,34 +85,59 @@ public sealed class UiChatScrollbar : UiElement
{ {
if (Model is not { } m || SpriteResolve is not { } resolve) return; if (Model is not { } m || SpriteResolve is not { } resolve) return;
// Track background, full element bounds. // Track background — TILED vertically (retail DrawMode=Normal). The native track
DrawSprite(ctx, resolve, TrackSprite, 0f, 0f, Width, Height); // 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); DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH);
// Down button — bottom ButtonH rows. // Down button — bottom ButtonH rows.
DrawSprite(ctx, resolve, DownSprite, 0f, Height - ButtonH, Width, ButtonH); 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) if (m.HasOverflow)
{ {
float trackTop = ButtonH; float trackTop = ButtonH;
float trackLen = Height - 2f * ButtonH; float trackLen = Height - 2f * ButtonH;
var (ty, th) = ThumbRect(m, trackTop, trackLen); 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);
}
} }
} }
/// <summary>Draw a sprite stretched 1:1 to the dest rect.</summary>
private void DrawSprite(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve, private void DrawSprite(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint id, float x, float y, float w, float h) 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); var (tex, _, _) = resolve(id);
if (tex == 0) return; if (tex == 0) return;
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One); ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One);
} }
/// <summary>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.</summary>
private void DrawTiled(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> 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) public override bool OnEvent(in UiEvent e)
{ {
if (Model is not { } m) return false; if (Model is not { } m) return false;

View file

@ -93,6 +93,11 @@ public abstract class UiElement
/// <summary>Painter's-algorithm z-order within siblings. Higher = on top.</summary> /// <summary>Painter's-algorithm z-order within siblings. Higher = on top.</summary>
public int ZOrder { get; set; } public int ZOrder { get; set; }
/// <summary>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.</summary>
public float Opacity { get; set; } = 1f;
/// <summary>If true, a left-drag on this element (or a non-draggable child of /// <summary>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, /// it) repositions it as a movable window. Intended for top-level panels,
/// whose Left/Top are screen coordinates (Root sits at the origin).</summary> /// whose Left/Top are screen coordinates (Root sits at the origin).</summary>
@ -179,8 +184,10 @@ public abstract class UiElement
{ {
if (!Visible) return; 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.PushTransform(Left, Top);
ctx.PushAlpha(Opacity);
try try
{ {
OnDraw(ctx); OnDraw(ctx);
@ -201,6 +208,7 @@ public abstract class UiElement
} }
finally finally
{ {
ctx.PopAlpha();
ctx.PopTransform(); ctx.PopTransform();
} }
} }
@ -220,9 +228,14 @@ public abstract class UiElement
/// </summary> /// </summary>
internal UiElement? HitTest(float localX, float localY) 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) if (_children.Count > 0)
{ {
var ordered = _children.ToArray(); var ordered = _children.ToArray();
@ -235,6 +248,7 @@ public abstract class UiElement
} }
} }
if (ClickThrough) return null;
return OnHitTest(localX, localY) ? this : null; return OnHitTest(localX, localY) ? this : null;
} }

View file

@ -22,6 +22,25 @@ public sealed class UiRenderContext
private readonly System.Collections.Generic.List<Vector2> _stack = new(); private readonly System.Collections.Generic.List<Vector2> _stack = new();
private Vector2 _current; 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<float> _alphaStack = new();
private float _alpha = 1f;
/// <summary>Current cumulative opacity multiplier applied to sprite + rect draws.</summary>
public float AlphaMod => _alpha;
/// <summary>Multiply <paramref name="a"/> into the running opacity. Pair with <see cref="PopAlpha"/>.</summary>
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) public UiRenderContext(TextRenderer tr, Vector2 screenSize, BitmapFont? defaultFont = null)
{ {
TextRenderer = tr; TextRenderer = tr;
@ -48,15 +67,18 @@ public sealed class UiRenderContext
// ── Pass-through draw helpers (add current translate) ────────────── // ── Pass-through draw helpers (add current translate) ──────────────
public void DrawRect(float x, float y, float w, float h, Vector4 color) 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) 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, public void DrawSprite(uint texture, float x, float y, float w, float h,
float u0, float v0, float u1, float v1, Vector4 tint) float u0, float v0, float u1, float v1, Vector4 tint)
=> TextRenderer.DrawSprite(texture, => 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));
/// <summary>Multiply the current window opacity into a draw color's alpha.</summary>
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) public void DrawString(string text, float x, float y, Vector4 color, BitmapFont? font = null)
{ {

View file

@ -1,3 +1,4 @@
using System.Linq;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.UI.Abstractions; using AcDream.UI.Abstractions;
@ -6,42 +7,40 @@ namespace AcDream.App.Tests.UI;
public class UiChannelMenuTests public class UiChannelMenuTests
{ {
[Fact] [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] [Fact]
public void Channels_FirstEntry_IsSay() public void Items_FirstEntry_IsSquelch_Special()
{ {
Assert.Equal(ChatChannelKind.Say, UiChannelMenu.Channels[0].Channel); Assert.Equal("Squelch (ignore)", UiChannelMenu.Items[0].Label);
Assert.Equal("Say", UiChannelMenu.Channels[0].Label); Assert.Null(UiChannelMenu.Items[0].Channel); // special item, no channel
} }
[Fact] [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(ChatChannelKind.Olthoi, last.Channel);
Assert.Equal("Olthoi", last.Label);
} }
[Fact] [Fact]
public void Channels_ContainsAllExpectedKinds() public void Items_ContainAll12ChannelKinds()
{ {
var kinds = new HashSet<ChatChannelKind>(UiChannelMenu.Channels.Select(c => c.Channel)); var kinds = new HashSet<ChatChannelKind>(
Assert.Contains(ChatChannelKind.Say, kinds); UiChannelMenu.Items.Where(i => i.Channel is not null).Select(i => i.Channel!.Value));
Assert.Contains(ChatChannelKind.General, kinds); foreach (var k in new[]
Assert.Contains(ChatChannelKind.Trade, kinds); {
Assert.Contains(ChatChannelKind.Lfg, kinds); ChatChannelKind.Say, ChatChannelKind.General, ChatChannelKind.Trade, ChatChannelKind.Lfg,
Assert.Contains(ChatChannelKind.Fellowship, kinds); ChatChannelKind.Fellowship, ChatChannelKind.Allegiance, ChatChannelKind.Patron,
Assert.Contains(ChatChannelKind.Allegiance, kinds); ChatChannelKind.Vassals, ChatChannelKind.Monarch, ChatChannelKind.Roleplay,
Assert.Contains(ChatChannelKind.Patron, kinds); ChatChannelKind.Society, ChatChannelKind.Olthoi,
Assert.Contains(ChatChannelKind.Vassals, kinds); })
Assert.Contains(ChatChannelKind.Monarch, kinds); Assert.Contains(k, kinds);
Assert.Contains(ChatChannelKind.Roleplay, kinds);
Assert.Contains(ChatChannelKind.Society, kinds);
Assert.Contains(ChatChannelKind.Olthoi, kinds);
} }
[Fact] [Fact]
@ -52,25 +51,50 @@ public class UiChannelMenuTests
} }
[Fact] [Fact]
public void OnChannelChanged_FiredWhenSelectionMadeViaEvent() public void Select_LeftColumnItem_FiresChannel()
{ {
var menu = new UiChannelMenu { Width = 80f, Height = 18f }; var menu = new UiChannelMenu { Width = 80f, Height = 18f };
var openEvt = new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5); // open
// Open the popup (click inside the button area — Data2 >= 0).
var openEvt = new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5);
Assert.True(menu.OnEvent(openEvt)); 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; ChatChannelKind? fired = null;
menu.OnChannelChanged = k => fired = k; menu.OnChannelChanged = k => fired = k;
var selectEvt = new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -168); // PopupH = 7*16 = 112, top = -112. "Chat to All" (Say) is index 2 = left col, row 2:
Assert.True(menu.OnEvent(selectEvt)); // 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); [Fact]
Assert.Equal(ChatChannelKind.General, menu.Selected); 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
} }
} }