acdream/src/AcDream.App/UI/UiPanel.cs
Erik b9674b1f1e fix(mosstank): unclickable button, empty skill list, dev font, and chat output
Four defects from the first in-world look, three of them with a definite root
cause rather than a plausible one.

**The Buff button did nothing.** Not a hit-testing problem -- the pointer found
the button perfectly. UiRoot's press handling asks the pressed widget whether
it owns the pointer; a widget that does not claim the press falls through to
"move the ancestor window", and a window drag returns early on release without
ever emitting a Click. UiButton and UiClickablePanel both override
HandlesClick for exactly this reason; UiSimpleButton never did. Latent since
that class was written, and invisible until it was put inside a draggable
window -- which is precisely what a markup plugin panel is.

Found by reproducing it headlessly through the real UiRoot dispatcher rather
than by reasoning about it: MarkupPanelClickTests drives press-and-release over
the button and asserts the bound action ran, with a separate test asserting the
pointer finds the button at all, so a future failure says which half broke.
My earlier guess -- that a modal at character select was swallowing the click
-- was wrong, and the screenshot of the panel live in world disproved it.

**"0 trained skills".** The skill-name table was read in OnLoad *before*
GameWindowCompositionPipeline.Run, which is what publishes the DAT collection,
so _dats was still null, the whole block was skipped, and the surface reported
an empty skill list with nothing to explain it. Bound in PublishDatCollection
instead -- the moment the data exists -- so it cannot run early again whatever
the phase ordering does, and a genuinely missing SkillTable now says so.

**Plugin text used the development bitmap font.** UiLabel and UiSimpleButton
gained a DatFont, and MarkupDocument now takes the retail interface font from
the host, so plugin panels render through the same glyph path (including
retail's two-plane outline) as authored panels.

**MossTank now writes to chat.** New BCL-only IPluginChat routes to retail's
ClientLocal log type (0x1A) -- the channel the client uses for its own notices,
local to this client, so a plugin cannot speak in the player's name. MossTank
announces the start, the finish with a cast count, and a stall.

Not addressed here: the cursor showing blue rather than amber. Traced but not
fixed -- CursorFeedbackController picks the cursor family from combat mode, and
CombatMode.Magic selects the blue Magic cursor where Default is amber. That is
a combat-mode question, unrelated to this change, and worth its own look rather
than a speculative fix folded in here.

Solution builds clean; 14,437 tests pass on the standard hermetic lane filter,
0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 18:27:28 +02:00

282 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Rectangular container with an optional translucent background and
/// border. Used as the base of every retail panel (attributes, chat,
/// inventory, login, etc.).
///
/// Retail has panel background art stored as 9-slice sprite assets in
/// the <c>0x06xxxxxx</c> RenderSurface range, and composed via
/// <c>LayoutDesc</c> (<c>0x21xxxxxx</c>) trees. Until our
/// <c>AcFont</c>/<c>UiSpriteBatch</c> consumes those directly, we draw a
/// simple translucent rectangle so panels are visible during development.
/// </summary>
public class UiPanel : UiElement
{
/// <summary>Background fill color. Set <see cref="Vector4.Zero"/> to skip.</summary>
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.55f);
/// <summary>Border color. Set <see cref="Vector4.Zero"/> to skip.</summary>
public Vector4 BorderColor { get; set; } = new(0.15f, 0.15f, 0.2f, 0.8f);
public float BorderThickness { get; set; } = 1f;
/// <summary>Optional dat RenderSurface id for the panel background sprite, drawn
/// in place of (or alongside) <see cref="BackgroundColor"/>. 0 = none.
/// When set, the sprite is stretched to fill the panel rect.
/// Used by the attribute-list selected-row highlight (sprite 0x06001397 = Button state 6).</summary>
public uint BackgroundSprite { get; set; }
/// <summary>Resolves a dat RenderSurface id to (GL tex handle, pixel width, pixel height).
/// Required when <see cref="BackgroundSprite"/> is non-zero.</summary>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
protected override void OnDraw(UiRenderContext ctx)
{
if (BackgroundSprite != 0 && SpriteResolve is { } sr)
{
var (tex, tw, th) = sr(BackgroundSprite);
if (tex != 0 && tw != 0 && th != 0)
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
}
else if (BackgroundColor.W > 0f)
{
// Panel fills are backgrounds. Draw them through the sprite/fill
// bucket so children that render as sprites/dat-font glyphs stay on
// top in painter order; DrawRect flushes after sprites and would
// cover text/icons.
ctx.DrawFill(0, 0, Width, Height, BackgroundColor);
}
if (BorderColor.W > 0f && BorderThickness > 0f)
ctx.DrawRectOutline(0, 0, Width, Height, BorderColor, BorderThickness);
}
}
/// <summary>
/// Static text label. Draws a single line of text using the context's
/// default font (or an override). Does not consume input.
///
/// Equivalent retail primitive: wide-string appended to a CString via
/// <c>FUN_0040b8f0</c> then drawn by the widget's draw method through
/// <c>FUN_00698330</c>.
/// </summary>
public class UiLabel : UiElement
{
public string Text { get; set; } = string.Empty;
public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f);
/// <summary>
/// Optional live text reader, evaluated each draw and preferred over
/// <see cref="Text"/> when set. Markup <c>{Binding}</c> labels use this so
/// the displayed value tracks the binding object instead of freezing at
/// whatever it held when the panel was built — which is what a plugin
/// status line needs.
/// </summary>
public Func<string?>? TextSource { get; set; }
/// <summary>
/// Retail dat font. When set the label renders through the same glyph path
/// every authored panel uses, so plugin text matches the rest of the
/// interface instead of falling back to the development bitmap font.
/// </summary>
public UiDatFont? DatFont { get; set; }
/// <summary>Two-plane glyph outline, as retail draws interface text.</summary>
public bool Outline { get; set; } = true;
public UiLabel() { ClickThrough = true; }
protected override void OnDraw(UiRenderContext ctx)
{
string text = TextSource?.Invoke() ?? Text;
if (DatFont is { } dat)
ctx.DrawStringDat(dat, text, 0, 0, TextColor, Outline);
else
ctx.DrawString(text, 0, 0, TextColor);
}
}
/// <summary>
/// Simple clickable button: panel background + centered label + click
/// callback. Retail equivalent is Keystone's button widget, driven by
/// a <c>StateDesc</c> per <c>UIStateId</c> (normal / hot / pressed /
/// disabled) from the panel layout.
/// Note: the dat-widget button (Type 1 / UIElement_Button) is <see cref="AcDream.App.UI.UiButton"/>
/// in <c>UiButton.cs</c> — that is the production widget used by D.2b panels.
/// This class is the earlier dev-scaffold button (plain rect + text; no dat sprites).
/// </summary>
public class UiSimpleButton : UiPanel
{
public string Text { get; set; } = string.Empty;
public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f);
/// <summary>
/// Optional live caption reader, preferred over <see cref="Text"/> when
/// set, so a markup-bound button can change its own label (Buff / Stop)
/// without the binding object touching UI objects.
/// </summary>
public Func<string?>? TextSource { get; set; }
/// <summary>Retail dat font for the caption; see <see cref="UiLabel.DatFont"/>.</summary>
public UiDatFont? DatFont { get; set; }
/// <summary>Two-plane glyph outline, as retail draws interface text.</summary>
public bool Outline { get; set; } = true;
public event System.Action? Click;
/// <summary>
/// Without this the button is unclickable inside any draggable window.
/// UiRoot's press handling asks the pressed widget whether it owns the
/// pointer; a widget that does not claim the press falls through to
/// "move the ancestor window", which swallows the release and never emits
/// a Click. <see cref="UiButton"/> and <see cref="UiClickablePanel"/>
/// already declare it — this one did not, which is why a markup plugin
/// panel's button did nothing while its hit-test was perfectly fine.
/// </summary>
public override bool HandlesClick => true;
public UiSimpleButton()
{
BackgroundColor = new Vector4(0.1f, 0.1f, 0.15f, 0.8f);
BorderColor = new Vector4(0.45f, 0.45f, 0.55f, 1f);
}
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Click && Enabled)
{
Click?.Invoke();
return true;
}
return false;
}
protected override void OnDraw(UiRenderContext ctx)
{
base.OnDraw(ctx);
string caption = TextSource?.Invoke() ?? Text;
if (caption.Length == 0) return;
if (DatFont is { } dat)
{
float datW = dat.MeasureWidth(caption);
ctx.DrawStringDat(
dat, caption,
(Width - datW) * 0.5f, (Height - dat.LineHeight) * 0.5f,
TextColor, Outline);
return;
}
if (ctx.DefaultFont is null) return;
float textW = ctx.DefaultFont.MeasureWidth(caption);
float tx = (Width - textW) * 0.5f;
float ty = (Height - ctx.DefaultFont.LineHeight) * 0.5f;
ctx.DrawString(caption, tx, ty, TextColor);
}
}
/// <summary>
/// A <see cref="UiPanel"/> that fires an <see cref="OnClick"/> callback when the user
/// left-clicks it. Used for the attribute-list rows in the Character window — each row
/// is a transparent container that needs to respond to pointer hits while its children
/// (icon, name, value) are ClickThrough decorations.
///
/// <para>Retail analog: the <c>AttributeInfoRegion</c> row widget in <c>gmAttributeUI</c>
/// catches <c>UIEvent_LeftClick</c> (0x01) and calls <c>SetSelectedAttribute</c> on the
/// parent window. In acdream we wire the equivalent via this action callback instead of
/// the retail message bus.</para>
///
/// <para>When <see cref="UseSelectionBars"/> is true and <see cref="UiPanel.BackgroundSprite"/>
/// is non-zero, draws the sprite as a thin full-width bar at the TOP and BOTTOM edges of
/// the row (not stretched to fill). This matches retail's selection highlight which shows
/// a horizontal dark bar on both the top and bottom edge of the selected attribute row,
/// with NO left/right end-caps. Bar height is <see cref="SelectionBarHeight"/> pixels
/// (default 3px).</para>
/// </summary>
public class UiClickablePanel : UiPanel
{
/// <summary>Called when the user releases the left mouse button over this panel.</summary>
public Action? OnClick { get; set; }
/// <summary>When true and <see cref="UiPanel.BackgroundSprite"/> is non-zero, draws
/// the sprite as a thin horizontal bar at the top AND bottom edges of the panel,
/// NOT as a full-height stretched fill. Matches retail's selected-row highlight
/// (sprite 0x06001397 — 300×32 px — shown as bars, not a block fill).
/// Default false (preserves legacy full-stretch behavior).</summary>
public bool UseSelectionBars { get; set; }
/// <summary>Height in pixels of each selection bar (top and bottom). Default 3px.
/// Ignored when <see cref="UseSelectionBars"/> is false.</summary>
public float SelectionBarHeight { get; set; } = 3f;
/// <summary>Settable tooltip, surfaced through the shared
/// <see cref="UiElement.GetTooltipText"/> hover pipeline (same pattern as
/// <see cref="UiButton.TooltipText"/> / <see cref="UiCatalogSlot"/>). TS-85's
/// character-panel gap: retail's <c>AttributeInfoRegion</c> /
/// <c>Attribute2ndInfoRegion</c> / <c>SkillInfoRegion</c> row constructors
/// (<c>UIElement::SetTooltip</c> at 0x004f1617 / 0x004f1777 / 0x004f222f) stamp
/// this once per row at construction — retail never updates it afterward, so a
/// plain settable string (not a live provider) matches.</summary>
public string? TooltipText { get; set; }
/// <inheritdoc />
public override string? GetTooltipText() =>
string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText;
public UiClickablePanel()
{
// Rows must receive pointer events — override the UiPanel default (ClickThrough=false,
// which is the UiElement base default). Explicit for clarity.
ClickThrough = false;
}
/// <summary>HandlesClick = true ensures this row receives its own Click even when
/// it is nested inside a Draggable ancestor window frame (e.g. the future character
/// window with a whole-window drag handle). Without this, the press would be consumed
/// by the ancestor's drag logic and the Click would never fire.</summary>
public override bool HandlesClick => true;
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Click && Enabled)
{
OnClick?.Invoke();
return true;
}
return false;
}
protected override void OnDraw(UiRenderContext ctx)
{
if (UseSelectionBars && BackgroundSprite != 0 && SpriteResolve is { } sr)
{
// Draw the selection highlight as a thin bar at the TOP and BOTTOM of the row.
// The sprite (0x06001397) is 300×32 px — we draw it as horizontal strips at
// native height (SelectionBarHeight), stretched to full panel width (UV tile
// horizontally). No left/right end-caps: u0=0, u1=Width/nativeW (UV repeat).
var (tex, tw, th) = sr(BackgroundSprite);
if (tex != 0 && tw > 0 && th > 0)
{
float barH = SelectionBarHeight;
float uTile = tw > 0 ? Width / tw : 1f;
// Top bar: shows the top barH px of the sprite (v = 0 → barH/th).
float vBot = th > 0 ? barH / th : 1f;
ctx.DrawSprite(tex, 0f, 0f, Width, barH, 0f, 0f, uTile, vBot, Vector4.One);
// Bottom bar: shows the bottom barH px of the sprite (v = 1barH/th → 1).
float vTop2 = th > 0 ? 1f - barH / th : 0f;
ctx.DrawSprite(tex, 0f, Height - barH, Width, barH, 0f, vTop2, uTile, 1f, Vector4.One);
}
// Selection-bar mode draws no border (rows have BorderColor=Zero by design).
}
else
{
// Default UiPanel draw: handles BackgroundSprite, BackgroundColor, AND border.
base.OnDraw(ctx);
}
}
}