Add middle-click sort
Support MMB to trigger Sort via context menu, and add Company Chest MMB organize (stack + compact). Bump version to 1.0.2.
This commit is contained in:
+482
-4
@@ -33,6 +33,9 @@ public sealed class Configuration : IPluginConfiguration
|
|||||||
public bool DebugMode { get; set; } = false;
|
public bool DebugMode { get; set; } = false;
|
||||||
public int TransferCooldownMs { get; set; } = 200;
|
public int TransferCooldownMs { get; set; } = 200;
|
||||||
|
|
||||||
|
public bool EnableMiddleClickSort { get; set; } = true;
|
||||||
|
public bool EnableCompanyChestMiddleClickOrganize { get; set; } = true;
|
||||||
|
|
||||||
public bool EnableCompanyChest { get; set; } = true;
|
public bool EnableCompanyChest { get; set; } = true;
|
||||||
public bool AutoConfirmCompanyChestQuantity { get; set; } = true;
|
public bool AutoConfirmCompanyChestQuantity { get; set; } = true;
|
||||||
public int CompanyChestCompartments { get; set; } = 3; // 3..5 (default game starts at 3)
|
public int CompanyChestCompartments { get; set; } = 3; // 3..5 (default game starts at 3)
|
||||||
@@ -63,6 +66,11 @@ public sealed unsafe class Plugin : IDalamudPlugin
|
|||||||
private long lastActionTickMs;
|
private long lastActionTickMs;
|
||||||
private (nint AgentPtr, nint AddonPtr, long EnqueuedAtMs, ModifierMode Mode)? pendingDeferredMenuClick;
|
private (nint AgentPtr, nint AddonPtr, long EnqueuedAtMs, ModifierMode Mode)? pendingDeferredMenuClick;
|
||||||
private (string AddonName, long EnqueuedAtMs, ModifierMode Mode)? pendingDeferredDefaultMenu;
|
private (string AddonName, long EnqueuedAtMs, ModifierMode Mode)? pendingDeferredDefaultMenu;
|
||||||
|
private (nint AgentPtr, nint AddonPtr, long EnqueuedAtMs)? pendingDeferredSortMenuClick;
|
||||||
|
private long pendingMiddleClickSortUntilMs;
|
||||||
|
private (FFXIVClientStructs.FFXIV.Client.Game.InventoryType Type, int Slot, uint AddonId, long EnqueuedAtMs)? pendingMiddleClickSortRequest;
|
||||||
|
private long lastMiddleClickSortMs;
|
||||||
|
|
||||||
private long pendingCompanyChestNumericConfirmUntilMs;
|
private long pendingCompanyChestNumericConfirmUntilMs;
|
||||||
private int pendingCompanyChestNumericConfirmAttempts;
|
private int pendingCompanyChestNumericConfirmAttempts;
|
||||||
private long pendingCloseContextMenuAtMs;
|
private long pendingCloseContextMenuAtMs;
|
||||||
@@ -70,7 +78,7 @@ public sealed unsafe class Plugin : IDalamudPlugin
|
|||||||
private bool pendingCompanyChestNumericValueSet;
|
private bool pendingCompanyChestNumericValueSet;
|
||||||
private long pendingCompanyChestNumericValueSetAtMs;
|
private long pendingCompanyChestNumericValueSetAtMs;
|
||||||
private uint pendingCompanyChestNumericDesired;
|
private uint pendingCompanyChestNumericDesired;
|
||||||
private enum PendingNumericKind { None, Store, Remove }
|
private enum PendingNumericKind { None, Store, Remove, Move }
|
||||||
private PendingNumericKind pendingNumericKind;
|
private PendingNumericKind pendingNumericKind;
|
||||||
|
|
||||||
private long lastShiftSeenMs;
|
private long lastShiftSeenMs;
|
||||||
@@ -101,6 +109,17 @@ public sealed unsafe class Plugin : IDalamudPlugin
|
|||||||
|
|
||||||
private CompanyChestDepositState companyChestDeposit;
|
private CompanyChestDepositState companyChestDeposit;
|
||||||
|
|
||||||
|
private struct CompanyChestOrganizeState
|
||||||
|
{
|
||||||
|
public bool Active;
|
||||||
|
public long NextAttemptAtMs;
|
||||||
|
public long ExpiresAtMs;
|
||||||
|
public int Steps;
|
||||||
|
public int Phase; // 0=stack, 1=compact
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompanyChestOrganizeState companyChestOrganize;
|
||||||
|
|
||||||
private enum ModifierMode
|
private enum ModifierMode
|
||||||
{
|
{
|
||||||
Shift,
|
Shift,
|
||||||
@@ -136,6 +155,7 @@ public sealed unsafe class Plugin : IDalamudPlugin
|
|||||||
EntrustToRetainer,
|
EntrustToRetainer,
|
||||||
RetrieveFromRetainer,
|
RetrieveFromRetainer,
|
||||||
RemoveFromCompanyChest,
|
RemoveFromCompanyChest,
|
||||||
|
Sort,
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly string[] ArmouryAddonNames =
|
private static readonly string[] ArmouryAddonNames =
|
||||||
@@ -203,6 +223,7 @@ public sealed unsafe class Plugin : IDalamudPlugin
|
|||||||
// Register without a name-filter so we can confirm it fires on this client build.
|
// Register without a name-filter so we can confirm it fires on this client build.
|
||||||
AddonLifecycle.RegisterListener(AddonEvent.PreSetup, OnInputNumericPreSetup);
|
AddonLifecycle.RegisterListener(AddonEvent.PreSetup, OnInputNumericPreSetup);
|
||||||
AddonLifecycle.RegisterListener(AddonEvent.PreDraw, OnAddonPreDraw);
|
AddonLifecycle.RegisterListener(AddonEvent.PreDraw, OnAddonPreDraw);
|
||||||
|
AddonLifecycle.RegisterListener(AddonEvent.PreReceiveEvent, OnAddonReceiveEvent);
|
||||||
|
|
||||||
Log.Information($"Loaded {PluginInterface.Manifest.Name}.");
|
Log.Information($"Loaded {PluginInterface.Manifest.Name}.");
|
||||||
Log.Information($"[QuickTransfer] DebugMode={Configuration.DebugMode}, Enabled={Configuration.Enabled}");
|
Log.Information($"[QuickTransfer] DebugMode={Configuration.DebugMode}, Enabled={Configuration.Enabled}");
|
||||||
@@ -230,6 +251,7 @@ public sealed unsafe class Plugin : IDalamudPlugin
|
|||||||
ContextMenu.OnMenuOpened -= OnContextMenuOpened;
|
ContextMenu.OnMenuOpened -= OnContextMenuOpened;
|
||||||
AddonLifecycle.UnregisterListener(AddonEvent.PreSetup, OnInputNumericPreSetup);
|
AddonLifecycle.UnregisterListener(AddonEvent.PreSetup, OnInputNumericPreSetup);
|
||||||
AddonLifecycle.UnregisterListener(AddonEvent.PreDraw, OnAddonPreDraw);
|
AddonLifecycle.UnregisterListener(AddonEvent.PreDraw, OnAddonPreDraw);
|
||||||
|
AddonLifecycle.UnregisterListener(AddonEvent.PreReceiveEvent, OnAddonReceiveEvent);
|
||||||
|
|
||||||
openForItemSlotHook?.Disable();
|
openForItemSlotHook?.Disable();
|
||||||
openForItemSlotHook?.Dispose();
|
openForItemSlotHook?.Dispose();
|
||||||
@@ -327,9 +349,10 @@ public sealed unsafe class Plugin : IDalamudPlugin
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
var now = Environment.TickCount64;
|
var now = Environment.TickCount64;
|
||||||
var mode = GetModifierModeLatched(now);
|
var middleSortActive = pendingMiddleClickSortUntilMs > 0 && now <= pendingMiddleClickSortUntilMs;
|
||||||
|
var mode = middleSortActive ? null : GetModifierModeLatched(now);
|
||||||
|
|
||||||
if (mode == null)
|
if (!middleSortActive && mode == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var saddlebagOpen = IsSaddlebagOpen();
|
var saddlebagOpen = IsSaddlebagOpen();
|
||||||
@@ -337,12 +360,22 @@ public sealed unsafe class Plugin : IDalamudPlugin
|
|||||||
var companyChestOpen = IsCompanyChestOpen();
|
var companyChestOpen = IsCompanyChestOpen();
|
||||||
var specialOpen = saddlebagOpen || retainerOpen || companyChestOpen;
|
var specialOpen = saddlebagOpen || retainerOpen || companyChestOpen;
|
||||||
|
|
||||||
if (mode == ModifierMode.Ctrl && !specialOpen)
|
if (!middleSortActive && mode == ModifierMode.Ctrl && !specialOpen)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (Configuration.DebugMode)
|
if (Configuration.DebugMode)
|
||||||
Log.Information($"[QuickTransfer] OnMenuOpened: AddonName='{args.AddonName}', MenuType={args.MenuType}, AgentPtr=0x{args.AgentPtr.ToInt64():X}, AddonPtr=0x{args.AddonPtr.ToInt64():X}");
|
Log.Information($"[QuickTransfer] OnMenuOpened: AddonName='{args.AddonName}', MenuType={args.MenuType}, AgentPtr=0x{args.AgentPtr.ToInt64():X}, AddonPtr=0x{args.AddonPtr.ToInt64():X}");
|
||||||
|
|
||||||
|
// Middle-click "Sort" uses an inventory context menu, but does not require Shift/Ctrl.
|
||||||
|
if (middleSortActive && args.MenuType == ContextMenuType.Inventory)
|
||||||
|
{
|
||||||
|
if (args.AgentPtr != IntPtr.Zero && args.AddonPtr != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
pendingDeferredSortMenuClick = ((nint)args.AgentPtr, (nint)args.AddonPtr, now);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Free Company Chest uses MenuType.Default (not Inventory).
|
// Free Company Chest uses MenuType.Default (not Inventory).
|
||||||
if (args.MenuType == ContextMenuType.Default &&
|
if (args.MenuType == ContextMenuType.Default &&
|
||||||
mode == ModifierMode.Shift &&
|
mode == ModifierMode.Shift &&
|
||||||
@@ -360,6 +393,9 @@ public sealed unsafe class Plugin : IDalamudPlugin
|
|||||||
if (args.AgentPtr == IntPtr.Zero || args.AddonPtr == IntPtr.Zero)
|
if (args.AgentPtr == IntPtr.Zero || args.AddonPtr == IntPtr.Zero)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (mode == null)
|
||||||
|
return;
|
||||||
|
|
||||||
// IMPORTANT: Do not click inside the open event (re-entrancy risk).
|
// IMPORTANT: Do not click inside the open event (re-entrancy risk).
|
||||||
pendingDeferredMenuClick = ((nint)args.AgentPtr, (nint)args.AddonPtr, Environment.TickCount64, mode.Value);
|
pendingDeferredMenuClick = ((nint)args.AgentPtr, (nint)args.AddonPtr, Environment.TickCount64, mode.Value);
|
||||||
}
|
}
|
||||||
@@ -506,6 +542,48 @@ public sealed unsafe class Plugin : IDalamudPlugin
|
|||||||
if (Configuration.EnableCompanyChest)
|
if (Configuration.EnableCompanyChest)
|
||||||
ProcessCompanyChestDeposit(now);
|
ProcessCompanyChestDeposit(now);
|
||||||
|
|
||||||
|
// Company Chest organize (MMB): auto-stack + compact items within FC chest pages.
|
||||||
|
if (Configuration.EnableCompanyChest && Configuration.EnableCompanyChestMiddleClickOrganize)
|
||||||
|
ProcessCompanyChestOrganize(now);
|
||||||
|
|
||||||
|
// Middle-click sort: open the context menu on the clicked slot, then auto-select "Sort".
|
||||||
|
var mmb = pendingMiddleClickSortRequest;
|
||||||
|
if (Configuration.EnableMiddleClickSort && mmb != null && now - mmb.Value.EnqueuedAtMs <= 1500)
|
||||||
|
{
|
||||||
|
// If the request was for Company Chest, run organize instead (there is no Sort entry on the item menu).
|
||||||
|
if (IsCompanyChestType(mmb.Value.Type) && Configuration.EnableCompanyChest && Configuration.EnableCompanyChestMiddleClickOrganize)
|
||||||
|
{
|
||||||
|
StartCompanyChestOrganize(now);
|
||||||
|
pendingMiddleClickSortRequest = null;
|
||||||
|
pendingMiddleClickSortUntilMs = 0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Open context menu for that slot. Our OnMenuOpened handler will enqueue the deferred sort selection.
|
||||||
|
var agentModule = AgentModule.Instance();
|
||||||
|
if (agentModule != null)
|
||||||
|
{
|
||||||
|
var agent = agentModule->GetAgentByInternalId(AgentId.InventoryContext);
|
||||||
|
var invCtx = (AgentInventoryContext*)agent;
|
||||||
|
if (invCtx != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ArmSuppressContextMenu(now, 250);
|
||||||
|
invCtx->OpenForItemSlot(mmb.Value.Type, mmb.Value.Slot, 0, mmb.Value.AddonId);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only try once; selection happens via deferred menu click.
|
||||||
|
pendingMiddleClickSortRequest = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Delay-close the context menu slightly; closing immediately can cancel some default-menu actions.
|
// Delay-close the context menu slightly; closing immediately can cancel some default-menu actions.
|
||||||
if (pendingCloseContextMenuAtMs > 0 && now >= pendingCloseContextMenuAtMs)
|
if (pendingCloseContextMenuAtMs > 0 && now >= pendingCloseContextMenuAtMs)
|
||||||
{
|
{
|
||||||
@@ -555,7 +633,11 @@ public sealed unsafe class Plugin : IDalamudPlugin
|
|||||||
|
|
||||||
var pending = pendingDeferredMenuClick;
|
var pending = pendingDeferredMenuClick;
|
||||||
if (pending == null)
|
if (pending == null)
|
||||||
|
{
|
||||||
|
// Process deferred middle-click sort selection even if no normal deferred click.
|
||||||
|
ProcessDeferredSortMenuClick(now);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Consume (only try once).
|
// Consume (only try once).
|
||||||
pendingDeferredMenuClick = null;
|
pendingDeferredMenuClick = null;
|
||||||
@@ -603,6 +685,45 @@ public sealed unsafe class Plugin : IDalamudPlugin
|
|||||||
{
|
{
|
||||||
Log.Warning(ex, "[QuickTransfer] Deferred menu select failed.");
|
Log.Warning(ex, "[QuickTransfer] Deferred menu select failed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also process a pending sort click (if any) after normal transfers.
|
||||||
|
ProcessDeferredSortMenuClick(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ProcessDeferredSortMenuClick(long now)
|
||||||
|
{
|
||||||
|
var pendingSort = pendingDeferredSortMenuClick;
|
||||||
|
if (pendingSort == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
pendingDeferredSortMenuClick = null;
|
||||||
|
pendingMiddleClickSortUntilMs = 0;
|
||||||
|
|
||||||
|
if (now - pendingSort.Value.EnqueuedAtMs > 1500)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var agent = (AgentInventoryContext*)pendingSort.Value.AgentPtr;
|
||||||
|
var addon = (AtkUnitBase*)pendingSort.Value.AddonPtr;
|
||||||
|
|
||||||
|
if (TrySelectSortAndClose(agent, addon, out var chosenText, out var chosenIndex))
|
||||||
|
{
|
||||||
|
lastActionTickMs = now;
|
||||||
|
ArmSuppressContextMenu(now, 500);
|
||||||
|
if (Configuration.DebugMode)
|
||||||
|
Log.Information($"[QuickTransfer] (MMB) Selected context action '{chosenText}' (idx={chosenIndex}) via deferred OnMenuOpened.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// If we opened a menu but didn't find Sort, close it to avoid leaving a hidden menu behind.
|
||||||
|
try { CloseContextMenuAddon(agent, addon); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "[QuickTransfer] Deferred sort select failed.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnAddonPreDraw(AddonEvent type, AddonArgs args)
|
private void OnAddonPreDraw(AddonEvent type, AddonArgs args)
|
||||||
@@ -636,6 +757,79 @@ public sealed unsafe class Plugin : IDalamudPlugin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnAddonReceiveEvent(AddonEvent type, AddonArgs args)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Configuration.Enabled || !Configuration.EnableMiddleClickSort)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (args is not AddonReceiveEventArgs recv)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var now = Environment.TickCount64;
|
||||||
|
if (now - lastMiddleClickSortMs < 250)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var eventType = (AtkEventType)recv.AtkEventType;
|
||||||
|
if (eventType != AtkEventType.DragDropClick && eventType != AtkEventType.MouseClick && eventType != AtkEventType.MouseDown)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var eventData = (AtkEventData*)recv.AtkEventData;
|
||||||
|
if (eventData == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Inventory slots are drag-drop components; use drag-drop mouse button id when available.
|
||||||
|
var buttonId = eventType == AtkEventType.DragDropClick ? eventData->DragDropData.MouseButtonId : eventData->MouseData.ButtonId;
|
||||||
|
const byte middleButtonId = 2;
|
||||||
|
if (buttonId != middleButtonId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var ddi = eventData->DragDropData.DragDropInterface;
|
||||||
|
if (ddi == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var payload = ddi->GetPayloadContainer();
|
||||||
|
if (payload == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var invType = (FFXIVClientStructs.FFXIV.Client.Game.InventoryType)payload->Int1;
|
||||||
|
var slot = payload->Int2;
|
||||||
|
if (slot < 0 || slot > 500)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Only act on inventory containers we understand (avoid hotbars, etc.).
|
||||||
|
if (!IsPlayerInventoryType(invType) && !IsArmouryType(invType) && !IsSaddlebagType(invType) && !IsRetainerType(invType) && !IsCompanyChestType(invType))
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Require a real item slot unless it's Company Chest (organize operates on whole chest).
|
||||||
|
if (!IsCompanyChestType(invType))
|
||||||
|
{
|
||||||
|
if (!TryGetItemInfo(invType, slot, out var itemId, out _, out _))
|
||||||
|
return;
|
||||||
|
if (itemId == 0)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var addon = (AtkUnitBase*)args.Addon.Address;
|
||||||
|
if (addon == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
pendingMiddleClickSortRequest = (invType, slot, addon->Id, now);
|
||||||
|
pendingMiddleClickSortUntilMs = now + 1500;
|
||||||
|
lastMiddleClickSortMs = now;
|
||||||
|
|
||||||
|
// Prevent the underlying UI from processing the click further.
|
||||||
|
var atkEvent = (AtkEvent*)recv.AtkEvent;
|
||||||
|
if (atkEvent != null)
|
||||||
|
atkEvent->SetEventIsHandled();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void MakeAddonInvisible(AtkUnitBase* addon)
|
private static void MakeAddonInvisible(AtkUnitBase* addon)
|
||||||
{
|
{
|
||||||
if (addon == null)
|
if (addon == null)
|
||||||
@@ -698,6 +892,7 @@ public sealed unsafe class Plugin : IDalamudPlugin
|
|||||||
return;
|
return;
|
||||||
if (pendingNumericKind == PendingNumericKind.Remove && !prompt.Contains("remove", StringComparison.OrdinalIgnoreCase))
|
if (pendingNumericKind == PendingNumericKind.Remove && !prompt.Contains("remove", StringComparison.OrdinalIgnoreCase))
|
||||||
return;
|
return;
|
||||||
|
// For "Move" we accept any prompt while the Company Chest is open (used for internal stack/organize moves).
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standard InputNumeric layout (also used by SimpleTweaks):
|
// Standard InputNumeric layout (also used by SimpleTweaks):
|
||||||
@@ -920,6 +1115,35 @@ public sealed unsafe class Plugin : IDalamudPlugin
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool TrySelectSortAndClose(AgentInventoryContext* agent, AtkUnitBase* contextMenuAddon, out string chosenText, out int chosenIndex)
|
||||||
|
{
|
||||||
|
chosenText = string.Empty;
|
||||||
|
chosenIndex = -1;
|
||||||
|
|
||||||
|
var max = Math.Min(agent->ContextItemCount, 64);
|
||||||
|
for (var i = 0; i < max; i++)
|
||||||
|
{
|
||||||
|
var param = agent->EventParams[agent->ContexItemStartIndex + i];
|
||||||
|
if (param.Type is not (AtkValueType.String or AtkValueType.ManagedString))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var text = ReadAtkValueString(param);
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!ContextLabelMatches(AutoContextAction.Sort, text))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
GenerateCallback(contextMenuAddon, 0, i, 0U, 0, 0);
|
||||||
|
CloseContextMenuAddon(agent, contextMenuAddon);
|
||||||
|
chosenText = text;
|
||||||
|
chosenIndex = i;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private bool StartCompanyChestDeposit(FFXIVClientStructs.FFXIV.Client.Game.InventoryType sourceType, uint sourceSlot)
|
private bool StartCompanyChestDeposit(FFXIVClientStructs.FFXIV.Client.Game.InventoryType sourceType, uint sourceSlot)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -1061,6 +1285,256 @@ public sealed unsafe class Plugin : IDalamudPlugin
|
|||||||
Log.Information($"[QuickTransfer] (Shift+RClick) Company Chest deposit step {companyChestDeposit.Steps}: {companyChestDeposit.SourceType} slot={companyChestDeposit.SourceSlot} -> {destType} slot={destSlot} (qty={qty}, stackMax={maxStack}).");
|
Log.Information($"[QuickTransfer] (Shift+RClick) Company Chest deposit step {companyChestDeposit.Steps}: {companyChestDeposit.SourceType} slot={companyChestDeposit.SourceSlot} -> {destType} slot={destSlot} (qty={qty}, stackMax={maxStack}).");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void StartCompanyChestOrganize(long now)
|
||||||
|
{
|
||||||
|
if (!Configuration.EnableCompanyChest || !IsCompanyChestOpen() || RaptureAtkModule.Instance() == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
companyChestOrganize = new CompanyChestOrganizeState
|
||||||
|
{
|
||||||
|
Active = true,
|
||||||
|
NextAttemptAtMs = now,
|
||||||
|
ExpiresAtMs = now + 20000,
|
||||||
|
Steps = 0,
|
||||||
|
Phase = 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ProcessCompanyChestOrganize(long now)
|
||||||
|
{
|
||||||
|
if (!companyChestOrganize.Active)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!Configuration.EnableCompanyChest || RaptureAtkModule.Instance() == null || !IsCompanyChestOpen())
|
||||||
|
{
|
||||||
|
companyChestOrganize.Active = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now >= companyChestOrganize.ExpiresAtMs || companyChestOrganize.Steps >= 80)
|
||||||
|
{
|
||||||
|
companyChestOrganize.Active = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryGetVisibleAddon(InputNumericAddonName, out _))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (now < companyChestOrganize.NextAttemptAtMs)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var pages = GetCompanyChestInventoryTypes();
|
||||||
|
if (pages.Length == 0)
|
||||||
|
{
|
||||||
|
companyChestOrganize.Active = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 0: merge stacks where possible.
|
||||||
|
if (companyChestOrganize.Phase == 0)
|
||||||
|
{
|
||||||
|
if (TryFindCompanyChestMergeMove(pages, out var srcType, out var srcSlot, out var dstType, out var dstSlot, out var needsNumeric))
|
||||||
|
{
|
||||||
|
if (!TryCompanyChestMoveItem(srcType, srcSlot, dstType, dstSlot, needsNumeric))
|
||||||
|
{
|
||||||
|
companyChestOrganize.Active = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
companyChestOrganize.Steps++;
|
||||||
|
companyChestOrganize.NextAttemptAtMs = now + (needsNumeric ? 650 : 350);
|
||||||
|
|
||||||
|
if (Configuration.AutoConfirmCompanyChestQuantity && needsNumeric)
|
||||||
|
{
|
||||||
|
pendingCompanyChestNumericConfirmUntilMs = now + 1500;
|
||||||
|
pendingCompanyChestNumericConfirmAttempts = 0;
|
||||||
|
pendingCompanyChestNumericArmed = true;
|
||||||
|
pendingNumericKind = PendingNumericKind.Move;
|
||||||
|
pendingCompanyChestNumericValueSet = false;
|
||||||
|
pendingCompanyChestNumericValueSetAtMs = 0;
|
||||||
|
pendingCompanyChestNumericDesired = 0;
|
||||||
|
ArmSuppressInputNumeric(now, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Configuration.DebugMode)
|
||||||
|
Log.Information($"[QuickTransfer] (MMB) Company Chest organize step {companyChestOrganize.Steps}: {srcType} slot={srcSlot} -> {dstType} slot={dstSlot} (phase=stack, numeric={needsNumeric}).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No more merges; move on to compaction.
|
||||||
|
companyChestOrganize.Phase = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: compact items to fill empty slots from the start.
|
||||||
|
if (TryFindCompanyChestCompactionMove(pages, out var cSrcType, out var cSrcSlot, out var cDstType, out var cDstSlot))
|
||||||
|
{
|
||||||
|
if (!TryCompanyChestMoveItem(cSrcType, cSrcSlot, cDstType, cDstSlot, keepAliveForInputNumeric: false))
|
||||||
|
{
|
||||||
|
companyChestOrganize.Active = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
companyChestOrganize.Steps++;
|
||||||
|
companyChestOrganize.NextAttemptAtMs = now + 250;
|
||||||
|
|
||||||
|
if (Configuration.DebugMode)
|
||||||
|
Log.Information($"[QuickTransfer] (MMB) Company Chest organize step {companyChestOrganize.Steps}: {cSrcType} slot={cSrcSlot} -> {cDstType} slot={cDstSlot} (phase=compact).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done.
|
||||||
|
companyChestOrganize.Active = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryFindCompanyChestMergeMove(
|
||||||
|
FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] pages,
|
||||||
|
out FFXIVClientStructs.FFXIV.Client.Game.InventoryType srcType,
|
||||||
|
out uint srcSlot,
|
||||||
|
out FFXIVClientStructs.FFXIV.Client.Game.InventoryType dstType,
|
||||||
|
out uint dstSlot,
|
||||||
|
out bool needsNumeric)
|
||||||
|
{
|
||||||
|
srcType = default;
|
||||||
|
srcSlot = 0;
|
||||||
|
dstType = default;
|
||||||
|
dstSlot = 0;
|
||||||
|
needsNumeric = false;
|
||||||
|
|
||||||
|
var inv = InventoryManager.Instance();
|
||||||
|
if (inv == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const int slotCap = 80;
|
||||||
|
|
||||||
|
// Find a destination stack with free space, then a later source stack of same item to merge.
|
||||||
|
foreach (var dt in pages)
|
||||||
|
{
|
||||||
|
for (var di = 0; di < slotCap; di++)
|
||||||
|
{
|
||||||
|
var d = inv->GetInventorySlot(dt, di);
|
||||||
|
if (d == null)
|
||||||
|
break;
|
||||||
|
if (d->ItemId == 0 || d->Quantity <= 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var itemId = d->ItemId;
|
||||||
|
var isHq = d->Flags.HasFlag(InventoryItem.ItemFlags.HighQuality);
|
||||||
|
var maxStack = GetItemStackSize(itemId);
|
||||||
|
if (maxStack <= 1)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var free = (int)maxStack - d->Quantity;
|
||||||
|
if (free <= 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Find a later stack to merge into this one.
|
||||||
|
var foundDest = false;
|
||||||
|
var destGlobalIndex = 0;
|
||||||
|
for (var pi = 0; pi < pages.Length; pi++)
|
||||||
|
{
|
||||||
|
if (pages[pi] != dt) continue;
|
||||||
|
destGlobalIndex = pi * slotCap + di;
|
||||||
|
foundDest = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!foundDest)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
for (var p = 0; p < pages.Length; p++)
|
||||||
|
{
|
||||||
|
var st = pages[p];
|
||||||
|
for (var si = 0; si < slotCap; si++)
|
||||||
|
{
|
||||||
|
var s = inv->GetInventorySlot(st, si);
|
||||||
|
if (s == null)
|
||||||
|
break;
|
||||||
|
if (s->ItemId == 0 || s->Quantity <= 0)
|
||||||
|
continue;
|
||||||
|
if (s->ItemId != itemId)
|
||||||
|
continue;
|
||||||
|
var sHq = s->Flags.HasFlag(InventoryItem.ItemFlags.HighQuality);
|
||||||
|
if (sHq != isHq)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var srcGlobalIndex = p * slotCap + si;
|
||||||
|
if (srcGlobalIndex <= destGlobalIndex)
|
||||||
|
continue;
|
||||||
|
if (st == dt && si == di)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Merging stacks usually prompts for quantity.
|
||||||
|
srcType = st;
|
||||||
|
srcSlot = (uint)si;
|
||||||
|
dstType = dt;
|
||||||
|
dstSlot = (uint)di;
|
||||||
|
needsNumeric = s->Quantity > 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryFindCompanyChestCompactionMove(
|
||||||
|
FFXIVClientStructs.FFXIV.Client.Game.InventoryType[] pages,
|
||||||
|
out FFXIVClientStructs.FFXIV.Client.Game.InventoryType srcType,
|
||||||
|
out uint srcSlot,
|
||||||
|
out FFXIVClientStructs.FFXIV.Client.Game.InventoryType dstType,
|
||||||
|
out uint dstSlot)
|
||||||
|
{
|
||||||
|
srcType = default;
|
||||||
|
srcSlot = 0;
|
||||||
|
dstType = default;
|
||||||
|
dstSlot = 0;
|
||||||
|
|
||||||
|
var inv = InventoryManager.Instance();
|
||||||
|
if (inv == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const int slotCap = 80;
|
||||||
|
|
||||||
|
// Find first empty, then next non-empty after it.
|
||||||
|
for (var dp = 0; dp < pages.Length; dp++)
|
||||||
|
{
|
||||||
|
var dt = pages[dp];
|
||||||
|
for (var di = 0; di < slotCap; di++)
|
||||||
|
{
|
||||||
|
var d = inv->GetInventorySlot(dt, di);
|
||||||
|
if (d == null)
|
||||||
|
break;
|
||||||
|
if (d->ItemId != 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Found empty destination.
|
||||||
|
for (var sp = dp; sp < pages.Length; sp++)
|
||||||
|
{
|
||||||
|
var st = pages[sp];
|
||||||
|
var start = sp == dp ? di + 1 : 0;
|
||||||
|
for (var si = start; si < slotCap; si++)
|
||||||
|
{
|
||||||
|
var s = inv->GetInventorySlot(st, si);
|
||||||
|
if (s == null)
|
||||||
|
break;
|
||||||
|
if (s->ItemId == 0 || s->Quantity <= 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
srcType = st;
|
||||||
|
srcSlot = (uint)si;
|
||||||
|
dstType = dt;
|
||||||
|
dstSlot = (uint)di;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private bool TryCompanyChestMoveItem(
|
private bool TryCompanyChestMoveItem(
|
||||||
FFXIVClientStructs.FFXIV.Client.Game.InventoryType sourceType,
|
FFXIVClientStructs.FFXIV.Client.Game.InventoryType sourceType,
|
||||||
uint sourceSlot,
|
uint sourceSlot,
|
||||||
@@ -1887,6 +2361,10 @@ public sealed unsafe class Plugin : IDalamudPlugin
|
|||||||
(Has(t, "Remove") && (Has(t, "Company") || Has(t, "Chest"))) ||
|
(Has(t, "Remove") && (Has(t, "Company") || Has(t, "Chest"))) ||
|
||||||
(Has(t, "Withdraw") && (Has(t, "Company") || Has(t, "Chest"))),
|
(Has(t, "Withdraw") && (Has(t, "Company") || Has(t, "Chest"))),
|
||||||
|
|
||||||
|
AutoContextAction.Sort =>
|
||||||
|
t.Equals("Sort", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
t.StartsWith("Sort", StringComparison.OrdinalIgnoreCase),
|
||||||
|
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
<RootNamespace>QuickTransfer</RootNamespace>
|
<RootNamespace>QuickTransfer</RootNamespace>
|
||||||
<OutputType>Library</OutputType>
|
<OutputType>Library</OutputType>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<Version>1.0.1</Version>
|
<Version>1.0.2</Version>
|
||||||
<AssemblyVersion>1.0.1.0</AssemblyVersion>
|
<AssemblyVersion>1.0.2.0</AssemblyVersion>
|
||||||
<FileVersion>1.0.1.0</FileVersion>
|
<FileVersion>1.0.2.0</FileVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<!-- Local builds: some setups have DALAMUD_HOME pointing at the XIVLauncher root,
|
<!-- Local builds: some setups have DALAMUD_HOME pointing at the XIVLauncher root,
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"Author": "flick",
|
"Author": "flick",
|
||||||
"Name": "QuickTransfer",
|
"Name": "QuickTransfer",
|
||||||
"InternalName": "QuickTransfer",
|
"InternalName": "QuickTransfer",
|
||||||
"AssemblyVersion": "1.0.1.0",
|
"AssemblyVersion": "1.0.2.0",
|
||||||
"Description": "Automate inventory transfers with Shift/Ctrl + Right-Click.",
|
"Description": "Automate inventory transfers with Shift/Ctrl + Right-Click.",
|
||||||
"ApplicableVersion": "any",
|
"ApplicableVersion": "any",
|
||||||
"RepoUrl": "https://github.com/Knack117/QuickTransfer",
|
"RepoUrl": "https://github.com/Knack117/QuickTransfer",
|
||||||
|
|||||||
@@ -53,6 +53,16 @@ public class QuickTransferWindow : Window, IDisposable
|
|||||||
|
|
||||||
ImGui.Spacing();
|
ImGui.Spacing();
|
||||||
|
|
||||||
|
// Middle-click sort
|
||||||
|
var mmbSort = _config.EnableMiddleClickSort;
|
||||||
|
if (ImGui.Checkbox("Enable Middle-Click Sort###EnableMiddleClickSort", ref mmbSort))
|
||||||
|
{
|
||||||
|
_config.EnableMiddleClickSort = mmbSort;
|
||||||
|
_config.Save();
|
||||||
|
}
|
||||||
|
ImGui.SameLine();
|
||||||
|
ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.7f, 0.7f), "(MMB on an item: auto-select \"Sort\" when available)");
|
||||||
|
|
||||||
// Company Chest
|
// Company Chest
|
||||||
var enableCompanyChest = _config.EnableCompanyChest;
|
var enableCompanyChest = _config.EnableCompanyChest;
|
||||||
if (ImGui.Checkbox("Enable Company Chest (Free Company Chest)###EnableCompanyChest", ref enableCompanyChest))
|
if (ImGui.Checkbox("Enable Company Chest (Free Company Chest)###EnableCompanyChest", ref enableCompanyChest))
|
||||||
@@ -63,6 +73,15 @@ public class QuickTransferWindow : Window, IDisposable
|
|||||||
ImGui.SameLine();
|
ImGui.SameLine();
|
||||||
ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.7f, 0.7f), "(Shift: deposit/withdraw while FC chest is open)");
|
ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.7f, 0.7f), "(Shift: deposit/withdraw while FC chest is open)");
|
||||||
|
|
||||||
|
var mmbCompanyOrganize = _config.EnableCompanyChestMiddleClickOrganize;
|
||||||
|
if (ImGui.Checkbox("Company Chest: Middle-Click Organize###EnableCompanyChestMiddleClickOrganize", ref mmbCompanyOrganize))
|
||||||
|
{
|
||||||
|
_config.EnableCompanyChestMiddleClickOrganize = mmbCompanyOrganize;
|
||||||
|
_config.Save();
|
||||||
|
}
|
||||||
|
ImGui.SameLine();
|
||||||
|
ImGui.TextColored(new Vector4(0.7f, 0.7f, 0.7f, 0.7f), "(MMB: auto-stack + compact in FC chest)");
|
||||||
|
|
||||||
var autoConfirmQty = _config.AutoConfirmCompanyChestQuantity;
|
var autoConfirmQty = _config.AutoConfirmCompanyChestQuantity;
|
||||||
if (ImGui.Checkbox("Auto-confirm Company Chest quantity prompt###AutoConfirmCompanyChestQty", ref autoConfirmQty))
|
if (ImGui.Checkbox("Auto-confirm Company Chest quantity prompt###AutoConfirmCompanyChestQty", ref autoConfirmQty))
|
||||||
{
|
{
|
||||||
@@ -98,6 +117,7 @@ public class QuickTransferWindow : Window, IDisposable
|
|||||||
ImGui.BulletText("Retainer + Saddlebags: Retainer → \"Add All to Saddlebag\", Saddlebags → \"Entrust to Retainer\"");
|
ImGui.BulletText("Retainer + Saddlebags: Retainer → \"Add All to Saddlebag\", Saddlebags → \"Entrust to Retainer\"");
|
||||||
ImGui.BulletText("Inventory + Armoury (no special container): (Gear) Inventory → \"Place in Armoury Chest\", Armoury → \"Return to Inventory\"");
|
ImGui.BulletText("Inventory + Armoury (no special container): (Gear) Inventory → \"Place in Armoury Chest\", Armoury → \"Return to Inventory\"");
|
||||||
ImGui.BulletText("Company Chest (FreeCompanyChest) open: Shift+RClick Inventory/Armoury deposits, Shift+RClick Company Chest withdraws (\"Remove\")");
|
ImGui.BulletText("Company Chest (FreeCompanyChest) open: Shift+RClick Inventory/Armoury deposits, Shift+RClick Company Chest withdraws (\"Remove\")");
|
||||||
|
ImGui.BulletText("Middle-Click: Sort the clicked container when a \"Sort\" menu entry exists. In Company Chest, MMB runs an organize pass (stack + compact).");
|
||||||
ImGui.BulletText("Use /qt or click 'Open Config' in plugin list to reopen this window");
|
ImGui.BulletText("Use /qt or click 'Open Config' in plugin list to reopen this window");
|
||||||
|
|
||||||
ImGui.Spacing();
|
ImGui.Spacing();
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ The plugin only clicks **existing** context menu options when they are available
|
|||||||
|
|
||||||
If an option is not present for the clicked item, **nothing happens**.
|
If an option is not present for the clicked item, **nothing happens**.
|
||||||
|
|
||||||
|
### Middle-Click Sort / Organize (MMB)
|
||||||
|
|
||||||
|
- For inventories that include a **Sort** entry in the item context menu, **middle-click an item** to auto-select **Sort** (without showing the menu).
|
||||||
|
- In the **Free Company Chest**, item context menus do not include Sort, so **middle-click** will run an **organize pass** (auto-stack + compact).
|
||||||
|
|
||||||
## Configuration Options
|
## Configuration Options
|
||||||
|
|
||||||
| Setting | Description | Default |
|
| Setting | Description | Default |
|
||||||
@@ -59,6 +64,8 @@ If an option is not present for the clicked item, **nothing happens**.
|
|||||||
| Enabled | Enable/disable the plugin | True |
|
| Enabled | Enable/disable the plugin | True |
|
||||||
| Debug Mode | Log transfer attempts to chat | False |
|
| Debug Mode | Log transfer attempts to chat | False |
|
||||||
| Transfer Cooldown | Milliseconds between transfers | 200 |
|
| Transfer Cooldown | Milliseconds between transfers | 200 |
|
||||||
|
| Enable Middle-Click Sort | Enable MMB sort behavior | True |
|
||||||
|
| Company Chest: Middle-Click Organize | Enable MMB organize (stack+compact) in FC chest | True |
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"Author": "flick",
|
"Author": "flick",
|
||||||
"Name": "QuickTransfer",
|
"Name": "QuickTransfer",
|
||||||
"InternalName": "QuickTransfer",
|
"InternalName": "QuickTransfer",
|
||||||
"AssemblyVersion": "1.0.1.0",
|
"AssemblyVersion": "1.0.2.0",
|
||||||
"Description": "Automate inventory transfers with Shift/Ctrl + Right-Click.",
|
"Description": "Automate inventory transfers with Shift/Ctrl + Right-Click.",
|
||||||
"ApplicableVersion": "any",
|
"ApplicableVersion": "any",
|
||||||
"RepoUrl": "https://github.com/Knack117/QuickTransfer",
|
"RepoUrl": "https://github.com/Knack117/QuickTransfer",
|
||||||
|
|||||||
Reference in New Issue
Block a user