acdream/src/AcDream.App/UI/UiElement.cs
Erik 1da697ec2a @
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>
@
2026-06-16 09:37:40 +02:00

301 lines
13 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>Which parent edges a child keeps a fixed margin to on resize.
/// Left+Right ⇒ width stretches; Top+Bottom ⇒ height stretches.</summary>
[System.Flags]
public enum AnchorEdges { None = 0, Left = 1, Top = 2, Right = 4, Bottom = 8 }
/// <summary>
/// Base class for every UI widget in the retained-mode tree.
///
/// Design notes:
/// - Retail AC delegates widget semantics to the external
/// <c>keystone.dll</c> library (see
/// <c>docs/research/retail-ui/02-class-hierarchy.md</c> — there is no
/// widget hierarchy inside <c>acclient.exe</c> itself). We implement
/// our own retained-mode toolkit here, matching the <i>behavior</i>
/// described in the decompile without trying to byte-match Keystone's
/// internal class layout.
/// - Events use the retail-faithful <see cref="UiEvent"/> struct and
/// the <see cref="UiEventType"/> constants so that hand-ported panel
/// code can use the same magic numbers the decompiled C uses
/// (e.g. <c>if (e.Type == 0x15) ...</c> for drag-begin).
/// - Hit-testing is children-first (topmost wins) with Z-order tie
/// breaking; drawing is back-to-front so later children appear on top.
/// - Coordinates are in <b>screen pixels</b>, origin top-left.
/// <see cref="Bounds"/> is in the parent's local coordinate space.
/// </summary>
public abstract class UiElement
{
// ── Identity ─────────────────────────────────────────────────────────
/// <summary>
/// Unique 32-bit event ID. Retail uses the range <c>0x10000000+</c>
/// for custom app events (see
/// <c>docs/research/retail-ui/04-input-events.md §3</c>). Assigned
/// by <see cref="UiRoot"/> when the element is added to the tree.
/// </summary>
public uint EventId { get; internal set; }
/// <summary>Human-readable name for debugging / FindByName.</summary>
public string? Name { get; init; }
// ── Geometry ────────────────────────────────────────────────────────
/// <summary>X in the parent's local pixel space.</summary>
public float Left { get; set; }
public float Top { get; set; }
public float Width { get; set; }
public float Height { get; set; }
/// <summary>Absolute (screen-space) top-left, computed by walking Parent.</summary>
public Vector2 ScreenPosition
{
get
{
var p = new Vector2(Left, Top);
var parent = Parent;
while (parent is not null)
{
p += new Vector2(parent.Left, parent.Top);
parent = parent.Parent;
}
return p;
}
}
// ── State flags ─────────────────────────────────────────────────────
public bool Visible { get; set; } = true;
public bool Enabled { get; set; } = true;
/// <summary>
/// If true, <see cref="HitTest"/> skips this element — the event
/// passes through to whatever is behind. Used by decoration widgets
/// (portrait frames, ornamental dividers).
/// </summary>
public bool ClickThrough { get; set; }
/// <summary>
/// If true, <see cref="UiRoot"/> will set focus here on click,
/// routing WM_KEYDOWN / WM_CHAR to <see cref="OnEvent"/> as
/// <see cref="UiEventType.KeyDown"/> / <see cref="UiEventType.Char"/>.
/// </summary>
public bool AcceptsFocus { get; set; }
/// <summary>
/// True if this is a text-entry (edit box); used by focus routing
/// to suppress global hotkeys while typing.
/// </summary>
public bool IsEditControl { get; set; }
/// <summary>Painter's-algorithm z-order within siblings. Higher = on top.</summary>
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
/// it) repositions it as a movable window. Intended for top-level panels,
/// whose Left/Top are screen coordinates (Root sits at the origin).</summary>
public bool Draggable { get; set; }
/// <summary>If true, a left-drag starting near this element's edge/corner
/// resizes it (window resize). Intended for top-level panels.</summary>
public bool Resizable { get; set; }
/// <summary>If true, a left-drag starting on this element is delivered to the
/// element (e.g. text selection) instead of moving/resizing an ancestor window.
/// Edge resize on a resizable ancestor still wins — only the interior move /
/// drag-drop candidacy is suppressed in favour of the element's own handling.</summary>
public bool CapturesPointerDrag { get; set; }
/// <summary>Minimum size enforced while resizing.</summary>
public float MinWidth { get; set; } = 40f;
public float MinHeight { get; set; } = 40f;
/// <summary>Allow horizontal (width) resize. Ignored unless <see cref="Resizable"/>.</summary>
public bool ResizeX { get; set; } = true;
/// <summary>Allow vertical (height) resize. Ignored unless <see cref="Resizable"/>.</summary>
public bool ResizeY { get; set; } = true;
/// <summary>Edges this element anchors to in its parent. Default Left|Top
/// (pinned top-left, fixed size — no reflow). Left|Right stretches width.</summary>
public AnchorEdges Anchors { get; set; } = AnchorEdges.Left | AnchorEdges.Top;
// ── Tree structure ──────────────────────────────────────────────────
public UiElement? Parent { get; private set; }
private readonly List<UiElement> _children = new();
public IReadOnlyList<UiElement> Children => _children;
public virtual void AddChild(UiElement child)
{
if (child.Parent is not null) child.Parent.RemoveChild(child);
child.Parent = this;
_children.Add(child);
}
public virtual bool RemoveChild(UiElement child)
{
if (!_children.Remove(child)) return false;
child.Parent = null;
return true;
}
// ── Virtual overrides ───────────────────────────────────────────────
/// <summary>
/// Draw THIS element (not its children). Children are composited by
/// <see cref="UiRoot"/> after this returns.
/// </summary>
protected virtual void OnDraw(UiRenderContext ctx) { }
/// <summary>Per-frame tick (animations, timers, caret blink).</summary>
protected virtual void OnTick(double deltaSeconds) { }
/// <summary>
/// Custom hit-test override. Default is a rectangle containment
/// check on (<see cref="Width"/>, <see cref="Height"/>).
/// </summary>
protected virtual bool OnHitTest(float localX, float localY)
=> localX >= 0f && localX < Width && localY >= 0f && localY < Height;
/// <summary>
/// Event handler. Return <c>true</c> to consume the event (the
/// <see cref="UiRoot"/> will stop propagation). Return <c>false</c>
/// to let ancestors / fall-through handle it.
/// </summary>
public virtual bool OnEvent(in UiEvent e) => false;
/// <summary>
/// Tooltip text for this widget. Retail fires event 0x07 after
/// ~1000ms hover, then queries the widget's virtual "GetString"
/// (vtable +0x88) to render the tooltip body.
/// </summary>
public virtual string? GetTooltipText() => null;
// ── Framework entry points (internal, called by UiRoot) ─────────────
internal void DrawSelfAndChildren(UiRenderContext ctx)
{
if (!Visible) return;
// 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);
// Anchor layout: reflow children to this element's current size.
for (int i = 0; i < _children.Count; i++)
_children[i].ApplyAnchor(Width, Height);
// Children painted back-to-front (lowest ZOrder first).
if (_children.Count > 0)
{
// Avoid LINQ allocation by copying to a temp array and sorting.
var ordered = _children.ToArray();
Array.Sort(ordered, static (a, b) => a.ZOrder.CompareTo(b.ZOrder));
for (int i = 0; i < ordered.Length; i++)
ordered[i].DrawSelfAndChildren(ctx);
}
}
finally
{
ctx.PopAlpha();
ctx.PopTransform();
}
}
internal void TickSelfAndChildren(double dt)
{
if (!Visible) return;
OnTick(dt);
for (int i = 0; i < _children.Count; i++)
_children[i].TickSelfAndChildren(dt);
}
/// <summary>
/// Top-down, children-first hit-test. <paramref name="localX"/> /
/// <paramref name="localY"/> are in THIS element's local space.
/// Returns the topmost descendant (or this) at the point, or null.
/// </summary>
internal UiElement? HitTest(float localX, float localY)
{
if (!Visible || !Enabled) return null;
// 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();
Array.Sort(ordered, static (a, b) => b.ZOrder.CompareTo(a.ZOrder));
for (int i = 0; i < ordered.Length; i++)
{
var c = ordered[i];
var childHit = c.HitTest(localX - c.Left, localY - c.Top);
if (childHit is not null) return childHit;
}
}
if (ClickThrough) return null;
return OnHitTest(localX, localY) ? this : null;
}
// ── Anchor layout ────────────────────────────────────────────────────
private bool _anchorCaptured;
private float _amL, _amT, _amR, _amB, _aw0, _ah0;
/// <summary>Reposition/resize this element per <see cref="Anchors"/>, keeping
/// the margins captured (at first layout / design size) to each anchored edge.
/// Called by the parent each frame before drawing children.</summary>
internal void ApplyAnchor(float parentW, float parentH)
{
if (Anchors == AnchorEdges.None) return;
if (!_anchorCaptured)
{
_amL = Left; _amT = Top;
_amR = parentW - (Left + Width);
_amB = parentH - (Top + Height);
_aw0 = Width; _ah0 = Height;
_anchorCaptured = true;
}
var (x, y, w, h) = ComputeAnchoredRect(Anchors, _amL, _amT, _amR, _amB, _aw0, _ah0, parentW, parentH);
Left = x; Top = y; Width = w; Height = h;
}
/// <summary>Compute an anchored child rect. Left&amp;Right ⇒ stretch width
/// (keep both margins); Right only ⇒ pin to right at fixed width; otherwise
/// pin left at fixed width. Same logic vertically.</summary>
public static (float x, float y, float w, float h) ComputeAnchoredRect(
AnchorEdges a, float mL, float mT, float mR, float mB,
float w0, float h0, float parentW, float parentH)
{
bool l = (a & AnchorEdges.Left) != 0, r = (a & AnchorEdges.Right) != 0;
float x, w;
if (l && r) { x = mL; w = parentW - mR - mL; }
else if (r) { w = w0; x = parentW - mR - w0; }
else { x = mL; w = w0; }
bool t = (a & AnchorEdges.Top) != 0, b = (a & AnchorEdges.Bottom) != 0;
float y, h;
if (t && b) { y = mT; h = parentH - mB - mT; }
else if (b) { h = h0; y = parentH - mB - h0; }
else { y = mT; h = h0; }
if (w < 0) w = 0;
if (h < 0) h = 0;
return (x, y, w, h);
}
}