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); + } +}