acdream/src/AcDream.App/UI/UiScrollbar.cs

942 lines
42 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 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 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
/// 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; }
/// <summary>
/// Optional live scalar reader used by plugin markup. It is sampled while
/// no thumb gesture is active so external/profile changes reach the widget
/// without fighting the value under the user's cursor.
/// </summary>
public Func<float?>? ScalarPositionSource { get; set; }
public bool Horizontal { get; set; }
/// <summary>True while a thumb drag is in progress (between a thumb-hit
/// <c>MouseDown</c>/drag-start and the matching <c>MouseUp</c>). OP5 review
/// fix S1, 2026-08-11: lets a consumer distinguish a per-tick drag edit
/// (defer any expensive settle work) from a single discrete edit (settle
/// immediately) without threading extra state through the scalar-value
/// callback.</summary>
public bool IsDragging => _draggingThumb;
/// <summary>
/// Fires once at the end of a press gesture that could have changed the
/// value: the <c>MouseUp</c> ending a MODEL-mode thumb drag, the
/// <c>MouseUp</c> ending ANY scalar-mode press (thumb drag OR bare
/// track-click jump — OP5 re-check R2: the scalar latch arms on
/// <c>MouseDown</c> before the jump applies, so the jump's flush defers
/// here rather than double-flushing), or a <c>WM_CAPTURECHANGED</c>
/// capture loss mid-drag (OP5 re-check R1 — the gesture completes with
/// the user's last-seen value). Never fires on a stray <c>MouseUp</c>
/// with no prior press, nor on model-mode button/page clicks. OP5 review
/// fix S1: the drag-end seam neither <see cref="ScalarChanged"/> (fires
/// per tick) nor <see cref="Model"/> scrolling provided — the Chat tab's
/// opacity sliders flush a batched settings write exactly once per
/// gesture instead of once per <c>MouseMove</c>.
/// </summary>
public Action? DragCompleted { 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);
protected override void OnTick(double deltaSeconds)
{
base.OnTick(deltaSeconds);
if (!_draggingThumb && ScalarPositionSource?.Invoke() is { } value)
SetScalarPosition(value);
}
/// <summary>Settable tooltip, surfaced through the shared
/// <see cref="UiElement.GetTooltipText"/> hover pipeline — the SAME
/// pattern <see cref="UiButton.TooltipText"/> already established
/// (OP6 rework, review S3). Lets a slider-row controller (e.g. the
/// Config tab's real-unit sliders) attach retail's own <c>_Help</c>
/// string to the scalar widget itself, since it — not the sibling
/// label text — is the interactive/hoverable surface for the row.</summary>
public string? TooltipText { get; set; }
/// <inheritdoc />
public override string? GetTooltipText() =>
string.IsNullOrWhiteSpace(TooltipText)
? base.GetTooltipText()
: TooltipText;
/// <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>
/// 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>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>
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 bool _hoveredThumb;
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;
/// <summary>
/// A content-fits (disabled) bar is still hit-testable and still
/// hover-highlights — retail's arrows and thumb are real child elements
/// whose Normal_rollover hot-tracking keeps running when the scrollbar
/// disables; the disabled state only hides the page-click regions
/// (UpdateLayout @0x004710d0's children 4-7) and, with attribute 0x79,
/// the whole bar. Scrolling input stays inert through geometry: with a
/// full-track thumb there is no travel and the line/page steps clamp to
/// nothing. The previous IsModelDisabled hit gate made the bar
/// hit-TRANSPARENT, which is why hovering it "did nothing" (2026-08-24
/// live probe: hovers over the bar reported widget=&lt;none&gt;).
/// Only a presentation-hidden bar (0x79 + disabled) ignores the pointer.
/// </summary>
protected override bool OnHitTest(float localX, float localY)
=> IsPresentationVisible && 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, ActiveThumbSprite, x, 0f, thumbWidth, Height);
return;
}
if (ScalarChanged is not null)
{
DrawVerticalScalar(ctx, resolve);
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 — 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.
{
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, 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
{
// R4-2 (Campaign CC gate round 1 re-test 3): the single-
// sprite thumb shape (no top/bottom caps — see this method's
// own doc, the R3-4/R3-7 fallback family: Skills listbox
// 0x100003f8, Summary overview 0x10000401, Summary how-to
// 0x100002e7) is a small fixed "diamond" marker graphic, NOT
// a stretchy bar — DrawTiled's UV-repeat was drawing it
// MULTIPLE times to fill the track-proportional thumb rect
// (~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, 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
/// within the computed thumb rect (<see cref="ThumbRect"/>'s own
/// decomp-cited <c>UIElement_Scrollbar::UpdateLayout @0x4710d0</c>
/// track-proportional geometry stays unchanged — this only changes HOW
/// the sprite fills that rect). Neither <see cref="DrawTiled"/> (UV-
/// repeat — draws the small marker graphic several times to fill a
/// large proportional thumb rect, R4-2's own "tiled diamonds" report)
/// nor a naive 1:1 stretch across the full computed rect (would distort
/// a small marker into an elongated bar) is correct for this shape —
/// <paramref name="vertical"/> selects which
/// axis is being filled/centered: a vertical scrollbar's thumb rect
/// varies in height (X/Width stay the bar's own full width, matching
/// every other draw call in this class), a horizontal one varies in
/// width (Y/Height stay the bar's own full height).
/// </summary>
private void DrawThumbMarker(
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint id, float rectX, float rectY, float rectW, float rectH, bool vertical)
{
if (id == 0 || rectW <= 0f || rectH <= 0f) return;
var (tex, nativeW, nativeH) = resolve(id);
if (tex == 0 || nativeW == 0 || nativeH == 0) return;
if (vertical)
{
float drawH = MathF.Min(nativeH, rectH);
float y = rectY + (rectH - drawH) * 0.5f;
ctx.DrawSprite(tex, rectX, y, rectW, drawH, 0f, 0f, rectW / nativeW, drawH / nativeH, Vector4.One);
}
else
{
float drawW = MathF.Min(nativeW, rectW);
float x = rectX + (rectW - drawW) * 0.5f;
ctx.DrawSprite(tex, x, rectY, drawW, rectH, 0f, 0f, drawW / nativeW, rectH / nativeH, Vector4.One);
}
}
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);
// 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, 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, ActiveThumbSprite, tx, 0f, tw, Height, vertical: false);
}
}
/// <summary>
/// Fix round F11 (Campaign CC CC6b-MOUNT review): the mirror-image
/// counterpart of the horizontal scalar draw block above, for scalar-mode
/// bars authored VERTICAL (taller than wide) — retail's chargen shade
/// scrollbar (<c>0x10000321</c>) is one, measured against the installed
/// EoR dat (<c>Width=33 Height=85</c>). Retail's own
/// <c>UIElement_Scrollbar</c> is one class handling both a model-driven
/// list scroll and a scalar-value slider on EITHER axis; this class only
/// had the horizontal half of the scalar shape before this fix, so a
/// vertically-authored scalar bar (like the shade control) drew nothing
/// scalar-specific and fell through to the model-mode branch below,
/// which requires a <see cref="UiScrollable"/> <see cref="Model"/> a
/// scalar-mode bar never has.
/// </summary>
private void DrawVerticalScalar(
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve)
{
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
float thumbHeight = ScalarThumbExtent(resolve, Height);
float travel = MathF.Max(0f, Height - thumbHeight);
float y = travel * ScalarPosition;
DrawSprite(ctx, resolve, ActiveThumbSprite, 0f, y, Width, thumbHeight);
}
/// <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)
{
// OP5 re-check R1: a capture drop without a MouseUp (panel hidden by
// a keybind mid-drag; a second button re-targeting capture) ends the
// drag HERE — completing the gesture (flush via DragCompleted) so the
// user's last-seen value persists and IsDragging cannot latch true
// forever. A normal MouseUp already cleared the latch, so this no-ops.
if (e.Type == UiEventType.CaptureChanged)
{
bool wasDragging = _draggingThumb;
_draggingThumb = false;
_pressedButton = EndButton.None;
if (wasDragging) DragCompleted?.Invoke();
return false; // informational — never consumes
}
// Only a presentation-HIDDEN bar ignores input (see OnHitTest's own
// doc): a visible disabled bar keeps hover/pressed visuals exactly
// like retail's still-hot-tracking button/thumb children, while its
// scroll operations no-op through zero travel.
if (!IsPresentationVisible)
{
_draggingThumb = false;
_hoveredThumb = false;
_hoveredButton = EndButton.None;
_pressedButton = EndButton.None;
return false;
}
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
// scalar-mode bar (ScalarChanged set, no Model) has always been
// possible on either axis in retail's own UIElement_Scrollbar.
// Gating this dispatch on Horizontal silently dropped every mouse
// event for a vertical scalar bar — it fell through the Horizontal
// Model branch below too, then hit "Model is not {} m => return
// false" since a scalar bar has no Model, so NOTHING ever routed to
// ScalarChanged in production for this orientation.
if (ScalarChanged is not null)
return Horizontal ? OnScalarEvent(e) : OnVerticalScalarEvent(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:
{
bool wasDragging = _draggingThumb;
_draggingThumb = false;
_pressedButton = EndButton.None;
if (wasDragging) DragCompleted?.Invoke();
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:
{
bool wasDragging = _draggingThumb;
_draggingThumb = false;
_pressedButton = EndButton.None;
if (wasDragging) DragCompleted?.Invoke();
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;
// OP5 re-check R2: the latch is set BEFORE the track-click
// jump below, so the jump's own ScalarChanged tick reads
// IsDragging=true and DEFERS its flush to the MouseUp's
// DragCompleted — one flush per press gesture, never the
// inline-then-DragCompleted double the previous order caused.
_draggingThumb = true;
if (x >= thumbX && x <= thumbX + thumbWidth)
{
_dragOffsetX = x - thumbX;
}
else
{
_dragOffsetX = thumbWidth * 0.5f;
ChangeScalarPosition((x - _dragOffsetX) / travel);
}
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:
{
bool wasDragging = _draggingThumb;
_draggingThumb = false;
_pressedButton = EndButton.None;
if (wasDragging) DragCompleted?.Invoke();
return true;
}
}
return false;
}
/// <summary>F11: the vertical mirror of <see cref="OnScalarEvent"/> —
/// same click-thumb-to-drag / click-track-to-jump shape, along Y/Height
/// instead of X/Width. Reuses <see cref="_dragOffsetY"/> (otherwise only
/// touched by the vertical MODEL-mode drag, mutually exclusive with
/// scalar mode on one instance) rather than adding a third offset field.
/// </summary>
private bool OnVerticalScalarEvent(in UiEvent e)
{
switch (e.Type)
{
case UiEventType.MouseDown:
{
float thumbHeight = ScalarThumbExtent(SpriteResolve, Height);
float travel = MathF.Max(1f, Height - thumbHeight);
float thumbY = travel * ScalarPosition;
float y = e.Data2;
// OP5 re-check R2 (mirrored from OnScalarEvent): latch
// before the jump so the jump's own tick defers its flush
// to MouseUp's DragCompleted.
_draggingThumb = true;
if (y >= thumbY && y <= thumbY + thumbHeight)
{
_dragOffsetY = y - thumbY;
}
else
{
_dragOffsetY = thumbHeight * 0.5f;
ChangeScalarPosition((y - _dragOffsetY) / travel);
}
return true;
}
case UiEventType.MouseMove when _draggingThumb:
{
float thumbHeight = ScalarThumbExtent(SpriteResolve, Height);
float travel = MathF.Max(1f, Height - thumbHeight);
ChangeScalarPosition(((float)e.Data2 - _dragOffsetY) / travel);
return true;
}
case UiEventType.MouseUp:
{
bool wasDragging = _draggingThumb;
_draggingThumb = false;
_pressedButton = EndButton.None;
if (wasDragging) DragCompleted?.Invoke();
return true;
}
}
return false;
}
private float ScalarThumbWidth(Func<uint, (uint tex, int w, int h)>? resolve) =>
ScalarThumbExtent(resolve, Width);
/// <summary>F11: generalized over <see cref="ScalarThumbWidth"/> so
/// <see cref="DrawVerticalScalar"/> can size the thumb along the
/// authored axis (native sprite width for a horizontal bar, native
/// sprite height for a vertical one) instead of assuming horizontal.
/// </summary>
private float ScalarThumbExtent(
Func<uint, (uint tex, int w, int h)>? resolve, float axisLength)
{
if (resolve is not null && ThumbSprite != 0)
{
var (_, width, height) = resolve(ThumbSprite);
int native = Horizontal ? width : height;
if (native > 0) return MathF.Min(native, axisLength);
}
return MathF.Min(16f, axisLength);
}
private void ChangeScalarPosition(float position)
{
SetScalarPosition(position);
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)
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;
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));
}