From 02c29e67c83ee1001a181158141110ad17b82fb8 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 23 Jul 2026 06:35:53 +0200 Subject: [PATCH] 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 --- AGENTS.md | 12 +- CLAUDE.md | 12 +- docs/plans/2026-04-11-roadmap.md | 16 +- docs/plans/2026-05-12-milestones.md | 8 + ...2026-07-23-world-interaction-completion.md | 106 +++++++++++++ ...-23-retail-spellbar-overflow-pseudocode.md | 141 ++++++++++++++++++ src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 24 ++- .../UI/Layout/SpellcastingUiController.cs | 40 ++++- src/AcDream.App/UI/UiScrollbar.cs | 112 ++++++++++---- .../UI/Layout/DatWidgetFactoryTests.cs | 54 +++++++ .../Layout/SpellcastingUiControllerTests.cs | 48 ++++++ .../AcDream.App.Tests/UI/UiScrollbarTests.cs | 54 +++++++ 12 files changed, 582 insertions(+), 45 deletions(-) create mode 100644 docs/plans/2026-07-23-world-interaction-completion.md create mode 100644 docs/research/2026-07-23-retail-spellbar-overflow-pseudocode.md diff --git a/AGENTS.md b/AGENTS.md index fef04b24..9525771c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index dc22e356..5fe42459 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 7f154665..e041cc06 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -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 diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 9881f166..1225be99 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -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 diff --git a/docs/plans/2026-07-23-world-interaction-completion.md b/docs/plans/2026-07-23-world-interaction-completion.md new file mode 100644 index 00000000..6178bcd1 --- /dev/null +++ b/docs/plans/2026-07-23-world-interaction-completion.md @@ -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`. diff --git a/docs/research/2026-07-23-retail-spellbar-overflow-pseudocode.md b/docs/research/2026-07-23-retail-spellbar-overflow-pseudocode.md new file mode 100644 index 00000000..a46b6a20 --- /dev/null +++ b/docs/research/2026-07-23-retail-spellbar-overflow-pseudocode.md @@ -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. diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 5bfdc7f2..2e38ef31 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -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); diff --git a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs index e19768f4..e06fef3b 100644 --- a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs +++ b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs @@ -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().FirstOrDefault(); + scrollbars[i] = Descendants(group).OfType().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; diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs index 8f65ba5d..49a2f0d1 100644 --- a/src/AcDream.App/UI/UiScrollbar.cs +++ b/src/AcDream.App/UI/UiScrollbar.cs @@ -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). /// public sealed class UiScrollbar : UiElement { @@ -84,16 +83,28 @@ public sealed class UiScrollbar : UiElement /// Down-arrow button sprite id (0x06004C6C Normal state, element 0x10000072). public uint DownSprite { get; set; } + /// + /// 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. + /// + public float DecrementButtonExtent { get; set; } = 16f; + + /// Authored extent of the increment button along the scrollbar axis. + public float IncrementButtonExtent { get; set; } = 16f; + + /// + /// Retail scrollbar property 0x79. When the linked scrollable is disabled + /// because its content fits, UpdateLayout also hides the scrollbar. + /// + public bool HideWhenDisabled { get; set; } + /// Retail attribute 0x89 floor: minimum thumb height in pixels. private const float MinThumb = 8f; /// Thumb cap height (native sprite height from base layout 0x2100003E). private const float CapH = 3f; - /// Up/down button height in pixels. Matches element height 16px from - /// the up/down button children in base layout 0x2100003E. - 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. public override bool ConsumesDatChildren => true; + /// + /// The model owns disabled state just as retail UIElement_Scrollable does: + /// no overflow means the linked scrollbar is disabled. + /// + 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); + /// /// Computes the thumb rectangle (local y origin and height) within the track area /// between the two end buttons. Ports retail 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 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)); } diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index 50e06688..b568c400 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -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( + 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() { diff --git a/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs index 4f78cf54..0329169f 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs @@ -34,8 +34,19 @@ public sealed class SpellcastingUiControllerTests UiElement group = layout.FindElement(0x100000AAu)!; UiItemList list = Descendants(group).OfType().First(); + UiScrollbar scrollbar = Descendants(group).OfType().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().Single(); + UiScrollbar scrollbar = Descendants(group).OfType().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() { diff --git a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs index 1e9ae1a6..6d02b452 100644 --- a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs +++ b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs @@ -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)]