acdream/src/AcDream.App/UI/UiScrollbar.cs
Erik 0134122c28 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>
2026-07-23 07:49:09 +02:00

583 lines
24 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Generic scrollbar. Ports retail <c>UIElement_Scrollbar</c>
/// (RegisterElementClass(0xb) @ acclient_2013_pseudo_c.txt:124137);
/// thumb size = trackLen * ThumbRatio (min 8px); step ±1 line.
/// </summary>
/// <remarks>
/// Dat element ids (chat LayoutDesc 0x21000006): track 0x10000012 (X=474 Y=6 W=16 H=68),
/// thumb 0x1000048C. 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.
/// 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
/// authored extent for drawing and hit comparison (16px in this base layout).
/// </remarks>
public sealed class UiScrollbar : UiElement
{
public override bool ReceivesHoverMouseMove => true;
/// <summary>The scroll model this bar reflects + drives (shared with the transcript).</summary>
public UiScrollable? Model { get; set; }
/// <summary>
/// Optional scalar mode used by retail's horizontal stack-split control. When set,
/// the bar reflects/drives one normalized value rather than a <see cref="UiScrollable"/>.
/// </summary>
public float ScalarPosition { get; private set; }
public Action<float>? ScalarChanged { get; set; }
public bool Horizontal { get; set; }
/// <summary>
/// Optional fill rendered beneath the scalar thumb. Retail's combat power
/// control is a horizontal scrollbar containing a meter child; the importer
/// folds that authored child into these properties because scrollbars consume
/// their DAT children.
/// </summary>
public Func<float?> ScalarFill { get; set; } = () => null;
public uint ScalarFillSprite { get; set; }
/// <summary>
/// Optional authored texture beneath the live scalar fill. gmCombatUI's
/// dark-red interior media comes from element <c>0x100005EF</c>.
/// </summary>
public uint ScalarRangeSprite { get; set; }
public float ScalarRangeLeft { get; set; }
public float ScalarRangeWidth { get; set; } = float.PositiveInfinity;
/// <summary>
/// Authored layout policy for a consumed range child. This preserves the
/// child's own DAT edge modes even though the scrollbar draws it procedurally.
/// </summary>
public UiLayoutPolicy? ScalarRangeLayoutPolicy { get; set; }
/// <summary>
/// Draw the scalar meter from the power end (right) toward the speed end
/// (left). This is the authored gmCombatUI power meter direction.
/// </summary>
public bool ScalarFillFromRight { get; set; }
/// <summary>Programmatically set retail scrollbar attribute 0x86 without broadcasting.</summary>
public void SetScalarPosition(float position)
=> ScalarPosition = Math.Clamp(position, 0f, 1f);
/// <summary>RenderSurface id → (GL tex, w, h). 0 id = skip.</summary>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
/// <summary>Track background sprite id (0x06004C5F from layout 0x2100003E element 0x10000455).</summary>
public uint TrackSprite { get; set; }
/// <summary>Thumb 3-slice MIDDLE tile sprite id (0x06004C63), tiled between the caps.</summary>
public uint ThumbSprite { get; set; }
/// <summary>Thumb 3-slice TOP cap sprite id (0x06004C60, 3px tall).</summary>
public uint ThumbTopSprite { get; set; }
/// <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>
public uint UpSprite { get; set; }
/// <summary>Down-arrow button sprite id (0x06004C6C Normal state, element 0x10000072).</summary>
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>
/// 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;
private bool _draggingThumb;
private float _dragOffsetY;
private float _dragOffsetX;
private EndButton _hoveredButton;
private EndButton _pressedButton;
private enum EndButton
{
None,
Decrement,
Increment,
}
public UiScrollbar() { CapturesPointerDrag = true; }
/// <summary>The scrollbar draws its own track/thumb/arrows; its dat up/down button
/// 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
/// @0x4710d0</c>: thumb height = max(MinThumb, trackLen * ThumbRatio); thumb top
/// offset = trackTop + (trackLen - thumbH) * PositionRatio.
/// </summary>
/// <param name="m">The scroll model.</param>
/// <param name="trackTop">Y of the top of the usable track area (below up-button).</param>
/// <param name="trackLen">Pixel length of the usable track area (between up and down buttons).</param>
/// <returns>Local Y of the thumb's top edge, and its pixel height.</returns>
public static (float y, float h) ThumbRect(UiScrollable m, float trackTop, float trackLen)
{
float h = MathF.Max(MinThumb, trackLen * m.ThumbRatio);
float travel = trackLen - h;
float y = trackTop + travel * m.PositionRatio;
return (y, h);
}
/// <summary>Returns the clipped scalar-meter span in local pixels.</summary>
public static (float x, float width) ScalarFillRect(
float totalWidth, float fill, bool fromRight)
=> ScalarFillRect(0f, totalWidth, fill, fromRight);
/// <summary>Returns a clipped scalar-meter span inside an authored sub-range.</summary>
public static (float x, float width) ScalarFillRect(
float rangeLeft, float rangeWidth, float fill, bool fromRight)
{
float safeWidth = MathF.Max(0f, rangeWidth);
float visibleWidth = safeWidth * Math.Clamp(fill, 0f, 1f);
return (fromRight ? rangeLeft + safeWidth - visibleWidth : rangeLeft, visibleWidth);
}
protected override void OnDraw(UiRenderContext ctx)
{
if (!IsPresentationVisible) return;
if (SpriteResolve is not { } resolve) return;
if (Horizontal)
{
if (ScalarChanged is null && Model is { } horizontalModel)
{
DrawHorizontalModel(ctx, resolve, horizontalModel);
return;
}
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
(float rangeLeft, float rangeWidth) = ScalarRangeRect();
if (ScalarRangeSprite != 0)
DrawTiled(ctx, resolve, ScalarRangeSprite,
rangeLeft, 0f, rangeWidth, Height);
float thumbWidth = ScalarThumbWidth(resolve);
if (ScalarFill() is float fill && ScalarFillSprite != 0)
{
// The authored charge child spans the entire scrollbar; it does
// not share the dark range child's inset geometry.
var (fillX, visibleWidth) = ScalarFillRect(
Width, fill, ScalarFillFromRight);
DrawTiledClipped(ctx, resolve, ScalarFillSprite,
0f, fillX, visibleWidth, Height);
}
float travel = MathF.Max(0f, Width - thumbWidth);
float x = travel * ScalarPosition;
DrawSprite(ctx, resolve, ThumbSprite, x, 0f, thumbWidth, Height);
return;
}
if (Model is not { } m) return;
// Track background — TILED vertically (retail DrawMode=Normal). The native track
// sprite (~16×32) repeats to fill the element height instead of stretch-distorting.
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
float decrementExtent = AxisExtent(DecrementButtonExtent, Height);
float incrementExtent = AxisExtent(IncrementButtonExtent, Height);
// Decrement/up and increment/down use their authored button heights.
DrawSprite(ctx, resolve, ActiveStartSprite, 0f, 0f, Width, decrementExtent);
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
// 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);
}
else
{
DrawTiled(ctx, resolve, ThumbSprite, 0f, ty, Width, th);
}
}
}
private void DrawHorizontalModel(
UiRenderContext ctx,
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, ActiveStartSprite, 0f, 0f, decrementExtent, Height);
DrawSprite(ctx, resolve, ActiveEndSprite,
Width - incrementExtent, 0f, incrementExtent, Height);
if (!model.HasOverflow) return;
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);
}
else
{
DrawTiled(ctx, resolve, ThumbSprite, tx, 0f, tw, Height);
}
}
/// <summary>Draw a sprite stretched 1:1 to the dest rect.</summary>
private void DrawSprite(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint id, float x, float y, float w, float h)
{
if (id == 0 || w <= 0f || h <= 0f) return;
var (tex, _, _) = resolve(id);
if (tex == 0) return;
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One);
}
/// <summary>Draw a sprite TILED to fill the dest rect (UV-repeat at native size on
/// both axes — the UI texture is GL_REPEAT-wrapped). A native-width axis gives 1:1.</summary>
private void DrawTiled(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint id, float x, float y, float w, float h)
{
if (id == 0 || w <= 0f || h <= 0f) return;
var (tex, tw, th) = resolve(id);
if (tex == 0 || tw == 0 || th == 0) return;
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, w / tw, h / th, Vector4.One);
}
/// <summary>
/// Draws a clipped portion of a tiled range without restarting the texture
/// phase at the clip edge. This mirrors retail meter child clipping.
/// </summary>
private void DrawTiledClipped(
UiRenderContext ctx,
Func<uint, (uint tex, int w, int h)> resolve,
uint id,
float rangeLeft,
float x,
float w,
float h)
{
if (id == 0 || w <= 0f || h <= 0f) return;
var (tex, tw, th) = resolve(id);
if (tex == 0 || tw == 0 || th == 0) return;
float u0 = (x - rangeLeft) / tw;
float u1 = u0 + w / tw;
ctx.DrawSprite(tex, x, 0f, w, h, u0, 0f, u1, h / th, Vector4.One);
}
internal (float left, float width) ScalarRangeRect()
{
float configuredLeft = ScalarRangeLeft;
float configuredWidth = ScalarRangeWidth;
if (ScalarRangeLayoutPolicy is { } policy)
{
UiPixelRect currentParent = UiPixelRect.FromPositionAndSize(
0, 0, (int)Width, (int)Height);
UiPixelRect range = policy.Apply(policy.OriginalChild, currentParent);
configuredLeft = range.X0;
configuredWidth = range.Width;
}
float left = Math.Clamp(configuredLeft, 0f, Width);
float requestedWidth = float.IsPositiveInfinity(configuredWidth)
? Width - left
: MathF.Max(0f, configuredWidth);
float width = Math.Clamp(requestedWidth, 0f, Width - left);
return (left, width);
}
public override bool OnEvent(in UiEvent e)
{
if (IsModelDisabled)
{
_draggingThumb = false;
_hoveredButton = EndButton.None;
_pressedButton = EndButton.None;
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)
return OnScalarEvent(e);
if (Horizontal && Model is not null)
return OnHorizontalModelEvent(e);
if (Model is not { } m) return false;
switch (e.Type)
{
case UiEventType.MouseDown:
{
// e.Data1 = local X, e.Data2 = local Y (int pixel coords, see UiRoot hit dispatch).
float ly = e.Data2;
_pressedButton = ButtonAt(e.Data1, e.Data2);
float decrementExtent = AxisExtent(DecrementButtonExtent, Height);
float incrementExtent = AxisExtent(IncrementButtonExtent, Height);
// Up-button region: authored top rows.
if (ly < decrementExtent) { 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 = decrementExtent;
float trackLen = MathF.Max(0f, Height - decrementExtent - incrementExtent);
var (ty, th) = ThumbRect(m, trackTop, trackLen);
if (ly >= ty && ly <= ty + th)
{
// Clicked inside the thumb — begin drag with offset from thumb top.
_draggingThumb = true;
_dragOffsetY = ly - ty;
}
else
{
// Clicked above or below thumb — page scroll (HandleButtonClick page case).
m.ScrollByPage(ly < ty ? -1 : 1);
}
return true;
}
case UiEventType.MouseMove when _draggingThumb:
{
// Map current local Y (minus drag offset from thumb top) back to a
// position ratio across the available travel distance.
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;
m.SetPositionRatio(newRatio);
return true;
}
case UiEventType.MouseUp:
_draggingThumb = false;
_pressedButton = EndButton.None;
return true;
}
return false;
}
private bool OnHorizontalModelEvent(in UiEvent e)
{
UiScrollable m = Model!;
switch (e.Type)
{
case UiEventType.MouseDown:
{
float x = e.Data1;
_pressedButton = ButtonAt(e.Data1, e.Data2);
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 = decrementExtent;
float trackLength = MathF.Max(0f, Width - decrementExtent - incrementExtent);
var (tx, tw) = ThumbRect(m, trackLeft, trackLength);
if (x >= tx && x <= tx + tw)
{
_draggingThumb = true;
_dragOffsetX = x - tx;
}
else
{
m.ScrollByPage(x < tx ? -1 : 1);
}
return true;
}
case UiEventType.MouseMove when _draggingThumb:
{
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;
m.SetPositionRatio(ratio);
return true;
}
case UiEventType.MouseUp:
_draggingThumb = false;
_pressedButton = EndButton.None;
return true;
}
return false;
}
private bool OnScalarEvent(in UiEvent e)
{
switch (e.Type)
{
case UiEventType.MouseDown:
{
float thumbWidth = ScalarThumbWidth(SpriteResolve);
float travel = MathF.Max(1f, Width - thumbWidth);
float thumbX = travel * ScalarPosition;
float x = e.Data1;
if (x >= thumbX && x <= thumbX + thumbWidth)
{
_dragOffsetX = x - thumbX;
}
else
{
_dragOffsetX = thumbWidth * 0.5f;
ChangeScalarPosition((x - _dragOffsetX) / travel);
}
_draggingThumb = true;
return true;
}
case UiEventType.MouseMove when _draggingThumb:
{
float thumbWidth = ScalarThumbWidth(SpriteResolve);
float travel = MathF.Max(1f, Width - thumbWidth);
ChangeScalarPosition(((float)e.Data1 - _dragOffsetX) / travel);
return true;
}
case UiEventType.MouseUp:
_draggingThumb = false;
_pressedButton = EndButton.None;
return true;
}
return false;
}
private float ScalarThumbWidth(Func<uint, (uint tex, int w, int h)>? resolve)
{
if (resolve is not null && ThumbSprite != 0)
{
var (_, width, _) = resolve(ThumbSprite);
if (width > 0) return MathF.Min(width, Width);
}
return MathF.Min(16f, Width);
}
private void ChangeScalarPosition(float position)
{
SetScalarPosition(position);
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)
=> Math.Clamp(authoredExtent, 0f, MathF.Max(0f, axisLength));
}