fix(ui): match retail spell bar controls

Place favorite-bar arrows by their authored sides, import rollover and pressed media through the shared scrollbar, and preserve manual offsets across passive refreshes. Carry the mixed-parent DAT anchor chain to a fixed 18-cell favorite viewport so overflow controls and the Cast button remain inside the retail-sized combat frame.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-23 07:01:01 +02:00
parent 02c29e67c8
commit 0134122c28
17 changed files with 424 additions and 59 deletions

View file

@ -116,7 +116,7 @@ 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 interaction-completion Slice 1 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 pass 293 focused tests, 3,472 App tests / 3 skips, and 7,845 complete-solution
tests / 5 skips. tests / 5 skips.
**Current work order:** the six-slice pre-M4 world-interaction completion **Current work order:** the six-slice pre-M4 world-interaction completion

View file

@ -114,7 +114,7 @@ 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 interaction-completion Slice 1 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 pass 293 focused tests, 3,472 App tests / 3 skips, and 7,845 complete-solution
tests / 5 skips. tests / 5 skips.
**Current work order:** the six-slice pre-M4 world-interaction completion **Current work order:** the six-slice pre-M4 world-interaction completion

View file

@ -14,8 +14,12 @@ bodies begin: spell-bar overflow controls, status-bar Use/Assess commands,
assessment information, equipped-child picking, vendor browsing, and assessment information, equipped-child picking, vendor browsing, and
server-authoritative vendor transactions. Slice 1 is active and ports the server-authoritative vendor transactions. Slice 1 is active and ports the
DAT-authored arrow-only favorite-spell scrollbar through the shared retained DAT-authored arrow-only favorite-spell scrollbar through the shared retained
scrollbar/list model. Its implementation and complete Release suite pass; scrollbar/list model. The corrected import preserves authored arrow sides and
connected overflow-arrow visual acceptance is pending. rollover/pressed media, passive refresh preserves manual offsets, and the
mixed-parent DAT anchor chain produces the fixed retail capacity of 18 visible
favorite cells (nine numbered plus nine unnumbered). Its
implementation and complete Release suite pass; connected overflow-arrow
visual acceptance is pending.
--- ---

View file

@ -43,12 +43,18 @@ Each slice begins with named-retail research, produces pseudocode and
conformance tests, updates the divergence register if required, and lands as a conformance tests, updates the divergence register if required, and lands as a
separate bisectable commit. A visual gate follows each UI-bearing slice. 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 Slice 1 now imports both 23-pixel arrow buttons, their rollover/pressed media,
combat fixture, shares the list's single pixel-scroll model, advances one and HideDisabled from the real combat fixture; places their artwork by the
32-pixel cell per press, and exposes programmatically selected spells. The authored leading/trailing positions; shares the list's single pixel-scroll
focused 57-test set, all 3,466 App tests / 3 skips, the Release solution build, model; advances one 32-pixel cell per press; and exposes only actively selected
and all 7,839 solution tests / 5 skips pass. Only the connected visual gate spells. Passive object/endowment refresh preserves a manual offset. The mixed
remains before Slice 2 starts. 610/800-pixel DAT anchor chain is solved to a fixed 747-pixel combat root,
producing exactly 18 visible 32-pixel favorite cells (nine numbered plus nine
unnumbered) without consuming the overflow. The initial 57-test/full-suite gate passed;
the corrective 104-test focus set, 3,472 App tests / 3 skips, Release solution
build, and 7,845 complete-solution tests / 5 skips pass. The connected gate is
pending after user feedback on arrow sides, rollover, width, and offset
persistence.
## Slice 1 — spell-bar overflow arrows ## Slice 1 — spell-bar overflow arrows
@ -74,7 +80,8 @@ Named retail references and executable pseudocode are recorded in
1. Generalize `DatWidgetFactory.BuildScrollbar` so horizontal and vertical 1. Generalize `DatWidgetFactory.BuildScrollbar` so horizontal and vertical
scrollbars both import the referenced decrement/increment children, scrollbars both import the referenced decrement/increment children,
including their authored dimensions and HideDisabled property. including their authored positions, dimensions, Normal/rollover/pressed
media, and HideDisabled property.
2. Generalize `UiScrollbar` to use distinct authored decrement/increment 2. Generalize `UiScrollbar` to use distinct authored decrement/increment
extents for rendering, hit-testing, track geometry, and dragging. Preserve extents for rendering, hit-testing, track geometry, and dragging. Preserve
the existing 16-pixel default for layouts whose button children are absent. the existing 16-pixel default for layouts whose button children are absent.
@ -85,10 +92,14 @@ Named retail references and executable pseudocode are recorded in
`UiItemList.Scroll`, enable horizontal scrolling, and retain one independent `UiItemList.Scroll`, enable horizontal scrolling, and retain one independent
pixel offset per favorite tab. pixel offset per favorite tab.
5. Match `SpellCastSubMenu::SetSelected`: selecting a spell through keyboard, 5. Match `SpellCastSubMenu::SetSelected`: selecting a spell through keyboard,
shortcut, or code scrolls that item into view. shortcut, or code scrolls that item into view; passive state refresh does
6. Pin the importer, arrow hit extents, 32-pixel step, no-overflow behavior, not re-expose it.
6. Carry the combat root's mixed-parent raw-edge policies through the complete
imported tree and solve for the retail 18-cell favorite viewport; keep that
HUD capacity fixed across desktop resizes.
7. Pin the importer, arrow hit extents, 32-pixel step, no-overflow behavior,
controller binding, and selection exposure with focused App tests. controller binding, and selection exposure with focused App tests.
7. Run the App Release suite, solution Release build, and complete Release 8. Run the App Release suite, solution Release build, and complete Release
suite. Then update the roadmap/memory and request the connected visual gate: suite. Then update the roadmap/memory and request the connected visual gate:
place more favorites than fit, scroll both directions, change tabs, and place more favorites than fit, scroll both directions, change tabs, and
verify arrows disappear on a non-overflowing tab. verify arrows disappear on a non-overflowing tab.

View file

@ -13,12 +13,12 @@ Each of the eight `SpellCastSubMenu` groups contains:
```text ```text
favorite group favorite group
scrollbar 0x100000B5, 685x36 scrollbar 0x100000B5, 685x36
decrement button 0x10000071, 23x36 increment reference 0x10000072, authored x=0, 23x36
Normal 0x06004CDE (left-facing arrow)
Rollover 0x06004CDF
decrement reference 0x10000071, authored x=63, 23x36
Normal 0x06004CDC Normal 0x06004CDC
Rollover 0x06004CDD Rollover 0x06004CDD
increment button 0x10000072, 23x36
Normal 0x06004CDE
Rollover 0x06004CDF
item list 0x100000B6, x=23, y=2, 639x32 item list 0x100000B6, x=23, y=2, 639x32
scrollbar property 0x77 = increment button 0x10000072 scrollbar property 0x77 = increment button 0x10000072
@ -28,7 +28,12 @@ item-list property 0x71 = horizontal scrollbar 0x100000B5
``` ```
The spell-bar scrollbar has no authored track or thumb. Its 23-pixel buttons 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. occupy the space immediately outside the 639-pixel item-list viewport. The
property names do not determine their drawn side:
`UIElement_Scrollbar::UpdateScrollingArea @ 0x00470AA0` places the 0x77
increment reference at the leading/top-left edge and the 0x78 decrement
reference at the trailing/bottom-right edge. For this horizontal fixture that
means `0x06004CDE` on the left and `0x06004CDC` on the right.
## Initialization and ownership ## Initialization and ownership
@ -85,8 +90,8 @@ UpdateScrollbarSize(horizontal):
Scrollbar.UpdateLayout(): Scrollbar.UpdateLayout():
scrollingArea = own rectangle scrollingArea = own rectangle
position decrement button at the start and subtract its authored width position property-0x77 button at the start and subtract its authored width
position increment button at the end and subtract its authored width position property-0x78 button at the end and subtract its authored width
update thumb/track geometry, when those children exist update thumb/track geometry, when those children exist
Visible = NOT (Disabled AND HideDisabled) Visible = NOT (Disabled AND HideDisabled)
``` ```
@ -95,6 +100,31 @@ For the favorite bar, disabled plus HideDisabled removes both arrows when every
favorite/empty cell fits. With overflow, the arrows remain visible and the favorite/empty cell fits. With overflow, the arrows remain visible and the
item list clips the horizontally translated cells. item list clips the horizontally translated cells.
The combat LayoutDesc deliberately mixes design extents: its outer combat root
is 610 pixels wide, while the magic page's children are authored against an
800-pixel parent. Applying retail
`UIElement::UpdateForParentSizeChange @ 0x00462640` through that complete
raw-edge chain gives a 439-pixel favorite viewport at the 610-pixel root—the
13-slot short bar seen before this slice.
The retail presentation exposes 18 favorite cells: nine shortcut-numbered
cells plus nine unnumbered cells. At 32 pixels per cell the target viewport is
576 pixels. Because every horizontal raw-edge stage carries the parent delta
one-for-one, solving the imported tree for that viewport yields:
```text
outer combat root = 747 px
magic page = 737 px (5 px inset on each side)
favorite group = 622 px (x=40, followed by Cast)
scrollbar = 622 px (23 + 576 + 23)
favorite list = 576 px (18 * 32)
Cast = x=662, width=75 inside the magic page
```
This is a fixed visible-capacity HUD, not a desktop-wide strip. Stretching it
to the display width makes the favorite content fit, which legitimately
disables and hides the `HideDisabled` overflow arrows.
## Scroll delta and selection exposure ## Scroll delta and selection exposure
Retail: Retail:
@ -127,15 +157,23 @@ SpellCastSubMenu.SetSelected(spellId):
``` ```
The favorite cells are 32 pixels wide, so a line arrow moves exactly 32 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 pixels. An actual programmatic, keyboard, or pointer selection exposes the
it was outside the prior viewport. selected cell even when it was outside the prior viewport. Passive endowment
or object-table refresh does not re-run `SetSelected`; doing so would undo a
manual scrollbar offset.
## Port constraints ## Port constraints
- Preserve the imported `0x71`/`0x77`/`0x78` relationships. - Preserve the imported `0x71`/`0x77`/`0x78` relationships.
- Preserve each authored button's dimension; do not assume the 16-pixel chat - Preserve each authored button's dimension; do not assume the 16-pixel chat
scrollbar button size. scrollbar button size.
- Preserve each authored button's Normal, Normal_rollover, and Normal_pressed
media through the shared scrollbar presentation path.
- Do not fabricate a track/thumb for this arrow-only control. - Do not fabricate a track/thumb for this arrow-only control.
- Derive disabled state from the shared model's content and viewport extents. - Derive disabled state from the shared model's content and viewport extents.
- Hide disabled controls from both drawing and hit-testing. - Hide disabled controls from both drawing and hit-testing.
- Keep every favorite tab's scroll offset independent. - Keep every favorite tab's scroll offset independent.
- Carry the DAT raw-edge policies through the full imported combat tree and
solve the root width for an 18-cell (576-pixel) favorite viewport.
- Keep the resulting 747-pixel HUD width fixed across desktop resizes; do not
turn the favorite bar into a desktop-wide strip.

View file

@ -126,8 +126,11 @@ public static class DatWidgetFactory
/// <summary> /// <summary>
/// Bind inherited scrollbar media structurally. Property 0x77 names the /// Bind inherited scrollbar media structurally. Property 0x77 names the
/// increment button and 0x78 the decrement button; the remaining Type-1 /// increment button and 0x78 the decrement button; retail
/// child is the thumb with ordered top/middle/bottom image slices. /// <c>UIElement_Scrollbar::UpdateScrollingArea @ 0x00470AA0</c> then places
/// those referenced child buttons by their authored leading/trailing
/// positions. The remaining Type-1 child is the thumb with ordered
/// top/middle/bottom image slices.
/// </summary> /// </summary>
private static UiScrollbar BuildScrollbar( private static UiScrollbar BuildScrollbar(
ElementInfo info, ElementInfo info,
@ -144,17 +147,31 @@ public static class DatWidgetFactory
uint decrementId = ReferencedElementId(info, 0x78u); uint decrementId = ReferencedElementId(info, 0x78u);
ElementInfo? increment = info.Children.FirstOrDefault(child => child.Id == incrementId); ElementInfo? increment = info.Children.FirstOrDefault(child => child.Id == incrementId);
ElementInfo? decrement = info.Children.FirstOrDefault(child => child.Id == decrementId); ElementInfo? decrement = info.Children.FirstOrDefault(child => child.Id == decrementId);
bar.UpSprite = decrement is null ? 0u : DefaultImage(decrement); ElementInfo? leadingButton = new[] { increment, decrement }
bar.DownSprite = increment is null ? 0u : DefaultImage(increment); .Where(child => child is not null)
.OrderBy(child => bar.Horizontal ? child!.X : child!.Y)
.ThenBy(child => child!.ReadOrder)
.FirstOrDefault();
ElementInfo? trailingButton = new[] { increment, decrement }
.Where(child => child is not null)
.OrderByDescending(child => bar.Horizontal ? child!.X : child!.Y)
.ThenByDescending(child => child!.ReadOrder)
.FirstOrDefault();
bar.UpSprite = ButtonStateImage(leadingButton, "Normal");
bar.UpRolloverSprite = ButtonStateImage(leadingButton, "Normal_rollover");
bar.UpPressedSprite = ButtonStateImage(leadingButton, "Normal_pressed");
bar.DownSprite = ButtonStateImage(trailingButton, "Normal");
bar.DownRolloverSprite = ButtonStateImage(trailingButton, "Normal_rollover");
bar.DownPressedSprite = ButtonStateImage(trailingButton, "Normal_pressed");
if (info.TryGetEffectiveBool(0x79u, out bool hideDisabled)) if (info.TryGetEffectiveBool(0x79u, out bool hideDisabled))
bar.HideWhenDisabled = hideDisabled; bar.HideWhenDisabled = hideDisabled;
if (bar.Horizontal) if (bar.Horizontal)
{ {
if (decrement is { Width: > 0f }) if (leadingButton is { Width: > 0f })
bar.DecrementButtonExtent = decrement.Width; bar.DecrementButtonExtent = leadingButton.Width;
if (increment is { Width: > 0f }) if (trailingButton is { Width: > 0f })
bar.IncrementButtonExtent = increment.Width; bar.IncrementButtonExtent = trailingButton.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.
@ -207,10 +224,10 @@ public static class DatWidgetFactory
return bar; return bar;
} }
if (decrement is { Height: > 0f }) if (leadingButton is { Height: > 0f })
bar.DecrementButtonExtent = decrement.Height; bar.DecrementButtonExtent = leadingButton.Height;
if (increment is { Height: > 0f }) if (trailingButton is { Height: > 0f })
bar.IncrementButtonExtent = increment.Height; bar.IncrementButtonExtent = trailingButton.Height;
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);
@ -272,6 +289,19 @@ public static class DatWidgetFactory
return 0u; return 0u;
} }
private static uint ButtonStateImage(ElementInfo? info, string stateName)
{
if (info is null)
return 0u;
if (info.StateMedia.TryGetValue(stateName, out var media))
return media.File;
UiStateInfo? state = info.States.Values.FirstOrDefault(
candidate => string.Equals(candidate.Name, stateName, StringComparison.Ordinal));
if (state?.Image is { } image)
return image.File;
return stateName == "Normal" ? DefaultImage(info) : 0u;
}
// ── Meter ──────────────────────────────────────────────────────────────── // ── Meter ────────────────────────────────────────────────────────────────
/// <summary> /// <summary>

View file

@ -0,0 +1,49 @@
namespace AcDream.App.UI.Layout;
/// <summary>Retail geometry policy for the shared melee/missile/magic combat root.</summary>
internal static class RetailCombatLayout
{
internal const int VisibleFavoriteSlots = 18;
internal const float FavoriteCellWidth = 32f;
/// <summary>
/// Resize the imported combat tree so its favorite list exposes exactly the
/// requested number of cells. The DAT deliberately authors the outer combat
/// root at 610 pixels while the magic page's descendants use an 800-pixel
/// design parent. Retail <c>UIElement::UpdateForParentSizeChange @
/// 0x00462640</c> carries that width delta through every raw-edge policy.
/// Applying the same chain and solving for the list viewport keeps the
/// endowment, arrows, list, Cast button, and outer frame in one coherent
/// retained layout instead of resizing individual controls ad hoc.
/// </summary>
internal static float FitFavoriteSlots(
ImportedLayout layout,
int visibleSlots = VisibleFavoriteSlots,
float cellWidth = FavoriteCellWidth)
{
ArgumentNullException.ThrowIfNull(layout);
if (visibleSlots < 1)
throw new ArgumentOutOfRangeException(nameof(visibleSlots));
if (!(cellWidth > 0f) || !float.IsFinite(cellWidth))
throw new ArgumentOutOfRangeException(nameof(cellWidth));
UiElement root = layout.Root;
ApplyDescendantLayout(root);
if (layout.FindElement(SpellcastingUiController.FavoriteListId) is not UiItemList list)
throw new InvalidOperationException("Retail combat layout has no favorite spell list.");
float targetViewport = visibleSlots * cellWidth;
root.Width = MathF.Max(0f, root.Width + targetViewport - list.Width);
ApplyDescendantLayout(root);
return root.Width;
}
private static void ApplyDescendantLayout(UiElement parent)
{
foreach (UiElement child in parent.Children)
{
child.ApplyAnchor(parent.Width, parent.Height);
ApplyDescendantLayout(child);
}
}
}

View file

@ -64,6 +64,12 @@ public static class RetailWindowFrame
public bool DrawChromeCenter { get; init; } = true; public bool DrawChromeCenter { get; init; } = true;
public bool Visible { get; init; } = true; public bool Visible { get; init; } = true;
/// <summary>
/// Top-level desktop anchors. Most movable windows are unanchored; fixed
/// retail HUD roots can preserve their authored screen-edge margins.
/// </summary>
public AnchorEdges OuterAnchors { get; init; } = AnchorEdges.None;
public AnchorEdges ContentAnchors { get; init; } = public AnchorEdges ContentAnchors { get; init; } =
AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom; AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom;
public bool? ContentClickThrough { get; init; } public bool? ContentClickThrough { get; init; }
@ -133,7 +139,7 @@ public static class RetailWindowFrame
outerFrame.Top = options.Top; outerFrame.Top = options.Top;
outerFrame.Width = outerWidth; outerFrame.Width = outerWidth;
outerFrame.Height = outerHeight; outerFrame.Height = outerHeight;
outerFrame.Anchors = AnchorEdges.None; outerFrame.Anchors = options.OuterAnchors;
outerFrame.Draggable = options.Draggable; outerFrame.Draggable = options.Draggable;
outerFrame.Resizable = options.Resizable; outerFrame.Resizable = options.Resizable;
outerFrame.ResizeX = options.ResizeX; outerFrame.ResizeX = options.ResizeX;

View file

@ -251,8 +251,6 @@ 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();
} }

View file

@ -764,11 +764,12 @@ public sealed class RetailUiRuntime : IDisposable
strings.Resolve); strings.Resolve);
labels = info is null ? null : CombatUiLabels.Resolve(info, strings); labels = info is null ? null : CombatUiLabels.Resolve(info, strings);
} }
if (layout is null || labels is null) if (info is null || layout is null || labels is null)
{ {
Console.WriteLine("[M2] combat: LayoutDesc 0x21000073 not found."); Console.WriteLine("[M2] combat: LayoutDesc 0x21000073 not found.");
return; return;
} }
float combatWidth = RetailCombatLayout.FitFavoriteSlots(layout);
CombatUiController? controller = Layout.CombatUiController.Bind( CombatUiController? controller = Layout.CombatUiController.Bind(
layout, layout,
@ -821,9 +822,13 @@ public sealed class RetailUiRuntime : IDisposable
{ {
WindowName = WindowNames.Combat, WindowName = WindowNames.Combat,
Chrome = RetailWindowChrome.Imported, Chrome = RetailWindowChrome.Imported,
Left = Math.Max(0f, (Host.Root.Width - root.Width) * 0.5f), Left = 0f,
Top = Math.Max(0f, Host.Root.Height - root.Height - 10f), Top = Math.Max(0f, Host.Root.Height - root.Height - 10f),
ContentWidth = combatWidth,
DatConstraintSource = info,
OuterAnchors = AnchorEdges.Left | AnchorEdges.Bottom,
Visible = false, Visible = false,
Draggable = false,
Resizable = false, Resizable = false,
ResizeX = false, ResizeX = false,
ResizeY = false, ResizeY = false,

View file

@ -233,6 +233,14 @@ public abstract class UiElement
/// self-driven interior drag such as text selection). Default false.</summary> /// self-driven interior drag such as text selection). Default false.</summary>
public virtual bool HandlesClick => false; public virtual bool HandlesClick => false;
/// <summary>
/// Whether pointer movement should continue reaching this element while it remains
/// hovered and no mouse capture is active. Composite controls use this to update
/// procedural sub-region hover state without broadening mouse-move delivery to every
/// retained widget.
/// </summary>
public virtual bool ReceivesHoverMouseMove => false;
/// <summary>Minimum size enforced while resizing.</summary> /// <summary>Minimum size enforced while resizing.</summary>
public float MinWidth { get; set; } = 40f; public float MinWidth { get; set; } = 40f;
public float MinHeight { get; set; } = 40f; public float MinHeight { get; set; } = 40f;

View file

@ -851,7 +851,12 @@ public sealed class UiRoot : UiElement
private void UpdateHover(int x, int y) private void UpdateHover(int x, int y)
{ {
var (w, _, _) = HitTestTopDown(x, y); var (w, _, _) = HitTestTopDown(x, y);
if (ReferenceEquals(w, _hoverWidget)) return; if (ReferenceEquals(w, _hoverWidget))
{
if (w?.ReceivesHoverMouseMove == true)
DispatchMouseMove(w, x, y);
return;
}
if (_hoverWidget is not null) if (_hoverWidget is not null)
{ {
@ -863,7 +868,13 @@ public sealed class UiRoot : UiElement
_tooltipFired = false; _tooltipFired = false;
if (w is not null) if (w is not null)
{ {
var enter = new UiEvent(w.EventId, w, UiEventType.HoverEnter); var screen = w.ScreenPosition;
var enter = new UiEvent(
w.EventId,
w,
UiEventType.HoverEnter,
Data1: (int)(x - screen.X),
Data2: (int)(y - screen.Y));
w.OnEvent(in enter); w.OnEvent(in enter);
} }
} }

View file

@ -21,6 +21,8 @@ namespace AcDream.App.UI;
/// </remarks> /// </remarks>
public sealed class UiScrollbar : UiElement public sealed class UiScrollbar : UiElement
{ {
public override bool ReceivesHoverMouseMove => true;
/// <summary>The scroll model this bar reflects + drives (shared with the transcript).</summary> /// <summary>The scroll model this bar reflects + drives (shared with the transcript).</summary>
public UiScrollable? Model { get; set; } public UiScrollable? Model { get; set; }
@ -83,6 +85,14 @@ 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>Rollover and pressed media for the start/decrement button.</summary>
public uint UpRolloverSprite { get; set; }
public uint UpPressedSprite { get; set; }
/// <summary>Rollover and pressed media for the end/increment button.</summary>
public uint DownRolloverSprite { get; set; }
public uint DownPressedSprite { get; set; }
/// <summary> /// <summary>
/// Authored extent of the decrement button along the scrollbar axis. Retail /// Authored extent of the decrement button along the scrollbar axis. Retail
/// positions and subtracts the referenced button's real size; chat uses /// positions and subtracts the referenced button's real size; chat uses
@ -108,6 +118,15 @@ public sealed class UiScrollbar : UiElement
private bool _draggingThumb; private bool _draggingThumb;
private float _dragOffsetY; private float _dragOffsetY;
private float _dragOffsetX; private float _dragOffsetX;
private EndButton _hoveredButton;
private EndButton _pressedButton;
private enum EndButton
{
None,
Decrement,
Increment,
}
public UiScrollbar() { CapturesPointerDrag = true; } public UiScrollbar() { CapturesPointerDrag = true; }
@ -201,8 +220,8 @@ public sealed class UiScrollbar : UiElement
float incrementExtent = AxisExtent(IncrementButtonExtent, Height); float incrementExtent = AxisExtent(IncrementButtonExtent, Height);
// Decrement/up and increment/down use their authored button heights. // Decrement/up and increment/down use their authored button heights.
DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, decrementExtent); DrawSprite(ctx, resolve, ActiveStartSprite, 0f, 0f, Width, decrementExtent);
DrawSprite(ctx, resolve, DownSprite, DrawSprite(ctx, resolve, ActiveEndSprite,
0f, Height - incrementExtent, Width, incrementExtent); 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 +
@ -235,8 +254,8 @@ public sealed class UiScrollbar : UiElement
float decrementExtent = AxisExtent(DecrementButtonExtent, Width); float decrementExtent = AxisExtent(DecrementButtonExtent, Width);
float incrementExtent = AxisExtent(IncrementButtonExtent, 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, decrementExtent, Height); DrawSprite(ctx, resolve, ActiveStartSprite, 0f, 0f, decrementExtent, Height);
DrawSprite(ctx, resolve, DownSprite, DrawSprite(ctx, resolve, ActiveEndSprite,
Width - incrementExtent, 0f, incrementExtent, Height); Width - incrementExtent, 0f, incrementExtent, Height);
if (!model.HasOverflow) return; if (!model.HasOverflow) return;
@ -322,9 +341,24 @@ public sealed class UiScrollbar : UiElement
if (IsModelDisabled) if (IsModelDisabled)
{ {
_draggingThumb = false; _draggingThumb = false;
_hoveredButton = EndButton.None;
_pressedButton = EndButton.None;
return false; return false;
} }
if (e.Type == UiEventType.HoverEnter)
{
_hoveredButton = ButtonAt(e.Data1, e.Data2);
return true;
}
if (e.Type == UiEventType.HoverLeave)
{
_hoveredButton = EndButton.None;
return true;
}
if (e.Type == UiEventType.MouseMove)
_hoveredButton = ButtonAt(e.Data1, e.Data2);
if (Horizontal && ScalarChanged is not null) if (Horizontal && ScalarChanged is not null)
return OnScalarEvent(e); return OnScalarEvent(e);
@ -339,6 +373,7 @@ 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;
_pressedButton = ButtonAt(e.Data1, e.Data2);
float decrementExtent = AxisExtent(DecrementButtonExtent, Height); float decrementExtent = AxisExtent(DecrementButtonExtent, Height);
float incrementExtent = AxisExtent(IncrementButtonExtent, Height); float incrementExtent = AxisExtent(IncrementButtonExtent, Height);
@ -386,6 +421,7 @@ public sealed class UiScrollbar : UiElement
case UiEventType.MouseUp: case UiEventType.MouseUp:
_draggingThumb = false; _draggingThumb = false;
_pressedButton = EndButton.None;
return true; return true;
} }
@ -400,6 +436,7 @@ public sealed class UiScrollbar : UiElement
case UiEventType.MouseDown: case UiEventType.MouseDown:
{ {
float x = e.Data1; float x = e.Data1;
_pressedButton = ButtonAt(e.Data1, e.Data2);
float decrementExtent = AxisExtent(DecrementButtonExtent, Width); float decrementExtent = AxisExtent(DecrementButtonExtent, Width);
float incrementExtent = AxisExtent(IncrementButtonExtent, Width); float incrementExtent = AxisExtent(IncrementButtonExtent, Width);
if (x < decrementExtent) { m.ScrollByLines(-1); return true; } if (x < decrementExtent) { m.ScrollByLines(-1); return true; }
@ -437,6 +474,7 @@ public sealed class UiScrollbar : UiElement
case UiEventType.MouseUp: case UiEventType.MouseUp:
_draggingThumb = false; _draggingThumb = false;
_pressedButton = EndButton.None;
return true; return true;
} }
return false; return false;
@ -475,6 +513,7 @@ public sealed class UiScrollbar : UiElement
case UiEventType.MouseUp: case UiEventType.MouseUp:
_draggingThumb = false; _draggingThumb = false;
_pressedButton = EndButton.None;
return true; return true;
} }
@ -497,6 +536,48 @@ public sealed class UiScrollbar : UiElement
ScalarChanged?.Invoke(ScalarPosition); ScalarChanged?.Invoke(ScalarPosition);
} }
private EndButton ButtonAt(float x, float y)
{
if (x < 0f || x >= Width || y < 0f || y >= Height)
return EndButton.None;
if (Horizontal)
{
if (x < AxisExtent(DecrementButtonExtent, Width))
return EndButton.Decrement;
if (x >= Width - AxisExtent(IncrementButtonExtent, Width))
return EndButton.Increment;
return EndButton.None;
}
if (y < AxisExtent(DecrementButtonExtent, Height))
return EndButton.Decrement;
if (y >= Height - AxisExtent(IncrementButtonExtent, Height))
return EndButton.Increment;
return EndButton.None;
}
private uint ActiveStartSprite
=> _pressedButton == EndButton.Decrement
&& _hoveredButton == EndButton.Decrement
&& UpPressedSprite != 0u
? UpPressedSprite
: _hoveredButton == EndButton.Decrement && UpRolloverSprite != 0u
? UpRolloverSprite
: UpSprite;
private uint ActiveEndSprite
=> _pressedButton == EndButton.Increment
&& _hoveredButton == EndButton.Increment
&& DownPressedSprite != 0u
? DownPressedSprite
: _hoveredButton == EndButton.Increment && DownRolloverSprite != 0u
? DownRolloverSprite
: DownSprite;
internal uint ActiveStartSpriteForTest => ActiveStartSprite;
internal uint ActiveEndSpriteForTest => ActiveEndSprite;
private static float AxisExtent(float authoredExtent, float axisLength) private static float AxisExtent(float authoredExtent, float axisLength)
=> Math.Clamp(authoredExtent, 0f, MathF.Max(0f, axisLength)); => Math.Clamp(authoredExtent, 0f, MathF.Max(0f, axisLength));
} }

View file

@ -230,24 +230,32 @@ public class DatWidgetFactoryTests
const uint IncrementId = 0x10000072u; const uint IncrementId = 0x10000072u;
const uint DecrementSprite = 0x06004CDCu; const uint DecrementSprite = 0x06004CDCu;
const uint IncrementSprite = 0x06004CDEu; const uint IncrementSprite = 0x06004CDEu;
const uint DecrementRollover = 0x06004CDDu;
const uint IncrementRollover = 0x06004CDFu;
var decrement = new ElementInfo var decrement = new ElementInfo
{ {
Id = DecrementId, Type = 1u, Width = 23f, Height = 36f, Id = DecrementId, Type = 1u, X = 63f, Width = 23f, Height = 36f,
}; };
decrement.States[UiStateInfo.DirectStateId] = new UiStateInfo decrement.States[UiStateInfo.DirectStateId] = new UiStateInfo
{ {
Id = UiStateInfo.DirectStateId, Id = UiStateInfo.DirectStateId,
Image = new UiImageMedia(DecrementSprite, 1), Image = new UiImageMedia(DecrementSprite, 1),
}; };
decrement.StateMedia["Normal"] = (DecrementSprite, 1);
decrement.StateMedia["Normal_rollover"] = (DecrementRollover, 1);
decrement.StateMedia["Normal_pressed"] = (DecrementSprite, 1);
var increment = new ElementInfo var increment = new ElementInfo
{ {
Id = IncrementId, Type = 1u, Width = 23f, Height = 36f, Id = IncrementId, Type = 1u, X = 0f, Width = 23f, Height = 36f,
}; };
increment.States[UiStateInfo.DirectStateId] = new UiStateInfo increment.States[UiStateInfo.DirectStateId] = new UiStateInfo
{ {
Id = UiStateInfo.DirectStateId, Id = UiStateInfo.DirectStateId,
Image = new UiImageMedia(IncrementSprite, 1), Image = new UiImageMedia(IncrementSprite, 1),
}; };
increment.StateMedia["Normal"] = (IncrementSprite, 1);
increment.StateMedia["Normal_rollover"] = (IncrementRollover, 1);
increment.StateMedia["Normal_pressed"] = (IncrementSprite, 1);
var info = new ElementInfo var info = new ElementInfo
{ {
Type = 11u, Type = 11u,
@ -271,8 +279,12 @@ public class DatWidgetFactoryTests
Assert.True(bar.HideWhenDisabled); Assert.True(bar.HideWhenDisabled);
Assert.Equal(23f, bar.DecrementButtonExtent); Assert.Equal(23f, bar.DecrementButtonExtent);
Assert.Equal(23f, bar.IncrementButtonExtent); Assert.Equal(23f, bar.IncrementButtonExtent);
Assert.Equal(DecrementSprite, bar.UpSprite); Assert.Equal(IncrementSprite, bar.UpSprite);
Assert.Equal(IncrementSprite, bar.DownSprite); Assert.Equal(IncrementRollover, bar.UpRolloverSprite);
Assert.Equal(IncrementSprite, bar.UpPressedSprite);
Assert.Equal(DecrementSprite, bar.DownSprite);
Assert.Equal(DecrementRollover, bar.DownRolloverSprite);
Assert.Equal(DecrementSprite, bar.DownPressedSprite);
Assert.Equal(0u, bar.TrackSprite); Assert.Equal(0u, bar.TrackSprite);
Assert.Equal(0u, bar.ThumbSprite); Assert.Equal(0u, bar.ThumbSprite);
} }

View file

@ -7,6 +7,43 @@ public sealed class RetailWindowFrameTests
{ {
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0); private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
[Theory]
[InlineData(9, 459f, 288f)]
[InlineData(13, 587f, 416f)]
[InlineData(18, 747f, 576f)]
public void CombatRoot_FitsRequestedFavoriteSlotCapacity(
int visibleSlots,
float expectedRootWidth,
float expectedListWidth)
{
ImportedLayout combat = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
float width = RetailCombatLayout.FitFavoriteSlots(combat, visibleSlots);
Assert.Equal(expectedRootWidth, width);
Assert.Equal(expectedListWidth, combat.FindElement(
SpellcastingUiController.FavoriteListId)!.Width);
}
[Fact]
public void CombatRoot_EighteenSlotsKeepArrowsAndCastInsideFrame()
{
ImportedLayout combat = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
RetailCombatLayout.FitFavoriteSlots(combat);
UiElement group = combat.FindElement(0x100000AAu)!;
UiElement scrollbar = combat.FindElement(SpellcastingUiController.FavoriteScrollbarId)!;
UiElement cast = combat.FindElement(SpellcastingUiController.CastButtonId)!;
Assert.Equal(747f, combat.Root.Width);
Assert.Equal((40f, 622f), (group.Left, group.Width));
Assert.Equal(622f, scrollbar.Width);
Assert.Equal((662f, 75f), (cast.Left, cast.Width));
Assert.Equal(737f, cast.Left + cast.Width);
}
[Fact] [Fact]
public void ImportedChrome_MountsRootWithoutASecondFrame() public void ImportedChrome_MountsRootWithoutASecondFrame()
{ {
@ -41,6 +78,30 @@ public sealed class RetailWindowFrameTests
Assert.Equal(40f, content.MinWidth); Assert.Equal(40f, content.MinWidth);
} }
[Fact]
public void ImportedChrome_CanPreserveFixedHudDesktopAnchors()
{
var root = new UiRoot { Width = 1920, Height = 1080 };
var content = new UiPanel { Width = 1730, Height = 90 };
RetailWindowHandle handle = RetailWindowFrame.Mount(
root,
content,
NoTex,
new RetailWindowFrame.Options
{
WindowName = "combat",
Chrome = RetailWindowChrome.Imported,
Left = 0f,
Top = 980f,
OuterAnchors = AnchorEdges.Left | AnchorEdges.Bottom,
});
Assert.Equal(
AnchorEdges.Left | AnchorEdges.Bottom,
handle.OuterFrame.Anchors);
}
[Fact] [Fact]
public void NineSlice_UsesCroppedContentAndDatHeightConstraints() public void NineSlice_UsesCroppedContentAndDatHeightConstraints()
{ {

View file

@ -16,7 +16,7 @@ public sealed class SpellcastingUiControllerTests
{ {
ImportedLayout layout = LayoutImporter.Build( ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null); FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
ApplyAnchors(layout.Root); RetailCombatLayout.FitFavoriteSlots(layout);
var spellbook = new Spellbook(); var spellbook = new Spellbook();
spellbook.OnSpellLearned(42u, 1f); spellbook.OnSpellLearned(42u, 1f);
spellbook.SetFavorite(0, 0, 42u); spellbook.SetFavorite(0, 0, 42u);
@ -44,11 +44,13 @@ public sealed class SpellcastingUiControllerTests
Assert.True(scrollbar.HideWhenDisabled); Assert.True(scrollbar.HideWhenDisabled);
Assert.Equal(23f, scrollbar.DecrementButtonExtent); Assert.Equal(23f, scrollbar.DecrementButtonExtent);
Assert.Equal(23f, scrollbar.IncrementButtonExtent); Assert.Equal(23f, scrollbar.IncrementButtonExtent);
Assert.Equal(0x06004CDCu, scrollbar.UpSprite); Assert.Equal(0x06004CDEu, scrollbar.UpSprite);
Assert.Equal(0x06004CDEu, scrollbar.DownSprite); Assert.Equal(0x06004CDFu, scrollbar.UpRolloverSprite);
Assert.Equal(0x06004CDCu, scrollbar.DownSprite);
Assert.Equal(0x06004CDDu, scrollbar.DownRolloverSprite);
Assert.False(scrollbar.IsPresentationVisible); Assert.False(scrollbar.IsPresentationVisible);
Assert.Equal(439f, list.Width); Assert.Equal(576f, list.Width);
Assert.Equal(13, list.GetNumUIItems()); Assert.Equal(18, list.GetNumUIItems());
UiCatalogSlot favorite = Assert.IsType<UiCatalogSlot>(list.GetItem(0)); UiCatalogSlot favorite = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
Assert.Equal(42u, favorite.EntryId); Assert.Equal(42u, favorite.EntryId);
@ -63,7 +65,7 @@ public sealed class SpellcastingUiControllerTests
Assert.Equal(i, slot.ShortcutNum); Assert.Equal(i, slot.ShortcutNum);
Assert.Same(empty, slot.ActiveDigitArray()); Assert.Same(empty, slot.ActiveDigitArray());
} }
for (int i = 9; i < 13; i++) for (int i = 9; i < 18; i++)
{ {
Assert.Equal(0x06001A97u, list.GetItem(i)!.EmptySprite); Assert.Equal(0x06001A97u, list.GetItem(i)!.EmptySprite);
Assert.Equal(-1, list.GetItem(i)!.ShortcutNum); Assert.Equal(-1, list.GetItem(i)!.ShortcutNum);
@ -71,7 +73,7 @@ public sealed class SpellcastingUiControllerTests
var cast = Assert.IsType<UiButton>( var cast = Assert.IsType<UiButton>(
layout.FindElement(SpellcastingUiController.CastButtonId)); layout.FindElement(SpellcastingUiController.CastButtonId));
Assert.Equal((525f, 75f), (cast.Left, cast.Width)); Assert.Equal((662f, 75f), (cast.Left, cast.Width));
Assert.Equal(3, cast.FaceSegmentCount); Assert.Equal(3, cast.FaceSegmentCount);
Assert.Equal( Assert.Equal(
[ [
@ -87,7 +89,7 @@ public sealed class SpellcastingUiControllerTests
{ {
ImportedLayout layout = LayoutImporter.Build( ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null); FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
ApplyAnchors(layout.Root); RetailCombatLayout.FitFavoriteSlots(layout);
var spellbook = new Spellbook(); var spellbook = new Spellbook();
for (uint spellId = 1u; spellId <= 20u; spellId++) for (uint spellId = 1u; spellId <= 20u; spellId++)
{ {
@ -105,7 +107,7 @@ public sealed class SpellcastingUiControllerTests
UiScrollbar scrollbar = Descendants(group).OfType<UiScrollbar>().Single( UiScrollbar scrollbar = Descendants(group).OfType<UiScrollbar>().Single(
candidate => candidate.DatElementId == SpellcastingUiController.FavoriteScrollbarId); candidate => candidate.DatElementId == SpellcastingUiController.FavoriteScrollbarId);
Assert.True(scrollbar.IsPresentationVisible); Assert.True(scrollbar.IsPresentationVisible);
Assert.Equal(201, list.Scroll.MaxScroll); Assert.Equal(64, list.Scroll.MaxScroll);
Assert.True(scrollbar.OnEvent(new UiEvent( Assert.True(scrollbar.OnEvent(new UiEvent(
0u, scrollbar, UiEventType.MouseDown, Data1: (int)scrollbar.Width - 1))); 0u, scrollbar, UiEventType.MouseDown, Data1: (int)scrollbar.Width - 1)));
@ -117,6 +119,14 @@ public sealed class SpellcastingUiControllerTests
controller.AddFavorite(20u); controller.AddFavorite(20u);
Assert.Equal(list.Scroll.MaxScroll, list.Scroll.ScrollY); Assert.Equal(list.Scroll.MaxScroll, list.Scroll.ScrollY);
Assert.True(scrollbar.OnEvent(new UiEvent(
0u, scrollbar, UiEventType.MouseDown, Data1: 0)));
int manualOffset = list.Scroll.ScrollY;
objects.AddOrUpdate(new ClientObject { ObjectId = 99u, Name = "Unrelated" });
controller.Tick();
Assert.Equal(manualOffset, list.Scroll.ScrollY);
} }
[Fact] [Fact]

View file

@ -157,6 +157,47 @@ public class UiScrollbarTests
Assert.Equal(64, model.ScrollY); Assert.Equal(64, model.ScrollY);
} }
[Fact]
public void HorizontalModel_UsesIndependentRolloverAndPressedArrowMedia()
{
var root = new UiRoot { Width = 300f, Height = 100f };
var model = new UiScrollable
{
ContentHeight = 640,
ViewHeight = 320,
LineHeight = 32,
};
var bar = new UiScrollbar
{
Width = 160f,
Height = 36f,
Horizontal = true,
Model = model,
UpSprite = 1u,
UpRolloverSprite = 2u,
UpPressedSprite = 3u,
DownSprite = 4u,
DownRolloverSprite = 5u,
DownPressedSprite = 6u,
};
root.AddChild(bar);
root.OnMouseMove(5, 10);
Assert.Equal(2u, bar.ActiveStartSpriteForTest);
Assert.Equal(4u, bar.ActiveEndSpriteForTest);
// Moving between two regions of the same procedural scrollbar must
// refresh its sub-control hover, not wait for a whole-widget leave.
root.OnMouseMove(155, 10);
Assert.Equal(1u, bar.ActiveStartSpriteForTest);
Assert.Equal(5u, bar.ActiveEndSpriteForTest);
root.OnMouseDown(UiMouseButton.Left, 155, 10);
Assert.Equal(6u, bar.ActiveEndSpriteForTest);
root.OnMouseUp(UiMouseButton.Left, 155, 10);
Assert.Equal(5u, bar.ActiveEndSpriteForTest);
}
[Fact] [Fact]
public void ModelWithoutOverflow_IsDisabledAndHideDisabledSuppressesPresentation() public void ModelWithoutOverflow_IsDisabledAndHideDisabledSuppressesPresentation()
{ {