fix(ui): retail scrollbar parity — button seating, full-track thumb, hover/pressed states
Owner report (2026-08-24): our scrollbar arrows pointed the wrong way, the thumb vanished when there was nothing to scroll, and neither the thumb nor the arrow buttons reacted to hover/press. All three are one retail mechanism we had not ported: 1. Seating: UIElement_Scrollbar::UpdateScrollingArea @0x00470AA0 moves the INCREMENT designee (attribute 0x77) to the top/left corner and the DECREMENT designee (0x78) to the bottom/right, ignoring authored positions. The vertical base skin (0x10000455 in layout 0x2100003E) authors the DOWN-arrow decrement at Y=0 and the UP-arrow increment at Y=32 (live-DAT probed; sprite art visually verified from decoded PNGs), so our authored-Y ordering drew both arrows upside down. DatWidgetFactory now seats by designation; the hand-wired sites (CharacterStatController, ExternalContainerController, the Config/Vendor menu chrome) share the new RetailScrollbarChrome catalog instead of local constants. 2. Full-track thumb: UpdateLayout @0x004710d0 sizes the thumb from proportion attribute 0x88, which DEFAULTS to 1.0 — a content-fits bar shows a thumb filling the whole track; disabled only removes input and the page regions. Our draw skipped the thumb entirely on !HasOverflow. 3. States: every arrow button and thumb slice authors Normal (red gem / dark navy), Normal_rollover (amber gem / bright blue) and Normal_pressed (gold highlight / dark) media. The widget now tracks thumb hover and selects rollover media on hover and pressed media while dragging; the factory extracts the thumb-state media for both the 3-slice and single-sprite thumb shapes. ScrollbarSkinLiveDatTests pins the designations and state media against the installed DAT so a revision or importer regression fails loudly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
35abbe1d0d
commit
8fd3d1a9f0
12 changed files with 541 additions and 64 deletions
|
|
@ -137,13 +137,10 @@ public static class CharacterStatController
|
|||
private const uint SkillHeaderUnusableSprite = 0x06000F89u;
|
||||
private const uint RowHighlightSprite = 0x06001397u;
|
||||
|
||||
// Scrollbar chrome from base layout 0x2100003E, shared with chat/inventory.
|
||||
private const uint ScrollTrackSprite = 0x06004C5Fu;
|
||||
private const uint ScrollThumbSprite = 0x06004C63u;
|
||||
private const uint ScrollThumbTop = 0x06004C60u;
|
||||
private const uint ScrollThumbBot = 0x06004C66u;
|
||||
private const uint ScrollUpSprite = 0x06004C69u;
|
||||
private const uint ScrollDownSprite = 0x06004C6Cu;
|
||||
// Scrollbar chrome from base layout 0x2100003E, shared with chat/
|
||||
// inventory — sprite set + retail button seating live in
|
||||
// RetailScrollbarChrome (2026-08-24: the previous local constants seated
|
||||
// the DOWN-arrow art on the top button).
|
||||
|
||||
private enum CharacterStatTab
|
||||
{
|
||||
|
|
@ -681,12 +678,7 @@ public static class CharacterStatController
|
|||
Func<uint, (uint handle, int w, int h)> spriteResolve)
|
||||
{
|
||||
bar.SpriteResolve = id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); };
|
||||
bar.TrackSprite = ScrollTrackSprite;
|
||||
bar.ThumbSprite = ScrollThumbSprite;
|
||||
bar.ThumbTopSprite = ScrollThumbTop;
|
||||
bar.ThumbBotSprite = ScrollThumbBot;
|
||||
bar.UpSprite = ScrollUpSprite;
|
||||
bar.DownSprite = ScrollDownSprite;
|
||||
RetailScrollbarChrome.ApplyVertical(bar);
|
||||
}
|
||||
|
||||
private static float SkillViewportWidth(UiElement statList, UiScrollbar? bar)
|
||||
|
|
|
|||
|
|
@ -272,8 +272,11 @@ public static class ConfigOptionsPageController
|
|||
public const uint ScrollThumbTop = 0x06004C60u;
|
||||
public const uint ScrollThumb = 0x06004C63u;
|
||||
public const uint ScrollThumbBottom = 0x06004C66u;
|
||||
public const uint ScrollUp = 0x06004C69u;
|
||||
public const uint ScrollDown = 0x06004C6Cu;
|
||||
// Retail seating (UpdateScrollingArea @0x00470AA0): the top slot
|
||||
// takes the INCREMENT designee's UP-arrow art, the bottom the
|
||||
// DECREMENT designee's DOWN-arrow (see RetailScrollbarChrome).
|
||||
public const uint ScrollUp = RetailScrollbarChrome.UpNormal;
|
||||
public const uint ScrollDown = RetailScrollbarChrome.DownNormal;
|
||||
}
|
||||
|
||||
/// <summary>Applies <see cref="MenuChromeSprites"/> + geometry to a
|
||||
|
|
|
|||
|
|
@ -232,16 +232,28 @@ public static class DatWidgetFactory
|
|||
uint decrementId = ReferencedElementId(info, 0x78u);
|
||||
ElementInfo? increment = info.Children.FirstOrDefault(child => child.Id == incrementId);
|
||||
ElementInfo? decrement = info.Children.FirstOrDefault(child => child.Id == decrementId);
|
||||
ElementInfo? leadingButton = new[] { increment, decrement }
|
||||
.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();
|
||||
|
||||
// Seat by DESIGNATION, not authored position: retail's
|
||||
// UpdateScrollingArea @0x00470AA0 moves the increment designee to the
|
||||
// top/left corner (MoveTo(0,0)) and the decrement designee to the
|
||||
// bottom/right corner regardless of where the layout drew them. The
|
||||
// vertical base skin authors the DOWN-arrow (0x10000071) at Y=0 and
|
||||
// the UP-arrow (0x10000072) at Y=32, so the previous authored-Y
|
||||
// ordering rendered both arrows upside down (2026-08-24 owner
|
||||
// report). Fall back to authored order only when the designations
|
||||
// are missing.
|
||||
ElementInfo? leadingButton = increment;
|
||||
ElementInfo? trailingButton = decrement;
|
||||
if (leadingButton is null && trailingButton is null)
|
||||
{
|
||||
ElementInfo[] typeOneChildren = info.Children
|
||||
.Where(child => child.Type == 1u && child.Id != 1u)
|
||||
.OrderBy(child => bar.Horizontal ? child.X : child.Y)
|
||||
.ThenBy(child => child.ReadOrder)
|
||||
.ToArray();
|
||||
leadingButton = typeOneChildren.FirstOrDefault();
|
||||
trailingButton = typeOneChildren.Length > 1 ? typeOneChildren[^1] : null;
|
||||
}
|
||||
bar.UpSprite = ButtonStateImage(leadingButton, "Normal");
|
||||
bar.UpRolloverSprite = ButtonStateImage(leadingButton, "Normal_rollover");
|
||||
bar.UpPressedSprite = ButtonStateImage(leadingButton, "Normal_pressed");
|
||||
|
|
@ -323,9 +335,24 @@ public static class DatWidgetFactory
|
|||
.OrderBy(child => child.Y)
|
||||
.ThenBy(child => child.ReadOrder)
|
||||
.ToArray();
|
||||
if (slices.Length > 0) bar.ThumbTopSprite = DefaultImage(slices[0]);
|
||||
if (slices.Length > 1) bar.ThumbSprite = DefaultImage(slices[1]);
|
||||
if (slices.Length > 2) bar.ThumbBotSprite = DefaultImage(slices[^1]);
|
||||
if (slices.Length > 0)
|
||||
{
|
||||
bar.ThumbTopSprite = ButtonStateImage(slices[0], "Normal");
|
||||
bar.ThumbTopRolloverSprite = ButtonStateImage(slices[0], "Normal_rollover");
|
||||
bar.ThumbTopPressedSprite = ButtonStateImage(slices[0], "Normal_pressed");
|
||||
}
|
||||
if (slices.Length > 1)
|
||||
{
|
||||
bar.ThumbSprite = ButtonStateImage(slices[1], "Normal");
|
||||
bar.ThumbRolloverSprite = ButtonStateImage(slices[1], "Normal_rollover");
|
||||
bar.ThumbPressedSprite = ButtonStateImage(slices[1], "Normal_pressed");
|
||||
}
|
||||
if (slices.Length > 2)
|
||||
{
|
||||
bar.ThumbBotSprite = ButtonStateImage(slices[^1], "Normal");
|
||||
bar.ThumbBotRolloverSprite = ButtonStateImage(slices[^1], "Normal_rollover");
|
||||
bar.ThumbBotPressedSprite = ButtonStateImage(slices[^1], "Normal_pressed");
|
||||
}
|
||||
|
||||
// R3-4/R3-7 (Campaign CC gate round 1 re-test 2): retail authors
|
||||
// TWO distinct thumb shapes for UIElement_Scrollbar (Type 11) —
|
||||
|
|
@ -350,7 +377,11 @@ public static class DatWidgetFactory
|
|||
// slice children (chat) is unaffected since `slices.Length == 0`
|
||||
// is false for that shape.
|
||||
if (slices.Length == 0)
|
||||
bar.ThumbSprite = DefaultImage(thumb);
|
||||
{
|
||||
bar.ThumbSprite = ButtonStateImage(thumb, "Normal");
|
||||
bar.ThumbRolloverSprite = ButtonStateImage(thumb, "Normal_rollover");
|
||||
bar.ThumbPressedSprite = ButtonStateImage(thumb, "Normal_pressed");
|
||||
}
|
||||
}
|
||||
|
||||
return bar;
|
||||
|
|
|
|||
|
|
@ -98,13 +98,10 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
|
|||
scrollbar.SpriteResolve ??= _contentsList.SpriteResolve;
|
||||
// Horizontal base LayoutDesc 0x2100003E media. The compatibility
|
||||
// factory treats all horizontal bars as scalar controls; this panel
|
||||
// binds the authored model sprites explicitly.
|
||||
scrollbar.TrackSprite = 0x06004C7Fu;
|
||||
scrollbar.ThumbTopSprite = 0x06004C80u;
|
||||
scrollbar.ThumbSprite = 0x06004C83u;
|
||||
scrollbar.ThumbBotSprite = 0x06004C86u;
|
||||
scrollbar.DownSprite = 0x06004C89u;
|
||||
scrollbar.UpSprite = 0x06004C8Cu;
|
||||
// binds the authored model sprites explicitly — full state set +
|
||||
// retail seating from RetailScrollbarChrome (increment/left =
|
||||
// 0x06004C8C, decrement/right = 0x06004C89).
|
||||
RetailScrollbarChrome.ApplyHorizontal(scrollbar);
|
||||
}
|
||||
|
||||
BindClose(layout, RequestClose);
|
||||
|
|
|
|||
|
|
@ -181,8 +181,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
/// list, with a real thumb/up/down-button subtree matching
|
||||
/// <see cref="UiScrollbar"/>'s own shape exactly: thumb caps
|
||||
/// <c>0x06004C60</c>/<c>63</c>/<c>66</c>, up button (element
|
||||
/// <c>0x10000071</c>) <c>0x06004C69</c>/<c>6A</c>/<c>6B</c>, down button
|
||||
/// (element <c>0x10000072</c>) <c>0x06004C6C</c>/<c>6D</c>/<c>6E</c>,
|
||||
/// <c>0x10000072</c>, retail-seated on top) <c>0x06004C6C</c>/<c>6D</c>/<c>6E</c>, down button
|
||||
/// (element <c>0x10000071</c>, retail-seated on the bottom) <c>0x06004C69</c>/<c>6A</c>/<c>6B</c>,
|
||||
/// track <c>0x06004C5F</c>). With 18 authored categories and only 6
|
||||
/// visible rows, retail's actual rendering is a single scrolling column
|
||||
/// (matching the user's reference screenshot: ~visible rows + scrollbar +
|
||||
|
|
@ -288,8 +288,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
private const uint TypeMenuScrollThumbTopSprite = 0x06004C60u;
|
||||
private const uint TypeMenuScrollThumbSprite = 0x06004C63u;
|
||||
private const uint TypeMenuScrollThumbBottomSprite = 0x06004C66u;
|
||||
private const uint TypeMenuScrollUpSprite = 0x06004C69u;
|
||||
private const uint TypeMenuScrollDownSprite = 0x06004C6Cu;
|
||||
// Retail seating (UpdateScrollingArea @0x00470AA0): top = the INCREMENT
|
||||
// designee's UP-arrow art, bottom = the DECREMENT designee's DOWN-arrow.
|
||||
private const uint TypeMenuScrollUpSprite = RetailScrollbarChrome.UpNormal;
|
||||
private const uint TypeMenuScrollDownSprite = RetailScrollbarChrome.DownNormal;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's ordered category table, transcribed verbatim from
|
||||
|
|
|
|||
108
src/AcDream.App/UI/RetailScrollbarChrome.cs
Normal file
108
src/AcDream.App/UI/RetailScrollbarChrome.cs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// The base scrollbar skin from LayoutDesc <c>0x2100003E</c>, seated the way
|
||||
/// retail seats it at runtime. <c>UIElement_Scrollbar::UpdateScrollingArea
|
||||
/// @0x00470AA0</c> ignores the buttons' authored positions: it moves the
|
||||
/// INCREMENT button (attribute <c>0x77</c>) to the top/left corner
|
||||
/// (<c>MoveTo(0,0)</c>) and the DECREMENT button (attribute <c>0x78</c>) to
|
||||
/// the bottom/right corner. The vertical skin (element <c>0x10000455</c>)
|
||||
/// designates <c>0x77=0x10000072</c> (the UP-arrow art, authored at Y=32)
|
||||
/// and <c>0x78=0x10000071</c> (the DOWN-arrow art, authored at Y=0) — so
|
||||
/// seating by authored Y renders both arrows upside down (the 2026-08-24
|
||||
/// owner report). Each button and thumb slice authors three states:
|
||||
/// Normal (red center gem / dark navy thumb), Normal_rollover (amber gem /
|
||||
/// bright blue thumb), Normal_pressed (gold highlight gem / dark thumb) —
|
||||
/// all live-DAT probed 2026-08-24.
|
||||
/// </summary>
|
||||
internal static class RetailScrollbarChrome
|
||||
{
|
||||
// ── Vertical skin (element 0x10000455, 16 px) ────────────────────────
|
||||
internal const uint Track = 0x06004C5Fu;
|
||||
|
||||
/// <summary>Top button = the INCREMENT designee 0x10000072 (up arrow).</summary>
|
||||
internal const uint UpNormal = 0x06004C6Cu;
|
||||
internal const uint UpRollover = 0x06004C6Du;
|
||||
internal const uint UpPressed = 0x06004C6Eu;
|
||||
|
||||
/// <summary>Bottom button = the DECREMENT designee 0x10000071 (down arrow).</summary>
|
||||
internal const uint DownNormal = 0x06004C69u;
|
||||
internal const uint DownRollover = 0x06004C6Au;
|
||||
internal const uint DownPressed = 0x06004C6Bu;
|
||||
|
||||
internal const uint ThumbTopNormal = 0x06004C60u;
|
||||
internal const uint ThumbTopRollover = 0x06004C61u;
|
||||
internal const uint ThumbTopPressed = 0x06004C62u;
|
||||
internal const uint ThumbMidNormal = 0x06004C63u;
|
||||
internal const uint ThumbMidRollover = 0x06004C64u;
|
||||
internal const uint ThumbMidPressed = 0x06004C65u;
|
||||
internal const uint ThumbBotNormal = 0x06004C66u;
|
||||
internal const uint ThumbBotRollover = 0x06004C67u;
|
||||
internal const uint ThumbBotPressed = 0x06004C68u;
|
||||
|
||||
// ── Horizontal skin (element 0x1000036D, 16 px) ──────────────────────
|
||||
internal const uint HTrack = 0x06004C7Fu;
|
||||
|
||||
/// <summary>Left button = the INCREMENT designee 0x1000036C.</summary>
|
||||
internal const uint LeftNormal = 0x06004C8Cu;
|
||||
internal const uint LeftRollover = 0x06004C8Du;
|
||||
internal const uint LeftPressed = 0x06004C8Eu;
|
||||
|
||||
/// <summary>Right button = the DECREMENT designee 0x1000036B.</summary>
|
||||
internal const uint RightNormal = 0x06004C89u;
|
||||
internal const uint RightRollover = 0x06004C8Au;
|
||||
internal const uint RightPressed = 0x06004C8Bu;
|
||||
|
||||
internal const uint HThumbTopNormal = 0x06004C80u;
|
||||
internal const uint HThumbTopRollover = 0x06004C81u;
|
||||
internal const uint HThumbTopPressed = 0x06004C82u;
|
||||
internal const uint HThumbMidNormal = 0x06004C83u;
|
||||
internal const uint HThumbMidRollover = 0x06004C84u;
|
||||
internal const uint HThumbMidPressed = 0x06004C85u;
|
||||
internal const uint HThumbBotNormal = 0x06004C86u;
|
||||
internal const uint HThumbBotRollover = 0x06004C87u;
|
||||
internal const uint HThumbBotPressed = 0x06004C88u;
|
||||
|
||||
/// <summary>Wires the full retail vertical skin onto <paramref name="bar"/>.</summary>
|
||||
internal static void ApplyVertical(UiScrollbar bar)
|
||||
{
|
||||
bar.TrackSprite = Track;
|
||||
bar.UpSprite = UpNormal;
|
||||
bar.UpRolloverSprite = UpRollover;
|
||||
bar.UpPressedSprite = UpPressed;
|
||||
bar.DownSprite = DownNormal;
|
||||
bar.DownRolloverSprite = DownRollover;
|
||||
bar.DownPressedSprite = DownPressed;
|
||||
bar.ThumbTopSprite = ThumbTopNormal;
|
||||
bar.ThumbTopRolloverSprite = ThumbTopRollover;
|
||||
bar.ThumbTopPressedSprite = ThumbTopPressed;
|
||||
bar.ThumbSprite = ThumbMidNormal;
|
||||
bar.ThumbRolloverSprite = ThumbMidRollover;
|
||||
bar.ThumbPressedSprite = ThumbMidPressed;
|
||||
bar.ThumbBotSprite = ThumbBotNormal;
|
||||
bar.ThumbBotRolloverSprite = ThumbBotRollover;
|
||||
bar.ThumbBotPressedSprite = ThumbBotPressed;
|
||||
}
|
||||
|
||||
/// <summary>Wires the full retail horizontal skin onto <paramref name="bar"/>.
|
||||
/// The leading (<see cref="UiScrollbar.UpSprite"/>) slot is the LEFT edge.</summary>
|
||||
internal static void ApplyHorizontal(UiScrollbar bar)
|
||||
{
|
||||
bar.TrackSprite = HTrack;
|
||||
bar.UpSprite = LeftNormal;
|
||||
bar.UpRolloverSprite = LeftRollover;
|
||||
bar.UpPressedSprite = LeftPressed;
|
||||
bar.DownSprite = RightNormal;
|
||||
bar.DownRolloverSprite = RightRollover;
|
||||
bar.DownPressedSprite = RightPressed;
|
||||
bar.ThumbTopSprite = HThumbTopNormal;
|
||||
bar.ThumbTopRolloverSprite = HThumbTopRollover;
|
||||
bar.ThumbTopPressedSprite = HThumbTopPressed;
|
||||
bar.ThumbSprite = HThumbMidNormal;
|
||||
bar.ThumbRolloverSprite = HThumbMidRollover;
|
||||
bar.ThumbPressedSprite = HThumbMidPressed;
|
||||
bar.ThumbBotSprite = HThumbBotNormal;
|
||||
bar.ThumbBotRolloverSprite = HThumbBotRollover;
|
||||
bar.ThumbBotPressedSprite = HThumbBotPressed;
|
||||
}
|
||||
}
|
||||
|
|
@ -12,9 +12,13 @@ namespace AcDream.App.UI;
|
|||
/// Dat element ids (chat LayoutDesc 0x2100006F, Campaign CH slice CH6a — retired the
|
||||
/// wrong 0x21000006 import): track 0x10000012 (X=384 Y=0 W=16 H=73 relative to the
|
||||
/// transcript panel). The track is instanced from base layout 0x2100003E which contains
|
||||
/// the full scrollbar widget with distinct up/down button children:
|
||||
/// Up button element 0x10000071 — Y=0, 16×16, Normal sprite 0x06004C69.
|
||||
/// Down button element 0x10000072 — Y=32, 16×16, Normal sprite 0x06004C6C.
|
||||
/// the full scrollbar widget with distinct button children. IMPORTANT — retail seats
|
||||
/// the buttons by DESIGNATION, not authored position: <c>UpdateScrollingArea
|
||||
/// @0x00470AA0</c> moves the INCREMENT designee (attribute 0x77 = 0x10000072, the
|
||||
/// UP-arrow art 0x06004C6C, authored at Y=32) to the TOP and the DECREMENT designee
|
||||
/// (attribute 0x78 = 0x10000071, the DOWN-arrow art 0x06004C69, authored at Y=0) to
|
||||
/// the BOTTOM. Seating by authored Y renders both arrows upside down (2026-08-24
|
||||
/// owner report). <see cref="RetailScrollbarChrome"/> holds the correctly-seated set.
|
||||
/// 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.
|
||||
/// The widget reproduces referenced button children procedurally and uses their
|
||||
|
|
@ -118,10 +122,31 @@ public sealed class UiScrollbar : UiElement
|
|||
/// <summary>Thumb 3-slice BOTTOM cap sprite id (0x06004C66, 3px tall).</summary>
|
||||
public uint ThumbBotSprite { get; set; }
|
||||
|
||||
/// <summary>Up-arrow button sprite id (0x06004C69 Normal state, element 0x10000071).</summary>
|
||||
/// <summary>
|
||||
/// Thumb rollover/pressed media. Retail's thumb (the scrollbar's
|
||||
/// structural child 1 — <c>UIElement_Scrollbar::UpdateLayout
|
||||
/// @0x004710d0</c> binds <c>m_pWidget = GetChild(this, 1)</c>) authors
|
||||
/// three states per slice: Normal (dark navy), Normal_rollover (bright
|
||||
/// blue highlight), Normal_pressed (dark again). Hovering the thumb
|
||||
/// highlights it; holding a drag shows the pressed art, which is
|
||||
/// authored to look like the resting color — the 2026-08-24 owner
|
||||
/// report's "highlights on hover, returns to the original color while
|
||||
/// you hold". Zero ids fall back to the Normal sprites.
|
||||
/// </summary>
|
||||
public uint ThumbRolloverSprite { get; set; }
|
||||
public uint ThumbPressedSprite { get; set; }
|
||||
public uint ThumbTopRolloverSprite { get; set; }
|
||||
public uint ThumbTopPressedSprite { get; set; }
|
||||
public uint ThumbBotRolloverSprite { get; set; }
|
||||
public uint ThumbBotPressedSprite { get; set; }
|
||||
|
||||
/// <summary>Top/leading button sprite id. Retail seats the INCREMENT
|
||||
/// designee here (vertical base skin: 0x10000072's UP-arrow art
|
||||
/// 0x06004C6C) — see the class remarks and <see cref="RetailScrollbarChrome"/>.</summary>
|
||||
public uint UpSprite { get; set; }
|
||||
|
||||
/// <summary>Down-arrow button sprite id (0x06004C6C Normal state, element 0x10000072).</summary>
|
||||
/// <summary>Bottom/trailing button sprite id. Retail seats the DECREMENT
|
||||
/// designee here (vertical base skin: 0x10000071's DOWN-arrow art 0x06004C69).</summary>
|
||||
public uint DownSprite { get; set; }
|
||||
|
||||
/// <summary>Rollover and pressed media for the start/decrement button.</summary>
|
||||
|
|
@ -155,6 +180,7 @@ public sealed class UiScrollbar : UiElement
|
|||
private const float CapH = 3f;
|
||||
|
||||
private bool _draggingThumb;
|
||||
private bool _hoveredThumb;
|
||||
private float _dragOffsetY;
|
||||
private float _dragOffsetX;
|
||||
private EndButton _hoveredButton;
|
||||
|
|
@ -245,7 +271,7 @@ public sealed class UiScrollbar : UiElement
|
|||
}
|
||||
float travel = MathF.Max(0f, Width - thumbWidth);
|
||||
float x = travel * ScalarPosition;
|
||||
DrawSprite(ctx, resolve, ThumbSprite, x, 0f, thumbWidth, Height);
|
||||
DrawSprite(ctx, resolve, ActiveThumbSprite, x, 0f, thumbWidth, Height);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -269,20 +295,24 @@ public sealed class UiScrollbar : UiElement
|
|||
DrawSprite(ctx, resolve, ActiveEndSprite,
|
||||
0f, Height - incrementExtent, Width, incrementExtent);
|
||||
|
||||
// Thumb — only when content overflows the view. Retail 3-slice: top cap +
|
||||
// tiled middle + bottom cap (base layout 0x2100003E thumb sub-elements
|
||||
// 0x10000364/65/66). Falls back to a single tiled middle if the caps are unset
|
||||
// Thumb — drawn even with nothing to scroll: retail's proportion
|
||||
// attribute 0x88 defaults to 1.0 (UpdateLayout @0x004710d0), so a
|
||||
// content-fits scrollbar shows a thumb FILLING the whole track
|
||||
// (ThumbRatio clamps to 1 → full trackLen); only the page-click
|
||||
// regions and input go away with the disabled state. Retail 3-slice:
|
||||
// top cap + tiled middle + bottom cap (base layout 0x2100003E thumb
|
||||
// sub-elements 0x10000364/65/66), each with hover/pressed state
|
||||
// media. Falls back to a single tiled middle if the caps are unset
|
||||
// or the thumb is too short to hold both caps.
|
||||
if (m.HasOverflow)
|
||||
{
|
||||
float trackTop = decrementExtent;
|
||||
float trackLen = MathF.Max(0f, Height - decrementExtent - incrementExtent);
|
||||
var (ty, th) = ThumbRect(m, trackTop, trackLen);
|
||||
if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && th >= 2f * CapH)
|
||||
{
|
||||
DrawSprite(ctx, resolve, ThumbTopSprite, 0f, ty, Width, CapH);
|
||||
DrawTiled(ctx, resolve, ThumbSprite, 0f, ty + CapH, Width, th - 2f * CapH);
|
||||
DrawSprite(ctx, resolve, ThumbBotSprite, 0f, ty + th - CapH, Width, CapH);
|
||||
DrawSprite(ctx, resolve, ActiveThumbTopSprite, 0f, ty, Width, CapH);
|
||||
DrawTiled(ctx, resolve, ActiveThumbSprite, 0f, ty + CapH, Width, th - 2f * CapH);
|
||||
DrawSprite(ctx, resolve, ActiveThumbBotSprite, 0f, ty + th - CapH, Width, CapH);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -296,11 +326,29 @@ public sealed class UiScrollbar : UiElement
|
|||
// (~9 repeats on Summary's overview bar, ~2 on Skills, per
|
||||
// the live capture). DrawThumbMarker draws exactly ONE
|
||||
// instance at its own native size.
|
||||
DrawThumbMarker(ctx, resolve, ThumbSprite, 0f, ty, Width, th, vertical: true);
|
||||
DrawThumbMarker(ctx, resolve, ActiveThumbSprite, 0f, ty, Width, th, vertical: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks a thumb slice's sprite for the current interaction state:
|
||||
/// dragging → pressed media, hovered → rollover media, else normal —
|
||||
/// mirroring retail's authored Normal/Normal_rollover/Normal_pressed
|
||||
/// state machine on the thumb's slice children. Zero-id media fall
|
||||
/// back to the normal sprite.
|
||||
/// </summary>
|
||||
private uint ActiveThumb(uint normal, uint rollover, uint pressed)
|
||||
=> _draggingThumb && pressed != 0u
|
||||
? pressed
|
||||
: _hoveredThumb && !_draggingThumb && rollover != 0u
|
||||
? rollover
|
||||
: normal;
|
||||
|
||||
private uint ActiveThumbSprite => ActiveThumb(ThumbSprite, ThumbRolloverSprite, ThumbPressedSprite);
|
||||
private uint ActiveThumbTopSprite => ActiveThumb(ThumbTopSprite, ThumbTopRolloverSprite, ThumbTopPressedSprite);
|
||||
private uint ActiveThumbBotSprite => ActiveThumb(ThumbBotSprite, ThumbBotRolloverSprite, ThumbBotPressedSprite);
|
||||
|
||||
/// <summary>
|
||||
/// R4-2 (Campaign CC gate round 1 re-test 3): draws ONE instance of a
|
||||
/// single-sprite scrollbar thumb at its own native size, centered
|
||||
|
|
@ -351,21 +399,22 @@ public sealed class UiScrollbar : UiElement
|
|||
DrawSprite(ctx, resolve, ActiveStartSprite, 0f, 0f, decrementExtent, Height);
|
||||
DrawSprite(ctx, resolve, ActiveEndSprite,
|
||||
Width - incrementExtent, 0f, incrementExtent, Height);
|
||||
if (!model.HasOverflow) return;
|
||||
|
||||
// Content-fits bars draw a FULL-track thumb, same as the vertical
|
||||
// path (retail proportion attribute 0x88 defaults to 1.0).
|
||||
float trackLeft = decrementExtent;
|
||||
float trackLength = MathF.Max(0f, Width - decrementExtent - incrementExtent);
|
||||
var (tx, tw) = ThumbRect(model, trackLeft, trackLength);
|
||||
if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && tw >= 2f * CapH)
|
||||
{
|
||||
DrawSprite(ctx, resolve, ThumbTopSprite, tx, 0f, CapH, Height);
|
||||
DrawTiled(ctx, resolve, ThumbSprite, tx + CapH, 0f, tw - 2f * CapH, Height);
|
||||
DrawSprite(ctx, resolve, ThumbBotSprite, tx + tw - CapH, 0f, CapH, Height);
|
||||
DrawSprite(ctx, resolve, ActiveThumbTopSprite, tx, 0f, CapH, Height);
|
||||
DrawTiled(ctx, resolve, ActiveThumbSprite, tx + CapH, 0f, tw - 2f * CapH, Height);
|
||||
DrawSprite(ctx, resolve, ActiveThumbBotSprite, tx + tw - CapH, 0f, CapH, Height);
|
||||
}
|
||||
else
|
||||
{
|
||||
// R4-2: horizontal counterpart of the vertical fallback above.
|
||||
DrawThumbMarker(ctx, resolve, ThumbSprite, tx, 0f, tw, Height, vertical: false);
|
||||
DrawThumbMarker(ctx, resolve, ActiveThumbSprite, tx, 0f, tw, Height, vertical: false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -390,7 +439,7 @@ public sealed class UiScrollbar : UiElement
|
|||
float thumbHeight = ScalarThumbExtent(resolve, Height);
|
||||
float travel = MathF.Max(0f, Height - thumbHeight);
|
||||
float y = travel * ScalarPosition;
|
||||
DrawSprite(ctx, resolve, ThumbSprite, 0f, y, Width, thumbHeight);
|
||||
DrawSprite(ctx, resolve, ActiveThumbSprite, 0f, y, Width, thumbHeight);
|
||||
}
|
||||
|
||||
/// <summary>Draw a sprite stretched 1:1 to the dest rect.</summary>
|
||||
|
|
@ -474,6 +523,7 @@ public sealed class UiScrollbar : UiElement
|
|||
if (IsModelDisabled)
|
||||
{
|
||||
_draggingThumb = false;
|
||||
_hoveredThumb = false;
|
||||
_hoveredButton = EndButton.None;
|
||||
_pressedButton = EndButton.None;
|
||||
return false;
|
||||
|
|
@ -482,15 +532,20 @@ public sealed class UiScrollbar : UiElement
|
|||
if (e.Type == UiEventType.HoverEnter)
|
||||
{
|
||||
_hoveredButton = ButtonAt(e.Data1, e.Data2);
|
||||
_hoveredThumb = ThumbAt(e.Data1, e.Data2);
|
||||
return true;
|
||||
}
|
||||
if (e.Type == UiEventType.HoverLeave)
|
||||
{
|
||||
_hoveredButton = EndButton.None;
|
||||
_hoveredThumb = false;
|
||||
return true;
|
||||
}
|
||||
if (e.Type == UiEventType.MouseMove)
|
||||
{
|
||||
_hoveredButton = ButtonAt(e.Data1, e.Data2);
|
||||
_hoveredThumb = ThumbAt(e.Data1, e.Data2);
|
||||
}
|
||||
|
||||
// Fix round F11: retail's chargen shade scrollbar (0x10000321) is
|
||||
// authored VERTICAL (measured against the installed dat), but a
|
||||
|
|
@ -758,6 +813,53 @@ public sealed class UiScrollbar : UiElement
|
|||
ScalarChanged?.Invoke(ScalarPosition);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the point sits on the thumb, for hover-state tracking —
|
||||
/// covers all four modes (model/scalar × vertical/horizontal) using the
|
||||
/// SAME geometry the matching draw and MouseDown paths compute.
|
||||
/// </summary>
|
||||
private bool ThumbAt(float x, float y)
|
||||
{
|
||||
if (x < 0f || x >= Width || y < 0f || y >= Height)
|
||||
return false;
|
||||
|
||||
if (ScalarChanged is not null)
|
||||
{
|
||||
if (Horizontal)
|
||||
{
|
||||
float thumbWidth = ScalarThumbWidth(SpriteResolve);
|
||||
float thumbX = MathF.Max(0f, Width - thumbWidth) * ScalarPosition;
|
||||
return x >= thumbX && x <= thumbX + thumbWidth;
|
||||
}
|
||||
float thumbHeight = ScalarThumbExtent(SpriteResolve, Height);
|
||||
float thumbY = MathF.Max(0f, Height - thumbHeight) * ScalarPosition;
|
||||
return y >= thumbY && y <= thumbY + thumbHeight;
|
||||
}
|
||||
|
||||
if (Model is not { } m) return false;
|
||||
|
||||
if (Horizontal)
|
||||
{
|
||||
float trackLeft = AxisExtent(DecrementButtonExtent, Width);
|
||||
float trackLength = MathF.Max(
|
||||
0f,
|
||||
Width
|
||||
- AxisExtent(DecrementButtonExtent, Width)
|
||||
- AxisExtent(IncrementButtonExtent, Width));
|
||||
var (tx, tw) = ThumbRect(m, trackLeft, trackLength);
|
||||
return x >= tx && x <= tx + tw;
|
||||
}
|
||||
|
||||
float trackTop = AxisExtent(DecrementButtonExtent, Height);
|
||||
float trackLen = MathF.Max(
|
||||
0f,
|
||||
Height
|
||||
- AxisExtent(DecrementButtonExtent, Height)
|
||||
- AxisExtent(IncrementButtonExtent, Height));
|
||||
var (ty, th) = ThumbRect(m, trackTop, trackLen);
|
||||
return y >= ty && y <= ty + th;
|
||||
}
|
||||
|
||||
private EndButton ButtonAt(float x, float y)
|
||||
{
|
||||
if (x < 0f || x >= Width || y < 0f || y >= Height)
|
||||
|
|
@ -799,6 +901,9 @@ public sealed class UiScrollbar : UiElement
|
|||
|
||||
internal uint ActiveStartSpriteForTest => ActiveStartSprite;
|
||||
internal uint ActiveEndSpriteForTest => ActiveEndSprite;
|
||||
internal uint ActiveThumbSpriteForTest => ActiveThumbSprite;
|
||||
internal uint ActiveThumbTopSpriteForTest => ActiveThumbTopSprite;
|
||||
internal uint ActiveThumbBotSpriteForTest => ActiveThumbBotSprite;
|
||||
|
||||
private static float AxisExtent(float authoredExtent, float axisLength)
|
||||
=> Math.Clamp(authoredExtent, 0f, MathF.Max(0f, axisLength));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue