acdream/src/AcDream.App/UI/UiPanel.cs
Erik 752782d0a9 fix(CT-GF1): UiLabel opts out of the self-clip — plugin markup text restored
CT7 gate regression (owner report): all MossTank plugin text vanished
except button captions. A markup <label> authors position only, so
UiLabel's box was degenerate (0x0) and CT-GF1's completed self-clip
(UIRegion::DrawHere @0x0069FA30 shape) cropped its glyphs to nothing;
markup buttons author w/h, which is why their captions survived.

UiLabel now opts out of the self-clip — it is ClickThrough pure text
whose real containment is its ancestors (the plugin panel/window, which
are properly sized), the effective retail behavior for a text region
whose box hugs its glyphs — and keeps a truthful box by measuring its
current text each draw. Mechanism pin: an unsized label's subtree must
render inside its sized parent (probe-child draw-capture test).

Gate note recorded by the owner in the same round: the Titles-page
divider IS visible inside the window in retail while scrolling — a
retail quirk our clipped rendering now reproduces exactly. CT7 gate
PASSED apart from this regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 07:28:26 +02:00

298 lines
14 KiB
C#

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.
/// <para>CT5 fix round (SHOULD-FIX 4): drawn at NATIVE SIZE — retail's
/// copy-or-tile blit, never scaled (see <c>UiDatElement.OnDraw</c>'s own
/// doc comment, ~lines 273-360, for the full ground truth). The UV
/// rectangle below (<c>Width / tw, Height / th</c>) is deliberately
/// UV-REPEAT, not a fixed 0..1 a stretch would use — GL_REPEAT-wrapped
/// UI textures tile past their native pixel size rather than scaling.
/// Retail's generic sprite blit (<c>Graphic::Draw</c> 0x00693b20 /
/// <c>Graphic::PutImage</c> 0x00693a30) has exactly two behaviors, copy
/// or tile, and can never scale a source image up to fill a larger
/// destination.</para>
/// <para>Used by the character-panel attribute/skill row's Highlight-state
/// swap (sprite 0x06000F93, template 0x10000248's Highlight-state
/// media) and CT5's Normal-state row background (0x06004CC2). The two
/// sprites behave differently under this same tile formula: 0x06000F93
/// is authored at exactly the row's own 282x20 native size (decoded
/// against the installed DAT, 2026-08-25: PFID_R8G8B8, 282x20), so it
/// draws as a plain COPY with no visible seam; 0x06004CC2 is a 48x48
/// uniform-color tile (PFID_A8R8G8B8, single color (0,0,0,175)) that
/// visibly TILES across the wider row — both are the same code path,
/// just different source-vs-destination ratios.</para>
/// <para>Retail's own <c>UIRegion::SetImageByDID</c> (@0x0069F960)
/// confirms its third parameter is a <c>BlitMode</c> COLOR-BLEND
/// selector, not a resize flag — the decompiled body switches purely on
/// that value (<c>param_2 == 2</c> -&gt; <c>Blit_3Alpha</c>,
/// <c>== 3</c> -&gt; <c>Blit_4Alpha</c>, else <c>Blit_Normal</c>) and
/// never touches width/height at all. This answers CT1's open "icon
/// draw mode 3" question (ground-truth doc §2): mode 3 selects
/// <c>Blit_4Alpha</c>, an alpha-blend variant, not a resize.</para>
/// </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; }
/// <summary>
/// CT7 gate regression (2026-08-25, MossTank plugin text vanished): a
/// markup <c>&lt;label&gt;</c> authors position only, so this element's
/// box is degenerate (0x0) — under CT-GF1's completed
/// <c>UIRegion::DrawHere @0x0069FA30</c> port the self-clip cropped the
/// glyphs to nothing. A label must not self-clip: it is
/// <see cref="UiElement.ClickThrough"/> pure text whose real clip is
/// its ancestors (the plugin panel/window, which ARE properly sized) —
/// the same effective containment retail gives a text region whose box
/// hugs its glyphs. <see cref="OnDraw"/> below also keeps the box
/// truthful by measuring the current text, so layout/hit consumers see
/// real extents (converges the frame after a bound text change).
/// </summary>
protected override bool ClipsChildren => false;
protected override void OnDraw(UiRenderContext ctx)
{
string text = TextSource?.Invoke() ?? Text;
float w = DatFont is { } df
? df.MeasureWidth(text)
: (ctx.DefaultFont?.MeasureWidth(text) ?? text.Length * 7f);
float h = DatFont?.LineHeight
?? ctx.DefaultFont?.LineHeight ?? 14f;
if (w != Width) Width = w;
if (h != Height) Height = h;
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>Campaign CT slice CT5 (2026-08-25): the selected-row highlight draws through the
/// inherited <see cref="UiPanel.OnDraw"/> full-panel <see cref="UiPanel.BackgroundSprite"/>
/// draw (native-size copy-or-tile — see that property's own doc comment for the CT5 fix
/// round's correction of the mechanism; NOT a stretch) — no override here. This class
/// previously had its own "selection bars" draw mode (a thin top/bottom-bar rendering
/// tuned to look like sprite 0x06001397's dark bars); CT1's ground-truth research found
/// that sprite belongs to a DIFFERENT retail mechanism entirely (the spellbook row's
/// overlay child), and the row's actual retail Highlight state (<c>InfoRegion::SetState</c>,
/// media 0x06000F93) is a plain full-row background SWAP — exactly what the inherited
/// <see cref="UiPanel.OnDraw"/> already draws (0x06000F93 is authored at exactly the row's
/// own 282x20 native size, so the swap needs no scaling to look right). The bars mode was
/// therefore retired rather than reconfigured to a wrong sprite's geometry; no consumer
/// outside <c>CharacterStatController</c> ever set it.</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>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;
}
}