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:
parent
07be994d97
commit
ad30c37a48
15 changed files with 13492 additions and 38 deletions
|
|
@ -525,6 +525,7 @@ public static class DatWidgetFactory
|
|||
// set DatFont afterward STILL override this (backward-compat guarantee).
|
||||
DatFont = elementFont,
|
||||
};
|
||||
t.ConfigureDatState(info);
|
||||
|
||||
// Font color from dat property 0x1B (ColorBaseProperty).
|
||||
// When present, seed DefaultColor so controllers that read it don't have to hard-code colors.
|
||||
|
|
@ -534,10 +535,7 @@ public static class DatWidgetFactory
|
|||
t.DefaultColor = info.FontColor.Value;
|
||||
|
||||
if (ResolveAuthoredString(info, stringResolve) is { Length: > 0 } authored)
|
||||
{
|
||||
UiText.Line[] line = [new UiText.Line(authored, t.DefaultColor)];
|
||||
t.LinesProvider = () => line;
|
||||
}
|
||||
t.LinesProvider = () => [new UiText.Line(authored, t.DefaultColor)];
|
||||
|
||||
return t;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,6 +160,13 @@ public static class LayoutImporter
|
|||
}
|
||||
}
|
||||
|
||||
// UIElement::SetState @ 0x00464E70 propagates a state's id only after the
|
||||
// child tree exists. Re-applying the imported default here gives retained
|
||||
// PassToChildren tabs their authored Open/Closed child media without turning
|
||||
// spatial construction into controller-specific special cases.
|
||||
if (w is IUiDatStateful stateful)
|
||||
stateful.TrySetRetailState(stateful.ActiveRetailStateId);
|
||||
|
||||
return w;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ public enum SpellbookWindowPage { Spells, Components }
|
|||
/// <summary>
|
||||
/// Binds retail's combined spellbook/component-book LayoutDesc 0x21000034.
|
||||
/// The spell page mirrors gmSpellbookUI filters; the component page mirrors
|
||||
/// ComponentTracker and exposes the 0..5000 desired purchase level field.
|
||||
/// ComponentTracker and exposes the 0..5000 desired purchase level field. The
|
||||
/// two authored tabs are stateful UIElement_Text controls, matching
|
||||
/// <c>gmSpellbookUI::PostInit @ 0x0048B2B0</c> and the resolved retail layout.
|
||||
/// </summary>
|
||||
public sealed class SpellbookWindowController : IRetainedPanelController
|
||||
{
|
||||
|
|
@ -54,8 +56,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
private readonly Action _close;
|
||||
private readonly UiElement _spellPage;
|
||||
private readonly UiElement _componentPage;
|
||||
private readonly UiButton _spellTab;
|
||||
private readonly UiButton _componentTab;
|
||||
private readonly UiElement _spellTab;
|
||||
private readonly UiElement _componentTab;
|
||||
private readonly UiButton _closeButton;
|
||||
private readonly UiItemList _spellList;
|
||||
private readonly UiItemList _componentList;
|
||||
|
|
@ -82,8 +84,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
Action close,
|
||||
UiElement spellPage,
|
||||
UiElement componentPage,
|
||||
UiButton spellTab,
|
||||
UiButton componentTab,
|
||||
UiElement spellTab,
|
||||
UiElement componentTab,
|
||||
UiButton closeButton,
|
||||
UiItemList spellList,
|
||||
UiItemList componentList)
|
||||
|
|
@ -108,8 +110,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
_spellList = spellList;
|
||||
_componentList = componentList;
|
||||
|
||||
spellTab.OnClick = () => ShowPage(SpellbookWindowPage.Spells);
|
||||
componentTab.OnClick = () => ShowPage(SpellbookWindowPage.Components);
|
||||
SetClick(spellTab, () => ShowPage(SpellbookWindowPage.Spells));
|
||||
SetClick(componentTab, () => ShowPage(SpellbookWindowPage.Components));
|
||||
closeButton.OnClick = close;
|
||||
foreach ((uint id, uint mask) in FilterButtons)
|
||||
{
|
||||
|
|
@ -151,8 +153,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
{
|
||||
if (layout.FindElement(SpellPageId) is not { } spellPage
|
||||
|| layout.FindElement(ComponentPageId) is not { } componentPage
|
||||
|| layout.FindElement(SpellTabId) is not UiButton spellTab
|
||||
|| layout.FindElement(ComponentTabId) is not UiButton componentTab
|
||||
|| layout.FindElement(SpellTabId) is not { } spellTab
|
||||
|| layout.FindElement(ComponentTabId) is not { } componentTab
|
||||
|| layout.FindElement(CloseId) is not UiButton closeButton
|
||||
|| layout.FindElement(SpellListId) is not UiItemList spellList)
|
||||
return null;
|
||||
|
|
@ -187,8 +189,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
CurrentPage = page;
|
||||
_spellPage.Visible = page == SpellbookWindowPage.Spells;
|
||||
_componentPage.Visible = page == SpellbookWindowPage.Components;
|
||||
_spellTab.Selected = page == SpellbookWindowPage.Spells;
|
||||
_componentTab.Selected = page == SpellbookWindowPage.Components;
|
||||
SetTabOpen(_spellTab, page == SpellbookWindowPage.Spells);
|
||||
SetTabOpen(_componentTab, page == SpellbookWindowPage.Components);
|
||||
}
|
||||
|
||||
private void ConfigureSpellList()
|
||||
|
|
@ -449,9 +451,29 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
|||
_objects.ObjectRemoved -= OnObjectRemoved;
|
||||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_objects.ContainerContentsReplaced -= OnContainerContentsReplaced;
|
||||
_spellTab.OnClick = null;
|
||||
_componentTab.OnClick = null;
|
||||
SetClick(_spellTab, null);
|
||||
SetClick(_componentTab, null);
|
||||
_closeButton.OnClick = null;
|
||||
foreach ((UiButton button, _) in _filters) button.OnClick = null;
|
||||
}
|
||||
|
||||
private static void SetClick(UiElement element, Action? action)
|
||||
{
|
||||
element.ClickThrough = action is null;
|
||||
switch (element)
|
||||
{
|
||||
case UiButton button: button.OnClick = action; break;
|
||||
case UiText text: text.OnClick = action; break;
|
||||
case UiDatElement dat: dat.OnClick = action; break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetTabOpen(UiElement element, bool open)
|
||||
{
|
||||
if (element is IUiDatStateful stateful
|
||||
&& stateful.TrySetRetailState(open ? RetailUiStateIds.Open : RetailUiStateIds.Closed))
|
||||
return;
|
||||
if (element is UiButton button)
|
||||
button.Selected = open;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -451,6 +451,9 @@ public sealed class SpellcastingUiController : IRetainedPanelController
|
|||
|
||||
private static void SetSelected(UiElement element, bool selected)
|
||||
{
|
||||
if (element is IUiDatStateful stateful
|
||||
&& stateful.TrySetRetailState(selected ? RetailUiStateIds.Open : RetailUiStateIds.Closed))
|
||||
return;
|
||||
if (element is UiButton button)
|
||||
button.Selected = selected;
|
||||
else if (element is UiText text)
|
||||
|
|
|
|||
|
|
@ -70,29 +70,35 @@ public sealed class UiDatElement : UiElement, IUiDatStateful
|
|||
|
||||
public bool TrySetRetailState(uint stateId)
|
||||
{
|
||||
UiStateInfo? selectedState = null;
|
||||
if (stateId == UiStateInfo.DirectStateId)
|
||||
{
|
||||
if (!_info.States.ContainsKey(stateId) && !_info.StateMedia.ContainsKey(""))
|
||||
if (!_info.States.TryGetValue(stateId, out selectedState)
|
||||
&& !_info.StateMedia.ContainsKey(""))
|
||||
return false;
|
||||
ActiveState = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_info.States.TryGetValue(stateId, out var state))
|
||||
else if (_info.States.TryGetValue(stateId, out selectedState))
|
||||
{
|
||||
ActiveState = state.Name;
|
||||
return true;
|
||||
ActiveState = selectedState.Name;
|
||||
}
|
||||
|
||||
string stateName = UiButtonStateMachine.StateName(stateId);
|
||||
if (string.IsNullOrEmpty(stateName))
|
||||
stateName = RetailUiStateIds.StateName(stateId);
|
||||
if (!string.IsNullOrEmpty(stateName) && _info.StateMedia.ContainsKey(stateName))
|
||||
else
|
||||
{
|
||||
string stateName = UiButtonStateMachine.StateName(stateId);
|
||||
if (string.IsNullOrEmpty(stateName))
|
||||
stateName = RetailUiStateIds.StateName(stateId);
|
||||
if (string.IsNullOrEmpty(stateName) || !_info.StateMedia.ContainsKey(stateName))
|
||||
return false;
|
||||
ActiveState = stateName;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
if (selectedState?.PassToChildren == true)
|
||||
{
|
||||
foreach (UiElement child in Children)
|
||||
if (child is IUiDatStateful stateful)
|
||||
stateful.TrySetRetailState(stateId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue