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

@ -115,9 +115,15 @@ and the final two-client portal-out/materialization observer flow are
user-gated. Deterministic world-lifecycle automation protects fresh login,
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
Release builds; the structural closeout plus verified interaction/recall
regressions pass 293 focused tests, 3,462 App tests / 3 skips, and 7,835
complete-solution tests / 5 skips.
Release builds; the structural closeout plus interaction-completion Slice 1
pass 293 focused tests, 3,466 App tests / 3 skips, and 7,839 complete-solution
tests / 5 skips.
**Current work order:** the six-slice pre-M4 world-interaction completion
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite
spell-bar overflow, status Use/Assess, assessment information,
equipped-child picking, vendor browsing, and authoritative vendor
transactions. Slice 1 is code-complete and awaits its connected visual gate.
**Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated

View file

@ -113,9 +113,15 @@ and the final two-client portal-out/materialization observer flow are
user-gated. Deterministic world-lifecycle automation protects fresh login,
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
Release builds; the structural closeout plus verified interaction/recall
regressions pass 293 focused tests, 3,462 App tests / 3 skips, and 7,835
complete-solution tests / 5 skips.
Release builds; the structural closeout plus interaction-completion Slice 1
pass 293 focused tests, 3,466 App tests / 3 skips, and 7,839 complete-solution
tests / 5 skips.
**Current work order:** the six-slice pre-M4 world-interaction completion
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite
spell-bar overflow, status Use/Assess, assessment information,
equipped-child picking, vendor browsing, and authoritative vendor
transactions. Slice 1 is code-complete and awaits its connected visual gate.
**Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated

View file

@ -5,7 +5,21 @@
---
## Current program: `GameWindow` structural stabilization (M4 prerequisite)
## Current program: world interaction completion (M4 prelude)
The active work order is
[`2026-07-23-world-interaction-completion.md`](2026-07-23-world-interaction-completion.md).
It closes six remaining retail interaction surfaces before larger M4 subsystem
bodies begin: spell-bar overflow controls, status-bar Use/Assess commands,
assessment information, equipped-child picking, vendor browsing, and
server-authoritative vendor transactions. Slice 1 is active and ports the
DAT-authored arrow-only favorite-spell scrollbar through the shared retained
scrollbar/list model. Its implementation and complete Release suite pass;
connected overflow-arrow visual acceptance is pending.
---
## Completed program: `GameWindow` structural stabilization (M4 prerequisite)
**Outcome:** `GameWindow` becomes a thin Silk.NET composition/lifecycle shell
without changing accepted gameplay or rendering behavior. Focused App owners

View file

@ -81,6 +81,14 @@ recall-tail regression gates also pass. The capped/RDP jump presentation
cadence alias remains non-blocking issue #235; it is smooth uncapped and does
not alter the retail 30 Hz object clock.
The immediate M4 prelude is the six-slice
[`world interaction completion`](2026-07-23-world-interaction-completion.md)
program: spell-bar overflow, status Use/Assess, assessment information,
equipped-child picking, vendor browsing, and authoritative vendor
transactions. This is deliberately using the extracted interaction owners and
canonical shared main-panel host before quest/emote/character-creation bodies
broaden the feature surface.
Carried:
#153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus
registered TS-50/TS-51/TS-53 timing residuals (the complete-root-Frame/object-workset

View file

@ -0,0 +1,106 @@
# World interaction completion — pre-M4 program
**Status:** Slice 1 code-complete 2026-07-23; connected visual gate pending.
**Milestone:** M4 prerequisite/preamble.
**Architecture:** retained gameplay UI over shared selection, object, and
interaction state. `GameWindow` remains a composition/callback shell.
## Outcome
Close the remaining retail interaction surfaces before the larger M4
quest/emote/character-creation bodies begin:
1. Favorite spell bars expose their DAT-authored overflow arrows and scroll
through every server-persisted favorite.
2. The status bar's hand and magnifying-glass controls invoke the same Use and
Assess commands as their keyboard paths.
3. Assessing a creature, player, NPC, or object opens its retail information in
the canonical shared main-panel host.
4. World picking can resolve visible equipped children, such as a character's
wielded weapon, while selection markers remain anchored to the picked child.
5. Vendor use opens the authored vendor surface, publishes its inventory, and
supports retail selection/browsing.
6. Vendor buy/sell transactions, quantities, pending-state ownership, and
authoritative inventory reconciliation complete the loop.
The program reuses the existing retained-window host, `SelectionState`,
`ClientObjectTable`, interaction transaction owner, and server-authoritative
inventory updates. It does not create parallel panel positions, item tables,
selection state, or optimistic inventory outcomes.
## Ordered slices
| Slice | Deliverable | Principal owner |
|---|---|---|
| 1 | Spell-bar overflow arrows | `SpellcastingUiController` + generic retained scrollbar/list |
| 2 | Status Use/Assess commands | focused status-bar controller binding to the existing action router |
| 3 | Assessment information panel | new retained panel controller mounted by the canonical main-panel host |
| 4 | Equipped-child world picking | pure world-query/picking policy plus presentation anchor |
| 5 | Vendor browse lifecycle | vendor session/controller plus authored panel |
| 6 | Vendor transactions | server-authoritative buy/sell command and reconciliation owner |
Each slice begins with named-retail research, produces pseudocode and
conformance tests, updates the divergence register if required, and lands as a
separate bisectable commit. A visual gate follows each UI-bearing slice.
Slice 1 now imports both 23-pixel arrow buttons and HideDisabled from the real
combat fixture, shares the list's single pixel-scroll model, advances one
32-pixel cell per press, and exposes programmatically selected spells. The
focused 57-test set, all 3,466 App tests / 3 skips, the Release solution build,
and all 7,839 solution tests / 5 skips pass. Only the connected visual gate
remains before Slice 2 starts.
## Slice 1 — spell-bar overflow arrows
### Retail oracle
The authored Magic combat layout contains, inside every favorite-tab group:
- horizontal scrollbar `0x100000B5`, 685×36 at the 800-pixel design width;
- decrement button `0x10000071`, 23×36;
- increment button `0x10000072`, 23×36;
- item list `0x100000B6`, inset by 23 pixels on both sides and 32 pixels high.
The list references the scrollbar through `UIElement_Scrollable` property
`0x71`. The scrollbar references increment/decrement buttons through
properties `0x77`/`0x78`, and property `0x79` enables HideDisabled. There is no
track or thumb in this specific control: the two authored arrows are the whole
visible scrollbar.
Named retail references and executable pseudocode are recorded in
[`../research/2026-07-23-retail-spellbar-overflow-pseudocode.md`](../research/2026-07-23-retail-spellbar-overflow-pseudocode.md).
### Implementation plan
1. Generalize `DatWidgetFactory.BuildScrollbar` so horizontal and vertical
scrollbars both import the referenced decrement/increment children,
including their authored dimensions and HideDisabled property.
2. Generalize `UiScrollbar` to use distinct authored decrement/increment
extents for rendering, hit-testing, track geometry, and dragging. Preserve
the existing 16-pixel default for layouts whose button children are absent.
3. Reproduce retail disabled presentation: a model without overflow rejects
pointer input; when HideDisabled is authored, the scrollbar also draws
nothing and does not claim hit tests.
4. In `SpellcastingUiController`, bind each group's scrollbar to its
`UiItemList.Scroll`, enable horizontal scrolling, and retain one independent
pixel offset per favorite tab.
5. Match `SpellCastSubMenu::SetSelected`: selecting a spell through keyboard,
shortcut, or code scrolls that item into view.
6. Pin the importer, arrow hit extents, 32-pixel step, no-overflow behavior,
controller binding, and selection exposure with focused App tests.
7. Run the App Release suite, solution Release build, and complete Release
suite. Then update the roadmap/memory and request the connected visual gate:
place more favorites than fit, scroll both directions, change tabs, and
verify arrows disappear on a non-overflowing tab.
### Invariants
- DAT supplies the controls and their artwork; no new spell-bar texture or
overlay is invented.
- The scrollbar and list share one `UiScrollable`; there is no second offset.
- One arrow press moves one 32-pixel favorite cell, matching
`UIElement_ListBox::InqScrollDelta`.
- Hidden disabled arrows cannot intercept combat-page dragging or clicks.
- Existing stack, combat-power, chat, inventory, spellbook, and external
container scrollbars retain their current behavior.
- No substantial feature body enters `GameWindow`.

View file

@ -0,0 +1,141 @@
# Retail favorite spell-bar overflow pseudocode
This note is the implementation oracle for world-interaction completion Slice
1. Addresses refer to the September 2013 EoR named retail client. The exact
element graph and inherited properties were verified against combat LayoutDesc
`0x21000073` and the existing imported fixture
`combat_21000073.json`.
## Authored element graph
Each of the eight `SpellCastSubMenu` groups contains:
```text
favorite group
scrollbar 0x100000B5, 685x36
decrement button 0x10000071, 23x36
Normal 0x06004CDC
Rollover 0x06004CDD
increment button 0x10000072, 23x36
Normal 0x06004CDE
Rollover 0x06004CDF
item list 0x100000B6, x=23, y=2, 639x32
scrollbar property 0x77 = increment button 0x10000072
scrollbar property 0x78 = decrement button 0x10000071
scrollbar property 0x79 = HideDisabled true
item-list property 0x71 = horizontal scrollbar 0x100000B5
```
The spell-bar scrollbar has no authored track or thumb. Its 23-pixel buttons
occupy the space immediately outside the 639-pixel item-list viewport.
## Initialization and ownership
Retail:
- `SpellCastSubMenu::Init @ 0x004C5A90`
- `UIElement_Scrollable::PostInit @ 0x004744B0`
- `UIElement_Scrollable::OnSetAttribute @ 0x00474630`
```text
SpellCastSubMenu.Init(parent, groupId, tabId, tabIndex):
group = parent.GetChildRecursive(groupId)
spellItemList = group.GetChildRecursive(0x100000B6)
register this as the item-list drag handler
spellTabElement = parent.GetChildRecursive(tabId)
UIElement_Scrollable.OnSetAttribute(property):
if property id == 0x71:
horizontalScrollbarId = property element id
UIElement_Scrollable.PostInit():
horizontalScrollbar = resolve horizontalScrollbarId
register for scrollbar messages
UpdateScrollbarSize(horizontal=true)
```
The controller does not synthesize arrow buttons. LayoutDesc owns them; the
ordinary scrollable/listbox relationship owns the offset.
## Size, disabled state, and visibility
Retail:
- `UIElement_Scrollable::UpdateScrollbarSize_ @ 0x004741A0`
- `UIElement_Scrollbar::SetDisabled @ 0x004703D0`
- `UIElement_Scrollbar::SetHideDisabled @ 0x00470400`
- `UIElement_Scrollbar::UpdateLayout @ 0x004710D0`
```text
UpdateScrollbarSize(horizontal):
content = scrollableWidth
viewport = element.Width
if content <= viewport:
content = viewport
scrollOffsetX = 0
disabled = true
else:
disabled = false
scrollbar.Horizontal = true
scrollbar.Disabled = disabled
scrollbar.Proportion = min(1, viewport / content)
Scrollbar.UpdateLayout():
scrollingArea = own rectangle
position decrement button at the start and subtract its authored width
position increment button at the end and subtract its authored width
update thumb/track geometry, when those children exist
Visible = NOT (Disabled AND HideDisabled)
```
For the favorite bar, disabled plus HideDisabled removes both arrows when every
favorite/empty cell fits. With overflow, the arrows remain visible and the
item list clips the horizontally translated cells.
## Scroll delta and selection exposure
Retail:
- `UIElement_Scrollable::HandleScrollbarMessage_ @ 0x00474370`
- `UIElement_ListBox::InqScrollDelta @ 0x0046DC10`
- `SpellCastSubMenu::SetSelected @ 0x004C5B00`
```text
on decrement:
delta = -itemList.InqScrollDelta(horizontal, line)
set horizontal pixel offset += delta
on increment:
delta = +itemList.InqScrollDelta(horizontal, line)
set horizontal pixel offset += delta
ItemList.InqScrollDelta(horizontal, line):
if column count is nonzero:
return min(viewportWidth, scrollableWidth / columnCount)
return 0
SpellCastSubMenu.SetSelected(spellId):
for each item in list order:
item.Selected = item.spellId == spellId
if selected:
endowmentSelected = false
itemList.ScrollToView(itemIndex)
selectedSpellId = spellId
```
The favorite cells are 32 pixels wide, so a line arrow moves exactly 32
pixels. Programmatic or keyboard selection exposes the selected cell even when
it was outside the prior viewport.
## Port constraints
- Preserve the imported `0x71`/`0x77`/`0x78` relationships.
- Preserve each authored button's dimension; do not assume the 16-pixel chat
scrollbar button size.
- Do not fabricate a track/thumb for this arrow-only control.
- Derive disabled state from the shared model's content and viewport extents.
- Hide disabled controls from both drawing and hit-testing.
- Keep every favorite tab's scroll offset independent.

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));
}

View file

@ -223,6 +223,60 @@ public class DatWidgetFactoryTests
Assert.Equal(0u, bar.DownSprite);
}
[Fact]
public void Type11_HorizontalScrollbar_ImportsAuthoredArrowButtonsAndHideDisabled()
{
const uint DecrementId = 0x10000071u;
const uint IncrementId = 0x10000072u;
const uint DecrementSprite = 0x06004CDCu;
const uint IncrementSprite = 0x06004CDEu;
var decrement = new ElementInfo
{
Id = DecrementId, Type = 1u, Width = 23f, Height = 36f,
};
decrement.States[UiStateInfo.DirectStateId] = new UiStateInfo
{
Id = UiStateInfo.DirectStateId,
Image = new UiImageMedia(DecrementSprite, 1),
};
var increment = new ElementInfo
{
Id = IncrementId, Type = 1u, Width = 23f, Height = 36f,
};
increment.States[UiStateInfo.DirectStateId] = new UiStateInfo
{
Id = UiStateInfo.DirectStateId,
Image = new UiImageMedia(IncrementSprite, 1),
};
var info = new ElementInfo
{
Type = 11u,
Id = SpellcastingUiController.FavoriteScrollbarId,
Width = 685f,
Height = 36f,
Children = [decrement, increment],
};
var state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
state.Properties.Values[0x77u] = new UiPropertyValue
{ Kind = UiPropertyKind.Enum, UnsignedValue = IncrementId };
state.Properties.Values[0x78u] = new UiPropertyValue
{ Kind = UiPropertyKind.Enum, UnsignedValue = DecrementId };
state.Properties.Values[0x79u] = Bool(true);
info.States[UiStateInfo.DirectStateId] = state;
var bar = Assert.IsType<UiScrollbar>(
DatWidgetFactory.Create(info, NoTex, null));
Assert.True(bar.Horizontal);
Assert.True(bar.HideWhenDisabled);
Assert.Equal(23f, bar.DecrementButtonExtent);
Assert.Equal(23f, bar.IncrementButtonExtent);
Assert.Equal(DecrementSprite, bar.UpSprite);
Assert.Equal(IncrementSprite, bar.DownSprite);
Assert.Equal(0u, bar.TrackSprite);
Assert.Equal(0u, bar.ThumbSprite);
}
[Fact]
public void RetailToolbarFixture_buildsEditableStackEntry_andAuthoredHorizontalSlider()
{

View file

@ -34,8 +34,19 @@ public sealed class SpellcastingUiControllerTests
UiElement group = layout.FindElement(0x100000AAu)!;
UiItemList list = Descendants(group).OfType<UiItemList>().First();
UiScrollbar scrollbar = Descendants(group).OfType<UiScrollbar>().Single(
candidate => candidate.DatElementId == SpellcastingUiController.FavoriteScrollbarId);
Assert.True(list.SingleRow);
Assert.True(list.HorizontalScroll);
Assert.True(list.FillVisibleEmptySlots);
Assert.Same(list.Scroll, scrollbar.Model);
Assert.True(scrollbar.Horizontal);
Assert.True(scrollbar.HideWhenDisabled);
Assert.Equal(23f, scrollbar.DecrementButtonExtent);
Assert.Equal(23f, scrollbar.IncrementButtonExtent);
Assert.Equal(0x06004CDCu, scrollbar.UpSprite);
Assert.Equal(0x06004CDEu, scrollbar.DownSprite);
Assert.False(scrollbar.IsPresentationVisible);
Assert.Equal(439f, list.Width);
Assert.Equal(13, list.GetNumUIItems());
@ -71,6 +82,43 @@ public sealed class SpellcastingUiControllerTests
cast.FaceSegmentRectsForTest());
}
[Fact]
public void FavoriteOverflow_ArrowButtonsStepOneCell_AndSelectionScrollsIntoView()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
ApplyAnchors(layout.Root);
var spellbook = new Spellbook();
for (uint spellId = 1u; spellId <= 20u; spellId++)
{
spellbook.OnSpellLearned(spellId, 1f);
spellbook.SetFavorite(0, (int)spellId - 1, spellId);
}
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
using SpellcastingUiController controller = Bind(
layout, spellbook, objects, _ => { })!;
UiElement group = layout.FindElement(0x100000AAu)!;
UiItemList list = Descendants(group).OfType<UiItemList>().Single();
UiScrollbar scrollbar = Descendants(group).OfType<UiScrollbar>().Single(
candidate => candidate.DatElementId == SpellcastingUiController.FavoriteScrollbarId);
Assert.True(scrollbar.IsPresentationVisible);
Assert.Equal(201, list.Scroll.MaxScroll);
Assert.True(scrollbar.OnEvent(new UiEvent(
0u, scrollbar, UiEventType.MouseDown, Data1: (int)scrollbar.Width - 1)));
Assert.Equal(32, list.Scroll.ScrollY);
Assert.True(scrollbar.OnEvent(new UiEvent(
0u, scrollbar, UiEventType.MouseDown, Data1: 0)));
Assert.Equal(0, list.Scroll.ScrollY);
controller.AddFavorite(20u);
Assert.Equal(list.Scroll.MaxScroll, list.Scroll.ScrollY);
}
[Fact]
public void ImportedFixture_BindsAllTabs_AndTracksEquippedEndowment()
{

View file

@ -128,6 +128,60 @@ public class UiScrollbarTests
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 144)));
}
[Fact]
public void HorizontalModel_UsesAuthoredArrowExtentsAndOneCellStep()
{
var model = new UiScrollable
{
ContentHeight = 640,
ViewHeight = 320,
LineHeight = 32,
};
model.SetScrollY(64);
var bar = new UiScrollbar
{
Width = 160f,
Height = 36f,
Horizontal = true,
Model = model,
DecrementButtonExtent = 23f,
IncrementButtonExtent = 29f,
};
Assert.True(bar.OnEvent(new UiEvent(
0u, bar, UiEventType.MouseDown, Data1: 22)));
Assert.Equal(32, model.ScrollY);
Assert.True(bar.OnEvent(new UiEvent(
0u, bar, UiEventType.MouseDown, Data1: 131)));
Assert.Equal(64, model.ScrollY);
}
[Fact]
public void ModelWithoutOverflow_IsDisabledAndHideDisabledSuppressesPresentation()
{
var model = new UiScrollable
{
ContentHeight = 320,
ViewHeight = 320,
LineHeight = 32,
};
var bar = new UiScrollbar
{
Width = 160f,
Height = 36f,
Horizontal = true,
Model = model,
HideWhenDisabled = true,
};
Assert.True(bar.IsModelDisabled);
Assert.False(bar.IsPresentationVisible);
Assert.False(bar.OnEvent(new UiEvent(
0u, bar, UiEventType.MouseDown, Data1: 159)));
Assert.Equal(0, model.ScrollY);
}
[Theory]
[InlineData(0f, 0f, 0f)]
[InlineData(0.5f, 0f, 50f)]