fix(ui): restore retail magic shortcut bar

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>
This commit is contained in:
Erik 2026-07-15 19:11:07 +02:00
parent 0527325b25
commit e3605672bb
12 changed files with 466 additions and 62 deletions

View file

@ -34,6 +34,7 @@ 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;
@ -175,12 +176,12 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
if (stateId == UiStateInfo.DirectStateId)
{
if (!_mediaInfo.States.ContainsKey(stateId) && !_mediaInfo.StateMedia.ContainsKey(""))
if (!TryFindState(stateId, out _) && !HasStateMedia(""))
return false;
ActiveState = "";
return true;
}
if (_mediaInfo.States.TryGetValue(stateId, out var state))
if (TryFindState(stateId, out var state))
{
ActiveState = state.Name;
return true;
@ -188,7 +189,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
string stateName = UiButtonStateMachine.StateName(stateId);
if (string.IsNullOrEmpty(stateName))
stateName = RetailUiStateIds.StateName(stateId);
if (!string.IsNullOrEmpty(stateName) && _mediaInfo.StateMedia.ContainsKey(stateName))
if (!string.IsNullOrEmpty(stateName) && HasStateMedia(stateName))
{
ActiveState = stateName;
return true;
@ -202,10 +203,14 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
public UiButton(
ElementInfo info,
Func<uint, (uint tex, int w, int h)> resolve,
ElementInfo? mediaInfo = null)
ElementInfo? mediaInfo = null,
IReadOnlyList<ElementInfo>? faceSegments = null)
{
_info = info;
_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
@ -213,9 +218,11 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
// 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.
foreach (string stateName in _mediaInfo.StateMedia.Keys)
if (UiButtonStateMachine.TryStateId(stateName, out uint stateId))
_availableStates.Add(stateId);
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;
@ -233,7 +240,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
// DefaultStateName wins; else "Normal" if that state has a sprite; else DirectState ("").
if (!string.IsNullOrEmpty(info.DefaultStateName))
ActiveState = info.DefaultStateName;
else if (_mediaInfo.StateMedia.ContainsKey("Normal"))
else if (HasStateMedia("Normal"))
ActiveState = "Normal";
// else ActiveState stays "" (DirectState)
@ -258,24 +265,32 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
/// Returns 0 if neither exists.
/// Mirrors <see cref="UiDatElement.ActiveMedia()"/>.
/// </summary>
private uint ActiveFile()
=> _mediaInfo.StateMedia.TryGetValue(ActiveState, out var m) ? m.File
: _mediaInfo.StateMedia.TryGetValue("", out var d) ? d.File : 0u;
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)
{
uint file = ActiveFile();
if (file != 0)
if (_faceSegments.Length != 0)
{
var (tex, tw, th) = _resolve(file);
if (tex != 0 && tw != 0 && th != 0)
foreach (FaceSegment segment in _faceSegments)
DrawFace(ctx, ActiveFile(segment.Info), segment.Rect(Width, Height));
}
else
{
uint file = ActiveFile(_mediaInfo);
if (file != 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);
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);
}
}
}
@ -302,6 +317,79 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
}
}
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)
@ -393,6 +481,11 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
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)