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:
parent
dc1649c493
commit
a20e5c68c7
19 changed files with 595 additions and 51 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
62
src/AcDream.Core/Items/StackSplitQuantityState.cs
Normal file
62
src/AcDream.Core/Items/StackSplitQuantityState.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue