acdream/src/AcDream.App/UI/Layout/SelectedObjectController.cs
Erik 4cfcc8b338
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
fix(ui): #353 — the stack-count entry takes the one-line draw path; RightAligned engages and the edit flicker dies
RightAligned only applies on UiField's single-line renderer; the entry
was falling into the multi-line path (ignoring the alignment AND
re-scrolling its extents per keystroke — the reported edit flicker).
A 14 px numeric entry is single-line by construction; OneLine = true
routes it correctly. The 233 now sits flush against the slider per the
authored HJustify=2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 17:49:24 +02:00

572 lines
27 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;
using AcDream.App.UI;
using AcDream.Core.Items;
using AcDream.Core.Selection;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Controller for the action bar's selected-object strip (ids 0x1000019E0x100001A4).
/// Analogue of retail <c>gmToolbarUI::HandleSelectionChanged</c>
/// (<c>docs/research/named-retail/acclient_2013_pseudo_c.txt:198635</c>) +
/// <c>RecvNotice_UpdateObjectHealth</c> (<c>:196213</c>) +
/// <c>RecvNotice_UpdateItemMana</c> (<c>:196188</c>).
///
/// <para>
/// On selection change: clears the strip (name, overlay flash, health meter), then if a
/// guid is provided it sets the name, flashes the selection overlay briefly, and sends
/// either <c>QueryHealth (0x01BF)</c> for health-bearing targets or
/// <c>QueryItemMana (0x0263)</c> for owned non-stack items. The Health meter
/// becomes visible only when the server actually reports health for the selected guid —
/// either an <c>UpdateHealth (0x01C0)</c> arrives (retail
/// <c>RecvNotice_UpdateObjectHealth</c> → <c>SetVisible(1)</c>) or the value is already
/// cached. So a friendly NPC you have not assessed shows name-only (no bar), and a
/// monster's bar appears after damage / a successful assess — matching retail.
/// </para>
///
/// <para>
/// <strong>Retail element roles</strong> (PostInit, <c>:198119</c>): <c>m_pSelObjectField</c>
/// is the container <c>0x1000019E</c> whose <c>SetState(0x1000000b/0c)</c> drives a
/// 0.25s <c>Pause→Normal</c> flash that cascades to the overlay child's green frame.
/// acdream has no state-cascade / transition-animation system, so this controller drives
/// the overlay element <c>0x100001A0</c> directly and reverts it after the same
/// <see cref="FlashSeconds"/> to reproduce the brief flash. The name element
/// <c>0x1000019F</c> is bumped to the top of the strip's z-order so it draws OVER the
/// overlay frame and the health bar (retail draws the name over the bar — see the
/// "Drudge Slinker" reference shot).
/// </para>
///
/// </summary>
public sealed class SelectedObjectController : IRetainedPanelController
{
// ── Element ids (toolbar LayoutDesc 0x21000016) ─────────────────────────
/// <summary>Selected-object container / field element id (retail m_pSelObjectField).</summary>
public const uint ContainerId = 0x1000019E;
/// <summary>Selected-object name element id (retail m_pSelObjectName, UIElement_Text).</summary>
public const uint NameId = 0x1000019F;
/// <summary>Selected-object overlay element id (states: ObjectSelected / StackedItemSelected).</summary>
public const uint OverlayId = 0x100001A0;
/// <summary>Selected-object health meter element id (retail m_pSelObjectHealthMeter).</summary>
public const uint HealthMeterId = 0x100001A1;
/// <summary>Selected-object item-mana meter element id (retail m_pSelObjectManaMeter).</summary>
public const uint ManaMeterId = 0x100001A2;
/// <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>
private const double FlashSeconds = 0.25;
/// <summary>Z-order for the name so it draws OVER the overlay frame + health bar.
/// The strip's other children sit at ReadOrder 14; this floats the name to the top.</summary>
private const int NameZOrderOnTop = 1_000_000;
/// <summary>Z-order for the selection-flash overlay — above the health meter (so the green
/// flash isn't hidden by the bar) but below the name (so the name stays readable).</summary>
private const int OverlayZOrder = NameZOrderOnTop - 1;
/// <summary>Height (px) of the black name band at the top of the 31px bar sprite. The name
/// label is constrained to this band (top-aligned) so the health bar shows below it —
/// retail "name on the black, bar below". The bar sprite's colored region starts ~y14.</summary>
private const float NameBandHeight = 15f;
// ── Found elements (any may be null for partial/test layouts) ───────────
private readonly UiElement? _name;
private readonly UiDatElement? _overlay;
private readonly UiMeter? _healthMeter;
private readonly UiMeter? _manaMeter;
private readonly UiField? _stackSizeEntry;
private readonly UiScrollbar? _stackSizeSlider;
// ── Captured delegates ───────────────────────────────────────────────────
private readonly Func<uint, bool> _isHealthTarget;
private readonly Func<uint, bool> _isOwnedByPlayer;
private readonly Func<uint, string?> _resolveName;
private readonly Func<uint, float> _healthPercent;
private readonly Func<uint, bool> _hasHealth;
private readonly Func<uint, uint> _stackSize;
private readonly Action<uint> _sendQueryHealth;
private readonly Func<uint, float> _manaPercent;
private readonly Action<uint> _sendQueryItemMana;
private readonly StackSplitQuantityState _splitQuantity;
private readonly SelectionState _selection;
private readonly Func<uint, bool> _isVendorSplitExempt;
private readonly Action<Action<uint, float>> _unsubscribeHealthChanged;
private readonly Action<Action<uint, float, bool>> _unsubscribeItemManaChanged;
private readonly Action<Action<ClientObject>> _unsubscribeObjectUpdated;
// ── Live state (read by closures on the per-frame draw path) ────────────
private uint? _current;
private string? _currentName;
private double _flashRemaining; // > 0 while the selection overlay is flashing
private bool _changingSplitFromSlider;
private bool _disposed;
/// <summary>White label color for the name line.</summary>
private static readonly Vector4 NameColor = new(1f, 1f, 1f, 1f);
private SelectedObjectController(
ImportedLayout layout,
SelectionState selection,
Action<Action<uint, float>> subscribeHealthChanged,
Action<Action<uint, float>> unsubscribeHealthChanged,
Action<Action<uint, float, bool>> subscribeItemManaChanged,
Action<Action<uint, float, bool>> unsubscribeItemManaChanged,
Func<uint, bool> isHealthTarget,
Func<uint, bool> isOwnedByPlayer,
Func<uint, string?> name,
Func<uint, float> healthPercent,
Func<uint, bool> hasHealth,
Func<uint, uint> stackSize,
Action<uint> sendQueryHealth,
Func<uint, float> manaPercent,
Action<uint> sendQueryItemMana,
UiDatFont? datFont,
StackSplitQuantityState splitQuantity,
Action<Action<ClientObject>> subscribeObjectUpdated,
Action<Action<ClientObject>> unsubscribeObjectUpdated,
Func<uint, bool> isVendorSplitExempt)
{
_isHealthTarget = isHealthTarget;
_isOwnedByPlayer = isOwnedByPlayer;
_resolveName = name;
_healthPercent = healthPercent;
_hasHealth = hasHealth;
_stackSize = stackSize;
_sendQueryHealth = sendQueryHealth;
_manaPercent = manaPercent;
_sendQueryItemMana = sendQueryItemMana;
_splitQuantity = splitQuantity ?? throw new ArgumentNullException(nameof(splitQuantity));
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_isVendorSplitExempt = isVendorSplitExempt
?? throw new ArgumentNullException(nameof(isVendorSplitExempt));
_unsubscribeHealthChanged = unsubscribeHealthChanged;
_unsubscribeItemManaChanged = unsubscribeItemManaChanged;
_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;
_manaMeter = layout.FindElement(ManaMeterId) 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.
// for players/monsters). Float it just below the name so the name stays readable.
if (_overlay is not null) _overlay.ZOrder = OverlayZOrder;
// This controller owns the health meter's initial-hidden state.
if (_healthMeter is not null)
{
_healthMeter.Visible = false;
// Fill polls live: _current holds the currently-selected guid (or null).
_healthMeter.Fill = () => _current is uint g ? _healthPercent(g) : (float?)0f;
}
if (_manaMeter is not null)
{
_manaMeter.Visible = false;
_manaMeter.Fill = () => _current is uint g ? _manaPercent(g) : (float?)0f;
}
if (_stackSizeEntry is not null)
{
_stackSizeEntry.Visible = false;
// #353: the entry is AUTHORED HJustify=2 (right) at X=0 W=50,
// flush against the slider at X=50 on the same row — the count
// reads right-adjacent to the bar, retail's look. UiField
// already supports it; the importer does not carry HJustify.
// OneLine is REQUIRED for the alignment: RightAligned only
// applies on the single-line draw path, and the multi-line
// path's per-keystroke scroll-extent churn was also the edit
// flicker the user reported. A 14 px numeric entry is
// single-line by construction.
_stackSizeEntry.OneLine = true;
_stackSizeEntry.RightAligned = true;
_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.SetScalarPosition(_splitQuantity.Ratio);
_stackSizeSlider.ScalarChanged = OnStackSliderChanged;
}
// 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
// (retail renders the object name over the bar).
//
// The bar sprite (0x0600193E/F, 146x31) carries a ~14px BLACK name band across its
// TOP with the colored bar in the lower portion (confirmed from the dat). Retail
// draws the object name in that black band with the health bar BELOW it — so the
// label is TOP-aligned by constraining its height to the band, not centered over the
// whole 31px strip (which overlapped the bar's middle).
if (_name is not null)
{
_name.ZOrder = NameZOrderOnTop;
// #353: the name element is AUTHORED two lines tall (H=31 at
// W=140) — a long name wraps at the authored PIXEL width onto a
// second row instead of overflowing (user-verified retail
// behavior). Two stacked centered one-line labels reuse the
// existing centered draw path; the second draws nothing when
// the name fits.
float nameWidth = _name.Width;
var wrapFont = datFont;
Func<int, UiText.Line[]> lineFor = index =>
{
var n = _currentName;
if (string.IsNullOrEmpty(n))
return Array.Empty<UiText.Line>();
(string first, string second) = WrapNameTwoLines(n, nameWidth, wrapFont);
string text = index == 0 ? first : second;
return text.Length == 0
? Array.Empty<UiText.Line>()
: new[] { new UiText.Line(text, NameColor) };
};
for (int lineIndex = 0; lineIndex < 2; lineIndex++)
{
int captured = lineIndex;
var label = new UiText
{
Left = 0f,
Top = captured * NameBandHeight,
Width = _name.Width,
Height = NameBandHeight,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
Centered = true,
OneLine = true,
DatFont = datFont,
ClickThrough = true,
AcceptsFocus = false,
IsEditControl = false,
CapturesPointerDrag = false,
LinesProvider = () => lineFor(captured),
};
_name.AddChild(label);
}
}
// Register the handlers LAST so the initial state is fully set up first.
_selection.Changed += OnSelectionTransition;
_splitQuantity.Changed += OnSplitQuantityChanged;
subscribeHealthChanged(OnHealthChanged);
subscribeItemManaChanged(OnItemManaChanged);
subscribeObjectUpdated(OnObjectUpdated);
if (_selection.SelectedObjectId is { } initial)
ApplySelection(initial);
}
/// <summary>
/// Create and bind a <see cref="SelectedObjectController"/> to <paramref name="layout"/>.
/// Port of retail <c>gmToolbarUI::HandleSelectionChanged</c> + <c>RecvNotice_UpdateObjectHealth</c>.
/// </summary>
/// <param name="layout">Imported toolbar layout (LayoutDesc 0x21000016).</param>
/// <param name="selection">The single Core selected-object owner.</param>
/// <param name="subscribeHealthChanged">Called once with <see cref="OnHealthChanged"/>
/// (typical host: <c>h =&gt; Combat.HealthChanged += h</c>) — drives meter visibility.</param>
/// <param name="isHealthTarget">Returns true for guids that may show a health meter
/// (proxy for retail's <c>IsPlayer() || pet_owner || ObjectIsAttackable()</c>).</param>
/// <param name="name">Returns retail's NAME_APPROPRIATE display name for a guid.</param>
/// <param name="healthPercent">Returns the health fill fraction [0..1] for a given guid.</param>
/// <param name="hasHealth">Returns true if real health has been received for a guid
/// (so a re-selected, already-known target shows its bar immediately).</param>
/// <param name="stackSize">Returns the stack size for a guid (0 or 1 = non-stacked).</param>
/// <param name="sendQueryHealth">Sends retail <c>QueryHealth (0x01BF)</c>; may be a no-op offline.</param>
/// <param name="datFont">Dat font for the name label; null = debug bitmap font fallback.</param>
/// <param name="isVendorSplitExempt">
/// Slice 6.2: retail's <c>gmToolbarUI::HandleSelectionChanged</c> vendor
/// branch (<c>pc:198779-198790</c>) — true when the selected guid is
/// owned by the currently-open vendor (its <c>ClientObject.ContainerId</c>
/// equals <c>VendorState.VendorId</c>) AND its type intersects
/// <see cref="VendorSplitPolicy.SplitExemptMask"/>. When true, a stack
/// seeds to quantity 1 instead of the full authored stack size — see
/// <see cref="ApplySelection"/>. F2 (Slice 6 review):
/// <c>VendorUiController.ResolveBuyQuantity</c> answers the equivalent
/// LIVE question for vendor's own display text and Buy dispatch (the
/// CURRENT slider value, not the seed) via the SAME
/// <see cref="VendorSplitPolicy"/> mask, so the mask exists in exactly
/// one place (composed at <c>InteractionRetainedUiComposition</c>).
/// </param>
public static SelectedObjectController Bind(
ImportedLayout layout,
SelectionState selection,
Action<Action<uint, float>> subscribeHealthChanged,
Action<Action<uint, float>> unsubscribeHealthChanged,
Action<Action<uint, float, bool>> subscribeItemManaChanged,
Action<Action<uint, float, bool>> unsubscribeItemManaChanged,
Func<uint, bool> isHealthTarget,
Func<uint, bool> isOwnedByPlayer,
Func<uint, string?> name,
Func<uint, float> healthPercent,
Func<uint, bool> hasHealth,
Func<uint, uint> stackSize,
Action<uint> sendQueryHealth,
Func<uint, float> manaPercent,
Action<uint> sendQueryItemMana,
UiDatFont? datFont,
StackSplitQuantityState splitQuantity,
Action<Action<ClientObject>> subscribeObjectUpdated,
Action<Action<ClientObject>> unsubscribeObjectUpdated,
Func<uint, bool> isVendorSplitExempt)
=> new SelectedObjectController(
layout, selection,
subscribeHealthChanged, unsubscribeHealthChanged,
subscribeItemManaChanged, unsubscribeItemManaChanged,
isHealthTarget, isOwnedByPlayer, name, healthPercent, hasHealth, stackSize,
sendQueryHealth, manaPercent, sendQueryItemMana, datFont,
splitQuantity, subscribeObjectUpdated, unsubscribeObjectUpdated,
isVendorSplitExempt);
/// <summary>
/// Port of <c>gmToolbarUI::HandleSelectionChanged</c> (<c>:198635</c>):
/// clear-then-populate the selected-object strip on any selection change.
/// </summary>
private void ApplySelection(uint? guid)
{
bool selectionChanged = _current != guid;
// gmToolbarUI::HandleSelectionChanged @ 0x004BF3D1: changing away from
// a visible meter cancels its server query with object id zero.
if (selectionChanged)
{
if (_healthMeter?.Visible == true)
_sendQueryHealth(0);
if (_manaMeter?.Visible == true)
_sendQueryItemMana(0);
}
// ── 1. Clear first (retail: SetText("") + m_pSelObjectField->SetState(0)
// + SetVisible(0) on the meters). ──────────────────────────────────────
if (selectionChanged)
{
if (_healthMeter is not null) _healthMeter.Visible = false;
if (_manaMeter is not null) _manaMeter.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;
if (guid is null)
{
// Deselect: clear the overlay flash immediately too.
SetOverlayState(UiStateInfo.DirectStateId);
_flashRemaining = 0;
return;
}
uint g = guid.Value;
// ── 2. Name (displayed via the UiText child's LinesProvider reading _currentName). ──
uint stackSize = _stackSize(g);
string? objectName = _resolveName(g);
_currentName = stackSize > 1u && !string.IsNullOrEmpty(objectName)
? $"{stackSize} {objectName}"
: objectName;
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] ApplySelection guid=0x{g:X8} stackSizeOperand={stackSize} "
+ $"objectName={objectName ?? "null"} builtLabel={_currentName ?? "null"}");
}
// ── 3. Selection overlay: brief flash (retail container ObjectSelected
// = Pause(0.25s)→Normal). "StackedItemSelected" for stacks. ──────────────
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. Slice 6.2: the
// vendor-owned branch (pc:198779-198790, mask literal pc:198784)
// seeds splitSize (the INITIAL value) to 1 instead of the full stack
// when the selection is owned by the currently-open vendor AND its
// type intersects VendorSplitPolicy.SplitExemptMask — see the
// isVendorSplitExempt parameter doc. maxSplitSize (the slider's
// RANGE) is always the full authored stack size regardless of
// exemption (research doc §B.3: "Sets GenItemHolder::splitSize =
// seed, GenItemHolder::maxSplitSize = stackSize") — only the
// starting VALUE differs, not the ceiling.
if (stackSize > 1u)
{
bool vendorSplitExempt = _isVendorSplitExempt(g);
uint seed = vendorSplitExempt ? 1u : stackSize;
_splitQuantity.Reset(stackSize, initialValue: seed);
if (_stackSizeEntry is not null) _stackSizeEntry.Visible = true;
if (_stackSizeSlider is not null) _stackSizeSlider.Visible = true;
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] ApplySelection guid=0x{g:X8} sliderVisible=true "
+ $"isVendorSplitExempt={vendorSplitExempt} maxSplitSize={stackSize} seed={seed}");
}
}
else if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] ApplySelection guid=0x{g:X8} sliderVisible=false "
+ $"failingPredicate=stackSize<=1u stackSize={stackSize}");
}
// ── 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). ──────────────────────────
if (stackSize <= 1u && _isHealthTarget(g))
{
if (selectionChanged)
_sendQueryHealth(g);
if (_hasHealth(g) && _healthMeter is not null)
_healthMeter.Visible = true;
}
else if (stackSize <= 1u && _isOwnedByPlayer(g))
{
if (selectionChanged)
_sendQueryItemMana(g);
}
}
/// <summary>
/// Port of <c>gmToolbarUI::RecvNotice_UpdateObjectHealth</c> (<c>:196213</c>): when the
/// server reports health for the currently-selected guid, make the Health meter visible.
/// The fill value is read live by the meter's <see cref="UiMeter.Fill"/> provider.
/// </summary>
public void OnHealthChanged(uint guid, float percent)
{
if (_current is uint c && c == guid && _isHealthTarget(guid) && _healthMeter is not null)
_healthMeter.Visible = true;
}
/// <summary>Per-frame tick: reverts the selection overlay after the brief flash window.</summary>
public void Tick(double deltaSeconds)
{
if (_flashRemaining <= 0) return;
_flashRemaining -= deltaSeconds;
if (_flashRemaining <= 0)
SetOverlayState(UiStateInfo.DirectStateId); // flash done → overlay back to blank
}
private void SetOverlayState(uint state)
{
_overlay?.TrySetRetailState(state);
}
/// <summary>
/// Port of <c>gmToolbarUI::RecvNotice_UpdateItemMana @ 0x004BD0C0</c>.
/// Invalid results cancel with object id zero; valid results reveal the meter.
/// </summary>
public void OnItemManaChanged(uint guid, float percent, bool valid)
{
if (_current != guid)
return;
if (!valid)
{
_sendQueryItemMana(0);
return;
}
if (_manaMeter is not null)
_manaMeter.Visible = true;
}
/// <summary>
/// #353: greedy word wrap of the selected-object name into at most two
/// lines at the authored pixel width. A single word longer than the
/// width stays unbroken on its line (retail does not hyphenate). The
/// second line carries everything remaining — the authored element is
/// exactly two lines tall, so anything longer simply clips like retail.
/// </summary>
internal static (string First, string Second) WrapNameTwoLines(
string name,
float width,
UiDatFont? font)
{
if (font is null || font.MeasureWidth(name) <= width)
return (name, string.Empty);
int breakAt = -1;
for (int i = 0; i < name.Length; i++)
{
if (name[i] != ' ')
continue;
if (font.MeasureWidth(name[..i]) <= width)
breakAt = i;
else
break;
}
if (breakAt <= 0)
return (name, string.Empty);
return (name[..breakAt], name[(breakAt + 1)..].TrimStart());
}
private void CommitStackEntry(string text)
=> _splitQuantity.SetFromText(text);
private void OnSplitQuantityChanged()
{
_stackSizeEntry?.SetText(_splitQuantity.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
// UIElement_Scrollbar owns its live thumb position while dragging and only
// broadcasts message 0xA. Entry/selection changes explicitly write attr 0x86.
if (!_changingSplitFromSlider)
_stackSizeSlider?.SetScalarPosition(_splitQuantity.Ratio);
}
private void OnStackSliderChanged(float position)
{
_changingSplitFromSlider = true;
try
{
_splitQuantity.SetFromSliderRatio(position);
}
finally
{
_changingSplitFromSlider = false;
}
}
private void OnObjectUpdated(ClientObject updated)
{
if (_current == updated.ObjectId && _stackSize(updated.ObjectId) != _splitQuantity.Maximum)
ApplySelection(updated.ObjectId);
}
private void OnSelectionTransition(SelectionTransition transition)
{
// Retail's CM_UI::SendNotice_SelectionChanged (0x00479F50) carries no
// selected-id payload. gmToolbarUI::HandleSelectionChanged therefore
// reads the live ACCWeenieObject::selectedID when its notice handler
// runs. This matters when an earlier handler (ClientCombatSystem::
// AutoTarget) selects a replacement reentrantly: the outer "cleared"
// notice must render that replacement, not stale transition data.
_ = transition;
ApplySelection(_selection.SelectedObjectId);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_selection.Changed -= OnSelectionTransition;
_splitQuantity.Changed -= OnSplitQuantityChanged;
_unsubscribeHealthChanged(OnHealthChanged);
_unsubscribeItemManaChanged(OnItemManaChanged);
_unsubscribeObjectUpdated(OnObjectUpdated);
}
}