feat(ui): port retained widget foundations

This commit is contained in:
Erik 2026-07-10 17:55:41 +02:00
parent 44f9ec13d9
commit d825572e31
44 changed files with 84813 additions and 292 deletions

View file

@ -30,10 +30,17 @@ namespace AcDream.App.UI;
/// earlier dev-scaffold widget with no dat sprites.
/// </para>
/// </summary>
public sealed class UiButton : UiElement
public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
{
private readonly ElementInfo _info;
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 double _nextHotClickTime = double.NaN;
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
public Action? OnClick { get; set; }
@ -57,14 +64,84 @@ public sealed class UiButton : UiElement
/// <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 _info.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;
}
}
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 (!_info.States.ContainsKey(stateId) && !_info.StateMedia.ContainsKey(""))
return false;
ActiveState = "";
return true;
}
if (_info.States.TryGetValue(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) && _info.StateMedia.ContainsKey(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>
@ -74,6 +151,25 @@ public sealed class UiButton : UiElement
_resolve = resolve;
ClickThrough = false; // buttons are interactive — opt OUT of click-through
foreach (uint stateId in info.States.Keys)
if (stateId != UiStateInfo.DirectStateId)
_availableStates.Add(stateId);
foreach (string stateName in info.StateMedia.Keys)
if (UiButtonStateMachine.TryStateId(stateName, out uint stateId))
_availableStates.Add(stateId);
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))
@ -81,6 +177,9 @@ public sealed class UiButton : UiElement
else if (info.StateMedia.ContainsKey("Normal"))
ActiveState = "Normal";
// else ActiveState stays "" (DirectState)
Enabled = !disabled;
UpdateVisualState();
}
/// <summary>The button draws its own face + label; any dat label child is reproduced
@ -128,7 +227,103 @@ public sealed class UiButton : UiElement
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; }
return false;
switch (e.Type)
{
case UiEventType.HoverEnter:
_pointerOver = true;
UpdateVisualState();
return true;
case UiEventType.HoverLeave:
_pointerOver = false;
UpdateVisualState();
return true;
case UiEventType.MouseDown:
_pointerOver = ContainsLocal(e.Data1, e.Data2);
_pressed = true;
UpdateVisualState();
if (HotClickEnabled && Enabled)
{
OnClick?.Invoke();
_hotClicking = true;
_nextHotClickTime = double.NaN;
}
return true;
case UiEventType.MouseMove:
if (_pressed)
{
_pointerOver = ContainsLocal(e.Data1, e.Data2);
UpdateVisualState();
return true;
}
return false;
case UiEventType.MouseUp:
_pointerOver = ContainsLocal(e.Data1, e.Data2);
_suppressNextClick = _hotClicking && _pointerOver;
_hotClicking = false;
_nextHotClickTime = double.NaN;
if (_pressed && _pointerOver && Enabled && ToggleBehavior)
_selected = !_selected;
_pressed = false;
UpdateVisualState();
return true;
case UiEventType.Click:
if (!Enabled) return true;
if (_suppressNextClick)
{
_suppressNextClick = false;
return true;
}
OnClick?.Invoke();
return OnClick is not null;
default:
return false;
}
}
protected override void OnEnabledChanged()
{
if (!Enabled)
{
_pressed = false;
_hotClicking = false;
_nextHotClickTime = double.NaN;
}
UpdateVisualState();
}
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();
_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);
}
}