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

@ -142,8 +142,27 @@ public static class DatWidgetFactory
{
SpriteResolve = resolve,
TrackSprite = DefaultImage(info),
Horizontal = info.Width > info.Height,
};
if (bar.Horizontal)
{
// gmToolbarUI stack slider (0x100001A4) has two direct Type-3 media
// children: a full-width 90x14 track (element 4) and a 16x14 thumb
// (element 1). Their geometry, not a button class, defines the roles.
ElementInfo? track = info.Children
.Where(child => DefaultImage(child) != 0u)
.OrderByDescending(child => child.Width)
.FirstOrDefault();
ElementInfo? scalarThumb = info.Children
.Where(child => !ReferenceEquals(child, track) && DefaultImage(child) != 0u)
.OrderBy(child => child.Width)
.FirstOrDefault();
bar.TrackSprite = track is null ? 0u : DefaultImage(track);
bar.ThumbSprite = scalarThumb is null ? 0u : DefaultImage(scalarThumb);
return bar;
}
uint incrementId = ReferencedElementId(info, 0x77u);
uint decrementId = ReferencedElementId(info, 0x78u);
ElementInfo? increment = info.Children.FirstOrDefault(child => child.Id == incrementId);

View file

@ -61,6 +61,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
private readonly Action<uint, uint, uint>? _sendStackableMerge;
private readonly Action<uint, uint>? _notifyMergeAttempt;
private readonly ItemInteractionController? _itemInteraction;
private readonly StackSplitQuantityState? _stackSplitQuantity;
private bool _disposed;
// ACE PropertyInt ids read by retail InqLoad (decomp 0x0058f130: InqInt(5)/InqInt(0xe6)).
@ -85,7 +86,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
Action<uint, uint, uint>? sendStackableMerge,
Action<uint, uint>? notifyMergeAttempt,
ItemInteractionController? itemInteraction,
Action? onClose)
Action? onClose,
StackSplitQuantityState? stackSplitQuantity)
{
_objects = objects;
_playerGuid = playerGuid;
@ -98,6 +100,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_sendStackableMerge = sendStackableMerge;
_notifyMergeAttempt = notifyMergeAttempt;
_itemInteraction = itemInteraction;
_stackSplitQuantity = stackSplitQuantity;
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
WindowChromeController.BindCloseButton(layout, onClose);
@ -206,13 +209,14 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
Action<uint, uint, uint>? sendStackableMerge = null,
Action<uint, uint>? notifyMergeAttempt = null,
ItemInteractionController? itemInteraction = null,
Action? onClose = null)
Action? onClose = null,
StackSplitQuantityState? stackSplitQuantity = null)
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, selection,
ownerName, datFont,
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
sendUse, sendNoLongerViewing, sendPutItemInContainer,
sendStackableMerge, notifyMergeAttempt, itemInteraction,
onClose);
onClose, stackSplitQuantity);
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
private void OnObjectRemoved(ClientObject o)
@ -463,6 +467,10 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|| _objects.Get(targetId) is not { } target)
return false;
int requestedAmount = _selection.SelectedObjectId == sourceId
&& _stackSplitQuantity is not null
? (int)Math.Min(_stackSplitQuantity.Value, int.MaxValue)
: Math.Max(1, source.StackSize);
StackMergePlan? plan = StackMergePlanner.Plan(
new StackMergeItem(
source.ObjectId,
@ -477,7 +485,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
target.StackSizeMax,
target.TradeState),
_itemInteraction?.CanMakeInventoryRequest ?? true,
requestedAmount: Math.Max(1, source.StackSize));
requestedAmount);
if (plan is not { } merge)
return false;

View file

@ -1,6 +1,7 @@
using System;
using System.Numerics;
using AcDream.App.UI;
using AcDream.Core.Items;
using AcDream.Core.Selection;
namespace AcDream.App.UI.Layout;
@ -53,6 +54,10 @@ public sealed class SelectedObjectController : IRetainedPanelController
public const uint OverlayId = 0x100001A0;
/// <summary>Selected-object health meter element id (retail m_pSelObjectHealthMeter).</summary>
public const uint HealthMeterId = 0x100001A1;
/// <summary>Editable stack quantity (retail m_pStackSizeEntryBox).</summary>
public const uint StackSizeEntryId = 0x100001A3;
/// <summary>Horizontal stack quantity slider (retail m_pStackSizeSlider).</summary>
public const uint StackSizeSliderId = 0x100001A4;
/// <summary>Selection-overlay flash duration — retail's container ObjectSelected state is a
/// Pause(0.25s)→Normal transition (toolbar dump, element 0x1000019E).</summary>
@ -75,6 +80,8 @@ public sealed class SelectedObjectController : IRetainedPanelController
private readonly UiElement? _name;
private readonly UiDatElement? _overlay;
private readonly UiMeter? _healthMeter;
private readonly UiField? _stackSizeEntry;
private readonly UiScrollbar? _stackSizeSlider;
// ── Captured delegates ───────────────────────────────────────────────────
private readonly Func<uint, bool> _isHealthTarget;
@ -83,8 +90,10 @@ public sealed class SelectedObjectController : IRetainedPanelController
private readonly Func<uint, bool> _hasHealth;
private readonly Func<uint, uint> _stackSize;
private readonly Action<uint> _sendQueryHealth;
private readonly StackSplitQuantityState _splitQuantity;
private readonly SelectionState _selection;
private readonly Action<Action<uint, float>> _unsubscribeHealthChanged;
private readonly Action<Action<ClientObject>> _unsubscribeObjectUpdated;
// ── Live state (read by closures on the per-frame draw path) ────────────
private uint? _current;
@ -106,7 +115,10 @@ public sealed class SelectedObjectController : IRetainedPanelController
Func<uint, bool> hasHealth,
Func<uint, uint> stackSize,
Action<uint> sendQueryHealth,
UiDatFont? datFont)
UiDatFont? datFont,
StackSplitQuantityState splitQuantity,
Action<Action<ClientObject>> subscribeObjectUpdated,
Action<Action<ClientObject>> unsubscribeObjectUpdated)
{
_isHealthTarget = isHealthTarget;
_resolveName = name;
@ -114,13 +126,17 @@ public sealed class SelectedObjectController : IRetainedPanelController
_hasHealth = hasHealth;
_stackSize = stackSize;
_sendQueryHealth = sendQueryHealth;
_splitQuantity = splitQuantity ?? throw new ArgumentNullException(nameof(splitQuantity));
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_unsubscribeHealthChanged = unsubscribeHealthChanged;
_unsubscribeObjectUpdated = unsubscribeObjectUpdated;
// Find elements — silently skip absent ones (partial/test layouts).
_name = layout.FindElement(NameId);
_overlay = layout.FindElement(OverlayId) as UiDatElement;
_healthMeter = layout.FindElement(HealthMeterId) as UiMeter;
_stackSizeEntry = layout.FindElement(StackSizeEntryId) as UiField;
_stackSizeSlider = layout.FindElement(StackSizeSliderId) as UiScrollbar;
// The selection-flash overlay must draw OVER the health meter (which spans the whole
// strip) — otherwise the meter hides the green flash whenever a bar is visible (i.e.
@ -135,6 +151,25 @@ public sealed class SelectedObjectController : IRetainedPanelController
_healthMeter.Fill = () => _current is uint g ? _healthPercent(g) : (float?)0f;
}
if (_stackSizeEntry is not null)
{
_stackSizeEntry.Visible = false;
_stackSizeEntry.Selectable = true;
_stackSizeEntry.ClearOnSubmit = false;
_stackSizeEntry.RecordHistory = false;
_stackSizeEntry.CharacterFilter = static c => c is >= '0' and <= '9';
_stackSizeEntry.SelectAllOnFocus = true;
_stackSizeEntry.OnSubmit = CommitStackEntry;
_stackSizeEntry.OnFocusLost = CommitStackEntry;
}
if (_stackSizeSlider is not null)
{
_stackSizeSlider.Visible = false;
_stackSizeSlider.Horizontal = true;
_stackSizeSlider.ScalarValue = () => _splitQuantity.Ratio;
_stackSizeSlider.ScalarChanged = _splitQuantity.SetFromSliderRatio;
}
// Attach a centered UiText child to the name element for the object name display.
// Mirrors VitalsController.BindMeter's number attach. The name is floated to the
// top of the strip's z-order so it draws OVER the overlay frame and the health bar
@ -172,7 +207,9 @@ public sealed class SelectedObjectController : IRetainedPanelController
// Register the handlers LAST so the initial state is fully set up first.
_selection.Changed += OnSelectionTransition;
_splitQuantity.Changed += OnSplitQuantityChanged;
subscribeHealthChanged(OnHealthChanged);
subscribeObjectUpdated(OnObjectUpdated);
if (_selection.SelectedObjectId is { } initial)
ApplySelection(initial);
}
@ -205,11 +242,15 @@ public sealed class SelectedObjectController : IRetainedPanelController
Func<uint, bool> hasHealth,
Func<uint, uint> stackSize,
Action<uint> sendQueryHealth,
UiDatFont? datFont)
UiDatFont? datFont,
StackSplitQuantityState splitQuantity,
Action<Action<ClientObject>> subscribeObjectUpdated,
Action<Action<ClientObject>> unsubscribeObjectUpdated)
=> new SelectedObjectController(
layout, selection,
subscribeHealthChanged, unsubscribeHealthChanged,
isHealthTarget, name, healthPercent, hasHealth, stackSize, sendQueryHealth, datFont);
isHealthTarget, name, healthPercent, hasHealth, stackSize, sendQueryHealth, datFont,
splitQuantity, subscribeObjectUpdated, unsubscribeObjectUpdated);
/// <summary>
/// Port of <c>gmToolbarUI::HandleSelectionChanged</c> (<c>:198635</c>):
@ -220,6 +261,9 @@ public sealed class SelectedObjectController : IRetainedPanelController
// ── 1. Clear first (retail: SetText("") + m_pSelObjectField->SetState(0)
// + SetVisible(0) on the meters). ──────────────────────────────────────
if (_healthMeter is not null) _healthMeter.Visible = false;
if (_stackSizeEntry is not null) _stackSizeEntry.Visible = false;
if (_stackSizeSlider is not null) _stackSizeSlider.Visible = false;
_splitQuantity.Reset(1u);
_currentName = null;
_current = guid;
@ -234,15 +278,30 @@ public sealed class SelectedObjectController : IRetainedPanelController
uint g = guid.Value;
// ── 2. Name (displayed via the UiText child's LinesProvider reading _currentName). ──
_currentName = _resolveName(g);
uint stackSize = _stackSize(g);
string? objectName = _resolveName(g);
_currentName = stackSize > 1u && !string.IsNullOrEmpty(objectName)
? $"{stackSize} {objectName}"
: objectName;
// ── 3. Selection overlay: brief flash (retail container ObjectSelected
// = Pause(0.25s)→Normal). "StackedItemSelected" for stacks. ──────────────
SetOverlayState(_stackSize(g) > 1u
SetOverlayState(stackSize > 1u
? RetailUiStateIds.StackedItemSelected
: RetailUiStateIds.ObjectSelected);
_flashRemaining = FlashSeconds;
// gmToolbarUI::HandleSelectionChanged @ 0x004BF52D..0x004BF666:
// stacks initialize to the full stack, show the numeric entry + horizontal
// slider, and set the stacked selection state. Vendor-owned stack precedence
// is intentionally absent until the vendor panel owns an active vendor id.
if (stackSize > 1u)
{
_splitQuantity.Reset(stackSize);
if (_stackSizeEntry is not null) _stackSizeEntry.Visible = true;
if (_stackSizeSlider is not null) _stackSizeSlider.Visible = true;
}
// ── 4. Health: query, and show the meter only if real health is already known.
// Otherwise the meter appears when OnHealthChanged fires for this guid
// (retail RecvNotice_UpdateObjectHealth :196213). ──────────────────────────
@ -279,6 +338,18 @@ public sealed class SelectedObjectController : IRetainedPanelController
_overlay?.TrySetRetailState(state);
}
private void CommitStackEntry(string text)
=> _splitQuantity.SetFromText(text);
private void OnSplitQuantityChanged()
=> _stackSizeEntry?.SetText(_splitQuantity.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
private void OnObjectUpdated(ClientObject updated)
{
if (_current == updated.ObjectId && _stackSize(updated.ObjectId) != _splitQuantity.Maximum)
ApplySelection(updated.ObjectId);
}
private void OnSelectionTransition(SelectionTransition transition)
=> ApplySelection(transition.SelectedObjectId);
@ -287,6 +358,8 @@ public sealed class SelectedObjectController : IRetainedPanelController
if (_disposed) return;
_disposed = true;
_selection.Changed -= OnSelectionTransition;
_splitQuantity.Changed -= OnSplitQuantityChanged;
_unsubscribeHealthChanged(OnHealthChanged);
_unsubscribeObjectUpdated(OnObjectUpdated);
}
}

View file

@ -45,7 +45,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
// render as stray bars / a black box on the toolbar. Retail hides A3/A4 in
// gmToolbarUI::HandleSelectionChanged (acclient_2013_pseudo_c.txt:198660/198742),
// showing them only for a stacked-item selection.
private static readonly uint[] HiddenIds = { 0x100001A2, 0x100001A3, 0x100001A4 };
private static readonly uint[] HiddenIds = { 0x100001A2 };
// Four mutually-exclusive combat-mode indicator elements — exactly one visible at a time.
// Index 0 = NonCombat (peace), 1 = Melee, 2 = Missile, 3 = Magic.

View file

@ -109,6 +109,7 @@ public sealed record RetailUiRuntimeBindings(
public sealed class RetailUiRuntime : IDisposable
{
private readonly RetailUiRuntimeBindings _bindings;
private readonly StackSplitQuantityState _stackSplitQuantity = new();
private readonly RetailWindowLayoutPersistence? _persistence;
private readonly RetailUiAutomationScriptRunner? _automation;
private bool _disposed;
@ -383,7 +384,10 @@ public sealed class RetailUiRuntime : IDisposable
b.HasHealth,
b.StackSize,
b.SendQueryHealth,
_bindings.Assets.DefaultFont);
_bindings.Assets.DefaultFont,
_stackSplitQuantity,
handler => b.Objects.ObjectUpdated += handler,
handler => b.Objects.ObjectUpdated -= handler);
UiElement root = layout.Root;
RetailWindowHandle handle = RetailWindowFrame.Mount(
@ -597,7 +601,8 @@ public sealed class RetailUiRuntime : IDisposable
contents, sideBag, mainPack, b.SendUse, b.SendNoLongerViewing,
b.SendPutItemInContainer, b.SendStackableMerge,
notifyMergeAttempt, b.ItemInteraction,
() => CloseWindow(WindowNames.Inventory));
() => CloseWindow(WindowNames.Inventory),
_stackSplitQuantity);
PaperdollController paperdoll = PaperdollController.Bind(
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Selection, b.SendWield,
contents, _bindings.Assets.DefaultFont, b.ItemInteraction);

View file

@ -33,6 +33,10 @@ public sealed class UiField : UiElement
public int MaxCharacters { get; set; } = 0xFFFF;
public bool OneLine { get; set; } = true;
public bool Selectable { get; set; }
public bool ClearOnSubmit { get; set; } = true;
public bool RecordHistory { get; set; } = true;
public Func<char, bool>? CharacterFilter { get; set; }
public bool SelectAllOnFocus { get; set; }
/// <summary>Keyboard device for clipboard (Ctrl+C/X/V) + modifier state (Ctrl/Shift).
/// Wired by the host from <see cref="UiHost.Keyboard"/>.</summary>
@ -48,6 +52,8 @@ public sealed class UiField : UiElement
public uint FocusFieldSprite { get; set; }
public Action<string>? OnSubmit { get; set; }
public Action? OnFocusGained { get; set; }
public Action<string>? OnFocusLost { get; set; }
private string _text = "";
private int _caret;
@ -61,6 +67,7 @@ public sealed class UiField : UiElement
private bool _focused;
private bool _selecting; // mouse drag in progress
private bool _preserveFocusSelectionOnMouseDown;
private float _scrollX; // horizontal pixel scroll so the caret stays in the field
// Held-key auto-repeat (Silk delivers one KeyDown per physical press).
@ -84,7 +91,7 @@ public sealed class UiField : UiElement
public void InsertChar(char c)
{
if (c < 0x20 || c == 0x7F) return;
if (c < 0x20 || c == 0x7F || (CharacterFilter is not null && !CharacterFilter(c))) return;
DeleteSelection();
if (_text.Length >= MaxCharacters) return;
_text = _text.Insert(_caret, c.ToString());
@ -149,13 +156,23 @@ public sealed class UiField : UiElement
return true;
}
private void SelectAll()
public void SelectAllText()
{
if (_text.Length == 0) { _selAnchor = null; return; }
_selAnchor = 0;
_caret = _text.Length;
}
public void SetText(string? text)
{
_text = text ?? "";
if (_text.Length > MaxCharacters)
_text = _text[..MaxCharacters];
_caret = _text.Length;
_selAnchor = null;
_historyIndex = -1;
}
private void CopySelection()
{
var s = SelectedText();
@ -179,7 +196,8 @@ public sealed class UiField : UiElement
// Single-line field: strip control chars (newlines/tabs) from pasted text.
var sb = new System.Text.StringBuilder(clip.Length);
foreach (char ch in clip)
if (ch >= 0x20 && ch != 0x7F) sb.Append(ch);
if (ch >= 0x20 && ch != 0x7F
&& (CharacterFilter is null || CharacterFilter(ch))) sb.Append(ch);
if (sb.Length == 0) return;
DeleteSelection();
@ -196,10 +214,14 @@ public sealed class UiField : UiElement
public void Submit()
{
var t = _text;
if (t.Trim().Length == 0) { Clear(); return; }
if (t.Trim().Length == 0)
{
if (ClearOnSubmit) Clear();
return;
}
OnSubmit?.Invoke(t);
PushHistory(t);
Clear();
if (RecordHistory) PushHistory(t);
if (ClearOnSubmit) Clear();
}
private void Clear() { _text = ""; _caret = 0; _selAnchor = null; _historyIndex = -1; }
@ -370,10 +392,23 @@ public sealed class UiField : UiElement
{
switch (e.Type)
{
case UiEventType.FocusGained: _focused = true; return true;
case UiEventType.FocusGained:
_focused = true;
if (SelectAllOnFocus)
{
SelectAllText();
// UiRoot assigns focus before delivering the initiating MouseDown.
// Retail's activation notice selects all after that press, so preserve
// this selection across the one MouseDown that caused focus.
_preserveFocusSelectionOnMouseDown = true;
}
OnFocusGained?.Invoke();
return true;
case UiEventType.FocusLost:
OnFocusLost?.Invoke(_text);
_focused = false; _historyIndex = -1;
_selAnchor = null; _selecting = false; _repeatKey = null;
_preserveFocusSelectionOnMouseDown = false;
return true;
case UiEventType.Char:
@ -381,6 +416,12 @@ public sealed class UiField : UiElement
return true;
case UiEventType.MouseDown:
if (_preserveFocusSelectionOnMouseDown)
{
_preserveFocusSelectionOnMouseDown = false;
_selecting = false;
return true;
}
_caret = HitCharX(e.Data1);
_selAnchor = Selectable ? _caret : null;
_selecting = Selectable;
@ -403,7 +444,7 @@ public sealed class UiField : UiElement
{
switch (key)
{
case Silk.NET.Input.Key.A when Selectable: SelectAll(); return true;
case Silk.NET.Input.Key.A when Selectable: SelectAllText(); return true;
case Silk.NET.Input.Key.C when Selectable: CopySelection(); return true;
case Silk.NET.Input.Key.X when Selectable: CutSelection(); return true;
case Silk.NET.Input.Key.V: Paste(); return true;

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);
}
}

View file

@ -0,0 +1,62 @@
using System;
using System.Globalization;
namespace AcDream.Core.Items;
/// <summary>
/// Shared owner for retail <c>GenItemHolder::splitSize</c> and
/// <c>GenItemHolder::maxSplitSize</c>. The toolbar entry/slider edits this state and
/// item-holder operations consume the same value.
/// </summary>
public sealed class StackSplitQuantityState
{
public uint Value { get; private set; } = 1u;
public uint Maximum { get; private set; } = 1u;
public float Ratio => Value / (float)Maximum;
public event Action? Changed;
public void Reset(uint stackSize, uint? initialValue = null)
{
uint maximum = Math.Max(stackSize, 1u);
Set(initialValue ?? maximum, maximum);
}
public void SetFromText(string? text)
{
// Retail uses wcstoul, whose invalid/empty result is zero, then clamps to one.
uint parsed = uint.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out uint value)
? value
: 0u;
SetValue(parsed);
}
/// <summary>
/// Ports <c>gmToolbarUI::ListenToElementMessage @ 0x004BEFE0</c> and
/// <c>UIElement_Scrollbar::SetScrollbarPosition @ 0x00470EC0</c>. The scrollbar
/// first truncates its normalized position to an integer in [0,1000], then the
/// toolbar converts it to <c>1 + floor(position * max / 1000)</c> and clamps.
/// </summary>
public void SetFromSliderRatio(float ratio)
{
float clamped = Math.Clamp(ratio, 0f, 1f);
uint positionMillis = (uint)MathF.Truncate(clamped * 1000f);
ulong scaled = (ulong)positionMillis * Maximum;
uint value = 1u + (uint)(scaled / 1000u);
SetValue(value);
}
public void SetValue(uint value) => Set(value, Maximum);
private void Set(uint value, uint maximum)
{
maximum = Math.Max(maximum, 1u);
value = Math.Clamp(value, 1u, maximum);
if (Value == value && Maximum == maximum)
return;
Value = value;
Maximum = maximum;
Changed?.Invoke();
}
}