Port the retail horizontal ItemList empty-slot padding and share UIItem shortcut-number graphics with the status toolbar. Preserve all authored face children on compound DAT buttons so the three-piece Cast control reflows and renders as one complete button. Co-authored-by: OpenAI Codex <codex@openai.com>
525 lines
20 KiB
C#
525 lines
20 KiB
C#
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, IUiGlobalTimeListener, IUiDatStateful
|
|
{
|
|
private readonly ElementInfo _info;
|
|
private readonly ElementInfo _mediaInfo;
|
|
private readonly FaceSegment[] _faceSegments;
|
|
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
|
|
private readonly HashSet<uint> _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;
|
|
|
|
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
|
|
public Action? OnClick { get; set; }
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public Action? OnPressed { get; set; }
|
|
public Action? OnReleased { get; set; }
|
|
|
|
/// <summary>
|
|
/// Optional position-aware click handler. Coordinates are local pixels in this button,
|
|
/// matching retail's <c>UIElementMessageInfo.ptWindow</c> paperdoll hit-test input.
|
|
/// </summary>
|
|
public Action<int, int>? OnClickAt { get; set; }
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public Func<ItemDragPayload, ItemDragAcceptance>? OnItemDragOver { get; set; }
|
|
public Action<ItemDragPayload>? OnItemDrop { get; set; }
|
|
public uint ItemDragAcceptSprite { get; set; }
|
|
public uint ItemDragRejectSprite { get; set; }
|
|
private ItemDragAcceptance _itemDragAcceptance;
|
|
|
|
internal ItemDragAcceptance ItemDragAcceptanceForTest => _itemDragAcceptance;
|
|
|
|
/// <summary>The dat element id from <see cref="ElementInfo.Id"/>.</summary>
|
|
public uint ElementId => _info.Id;
|
|
|
|
/// <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>Optional authored face rectangle. Full-button by default; retail
|
|
/// UIOption_Checkbox uses its 13x13 indicator child as the button face.</summary>
|
|
public float FaceLeft { get; set; }
|
|
public float FaceTop { get; set; }
|
|
public float FaceWidth { get; set; }
|
|
public float FaceHeight { get; set; }
|
|
|
|
/// <summary>Additional left inset for left-aligned labels.</summary>
|
|
public float LabelOffsetX { get; set; } = 3f;
|
|
|
|
/// <summary>Horizontal alignment of <see cref="Label"/>. Center (default) for normal buttons;
|
|
/// Left for the paperdoll "Slots" caption that sits at the left edge, before the slots.</summary>
|
|
public LabelAlignment LabelAlign { get; set; } = LabelAlignment.Center;
|
|
|
|
/// <summary>Label horizontal alignment options.</summary>
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Active state name, runtime-settable (e.g. Max/Min toggling Normal ↔ Minimized).
|
|
/// Matches <see cref="UiDatElement.ActiveState"/>.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>Reads a resolved enum-valued DAT attribute from this button.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <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,
|
|
ElementInfo? mediaInfo = null,
|
|
IReadOnlyList<ElementInfo>? 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();
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
public override bool ConsumesDatChildren => true;
|
|
|
|
/// <summary>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.</summary>
|
|
public override bool HandlesClick => true;
|
|
|
|
/// <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(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<UiPixelRect> 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);
|
|
}
|
|
}
|