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

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -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)

View file

@ -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>