feat(ui): port retail selected-stack quantity

Bind the authored stack count entry and horizontal slider to one Core split-quantity owner, preserve retail count-first naming and exact 1000-step rounding, refresh on stack changes, and consume the selected amount during merges. Conformance covers the production DAT fixture and retained pointer/focus paths.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-11 10:07:25 +02:00
parent dc1649c493
commit a20e5c68c7
19 changed files with 595 additions and 51 deletions

View file

@ -25,6 +25,14 @@ public sealed class UiScrollbar : UiElement
/// <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 Func<float>? ScalarValue { get; set; }
public Action<float>? ScalarChanged { get; set; }
public bool Horizontal { get; set; }
/// <summary>RenderSurface id → (GL tex, w, h). 0 id = skip.</summary>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
@ -58,6 +66,7 @@ public sealed class UiScrollbar : UiElement
private bool _draggingThumb;
private float _dragOffsetY;
private float _dragOffsetX;
public UiScrollbar() { CapturesPointerDrag = true; }
@ -85,7 +94,18 @@ public sealed class UiScrollbar : UiElement
protected override void OnDraw(UiRenderContext ctx)
{
if (Model is not { } m || SpriteResolve is not { } resolve) return;
if (SpriteResolve is not { } resolve) return;
if (Horizontal && ScalarValue is not null)
{
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
float thumbWidth = ScalarThumbWidth(resolve);
float travel = MathF.Max(0f, Width - thumbWidth);
float x = travel * Math.Clamp(ScalarValue(), 0f, 1f);
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.
@ -142,6 +162,9 @@ public sealed class UiScrollbar : UiElement
public override bool OnEvent(in UiEvent e)
{
if (Horizontal && ScalarValue is not null && ScalarChanged is not null)
return OnScalarEvent(e);
if (Model is not { } m) return false;
switch (e.Type)
@ -196,4 +219,53 @@ public sealed class UiScrollbar : UiElement
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 * Math.Clamp(ScalarValue!(), 0f, 1f);
float x = e.Data1;
if (x >= thumbX && x <= thumbX + thumbWidth)
{
_dragOffsetX = x - thumbX;
}
else
{
_dragOffsetX = thumbWidth * 0.5f;
ScalarChanged!(Math.Clamp((x - _dragOffsetX) / travel, 0f, 1f));
}
_draggingThumb = true;
return true;
}
case UiEventType.MouseMove when _draggingThumb:
{
float thumbWidth = ScalarThumbWidth(SpriteResolve);
float travel = MathF.Max(1f, Width - thumbWidth);
ScalarChanged!(Math.Clamp(((float)e.Data1 - _dragOffsetX) / travel, 0f, 1f));
return true;
}
case UiEventType.MouseUp:
_draggingThumb = false;
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);
}
}