Four visual residuals from the lead's own live-client captures of 1.0.2-cc.m, all root-caused via decomp + live-DAT evidence: - R4-1: Skills credits value overlapped mid-caption again. Root cause was a missing UiLayoutPolicy raw-edge reflow on UiButton's value-child rect (the child is base-inherited across four sibling buttons of differing widths, so its baked-in OriginalParentWidth diverges from the actual 231px-wide Skills credits button) plus an HJustify.Right value child mapped to Center instead of a real far-edge Right. - R4-2: the single-sprite scrollbar thumb tiled (GL_REPEAT) instead of drawing once — DrawTiled was reused for a small fixed marker graphic whose native size is far smaller than the track-proportional thumb rect. New DrawThumbMarker draws exactly one native-size instance. - R4-3: the skills info-box formula line clipped past the surrounding gold frame's own authored bottom edge (the pane's own raw box is 20px taller than the frame that visually contains it) — clamp the pane's Height to the frame's bottom (register AD-105, since retail's ShowSkillsText has no code relationship to the frame to cite). - R4-4: the Appearance help text started mid-sentence — the box was never touched by its page controller, so it kept UiText's chat-style PreserveEndOnLayout=true default; the scroll model's wasAtEnd check is vacuously true on its first-ever overflow transition, pinning the first render to the bottom. Set PreserveEndOnLayout=false (a static top-oriented report, not a transcript) and wired the box's own nested authored scrollbar, never wired before. App suite live-DAT env 5372/3 -> 5379/3 (+7, zero regressions). Runtime 1735/0 unchanged. Full solution 14585/4 skips/1 failure (the documented Core.Net NakEmission full-solution-only flake, confirmed standalone-pass). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
805 lines
35 KiB
C#
805 lines
35 KiB
C#
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 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>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);
|
||
|
||
/// <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) ? null : 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>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 (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 — 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
|
||
{
|
||
// 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, ThumbSprite, 0f, ty, Width, th, vertical: true);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <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);
|
||
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
|
||
{
|
||
// R4-2: horizontal counterpart of the vertical fallback above.
|
||
DrawThumbMarker(ctx, resolve, ThumbSprite, 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, ThumbSprite, 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
|
||
}
|
||
|
||
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);
|
||
|
||
// 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);
|
||
}
|
||
|
||
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));
|
||
}
|