using System; using Dalamud.Plugin.Services; using FFXIVClientStructs.FFXIV.Client.UI.Agent; using FFXIVClientStructs.FFXIV.Component.GUI; using AtkValueType = FFXIVClientStructs.FFXIV.Component.GUI.ValueType; namespace QuickTransfer; /// /// Handles context menu selection and matching logic. /// internal static unsafe class ContextMenuHandler { public enum ModifierMode { Shift, Ctrl, Alt, } // Access services through Plugin's static properties private static IGameGui GameGui => Plugin.GameGui; public enum AutoContextAction { AddAllToSaddlebag, RemoveAllFromSaddlebag, PlaceInArmouryChest, ReturnToInventory, EntrustToRetainer, RetrieveFromRetainer, RemoveFromCompanyChest, Split, Sort, Trade, Sell, HandOver, Use, } public static bool ContextLabelMatches(AutoContextAction desiredAction, string menuText) { var t = menuText.Trim(); static bool Has(string s, string needle) => s.Contains(needle, StringComparison.OrdinalIgnoreCase); return desiredAction switch { AutoContextAction.AddAllToSaddlebag => t.Equals("Add All to Saddlebag", StringComparison.OrdinalIgnoreCase) || (Has(t, "Add All") && Has(t, "Saddlebag")), AutoContextAction.RemoveAllFromSaddlebag => t.Equals("Remove All from Saddlebag", StringComparison.OrdinalIgnoreCase) || (Has(t, "Remove All") && Has(t, "Saddlebag")) || (Has(t, "Remove") && Has(t, "Saddlebag")) || t.Equals("Remove All", StringComparison.OrdinalIgnoreCase) || ((Has(t, "Retrieve") || Has(t, "Take out") || Has(t, "Take Out")) && Has(t, "Saddlebag")), AutoContextAction.PlaceInArmouryChest => t.Equals("Place in Armoury Chest", StringComparison.OrdinalIgnoreCase) || (Has(t, "Place") && (Has(t, "Armoury") || Has(t, "Armory")) && Has(t, "Chest")), AutoContextAction.ReturnToInventory => t.Equals("Return to Inventory", StringComparison.OrdinalIgnoreCase) || (Has(t, "Return") && Has(t, "Inventory")), AutoContextAction.EntrustToRetainer => t.Equals("Entrust to Retainer", StringComparison.OrdinalIgnoreCase) || (Has(t, "Entrust") && Has(t, "Retainer")), AutoContextAction.RetrieveFromRetainer => t.Equals("Retrieve from Retainer", StringComparison.OrdinalIgnoreCase) || (Has(t, "Retrieve") && Has(t, "Retainer")), AutoContextAction.RemoveFromCompanyChest => t.Equals("Remove", StringComparison.OrdinalIgnoreCase) || (Has(t, "Remove") && (Has(t, "Company") || Has(t, "Chest"))) || (Has(t, "Withdraw") && (Has(t, "Company") || Has(t, "Chest"))), AutoContextAction.Split => t.Equals("Split", StringComparison.OrdinalIgnoreCase) || t.StartsWith("Split", StringComparison.OrdinalIgnoreCase), AutoContextAction.Sort => t.Equals("Sort", StringComparison.OrdinalIgnoreCase) || t.StartsWith("Sort", StringComparison.OrdinalIgnoreCase), AutoContextAction.Trade => t.Equals("Trade", StringComparison.OrdinalIgnoreCase) || t.StartsWith("Trade", StringComparison.OrdinalIgnoreCase) || (Has(t, "Trade") && Has(t, "Item")), AutoContextAction.Sell => t.Equals("Sell", StringComparison.OrdinalIgnoreCase) || t.StartsWith("Sell", StringComparison.OrdinalIgnoreCase) || (Has(t, "Sell") && Has(t, "Item")), AutoContextAction.HandOver => t.Equals("Hand Over", StringComparison.OrdinalIgnoreCase) || (Has(t, "Hand") && Has(t, "Over")) || (Has(t, "Hand Over") && Has(t, "Item")), AutoContextAction.Use => t.Equals("Use", StringComparison.OrdinalIgnoreCase) || t.StartsWith("Use", StringComparison.OrdinalIgnoreCase), _ => false, }; } public static void CloseContextMenuAddon(AgentInventoryContext* agent, AtkUnitBase* contextMenuAddon) { try { agent->AgentInterface.Hide(); } catch { /* ignore */ } try { contextMenuAddon->Hide(false, true, 0); } catch { /* ignore */ } } public static bool TryAutoSelectAndClose( AgentInventoryContext* agent, AtkUnitBase* contextMenuAddon, ModifierMode mode, Configuration configuration, out string chosenText, out int chosenIndex, ref long pendingCloseContextMenuAtMs) { chosenText = string.Empty; chosenIndex = -1; // Single-pass: decode each label once, record first match per action. var foundAny = false; int removeIdx = -1, addIdx = -1, placeIdx = -1, returnIdx = -1, entrustIdx = -1, retrieveIdx = -1, companyRemoveIdx = -1, splitIdx = -1, tradeIdx = -1, sellIdx = -1, handOverIdx = -1, useIdx = -1; string? removeTxt = null, addTxt = null, placeTxt = null, returnTxt = null, entrustTxt = null, retrieveTxt = null, companyRemoveTxt = null, splitTxt = null, tradeTxt = null, sellTxt = null, handOverTxt = null, useTxt = null; 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 = AtkValueHelpers.ReadAtkValueString(param); if (string.IsNullOrWhiteSpace(text)) continue; foundAny = true; // Priority matters: we want the first matching index for each action. if (removeIdx < 0 && ContextLabelMatches(AutoContextAction.RemoveAllFromSaddlebag, text)) { removeIdx = i; removeTxt = text; continue; } if (companyRemoveIdx < 0 && ContextLabelMatches(AutoContextAction.RemoveFromCompanyChest, text)) { companyRemoveIdx = i; companyRemoveTxt = text; continue; } if (addIdx < 0 && ContextLabelMatches(AutoContextAction.AddAllToSaddlebag, text)) { addIdx = i; addTxt = text; continue; } if (placeIdx < 0 && ContextLabelMatches(AutoContextAction.PlaceInArmouryChest, text)) { placeIdx = i; placeTxt = text; continue; } if (returnIdx < 0 && ContextLabelMatches(AutoContextAction.ReturnToInventory, text)) { returnIdx = i; returnTxt = text; continue; } if (entrustIdx < 0 && ContextLabelMatches(AutoContextAction.EntrustToRetainer, text)) { entrustIdx = i; entrustTxt = text; continue; } if (retrieveIdx < 0 && ContextLabelMatches(AutoContextAction.RetrieveFromRetainer, text)) { retrieveIdx = i; retrieveTxt = text; continue; } if (splitIdx < 0 && ContextLabelMatches(AutoContextAction.Split, text)) { splitIdx = i; splitTxt = text; continue; } if (tradeIdx < 0 && ContextLabelMatches(AutoContextAction.Trade, text)) { tradeIdx = i; tradeTxt = text; } if (sellIdx < 0 && ContextLabelMatches(AutoContextAction.Sell, text)) { sellIdx = i; sellTxt = text; } if (handOverIdx < 0 && ContextLabelMatches(AutoContextAction.HandOver, text)) { handOverIdx = i; handOverTxt = text; } if (useIdx < 0 && ContextLabelMatches(AutoContextAction.Use, text)) { useIdx = i; useTxt = text; } } if (!foundAny) return false; var saddlebagOpen = InventoryHelpers.IsSaddlebagOpen(); var retainerOpen = InventoryHelpers.IsRetainerOpen(); var companyChestOpen = InventoryHelpers.IsCompanyChestOpen(); var tradeOpen = InventoryHelpers.IsTradeOpen(); var vendorOpen = InventoryHelpers.IsVendorOpen(); // Choose the best action that exists in the menu. (int idx, string? txt) chosen; if (mode == ModifierMode.Alt) { chosen = splitIdx >= 0 ? (splitIdx, splitTxt) : (-1, (string?)null); } else if (mode == ModifierMode.Shift && vendorOpen && configuration.EnableVendorQuickSell) { // Vendor shop: prioritize Sell action when vendor is open chosen = sellIdx >= 0 ? (sellIdx, sellTxt) : (-1, (string?)null); } else if (mode == ModifierMode.Shift && tradeOpen) { // Trade window: prioritize Trade action when Trade window is open chosen = tradeIdx >= 0 ? (tradeIdx, tradeTxt) : (-1, (string?)null); } else if (mode == ModifierMode.Shift && companyChestOpen && configuration.EnableCompanyChest) { chosen = companyRemoveIdx >= 0 ? (companyRemoveIdx, companyRemoveTxt) : (-1, (string?)null); } else if (mode == ModifierMode.Shift && handOverIdx >= 0) { // Quest/dialogue: hand over item to NPC chosen = (handOverIdx, handOverTxt); } else if (mode == ModifierMode.Shift && !saddlebagOpen && !retainerOpen && !companyChestOpen && !tradeOpen && !vendorOpen && useIdx >= 0 && configuration.EnableQuickUse) { // No other inventories open: quick Use for usable items (potions, food, etc.) chosen = (useIdx, useTxt); } else if (mode == ModifierMode.Ctrl) { chosen = returnIdx >= 0 ? (returnIdx, returnTxt) : placeIdx >= 0 ? (placeIdx, placeTxt) : (-1, (string?)null); } else if (retainerOpen) { if (saddlebagOpen) { // Retainer <-> Saddlebag: // - Retainer item: Add All to Saddlebag // - Saddlebag item: Entrust to Retainer chosen = addIdx >= 0 ? (addIdx, addTxt) : entrustIdx >= 0 ? (entrustIdx, entrustTxt) : // last-resort fallback removeIdx >= 0 ? (removeIdx, removeTxt) : (-1, (string?)null); } else { // Retainer <-> Player (Inventory/Armoury): // - Retainer item: Retrieve from Retainer // - Player item: Entrust to Retainer chosen = retrieveIdx >= 0 ? (retrieveIdx, retrieveTxt) : entrustIdx >= 0 ? (entrustIdx, entrustTxt) : (-1, (string?)null); } } else if (saddlebagOpen) { chosen = removeIdx >= 0 ? (removeIdx, removeTxt) : addIdx >= 0 ? (addIdx, addTxt) : (-1, (string?)null); } else { chosen = placeIdx >= 0 ? (placeIdx, placeTxt) : returnIdx >= 0 ? (returnIdx, returnTxt) : (-1, (string?)null); } if (chosen.idx < 0 || string.IsNullOrWhiteSpace(chosen.txt)) return false; AtkValueHelpers.GenerateCallback(contextMenuAddon, 0, chosen.idx, 0U, 0, 0); // Some actions (notably Split and Trade) can be cancelled if we close the menu immediately. // Delay the close slightly to allow the follow-up UI (InputNumeric) to spawn. if (chosen.txt != null && (ContextLabelMatches(AutoContextAction.Split, chosen.txt) || ContextLabelMatches(AutoContextAction.Trade, chosen.txt))) { // Don't close immediately: on some setups this cancels the action before InputNumeric opens. // We'll keep the menu invisible (via suppression) and close it later as a cleanup. pendingCloseContextMenuAtMs = Environment.TickCount64 + 3000; } else { CloseContextMenuAddon(agent, contextMenuAddon); } chosenText = chosen.txt!; chosenIndex = chosen.idx; return true; } public static bool TrySelectSortAndClose(AgentInventoryContext* agent, AtkUnitBase* contextMenuAddon, out string chosenText, out int chosenIndex) { chosenText = string.Empty; chosenIndex = -1; var undoSortIdx = -1; string? undoSortText = null; 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 = AtkValueHelpers.ReadAtkValueString(param); if (string.IsNullOrWhiteSpace(text)) continue; // If Sort isn't present (because the container is already sorted), the menu often contains "Undo Sort" instead. // We treat that as "already sorted" and do nothing (closing the menu). if (undoSortIdx < 0 && text.Trim().Equals("Undo Sort", StringComparison.OrdinalIgnoreCase)) { undoSortIdx = i; undoSortText = text; } if (!ContextLabelMatches(AutoContextAction.Sort, text)) continue; AtkValueHelpers.GenerateCallback(contextMenuAddon, 0, i, 0U, 0, 0); CloseContextMenuAddon(agent, contextMenuAddon); chosenText = text; chosenIndex = i; return true; } // No "Sort" entry. If "Undo Sort" exists, we're already sorted; close the menu without changing state. if (undoSortIdx >= 0) { try { CloseContextMenuAddon(agent, contextMenuAddon); } catch { /* ignore */ } chosenText = "Already sorted"; chosenIndex = -1; return true; } return false; } }