acdream/src/AcDream.App/UI/UiScrollbar.cs
Erik 472525b99e fix(ui): OP6 rework — six range captions, un-invert Sound enabled flags, five font faces
Fixes all three MUST-FIX findings from the OP6 REJECT review
(docs/research/2026-08-11-op6-review.md) plus its SHOULD-FIXes and NOTEs.

M1 — the "retail ships zero range captions" claim was a Binary Ninja
constant-folding artifact (the same class the header-string globals a few
lines above already worked around). The six SetSliderLabel call sites
byte-decode to reads of runtime-filled ID_Graphics_Value_* globals, not
immediate zeros (PE-byte-verified against the PDB-paired acclient.exe,
independently re-derived in this session, not just re-asserted from the
review). ConfigOptionsPageController.BuildSliderRow gained optional
rangeLowKey/rangeHighKey parameters wired for all six idx6 sliders (Camera
Stiffness Soft/Hard, Adjustment Speed Slow/Fast, FOV Narrow/Wide, Screen
Brightness Dark/Bright, Graphics Performance Speed/Detail, Degrade Distance
Close/Far) via the same SetRangeLabel mechanism OP5's Chat opacity sliders
already established. Mouse Look Sensitivity (idx3) correctly stays
uncaptioned — the one genuine SetSliderLabel omission. Class doc corrected;
gate-script lines 535/653-equivalent corrected in place.

M2 — the three Sound "Disabled" toggles were semantically inverted:
SoundManager::effect_sounds_enabled/ambient_sounds_enabled/
interface_sounds_enabled are all compiled = 1 in .data, and
UserPreferences::RegisterPreference binds the checkbox's boolean value
DIRECTLY onto those enabled-sense statics — checked-by-default means
enabled-by-default, not disabled. AudioSettings.SfxDisabled/AmbientDisabled/
InterfaceDisabled renamed to SfxEnabled/AmbientEnabled/InterfaceEnabled
(fresh JSON keys — the rejected slice's keys never shipped in an accepted
build); RuntimeSettingsStartupTargets.ApplyAudio now computes effective
volume through the extracted, independently-unit-tested pure function
ComputeEffectiveCategoryVolumes (enabled ? slider : 0f). This closes the
blast radius the review flagged: a missing key in an EXISTING settings.json
now falls back to AudioSettings.Default, which is enabled=true, so a fresh
launch is audible, not muted. AP-199's wording and gate-script step 6
corrected; the enshrined-inversion test rewritten to assert the correct
default and a new SettingsStore test pins the legacy-file fallback path.

M3 — UI_ChatFontFace now ships all five of retail's authored choices
(Arial, CourierNew, PalatinoLinotype, Tahoma, TimesNewRoman — a fixed
compile-time array at gmClient::InitUIPreferences, PE-byte-verified
present verbatim in .rdata, not a per-machine runtime enumeration as the
rejected slice's comment claimed). Default index 2 (PalatinoLinotype) now
indexes a real entry.

S1 — Bind() now emits the sixth trailing AddSeperator retail's own
InitOptions ends with (0x0049E80D), matching retail's 39-item ListBox (6
headers + 6 separators + 27 option-widget-rows) instead of 38.

S2 — Screen Brightness gets its own DisplaySettings.ScreenBrightness field
([-1,1], default 0) instead of overloading Gamma, which has a different
unit system (default 1.0, legacy [0.5,2.0] slider) and its own live
Settings-panel consumer.

S3 — UiScrollbar and UiMenu gained a settable TooltipText surfaced through
GetTooltipText (UiButton's existing pattern). Every slider and menu row's
own interactive widget (not just toggle/trio rows) now carries retail's
"<label>_Help" tooltip, verified as a universal suffix convention across
every AttachPreference site touched by this tab.

S4 — "800x600" added to DisplaySettings.AvailableResolutions: a genuine
retail display mode (Device::ForceDisplayResolution(1,0x320,0x258) at
startup) and the Config tab's own byte-verified Resolution row default, not
an invented preset. Defaults now lands on a highlighted, re-selectable
dropdown entry instead of an orphaned value.

S5 — four new/extended tests: ComputeEffectiveCategoryVolumes gets a
dedicated pure-function value assertion (Theory + a default-profile-is-
audible Fact) in RuntimeSettingsControllerTests, closing the "only event
order was asserted" gap that let M2 ship; a label/choice-key conformance
table in ConfigOptionsPageControllerTests enumerates every key this tab
queries (traced directly from the fixed code paths, not guessed) and fails
on an invented OR a dropped key; a per-row DefaultValue pin asserts every
row's default against the retail literal directly, independent of the
underlying settings-record defaults; and the S1 separator fix gets its own
39-item stacked-ListBox count pin.

NOTEs — AP-198's row count was always ten (its own enumeration never said
nine); the commit-message inconsistency N1 flagged is reconciled in both
the row and the section-summary line, and its Screen Brightness sub-clause
now matches S2. N2: Bind() now reads the scrollbar id from
UiTemplateListBox.ScrollbarElementId (dat property 0x72) instead of a
hardcoded constant. N3 (batch Defaults writes) and N4 (AfterApply on
Config-tab entry, needs no action) are left as recorded — out of this
rework's scope per the review's own disposition.

Full Release suite: 13,125 passed / 4 skipped / 0 failed (baseline
13,117/4/0 — net +8 tests added, 0 regressions, 0 removed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 08:06:20 +02:00

629 lines
26 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 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 <c>MouseUp</c> that ends a thumb drag — never on a
/// <c>MouseUp</c> that was not preceded by an actual drag (a bare click that
/// only page-scrolled or jumped, or a stray <c>MouseUp</c> with no prior
/// <c>MouseDown</c>). OP5 review fix S1: the drag-end seam neither
/// <see cref="ScalarChanged"/> (fires on every tick) nor <see cref="Model"/>
/// scrolling provided — the Chat tab's opacity sliders use this to flush a
/// batched settings write exactly once per drag 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 (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:
{
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;
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:
{
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)
{
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));
}