fix(ui): restore retail spell tabs and spellbook

Bind the spellbook and component-book tabs as their authored stateful text controls, propagate Closed/Open state into the retained DAT child tree, and keep authored label colors live across state changes.

Match retail UIElement_Text zero-margin construction so the Magic favorite captions I through VIII are no longer clipped. Add a real portal.dat spellbook fixture plus controller, state-propagation, and text-layout regression coverage.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-15 15:54:11 +02:00
parent 07be994d97
commit ad30c37a48
15 changed files with 13492 additions and 38 deletions

View file

@ -20,7 +20,7 @@ namespace AcDream.App.UI;
/// Display-only text remains click-through and cannot steal focus or window drag.
/// </para>
/// </summary>
public sealed class UiText : UiElement
public sealed class UiText : UiElement, IUiDatStateful
{
/// <summary>Optional base-element click notice used by authored text tabs.</summary>
public Action? OnClick { get; set; }
@ -79,8 +79,12 @@ public sealed class UiText : UiElement
/// <summary>Highlight colour painted behind a selected character span.</summary>
public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f);
/// <summary>Inner text inset from the view edges, px.</summary>
public float Padding { get; set; } = 4f;
/// <summary>
/// Uniform inner text inset in pixels. The retail default is zero:
/// <c>UIElement_Text::UIElement_Text @ 0x00468570</c> clears all four margins
/// before applying any authored margin properties.
/// </summary>
public float Padding { get; set; }
/// <summary>Retail property 0x20. Independent of horizontal/vertical
/// justification; false permits the normal multi-line layout path.</summary>
@ -156,7 +160,11 @@ public sealed class UiText : UiElement
private UiDatFont? _lastDatFont;
private float _lastLineHeight = 16f;
private float _lastBaseY; // top Y of line 0 in local space
private float _lastPadding = 4f;
private float _lastPadding;
private ElementInfo? _datInfo;
private uint _activeRetailStateId = UiStateInfo.DirectStateId;
private string _activeDatStateName = "";
// ── Selection state ──────────────────────────────────────────────────
private Pos? _selAnchor; // where the drag started
@ -172,9 +180,105 @@ public sealed class UiText : UiElement
CapturesPointerDrag = false;
}
/// <summary>The text view draws its own lines + background; any dat sub-elements
/// (scroll indicators, caps) are not built as separate widgets by the importer.</summary>
public override bool ConsumesDatChildren => true;
/// <summary>
/// Text widgets normally reproduce their own scroll/edit chrome. Retail tab text,
/// however, uses <c>PassToChildren</c> states whose child elements are the authored
/// Open/Closed cap sprites. Those children must remain in the retained tree so
/// <see cref="TrySetRetailState"/> can propagate the state exactly as retail does.
/// </summary>
public override bool ConsumesDatChildren
{
get
{
if (_datInfo is null) return true;
foreach (UiStateInfo state in _datInfo.States.Values)
if (state.PassToChildren)
return false;
return true;
}
}
public uint ActiveRetailStateId => _activeRetailStateId;
public override string ActiveCursorStateName => _activeDatStateName;
internal void ConfigureDatState(ElementInfo info)
{
_datInfo = info;
_activeRetailStateId = info.EffectiveDefaultStateId();
ApplyDatState(_activeRetailStateId, propagate: false);
}
public bool TrySetRetailState(uint stateId)
=> ApplyDatState(stateId, propagate: true);
private bool ApplyDatState(uint stateId, bool propagate)
{
if (_datInfo is null) return false;
UiStateInfo? state = null;
string stateName;
if (stateId == UiStateInfo.DirectStateId)
{
if (!_datInfo.States.TryGetValue(stateId, out state)
&& !_datInfo.StateMedia.ContainsKey(""))
return false;
stateName = "";
}
else if (_datInfo.States.TryGetValue(stateId, out state))
{
stateName = state.Name;
}
else
{
stateName = UiButtonStateMachine.StateName(stateId);
if (string.IsNullOrEmpty(stateName))
stateName = RetailUiStateIds.StateName(stateId);
if (string.IsNullOrEmpty(stateName)
|| !_datInfo.StateMedia.ContainsKey(stateName))
return false;
}
_activeRetailStateId = stateId;
_activeDatStateName = stateName;
BackgroundSprite = _datInfo.StateMedia.TryGetValue(stateName, out var media)
? media.File
: _datInfo.StateMedia.TryGetValue("", out var direct) ? direct.File : 0u;
if (_datInfo.TryGetEffectiveProperty(0x1Bu, out UiPropertyValue color, stateId)
&& TryColor(color, out Vector4 resolvedColor))
DefaultColor = resolvedColor;
if (propagate && state?.PassToChildren == true)
{
foreach (UiElement child in Children)
if (child is IUiDatStateful stateful)
stateful.TrySetRetailState(stateId);
}
return true;
}
private static bool TryColor(UiPropertyValue property, out Vector4 color)
{
UiPropertyValue? value = property.Kind == UiPropertyKind.Color
? property
: property.Kind == UiPropertyKind.Array
&& property.ArrayValue.Count > 0
&& property.ArrayValue[0].Kind == UiPropertyKind.Color
? property.ArrayValue[0]
: null;
if (value is null)
{
color = default;
return false;
}
UiColorValue c = value.ColorValue;
float alpha = c.Alpha == 0 ? 1f : c.Alpha / 255f;
color = new Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, alpha);
return true;
}
/// <summary>
/// Clamp a scroll offset to [0, max] where max = content-height - view-height