diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs
index 87e1f2de..64c0fc6b 100644
--- a/src/AcDream.App/UI/Layout/ChatWindowController.cs
+++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs
@@ -243,9 +243,8 @@ public sealed class ChatWindowController
// ── Send button — Enter-alternate submit trigger ──────────────────
// 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();
// The Send sprite is a blank gold button — retail draws the caption as text.
sendEl.Label = "Send";
@@ -276,14 +275,13 @@ public sealed class ChatWindowController
}
// ── 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
// 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.OnClick = c.ToggleMaximize;
}
diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
index ee4d3da4..20b688a1 100644
--- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
+++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
@@ -1,5 +1,6 @@
using System;
using System.Linq;
+using AcDream.App.UI;
namespace AcDream.App.UI.Layout;
@@ -57,6 +58,7 @@ public static class DatWidgetFactory
UiElement e = info.Type switch
{
+ 1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
7 => BuildMeter(info, resolve, datFont), // UIElement_Meter
11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137)
_ => new UiDatElement(info, resolve), // generic fallback for all other types
diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs
new file mode 100644
index 00000000..c6c5be26
--- /dev/null
+++ b/src/AcDream.App/UI/UiButton.cs
@@ -0,0 +1,111 @@
+using System;
+using System.Numerics;
+using AcDream.App.UI.Layout;
+
+namespace AcDream.App.UI;
+
+///
+/// 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).
+///
+///
+/// Draws per-state sprite media exactly like (same
+/// ActiveState defaulting, same ActiveMedia() fallback chain, same tiled
+/// DrawSprite call with UV-repeat so chrome edges tile correctly) plus an
+/// optional centered text label. The click behavior mirrors
+/// one-for-one so the chat Send and Max/Min buttons that previously bound through
+/// UiDatElement.OnClick continue to work without behavioral change.
+///
+///
+///
+/// State selection: picks if set, then
+/// "Normal" if the element has a Normal state sprite, then falls back to the unnamed
+/// DirectState ("" key) — identical to .
+///
+///
+///
+/// Built by for Type-1 elements (chat Send 0x10000019,
+/// Max/Min 0x1000046F). NOT the same as , which is an
+/// earlier dev-scaffold widget with no dat sprites.
+///
+///
+public sealed class UiButton : UiElement
+{
+ private readonly ElementInfo _info;
+ private readonly Func _resolve;
+
+ /// Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).
+ public Action? OnClick { get; set; }
+
+ /// Optional centered text label drawn over the sprite (e.g. "Send" on a blank gold frame).
+ public string? Label { get; set; }
+
+ /// Dat font for . Required for the label to draw.
+ public UiDatFont? LabelFont { get; set; }
+
+ /// Label color (default white).
+ public Vector4 LabelColor { get; set; } = Vector4.One;
+
+ ///
+ /// Active state name, runtime-settable (e.g. Max/Min toggling Normal ↔ Minimized).
+ /// Matches .
+ ///
+ public string ActiveState { get; set; } = "";
+
+ /// Merged for this element.
+ /// Dat file-id → (GL texture handle, native px width, native px height).
+ /// Returns (0,0,0) when the texture is not yet uploaded.
+ public UiButton(ElementInfo info, Func 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)
+ }
+
+ ///
+ /// Returns the File id for the current , falling back to
+ /// the DirectState ("" key) if the named state is absent.
+ /// Returns 0 if neither exists.
+ /// Mirrors .
+ ///
+ 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;
+ }
+}
diff --git a/src/AcDream.App/UI/UiPanel.cs b/src/AcDream.App/UI/UiPanel.cs
index 9f941da1..b6a2085f 100644
--- a/src/AcDream.App/UI/UiPanel.cs
+++ b/src/AcDream.App/UI/UiPanel.cs
@@ -57,14 +57,17 @@ public class UiLabel : UiElement
/// callback. Retail equivalent is Keystone's button widget, driven by
/// a StateDesc per UIStateId (normal / hot / pressed /
/// disabled) from the panel layout.
+/// Note: the dat-widget button (Type 1 / UIElement_Button) is
+/// in UiButton.cs — that is the production widget used by D.2b panels.
+/// This class is the earlier dev-scaffold button (plain rect + text; no dat sprites).
///
-public class UiButton : UiPanel
+public class UiSimpleButton : UiPanel
{
public string Text { get; set; } = string.Empty;
public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f);
public event System.Action? Click;
- public UiButton()
+ public UiSimpleButton()
{
BackgroundColor = new Vector4(0.1f, 0.1f, 0.15f, 0.8f);
BorderColor = new Vector4(0.45f, 0.45f, 0.55f, 1f);
diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
index cd543635..d2e8c439 100644
--- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
@@ -97,6 +97,15 @@ public class DatWidgetFactoryTests
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(e);
+ }
+
// ── Test 5b: Type 11 → UiScrollbar ──────────────────────────────────────
[Fact]
diff --git a/tests/AcDream.App.Tests/UI/UiButtonTests.cs b/tests/AcDream.App.Tests/UI/UiButtonTests.cs
new file mode 100644
index 00000000..8bbadae2
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/UiButtonTests.cs
@@ -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);
+ }
+}