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:
parent
20e2998adb
commit
02c29e67c8
12 changed files with 582 additions and 45 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue