Add IContextMenu service (#1682)

This commit is contained in:
Asriel Camora 2024-02-29 15:15:02 -08:00 committed by GitHub
parent 3d59fa3da0
commit 5f62c703bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1387 additions and 146 deletions

View file

@ -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;
/// <summary>
/// This class handles interacting with the game's (right-click) context menu.
/// </summary>
[InterfaceVersion("1.0")]
[ServiceManager.EarlyLoadedService]
internal sealed unsafe class ContextMenu : IDisposable, IServiceType, IContextMenu
{
private static readonly ModuleLog Log = new("ContextMenu");
private readonly Hook<RaptureAtkModuleOpenAddonByAgentDelegate> raptureAtkModuleOpenAddonByAgentHook;
private readonly Hook<AddonContextMenuOnMenuSelectedDelegate> addonContextMenuOnMenuSelectedHook;
private readonly RaptureAtkModuleOpenAddonDelegate raptureAtkModuleOpenAddon;
[ServiceManager.ServiceConstructor]
private ContextMenu()
{
this.raptureAtkModuleOpenAddonByAgentHook = Hook<RaptureAtkModuleOpenAddonByAgentDelegate>.FromAddress((nint)RaptureAtkModule.Addresses.OpenAddonByAgent.Value, this.RaptureAtkModuleOpenAddonByAgentDetour);
this.addonContextMenuOnMenuSelectedHook = Hook<AddonContextMenuOnMenuSelectedDelegate>.FromAddress((nint)AddonContextMenu.StaticVTable.OnMenuSelected, this.AddonContextMenuOnMenuSelectedDetour);
this.raptureAtkModuleOpenAddon = Marshal.GetDelegateForFunctionPointer<RaptureAtkModuleOpenAddonDelegate>((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);
/// <inheritdoc/>
public event IContextMenu.OnMenuOpenedDelegate OnMenuOpened;
private Dictionary<ContextMenuType, List<MenuItem>> MenuItems { get; } = new();
private object MenuItemsLock { get; } = new();
private AgentInterface* SelectedAgent { get; set; }
private ContextMenuType? SelectedMenuType { get; set; }
private List<MenuItem>? SelectedItems { get; set; }
private HashSet<nint> SelectedEventInterfaces { get; } = new();
private AtkUnitBase* SelectedParentAddon { get; set; }
// -1 -> -inf: native items
// 0 -> inf: selected items
private List<int> MenuCallbackIds { get; } = new();
private IReadOnlyList<MenuItem>? SubmenuItems { get; set; }
/// <inheritdoc/>
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();
}
/// <inheritdoc/>
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);
}
}
/// <inheritdoc/>
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<AtkValue> 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<AtkValue>.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<MenuItem> 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<AtkValue>(values, headerCount);
var nameData = new Span<AtkValue>(values + headerCount, nativeMenuSize + items.Count);
var disabledData = hasAnyDisabled ? new Span<AtkValue>(values + headerCount + nativeMenuSize + items.Count, nativeMenuSize + items.Count) : Span<AtkValue>.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<AtkValue>(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<AtkValue> disabledData, Span<AtkValue> 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<MenuItem> 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<MenuItem> 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<Pointer<AtkEventInterface>>(menu->EventHandlerArray, 32);
var ids = new Span<byte>(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<MenuItem> FixupMenuList(List<MenuItem> 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<MenuItem> 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);
}
}
/// <summary>
/// Plugin-scoped version of a <see cref="ContextMenu"/> service.
/// </summary>
[PluginInterface]
[InterfaceVersion("1.0")]
[ServiceManager.ScopedService]
#pragma warning disable SA1015
[ResolveVia<IContextMenu>]
#pragma warning restore SA1015
internal class ContextMenuPluginScoped : IDisposable, IServiceType, IContextMenu
{
[ServiceManager.ServiceDependency]
private readonly ContextMenu parentService = Service<ContextMenu>.Get();
private ContextMenuPluginScoped()
{
this.parentService.OnMenuOpened += this.OnMenuOpenedForward;
}
/// <inheritdoc/>
public event IContextMenu.OnMenuOpenedDelegate OnMenuOpened;
private Dictionary<ContextMenuType, List<MenuItem>> MenuItems { get; } = new();
private object MenuItemsLock { get; } = new();
/// <inheritdoc/>
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);
}
}
}
/// <inheritdoc/>
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);
}
/// <inheritdoc/>
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);
}

View file

@ -0,0 +1,18 @@
namespace Dalamud.Game.Gui.ContextMenu;
/// <summary>
/// The type of context menu.
/// Each one has a different associated <see cref="MenuTarget"/>.
/// </summary>
public enum ContextMenuType
{
/// <summary>
/// The default context menu.
/// </summary>
Default,
/// <summary>
/// The inventory context menu. Used when right-clicked on an item.
/// </summary>
Inventory,
}

View file

@ -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;
/// <summary>
/// Base class for <see cref="IContextMenu"/> menu args.
/// </summary>
public abstract unsafe class MenuArgs
{
private IReadOnlySet<nint>? eventInterfaces;
/// <summary>
/// Initializes a new instance of the <see cref="MenuArgs"/> class.
/// </summary>
/// <param name="addon">Addon associated with the context menu.</param>
/// <param name="agent">Agent associated with the context menu.</param>
/// <param name="type">The type of context menu.</param>
/// <param name="eventInterfaces">List of AtkEventInterfaces associated with the context menu.</param>
protected internal MenuArgs(AtkUnitBase* addon, AgentInterface* agent, ContextMenuType type, IReadOnlySet<nint>? 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)),
};
}
/// <summary>
/// Gets the name of the addon that opened the context menu.
/// </summary>
public string? AddonName { get; }
/// <summary>
/// Gets the memory pointer of the addon that opened the context menu.
/// </summary>
public nint AddonPtr { get; }
/// <summary>
/// Gets the memory pointer of the agent that opened the context menu.
/// </summary>
public nint AgentPtr { get; }
/// <summary>
/// Gets the type of the context menu.
/// </summary>
public ContextMenuType MenuType { get; }
/// <summary>
/// Gets the target info of the context menu. The actual type depends on <see cref="MenuType"/>.
/// <see cref="ContextMenuType.Default"/> signifies a <see cref="MenuTargetDefault"/>.
/// <see cref="ContextMenuType.Inventory"/> signifies a <see cref="MenuTargetInventory"/>.
/// </summary>
public MenuTarget Target { get; }
/// <summary>
/// Gets a list of AtkEventInterface pointers associated with the context menu.
/// Only available with <see cref="ContextMenuType.Default"/>.
/// Almost always an agent pointer. You can use this to find out what type of context menu it is.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when the context menu is not a <see cref="ContextMenuType.Default"/>.</exception>
public IReadOnlySet<nint> EventInterfaces =>
this.MenuType != ContextMenuType.Default ?
this.eventInterfaces :
throw new InvalidOperationException("Not a default context menu");
}

View file

@ -0,0 +1,91 @@
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
using Lumina.Excel.GeneratedSheets;
namespace Dalamud.Game.Gui.ContextMenu;
/// <summary>
/// A menu item that can be added to a context menu.
/// </summary>
public sealed record MenuItem
{
/// <summary>
/// Gets or sets the display name of the menu item.
/// </summary>
public SeString Name { get; set; } = SeString.Empty;
/// <summary>
/// Gets or sets the prefix attached to the beginning of <see cref="Name"/>.
/// </summary>
public SeIconChar? Prefix { get; set; }
/// <summary>
/// Sets the character to prefix the <see cref="Name"/> with. Will be converted into a fancy boxed letter icon. Must be an uppercase letter.
/// </summary>
/// <exception cref="ArgumentException"><paramref name="value"/> must be an uppercase letter.</exception>
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;
}
}
}
/// <summary>
/// Gets or sets the color of the <see cref="Prefix"/>. Specifies a <see cref="UIColor"/> row id.
/// </summary>
public ushort PrefixColor { get; set; }
/// <summary>
/// Gets or sets the callback to be invoked when the menu item is clicked.
/// </summary>
public Action<MenuItemClickedArgs>? OnClicked { get; set; }
/// <summary>
/// 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.
/// </summary>
public int Priority { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the menu item is enabled.
/// Disabled items will be faded and cannot be clicked on.
/// </summary>
public bool IsEnabled { get; set; } = true;
/// <summary>
/// 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.
/// </summary>
public bool IsSubmenu { get; set; }
/// <summary>
/// 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 <see cref="IsSubmenu"/> and <see cref="IsReturn"/> are true, the return arrow will take precedence.
/// </summary>
public bool IsReturn { get; set; }
/// <summary>
/// Gets the name with the given prefix.
/// </summary>
internal SeString PrefixedName =>
this.Prefix is { } prefix
? new SeStringBuilder()
.AddUiForeground($"{prefix.ToIconString()} ", this.PrefixColor)
.Append(this.Name)
.Build()
: this.Name;
}

View file

@ -0,0 +1,44 @@
using System.Collections.Generic;
using Dalamud.Game.Text.SeStringHandling;
using FFXIVClientStructs.FFXIV.Component.GUI;
namespace Dalamud.Game.Gui.ContextMenu;
/// <summary>
/// Callback args used when a menu item is clicked.
/// </summary>
public sealed unsafe class MenuItemClickedArgs : MenuArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="MenuItemClickedArgs"/> class.
/// </summary>
/// <param name="openSubmenu">Callback for opening a submenu.</param>
/// <param name="addon">Addon associated with the context menu.</param>
/// <param name="agent">Agent associated with the context menu.</param>
/// <param name="type">The type of context menu.</param>
/// <param name="eventInterfaces">List of AtkEventInterfaces associated with the context menu.</param>
internal MenuItemClickedArgs(Action<SeString?, IReadOnlyList<MenuItem>> openSubmenu, AtkUnitBase* addon, AgentInterface* agent, ContextMenuType type, IReadOnlySet<nint> eventInterfaces)
: base(addon, agent, type, eventInterfaces)
{
this.OnOpenSubmenu = openSubmenu;
}
private Action<SeString?, IReadOnlyList<MenuItem>> OnOpenSubmenu { get; }
/// <summary>
/// Opens a submenu with the given name and items.
/// </summary>
/// <param name="name">The name of the submenu, displayed at the top.</param>
/// <param name="items">The items to display in the submenu.</param>
public void OpenSubmenu(SeString name, IReadOnlyList<MenuItem> items) =>
this.OnOpenSubmenu(name, items);
/// <summary>
/// Opens a submenu with the given items.
/// </summary>
/// <param name="items">The items to display in the submenu.</param>
public void OpenSubmenu(IReadOnlyList<MenuItem> items) =>
this.OnOpenSubmenu(null, items);
}

View file

@ -0,0 +1,34 @@
using System.Collections.Generic;
using FFXIVClientStructs.FFXIV.Component.GUI;
namespace Dalamud.Game.Gui.ContextMenu;
/// <summary>
/// Callback args used when a menu item is opened.
/// </summary>
public sealed unsafe class MenuOpenedArgs : MenuArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="MenuOpenedArgs"/> class.
/// </summary>
/// <param name="addMenuItem">Callback for adding a custom menu item.</param>
/// <param name="addon">Addon associated with the context menu.</param>
/// <param name="agent">Agent associated with the context menu.</param>
/// <param name="type">The type of context menu.</param>
/// <param name="eventInterfaces">List of AtkEventInterfaces associated with the context menu.</param>
internal MenuOpenedArgs(Action<MenuItem> addMenuItem, AtkUnitBase* addon, AgentInterface* agent, ContextMenuType type, IReadOnlySet<nint> eventInterfaces)
: base(addon, agent, type, eventInterfaces)
{
this.OnAddMenuItem = addMenuItem;
}
private Action<MenuItem> OnAddMenuItem { get; }
/// <summary>
/// Adds a custom menu item to the context menu.
/// </summary>
/// <param name="item">The menu item to add.</param>
public void AddMenuItem(MenuItem item) =>
this.OnAddMenuItem(item);
}

View file

@ -0,0 +1,9 @@
namespace Dalamud.Game.Gui.ContextMenu;
/// <summary>
/// Base class for <see cref="MenuArgs"/> contexts.
/// Discriminated based on <see cref="ContextMenuType"/>.
/// </summary>
public abstract class MenuTarget
{
}

View file

@ -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;
/// <summary>
/// Target information on a default context menu.
/// </summary>
public sealed unsafe class MenuTargetDefault : MenuTarget
{
/// <summary>
/// Initializes a new instance of the <see cref="MenuTargetDefault"/> class.
/// </summary>
/// <param name="context">The agent associated with the context menu.</param>
internal MenuTargetDefault(AgentContext* context)
{
this.Context = context;
}
/// <summary>
/// Gets the name of the target.
/// </summary>
public string TargetName => this.Context->TargetName.ToString();
/// <summary>
/// Gets the object id of the target.
/// </summary>
public ulong TargetObjectId => this.Context->TargetObjectId;
/// <summary>
/// Gets the target object.
/// </summary>
public GameObject? TargetObject => Service<ObjectTable>.Get().SearchById(this.TargetObjectId);
/// <summary>
/// Gets the content id of the target.
/// </summary>
public ulong TargetContentId => this.Context->TargetContentId;
/// <summary>
/// Gets the home world id of the target.
/// </summary>
public ExcelResolver<World> TargetHomeWorld => new((uint)this.Context->TargetHomeWorldId);
/// <summary>
/// Gets the currently targeted character. Only shows up for specific targets, like friends, party finder listings, or party members.
/// Just because this is <see langword="null"/> doesn't mean the target isn't a character.
/// </summary>
public CharacterData? TargetCharacter
{
get
{
var target = this.Context->CurrentContextMenuTarget;
if (target != null)
return new(target);
return null;
}
}
private AgentContext* Context { get; }
}

View file

@ -0,0 +1,36 @@
using Dalamud.Game.Inventory;
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
namespace Dalamud.Game.Gui.ContextMenu;
/// <summary>
/// Target information on an inventory context menu.
/// </summary>
public sealed unsafe class MenuTargetInventory : MenuTarget
{
/// <summary>
/// Initializes a new instance of the <see cref="MenuTargetInventory"/> class.
/// </summary>
/// <param name="context">The agent associated with the context menu.</param>
internal MenuTargetInventory(AgentInventoryContext* context)
{
this.Context = context;
}
/// <summary>
/// Gets the target item.
/// </summary>
public GameInventoryItem? TargetItem
{
get
{
var target = this.Context->TargetInventorySlot;
if (target != null)
return new(*target);
return null;
}
}
private AgentInventoryContext* Context { get; }
}

View file

@ -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<GameInventoryItem>
/// <summary>
/// Gets the array of materia grades.
/// </summary>
// TODO: Replace with MateriaGradeBytes
[Api10ToDo(Api10ToDoAttribute.DeleteCompatBehavior)]
public ReadOnlySpan<ushort> MateriaGrade =>
new(Unsafe.AsPointer(ref Unsafe.AsRef(in this.InternalItem.MateriaGrade[0])), 5);
this.MateriaGradeBytes.ToArray().Select(g => (ushort)g).ToArray().AsSpan();
/// <summary>
/// Gets the address of native inventory item in the game.<br />
@ -146,6 +151,9 @@ public unsafe struct GameInventoryItem : IEquatable<GameInventoryItem>
/// </summary>
internal ulong CrafterContentId => this.InternalItem.CrafterContentID;
private ReadOnlySpan<byte> 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);

View file

@ -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;
/// <summary>
/// Dalamud wrapper around a client structs <see cref="InfoProxyCommonList.CharacterData"/>.
/// </summary>
public unsafe class CharacterData
{
/// <summary>
/// Initializes a new instance of the <see cref="CharacterData"/> class.
/// </summary>
/// <param name="data">Character data to wrap.</param>
internal CharacterData(InfoProxyCommonList.CharacterData* data)
{
this.Address = (nint)data;
}
/// <summary>
/// Gets the address of the <see cref="InfoProxyCommonList.CharacterData"/> in memory.
/// </summary>
public nint Address { get; }
/// <summary>
/// Gets the content id of the character.
/// </summary>
public ulong ContentId => this.Struct->ContentId;
/// <summary>
/// Gets the status mask of the character.
/// </summary>
public ulong StatusMask => (ulong)this.Struct->State;
/// <summary>
/// Gets the applicable statues of the character.
/// </summary>
public IReadOnlyList<ExcelResolver<OnlineStatus>> Statuses
{
get
{
var statuses = new List<ExcelResolver<OnlineStatus>>();
for (var i = 0; i < 64; i++)
{
if ((this.StatusMask & (1UL << i)) != 0)
statuses.Add(new((uint)i));
}
return statuses;
}
}
/// <summary>
/// Gets the display group of the character.
/// </summary>
public DisplayGroup DisplayGroup => (DisplayGroup)this.Struct->Group;
/// <summary>
/// Gets a value indicating whether the character's home world is different from the current world.
/// </summary>
public bool IsFromOtherServer => this.Struct->IsOtherServer;
/// <summary>
/// Gets the sort order of the character.
/// </summary>
public byte Sort => this.Struct->Sort;
/// <summary>
/// Gets the current world of the character.
/// </summary>
public ExcelResolver<World> CurrentWorld => new(this.Struct->CurrentWorld);
/// <summary>
/// Gets the home world of the character.
/// </summary>
public ExcelResolver<World> HomeWorld => new(this.Struct->HomeWorld);
/// <summary>
/// Gets the location of the character.
/// </summary>
public ExcelResolver<TerritoryType> Location => new(this.Struct->Location);
/// <summary>
/// Gets the grand company of the character.
/// </summary>
public ExcelResolver<GrandCompany> GrandCompany => new((uint)this.Struct->GrandCompany);
/// <summary>
/// Gets the primary client language of the character.
/// </summary>
public ClientLanguage ClientLanguage => (ClientLanguage)this.Struct->ClientLanguage;
/// <summary>
/// Gets the supported language mask of the character.
/// </summary>
public byte LanguageMask => (byte)this.Struct->Languages;
/// <summary>
/// Gets the supported languages the character supports.
/// </summary>
public IReadOnlyList<ClientLanguage> Languages
{
get
{
var languages = new List<ClientLanguage>();
for (var i = 0; i < 4; i++)
{
if ((this.LanguageMask & (1 << i)) != 0)
languages.Add((ClientLanguage)i);
}
return languages;
}
}
/// <summary>
/// Gets the gender of the character.
/// </summary>
public byte Gender => this.Struct->Sex;
/// <summary>
/// Gets the job of the character.
/// </summary>
public ExcelResolver<ClassJob> ClassJob => new(this.Struct->Job);
/// <summary>
/// Gets the name of the character.
/// </summary>
public string Name => MemoryHelper.ReadString((nint)this.Struct->Name, 32);
/// <summary>
/// Gets the free company tag of the character.
/// </summary>
public string FCTag => MemoryHelper.ReadString((nint)this.Struct->Name, 6);
/// <summary>
/// Gets the underlying <see cref="InfoProxyCommonList.CharacterData"/> struct.
/// </summary>
internal InfoProxyCommonList.CharacterData* Struct => (InfoProxyCommonList.CharacterData*)this.Address;
}
/// <summary>
/// Display group of a character. Used for friends.
/// </summary>
public enum DisplayGroup : sbyte
{
/// <summary>
/// All display groups.
/// </summary>
All = -1,
/// <summary>
/// No display group.
/// </summary>
None,
/// <summary>
/// Star display group.
/// </summary>
Star,
/// <summary>
/// Circle display group.
/// </summary>
Circle,
/// <summary>
/// Triangle display group.
/// </summary>
Triangle,
/// <summary>
/// Diamond display group.
/// </summary>
Diamond,
/// <summary>
/// Heart display group.
/// </summary>
Heart,
/// <summary>
/// Spade display group.
/// </summary>
Spade,
/// <summary>
/// Club display group.
/// </summary>
Club,
}

View file

@ -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;
/// </summary>
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<Item> itemSheet;
private ExcelSheet<Materia> materiaSheet;
private ExcelSheet<Stain> stainSheet;
private enum SubStep
{
Start,
TestItem,
TestGameObject,
TestSubMenu,
TestMultiple,
TestInventoryAndSubmenu,
TestDefault,
Finish,
}
*/
/// <inheritdoc/>
public string Name => "Test Context Menu";
@ -45,23 +43,24 @@ internal class ContextMenuAgingStep : IAgingStep
/// <inheritdoc/>
public SelfTestStepResult RunStep()
{
/*
var contextMenu = Service<ContextMenu>.Get();
var dataMgr = Service<DataManager>.Get();
this.itemSheet = dataMgr.GetExcelSheet<Item>()!;
this.materiaSheet = dataMgr.GetExcelSheet<Materia>()!;
this.stainSheet = dataMgr.GetExcelSheet<Stain>()!;
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<Item>()!.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;
}
/// <inheritdoc/>
public void CleanUp()
{
/*
var contextMenu = Service<ContextMenu>.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<string>();
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"})");
}
}
*/
}

View file

@ -0,0 +1,37 @@
using Dalamud.Game.Gui.ContextMenu;
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
namespace Dalamud.Plugin.Services;
/// <summary>
/// This class provides methods for interacting with the game's context menu.
/// </summary>
public interface IContextMenu
{
/// <summary>
/// A delegate type used for the <see cref="OnMenuOpened"/> event.
/// </summary>
/// <param name="args">Information about the currently opening menu.</param>
public delegate void OnMenuOpenedDelegate(MenuOpenedArgs args);
/// <summary>
/// Event that gets fired every time the game framework updates.
/// </summary>
event OnMenuOpenedDelegate OnMenuOpened;
/// <summary>
/// Adds a menu item to a context menu.
/// </summary>
/// <param name="menuType">The type of context menu to add the item to.</param>
/// <param name="item">The item to add.</param>
void AddMenuItem(ContextMenuType menuType, MenuItem item);
/// <summary>
/// Removes a menu item from a context menu.
/// </summary>
/// <param name="menuType">The type of context menu to remove the item from.</param>
/// <param name="item">The item to add.</param>
/// <returns><see langword="true"/> if the item was removed, <see langword="false"/> if it was not found.</returns>
bool RemoveMenuItem(ContextMenuType menuType, MenuItem item);
}

View file

@ -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
}
}
/// <summary>
/// Replacement for Invoke() on OnMenuOpenedDelegate to catch exceptions that stop event propagation in case
/// of a thrown Exception inside of an invocation.
/// </summary>
/// <param name="openedDelegate">The OnMenuOpenedDelegate in question.</param>
/// <param name="argument">Templated argument for Action.</param>
public static void InvokeSafely(this IContextMenu.OnMenuOpenedDelegate? openedDelegate, MenuOpenedArgs argument)
{
if (openedDelegate == null)
return;
foreach (var action in openedDelegate.GetInvocationList().Cast<IContextMenu.OnMenuOpenedDelegate>())
{
HandleInvoke(() => action(argument));
}
}
private static void HandleInvoke(Action act)
{
try