feat(D.2b): UiButton (Type 1) — Send + Max/Min as generic buttons (widget-generalization Task 3)

Introduces UiButton: a dedicated dat-widget button that ports UIElement_Button
(RegisterElementClass(1,...) @ acclient_2013_pseudo_c.txt:125828). State selection,
tiled DrawSprite, and label rendering mirror UiDatElement exactly so the chat Send
and Max/Min buttons have zero behavioral change.

DatWidgetFactory now maps Type 1 → UiButton (beside Type 7 → UiMeter, Type 11 →
UiScrollbar). ChatWindowController's Send and Max/Min bind blocks updated from
UiDatElement casts to UiButton casts; ClickThrough=false lines dropped (UiButton
is interactive by construction).

The old UiPanel.cs UiButton (a plain dev-scaffold rect+text button with no dat
sprites) is renamed UiSimpleButton to free the name — no production code
instantiated it.

Full suite: 402 passed, 2 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-16 17:07:58 +02:00
parent 3593d6623d
commit 805ab5f40b
6 changed files with 154 additions and 6 deletions

View file

@ -243,9 +243,8 @@ public sealed class ChatWindowController
// ── Send button — Enter-alternate submit trigger ────────────────── // ── Send button — Enter-alternate submit trigger ──────────────────
// Retail's gmMainChatUI wires the Send button to the same ProcessCommand path. // Retail's gmMainChatUI wires the Send button to the same ProcessCommand path.
if (layout.FindElement(SendId) is UiDatElement sendEl) if (layout.FindElement(SendId) is UiButton sendEl)
{ {
sendEl.ClickThrough = false;
sendEl.OnClick = () => c.Input.Submit(); sendEl.OnClick = () => c.Input.Submit();
// The Send sprite is a blank gold button — retail draws the caption as text. // The Send sprite is a blank gold button — retail draws the caption as text.
sendEl.Label = "Send"; sendEl.Label = "Send";
@ -276,14 +275,13 @@ 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 UiButton maxMinEl)
{ {
// The dat puts max/min and the scrollbar up-button at the SAME X (both // 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 // right-anchored), so at content width they overlap. Retail shows max/min
// just LEFT of the scrollbar column — shift it one button-width left. // just LEFT of the scrollbar column — shift it one button-width left.
if (track is not null) if (track is not null)
maxMinEl.Left = track.Left - maxMinEl.Width; maxMinEl.Left = track.Left - maxMinEl.Width;
maxMinEl.ClickThrough = false;
maxMinEl.OnClick = c.ToggleMaximize; maxMinEl.OnClick = c.ToggleMaximize;
} }

View file

@ -1,5 +1,6 @@
using System; using System;
using System.Linq; using System.Linq;
using AcDream.App.UI;
namespace AcDream.App.UI.Layout; namespace AcDream.App.UI.Layout;
@ -57,6 +58,7 @@ public static class DatWidgetFactory
UiElement e = info.Type switch UiElement e = info.Type switch
{ {
1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
7 => BuildMeter(info, resolve, datFont), // UIElement_Meter 7 => BuildMeter(info, resolve, datFont), // UIElement_Meter
11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137) 11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137)
_ => new UiDatElement(info, resolve), // generic fallback for all other types _ => new UiDatElement(info, resolve), // generic fallback for all other types

View file

@ -0,0 +1,111 @@
using System;
using System.Numerics;
using AcDream.App.UI.Layout;
namespace AcDream.App.UI;
/// <summary>
/// Generic dat-widget button — the production replacement for any dat element of
/// Type 1 (UIElement_Button, registered via RegisterElementClass(1, UIElement_Button::Create)
/// @ acclient_2013_pseudo_c.txt:125828).
///
/// <para>
/// Draws per-state sprite media exactly like <see cref="UiDatElement"/> (same
/// <c>ActiveState</c> defaulting, same <c>ActiveMedia()</c> fallback chain, same tiled
/// <c>DrawSprite</c> call with UV-repeat so chrome edges tile correctly) plus an
/// optional centered text label. The click behavior mirrors <see cref="UiDatElement"/>
/// one-for-one so the chat Send and Max/Min buttons that previously bound through
/// <c>UiDatElement.OnClick</c> continue to work without behavioral change.
/// </para>
///
/// <para>
/// State selection: picks <see cref="ElementInfo.DefaultStateName"/> if set, then
/// "Normal" if the element has a Normal state sprite, then falls back to the unnamed
/// DirectState ("" key) — identical to <see cref="UiDatElement"/>.
/// </para>
///
/// <para>
/// Built by <see cref="DatWidgetFactory"/> for Type-1 elements (chat Send 0x10000019,
/// Max/Min 0x1000046F). NOT the same as <see cref="UiSimpleButton"/>, which is an
/// earlier dev-scaffold widget with no dat sprites.
/// </para>
/// </summary>
public sealed class UiButton : UiElement
{
private readonly ElementInfo _info;
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
public Action? OnClick { get; set; }
/// <summary>Optional centered text label drawn over the sprite (e.g. "Send" on a blank gold frame).</summary>
public string? Label { get; set; }
/// <summary>Dat font for <see cref="Label"/>. Required for the label to draw.</summary>
public UiDatFont? LabelFont { get; set; }
/// <summary>Label color (default white).</summary>
public Vector4 LabelColor { get; set; } = Vector4.One;
/// <summary>
/// Active state name, runtime-settable (e.g. Max/Min toggling Normal ↔ Minimized).
/// Matches <see cref="UiDatElement.ActiveState"/>.
/// </summary>
public string ActiveState { get; set; } = "";
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
public UiButton(ElementInfo info, Func<uint, (uint tex, int w, int h)> resolve)
{
_info = info;
_resolve = resolve;
ClickThrough = false; // buttons are interactive — opt OUT of click-through
// State defaulting matches UiDatElement exactly:
// DefaultStateName wins; else "Normal" if that state has a sprite; else DirectState ("").
if (!string.IsNullOrEmpty(info.DefaultStateName))
ActiveState = info.DefaultStateName;
else if (info.StateMedia.ContainsKey("Normal"))
ActiveState = "Normal";
// else ActiveState stays "" (DirectState)
}
/// <summary>
/// Returns the File id for the current <see cref="ActiveState"/>, falling back to
/// the DirectState ("" key) if the named state is absent.
/// Returns 0 if neither exists.
/// Mirrors <see cref="UiDatElement.ActiveMedia()"/>.
/// </summary>
private uint ActiveFile()
=> _info.StateMedia.TryGetValue(ActiveState, out var m) ? m.File
: _info.StateMedia.TryGetValue("", out var d) ? d.File : 0u;
protected override void OnDraw(UiRenderContext ctx)
{
uint file = ActiveFile();
if (file != 0)
{
var (tex, tw, th) = _resolve(file);
if (tex != 0 && tw != 0 && th != 0)
{
// Tiled draw — same call shape as UiDatElement.OnDraw (UV-repeat; GL_REPEAT-wrapped
// UI texture). Matches ImgTex::TileCSI; no Stretch mode exists.
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
}
}
if (Label is { Length: > 0 } label && LabelFont is { } lf)
{
float tx = (Width - lf.MeasureWidth(label)) * 0.5f;
float ty = (Height - lf.LineHeight) * 0.5f;
ctx.DrawStringDat(lf, label, tx, ty, LabelColor);
}
}
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; }
return false;
}
}

View file

@ -57,14 +57,17 @@ public class UiLabel : UiElement
/// callback. Retail equivalent is Keystone's button widget, driven by /// callback. Retail equivalent is Keystone's button widget, driven by
/// a <c>StateDesc</c> per <c>UIStateId</c> (normal / hot / pressed / /// a <c>StateDesc</c> per <c>UIStateId</c> (normal / hot / pressed /
/// disabled) from the panel layout. /// 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> /// </summary>
public class UiButton : UiPanel public class UiSimpleButton : UiPanel
{ {
public string Text { get; set; } = string.Empty; public string Text { get; set; } = string.Empty;
public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f); public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f);
public event System.Action? Click; public event System.Action? Click;
public UiButton() public UiSimpleButton()
{ {
BackgroundColor = new Vector4(0.1f, 0.1f, 0.15f, 0.8f); BackgroundColor = new Vector4(0.1f, 0.1f, 0.15f, 0.8f);
BorderColor = new Vector4(0.45f, 0.45f, 0.55f, 1f); BorderColor = new Vector4(0.45f, 0.45f, 0.55f, 1f);

View file

@ -97,6 +97,15 @@ public class DatWidgetFactoryTests
Assert.Null(DatWidgetFactory.Create(noMedia, NoTex, null)); Assert.Null(DatWidgetFactory.Create(noMedia, NoTex, null));
} }
// ── Test 5c: Type 1 → UiButton ──────────────────────────────────────────
[Fact]
public void Type1_Button_MakesUiButton()
{
var e = DatWidgetFactory.Create(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex, null);
Assert.IsType<UiButton>(e);
}
// ── Test 5b: Type 11 → UiScrollbar ────────────────────────────────────── // ── Test 5b: Type 11 → UiScrollbar ──────────────────────────────────────
[Fact] [Fact]

View file

@ -0,0 +1,25 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI;
public class UiButtonTests
{
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
private bool _clicked;
[Fact]
public void Click_InvokesOnClick()
{
var b = new UiButton(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex)
{ OnClick = () => _clicked = true };
b.OnEvent(new UiEvent(0, null, UiEventType.Click));
Assert.True(_clicked);
}
[Fact]
public void NotClickThrough_SoItReceivesClicks()
{
var b = new UiButton(new ElementInfo { Type = 1 }, NoTex);
Assert.False(b.ClickThrough);
}
}