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 selected overlay child 0x10000342 -> 0x06001397
label child 0x10000344 (x=42, width=230) label child 0x10000344 (x=42, width=230)
icon child 0x1000033B (x=0, 32 x 32) 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 button 0x100002A5
delete label child 0x100002A6 (100 x 32, centered, font 0x40000001)
``` ```
The row's authored background contains the narrow horizontal separator visible The row's authored background contains the narrow horizontal separator visible
between entries. It is not an invented procedural line. The selection surface 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. 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 ```text
AddSpell(spellID): AddSpell(spellID):

View file

@ -76,9 +76,9 @@ public static class DatWidgetFactory
UiElement e = info.Type switch UiElement e = info.Type switch
{ {
UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80) 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( EffectsIndicatorController.RetailClassId => BuildButton(
info, resolve, elementFont, stringResolve), // gmUIElement_EffectsIndicator info, resolve, elementFont, fontResolve, stringResolve), // gmUIElement_EffectsIndicator
6 => new UiMenu(), // UIElement_Menu (reg :120163) 6 => new UiMenu(), // UIElement_Menu (reg :120163)
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter 7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf 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 12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text
0x13 => new UiDialogRoot(), // ConfirmationDialog 0x13 => new UiDialogRoot(), // ConfirmationDialog
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots 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) _ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
}; };
@ -544,14 +545,63 @@ public static class DatWidgetFactory
ElementInfo info, ElementInfo info,
Func<uint, (uint, int, int)> resolve, Func<uint, (uint, int, int)> resolve,
UiDatFont? elementFont, UiDatFont? elementFont,
Func<uint, UiDatFont?>? fontResolve,
Func<UiStringInfoValue, string?>? stringResolve) 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), // Normal retail buttons such as the spellbook Delete control keep
LabelFont = elementFont, // their caption in a full-size UIElement_Text child. UiButton is a
LabelColor = info.FontColor ?? System.Numerics.Vector4.One, // 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> /// <summary>
/// Retail UIOption_Checkbox is a UIElement_Button whose visible face is its /// Retail UIOption_Checkbox is a UIElement_Button whose visible face is its
/// authored indicator child. Its label lives on the option object rather than /// authored indicator child. Its label lives on the option object rather than
@ -561,13 +611,16 @@ public static class DatWidgetFactory
ElementInfo info, ElementInfo info,
Func<uint, (uint, int, int)> resolve, Func<uint, (uint, int, int)> resolve,
UiDatFont? elementFont, UiDatFont? elementFont,
Func<uint, UiDatFont?>? fontResolve,
Func<UiStringInfoValue, string?>? stringResolve) Func<UiStringInfoValue, string?>? stringResolve)
{ {
ElementInfo? indicator = info.Children.FirstOrDefault(child => DefaultImage(child) != 0u); ElementInfo? indicator = FindStatefulFaceChild(info);
var button = new UiButton(info, resolve, indicator) var button = new UiButton(info, resolve, indicator)
{ {
Label = ResolveAuthoredString(info, stringResolve), 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, LabelColor = info.FontColor ?? System.Numerics.Vector4.One,
LabelAlign = UiButton.LabelAlignment.Left, LabelAlign = UiButton.LabelAlignment.Left,
}; };
@ -584,6 +637,13 @@ public static class DatWidgetFactory
return button; 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( private static string? ResolveAuthoredString(
ElementInfo info, ElementInfo info,
Func<UiStringInfoValue, string?>? stringResolve) Func<UiStringInfoValue, string?>? stringResolve)

View file

@ -57,6 +57,39 @@ public sealed class SpellbookWindowControllerTests
Assert.Empty(closed); 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] [Fact]
public void LearnedSpells_AreAuthoredRows_InDisplayOrder_WithSelectionAndDragPayload() public void LearnedSpells_AreAuthoredRows_InDisplayOrder_WithSelectionAndDragPayload()
{ {