feat(ui): port favorite spell bar overflow
Import the retail arrow-only spell bar scrollbar from LayoutDesc, preserve authored end-button extents and HideDisabled behavior in the shared retained widget, and bind each favorite list to its sole horizontal pixel-scroll model. Match retail selection exposure for off-screen spells and pin the behavior with real-fixture conformance tests. Document the six-slice world-interaction completion program as the active pre-M4 work order. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
20e2998adb
commit
02c29e67c8
12 changed files with 582 additions and 45 deletions
12
AGENTS.md
12
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,
|
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
|
||||||
|
|
|
||||||
12
CLAUDE.md
12
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,
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
106
docs/plans/2026-07-23-world-interaction-completion.md
Normal file
106
docs/plans/2026-07-23-world-interaction-completion.md
Normal 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`.
|
||||||
141
docs/research/2026-07-23-retail-spellbar-overflow-pseudocode.md
Normal file
141
docs/research/2026-07-23-retail-spellbar-overflow-pseudocode.md
Normal 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.
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue