From 8fd3d1a9f0f2e9b2dfbba3e79320b5240ffde603 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 19:20:43 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20retail=20scrollbar=20parity=20?= =?UTF-8?q?=E2=80=94=20button=20seating,=20full-track=20thumb,=20hover/pre?= =?UTF-8?q?ssed=20states?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../UI/Layout/CharacterStatController.cs | 18 +-- .../UI/Layout/ConfigOptionsPageController.cs | 7 +- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 59 +++++-- .../UI/Layout/ExternalContainerController.cs | 11 +- .../UI/Layout/VendorUiController.cs | 10 +- src/AcDream.App/UI/RetailScrollbarChrome.cs | 108 +++++++++++++ src/AcDream.App/UI/UiScrollbar.cs | 145 +++++++++++++++--- .../UI/Layout/CharacterStatControllerTests.cs | 8 +- .../UI/Layout/ChatLayoutConformanceTests.cs | 10 +- .../UI/Layout/DatWidgetFactoryTests.cs | 60 ++++++++ .../UI/Layout/ScrollbarSkinLiveDatTests.cs | 67 ++++++++ .../AcDream.App.Tests/UI/UiScrollbarTests.cs | 102 ++++++++++++ 12 files changed, 541 insertions(+), 64 deletions(-) create mode 100644 src/AcDream.App/UI/RetailScrollbarChrome.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/ScrollbarSkinLiveDatTests.cs diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index d29dfdc4..0cdfad33 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -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 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) diff --git a/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs b/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs index 10d2f26f..be320d65 100644 --- a/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs +++ b/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs @@ -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; } /// Applies + geometry to a diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index bb12f3fb..2e5f7a0b 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -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; diff --git a/src/AcDream.App/UI/Layout/ExternalContainerController.cs b/src/AcDream.App/UI/Layout/ExternalContainerController.cs index db66e5ed..75d9a9f5 100644 --- a/src/AcDream.App/UI/Layout/ExternalContainerController.cs +++ b/src/AcDream.App/UI/Layout/ExternalContainerController.cs @@ -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); diff --git a/src/AcDream.App/UI/Layout/VendorUiController.cs b/src/AcDream.App/UI/Layout/VendorUiController.cs index b1ddf49e..f6dd3231 100644 --- a/src/AcDream.App/UI/Layout/VendorUiController.cs +++ b/src/AcDream.App/UI/Layout/VendorUiController.cs @@ -181,8 +181,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag /// list, with a real thumb/up/down-button subtree matching /// 's own shape exactly: thumb caps /// 0x06004C60/63/66, up button (element - /// 0x10000071) 0x06004C69/6A/6B, down button - /// (element 0x10000072) 0x06004C6C/6D/6E, + /// 0x10000072, retail-seated on top) 0x06004C6C/6D/6E, down button + /// (element 0x10000071, retail-seated on the bottom) 0x06004C69/6A/6B, /// track 0x06004C5F). 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; /// /// Retail's ordered category table, transcribed verbatim from diff --git a/src/AcDream.App/UI/RetailScrollbarChrome.cs b/src/AcDream.App/UI/RetailScrollbarChrome.cs new file mode 100644 index 00000000..0dc3623d --- /dev/null +++ b/src/AcDream.App/UI/RetailScrollbarChrome.cs @@ -0,0 +1,108 @@ +namespace AcDream.App.UI; + +/// +/// The base scrollbar skin from LayoutDesc 0x2100003E, seated the way +/// retail seats it at runtime. UIElement_Scrollbar::UpdateScrollingArea +/// @0x00470AA0 ignores the buttons' authored positions: it moves the +/// INCREMENT button (attribute 0x77) to the top/left corner +/// (MoveTo(0,0)) and the DECREMENT button (attribute 0x78) to +/// the bottom/right corner. The vertical skin (element 0x10000455) +/// designates 0x77=0x10000072 (the UP-arrow art, authored at Y=32) +/// and 0x78=0x10000071 (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. +/// +internal static class RetailScrollbarChrome +{ + // ── Vertical skin (element 0x10000455, 16 px) ──────────────────────── + internal const uint Track = 0x06004C5Fu; + + /// Top button = the INCREMENT designee 0x10000072 (up arrow). + internal const uint UpNormal = 0x06004C6Cu; + internal const uint UpRollover = 0x06004C6Du; + internal const uint UpPressed = 0x06004C6Eu; + + /// Bottom button = the DECREMENT designee 0x10000071 (down arrow). + 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; + + /// Left button = the INCREMENT designee 0x1000036C. + internal const uint LeftNormal = 0x06004C8Cu; + internal const uint LeftRollover = 0x06004C8Du; + internal const uint LeftPressed = 0x06004C8Eu; + + /// Right button = the DECREMENT designee 0x1000036B. + 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; + + /// Wires the full retail vertical skin onto . + 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; + } + + /// Wires the full retail horizontal skin onto . + /// The leading () slot is the LEFT edge. + 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; + } +} diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs index 3892bdac..3ecef835 100644 --- a/src/AcDream.App/UI/UiScrollbar.cs +++ b/src/AcDream.App/UI/UiScrollbar.cs @@ -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: UpdateScrollingArea +/// @0x00470AA0 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). 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 /// Thumb 3-slice BOTTOM cap sprite id (0x06004C66, 3px tall). public uint ThumbBotSprite { get; set; } - /// Up-arrow button sprite id (0x06004C69 Normal state, element 0x10000071). + /// + /// Thumb rollover/pressed media. Retail's thumb (the scrollbar's + /// structural child 1 — UIElement_Scrollbar::UpdateLayout + /// @0x004710d0 binds m_pWidget = GetChild(this, 1)) 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. + /// + 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; } + + /// 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 . public uint UpSprite { get; set; } - /// Down-arrow button sprite id (0x06004C6C Normal state, element 0x10000072). + /// Bottom/trailing button sprite id. Retail seats the DECREMENT + /// designee here (vertical base skin: 0x10000071's DOWN-arrow art 0x06004C69). public uint DownSprite { get; set; } /// Rollover and pressed media for the start/decrement button. @@ -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); } } } + /// + /// 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. + /// + 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); + /// /// 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); } /// Draw a sprite stretched 1:1 to the dest rect. @@ -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); } + /// + /// 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. + /// + 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)); diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index a62590b0..96e142b2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -1945,8 +1945,12 @@ public class CharacterStatControllerTests Assert.NotNull(scrollbar.Model); Assert.NotNull(scrollbar.SpriteResolve); Assert.Equal(0x06004C5Fu, scrollbar.TrackSprite); - Assert.Equal(0x06004C69u, scrollbar.UpSprite); - Assert.Equal(0x06004C6Cu, scrollbar.DownSprite); + // Retail seating (2026-08-24): top = the INCREMENT designee's + // UP-arrow art, bottom = the DECREMENT designee's DOWN-arrow. + Assert.Equal(RetailScrollbarChrome.UpNormal, scrollbar.UpSprite); + Assert.Equal(RetailScrollbarChrome.DownNormal, scrollbar.DownSprite); + Assert.Equal(RetailScrollbarChrome.UpRollover, scrollbar.UpRolloverSprite); + Assert.Equal(RetailScrollbarChrome.ThumbMidRollover, scrollbar.ThumbRolloverSprite); } // ── Helpers ────────────────────────────────────────────────────────────── diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs index 28558957..a32066da 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs @@ -287,8 +287,14 @@ public class ChatLayoutConformanceTests Assert.Equal(0x06004C60u, scrollbar.ThumbTopSprite); Assert.Equal(0x06004C63u, scrollbar.ThumbSprite); Assert.Equal(0x06004C66u, scrollbar.ThumbBotSprite); - Assert.Equal(0x06004C69u, scrollbar.UpSprite); - Assert.Equal(0x06004C6Cu, scrollbar.DownSprite); + // Retail seating (2026-08-24): UpdateScrollingArea @0x00470AA0 puts + // the INCREMENT designee (0x10000072, UP-arrow art 0x06004C6C) on the + // top button and the DECREMENT designee (0x10000071, DOWN-arrow + // 0x06004C69) on the bottom, ignoring authored Y. + Assert.Equal(0x06004C6Cu, scrollbar.UpSprite); + Assert.Equal(0x06004C69u, scrollbar.DownSprite); + Assert.Equal(0x06004C64u, scrollbar.ThumbRolloverSprite); + Assert.Equal(0x06004C65u, scrollbar.ThumbPressedSprite); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index 1ca6c0c2..7644d6af 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -786,6 +786,61 @@ public class DatWidgetFactoryTests Assert.Equal(Thumb, bar.ThumbSprite); Assert.Equal(0u, bar.ThumbTopSprite); Assert.Equal(0u, bar.ThumbBotSprite); + // 2026-08-24: the thumb's own rollover/pressed media survive onto + // the widget (hover highlight / held-drag art). + Assert.Equal(0x06005A12u, bar.ThumbRolloverSprite); + Assert.Equal(0x06005A13u, bar.ThumbPressedSprite); + } + + /// + /// 2026-08-24 owner report ("the arrows point in the wrong direction"): + /// retail seats scrollbar buttons by DESIGNATION — + /// UIElement_Scrollbar::UpdateScrollingArea @0x00470AA0 moves the + /// INCREMENT designee (attribute 0x77) to the top corner and the + /// DECREMENT designee (0x78) to the bottom corner regardless of authored + /// position. The real vertical base skin (0x10000455 in layout + /// 0x2100003E) authors the DOWN-arrow decrement at Y=0 and the UP-arrow + /// increment at Y=32, so the previous authored-Y ordering put the + /// down-arrow art on the TOP button. + /// + [Fact] + public void Type11_VerticalScrollbar_SeatsButtonsByDesignation_NotAuthoredPosition() + { + const uint DecrementId = 0x10000071u; // DOWN arrow, authored at Y=0 + const uint IncrementId = 0x10000072u; // UP arrow, authored at Y=32 + var decrement = new ElementInfo { Id = DecrementId, Type = 1u, Y = 0f, Width = 16f, Height = 16f }; + decrement.StateMedia["Normal"] = (0x06004C69u, 1); + decrement.StateMedia["Normal_rollover"] = (0x06004C6Au, 1); + decrement.StateMedia["Normal_pressed"] = (0x06004C6Bu, 1); + var increment = new ElementInfo { Id = IncrementId, Type = 1u, Y = 32f, Width = 16f, Height = 16f }; + increment.StateMedia["Normal"] = (0x06004C6Cu, 1); + increment.StateMedia["Normal_rollover"] = (0x06004C6Du, 1); + increment.StateMedia["Normal_pressed"] = (0x06004C6Eu, 1); + + var info = new ElementInfo + { + Type = 11u, + Width = 16f, + Height = 48f, + 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 }; + info.States[UiStateInfo.DirectStateId] = state; + + var bar = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); + + // Top slot = the increment designee's UP-arrow media. + Assert.Equal(0x06004C6Cu, bar.UpSprite); + Assert.Equal(0x06004C6Du, bar.UpRolloverSprite); + Assert.Equal(0x06004C6Eu, bar.UpPressedSprite); + // Bottom slot = the decrement designee's DOWN-arrow media. + Assert.Equal(0x06004C69u, bar.DownSprite); + Assert.Equal(0x06004C6Au, bar.DownRolloverSprite); + Assert.Equal(0x06004C6Bu, bar.DownPressedSprite); } /// @@ -810,6 +865,8 @@ public class DatWidgetFactoryTests topCap.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal", Image = new UiImageMedia(Top, 1) }; var mid = new ElementInfo { Id = 0x10000365u, Type = 3u, Y = 3f, Width = 16f, Height = 10f }; mid.StateMedia["Normal"] = (Mid, 1); + mid.StateMedia["Normal_rollover"] = (0x06004C64u, 1); + mid.StateMedia["Normal_pressed"] = (0x06004C65u, 1); mid.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal", Image = new UiImageMedia(Mid, 1) }; var botCap = new ElementInfo { Id = 0x10000366u, Type = 3u, Y = 13f, Width = 16f, Height = 3f }; botCap.StateMedia["Normal"] = (Bot, 1); @@ -838,6 +895,9 @@ public class DatWidgetFactoryTests Assert.Equal(Top, bar.ThumbTopSprite); Assert.Equal(Mid, bar.ThumbSprite); Assert.Equal(Bot, bar.ThumbBotSprite); + // 2026-08-24: slice rollover/pressed media survive onto the widget. + Assert.Equal(0x06004C64u, bar.ThumbRolloverSprite); + Assert.Equal(0x06004C65u, bar.ThumbPressedSprite); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/ScrollbarSkinLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/ScrollbarSkinLiveDatTests.cs new file mode 100644 index 00000000..b3d13d9e --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ScrollbarSkinLiveDatTests.cs @@ -0,0 +1,67 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using DatReaderWriter; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// 2026-08-24 pin: the base scrollbar skin (layout 0x2100003E) +/// authors the button DESIGNATIONS that drive retail's runtime seating +/// (UIElement_Scrollbar::UpdateScrollingArea @0x00470AA0 moves the +/// increment designee to the top/left, the decrement designee to the +/// bottom/right, ignoring authored positions). The vertical skin +/// designates 0x77=0x10000072 (UP-arrow art) and 0x78=0x10000071 +/// (DOWN-arrow art) — seating by authored Y renders both arrows upside +/// down, the owner-reported bug. Also pins the three-state media sets +/// mirrors, so a DAT revision or +/// importer regression that loses a state fails loudly. +/// +[Trait("Lane", "InstalledDat")] +public sealed class ScrollbarSkinLiveDatTests +{ + private static string DatDirectory => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + + [InstalledDatFact] + public void VerticalBaseSkin_DesignatesUpArrowAsIncrement_WithThreeStateMedia() + { + using var dats = new DatCollection( + DatDirectory, DatReaderWriter.Options.DatAccessType.Read); + ElementInfo? tree = LayoutImporter.ImportInfos(dats, 0x2100003Eu); + Assert.NotNull(tree); + + ElementInfo bar = Assert.Single(Flatten(tree!), e => e.Id == 0x10000455u); + Assert.True(bar.TryGetEffectiveProperty(0x77u, out UiPropertyValue inc)); + Assert.True(bar.TryGetEffectiveProperty(0x78u, out UiPropertyValue dec)); + Assert.Equal(0x10000072u, inc.UnsignedValue); // increment = UP arrow (authored Y=32) + Assert.Equal(0x10000071u, dec.UnsignedValue); // decrement = DOWN arrow (authored Y=0) + + // Increment (top after seating): red-gem normal, amber rollover, + // highlight pressed — the RetailScrollbarChrome Up set. + ElementInfo up = Assert.Single(bar.Children, c => c.Id == 0x10000072u); + Assert.Equal(RetailScrollbarChrome.UpNormal, up.StateMedia["Normal"].File); + Assert.Equal(RetailScrollbarChrome.UpRollover, up.StateMedia["Normal_rollover"].File); + Assert.Equal(RetailScrollbarChrome.UpPressed, up.StateMedia["Normal_pressed"].File); + ElementInfo down = Assert.Single(bar.Children, c => c.Id == 0x10000071u); + Assert.Equal(RetailScrollbarChrome.DownNormal, down.StateMedia["Normal"].File); + Assert.Equal(RetailScrollbarChrome.DownRollover, down.StateMedia["Normal_rollover"].File); + Assert.Equal(RetailScrollbarChrome.DownPressed, down.StateMedia["Normal_pressed"].File); + + // Thumb (structural child 1) slices each author the three states. + ElementInfo thumb = Assert.Single(bar.Children, c => c.Id == 1u); + ElementInfo mid = Assert.Single(thumb.Children, c => c.Id == 0x10000365u); + Assert.Equal(RetailScrollbarChrome.ThumbMidNormal, mid.StateMedia["Normal"].File); + Assert.Equal(RetailScrollbarChrome.ThumbMidRollover, mid.StateMedia["Normal_rollover"].File); + Assert.Equal(RetailScrollbarChrome.ThumbMidPressed, mid.StateMedia["Normal_pressed"].File); + } + + private static IEnumerable Flatten(ElementInfo e) + { + yield return e; + foreach (var c in e.Children) + foreach (var d in Flatten(c)) + yield return d; + } +} diff --git a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs index 36c80c83..daa501a3 100644 --- a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs +++ b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs @@ -471,6 +471,108 @@ public class UiScrollbarTests Assert.Equal(expectedWidth, width, 3); } + /// + /// 2026-08-24 owner report: retail shows a thumb FILLING the whole + /// track when there is nothing to scroll — the proportion attribute + /// 0x88 defaults to 1.0 in UIElement_Scrollbar::UpdateLayout + /// @0x004710d0, so a content-fits bar sizes the widget to the full + /// scrolling area; only input goes away with the disabled state. The + /// previous draw skipped the thumb entirely on !HasOverflow. + /// + [Fact] + public void NoOverflow_DrawsAFullTrackThumb() + { + var device = new RecordingGpuDevice(); + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + renderer.Begin(new Vector2(800f, 600f)); + var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f)); + + const uint topTex = 60u, midTex = 63u, botTex = 66u; + var model = new UiScrollable { ContentHeight = 150, ViewHeight = 150 }; + var bar = new UiScrollbar + { + Width = 16f, + Height = 200f, + SpriteResolve = id => id is topTex or midTex or botTex ? (id, 16, 3) : (0u, 0, 0), + ThumbTopSprite = topTex, + ThumbSprite = midTex, + ThumbBotSprite = botTex, + Model = model, + }; + Assert.True(bar.IsModelDisabled); + + bar.DrawSelfAndChildren(ctx); + + // Top cap sits at the top of the track (below the 16px up button)… + var top = Assert.Single( + renderer.DebugSpriteSegmentVerts, s => s.Texture == topTex); + float topMinY = Enumerable.Range(0, top.Verts.Count / 8) + .Min(i => top.Verts[i * 8 + 1]); + Assert.Equal(16f, topMinY, 1); + // …and the bottom cap ends at the bottom of the track (above the + // 16px down button) — a full-track thumb. + var bot = Assert.Single( + renderer.DebugSpriteSegmentVerts, s => s.Texture == botTex); + float botMaxY = Enumerable.Range(0, bot.Verts.Count / 8) + .Max(i => bot.Verts[i * 8 + 1]); + Assert.Equal(184f, botMaxY, 1); + } + + /// + /// 2026-08-24 owner report: hovering the thumb highlights it + /// (Normal_rollover media — bright blue on the base skin) and holding a + /// drag shows the pressed media (authored to look like the resting + /// color). Mirrors retail's authored three-state thumb slices. + /// + [Fact] + public void ThumbHoverAndDrag_SelectRolloverAndPressedMedia() + { + var model = new UiScrollable { ContentHeight = 200, ViewHeight = 150, LineHeight = 10 }; + var bar = new UiScrollbar + { + Width = 16f, + Height = 200f, + Model = model, + ThumbSprite = 1u, + ThumbRolloverSprite = 2u, + ThumbPressedSprite = 3u, + ThumbTopSprite = 10u, + ThumbTopRolloverSprite = 20u, + ThumbTopPressedSprite = 30u, + ThumbBotSprite = 100u, + ThumbBotRolloverSprite = 200u, + ThumbBotPressedSprite = 300u, + }; + // Track 16..184 (168px), ratio 0.75 → thumb 16..142 at position 0. + Assert.Equal(1u, bar.ActiveThumbSpriteForTest); + + // Hover over the thumb → rollover on every slice. + bar.OnEvent(new UiEvent(0u, bar, UiEventType.HoverEnter, Data1: 8, Data2: 50)); + Assert.Equal(2u, bar.ActiveThumbSpriteForTest); + Assert.Equal(20u, bar.ActiveThumbTopSpriteForTest); + Assert.Equal(200u, bar.ActiveThumbBotSpriteForTest); + + // Press and hold (drag) → pressed media. + bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 8, Data2: 50)); + Assert.True(bar.IsDragging); + Assert.Equal(3u, bar.ActiveThumbSpriteForTest); + Assert.Equal(30u, bar.ActiveThumbTopSpriteForTest); + Assert.Equal(300u, bar.ActiveThumbBotSpriteForTest); + + // Release while still over the thumb → back to the hover highlight. + bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 8, Data2: 50)); + Assert.Equal(2u, bar.ActiveThumbSpriteForTest); + + // Move to the track BELOW the thumb → back to normal. + bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data1: 8, Data2: 170)); + Assert.Equal(1u, bar.ActiveThumbSpriteForTest); + + // Leave the bar entirely → normal. + bar.OnEvent(new UiEvent(0u, bar, UiEventType.HoverEnter, Data1: 8, Data2: 50)); + bar.OnEvent(new UiEvent(0u, bar, UiEventType.HoverLeave)); + Assert.Equal(1u, bar.ActiveThumbSpriteForTest); + } + private sealed class NullGpuFrameSource : ICurrentGpuFrameSource { public IGpuFrame? CurrentFrame => null;