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;

View file

@ -16,9 +16,8 @@ namespace AcDream.App.UI;
/// Down button element 0x10000072 — Y=32, 16×16, Normal sprite 0x06004C6C.
/// Track body sprite: 0x06004C5F (48px tall in the base template; stretched to H=68 in chat).
/// Thumb is a 3-slice: top cap 0x06004C60, middle 0x06004C63, bottom cap 0x06004C66.
/// For Task H wiring: up/down regions occupy the top and bottom ButtonH (16px) of the
/// rendered scrollbar's height; the widget responds to those regions directly via hit
/// comparison in OnEvent without requiring separate child elements.
/// The widget reproduces referenced button children procedurally and uses their
/// authored extent for drawing and hit comparison (16px in this base layout).
/// </remarks>
public sealed class UiScrollbar : UiElement
{
@ -84,16 +83,28 @@ public sealed class UiScrollbar : UiElement
/// <summary>Down-arrow button sprite id (0x06004C6C Normal state, element 0x10000072).</summary>
public uint DownSprite { get; set; }
/// <summary>
/// Authored extent of the decrement button along the scrollbar axis. Retail
/// positions and subtracts the referenced button's real size; chat uses
/// 16 pixels while the favorite-spell bar uses 23.
/// </summary>
public float DecrementButtonExtent { get; set; } = 16f;
/// <summary>Authored extent of the increment button along the scrollbar axis.</summary>
public float IncrementButtonExtent { get; set; } = 16f;
/// <summary>
/// Retail scrollbar property 0x79. When the linked scrollable is disabled
/// because its content fits, UpdateLayout also hides the scrollbar.
/// </summary>
public bool HideWhenDisabled { get; set; }
/// <summary>Retail attribute 0x89 floor: minimum thumb height in pixels.</summary>
private const float MinThumb = 8f;
/// <summary>Thumb cap height (native sprite height from base layout 0x2100003E).</summary>
private const float CapH = 3f;
/// <summary>Up/down button height in pixels. Matches element height 16px from
/// the up/down button children in base layout 0x2100003E.</summary>
private const float ButtonH = 16f;
private bool _draggingThumb;
private float _dragOffsetY;
private float _dragOffsetX;
@ -104,6 +115,18 @@ public sealed class UiScrollbar : UiElement
/// children are reproduced procedurally, so the importer must not build them.</summary>
public override bool ConsumesDatChildren => true;
/// <summary>
/// The model owns disabled state just as retail UIElement_Scrollable does:
/// no overflow means the linked scrollbar is disabled.
/// </summary>
internal bool IsModelDisabled
=> ScalarChanged is null && Model is { HasOverflow: false };
internal bool IsPresentationVisible => !HideWhenDisabled || !IsModelDisabled;
protected override bool OnHitTest(float localX, float localY)
=> !IsModelDisabled && base.OnHitTest(localX, localY);
/// <summary>
/// Computes the thumb rectangle (local y origin and height) within the track area
/// between the two end buttons. Ports retail <c>UIElement_Scrollbar::UpdateLayout
@ -138,6 +161,7 @@ public sealed class UiScrollbar : UiElement
protected override void OnDraw(UiRenderContext ctx)
{
if (!IsPresentationVisible) return;
if (SpriteResolve is not { } resolve) return;
if (Horizontal)
{
@ -173,11 +197,13 @@ public sealed class UiScrollbar : UiElement
// sprite (~16×32) repeats to fill the element height instead of stretch-distorting.
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
// Decrement/up button — top ButtonH rows (inherited element 0x10000071).
DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH);
float decrementExtent = AxisExtent(DecrementButtonExtent, Height);
float incrementExtent = AxisExtent(IncrementButtonExtent, Height);
// Increment/down button — bottom ButtonH rows (inherited element 0x10000072).
DrawSprite(ctx, resolve, DownSprite, 0f, Height - ButtonH, Width, ButtonH);
// Decrement/up and increment/down use their authored button heights.
DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, decrementExtent);
DrawSprite(ctx, resolve, DownSprite,
0f, Height - incrementExtent, Width, incrementExtent);
// Thumb — only when content overflows the view. Retail 3-slice: top cap +
// tiled middle + bottom cap (base layout 0x2100003E thumb sub-elements
@ -185,8 +211,8 @@ public sealed class UiScrollbar : UiElement
// or the thumb is too short to hold both caps.
if (m.HasOverflow)
{
float trackTop = ButtonH;
float trackLen = Height - 2f * ButtonH;
float trackTop = decrementExtent;
float trackLen = MathF.Max(0f, Height - decrementExtent - incrementExtent);
var (ty, th) = ThumbRect(m, trackTop, trackLen);
if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && th >= 2f * CapH)
{
@ -206,13 +232,16 @@ public sealed class UiScrollbar : UiElement
Func<uint, (uint tex, int w, int h)> resolve,
UiScrollable model)
{
float decrementExtent = AxisExtent(DecrementButtonExtent, Width);
float incrementExtent = AxisExtent(IncrementButtonExtent, Width);
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
DrawSprite(ctx, resolve, UpSprite, 0f, 0f, ButtonH, Height);
DrawSprite(ctx, resolve, DownSprite, Width - ButtonH, 0f, ButtonH, Height);
DrawSprite(ctx, resolve, UpSprite, 0f, 0f, decrementExtent, Height);
DrawSprite(ctx, resolve, DownSprite,
Width - incrementExtent, 0f, incrementExtent, Height);
if (!model.HasOverflow) return;
float trackLeft = ButtonH;
float trackLength = MathF.Max(0f, Width - 2f * ButtonH);
float trackLeft = decrementExtent;
float trackLength = MathF.Max(0f, Width - decrementExtent - incrementExtent);
var (tx, tw) = ThumbRect(model, trackLeft, trackLength);
if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && tw >= 2f * CapH)
{
@ -290,6 +319,12 @@ public sealed class UiScrollbar : UiElement
public override bool OnEvent(in UiEvent e)
{
if (IsModelDisabled)
{
_draggingThumb = false;
return false;
}
if (Horizontal && ScalarChanged is not null)
return OnScalarEvent(e);
@ -304,16 +339,18 @@ public sealed class UiScrollbar : UiElement
{
// e.Data1 = local X, e.Data2 = local Y (int pixel coords, see UiRoot hit dispatch).
float ly = e.Data2;
float decrementExtent = AxisExtent(DecrementButtonExtent, Height);
float incrementExtent = AxisExtent(IncrementButtonExtent, Height);
// Up-button region: top ButtonH rows.
if (ly <= ButtonH) { m.ScrollByLines(-1); return true; }
// Up-button region: authored top rows.
if (ly < decrementExtent) { m.ScrollByLines(-1); return true; }
// Down-button region: bottom ButtonH rows.
if (ly >= Height - ButtonH) { m.ScrollByLines(1); return true; }
// Down-button region: authored bottom rows.
if (ly >= Height - incrementExtent) { m.ScrollByLines(1); return true; }
// Track interior: start a thumb drag or page-scroll.
float trackTop = ButtonH;
float trackLen = Height - 2f * ButtonH;
float trackTop = decrementExtent;
float trackLen = MathF.Max(0f, Height - decrementExtent - incrementExtent);
var (ty, th) = ThumbRect(m, trackTop, trackLen);
if (ly >= ty && ly <= ty + th)
@ -334,8 +371,12 @@ public sealed class UiScrollbar : UiElement
{
// Map current local Y (minus drag offset from thumb top) back to a
// position ratio across the available travel distance.
float trackTop = ButtonH;
float trackLen = Height - 2f * ButtonH;
float trackTop = AxisExtent(DecrementButtonExtent, Height);
float trackLen = MathF.Max(
0f,
Height
- AxisExtent(DecrementButtonExtent, Height)
- AxisExtent(IncrementButtonExtent, Height));
float thumbH = MathF.Max(MinThumb, trackLen * m.ThumbRatio);
float travel = MathF.Max(1f, trackLen - thumbH);
float newRatio = ((float)e.Data2 - _dragOffsetY - trackTop) / travel;
@ -359,11 +400,13 @@ public sealed class UiScrollbar : UiElement
case UiEventType.MouseDown:
{
float x = e.Data1;
if (x <= ButtonH) { m.ScrollByLines(-1); return true; }
if (x >= Width - ButtonH) { m.ScrollByLines(1); return true; }
float decrementExtent = AxisExtent(DecrementButtonExtent, Width);
float incrementExtent = AxisExtent(IncrementButtonExtent, Width);
if (x < decrementExtent) { m.ScrollByLines(-1); return true; }
if (x >= Width - incrementExtent) { m.ScrollByLines(1); return true; }
float trackLeft = ButtonH;
float trackLength = MathF.Max(0f, Width - 2f * ButtonH);
float trackLeft = decrementExtent;
float trackLength = MathF.Max(0f, Width - decrementExtent - incrementExtent);
var (tx, tw) = ThumbRect(m, trackLeft, trackLength);
if (x >= tx && x <= tx + tw)
{
@ -379,8 +422,12 @@ public sealed class UiScrollbar : UiElement
case UiEventType.MouseMove when _draggingThumb:
{
float trackLeft = ButtonH;
float trackLength = MathF.Max(0f, Width - 2f * ButtonH);
float trackLeft = AxisExtent(DecrementButtonExtent, Width);
float trackLength = MathF.Max(
0f,
Width
- AxisExtent(DecrementButtonExtent, Width)
- AxisExtent(IncrementButtonExtent, Width));
float thumbWidth = MathF.Max(MinThumb, trackLength * m.ThumbRatio);
float travel = MathF.Max(1f, trackLength - thumbWidth);
float ratio = ((float)e.Data1 - _dragOffsetX - trackLeft) / travel;
@ -449,4 +496,7 @@ public sealed class UiScrollbar : UiElement
SetScalarPosition(position);
ScalarChanged?.Invoke(ScalarPosition);
}
private static float AxisExtent(float authoredExtent, float axisLength)
=> Math.Clamp(authoredExtent, 0f, MathF.Max(0f, axisLength));
}