feat(ui): port favorite spell bar overflow

Import the retail arrow-only spell bar scrollbar from LayoutDesc, preserve authored end-button extents and HideDisabled behavior in the shared retained widget, and bind each favorite list to its sole horizontal pixel-scroll model. Match retail selection exposure for off-screen spells and pin the behavior with real-fixture conformance tests.

Document the six-slice world-interaction completion program as the active pre-M4 work order.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-23 06:35:53 +02:00
parent 20e2998adb
commit 02c29e67c8
12 changed files with 582 additions and 45 deletions

View file

@ -16,9 +16,8 @@ namespace AcDream.App.UI;
/// Down button element 0x10000072 — Y=32, 16×16, Normal sprite 0x06004C6C.
/// Track body sprite: 0x06004C5F (48px tall in the base template; stretched to H=68 in chat).
/// Thumb is a 3-slice: top cap 0x06004C60, middle 0x06004C63, bottom cap 0x06004C66.
/// For Task H wiring: up/down regions occupy the top and bottom ButtonH (16px) of the
/// rendered scrollbar's height; the widget responds to those regions directly via hit
/// comparison in OnEvent without requiring separate child elements.
/// The widget reproduces referenced button children procedurally and uses their
/// authored extent for drawing and hit comparison (16px in this base layout).
/// </remarks>
public sealed class UiScrollbar : UiElement
{
@ -84,16 +83,28 @@ public sealed class UiScrollbar : UiElement
/// <summary>Down-arrow button sprite id (0x06004C6C Normal state, element 0x10000072).</summary>
public uint DownSprite { get; set; }
/// <summary>
/// Authored extent of the decrement button along the scrollbar axis. Retail
/// positions and subtracts the referenced button's real size; chat uses
/// 16 pixels while the favorite-spell bar uses 23.
/// </summary>
public float DecrementButtonExtent { get; set; } = 16f;
/// <summary>Authored extent of the increment button along the scrollbar axis.</summary>
public float IncrementButtonExtent { get; set; } = 16f;
/// <summary>
/// Retail scrollbar property 0x79. When the linked scrollable is disabled
/// because its content fits, UpdateLayout also hides the scrollbar.
/// </summary>
public bool HideWhenDisabled { get; set; }
/// <summary>Retail attribute 0x89 floor: minimum thumb height in pixels.</summary>
private const float MinThumb = 8f;
/// <summary>Thumb cap height (native sprite height from base layout 0x2100003E).</summary>
private const float CapH = 3f;
/// <summary>Up/down button height in pixels. Matches element height 16px from
/// the up/down button children in base layout 0x2100003E.</summary>
private const float ButtonH = 16f;
private bool _draggingThumb;
private float _dragOffsetY;
private float _dragOffsetX;
@ -104,6 +115,18 @@ public sealed class UiScrollbar : UiElement
/// children are reproduced procedurally, so the importer must not build them.</summary>
public override bool ConsumesDatChildren => true;
/// <summary>
/// The model owns disabled state just as retail UIElement_Scrollable does:
/// no overflow means the linked scrollbar is disabled.
/// </summary>
internal bool IsModelDisabled
=> ScalarChanged is null && Model is { HasOverflow: false };
internal bool IsPresentationVisible => !HideWhenDisabled || !IsModelDisabled;
protected override bool OnHitTest(float localX, float localY)
=> !IsModelDisabled && base.OnHitTest(localX, localY);
/// <summary>
/// Computes the thumb rectangle (local y origin and height) within the track area
/// between the two end buttons. Ports retail <c>UIElement_Scrollbar::UpdateLayout
@ -138,6 +161,7 @@ public sealed class UiScrollbar : UiElement
protected override void OnDraw(UiRenderContext ctx)
{
if (!IsPresentationVisible) return;
if (SpriteResolve is not { } resolve) return;
if (Horizontal)
{
@ -173,11 +197,13 @@ public sealed class UiScrollbar : UiElement
// sprite (~16×32) repeats to fill the element height instead of stretch-distorting.
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
// Decrement/up button — top ButtonH rows (inherited element 0x10000071).
DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH);
float decrementExtent = AxisExtent(DecrementButtonExtent, Height);
float incrementExtent = AxisExtent(IncrementButtonExtent, Height);
// Increment/down button — bottom ButtonH rows (inherited element 0x10000072).
DrawSprite(ctx, resolve, DownSprite, 0f, Height - ButtonH, Width, ButtonH);
// Decrement/up and increment/down use their authored button heights.
DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, decrementExtent);
DrawSprite(ctx, resolve, DownSprite,
0f, Height - incrementExtent, Width, incrementExtent);
// Thumb — only when content overflows the view. Retail 3-slice: top cap +
// tiled middle + bottom cap (base layout 0x2100003E thumb sub-elements
@ -185,8 +211,8 @@ public sealed class UiScrollbar : UiElement
// or the thumb is too short to hold both caps.
if (m.HasOverflow)
{
float trackTop = ButtonH;
float trackLen = Height - 2f * ButtonH;
float trackTop = decrementExtent;
float trackLen = MathF.Max(0f, Height - decrementExtent - incrementExtent);
var (ty, th) = ThumbRect(m, trackTop, trackLen);
if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && th >= 2f * CapH)
{
@ -206,13 +232,16 @@ public sealed class UiScrollbar : UiElement
Func<uint, (uint tex, int w, int h)> resolve,
UiScrollable model)
{
float decrementExtent = AxisExtent(DecrementButtonExtent, Width);
float incrementExtent = AxisExtent(IncrementButtonExtent, Width);
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
DrawSprite(ctx, resolve, UpSprite, 0f, 0f, ButtonH, Height);
DrawSprite(ctx, resolve, DownSprite, Width - ButtonH, 0f, ButtonH, Height);
DrawSprite(ctx, resolve, UpSprite, 0f, 0f, decrementExtent, Height);
DrawSprite(ctx, resolve, DownSprite,
Width - incrementExtent, 0f, incrementExtent, Height);
if (!model.HasOverflow) return;
float trackLeft = ButtonH;
float trackLength = MathF.Max(0f, Width - 2f * ButtonH);
float trackLeft = decrementExtent;
float trackLength = MathF.Max(0f, Width - decrementExtent - incrementExtent);
var (tx, tw) = ThumbRect(model, trackLeft, trackLength);
if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && tw >= 2f * CapH)
{
@ -290,6 +319,12 @@ public sealed class UiScrollbar : UiElement
public override bool OnEvent(in UiEvent e)
{
if (IsModelDisabled)
{
_draggingThumb = false;
return false;
}
if (Horizontal && ScalarChanged is not null)
return OnScalarEvent(e);
@ -304,16 +339,18 @@ public sealed class UiScrollbar : UiElement
{
// e.Data1 = local X, e.Data2 = local Y (int pixel coords, see UiRoot hit dispatch).
float ly = e.Data2;
float decrementExtent = AxisExtent(DecrementButtonExtent, Height);
float incrementExtent = AxisExtent(IncrementButtonExtent, Height);
// Up-button region: top ButtonH rows.
if (ly <= ButtonH) { m.ScrollByLines(-1); return true; }
// Up-button region: authored top rows.
if (ly < decrementExtent) { m.ScrollByLines(-1); return true; }
// Down-button region: bottom ButtonH rows.
if (ly >= Height - ButtonH) { m.ScrollByLines(1); return true; }
// Down-button region: authored bottom rows.
if (ly >= Height - incrementExtent) { m.ScrollByLines(1); return true; }
// Track interior: start a thumb drag or page-scroll.
float trackTop = ButtonH;
float trackLen = Height - 2f * ButtonH;
float trackTop = decrementExtent;
float trackLen = MathF.Max(0f, Height - decrementExtent - incrementExtent);
var (ty, th) = ThumbRect(m, trackTop, trackLen);
if (ly >= ty && ly <= ty + th)
@ -334,8 +371,12 @@ public sealed class UiScrollbar : UiElement
{
// Map current local Y (minus drag offset from thumb top) back to a
// position ratio across the available travel distance.
float trackTop = ButtonH;
float trackLen = Height - 2f * ButtonH;
float trackTop = AxisExtent(DecrementButtonExtent, Height);
float trackLen = MathF.Max(
0f,
Height
- AxisExtent(DecrementButtonExtent, Height)
- AxisExtent(IncrementButtonExtent, Height));
float thumbH = MathF.Max(MinThumb, trackLen * m.ThumbRatio);
float travel = MathF.Max(1f, trackLen - thumbH);
float newRatio = ((float)e.Data2 - _dragOffsetY - trackTop) / travel;
@ -359,11 +400,13 @@ public sealed class UiScrollbar : UiElement
case UiEventType.MouseDown:
{
float x = e.Data1;
if (x <= ButtonH) { m.ScrollByLines(-1); return true; }
if (x >= Width - ButtonH) { m.ScrollByLines(1); return true; }
float decrementExtent = AxisExtent(DecrementButtonExtent, Width);
float incrementExtent = AxisExtent(IncrementButtonExtent, Width);
if (x < decrementExtent) { m.ScrollByLines(-1); return true; }
if (x >= Width - incrementExtent) { m.ScrollByLines(1); return true; }
float trackLeft = ButtonH;
float trackLength = MathF.Max(0f, Width - 2f * ButtonH);
float trackLeft = decrementExtent;
float trackLength = MathF.Max(0f, Width - decrementExtent - incrementExtent);
var (tx, tw) = ThumbRect(m, trackLeft, trackLength);
if (x >= tx && x <= tx + tw)
{
@ -379,8 +422,12 @@ public sealed class UiScrollbar : UiElement
case UiEventType.MouseMove when _draggingThumb:
{
float trackLeft = ButtonH;
float trackLength = MathF.Max(0f, Width - 2f * ButtonH);
float trackLeft = AxisExtent(DecrementButtonExtent, Width);
float trackLength = MathF.Max(
0f,
Width
- AxisExtent(DecrementButtonExtent, Width)
- AxisExtent(IncrementButtonExtent, Width));
float thumbWidth = MathF.Max(MinThumb, trackLength * m.ThumbRatio);
float travel = MathF.Max(1f, trackLength - thumbWidth);
float ratio = ((float)e.Data1 - _dragOffsetX - trackLeft) / travel;
@ -449,4 +496,7 @@ public sealed class UiScrollbar : UiElement
SetScalarPosition(position);
ScalarChanged?.Invoke(ScalarPosition);
}
private static float AxisExtent(float authoredExtent, float axisLength)
=> Math.Clamp(authoredExtent, 0f, MathF.Max(0f, axisLength));
}