fix(ui): retain compound spellbook button children

Render Type-1 state-propagating child media as the button face so spellbook school and level filters show their authored green Highlight indicator and keep labels at the retail left inset.

Lift captions, fonts, and colors from authored text children for framed buttons such as Delete, preventing centered filter labels from being covered by the overlapping delete frame.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-15 16:40:42 +02:00
parent ac2ca8f965
commit c918ccea06
3 changed files with 113 additions and 9 deletions

View file

@ -269,12 +269,23 @@ row background 0x06001396
selected overlay child 0x10000342 -> 0x06001397
label child 0x10000344 (x=42, width=230)
icon child 0x1000033B (x=0, 32 x 32)
school/level filters Type-1, 90 x 14; label property on parent
filter indicator child 0x10000328 (x=0, y=1, 13 x 13)
filter Normal/Highlight art 0x06004D15 / 0x06004D17
delete button 0x100002A5
delete label child 0x100002A6 (100 x 32, centered, font 0x40000001)
```
The row's authored background contains the narrow horizontal separator visible
between entries. It is not an invented procedural line. The selection surface
is a separate overlay, so selecting a spell does not replace its icon or label.
The filter controls are ordinary `UIElement_Button` objects whose states have
`PassToChildren`; the green selected face is therefore the `Highlight` media on
their 13-pixel child, while the parent owns the label and the 90-pixel hit box.
The label begins four pixels after that child. Conversely, the Delete button
owns its frame media but its caption/font live in a full-size text child. A
retained leaf must lift those authored child roles; discarding all button
children loses both the filter indicator and the Delete caption.
```text
AddSpell(spellID):

View file

@ -76,9 +76,9 @@ public static class DatWidgetFactory
UiElement e = info.Type switch
{
UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80)
1 => BuildButton(info, resolve, elementFont, stringResolve), // UIElement_Button
1 => BuildButton(info, resolve, elementFont, fontResolve, stringResolve), // UIElement_Button
EffectsIndicatorController.RetailClassId => BuildButton(
info, resolve, elementFont, stringResolve), // gmUIElement_EffectsIndicator
info, resolve, elementFont, fontResolve, stringResolve), // gmUIElement_EffectsIndicator
6 => new UiMenu(), // UIElement_Menu (reg :120163)
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf
@ -86,7 +86,8 @@ public static class DatWidgetFactory
12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text
0x13 => new UiDialogRoot(), // ConfirmationDialog
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
0x10000035u => BuildCheckbox(info, resolve, elementFont, stringResolve), // UIOption_Checkbox
0x10000035u => BuildCheckbox(
info, resolve, elementFont, fontResolve, stringResolve), // UIOption_Checkbox
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
};
@ -544,14 +545,63 @@ public static class DatWidgetFactory
ElementInfo info,
Func<uint, (uint, int, int)> resolve,
UiDatFont? elementFont,
Func<uint, UiDatFont?>? fontResolve,
Func<UiStringInfoValue, string?>? stringResolve)
=> new(info, resolve)
{
// UIElement_Button propagates its retail state into authored children.
// Spellbook school/level filters are Type-1 buttons with no parent media:
// their 13x13 child carries Normal/Highlight art. Keep that child as the
// face of this retained leaf instead of consuming and losing it.
ElementInfo? face = info.StateMedia.Count == 0
? FindStatefulFaceChild(info)
: null;
string? label = ResolveAuthoredString(info, stringResolve);
ElementInfo labelInfo = info;
if (label is null)
{
Label = ResolveAuthoredString(info, stringResolve),
LabelFont = elementFont,
LabelColor = info.FontColor ?? System.Numerics.Vector4.One,
// Normal retail buttons such as the spellbook Delete control keep
// their caption in a full-size UIElement_Text child. UiButton is a
// retained leaf, so lift that authored text/font/color onto the leaf.
foreach (ElementInfo child in info.Children.Where(child => child.Type == 12u))
{
label = ResolveAuthoredString(child, stringResolve);
if (label is null) continue;
labelInfo = child;
break;
}
}
UiDatFont? labelFont = elementFont;
if (labelInfo.FontDid != 0u && fontResolve is not null)
labelFont = fontResolve(labelInfo.FontDid) ?? elementFont;
var button = new UiButton(info, resolve, face)
{
Label = label,
LabelFont = labelFont,
LabelColor = labelInfo.FontColor ?? info.FontColor
?? System.Numerics.Vector4.One,
};
if (face is not null)
{
button.FaceLeft = face.X;
button.FaceTop = face.Y;
button.FaceWidth = face.Width;
button.FaceHeight = face.Height;
button.LabelAlign = UiButton.LabelAlignment.Left;
button.LabelOffsetX = face.X + face.Width + 4f;
}
else if (!ReferenceEquals(labelInfo, info) && labelInfo.HJustify == HJustify.Left)
{
button.LabelAlign = UiButton.LabelAlignment.Left;
button.LabelOffsetX = labelInfo.X;
}
return button;
}
/// <summary>
/// Retail UIOption_Checkbox is a UIElement_Button whose visible face is its
/// authored indicator child. Its label lives on the option object rather than
@ -561,13 +611,16 @@ public static class DatWidgetFactory
ElementInfo info,
Func<uint, (uint, int, int)> resolve,
UiDatFont? elementFont,
Func<uint, UiDatFont?>? fontResolve,
Func<UiStringInfoValue, string?>? stringResolve)
{
ElementInfo? indicator = info.Children.FirstOrDefault(child => DefaultImage(child) != 0u);
ElementInfo? indicator = FindStatefulFaceChild(info);
var button = new UiButton(info, resolve, indicator)
{
Label = ResolveAuthoredString(info, stringResolve),
LabelFont = elementFont,
LabelFont = info.FontDid != 0u && fontResolve is not null
? fontResolve(info.FontDid) ?? elementFont
: elementFont,
LabelColor = info.FontColor ?? System.Numerics.Vector4.One,
LabelAlign = UiButton.LabelAlignment.Left,
};
@ -584,6 +637,13 @@ public static class DatWidgetFactory
return button;
}
private static ElementInfo? FindStatefulFaceChild(ElementInfo info)
=> info.Children.FirstOrDefault(child =>
child.StateMedia.Count != 0
&& child.StateMedia.Keys.Any(childState =>
info.States.Values.Any(parentState =>
string.Equals(parentState.Name, childState, StringComparison.Ordinal))));
private static string? ResolveAuthoredString(
ElementInfo info,
Func<UiStringInfoValue, string?>? stringResolve)

View file

@ -57,6 +57,39 @@ public sealed class SpellbookWindowControllerTests
Assert.Empty(closed);
}
[Fact]
public void RetailFixture_BindsFilterIndicatorChildren_AndDeleteCaptionChild()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadSpellbookInfos(),
_ => (0u, 0, 0),
datFont: null,
stringResolve: value => value.StringId == 164868556u ? "Delete" : "Filter");
UiButton creature = Assert.IsType<UiButton>(layout.FindElement(0x10000298u));
Assert.Equal((0f, 1f, 13f, 13f),
(creature.FaceLeft, creature.FaceTop, creature.FaceWidth, creature.FaceHeight));
Assert.Equal(UiButton.LabelAlignment.Left, creature.LabelAlign);
Assert.Equal(17f, creature.LabelOffsetX);
Assert.Equal("Filter", creature.Label);
Assert.True(creature.ToggleBehavior);
creature.Selected = true;
Assert.Equal("Highlight", creature.ActiveState);
Assert.Equal(UiButtonStateMachine.Highlight, creature.ActiveRetailStateId);
UiButton levelSix = Assert.IsType<UiButton>(layout.FindElement(0x100002A1u));
Assert.Equal(UiButton.LabelAlignment.Left, levelSix.LabelAlign);
Assert.Equal(17f, levelSix.LabelOffsetX);
UiButton delete = Assert.IsType<UiButton>(
layout.FindElement(SpellbookWindowController.DeleteButtonId));
Assert.Equal("Delete", delete.Label);
Assert.Equal(UiButton.LabelAlignment.Center, delete.LabelAlign);
Assert.Equal((187f, 61f, 100f, 32f),
(delete.Left, delete.Top, delete.FaceWidth, delete.FaceHeight));
}
[Fact]
public void LearnedSpells_AreAuthoredRows_InDisplayOrder_WithSelectionAndDragPayload()
{