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, user-gated. Deterministic world-lifecycle automation protects fresh login,
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
Release builds; the structural closeout plus verified interaction/recall Release builds; the structural closeout plus interaction-completion Slice 1
regressions pass 293 focused tests, 3,462 App tests / 3 skips, and 7,835 pass 293 focused tests, 3,466 App tests / 3 skips, and 7,839 complete-solution
complete-solution tests / 5 skips. 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 **Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated 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, user-gated. Deterministic world-lifecycle automation protects fresh login,
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
Release builds; the structural closeout plus verified interaction/recall Release builds; the structural closeout plus interaction-completion Slice 1
regressions pass 293 focused tests, 3,462 App tests / 3 skips, and 7,835 pass 293 focused tests, 3,466 App tests / 3 skips, and 7,839 complete-solution
complete-solution tests / 5 skips. 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 **Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated 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 **Outcome:** `GameWindow` becomes a thin Silk.NET composition/lifecycle shell
without changing accepted gameplay or rendering behavior. Focused App owners 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 cadence alias remains non-blocking issue #235; it is smooth uncapped and does
not alter the retail 30 Hz object clock. 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: Carried:
#153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus #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 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, 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 (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 // Retail horizontal scrollbars use structural child ids: element 1 is
// the thumb and element 4 is the optional child-authored track. // the thumb and element 4 is the optional child-authored track.
ElementInfo? scalarThumb = info.Children.FirstOrDefault(child => child.Id == 1u); ElementInfo? scalarThumb = info.Children.FirstOrDefault(child => child.Id == 1u);
@ -193,12 +207,10 @@ public static class DatWidgetFactory
return bar; return bar;
} }
uint incrementId = ReferencedElementId(info, 0x77u); if (decrement is { Height: > 0f })
uint decrementId = ReferencedElementId(info, 0x78u); bar.DecrementButtonExtent = decrement.Height;
ElementInfo? increment = info.Children.FirstOrDefault(child => child.Id == incrementId); if (increment is { Height: > 0f })
ElementInfo? decrement = info.Children.FirstOrDefault(child => child.Id == decrementId); bar.IncrementButtonExtent = increment.Height;
bar.UpSprite = decrement is null ? 0u : DefaultImage(decrement);
bar.DownSprite = increment is null ? 0u : DefaultImage(increment);
ElementInfo? thumb = info.Children.FirstOrDefault(child => ElementInfo? thumb = info.Children.FirstOrDefault(child =>
child.Type == 1u && child.Id != incrementId && child.Id != decrementId); 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 SpellNameId = 0x1000048Bu;
public const uint EndowmentId = 0x100000B1u; public const uint EndowmentId = 0x100000B1u;
public const uint CastButtonId = 0x100000B2u; public const uint CastButtonId = 0x100000B2u;
public const uint FavoriteScrollbarId = 0x100000B5u;
public const uint FavoriteListId = 0x100000B6u; public const uint FavoriteListId = 0x100000B6u;
private static readonly uint[] TabIds = private static readonly uint[] TabIds =
@ -50,6 +51,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
private readonly UiElement[] _tabs; private readonly UiElement[] _tabs;
private readonly UiElement[] _groups; private readonly UiElement[] _groups;
private readonly UiItemList?[] _lists; private readonly UiItemList?[] _lists;
private readonly UiScrollbar?[] _scrollbars;
private readonly UiButton _cast; private readonly UiButton _cast;
private readonly UiText? _spellName; private readonly UiText? _spellName;
private readonly UiElement _endowmentHost; private readonly UiElement _endowmentHost;
@ -78,6 +80,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
UiElement[] tabs, UiElement[] tabs,
UiElement[] groups, UiElement[] groups,
UiItemList?[] lists, UiItemList?[] lists,
UiScrollbar?[] scrollbars,
UiButton cast, UiButton cast,
UiElement endowmentHost, UiElement endowmentHost,
UiShortcutDigitGraphics? shortcutDigits, UiShortcutDigitGraphics? shortcutDigits,
@ -98,6 +101,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
_tabs = tabs; _tabs = tabs;
_groups = groups; _groups = groups;
_lists = lists; _lists = lists;
_scrollbars = scrollbars;
_cast = cast; _cast = cast;
_spellName = layout.FindElement(SpellNameId) as UiText; _spellName = layout.FindElement(SpellNameId) as UiText;
_endowmentHost = endowmentHost; _endowmentHost = endowmentHost;
@ -114,6 +118,16 @@ public sealed class SpellcastingUiController : IRetainedPanelController
_endowmentSlot.DoubleClicked = () => { SelectEndowment(); CastSelected(); }; _endowmentSlot.DoubleClicked = () => { SelectEndowment(); CastSelected(); };
_endowmentHost.AddChild(_endowmentSlot); _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++) for (int i = 0; i < tabs.Length; i++)
{ {
int index = i; int index = i;
@ -156,6 +170,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
var tabs = new UiElement[8]; var tabs = new UiElement[8];
var groups = new UiElement[8]; var groups = new UiElement[8];
var lists = new UiItemList?[8]; var lists = new UiItemList?[8];
var scrollbars = new UiScrollbar?[8];
for (int i = 0; i < 8; i++) for (int i = 0; i < 8; i++)
{ {
if (layout.FindElement(TabIds[i]) is not { } tab if (layout.FindElement(TabIds[i]) is not { } tab
@ -164,13 +179,15 @@ public sealed class SpellcastingUiController : IRetainedPanelController
tabs[i] = tab; tabs[i] = tab;
groups[i] = group; groups[i] = group;
lists[i] = Descendants(group).OfType<UiItemList>().FirstOrDefault(); lists[i] = Descendants(group).OfType<UiItemList>().FirstOrDefault();
scrollbars[i] = Descendants(group).OfType<UiScrollbar>().FirstOrDefault(
scrollbar => scrollbar.DatElementId == FavoriteScrollbarId);
} }
return new SpellcastingUiController( return new SpellcastingUiController(
layout, spellbook, casting, objects, playerGuid, resolveSpellIcon, layout, spellbook, casting, objects, playerGuid, resolveSpellIcon,
resolveItemDragIcon, useItem, selection, resolveItemDragIcon, useItem, selection,
addFavorite, removeFavorite, addFavorite, removeFavorite,
tabs, groups, lists, cast, endowmentHost, tabs, groups, lists, scrollbars, cast, endowmentHost,
shortcutDigits, emptySlotSprite); shortcutDigits, emptySlotSprite);
} }
@ -234,6 +251,8 @@ public sealed class SpellcastingUiController : IRetainedPanelController
_endowmentSelected[_activeTab] = favorites.Count == 0 && _endowmentItemId != 0u; _endowmentSelected[_activeTab] = favorites.Count == 0 && _endowmentItemId != 0u;
} }
SyncSelection(); SyncSelection();
if (_selected[_activeTab] is uint visibleSpell)
ScrollSelectedSpellIntoView(_activeTab, visibleSpell);
UpdateCastAvailability(); UpdateCastAvailability();
} }
@ -256,9 +275,27 @@ public sealed class SpellcastingUiController : IRetainedPanelController
_endowmentSelected[_activeTab] = false; _endowmentSelected[_activeTab] = false;
_selected[_activeTab] = spellId; _selected[_activeTab] = spellId;
SyncSelection(); SyncSelection();
ScrollSelectedSpellIntoView(_activeTab, spellId);
UpdateCastAvailability(); 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() private void CastSelected()
{ {
if (_endowmentSelected[_activeTab] && _endowmentItemId != 0u) if (_endowmentSelected[_activeTab] && _endowmentItemId != 0u)
@ -294,6 +331,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
{ {
list.Flush(); list.Flush();
list.SingleRow = true; list.SingleRow = true;
list.HorizontalScroll = true;
list.CellWidth = 32f; list.CellWidth = 32f;
list.CellHeight = 32f; list.CellHeight = 32f;
list.CellEmptySprite = _emptySlotSprite; list.CellEmptySprite = _emptySlotSprite;

View file

@ -16,9 +16,8 @@ namespace AcDream.App.UI;
/// Down button element 0x10000072 — Y=32, 16×16, Normal sprite 0x06004C6C. /// 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). /// 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. /// 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 /// The widget reproduces referenced button children procedurally and uses their
/// rendered scrollbar's height; the widget responds to those regions directly via hit /// authored extent for drawing and hit comparison (16px in this base layout).
/// comparison in OnEvent without requiring separate child elements.
/// </remarks> /// </remarks>
public sealed class UiScrollbar : UiElement 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> /// <summary>Down-arrow button sprite id (0x06004C6C Normal state, element 0x10000072).</summary>
public uint DownSprite { get; set; } 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> /// <summary>Retail attribute 0x89 floor: minimum thumb height in pixels.</summary>
private const float MinThumb = 8f; private const float MinThumb = 8f;
/// <summary>Thumb cap height (native sprite height from base layout 0x2100003E).</summary> /// <summary>Thumb cap height (native sprite height from base layout 0x2100003E).</summary>
private const float CapH = 3f; 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 bool _draggingThumb;
private float _dragOffsetY; private float _dragOffsetY;
private float _dragOffsetX; private float _dragOffsetX;
@ -104,6 +115,18 @@ public sealed class UiScrollbar : UiElement
/// children are reproduced procedurally, so the importer must not build them.</summary> /// children are reproduced procedurally, so the importer must not build them.</summary>
public override bool ConsumesDatChildren => true; 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> /// <summary>
/// Computes the thumb rectangle (local y origin and height) within the track area /// Computes the thumb rectangle (local y origin and height) within the track area
/// between the two end buttons. Ports retail <c>UIElement_Scrollbar::UpdateLayout /// 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) protected override void OnDraw(UiRenderContext ctx)
{ {
if (!IsPresentationVisible) return;
if (SpriteResolve is not { } resolve) return; if (SpriteResolve is not { } resolve) return;
if (Horizontal) if (Horizontal)
{ {
@ -173,11 +197,13 @@ public sealed class UiScrollbar : UiElement
// sprite (~16×32) repeats to fill the element height instead of stretch-distorting. // sprite (~16×32) repeats to fill the element height instead of stretch-distorting.
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height); DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
// Decrement/up button — top ButtonH rows (inherited element 0x10000071). float decrementExtent = AxisExtent(DecrementButtonExtent, Height);
DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH); float incrementExtent = AxisExtent(IncrementButtonExtent, Height);
// Increment/down button — bottom ButtonH rows (inherited element 0x10000072). // Decrement/up and increment/down use their authored button heights.
DrawSprite(ctx, resolve, DownSprite, 0f, Height - ButtonH, Width, ButtonH); 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 + // Thumb — only when content overflows the view. Retail 3-slice: top cap +
// tiled middle + bottom cap (base layout 0x2100003E thumb sub-elements // 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. // or the thumb is too short to hold both caps.
if (m.HasOverflow) if (m.HasOverflow)
{ {
float trackTop = ButtonH; float trackTop = decrementExtent;
float trackLen = Height - 2f * ButtonH; float trackLen = MathF.Max(0f, Height - decrementExtent - incrementExtent);
var (ty, th) = ThumbRect(m, trackTop, trackLen); var (ty, th) = ThumbRect(m, trackTop, trackLen);
if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && th >= 2f * CapH) 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, Func<uint, (uint tex, int w, int h)> resolve,
UiScrollable model) UiScrollable model)
{ {
float decrementExtent = AxisExtent(DecrementButtonExtent, Width);
float incrementExtent = AxisExtent(IncrementButtonExtent, Width);
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height); DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
DrawSprite(ctx, resolve, UpSprite, 0f, 0f, ButtonH, Height); DrawSprite(ctx, resolve, UpSprite, 0f, 0f, decrementExtent, Height);
DrawSprite(ctx, resolve, DownSprite, Width - ButtonH, 0f, ButtonH, Height); DrawSprite(ctx, resolve, DownSprite,
Width - incrementExtent, 0f, incrementExtent, Height);
if (!model.HasOverflow) return; if (!model.HasOverflow) return;
float trackLeft = ButtonH; float trackLeft = decrementExtent;
float trackLength = MathF.Max(0f, Width - 2f * ButtonH); float trackLength = MathF.Max(0f, Width - decrementExtent - incrementExtent);
var (tx, tw) = ThumbRect(model, trackLeft, trackLength); var (tx, tw) = ThumbRect(model, trackLeft, trackLength);
if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && tw >= 2f * CapH) if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && tw >= 2f * CapH)
{ {
@ -290,6 +319,12 @@ public sealed class UiScrollbar : UiElement
public override bool OnEvent(in UiEvent e) public override bool OnEvent(in UiEvent e)
{ {
if (IsModelDisabled)
{
_draggingThumb = false;
return false;
}
if (Horizontal && ScalarChanged is not null) if (Horizontal && ScalarChanged is not null)
return OnScalarEvent(e); 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). // e.Data1 = local X, e.Data2 = local Y (int pixel coords, see UiRoot hit dispatch).
float ly = e.Data2; float ly = e.Data2;
float decrementExtent = AxisExtent(DecrementButtonExtent, Height);
float incrementExtent = AxisExtent(IncrementButtonExtent, Height);
// Up-button region: top ButtonH rows. // Up-button region: authored top rows.
if (ly <= ButtonH) { m.ScrollByLines(-1); return true; } if (ly < decrementExtent) { m.ScrollByLines(-1); return true; }
// Down-button region: bottom ButtonH rows. // Down-button region: authored bottom rows.
if (ly >= Height - ButtonH) { m.ScrollByLines(1); return true; } if (ly >= Height - incrementExtent) { m.ScrollByLines(1); return true; }
// Track interior: start a thumb drag or page-scroll. // Track interior: start a thumb drag or page-scroll.
float trackTop = ButtonH; float trackTop = decrementExtent;
float trackLen = Height - 2f * ButtonH; float trackLen = MathF.Max(0f, Height - decrementExtent - incrementExtent);
var (ty, th) = ThumbRect(m, trackTop, trackLen); var (ty, th) = ThumbRect(m, trackTop, trackLen);
if (ly >= ty && ly <= ty + th) 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 // Map current local Y (minus drag offset from thumb top) back to a
// position ratio across the available travel distance. // position ratio across the available travel distance.
float trackTop = ButtonH; float trackTop = AxisExtent(DecrementButtonExtent, Height);
float trackLen = Height - 2f * ButtonH; float trackLen = MathF.Max(
0f,
Height
- AxisExtent(DecrementButtonExtent, Height)
- AxisExtent(IncrementButtonExtent, Height));
float thumbH = MathF.Max(MinThumb, trackLen * m.ThumbRatio); float thumbH = MathF.Max(MinThumb, trackLen * m.ThumbRatio);
float travel = MathF.Max(1f, trackLen - thumbH); float travel = MathF.Max(1f, trackLen - thumbH);
float newRatio = ((float)e.Data2 - _dragOffsetY - trackTop) / travel; float newRatio = ((float)e.Data2 - _dragOffsetY - trackTop) / travel;
@ -359,11 +400,13 @@ public sealed class UiScrollbar : UiElement
case UiEventType.MouseDown: case UiEventType.MouseDown:
{ {
float x = e.Data1; float x = e.Data1;
if (x <= ButtonH) { m.ScrollByLines(-1); return true; } float decrementExtent = AxisExtent(DecrementButtonExtent, Width);
if (x >= Width - ButtonH) { m.ScrollByLines(1); return true; } 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 trackLeft = decrementExtent;
float trackLength = MathF.Max(0f, Width - 2f * ButtonH); float trackLength = MathF.Max(0f, Width - decrementExtent - incrementExtent);
var (tx, tw) = ThumbRect(m, trackLeft, trackLength); var (tx, tw) = ThumbRect(m, trackLeft, trackLength);
if (x >= tx && x <= tx + tw) if (x >= tx && x <= tx + tw)
{ {
@ -379,8 +422,12 @@ public sealed class UiScrollbar : UiElement
case UiEventType.MouseMove when _draggingThumb: case UiEventType.MouseMove when _draggingThumb:
{ {
float trackLeft = ButtonH; float trackLeft = AxisExtent(DecrementButtonExtent, Width);
float trackLength = MathF.Max(0f, Width - 2f * ButtonH); float trackLength = MathF.Max(
0f,
Width
- AxisExtent(DecrementButtonExtent, Width)
- AxisExtent(IncrementButtonExtent, Width));
float thumbWidth = MathF.Max(MinThumb, trackLength * m.ThumbRatio); float thumbWidth = MathF.Max(MinThumb, trackLength * m.ThumbRatio);
float travel = MathF.Max(1f, trackLength - thumbWidth); float travel = MathF.Max(1f, trackLength - thumbWidth);
float ratio = ((float)e.Data1 - _dragOffsetX - trackLeft) / travel; float ratio = ((float)e.Data1 - _dragOffsetX - trackLeft) / travel;
@ -449,4 +496,7 @@ public sealed class UiScrollbar : UiElement
SetScalarPosition(position); SetScalarPosition(position);
ScalarChanged?.Invoke(ScalarPosition); 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); 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] [Fact]
public void RetailToolbarFixture_buildsEditableStackEntry_andAuthoredHorizontalSlider() public void RetailToolbarFixture_buildsEditableStackEntry_andAuthoredHorizontalSlider()
{ {

View file

@ -34,8 +34,19 @@ public sealed class SpellcastingUiControllerTests
UiElement group = layout.FindElement(0x100000AAu)!; UiElement group = layout.FindElement(0x100000AAu)!;
UiItemList list = Descendants(group).OfType<UiItemList>().First(); 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.SingleRow);
Assert.True(list.HorizontalScroll);
Assert.True(list.FillVisibleEmptySlots); 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(439f, list.Width);
Assert.Equal(13, list.GetNumUIItems()); Assert.Equal(13, list.GetNumUIItems());
@ -71,6 +82,43 @@ public sealed class SpellcastingUiControllerTests
cast.FaceSegmentRectsForTest()); 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] [Fact]
public void ImportedFixture_BindsAllTabs_AndTracksEquippedEndowment() 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))); 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] [Theory]
[InlineData(0f, 0f, 0f)] [InlineData(0f, 0f, 0f)]
[InlineData(0.5f, 0f, 50f)] [InlineData(0.5f, 0f, 50f)]