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

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