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, IUiGlobalTimeListener, IUiDatStateful { private readonly ElementInfo _info; private readonly ElementInfo _mediaInfo; private readonly FaceSegment[] _faceSegments; private readonly Func _resolve; private readonly HashSet _availableStates = new(); private bool _pressed; private bool _pointerOver; private bool _selected; private bool _hotClicking; private bool _suppressNextClick; private int _pointerX; private int _pointerY; private double _nextHotClickTime = double.NaN; /// Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize). public Action? OnClick { get; set; } /// /// Optional pointer transition handlers. These expose retail's distinct /// pressed/released element messages for controls such as the combat-height /// buttons, where mouse-down begins charging and mouse-up commits the attack. /// public Action? OnPressed { get; set; } public Action? OnReleased { get; set; } /// /// Optional position-aware click handler. Coordinates are local pixels in this button, /// matching retail's UIElementMessageInfo.ptWindow paperdoll hit-test input. /// public Action? OnClickAt { get; set; } /// /// Optional item-drop target callbacks. Retail uses ordinary buttons as drop surfaces /// in a few panels (notably gmToolbarUI's inventory button), so this belongs on the /// retained button rather than in UiRoot or a panel-specific hit-test workaround. /// public Func? OnItemDragOver { get; set; } public Action? OnItemDrop { get; set; } public uint ItemDragAcceptSprite { get; set; } public uint ItemDragRejectSprite { get; set; } private ItemDragAcceptance _itemDragAcceptance; internal ItemDragAcceptance ItemDragAcceptanceForTest => _itemDragAcceptance; /// The dat element id from . public uint ElementId => _info.Id; /// 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; /// Optional authored face rectangle. Full-button by default; retail /// UIOption_Checkbox uses its 13x13 indicator child as the button face. public float FaceLeft { get; set; } public float FaceTop { get; set; } public float FaceWidth { get; set; } public float FaceHeight { get; set; } /// Additional left inset for left-aligned labels. public float LabelOffsetX { get; set; } = 3f; /// Horizontal alignment of . Center (default) for normal buttons; /// Left for the paperdoll "Slots" caption that sits at the left edge, before the slots. public LabelAlignment LabelAlign { get; set; } = LabelAlignment.Center; /// Label horizontal alignment options. public enum LabelAlignment { Center, Left } public bool ToggleBehavior { get; } public bool RolloverEnabled { get; } public bool HotClickEnabled { get; } public float HotClickInitialDelay { get; } public float HotClickRepeatInterval { get; } public bool Selected { get => _selected; set { if (_selected == value) return; _selected = value; UpdateVisualState(); } } /// /// Active state name, runtime-settable (e.g. Max/Min toggling Normal ↔ Minimized). /// Matches . /// public string ActiveState { get; set; } = ""; public uint ActiveRetailStateId { get { if (string.IsNullOrEmpty(ActiveState)) return UiStateInfo.DirectStateId; foreach (var (id, state) in _mediaInfo.States) if (string.Equals(state.Name, ActiveState, StringComparison.Ordinal)) return id; return UiButtonStateMachine.TryStateId(ActiveState, out uint standard) ? standard : RetailUiStateIds.TryStateId(ActiveState, out uint custom) ? custom : 0u; } } /// Reads a resolved enum-valued DAT attribute from this button. public bool TryGetEnumAttribute(uint propertyId, out uint value) { if (_info.TryGetEffectiveProperty(propertyId, out var property) && property.Kind == UiPropertyKind.Enum) { value = checked((uint)property.UnsignedValue); return true; } value = 0; return false; } public override string ActiveCursorStateName => ActiveState; public bool TrySetRetailState(uint stateId) { if (ToggleBehavior && stateId is UiButtonStateMachine.Normal or UiButtonStateMachine.Highlight) { Selected = stateId == UiButtonStateMachine.Highlight; return true; } if (stateId == UiButtonStateMachine.Ghosted) { Enabled = false; return true; } if (!Enabled && stateId != UiButtonStateMachine.Ghosted) Enabled = true; if (stateId == UiStateInfo.DirectStateId) { if (!TryFindState(stateId, out _) && !HasStateMedia("")) return false; ActiveState = ""; return true; } if (TryFindState(stateId, out var state)) { ActiveState = state.Name; return true; } string stateName = UiButtonStateMachine.StateName(stateId); if (string.IsNullOrEmpty(stateName)) stateName = RetailUiStateIds.StateName(stateId); if (!string.IsNullOrEmpty(stateName) && HasStateMedia(stateName)) { ActiveState = stateName; return true; } return false; } /// 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, ElementInfo? mediaInfo = null, IReadOnlyList? faceSegments = null) { _info = info; _mediaInfo = mediaInfo ?? info; _faceSegments = faceSegments is null ? [] : faceSegments.Select(static segment => new FaceSegment(segment)).ToArray(); _resolve = resolve; ClickThrough = false; // buttons are interactive — opt OUT of click-through // Visual transitions can select only states with an actual button face. // Retail layouts commonly declare an empty Normal_pressed descriptor while // supplying art only for Normal/Highlight. Treating that property-only state // as drawable briefly blanks the button during mouse-down. if (_faceSegments.Length == 0) AddAvailableStates(_mediaInfo); else foreach (FaceSegment segment in _faceSegments) AddAvailableStates(segment.Info); ToggleBehavior = info.TryGetEffectiveBool(0x0Bu, out bool toggle) && toggle; RolloverEnabled = info.TryGetEffectiveBool(0x13u, out bool rollover) && rollover; HotClickEnabled = info.TryGetEffectiveBool(0x0Fu, out bool hotClick) && hotClick; HotClickInitialDelay = info.TryGetEffectiveFloat(0x10u, out float initialDelay) ? initialDelay : 0f; HotClickRepeatInterval = info.TryGetEffectiveFloat(0x11u, out float repeatInterval) ? repeatInterval : 0f; _selected = info.TryGetEffectiveBool(0x0Eu, out bool selected) && selected; bool disabled = info.TryGetEffectiveBool(0x0Du, out bool ghosted) && ghosted; // 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 (HasStateMedia("Normal")) ActiveState = "Normal"; // else ActiveState stays "" (DirectState) Enabled = !disabled; FaceWidth = mediaInfo?.Width ?? info.Width; FaceHeight = mediaInfo?.Height ?? info.Height; UpdateVisualState(); } /// The button draws its own face + label; any dat label child is reproduced /// procedurally, so the importer must not build the button's children as widgets. public override bool ConsumesDatChildren => true; /// A button is interactive — it must receive its Click even inside a whole-window-Draggable /// frame (e.g. the paperdoll "Slots" toggle in the inventory window), so it opts out of the /// IA-12 whole-window-drag that would otherwise swallow the press. public override bool HandlesClick => true; /// /// 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(ElementInfo mediaInfo) => mediaInfo.StateMedia.TryGetValue(ActiveState, out var m) ? m.File : mediaInfo.StateMedia.TryGetValue("", out var d) ? d.File : 0u; protected override void OnDraw(UiRenderContext ctx) { if (_faceSegments.Length != 0) { foreach (FaceSegment segment in _faceSegments) DrawFace(ctx, ActiveFile(segment.Info), segment.Rect(Width, Height)); } else { uint file = ActiveFile(_mediaInfo); 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. float faceWidth = FaceWidth > 0f ? FaceWidth : Width; float faceHeight = FaceHeight > 0f ? FaceHeight : Height; ctx.DrawSprite(tex, FaceLeft, FaceTop, faceWidth, faceHeight, 0, 0, faceWidth / tw, faceHeight / th, Vector4.One); } } } if (Label is { Length: > 0 } label && LabelFont is { } lf) { float tx = LabelAlign == LabelAlignment.Left ? LabelOffsetX : (Width - lf.MeasureWidth(label)) * 0.5f; // centered (default) float ty = (Height - lf.LineHeight) * 0.5f; ctx.DrawStringDat(lf, label, tx, ty, LabelColor); } uint dragSprite = _itemDragAcceptance switch { ItemDragAcceptance.Accept => ItemDragAcceptSprite, ItemDragAcceptance.Reject => ItemDragRejectSprite, _ => 0u, }; if (dragSprite != 0) { var (tex, _, _) = _resolve(dragSprite); if (tex != 0) ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); } } private void DrawFace(UiRenderContext ctx, uint file, UiPixelRect rect) { if (file == 0 || rect.Width <= 0 || rect.Height <= 0) return; var (texture, textureWidth, textureHeight) = _resolve(file); if (texture == 0 || textureWidth == 0 || textureHeight == 0) return; // Same tiled/cropped media path as UiDatElement. Segment geometry is // first reflowed by its own four-edge retail layout policy. ctx.DrawSprite(texture, rect.X0, rect.Y0, rect.Width, rect.Height, 0f, 0f, (float)rect.Width / textureWidth, (float)rect.Height / textureHeight, Vector4.One); } private void AddAvailableStates(ElementInfo mediaInfo) { foreach (string stateName in mediaInfo.StateMedia.Keys) if (UiButtonStateMachine.TryStateId(stateName, out uint stateId)) _availableStates.Add(stateId); } private bool HasStateMedia(string stateName) { if (_faceSegments.Length == 0) return _mediaInfo.StateMedia.ContainsKey(stateName); foreach (FaceSegment segment in _faceSegments) if (segment.Info.StateMedia.ContainsKey(stateName)) return true; return false; } private bool TryFindState(uint stateId, out UiStateInfo state) { if (_faceSegments.Length == 0) return _mediaInfo.States.TryGetValue(stateId, out state!); foreach (FaceSegment segment in _faceSegments) if (segment.Info.States.TryGetValue(stateId, out state!)) return true; state = null!; return false; } private sealed class FaceSegment { private readonly UiPixelRect _original; private readonly UiLayoutPolicy? _layout; public FaceSegment(ElementInfo info) { Info = info; _original = UiPixelRect.FromPositionAndSize( (int)info.X, (int)info.Y, (int)info.Width, (int)info.Height); if (info.HasOriginalParentSize) _layout = new UiLayoutPolicy( info.Left, info.Top, info.Right, info.Bottom, _original, UiPixelRect.FromPositionAndSize( 0, 0, (int)info.OriginalParentWidth, (int)info.OriginalParentHeight)); } public ElementInfo Info { get; } public UiPixelRect Rect(float parentWidth, float parentHeight) { if (_layout is null) return _original; return _layout.Apply( _original, UiPixelRect.FromPositionAndSize(0, 0, (int)parentWidth, (int)parentHeight)); } } public override bool OnEvent(in UiEvent e) { switch (e.Type) { case UiEventType.HoverEnter: _pointerOver = true; UpdateVisualState(); return true; case UiEventType.HoverLeave: _pointerOver = false; UpdateVisualState(); return true; case UiEventType.MouseDown: _pointerX = e.Data1; _pointerY = e.Data2; _pointerOver = ContainsLocal(e.Data1, e.Data2); _pressed = true; UpdateVisualState(); if (Enabled) OnPressed?.Invoke(); if (HotClickEnabled && Enabled) { OnClick?.Invoke(); OnClickAt?.Invoke(_pointerX, _pointerY); _hotClicking = true; _nextHotClickTime = double.NaN; } return true; case UiEventType.MouseMove: if (_pressed) { _pointerX = e.Data1; _pointerY = e.Data2; _pointerOver = ContainsLocal(e.Data1, e.Data2); UpdateVisualState(); return true; } return false; case UiEventType.MouseUp: _pointerX = e.Data1; _pointerY = e.Data2; _pointerOver = ContainsLocal(e.Data1, e.Data2); _suppressNextClick = _hotClicking && _pointerOver; _hotClicking = false; _nextHotClickTime = double.NaN; if (_pressed && _pointerOver && Enabled && ToggleBehavior) _selected = !_selected; if (_pressed && Enabled) OnReleased?.Invoke(); _pressed = false; UpdateVisualState(); return true; case UiEventType.Click: if (!Enabled) return true; if (_suppressNextClick) { _suppressNextClick = false; return true; } OnClick?.Invoke(); OnClickAt?.Invoke(e.Data1, e.Data2); return OnClick is not null || OnClickAt is not null; case UiEventType.DragEnter: _itemDragAcceptance = e.Payload is ItemDragPayload payload ? OnItemDragOver?.Invoke(payload) ?? ItemDragAcceptance.None : ItemDragAcceptance.None; return OnItemDragOver is not null; case UiEventType.DragOver: _itemDragAcceptance = ItemDragAcceptance.None; return OnItemDragOver is not null; case UiEventType.DropReleased: _itemDragAcceptance = ItemDragAcceptance.None; if (e.Payload is ItemDragPayload dropped) OnItemDrop?.Invoke(dropped); return OnItemDrop is not null; default: return false; } } protected override void OnEnabledChanged() { if (!Enabled) { _pressed = false; _hotClicking = false; _nextHotClickTime = double.NaN; } UpdateVisualState(); } internal int FaceSegmentCount => _faceSegments.Length; internal IReadOnlyList FaceSegmentRectsForTest() => _faceSegments.Select(segment => segment.Rect(Width, Height)).ToArray(); public void OnGlobalUiTime(double nowSeconds) { if (!_hotClicking || !HotClickEnabled) return; if (double.IsNaN(_nextHotClickTime)) _nextHotClickTime = nowSeconds + HotClickInitialDelay; if (!_pointerOver && nowSeconds >= _nextHotClickTime) { _nextHotClickTime = nowSeconds; return; } if (_pointerOver && nowSeconds >= _nextHotClickTime) { OnClick?.Invoke(); OnClickAt?.Invoke(_pointerX, _pointerY); _nextHotClickTime += HotClickRepeatInterval; } } private bool ContainsLocal(int x, int y) => x >= 0 && y >= 0 && x < Width && y < Height; private void UpdateVisualState() { uint requested = UiButtonStateMachine.RequestedState(new UiButtonVisualInput( Disabled: !Enabled, Selected: _selected, RolloverEnabled: RolloverEnabled, Pressed: _pressed, PointerOver: _pointerOver)); if (_availableStates.Contains(requested)) ActiveState = UiButtonStateMachine.StateName(requested); } }