feat(ui): port favorite spell bar overflow

Import the retail arrow-only spell bar scrollbar from LayoutDesc, preserve authored end-button extents and HideDisabled behavior in the shared retained widget, and bind each favorite list to its sole horizontal pixel-scroll model. Match retail selection exposure for off-screen spells and pin the behavior with real-fixture conformance tests.

Document the six-slice world-interaction completion program as the active pre-M4 work order.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-23 06:35:53 +02:00
parent 20e2998adb
commit 02c29e67c8
12 changed files with 582 additions and 45 deletions

View file

@ -140,8 +140,22 @@ public static class DatWidgetFactory
Horizontal = info.Width > info.Height,
};
uint incrementId = ReferencedElementId(info, 0x77u);
uint decrementId = ReferencedElementId(info, 0x78u);
ElementInfo? increment = info.Children.FirstOrDefault(child => child.Id == incrementId);
ElementInfo? decrement = info.Children.FirstOrDefault(child => child.Id == decrementId);
bar.UpSprite = decrement is null ? 0u : DefaultImage(decrement);
bar.DownSprite = increment is null ? 0u : DefaultImage(increment);
if (info.TryGetEffectiveBool(0x79u, out bool hideDisabled))
bar.HideWhenDisabled = hideDisabled;
if (bar.Horizontal)
{
if (decrement is { Width: > 0f })
bar.DecrementButtonExtent = decrement.Width;
if (increment is { Width: > 0f })
bar.IncrementButtonExtent = increment.Width;
// Retail horizontal scrollbars use structural child ids: element 1 is
// the thumb and element 4 is the optional child-authored track.
ElementInfo? scalarThumb = info.Children.FirstOrDefault(child => child.Id == 1u);
@ -193,12 +207,10 @@ public static class DatWidgetFactory
return bar;
}
uint incrementId = ReferencedElementId(info, 0x77u);
uint decrementId = ReferencedElementId(info, 0x78u);
ElementInfo? increment = info.Children.FirstOrDefault(child => child.Id == incrementId);
ElementInfo? decrement = info.Children.FirstOrDefault(child => child.Id == decrementId);
bar.UpSprite = decrement is null ? 0u : DefaultImage(decrement);
bar.DownSprite = increment is null ? 0u : DefaultImage(increment);
if (decrement is { Height: > 0f })
bar.DecrementButtonExtent = decrement.Height;
if (increment is { Height: > 0f })
bar.IncrementButtonExtent = increment.Height;
ElementInfo? thumb = info.Children.FirstOrDefault(child =>
child.Type == 1u && child.Id != incrementId && child.Id != decrementId);

View file

@ -21,6 +21,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
public const uint SpellNameId = 0x1000048Bu;
public const uint EndowmentId = 0x100000B1u;
public const uint CastButtonId = 0x100000B2u;
public const uint FavoriteScrollbarId = 0x100000B5u;
public const uint FavoriteListId = 0x100000B6u;
private static readonly uint[] TabIds =
@ -50,6 +51,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
private readonly UiElement[] _tabs;
private readonly UiElement[] _groups;
private readonly UiItemList?[] _lists;
private readonly UiScrollbar?[] _scrollbars;
private readonly UiButton _cast;
private readonly UiText? _spellName;
private readonly UiElement _endowmentHost;
@ -78,6 +80,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
UiElement[] tabs,
UiElement[] groups,
UiItemList?[] lists,
UiScrollbar?[] scrollbars,
UiButton cast,
UiElement endowmentHost,
UiShortcutDigitGraphics? shortcutDigits,
@ -98,6 +101,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
_tabs = tabs;
_groups = groups;
_lists = lists;
_scrollbars = scrollbars;
_cast = cast;
_spellName = layout.FindElement(SpellNameId) as UiText;
_endowmentHost = endowmentHost;
@ -114,6 +118,16 @@ public sealed class SpellcastingUiController : IRetainedPanelController
_endowmentSlot.DoubleClicked = () => { SelectEndowment(); CastSelected(); };
_endowmentHost.AddChild(_endowmentSlot);
for (int i = 0; i < _lists.Length; i++)
{
if (_lists[i] is not { } list || _scrollbars[i] is not { } scrollbar)
continue;
list.HorizontalScroll = true;
scrollbar.Horizontal = true;
scrollbar.Model = list.Scroll;
scrollbar.SpriteResolve ??= list.SpriteResolve;
}
for (int i = 0; i < tabs.Length; i++)
{
int index = i;
@ -156,6 +170,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
var tabs = new UiElement[8];
var groups = new UiElement[8];
var lists = new UiItemList?[8];
var scrollbars = new UiScrollbar?[8];
for (int i = 0; i < 8; i++)
{
if (layout.FindElement(TabIds[i]) is not { } tab
@ -164,13 +179,15 @@ public sealed class SpellcastingUiController : IRetainedPanelController
tabs[i] = tab;
groups[i] = group;
lists[i] = Descendants(group).OfType<UiItemList>().FirstOrDefault();
scrollbars[i] = Descendants(group).OfType<UiScrollbar>().FirstOrDefault(
scrollbar => scrollbar.DatElementId == FavoriteScrollbarId);
}
return new SpellcastingUiController(
layout, spellbook, casting, objects, playerGuid, resolveSpellIcon,
resolveItemDragIcon, useItem, selection,
addFavorite, removeFavorite,
tabs, groups, lists, cast, endowmentHost,
tabs, groups, lists, scrollbars, cast, endowmentHost,
shortcutDigits, emptySlotSprite);
}
@ -234,6 +251,8 @@ public sealed class SpellcastingUiController : IRetainedPanelController
_endowmentSelected[_activeTab] = favorites.Count == 0 && _endowmentItemId != 0u;
}
SyncSelection();
if (_selected[_activeTab] is uint visibleSpell)
ScrollSelectedSpellIntoView(_activeTab, visibleSpell);
UpdateCastAvailability();
}
@ -256,9 +275,27 @@ public sealed class SpellcastingUiController : IRetainedPanelController
_endowmentSelected[_activeTab] = false;
_selected[_activeTab] = spellId;
SyncSelection();
ScrollSelectedSpellIntoView(_activeTab, spellId);
UpdateCastAvailability();
}
private void ScrollSelectedSpellIntoView(int tab, uint spellId)
{
UiItemList? list = _lists[tab];
if (list is null) return;
for (int i = 0; i < list.GetNumUIItems(); i++)
{
if (list.GetItem(i) is UiCatalogSlot { EntryId: var entryId }
&& entryId == spellId)
{
// SpellCastSubMenu::SetSelected @ 0x004C5B00 exposes the
// selected UIItem through UIElement_ListBox::ScrollToView.
list.ScrollItemIntoView(i);
return;
}
}
}
private void CastSelected()
{
if (_endowmentSelected[_activeTab] && _endowmentItemId != 0u)
@ -294,6 +331,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
{
list.Flush();
list.SingleRow = true;
list.HorizontalScroll = true;
list.CellWidth = 32f;
list.CellHeight = 32f;
list.CellEmptySprite = _emptySlotSprite;