From 5f62c703bff4137a1f887553fc1e0bd932d6dc6e Mon Sep 17 00:00:00 2001 From: Asriel Camora Date: Thu, 29 Feb 2024 15:15:02 -0800 Subject: [PATCH] Add IContextMenu service (#1682) --- Dalamud/Game/Gui/ContextMenu/ContextMenu.cs | 560 ++++++++++++++++++ .../Game/Gui/ContextMenu/ContextMenuType.cs | 18 + Dalamud/Game/Gui/ContextMenu/MenuArgs.cs | 77 +++ Dalamud/Game/Gui/ContextMenu/MenuItem.cs | 91 +++ .../Gui/ContextMenu/MenuItemClickedArgs.cs | 44 ++ .../Game/Gui/ContextMenu/MenuOpenedArgs.cs | 34 ++ Dalamud/Game/Gui/ContextMenu/MenuTarget.cs | 9 + .../Game/Gui/ContextMenu/MenuTargetDefault.cs | 67 +++ .../Gui/ContextMenu/MenuTargetInventory.cs | 36 ++ Dalamud/Game/Inventory/GameInventoryItem.cs | 12 +- .../Structures/InfoProxy/CharacterData.cs | 197 ++++++ .../AgingSteps/ContextMenuAgingStep.cs | 333 ++++++----- Dalamud/Plugin/Services/IContextMenu.cs | 37 ++ Dalamud/Utility/EventHandlerExtensions.cs | 18 + 14 files changed, 1387 insertions(+), 146 deletions(-) create mode 100644 Dalamud/Game/Gui/ContextMenu/ContextMenu.cs create mode 100644 Dalamud/Game/Gui/ContextMenu/ContextMenuType.cs create mode 100644 Dalamud/Game/Gui/ContextMenu/MenuArgs.cs create mode 100644 Dalamud/Game/Gui/ContextMenu/MenuItem.cs create mode 100644 Dalamud/Game/Gui/ContextMenu/MenuItemClickedArgs.cs create mode 100644 Dalamud/Game/Gui/ContextMenu/MenuOpenedArgs.cs create mode 100644 Dalamud/Game/Gui/ContextMenu/MenuTarget.cs create mode 100644 Dalamud/Game/Gui/ContextMenu/MenuTargetDefault.cs create mode 100644 Dalamud/Game/Gui/ContextMenu/MenuTargetInventory.cs create mode 100644 Dalamud/Game/Network/Structures/InfoProxy/CharacterData.cs create mode 100644 Dalamud/Plugin/Services/IContextMenu.cs diff --git a/Dalamud/Game/Gui/ContextMenu/ContextMenu.cs b/Dalamud/Game/Gui/ContextMenu/ContextMenu.cs new file mode 100644 index 000000000..65c9b2760 --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenu/ContextMenu.cs @@ -0,0 +1,560 @@ +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +using Dalamud.Game.Text; +using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Hooking; +using Dalamud.IoC; +using Dalamud.IoC.Internal; +using Dalamud.Logging.Internal; +using Dalamud.Memory; +using Dalamud.Plugin.Services; +using Dalamud.Utility; + +using FFXIVClientStructs.FFXIV.Client.System.Memory; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using FFXIVClientStructs.FFXIV.Component.GUI; +using FFXIVClientStructs.Interop; + +using ValueType = FFXIVClientStructs.FFXIV.Component.GUI.ValueType; + +namespace Dalamud.Game.Gui.ContextMenu; + +/// +/// This class handles interacting with the game's (right-click) context menu. +/// +[InterfaceVersion("1.0")] +[ServiceManager.EarlyLoadedService] +internal sealed unsafe class ContextMenu : IDisposable, IServiceType, IContextMenu +{ + private static readonly ModuleLog Log = new("ContextMenu"); + + private readonly Hook raptureAtkModuleOpenAddonByAgentHook; + private readonly Hook addonContextMenuOnMenuSelectedHook; + private readonly RaptureAtkModuleOpenAddonDelegate raptureAtkModuleOpenAddon; + + [ServiceManager.ServiceConstructor] + private ContextMenu() + { + this.raptureAtkModuleOpenAddonByAgentHook = Hook.FromAddress((nint)RaptureAtkModule.Addresses.OpenAddonByAgent.Value, this.RaptureAtkModuleOpenAddonByAgentDetour); + this.addonContextMenuOnMenuSelectedHook = Hook.FromAddress((nint)AddonContextMenu.StaticVTable.OnMenuSelected, this.AddonContextMenuOnMenuSelectedDetour); + this.raptureAtkModuleOpenAddon = Marshal.GetDelegateForFunctionPointer((nint)RaptureAtkModule.Addresses.OpenAddon.Value); + + this.raptureAtkModuleOpenAddonByAgentHook.Enable(); + this.addonContextMenuOnMenuSelectedHook.Enable(); + } + + private unsafe delegate ushort RaptureAtkModuleOpenAddonByAgentDelegate(RaptureAtkModule* module, byte* addonName, AtkUnitBase* addon, int valueCount, AtkValue* values, AgentInterface* agent, nint a7, ushort parentAddonId); + + private unsafe delegate bool AddonContextMenuOnMenuSelectedDelegate(AddonContextMenu* addon, int selectedIdx, byte a3); + + private unsafe delegate ushort RaptureAtkModuleOpenAddonDelegate(RaptureAtkModule* a1, uint addonNameId, uint valueCount, AtkValue* values, AgentInterface* parentAgent, ulong unk, ushort parentAddonId, int unk2); + + /// + public event IContextMenu.OnMenuOpenedDelegate OnMenuOpened; + + private Dictionary> MenuItems { get; } = new(); + + private object MenuItemsLock { get; } = new(); + + private AgentInterface* SelectedAgent { get; set; } + + private ContextMenuType? SelectedMenuType { get; set; } + + private List? SelectedItems { get; set; } + + private HashSet SelectedEventInterfaces { get; } = new(); + + private AtkUnitBase* SelectedParentAddon { get; set; } + + // -1 -> -inf: native items + // 0 -> inf: selected items + private List MenuCallbackIds { get; } = new(); + + private IReadOnlyList? SubmenuItems { get; set; } + + /// + public void Dispose() + { + var manager = RaptureAtkUnitManager.Instance(); + var menu = manager->GetAddonByName("ContextMenu"); + var submenu = manager->GetAddonByName("AddonContextSub"); + if (menu->IsVisible) + menu->FireCallbackInt(-1); + if (submenu->IsVisible) + submenu->FireCallbackInt(-1); + + this.raptureAtkModuleOpenAddonByAgentHook.Dispose(); + this.addonContextMenuOnMenuSelectedHook.Dispose(); + } + + /// + public void AddMenuItem(ContextMenuType menuType, MenuItem item) + { + lock (this.MenuItemsLock) + { + if (!this.MenuItems.TryGetValue(menuType, out var items)) + this.MenuItems[menuType] = items = new(); + items.Add(item); + } + } + + /// + public bool RemoveMenuItem(ContextMenuType menuType, MenuItem item) + { + lock (this.MenuItemsLock) + { + if (!this.MenuItems.TryGetValue(menuType, out var items)) + return false; + return items.Remove(item); + } + } + + private AtkValue* ExpandContextMenuArray(Span oldValues, int newSize) + { + // if the array has enough room, don't reallocate + if (oldValues.Length >= newSize) + return (AtkValue*)Unsafe.AsPointer(ref oldValues[0]); + + var size = (sizeof(AtkValue) * newSize) + 8; + var newArray = (nint)IMemorySpace.GetUISpace()->Malloc((ulong)size, 0); + if (newArray == nint.Zero) + throw new OutOfMemoryException(); + NativeMemory.Fill((void*)newArray, (nuint)size, 0); + + *(ulong*)newArray = (ulong)newSize; + + // copy old memory if existing + if (!oldValues.IsEmpty) + oldValues.CopyTo(new((void*)(newArray + 8), oldValues.Length)); + + return (AtkValue*)(newArray + 8); + } + + private void FreeExpandedContextMenuArray(AtkValue* newValues, int newSize) => + IMemorySpace.Free((void*)((nint)newValues - 8), (ulong)((newSize * sizeof(AtkValue)) + 8)); + + private AtkValue* CreateEmptySubmenuContextMenuArray(SeString name, int x, int y, out int valueCount) + { + // 0: UInt = ContextItemCount + // 1: String = Name + // 2: Int = PositionX + // 3: Int = PositionY + // 4: Bool = false + // 5: UInt = ContextItemSubmenuMask + // 6: UInt = ReturnArrowMask (_gap_0x6BC ? 1 << (ContextItemCount - 1) : 0) + // 7: UInt = 1 + + valueCount = 8; + var values = this.ExpandContextMenuArray(Span.Empty, valueCount); + values[0].ChangeType(ValueType.UInt); + values[0].UInt = 0; + values[1].ChangeType(ValueType.String); + values[1].SetString(name.Encode().NullTerminate()); + values[2].ChangeType(ValueType.Int); + values[2].Int = x; + values[3].ChangeType(ValueType.Int); + values[3].Int = y; + values[4].ChangeType(ValueType.Bool); + values[4].Byte = 0; + values[5].ChangeType(ValueType.UInt); + values[5].UInt = 0; + values[6].ChangeType(ValueType.UInt); + values[6].UInt = 0; + values[7].ChangeType(ValueType.UInt); + values[7].UInt = 1; + return values; + } + + private void SetupGenericMenu(int headerCount, int sizeHeaderIdx, int returnHeaderIdx, int submenuHeaderIdx, IReadOnlyList items, ref int valueCount, ref AtkValue* values) + { + var itemsWithIdx = items.Select((item, idx) => (item, idx)).OrderBy(i => i.item.Priority); + var prefixItems = itemsWithIdx.Where(i => i.item.Priority < 0).ToArray(); + var suffixItems = itemsWithIdx.Where(i => i.item.Priority >= 0).ToArray(); + + var nativeMenuSize = (int)values[sizeHeaderIdx].UInt; + var prefixMenuSize = prefixItems.Length; + var suffixMenuSize = suffixItems.Length; + + var hasGameDisabled = valueCount - headerCount - nativeMenuSize > 0; + + var hasCustomDisabled = items.Any(item => !item.IsEnabled); + var hasAnyDisabled = hasGameDisabled || hasCustomDisabled; + + values = this.ExpandContextMenuArray( + new(values, valueCount), + valueCount = (nativeMenuSize + items.Count) * (hasAnyDisabled ? 2 : 1) + headerCount); + var offsetData = new Span(values, headerCount); + var nameData = new Span(values + headerCount, nativeMenuSize + items.Count); + var disabledData = hasAnyDisabled ? new Span(values + headerCount + nativeMenuSize + items.Count, nativeMenuSize + items.Count) : Span.Empty; + + var returnMask = offsetData[returnHeaderIdx].UInt; + var submenuMask = offsetData[submenuHeaderIdx].UInt; + + nameData[..nativeMenuSize].CopyTo(nameData.Slice(prefixMenuSize, nativeMenuSize)); + if (hasAnyDisabled) + { + if (hasGameDisabled) + { + // copy old disabled data + var oldDisabledData = new Span(values + headerCount + nativeMenuSize, nativeMenuSize); + oldDisabledData.CopyTo(disabledData.Slice(prefixMenuSize, nativeMenuSize)); + } + else + { + // enable all + for (var i = prefixMenuSize; i < prefixMenuSize + nativeMenuSize; ++i) + { + disabledData[i].ChangeType(ValueType.Int); + disabledData[i].Int = 0; + } + } + } + + returnMask <<= prefixMenuSize; + submenuMask <<= prefixMenuSize; + + void FillData(Span disabledData, Span nameData, int i, MenuItem item, int idx) + { + this.MenuCallbackIds.Add(idx); + + if (hasAnyDisabled) + { + disabledData[i].ChangeType(ValueType.Int); + disabledData[i].Int = item.IsEnabled ? 0 : 1; + } + + if (item.IsReturn) + returnMask |= 1u << i; + if (item.IsSubmenu) + submenuMask |= 1u << i; + + nameData[i].ChangeType(ValueType.String); + nameData[i].SetString(item.PrefixedName.Encode().NullTerminate()); + } + + for (var i = 0; i < prefixMenuSize; ++i) + { + var (item, idx) = prefixItems[i]; + FillData(disabledData, nameData, i, item, idx); + } + + this.MenuCallbackIds.AddRange(Enumerable.Range(0, nativeMenuSize).Select(i => -i - 1)); + + for (var i = prefixMenuSize + nativeMenuSize; i < prefixMenuSize + nativeMenuSize + suffixMenuSize; ++i) + { + var (item, idx) = suffixItems[i - prefixMenuSize - nativeMenuSize]; + FillData(disabledData, nameData, i, item, idx); + } + + offsetData[returnHeaderIdx].UInt = returnMask; + offsetData[submenuHeaderIdx].UInt = submenuMask; + + offsetData[sizeHeaderIdx].UInt += (uint)items.Count; + } + + private void SetupContextMenu(IReadOnlyList items, ref int valueCount, ref AtkValue* values) + { + // 0: UInt = Item Count + // 1: UInt = 0 (probably window name, just unused) + // 2: UInt = Return Mask (?) + // 3: UInt = Submenu Mask + // 4: UInt = OpenAtCursorPosition ? 2 : 1 + // 5: UInt = 0 + // 6: UInt = 0 + + foreach (var item in items) + { + if (!item.Prefix.HasValue) + { + item.PrefixChar = 'D'; + item.PrefixColor = 539; + Log.Warning($"Menu item \"{item.Name}\" has no prefix, defaulting to Dalamud's. Menu items outside of a submenu must have a prefix."); + } + } + + this.SetupGenericMenu(7, 0, 2, 3, items, ref valueCount, ref values); + } + + private void SetupContextSubMenu(IReadOnlyList items, ref int valueCount, ref AtkValue* values) + { + // 0: UInt = ContextItemCount + // 1: skipped? + // 2: Int = PositionX + // 3: Int = PositionY + // 4: Bool = false + // 5: UInt = ContextItemSubmenuMask + // 6: UInt = _gap_0x6BC ? 1 << (ContextItemCount - 1) : 0 + // 7: UInt = 1 + + this.SetupGenericMenu(8, 0, 6, 5, items, ref valueCount, ref values); + } + + private ushort RaptureAtkModuleOpenAddonByAgentDetour(RaptureAtkModule* module, byte* addonName, AtkUnitBase* addon, int valueCount, AtkValue* values, AgentInterface* agent, nint a7, ushort parentAddonId) + { + var oldValues = values; + + if (MemoryHelper.EqualsZeroTerminatedString("ContextMenu", (nint)addonName)) + { + this.MenuCallbackIds.Clear(); + this.SelectedAgent = agent; + this.SelectedParentAddon = module->RaptureAtkUnitManager.GetAddonById(parentAddonId); + this.SelectedEventInterfaces.Clear(); + if (this.SelectedAgent == AgentInventoryContext.Instance()) + { + this.SelectedMenuType = ContextMenuType.Inventory; + } + else if (this.SelectedAgent == AgentContext.Instance()) + { + this.SelectedMenuType = ContextMenuType.Default; + + var menu = AgentContext.Instance()->CurrentContextMenu; + var handlers = new Span>(menu->EventHandlerArray, 32); + var ids = new Span(menu->EventIdArray, 32); + var count = (int)values[0].UInt; + handlers = handlers.Slice(7, count); + ids = ids.Slice(7, count); + for (var i = 0; i < count; ++i) + { + if (ids[i] <= 106) + continue; + this.SelectedEventInterfaces.Add((nint)handlers[i].Value); + } + } + else + { + this.SelectedMenuType = null; + } + + this.SubmenuItems = null; + + if (this.SelectedMenuType is { } menuType) + { + lock (this.MenuItemsLock) + { + if (this.MenuItems.TryGetValue(menuType, out var items)) + this.SelectedItems = new(items); + else + this.SelectedItems = new(); + } + + var args = new MenuOpenedArgs(this.SelectedItems.Add, this.SelectedParentAddon, this.SelectedAgent, this.SelectedMenuType.Value, this.SelectedEventInterfaces); + this.OnMenuOpened?.InvokeSafely(args); + this.SelectedItems = this.FixupMenuList(this.SelectedItems, (int)values[0].UInt); + this.SetupContextMenu(this.SelectedItems, ref valueCount, ref values); + Log.Verbose($"Opening {this.SelectedMenuType} context menu with {this.SelectedItems.Count} custom items."); + } + else + { + this.SelectedItems = null; + } + } + else if (MemoryHelper.EqualsZeroTerminatedString("AddonContextSub", (nint)addonName)) + { + this.MenuCallbackIds.Clear(); + if (this.SubmenuItems != null) + { + this.SubmenuItems = this.FixupMenuList(this.SubmenuItems.ToList(), (int)values[0].UInt); + + this.SetupContextSubMenu(this.SubmenuItems, ref valueCount, ref values); + Log.Verbose($"Opening {this.SelectedMenuType} submenu with {this.SubmenuItems.Count} custom items."); + } + } + + var ret = this.raptureAtkModuleOpenAddonByAgentHook.Original(module, addonName, addon, valueCount, values, agent, a7, parentAddonId); + if (values != oldValues) + this.FreeExpandedContextMenuArray(values, valueCount); + return ret; + } + + private List FixupMenuList(List items, int nativeMenuSize) + { + // The in game menu actually supports 32 items, but the last item can't have a visible submenu arrow. + // As such, we'll only work with 31 items. + const int MaxMenuItems = 31; + if (items.Count + nativeMenuSize > MaxMenuItems) + { + Log.Warning($"Menu size exceeds {MaxMenuItems} items, truncating."); + var orderedItems = items.OrderBy(i => i.Priority).ToArray(); + var newItems = orderedItems[..(MaxMenuItems - nativeMenuSize - 1)]; + var submenuItems = orderedItems[(MaxMenuItems - nativeMenuSize - 1)..]; + return newItems.Append(new MenuItem + { + Prefix = SeIconChar.BoxedLetterD, + PrefixColor = 539, + IsSubmenu = true, + Priority = int.MaxValue, + Name = $"See More ({submenuItems.Length})", + OnClicked = a => a.OpenSubmenu(submenuItems), + }).ToList(); + } + + return items; + } + + private void OpenSubmenu(SeString name, IReadOnlyList submenuItems, int posX, int posY) + { + if (submenuItems.Count == 0) + throw new ArgumentException("Submenu must not be empty", nameof(submenuItems)); + + this.SubmenuItems = submenuItems; + + var module = RaptureAtkModule.Instance(); + var values = this.CreateEmptySubmenuContextMenuArray(name, posX, posY, out var valueCount); + + switch (this.SelectedMenuType) + { + case ContextMenuType.Default: + { + var ownerAddonId = ((AgentContext*)this.SelectedAgent)->OwnerAddon; + this.raptureAtkModuleOpenAddon(module, 445, (uint)valueCount, values, this.SelectedAgent, 71, checked((ushort)ownerAddonId), 4); + break; + } + + case ContextMenuType.Inventory: + { + var ownerAddonId = ((AgentInventoryContext*)this.SelectedAgent)->OwnerAddonId; + this.raptureAtkModuleOpenAddon(module, 445, (uint)valueCount, values, this.SelectedAgent, 0, checked((ushort)ownerAddonId), 4); + break; + } + + default: + Log.Warning($"Unknown context menu type (agent: {(nint)this.SelectedAgent}, cannot open submenu"); + break; + } + + this.FreeExpandedContextMenuArray(values, valueCount); + } + + private bool AddonContextMenuOnMenuSelectedDetour(AddonContextMenu* addon, int selectedIdx, byte a3) + { + var items = this.SubmenuItems ?? this.SelectedItems; + if (items == null) + goto original; + if (this.MenuCallbackIds.Count == 0) + goto original; + if (selectedIdx < 0) + goto original; + if (selectedIdx >= this.MenuCallbackIds.Count) + goto original; + + var callbackId = this.MenuCallbackIds[selectedIdx]; + + if (callbackId < 0) + { + selectedIdx = -callbackId - 1; + goto original; + } + else + { + var item = items[callbackId]; + var openedSubmenu = false; + + try + { + if (item.OnClicked == null) + throw new InvalidOperationException("Item has no OnClicked handler"); + item.OnClicked.InvokeSafely(new( + (name, items) => + { + short x, y; + addon->AtkUnitBase.GetPosition(&x, &y); + this.OpenSubmenu(name ?? item.Name, items, x, y); + openedSubmenu = true; + }, + this.SelectedParentAddon, + this.SelectedAgent, + this.SelectedMenuType.Value, + this.SelectedEventInterfaces)); + } + catch (Exception e) + { + Log.Error(e, "Error while handling context menu click"); + } + + // Close with clicky sound + if (!openedSubmenu) + addon->AtkUnitBase.FireCallbackInt(-2); + return false; + } + +original: + // Eventually handled by inventorycontext here: 14022BBD0 (6.51) + return this.addonContextMenuOnMenuSelectedHook.Original(addon, selectedIdx, a3); + } +} + +/// +/// Plugin-scoped version of a service. +/// +[PluginInterface] +[InterfaceVersion("1.0")] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +internal class ContextMenuPluginScoped : IDisposable, IServiceType, IContextMenu +{ + [ServiceManager.ServiceDependency] + private readonly ContextMenu parentService = Service.Get(); + + private ContextMenuPluginScoped() + { + this.parentService.OnMenuOpened += this.OnMenuOpenedForward; + } + + /// + public event IContextMenu.OnMenuOpenedDelegate OnMenuOpened; + + private Dictionary> MenuItems { get; } = new(); + + private object MenuItemsLock { get; } = new(); + + /// + public void Dispose() + { + this.parentService.OnMenuOpened -= this.OnMenuOpenedForward; + + this.OnMenuOpened = null; + + lock (this.MenuItemsLock) + { + foreach (var (menuType, items) in this.MenuItems) + { + foreach (var item in items) + this.parentService.RemoveMenuItem(menuType, item); + } + } + } + + /// + public void AddMenuItem(ContextMenuType menuType, MenuItem item) + { + lock (this.MenuItemsLock) + { + if (!this.MenuItems.TryGetValue(menuType, out var items)) + this.MenuItems[menuType] = items = new(); + items.Add(item); + } + + this.parentService.AddMenuItem(menuType, item); + } + + /// + public bool RemoveMenuItem(ContextMenuType menuType, MenuItem item) + { + lock (this.MenuItemsLock) + { + if (this.MenuItems.TryGetValue(menuType, out var items)) + items.Remove(item); + } + + return this.parentService.RemoveMenuItem(menuType, item); + } + + private void OnMenuOpenedForward(MenuOpenedArgs args) => + this.OnMenuOpened?.Invoke(args); +} diff --git a/Dalamud/Game/Gui/ContextMenu/ContextMenuType.cs b/Dalamud/Game/Gui/ContextMenu/ContextMenuType.cs new file mode 100644 index 000000000..2cd52a4b7 --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenu/ContextMenuType.cs @@ -0,0 +1,18 @@ +namespace Dalamud.Game.Gui.ContextMenu; + +/// +/// The type of context menu. +/// Each one has a different associated . +/// +public enum ContextMenuType +{ + /// + /// The default context menu. + /// + Default, + + /// + /// The inventory context menu. Used when right-clicked on an item. + /// + Inventory, +} diff --git a/Dalamud/Game/Gui/ContextMenu/MenuArgs.cs b/Dalamud/Game/Gui/ContextMenu/MenuArgs.cs new file mode 100644 index 000000000..d0d8ec0dc --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenu/MenuArgs.cs @@ -0,0 +1,77 @@ +using System.Collections.Generic; + +using Dalamud.Memory; +using Dalamud.Plugin.Services; + +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace Dalamud.Game.Gui.ContextMenu; + +/// +/// Base class for menu args. +/// +public abstract unsafe class MenuArgs +{ + private IReadOnlySet? eventInterfaces; + + /// + /// Initializes a new instance of the class. + /// + /// Addon associated with the context menu. + /// Agent associated with the context menu. + /// The type of context menu. + /// List of AtkEventInterfaces associated with the context menu. + protected internal MenuArgs(AtkUnitBase* addon, AgentInterface* agent, ContextMenuType type, IReadOnlySet? eventInterfaces) + { + this.AddonName = addon != null ? MemoryHelper.ReadString((nint)addon->Name, 32) : null; + this.AddonPtr = (nint)addon; + this.AgentPtr = (nint)agent; + this.MenuType = type; + this.eventInterfaces = eventInterfaces; + this.Target = type switch + { + ContextMenuType.Default => new MenuTargetDefault((AgentContext*)agent), + ContextMenuType.Inventory => new MenuTargetInventory((AgentInventoryContext*)agent), + _ => throw new ArgumentException("Invalid context menu type", nameof(type)), + }; + } + + /// + /// Gets the name of the addon that opened the context menu. + /// + public string? AddonName { get; } + + /// + /// Gets the memory pointer of the addon that opened the context menu. + /// + public nint AddonPtr { get; } + + /// + /// Gets the memory pointer of the agent that opened the context menu. + /// + public nint AgentPtr { get; } + + /// + /// Gets the type of the context menu. + /// + public ContextMenuType MenuType { get; } + + /// + /// Gets the target info of the context menu. The actual type depends on . + /// signifies a . + /// signifies a . + /// + public MenuTarget Target { get; } + + /// + /// Gets a list of AtkEventInterface pointers associated with the context menu. + /// Only available with . + /// Almost always an agent pointer. You can use this to find out what type of context menu it is. + /// + /// Thrown when the context menu is not a . + public IReadOnlySet EventInterfaces => + this.MenuType != ContextMenuType.Default ? + this.eventInterfaces : + throw new InvalidOperationException("Not a default context menu"); +} diff --git a/Dalamud/Game/Gui/ContextMenu/MenuItem.cs b/Dalamud/Game/Gui/ContextMenu/MenuItem.cs new file mode 100644 index 000000000..fdeb64d13 --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenu/MenuItem.cs @@ -0,0 +1,91 @@ +using Dalamud.Game.Text; +using Dalamud.Game.Text.SeStringHandling; + +using Lumina.Excel.GeneratedSheets; + +namespace Dalamud.Game.Gui.ContextMenu; + +/// +/// A menu item that can be added to a context menu. +/// +public sealed record MenuItem +{ + /// + /// Gets or sets the display name of the menu item. + /// + public SeString Name { get; set; } = SeString.Empty; + + /// + /// Gets or sets the prefix attached to the beginning of . + /// + public SeIconChar? Prefix { get; set; } + + /// + /// Sets the character to prefix the with. Will be converted into a fancy boxed letter icon. Must be an uppercase letter. + /// + /// must be an uppercase letter. + public char? PrefixChar + { + set + { + if (value is { } prefix) + { + if (!char.IsAsciiLetterUpper(prefix)) + throw new ArgumentException("Prefix must be an uppercase letter", nameof(value)); + + this.Prefix = SeIconChar.BoxedLetterA + prefix - 'A'; + } + else + { + this.Prefix = null; + } + } + } + + /// + /// Gets or sets the color of the . Specifies a row id. + /// + public ushort PrefixColor { get; set; } + + /// + /// Gets or sets the callback to be invoked when the menu item is clicked. + /// + public Action? OnClicked { get; set; } + + /// + /// Gets or sets the priority (or order) with which the menu item should be displayed in descending order. + /// Priorities below 0 will be displayed above the native menu items. + /// Other priorities will be displayed below the native menu items. + /// + public int Priority { get; set; } + + /// + /// Gets or sets a value indicating whether the menu item is enabled. + /// Disabled items will be faded and cannot be clicked on. + /// + public bool IsEnabled { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the menu item is a submenu. + /// This value is purely visual. Submenu items will have an arrow to its right. + /// + public bool IsSubmenu { get; set; } + + /// + /// Gets or sets a value indicating whether the menu item is a return item. + /// This value is purely visual. Return items will have a back arrow to its left. + /// If both and are true, the return arrow will take precedence. + /// + public bool IsReturn { get; set; } + + /// + /// Gets the name with the given prefix. + /// + internal SeString PrefixedName => + this.Prefix is { } prefix + ? new SeStringBuilder() + .AddUiForeground($"{prefix.ToIconString()} ", this.PrefixColor) + .Append(this.Name) + .Build() + : this.Name; +} diff --git a/Dalamud/Game/Gui/ContextMenu/MenuItemClickedArgs.cs b/Dalamud/Game/Gui/ContextMenu/MenuItemClickedArgs.cs new file mode 100644 index 000000000..bec16590d --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenu/MenuItemClickedArgs.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; + +using Dalamud.Game.Text.SeStringHandling; + +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace Dalamud.Game.Gui.ContextMenu; + +/// +/// Callback args used when a menu item is clicked. +/// +public sealed unsafe class MenuItemClickedArgs : MenuArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// Callback for opening a submenu. + /// Addon associated with the context menu. + /// Agent associated with the context menu. + /// The type of context menu. + /// List of AtkEventInterfaces associated with the context menu. + internal MenuItemClickedArgs(Action> openSubmenu, AtkUnitBase* addon, AgentInterface* agent, ContextMenuType type, IReadOnlySet eventInterfaces) + : base(addon, agent, type, eventInterfaces) + { + this.OnOpenSubmenu = openSubmenu; + } + + private Action> OnOpenSubmenu { get; } + + /// + /// Opens a submenu with the given name and items. + /// + /// The name of the submenu, displayed at the top. + /// The items to display in the submenu. + public void OpenSubmenu(SeString name, IReadOnlyList items) => + this.OnOpenSubmenu(name, items); + + /// + /// Opens a submenu with the given items. + /// + /// The items to display in the submenu. + public void OpenSubmenu(IReadOnlyList items) => + this.OnOpenSubmenu(null, items); +} diff --git a/Dalamud/Game/Gui/ContextMenu/MenuOpenedArgs.cs b/Dalamud/Game/Gui/ContextMenu/MenuOpenedArgs.cs new file mode 100644 index 000000000..de3347f63 --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenu/MenuOpenedArgs.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace Dalamud.Game.Gui.ContextMenu; + +/// +/// Callback args used when a menu item is opened. +/// +public sealed unsafe class MenuOpenedArgs : MenuArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// Callback for adding a custom menu item. + /// Addon associated with the context menu. + /// Agent associated with the context menu. + /// The type of context menu. + /// List of AtkEventInterfaces associated with the context menu. + internal MenuOpenedArgs(Action addMenuItem, AtkUnitBase* addon, AgentInterface* agent, ContextMenuType type, IReadOnlySet eventInterfaces) + : base(addon, agent, type, eventInterfaces) + { + this.OnAddMenuItem = addMenuItem; + } + + private Action OnAddMenuItem { get; } + + /// + /// Adds a custom menu item to the context menu. + /// + /// The menu item to add. + public void AddMenuItem(MenuItem item) => + this.OnAddMenuItem(item); +} diff --git a/Dalamud/Game/Gui/ContextMenu/MenuTarget.cs b/Dalamud/Game/Gui/ContextMenu/MenuTarget.cs new file mode 100644 index 000000000..c486a3b9b --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenu/MenuTarget.cs @@ -0,0 +1,9 @@ +namespace Dalamud.Game.Gui.ContextMenu; + +/// +/// Base class for contexts. +/// Discriminated based on . +/// +public abstract class MenuTarget +{ +} diff --git a/Dalamud/Game/Gui/ContextMenu/MenuTargetDefault.cs b/Dalamud/Game/Gui/ContextMenu/MenuTargetDefault.cs new file mode 100644 index 000000000..d87bc36b6 --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenu/MenuTargetDefault.cs @@ -0,0 +1,67 @@ +using Dalamud.Game.ClientState.Objects; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Game.ClientState.Resolvers; +using Dalamud.Game.Network.Structures.InfoProxy; + +using FFXIVClientStructs.FFXIV.Client.UI.Agent; + +using Lumina.Excel.GeneratedSheets; + +namespace Dalamud.Game.Gui.ContextMenu; + +/// +/// Target information on a default context menu. +/// +public sealed unsafe class MenuTargetDefault : MenuTarget +{ + /// + /// Initializes a new instance of the class. + /// + /// The agent associated with the context menu. + internal MenuTargetDefault(AgentContext* context) + { + this.Context = context; + } + + /// + /// Gets the name of the target. + /// + public string TargetName => this.Context->TargetName.ToString(); + + /// + /// Gets the object id of the target. + /// + public ulong TargetObjectId => this.Context->TargetObjectId; + + /// + /// Gets the target object. + /// + public GameObject? TargetObject => Service.Get().SearchById(this.TargetObjectId); + + /// + /// Gets the content id of the target. + /// + public ulong TargetContentId => this.Context->TargetContentId; + + /// + /// Gets the home world id of the target. + /// + public ExcelResolver TargetHomeWorld => new((uint)this.Context->TargetHomeWorldId); + + /// + /// Gets the currently targeted character. Only shows up for specific targets, like friends, party finder listings, or party members. + /// Just because this is doesn't mean the target isn't a character. + /// + public CharacterData? TargetCharacter + { + get + { + var target = this.Context->CurrentContextMenuTarget; + if (target != null) + return new(target); + return null; + } + } + + private AgentContext* Context { get; } +} diff --git a/Dalamud/Game/Gui/ContextMenu/MenuTargetInventory.cs b/Dalamud/Game/Gui/ContextMenu/MenuTargetInventory.cs new file mode 100644 index 000000000..dee550370 --- /dev/null +++ b/Dalamud/Game/Gui/ContextMenu/MenuTargetInventory.cs @@ -0,0 +1,36 @@ +using Dalamud.Game.Inventory; + +using FFXIVClientStructs.FFXIV.Client.UI.Agent; + +namespace Dalamud.Game.Gui.ContextMenu; + +/// +/// Target information on an inventory context menu. +/// +public sealed unsafe class MenuTargetInventory : MenuTarget +{ + /// + /// Initializes a new instance of the class. + /// + /// The agent associated with the context menu. + internal MenuTargetInventory(AgentInventoryContext* context) + { + this.Context = context; + } + + /// + /// Gets the target item. + /// + public GameInventoryItem? TargetItem + { + get + { + var target = this.Context->TargetInventorySlot; + if (target != null) + return new(*target); + return null; + } + } + + private AgentInventoryContext* Context { get; } +} diff --git a/Dalamud/Game/Inventory/GameInventoryItem.cs b/Dalamud/Game/Inventory/GameInventoryItem.cs index 912b91f53..d37e1081f 100644 --- a/Dalamud/Game/Inventory/GameInventoryItem.cs +++ b/Dalamud/Game/Inventory/GameInventoryItem.cs @@ -1,7 +1,10 @@ -using System.Diagnostics; +using System.Diagnostics; +using System.Linq; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using Dalamud.Utility; + using FFXIVClientStructs.FFXIV.Client.Game; namespace Dalamud.Game.Inventory; @@ -103,8 +106,10 @@ public unsafe struct GameInventoryItem : IEquatable /// /// Gets the array of materia grades. /// + // TODO: Replace with MateriaGradeBytes + [Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)] public ReadOnlySpan MateriaGrade => - new(Unsafe.AsPointer(ref Unsafe.AsRef(in this.InternalItem.MateriaGrade[0])), 5); + this.MateriaGradeBytes.ToArray().Select(g => (ushort)g).ToArray().AsSpan(); /// /// Gets the address of native inventory item in the game.
@@ -146,6 +151,9 @@ public unsafe struct GameInventoryItem : IEquatable ///
internal ulong CrafterContentId => this.InternalItem.CrafterContentID; + private ReadOnlySpan MateriaGradeBytes => + new(Unsafe.AsPointer(ref Unsafe.AsRef(in this.InternalItem.MateriaGrade[0])), 5); + public static bool operator ==(in GameInventoryItem l, in GameInventoryItem r) => l.Equals(r); public static bool operator !=(in GameInventoryItem l, in GameInventoryItem r) => !l.Equals(r); diff --git a/Dalamud/Game/Network/Structures/InfoProxy/CharacterData.cs b/Dalamud/Game/Network/Structures/InfoProxy/CharacterData.cs new file mode 100644 index 000000000..0ca35d672 --- /dev/null +++ b/Dalamud/Game/Network/Structures/InfoProxy/CharacterData.cs @@ -0,0 +1,197 @@ +using System.Collections.Generic; + +using Dalamud.Game.ClientState.Resolvers; +using Dalamud.Memory; + +using FFXIVClientStructs.FFXIV.Client.UI.Info; + +using Lumina.Excel.GeneratedSheets; + +namespace Dalamud.Game.Network.Structures.InfoProxy; + +/// +/// Dalamud wrapper around a client structs . +/// +public unsafe class CharacterData +{ + /// + /// Initializes a new instance of the class. + /// + /// Character data to wrap. + internal CharacterData(InfoProxyCommonList.CharacterData* data) + { + this.Address = (nint)data; + } + + /// + /// Gets the address of the in memory. + /// + public nint Address { get; } + + /// + /// Gets the content id of the character. + /// + public ulong ContentId => this.Struct->ContentId; + + /// + /// Gets the status mask of the character. + /// + public ulong StatusMask => (ulong)this.Struct->State; + + /// + /// Gets the applicable statues of the character. + /// + public IReadOnlyList> Statuses + { + get + { + var statuses = new List>(); + for (var i = 0; i < 64; i++) + { + if ((this.StatusMask & (1UL << i)) != 0) + statuses.Add(new((uint)i)); + } + + return statuses; + } + } + + /// + /// Gets the display group of the character. + /// + public DisplayGroup DisplayGroup => (DisplayGroup)this.Struct->Group; + + /// + /// Gets a value indicating whether the character's home world is different from the current world. + /// + public bool IsFromOtherServer => this.Struct->IsOtherServer; + + /// + /// Gets the sort order of the character. + /// + public byte Sort => this.Struct->Sort; + + /// + /// Gets the current world of the character. + /// + public ExcelResolver CurrentWorld => new(this.Struct->CurrentWorld); + + /// + /// Gets the home world of the character. + /// + public ExcelResolver HomeWorld => new(this.Struct->HomeWorld); + + /// + /// Gets the location of the character. + /// + public ExcelResolver Location => new(this.Struct->Location); + + /// + /// Gets the grand company of the character. + /// + public ExcelResolver GrandCompany => new((uint)this.Struct->GrandCompany); + + /// + /// Gets the primary client language of the character. + /// + public ClientLanguage ClientLanguage => (ClientLanguage)this.Struct->ClientLanguage; + + /// + /// Gets the supported language mask of the character. + /// + public byte LanguageMask => (byte)this.Struct->Languages; + + /// + /// Gets the supported languages the character supports. + /// + public IReadOnlyList Languages + { + get + { + var languages = new List(); + for (var i = 0; i < 4; i++) + { + if ((this.LanguageMask & (1 << i)) != 0) + languages.Add((ClientLanguage)i); + } + + return languages; + } + } + + /// + /// Gets the gender of the character. + /// + public byte Gender => this.Struct->Sex; + + /// + /// Gets the job of the character. + /// + public ExcelResolver ClassJob => new(this.Struct->Job); + + /// + /// Gets the name of the character. + /// + public string Name => MemoryHelper.ReadString((nint)this.Struct->Name, 32); + + /// + /// Gets the free company tag of the character. + /// + public string FCTag => MemoryHelper.ReadString((nint)this.Struct->Name, 6); + + /// + /// Gets the underlying struct. + /// + internal InfoProxyCommonList.CharacterData* Struct => (InfoProxyCommonList.CharacterData*)this.Address; +} + +/// +/// Display group of a character. Used for friends. +/// +public enum DisplayGroup : sbyte +{ + /// + /// All display groups. + /// + All = -1, + + /// + /// No display group. + /// + None, + + /// + /// Star display group. + /// + Star, + + /// + /// Circle display group. + /// + Circle, + + /// + /// Triangle display group. + /// + Triangle, + + /// + /// Diamond display group. + /// + Diamond, + + /// + /// Heart display group. + /// + Heart, + + /// + /// Spade display group. + /// + Spade, + + /// + /// Club display group. + /// + Club, +} diff --git a/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/ContextMenuAgingStep.cs b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/ContextMenuAgingStep.cs index 570e362ef..579f8357b 100644 --- a/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/ContextMenuAgingStep.cs +++ b/Dalamud/Interface/Internal/Windows/SelfTest/AgingSteps/ContextMenuAgingStep.cs @@ -1,10 +1,17 @@ -/*using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; using Dalamud.Data; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.Gui.ContextMenu; +using Dalamud.Game.Text; +using Dalamud.Game.Text.SeStringHandling; using Dalamud.Utility; using ImGuiNET; +using Lumina.Excel; using Lumina.Excel.GeneratedSheets; -using Serilog;*/ +using Serilog; namespace Dalamud.Interface.Internal.Windows.SelfTest.AgingSteps; @@ -13,31 +20,22 @@ namespace Dalamud.Interface.Internal.Windows.SelfTest.AgingSteps; /// internal class ContextMenuAgingStep : IAgingStep { - /* private SubStep currentSubStep; - private uint clickedItemId; - private bool clickedItemHq; - private uint clickedItemCount; + private bool? targetInventorySubmenuOpened; + private PlayerCharacter? targetCharacter; - private string? clickedPlayerName; - private ushort? clickedPlayerWorld; - private ulong? clickedPlayerCid; - private uint? clickedPlayerId; - - private bool multipleTriggerOne; - private bool multipleTriggerTwo; + private ExcelSheet itemSheet; + private ExcelSheet materiaSheet; + private ExcelSheet stainSheet; private enum SubStep { Start, - TestItem, - TestGameObject, - TestSubMenu, - TestMultiple, + TestInventoryAndSubmenu, + TestDefault, Finish, } - */ /// public string Name => "Test Context Menu"; @@ -45,23 +43,24 @@ internal class ContextMenuAgingStep : IAgingStep /// public SelfTestStepResult RunStep() { - /* var contextMenu = Service.Get(); var dataMgr = Service.Get(); + this.itemSheet = dataMgr.GetExcelSheet()!; + this.materiaSheet = dataMgr.GetExcelSheet()!; + this.stainSheet = dataMgr.GetExcelSheet()!; ImGui.Text(this.currentSubStep.ToString()); switch (this.currentSubStep) { case SubStep.Start: - contextMenu.ContextMenuOpened += this.ContextMenuOnContextMenuOpened; + contextMenu.OnMenuOpened += this.OnMenuOpened; this.currentSubStep++; break; - case SubStep.TestItem: - if (this.clickedItemId != 0) + case SubStep.TestInventoryAndSubmenu: + if (this.targetInventorySubmenuOpened == true) { - var item = dataMgr.GetExcelSheet()!.GetRow(this.clickedItemId); - ImGui.Text($"Did you click \"{item!.Name.RawString}\", hq:{this.clickedItemHq}, count:{this.clickedItemCount}?"); + ImGui.Text($"Is the data in the submenu correct?"); if (ImGui.Button("Yes")) this.currentSubStep++; @@ -73,7 +72,7 @@ internal class ContextMenuAgingStep : IAgingStep } else { - ImGui.Text("Right-click an item."); + ImGui.Text("Right-click an item and select \"Self Test\"."); if (ImGui.Button("Skip")) this.currentSubStep++; @@ -81,10 +80,10 @@ internal class ContextMenuAgingStep : IAgingStep break; - case SubStep.TestGameObject: - if (!this.clickedPlayerName.IsNullOrEmpty()) + case SubStep.TestDefault: + if (this.targetCharacter is { } character) { - ImGui.Text($"Did you click \"{this.clickedPlayerName}\", world:{this.clickedPlayerWorld}, cid:{this.clickedPlayerCid}, id:{this.clickedPlayerId}?"); + ImGui.Text($"Did you click \"{character.Name}\" ({character.ClassJob.GameData!.Abbreviation.ToDalamudString()})?"); if (ImGui.Button("Yes")) this.currentSubStep++; @@ -103,149 +102,195 @@ internal class ContextMenuAgingStep : IAgingStep } break; - case SubStep.TestSubMenu: - if (this.multipleTriggerOne && this.multipleTriggerTwo) - { - this.currentSubStep++; - this.multipleTriggerOne = this.multipleTriggerTwo = false; - } - else - { - ImGui.Text("Right-click a character and select both options in the submenu."); + case SubStep.Finish: + return SelfTestStepResult.Pass; - if (ImGui.Button("Skip")) - this.currentSubStep++; - } - - break; - - case SubStep.TestMultiple: - if (this.multipleTriggerOne && this.multipleTriggerTwo) - { - this.currentSubStep = SubStep.Finish; - return SelfTestStepResult.Pass; - } - - ImGui.Text("Select both options on any context menu."); - if (ImGui.Button("Skip")) - this.currentSubStep++; - break; default: throw new ArgumentOutOfRangeException(); } return SelfTestStepResult.Waiting; - */ - - return SelfTestStepResult.Pass; } - + /// public void CleanUp() { - /* var contextMenu = Service.Get(); - contextMenu.ContextMenuOpened -= this.ContextMenuOnContextMenuOpened; + contextMenu.OnMenuOpened -= this.OnMenuOpened; this.currentSubStep = SubStep.Start; - this.clickedItemId = 0; - this.clickedPlayerName = null; - this.multipleTriggerOne = this.multipleTriggerTwo = false; - */ + this.targetInventorySubmenuOpened = null; + this.targetCharacter = null; } - /* - private void ContextMenuOnContextMenuOpened(ContextMenuOpenedArgs args) + private void OnMenuOpened(MenuOpenedArgs args) { - Log.Information("Got context menu with parent addon: {ParentAddonName}, title:{Title}, itemcnt:{ItemCount}", args.ParentAddonName, args.Title, args.Items.Count); - if (args.GameObjectContext != null) - { - Log.Information(" => GameObject:{GameObjectName} world:{World} cid:{Cid} id:{Id}", args.GameObjectContext.Name, args.GameObjectContext.WorldId, args.GameObjectContext.ContentId, args.GameObjectContext.Id); - } - - if (args.InventoryItemContext != null) - { - Log.Information(" => Inventory:{ItemId} hq:{Hq} count:{Count}", args.InventoryItemContext.Id, args.InventoryItemContext.IsHighQuality, args.InventoryItemContext.Count); - } + LogMenuOpened(args); switch (this.currentSubStep) { - case SubStep.TestSubMenu: - args.AddCustomSubMenu("Aging Submenu", openedArgs => + case SubStep.TestInventoryAndSubmenu: + if (args.MenuType == ContextMenuType.Inventory) { - openedArgs.AddCustomItem("Submenu Item 1", _ => + args.AddMenuItem(new() { - this.multipleTriggerOne = true; - }); - - openedArgs.AddCustomItem("Submenu Item 2", _ => - { - this.multipleTriggerTwo = true; - }); - }); - - return; - case SubStep.TestMultiple: - args.AddCustomItem("Aging Item 1", _ => - { - this.multipleTriggerOne = true; - }); - - args.AddCustomItem("Aging Item 2", _ => - { - this.multipleTriggerTwo = true; - }); - - return; - case SubStep.Finish: - return; - - default: - switch (args.ParentAddonName) - { - case "Inventory": - if (this.currentSubStep != SubStep.TestItem) - return; - - args.AddCustomItem("Aging Item", _ => + Name = "Self Test", + Prefix = SeIconChar.Hyadelyn, + PrefixColor = 56, + Priority = -1, + IsSubmenu = true, + OnClicked = (MenuItemClickedArgs a) => { - this.clickedItemId = args.InventoryItemContext!.Id; - this.clickedItemHq = args.InventoryItemContext!.IsHighQuality; - this.clickedItemCount = args.InventoryItemContext!.Count; - Log.Warning("Clicked item: {Id} hq:{Hq} count:{Count}", this.clickedItemId, this.clickedItemHq, this.clickedItemCount); - }); - break; + SeString name; + uint count; + var targetItem = (a.Target as MenuTargetInventory).TargetItem; + if (targetItem is { } item) + { + name = (this.itemSheet.GetRow(item.ItemId)?.Name.ToDalamudString() ?? $"Unknown ({item.ItemId})") + (item.IsHq ? $" {SeIconChar.HighQuality.ToIconString()}" : string.Empty); + count = item.Quantity; + } + else + { + name = "None"; + count = 0; + } - case null: - case "_PartyList": - case "ChatLog": - case "ContactList": - case "ContentMemberList": - case "CrossWorldLinkshell": - case "FreeCompany": - case "FriendList": - case "LookingForGroup": - case "LinkShell": - case "PartyMemberList": - case "SocialList": - if (this.currentSubStep != SubStep.TestGameObject || args.GameObjectContext == null || args.GameObjectContext.Name.IsNullOrEmpty()) - return; + a.OpenSubmenu(new MenuItem[] + { + new() + { + Name = "Name: " + name, + IsEnabled = false, + }, + new() + { + Name = $"Count: {count}", + IsEnabled = false, + }, + }); - args.AddCustomItem("Aging Character", _ => - { - this.clickedPlayerName = args.GameObjectContext.Name!; - this.clickedPlayerWorld = args.GameObjectContext.WorldId; - this.clickedPlayerCid = args.GameObjectContext.ContentId; - this.clickedPlayerId = args.GameObjectContext.Id; - - Log.Warning("Clicked player: {Name} world:{World} cid:{Cid} id:{Id}", this.clickedPlayerName, this.clickedPlayerWorld, this.clickedPlayerCid, this.clickedPlayerId); - }); - - break; + this.targetInventorySubmenuOpened = true; + }, + }); } break; + + case SubStep.TestDefault: + if (args.Target is MenuTargetDefault { TargetObject: PlayerCharacter { } character }) + this.targetCharacter = character; + break; + + case SubStep.Finish: + return; + } + } + + private void LogMenuOpened(MenuOpenedArgs args) + { + Log.Verbose($"Got {args.MenuType} context menu with addon 0x{args.AddonPtr:X8} ({args.AddonName}) and agent 0x{args.AgentPtr:X8}"); + if (args.Target is MenuTargetDefault targetDefault) + { + { + var b = new StringBuilder(); + b.AppendLine($"Target: {targetDefault.TargetName}"); + b.AppendLine($"Home World: {targetDefault.TargetHomeWorld.GameData?.Name.ToDalamudString() ?? "Unknown"} ({targetDefault.TargetHomeWorld.Id})"); + b.AppendLine($"Content Id: 0x{targetDefault.TargetContentId:X8}"); + b.AppendLine($"Object Id: 0x{targetDefault.TargetObjectId:X8}"); + Log.Verbose(b.ToString()); + } + + if (targetDefault.TargetCharacter is { } character) + { + var b = new StringBuilder(); + b.AppendLine($"Character: {character.Name}"); + + b.AppendLine($"Name: {character.Name}"); + b.AppendLine($"Content Id: 0x{character.ContentId:X8}"); + b.AppendLine($"FC Tag: {character.FCTag}"); + + b.AppendLine($"Job: {character.ClassJob.GameData?.Abbreviation.ToDalamudString() ?? "Unknown"} ({character.ClassJob.Id})"); + b.AppendLine($"Statuses: {string.Join(", ", character.Statuses.Select(s => s.GameData?.Name.ToDalamudString() ?? s.Id.ToString()))}"); + b.AppendLine($"Home World: {character.HomeWorld.GameData?.Name.ToDalamudString() ?? "Unknown"} ({character.HomeWorld.Id})"); + b.AppendLine($"Current World: {character.CurrentWorld.GameData?.Name.ToDalamudString() ?? "Unknown"} ({character.CurrentWorld.Id})"); + b.AppendLine($"Is From Other Server: {character.IsFromOtherServer}"); + + b.Append("Location: "); + if (character.Location.GameData is { } location) + b.Append($"{location.PlaceNameRegion.Value?.Name.ToDalamudString() ?? "Unknown"}/{location.PlaceNameZone.Value?.Name.ToDalamudString() ?? "Unknown"}/{location.PlaceName.Value?.Name.ToDalamudString() ?? "Unknown"}"); + else + b.Append("Unknown"); + b.AppendLine($" ({character.Location.Id})"); + + b.AppendLine($"Grand Company: {character.GrandCompany.GameData?.Name.ToDalamudString() ?? "Unknown"} ({character.GrandCompany.Id})"); + b.AppendLine($"Client Language: {character.ClientLanguage}"); + b.AppendLine($"Languages: {string.Join(", ", character.Languages)}"); + b.AppendLine($"Gender: {character.Gender}"); + b.AppendLine($"Display Group: {character.DisplayGroup}"); + b.AppendLine($"Sort: {character.Sort}"); + + Log.Verbose(b.ToString()); + } + else + { + Log.Verbose($"Character: null"); + } + } + else if (args.Target is MenuTargetInventory targetInventory) + { + if (targetInventory.TargetItem is { } item) + { + var b = new StringBuilder(); + b.AppendLine($"Item: {(item.IsEmpty ? "None" : this.itemSheet.GetRow(item.ItemId)?.Name.ToDalamudString())} ({item.ItemId})"); + b.AppendLine($"Container: {item.ContainerType}"); + b.AppendLine($"Slot: {item.InventorySlot}"); + b.AppendLine($"Quantity: {item.Quantity}"); + b.AppendLine($"{(item.IsCollectable ? "Collectability" : "Spiritbond")}: {item.Spiritbond}"); + b.AppendLine($"Condition: {item.Condition / 300f:0.00}% ({item.Condition})"); + b.AppendLine($"Is HQ: {item.IsHq}"); + b.AppendLine($"Is Company Crest Applied: {item.IsCompanyCrestApplied}"); + b.AppendLine($"Is Relic: {item.IsRelic}"); + b.AppendLine($"Is Collectable: {item.IsCollectable}"); + + b.Append("Materia: "); + var materias = new List(); + foreach (var (materiaId, materiaGrade) in item.Materia.ToArray().Zip(item.MateriaGrade.ToArray()).Where(m => m.First != 0)) + { + Log.Verbose($"{materiaId} {materiaGrade}"); + if (this.materiaSheet.GetRow(materiaId) is { } materia && + materia.Item[materiaGrade].Value is { } materiaItem) + materias.Add($"{materiaItem.Name.ToDalamudString()}"); + else + materias.Add($"Unknown (Id: {materiaId}, Grade: {materiaGrade})"); + } + + if (materias.Count == 0) + b.AppendLine("None"); + else + b.AppendLine(string.Join(", ", materias)); + + b.Append($"Dye/Stain: "); + if (item.Stain != 0) + b.AppendLine($"{this.stainSheet.GetRow(item.Stain)?.Name.ToDalamudString() ?? "Unknown"} ({item.Stain})"); + else + b.AppendLine("None"); + + b.Append("Glamoured Item: "); + if (item.GlamourId != 0) + b.AppendLine($"{this.itemSheet.GetRow(item.GlamourId)?.Name.ToDalamudString() ?? "Unknown"} ({item.GlamourId})"); + else + b.AppendLine("None"); + + Log.Verbose(b.ToString()); + } + else + { + Log.Verbose("Item: null"); + } + } + else + { + Log.Verbose($"Target: Unknown ({args.Target?.GetType().Name ?? "null"})"); } } - */ } diff --git a/Dalamud/Plugin/Services/IContextMenu.cs b/Dalamud/Plugin/Services/IContextMenu.cs new file mode 100644 index 000000000..4d792116d --- /dev/null +++ b/Dalamud/Plugin/Services/IContextMenu.cs @@ -0,0 +1,37 @@ +using Dalamud.Game.Gui.ContextMenu; +using Dalamud.Game.Text; +using Dalamud.Game.Text.SeStringHandling; + +namespace Dalamud.Plugin.Services; + +/// +/// This class provides methods for interacting with the game's context menu. +/// +public interface IContextMenu +{ + /// + /// A delegate type used for the event. + /// + /// Information about the currently opening menu. + public delegate void OnMenuOpenedDelegate(MenuOpenedArgs args); + + /// + /// Event that gets fired every time the game framework updates. + /// + event OnMenuOpenedDelegate OnMenuOpened; + + /// + /// Adds a menu item to a context menu. + /// + /// The type of context menu to add the item to. + /// The item to add. + void AddMenuItem(ContextMenuType menuType, MenuItem item); + + /// + /// Removes a menu item from a context menu. + /// + /// The type of context menu to remove the item from. + /// The item to add. + /// if the item was removed, if it was not found. + bool RemoveMenuItem(ContextMenuType menuType, MenuItem item); +} diff --git a/Dalamud/Utility/EventHandlerExtensions.cs b/Dalamud/Utility/EventHandlerExtensions.cs index d05ad6ea5..9bb35a8f1 100644 --- a/Dalamud/Utility/EventHandlerExtensions.cs +++ b/Dalamud/Utility/EventHandlerExtensions.cs @@ -1,6 +1,7 @@ using System.Linq; using Dalamud.Game; +using Dalamud.Game.Gui.ContextMenu; using Dalamud.Plugin.Services; using Serilog; @@ -99,6 +100,23 @@ internal static class EventHandlerExtensions } } + /// + /// Replacement for Invoke() on OnMenuOpenedDelegate to catch exceptions that stop event propagation in case + /// of a thrown Exception inside of an invocation. + /// + /// The OnMenuOpenedDelegate in question. + /// Templated argument for Action. + public static void InvokeSafely(this IContextMenu.OnMenuOpenedDelegate? openedDelegate, MenuOpenedArgs argument) + { + if (openedDelegate == null) + return; + + foreach (var action in openedDelegate.GetInvocationList().Cast()) + { + HandleInvoke(() => action(argument)); + } + } + private static void HandleInvoke(Action act) { try