From 3d59fa3da0a2d5292d0517c5d5ba59bdc26a3638 Mon Sep 17 00:00:00 2001 From: srkizer Date: Fri, 1 Mar 2024 08:13:33 +0900 Subject: [PATCH 01/13] Sanitize PDB root name from loaded modules (#1687) --- Dalamud.Boot/Dalamud.Boot.vcxproj | 4 +- Dalamud.Boot/Dalamud.Boot.vcxproj.filters | 6 ++ Dalamud.Boot/hooks.cpp | 32 +------ Dalamud.Boot/hooks.h | 1 - Dalamud.Boot/ntdll.cpp | 15 +++ Dalamud.Boot/ntdll.h | 33 +++++++ Dalamud.Boot/pch.h | 7 ++ Dalamud.Boot/xivfixes.cpp | 109 +++++++++++++++++++++- Dalamud.Boot/xivfixes.h | 1 + Dalamud.Injector/EntryPoint.cs | 12 ++- 10 files changed, 181 insertions(+), 39 deletions(-) create mode 100644 Dalamud.Boot/ntdll.cpp create mode 100644 Dalamud.Boot/ntdll.h diff --git a/Dalamud.Boot/Dalamud.Boot.vcxproj b/Dalamud.Boot/Dalamud.Boot.vcxproj index ab68c1ec0..298edbcbc 100644 --- a/Dalamud.Boot/Dalamud.Boot.vcxproj +++ b/Dalamud.Boot/Dalamud.Boot.vcxproj @@ -58,7 +58,7 @@ Windows true false - Version.lib;%(AdditionalDependencies) + Version.lib;Shlwapi.lib;%(AdditionalDependencies) ..\lib\CoreCLR;%(AdditionalLibraryDirectories) @@ -137,6 +137,7 @@ NotUsing NotUsing + NotUsing NotUsing @@ -176,6 +177,7 @@ + diff --git a/Dalamud.Boot/Dalamud.Boot.vcxproj.filters b/Dalamud.Boot/Dalamud.Boot.vcxproj.filters index a1b1650e2..87eaf6fcc 100644 --- a/Dalamud.Boot/Dalamud.Boot.vcxproj.filters +++ b/Dalamud.Boot/Dalamud.Boot.vcxproj.filters @@ -73,6 +73,9 @@ Dalamud.Boot DLL + + Dalamud.Boot DLL + @@ -140,6 +143,9 @@ + + Dalamud.Boot DLL + diff --git a/Dalamud.Boot/hooks.cpp b/Dalamud.Boot/hooks.cpp index 7cf489195..1b1280cf0 100644 --- a/Dalamud.Boot/hooks.cpp +++ b/Dalamud.Boot/hooks.cpp @@ -2,39 +2,9 @@ #include "hooks.h" +#include "ntdll.h" #include "logging.h" -enum { - LDR_DLL_NOTIFICATION_REASON_LOADED = 1, - LDR_DLL_NOTIFICATION_REASON_UNLOADED = 2, -}; - -struct LDR_DLL_UNLOADED_NOTIFICATION_DATA { - ULONG Flags; //Reserved. - const UNICODE_STRING* FullDllName; //The full path name of the DLL module. - const UNICODE_STRING* BaseDllName; //The base file name of the DLL module. - PVOID DllBase; //A pointer to the base address for the DLL in memory. - ULONG SizeOfImage; //The size of the DLL image, in bytes. -}; - -struct LDR_DLL_LOADED_NOTIFICATION_DATA { - ULONG Flags; //Reserved. - const UNICODE_STRING* FullDllName; //The full path name of the DLL module. - const UNICODE_STRING* BaseDllName; //The base file name of the DLL module. - PVOID DllBase; //A pointer to the base address for the DLL in memory. - ULONG SizeOfImage; //The size of the DLL image, in bytes. -}; - -union LDR_DLL_NOTIFICATION_DATA { - LDR_DLL_LOADED_NOTIFICATION_DATA Loaded; - LDR_DLL_UNLOADED_NOTIFICATION_DATA Unloaded; -}; - -using PLDR_DLL_NOTIFICATION_FUNCTION = VOID CALLBACK(_In_ ULONG NotificationReason, _In_ const LDR_DLL_NOTIFICATION_DATA* NotificationData, _In_opt_ PVOID Context); - -static const auto LdrRegisterDllNotification = utils::loaded_module(GetModuleHandleW(L"ntdll.dll")).get_exported_function("LdrRegisterDllNotification"); -static const auto LdrUnregisterDllNotification = utils::loaded_module(GetModuleHandleW(L"ntdll.dll")).get_exported_function("LdrUnregisterDllNotification"); - hooks::getprocaddress_singleton_import_hook::getprocaddress_singleton_import_hook() : m_pfnGetProcAddress(GetProcAddress) , m_thunk("kernel32!GetProcAddress(Singleton Import Hook)", diff --git a/Dalamud.Boot/hooks.h b/Dalamud.Boot/hooks.h index ad3b2cc6c..f6ad370d1 100644 --- a/Dalamud.Boot/hooks.h +++ b/Dalamud.Boot/hooks.h @@ -1,6 +1,5 @@ #pragma once -#include #include #include "utils.h" diff --git a/Dalamud.Boot/ntdll.cpp b/Dalamud.Boot/ntdll.cpp new file mode 100644 index 000000000..9bda0e1c4 --- /dev/null +++ b/Dalamud.Boot/ntdll.cpp @@ -0,0 +1,15 @@ +#include "pch.h" + +#include "ntdll.h" + +#include "utils.h" + +NTSTATUS LdrRegisterDllNotification(ULONG Flags, PLDR_DLL_NOTIFICATION_FUNCTION NotificationFunction, PVOID Context, PVOID* Cookie) { + static const auto pfn = utils::loaded_module(GetModuleHandleW(L"ntdll.dll")).get_exported_function("LdrRegisterDllNotification"); + return pfn(Flags, NotificationFunction, Context, Cookie); +} + +NTSTATUS LdrUnregisterDllNotification(PVOID Cookie) { + static const auto pfn = utils::loaded_module(GetModuleHandleW(L"ntdll.dll")).get_exported_function("LdrUnregisterDllNotification"); + return pfn(Cookie); +} diff --git a/Dalamud.Boot/ntdll.h b/Dalamud.Boot/ntdll.h new file mode 100644 index 000000000..c631475d1 --- /dev/null +++ b/Dalamud.Boot/ntdll.h @@ -0,0 +1,33 @@ +#pragma once + +// ntdll exports +enum { + LDR_DLL_NOTIFICATION_REASON_LOADED = 1, + LDR_DLL_NOTIFICATION_REASON_UNLOADED = 2, +}; + +struct LDR_DLL_UNLOADED_NOTIFICATION_DATA { + ULONG Flags; //Reserved. + const UNICODE_STRING* FullDllName; //The full path name of the DLL module. + const UNICODE_STRING* BaseDllName; //The base file name of the DLL module. + PVOID DllBase; //A pointer to the base address for the DLL in memory. + ULONG SizeOfImage; //The size of the DLL image, in bytes. +}; + +struct LDR_DLL_LOADED_NOTIFICATION_DATA { + ULONG Flags; //Reserved. + const UNICODE_STRING* FullDllName; //The full path name of the DLL module. + const UNICODE_STRING* BaseDllName; //The base file name of the DLL module. + PVOID DllBase; //A pointer to the base address for the DLL in memory. + ULONG SizeOfImage; //The size of the DLL image, in bytes. +}; + +union LDR_DLL_NOTIFICATION_DATA { + LDR_DLL_LOADED_NOTIFICATION_DATA Loaded; + LDR_DLL_UNLOADED_NOTIFICATION_DATA Unloaded; +}; + +using PLDR_DLL_NOTIFICATION_FUNCTION = VOID CALLBACK(_In_ ULONG NotificationReason, _In_ const LDR_DLL_NOTIFICATION_DATA* NotificationData, _In_opt_ PVOID Context); + +NTSTATUS LdrRegisterDllNotification(ULONG Flags, PLDR_DLL_NOTIFICATION_FUNCTION NotificationFunction, PVOID Context, PVOID* Cookie); +NTSTATUS LdrUnregisterDllNotification(PVOID Cookie); diff --git a/Dalamud.Boot/pch.h b/Dalamud.Boot/pch.h index a09882c74..c2194c157 100644 --- a/Dalamud.Boot/pch.h +++ b/Dalamud.Boot/pch.h @@ -15,14 +15,20 @@ #include // Windows Header Files (2) +#include #include #include +#include #include #include #include +#include #include #include +// Windows Header Files (3) +#include // Must be loaded after iphlpapi.h + // MSVC Compiler Intrinsic #include @@ -30,6 +36,7 @@ #include // C++ Standard Libraries +#include #include #include #include diff --git a/Dalamud.Boot/xivfixes.cpp b/Dalamud.Boot/xivfixes.cpp index 39cce53c9..f3b6aaa2c 100644 --- a/Dalamud.Boot/xivfixes.cpp +++ b/Dalamud.Boot/xivfixes.cpp @@ -5,9 +5,8 @@ #include "DalamudStartInfo.h" #include "hooks.h" #include "logging.h" +#include "ntdll.h" #include "utils.h" -#include -#include template static std::span assume_nonempty_span(std::span t, const char* descr) { @@ -546,6 +545,109 @@ void xivfixes::prevent_icmphandle_crashes(bool bApply) { } } +void xivfixes::symbol_load_patches(bool bApply) { + static const char* LogTag = "[xivfixes:symbol_load_patches]"; + + static std::optional> s_hookSymInitialize; + static PVOID s_dllNotificationCookie = nullptr; + + static const auto RemoveFullPathPdbInfo = [](const utils::loaded_module& mod) { + const auto ddva = mod.data_directory(IMAGE_DIRECTORY_ENTRY_DEBUG).VirtualAddress; + if (!ddva) + return; + + const auto& ddir = mod.ref_as(ddva); + if (ddir.Type == IMAGE_DEBUG_TYPE_CODEVIEW) { + // The Visual C++ debug information. + // Ghidra calls it "DotNetPdbInfo". + static constexpr DWORD DotNetPdbInfoSignatureValue = 0x53445352; + struct DotNetPdbInfo { + DWORD Signature; // RSDS + GUID Guid; + DWORD Age; + char PdbPath[1]; + }; + + const auto& pdbref = mod.ref_as(ddir.AddressOfRawData); + if (pdbref.Signature == DotNetPdbInfoSignatureValue) { + const auto pathSpan = std::string_view(pdbref.PdbPath, strlen(pdbref.PdbPath)); + const auto pathWide = unicode::convert(pathSpan); + std::wstring windowsDirectory(GetWindowsDirectoryW(nullptr, 0) + 1, L'\0'); + windowsDirectory.resize( + GetWindowsDirectoryW(windowsDirectory.data(), static_cast(windowsDirectory.size()))); + if (!PathIsRelativeW(pathWide.c_str()) && !PathIsSameRootW(windowsDirectory.c_str(), pathWide.c_str())) { + utils::memory_tenderizer pathOverwrite(&pdbref.PdbPath, pathSpan.size(), PAGE_READWRITE); + auto sep = std::find(pathSpan.rbegin(), pathSpan.rend(), '/'); + if (sep == pathSpan.rend()) + sep = std::find(pathSpan.rbegin(), pathSpan.rend(), '\\'); + if (sep != pathSpan.rend()) { + logging::I( + "{} Stripping pdb path folder: {} to {}", + LogTag, + pathSpan, + &*sep + 1); + memmove(const_cast(pathSpan.data()), &*sep + 1, sep - pathSpan.rbegin() + 1); + } else { + logging::I("{} Leaving pdb path unchanged: {}", LogTag, pathSpan); + } + } else { + logging::I("{} Leaving pdb path unchanged: {}", LogTag, pathSpan); + } + } else { + logging::I("{} CODEVIEW struct signature mismatch: got {:08X} instead.", LogTag, pdbref.Signature); + } + } else { + logging::I("{} Debug directory: type {} is unsupported.", LogTag, ddir.Type); + } + }; + + if (bApply) { + if (!g_startInfo.BootEnabledGameFixes.contains("symbol_load_patches")) { + logging::I("{} Turned off via environment variable.", LogTag); + return; + } + + for (const auto& mod : utils::loaded_module::all_modules()) + RemoveFullPathPdbInfo(mod); + + if (!s_dllNotificationCookie) { + const auto res = LdrRegisterDllNotification( + 0, + [](ULONG notiReason, const LDR_DLL_NOTIFICATION_DATA* pData, void* /* context */) { + if (notiReason == LDR_DLL_NOTIFICATION_REASON_LOADED) + RemoveFullPathPdbInfo(pData->Loaded.DllBase); + }, + nullptr, + &s_dllNotificationCookie); + + if (res != STATUS_SUCCESS) { + logging::E("{} LdrRegisterDllNotification failure: 0x{:08X}", LogTag, res); + s_dllNotificationCookie = nullptr; + } + } + + s_hookSymInitialize.emplace("dbghelp.dll!SymInitialize (import, symbol_load_patches)", "dbghelp.dll", "SymInitialize", 0); + s_hookSymInitialize->set_detour([](HANDLE hProcess, PCSTR UserSearchPath, BOOL fInvadeProcess) noexcept { + logging::I("{} Suppressed SymInitialize.", LogTag); + SetLastError(ERROR_NOT_SUPPORTED); + return FALSE; + }); + + logging::I("{} Enable", LogTag); + } + else { + if (s_hookSymInitialize) { + logging::I("{} Disable", LogTag); + s_hookSymInitialize.reset(); + } + + if (s_dllNotificationCookie) { + (void)LdrUnregisterDllNotification(s_dllNotificationCookie); + s_dllNotificationCookie = nullptr; + } + } +} + void xivfixes::apply_all(bool bApply) { for (const auto& [taskName, taskFunction] : std::initializer_list> { @@ -554,7 +656,8 @@ void xivfixes::apply_all(bool bApply) { { "disable_game_openprocess_access_check", &disable_game_openprocess_access_check }, { "redirect_openprocess", &redirect_openprocess }, { "backup_userdata_save", &backup_userdata_save }, - { "prevent_icmphandle_crashes", &prevent_icmphandle_crashes } + { "prevent_icmphandle_crashes", &prevent_icmphandle_crashes }, + { "symbol_load_patches", &symbol_load_patches }, } ) { try { diff --git a/Dalamud.Boot/xivfixes.h b/Dalamud.Boot/xivfixes.h index f534ad7dd..afe2edb45 100644 --- a/Dalamud.Boot/xivfixes.h +++ b/Dalamud.Boot/xivfixes.h @@ -7,6 +7,7 @@ namespace xivfixes { void redirect_openprocess(bool bApply); void backup_userdata_save(bool bApply); void prevent_icmphandle_crashes(bool bApply); + void symbol_load_patches(bool bApply); void apply_all(bool bApply); } diff --git a/Dalamud.Injector/EntryPoint.cs b/Dalamud.Injector/EntryPoint.cs index 2d776b043..9085eae04 100644 --- a/Dalamud.Injector/EntryPoint.cs +++ b/Dalamud.Injector/EntryPoint.cs @@ -395,9 +395,15 @@ namespace Dalamud.Injector startInfo.BootShowConsole = args.Contains("--console"); startInfo.BootEnableEtw = args.Contains("--etw"); startInfo.BootLogPath = GetLogPath(startInfo.LogPath, "dalamud.boot", startInfo.LogName); - startInfo.BootEnabledGameFixes = new List { - "prevent_devicechange_crashes", "disable_game_openprocess_access_check", - "redirect_openprocess", "backup_userdata_save", "prevent_icmphandle_crashes", + startInfo.BootEnabledGameFixes = new() + { + // See: xivfixes.h, xivfixes.cpp + "prevent_devicechange_crashes", + "disable_game_openprocess_access_check", + "redirect_openprocess", + "backup_userdata_save", + "prevent_icmphandle_crashes", + "symbol_load_patches", }; startInfo.BootDotnetOpenProcessHookMode = 0; startInfo.BootWaitMessageBox |= args.Contains("--msgbox1") ? 1 : 0; From 5f62c703bff4137a1f887553fc1e0bd932d6dc6e Mon Sep 17 00:00:00 2001 From: Asriel Camora Date: Thu, 29 Feb 2024 15:15:02 -0800 Subject: [PATCH 02/13] 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 From 8a21fc721f58c1955e2aff81a25d2d77677ec686 Mon Sep 17 00:00:00 2001 From: Aireil <33433913+Aireil@users.noreply.github.com> Date: Mon, 4 Mar 2024 18:51:25 +0100 Subject: [PATCH 03/13] feat: add AdjustedTotalCastTime to BattleChara (#1694) * feat: add AdjustedTotalCastTime to BattleChara * Update Dalamud/Game/ClientState/Objects/Types/BattleChara.cs Co-authored-by: KazWolfe --------- Co-authored-by: KazWolfe --- .../Game/ClientState/Objects/Types/BattleChara.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Dalamud/Game/ClientState/Objects/Types/BattleChara.cs b/Dalamud/Game/ClientState/Objects/Types/BattleChara.cs index 63a5b828a..0c5d16675 100644 --- a/Dalamud/Game/ClientState/Objects/Types/BattleChara.cs +++ b/Dalamud/Game/ClientState/Objects/Types/BattleChara.cs @@ -1,6 +1,7 @@ using System; using Dalamud.Game.ClientState.Statuses; +using Dalamud.Utility; namespace Dalamud.Game.ClientState.Objects.Types; @@ -57,8 +58,22 @@ public unsafe class BattleChara : Character /// /// Gets the total casting time of the spell being cast by the chara. /// + /// + /// This can only be a portion of the total cast for some actions. + /// Use AdjustedTotalCastTime if you always need the total cast time. + /// + [Api10ToDo("Rename so it is not confused with AdjustedTotalCastTime")] public float TotalCastTime => this.Struct->GetCastInfo->TotalCastTime; + /// + /// Gets the plus any adjustments from the game, such as Action offset 2B. Used for display purposes. + /// + /// + /// This is the actual total cast time for all actions. + /// + [Api10ToDo("Rename so it is not confused with TotalCastTime")] + public float AdjustedTotalCastTime => this.Struct->GetCastInfo->AdjustedTotalCastTime; + /// /// Gets the underlying structure. /// From 2cdc1f017177ad4ab0ffa2283c51bcd9051e278f Mon Sep 17 00:00:00 2001 From: Asriel Camora Date: Tue, 5 Mar 2024 09:13:43 -0800 Subject: [PATCH 04/13] Fix duty pop chat message italics (#1697) --- Dalamud/Game/Network/Internal/NetworkHandlers.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Dalamud/Game/Network/Internal/NetworkHandlers.cs b/Dalamud/Game/Network/Internal/NetworkHandlers.cs index 76d3b5659..8d5ec1344 100644 --- a/Dalamud/Game/Network/Internal/NetworkHandlers.cs +++ b/Dalamud/Game/Network/Internal/NetworkHandlers.cs @@ -12,6 +12,7 @@ using Dalamud.Game.Gui; using Dalamud.Game.Network.Internal.MarketBoardUploaders; using Dalamud.Game.Network.Internal.MarketBoardUploaders.Universalis; using Dalamud.Game.Network.Structures; +using Dalamud.Game.Text.SeStringHandling; using Dalamud.Hooking; using Dalamud.Networking.Http; using Dalamud.Utility; @@ -268,8 +269,8 @@ internal unsafe class NetworkHandlers : IDisposable, IServiceType return result; } - var cfcName = cfCondition.Name.ToString(); - if (cfcName.IsNullOrEmpty()) + var cfcName = cfCondition.Name.ToDalamudString(); + if (cfcName.Payloads.Count == 0) { cfcName = "Duty Roulette"; cfCondition.Image = 112324; @@ -279,7 +280,10 @@ internal unsafe class NetworkHandlers : IDisposable, IServiceType { if (this.configuration.DutyFinderChatMessage) { - Service.GetNullable()?.Print($"Duty pop: {cfcName}"); + var b = new SeStringBuilder(); + b.Append("Duty pop: "); + b.Append(cfcName); + Service.GetNullable()?.Print(b.Build()); } this.CfPop.InvokeSafely(cfCondition); From c326537f9f21574af97323bc8a0503f37c0ef399 Mon Sep 17 00:00:00 2001 From: Soreepeong Date: Thu, 7 Mar 2024 00:37:46 +0900 Subject: [PATCH 05/13] test --- Dalamud/Interface/Internal/DalamudCommands.cs | 11 - Dalamud/Interface/Internal/DalamudIme.cs | 512 +++++++++++++----- .../Interface/Internal/DalamudInterface.cs | 18 - .../Interface/Internal/InterfaceManager.cs | 5 - .../Internal/Windows/DalamudImeWindow.cs | 266 --------- Dalamud/ServiceManager.cs | 15 +- 6 files changed, 383 insertions(+), 444 deletions(-) delete mode 100644 Dalamud/Interface/Internal/Windows/DalamudImeWindow.cs diff --git a/Dalamud/Interface/Internal/DalamudCommands.cs b/Dalamud/Interface/Internal/DalamudCommands.cs index ace8887f1..b64df8f19 100644 --- a/Dalamud/Interface/Internal/DalamudCommands.cs +++ b/Dalamud/Interface/Internal/DalamudCommands.cs @@ -96,12 +96,6 @@ internal class DalamudCommands : IServiceType ShowInHelp = false, }); - commandManager.AddHandler("/xlime", new CommandInfo(this.OnDebugDrawIMEPanel) - { - HelpMessage = Loc.Localize("DalamudIMEPanelHelp", "Draw IME panel"), - ShowInHelp = false, - }); - commandManager.AddHandler("/xllog", new CommandInfo(this.OnOpenLog) { HelpMessage = Loc.Localize("DalamudDevLogHelp", "Open dev log DEBUG"), @@ -308,11 +302,6 @@ internal class DalamudCommands : IServiceType dalamudInterface.ToggleDataWindow(arguments); } - private void OnDebugDrawIMEPanel(string command, string arguments) - { - Service.Get().OpenImeWindow(); - } - private void OnOpenLog(string command, string arguments) { Service.Get().ToggleLogWindow(); diff --git a/Dalamud/Interface/Internal/DalamudIme.cs b/Dalamud/Interface/Internal/DalamudIme.cs index 1ee248b17..6c01b74d7 100644 --- a/Dalamud/Interface/Internal/DalamudIme.cs +++ b/Dalamud/Interface/Internal/DalamudIme.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Numerics; +using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; @@ -17,6 +18,8 @@ using Dalamud.Interface.Utility; using ImGuiNET; +using Serilog; + using TerraFX.Interop.Windows; using static TerraFX.Interop.Windows.Windows; @@ -26,12 +29,21 @@ namespace Dalamud.Interface.Internal; /// /// This class handles CJK IME. /// -[ServiceManager.BlockingEarlyLoadedService] +[ServiceManager.EarlyLoadedService] internal sealed unsafe class DalamudIme : IDisposable, IServiceType { private const int CImGuiStbTextCreateUndoOffset = 0xB57A0; private const int CImGuiStbTextUndoOffset = 0xB59C0; + private const int ImePageSize = 9; + + private static readonly Dictionary WmNames = + typeof(WM).GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(x => x.IsLiteral && !x.IsInitOnly && x.FieldType == typeof(int)) + .Select(x => ((int)x.GetRawConstantValue()!, x.Name)) + .DistinctBy(x => x.Item1) + .ToDictionary(x => x.Item1, x => x.Name); + private static readonly UnicodeRange[] HanRange = { UnicodeRanges.CjkRadicalsSupplement, @@ -57,8 +69,41 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType private static readonly delegate* unmanaged StbTextUndo; + [ServiceManager.ServiceDependency] + private readonly WndProcHookManager wndProcHookManager = Service.Get(); + + private readonly InterfaceManager interfaceManager; + private readonly ImGuiSetPlatformImeDataDelegate setPlatformImeDataDelegate; + /// The candidates. + private readonly List candidateStrings = new(); + + /// The selected imm component. + private string compositionString = string.Empty; + + /// The cursor position in screen coordinates. + private Vector2 cursorScreenPos; + + /// The associated viewport. + private ImGuiViewportPtr associatedViewport; + + /// The index of the first imm candidate in relation to the full list. + private CANDIDATELIST immCandNative; + + /// The partial conversion from-range. + private int partialConversionFrom; + + /// The partial conversion to-range. + private int partialConversionTo; + + /// The cursor offset in the composition string. + private int compositionCursorOffset; + + /// The input mode icon from . + private char inputModeIcon; + + /// Undo range for modifying the buffer while composition is in progress. private (int Start, int End, int Cursor)? temporaryUndoSelection; [SuppressMessage("StyleCop.CSharp.SpacingRules", "SA1003:Symbols should be spaced correctly", Justification = ".")] @@ -87,7 +132,17 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType } [ServiceManager.ServiceConstructor] - private DalamudIme() => this.setPlatformImeDataDelegate = this.ImGuiSetPlatformImeData; + private DalamudIme(InterfaceManager.InterfaceManagerWithScene imws) + { + Debug.Assert(ImGuiHelpers.IsImGuiInitialized, "IMWS initialized but IsImGuiInitialized is false?"); + + this.interfaceManager = imws.Manager; + this.setPlatformImeDataDelegate = this.ImGuiSetPlatformImeData; + + ImGui.GetIO().SetPlatformImeDataFn = Marshal.GetFunctionPointerForDelegate(this.setPlatformImeDataDelegate); + this.interfaceManager.Draw += this.Draw; + this.wndProcHookManager.PreWndProc += this.WndProcHookManagerOnPreWndProc; + } /// /// Finalizes an instance of the class. @@ -109,7 +164,7 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType /// /// Gets a value indicating whether to display the cursor in input text. This also deals with blinking. /// - internal static bool ShowCursorInInputText + private static bool ShowCursorInInputText { get { @@ -126,63 +181,21 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType } } - /// - /// Gets the cursor position, in screen coordinates. - /// - internal Vector2 CursorPos { get; private set; } - - /// - /// Gets the associated viewport. - /// - internal ImGuiViewportPtr AssociatedViewport { get; private set; } - - /// - /// Gets the index of the first imm candidate in relation to the full list. - /// - internal CANDIDATELIST ImmCandNative { get; private set; } - - /// - /// Gets the imm candidates. - /// - internal List ImmCand { get; private set; } = new(); - - /// - /// Gets the selected imm component. - /// - internal string ImmComp { get; private set; } = string.Empty; - - /// - /// Gets the partial conversion from-range. - /// - internal int PartialConversionFrom { get; private set; } - - /// - /// Gets the partial conversion to-range. - /// - internal int PartialConversionTo { get; private set; } - - /// - /// Gets the cursor offset in the composition string. - /// - internal int CompositionCursorOffset { get; private set; } - - /// - /// Gets a value indicating whether to display partial conversion status. - /// - internal bool ShowPartialConversion => this.PartialConversionFrom != 0 || - this.PartialConversionTo != this.ImmComp.Length; - - /// - /// Gets the input mode icon from . - /// - internal char InputModeIcon { get; private set; } - private static ImGuiInputTextState* TextState => (ImGuiInputTextState*)(ImGui.GetCurrentContext() + ImGuiContextOffsets.TextStateOffset); + /// Gets a value indicating whether to display partial conversion status. + private bool ShowPartialConversion => this.partialConversionFrom != 0 || + this.partialConversionTo != this.compositionString.Length; + + /// Gets a value indicating whether to draw. + private bool ShouldDraw => + this.candidateStrings.Count != 0 || this.ShowPartialConversion || this.inputModeIcon != default; + /// public void Dispose() { + this.interfaceManager.Draw -= this.Draw; this.ReleaseUnmanagedResources(); GC.SuppressFinalize(this); } @@ -195,13 +208,13 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType { foreach (var chr in str) { - if (HanRange.Any(x => x.FirstCodePoint <= chr && chr < x.FirstCodePoint + x.Length)) + if (!this.EncounteredHan) { - if (Service.Get() - ?.GetFdtReader(GameFontFamilyAndSize.Axis12) - .FindGlyph(chr) is null) + if (HanRange.Any(x => x.FirstCodePoint <= chr && chr < x.FirstCodePoint + x.Length)) { - if (!this.EncounteredHan) + if (Service.Get() + ?.GetFdtReader(GameFontFamilyAndSize.Axis12) + .FindGlyph(chr) is null) { this.EncounteredHan = true; Service.Get().RebuildFonts(); @@ -209,9 +222,9 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType } } - if (HangulRange.Any(x => x.FirstCodePoint <= chr && chr < x.FirstCodePoint + x.Length)) + if (!this.EncounteredHangul) { - if (!this.EncounteredHangul) + if (HangulRange.Any(x => x.FirstCodePoint <= chr && chr < x.FirstCodePoint + x.Length)) { this.EncounteredHangul = true; Service.Get().RebuildFonts(); @@ -220,11 +233,24 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType } } - /// - /// Processes window messages. - /// - /// The arguments. - public void ProcessImeMessage(WndProcEventArgs args) + private static string ImmGetCompositionString(HIMC hImc, uint comp) + { + var numBytes = ImmGetCompositionStringW(hImc, comp, null, 0); + if (numBytes == 0) + return string.Empty; + + var data = stackalloc char[numBytes / 2]; + _ = ImmGetCompositionStringW(hImc, comp, data, (uint)numBytes); + return new(data, 0, numBytes / 2); + } + + private void ReleaseUnmanagedResources() + { + if (ImGuiHelpers.IsImGuiInitialized) + ImGui.GetIO().SetPlatformImeDataFn = nint.Zero; + } + + private void WndProcHookManagerOnPreWndProc(WndProcEventArgs args) { if (!ImGuiHelpers.IsImGuiInitialized) return; @@ -246,7 +272,7 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType case WM.WM_IME_NOTIFY when (nint)args.WParam is IMN.IMN_OPENCANDIDATE or IMN.IMN_CLOSECANDIDATE or IMN.IMN_CHANGECANDIDATE: - this.UpdateImeWindowStatus(hImc); + this.UpdateCandidates(hImc); args.SuppressWithValue(0); break; @@ -260,22 +286,22 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType else this.ReplaceCompositionString(hImc, (uint)args.LParam); - // Log.Verbose($"{nameof(WM.WM_IME_COMPOSITION)}({(nint)args.LParam:X}): {this.ImmComp}"); + // Log.Verbose($"{nameof(WM.WM_IME_COMPOSITION)}({(nint)args.LParam:X}): {this.compositionString}"); args.SuppressWithValue(0); break; case WM.WM_IME_ENDCOMPOSITION: - // Log.Verbose($"{nameof(WM.WM_IME_ENDCOMPOSITION)}({(nint)args.WParam:X}, {(nint)args.LParam:X}): {this.ImmComp}"); + // Log.Verbose($"{nameof(WM.WM_IME_ENDCOMPOSITION)}({(nint)args.WParam:X}, {(nint)args.LParam:X}): {this.compositionString}"); args.SuppressWithValue(0); break; case WM.WM_IME_CONTROL: - // Log.Verbose($"{nameof(WM.WM_IME_CONTROL)}({(nint)args.WParam:X}, {(nint)args.LParam:X}): {this.ImmComp}"); + // Log.Verbose($"{nameof(WM.WM_IME_CONTROL)}({(nint)args.WParam:X}, {(nint)args.LParam:X}): {this.compositionString}"); args.SuppressWithValue(0); break; case WM.WM_IME_REQUEST: - // Log.Verbose($"{nameof(WM.WM_IME_REQUEST)}({(nint)args.WParam:X}, {(nint)args.LParam:X}): {this.ImmComp}"); + // Log.Verbose($"{nameof(WM.WM_IME_REQUEST)}({(nint)args.WParam:X}, {(nint)args.LParam:X}): {this.compositionString}"); args.SuppressWithValue(0); break; @@ -283,12 +309,12 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType // Hide candidate and composition windows. args.LParam = (LPARAM)((nint)args.LParam & ~(ISC_SHOWUICOMPOSITIONWINDOW | 0xF)); - // Log.Verbose($"{nameof(WM.WM_IME_SETCONTEXT)}({(nint)args.WParam:X}, {(nint)args.LParam:X}): {this.ImmComp}"); + // Log.Verbose($"{nameof(WM.WM_IME_SETCONTEXT)}({(nint)args.WParam:X}, {(nint)args.LParam:X}): {this.compositionString}"); args.SuppressWithDefault(); break; case WM.WM_IME_NOTIFY: - // Log.Verbose($"{nameof(WM.WM_IME_NOTIFY)}({(nint)args.WParam:X}): {this.ImmComp}"); + // Log.Verbose($"{nameof(WM.WM_IME_NOTIFY)}({(nint)args.WParam:X}): {this.compositionString}"); break; case WM.WM_KEYDOWN when (int)args.WParam is @@ -302,12 +328,14 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType or VK.VK_RIGHT or VK.VK_DOWN or VK.VK_RETURN: - if (this.ImmCand.Count != 0) + if (this.candidateStrings.Count != 0) { this.ClearState(hImc); args.WParam = VK.VK_PROCESSKEY; } + this.UpdateCandidates(hImc); + break; case WM.WM_LBUTTONDOWN: @@ -316,9 +344,15 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType case WM.WM_XBUTTONDOWN: ImmNotifyIME(hImc, NI.NI_COMPOSITIONSTR, CPS_COMPLETE, 0); break; + + // default: + // Log.Verbose($"{(WmNames.TryGetValue((int)args.Message, out var v) ? v : args.Message.ToString())}({(nint)args.WParam:X}, {(nint)args.LParam:X})"); + // break; } this.UpdateInputLanguage(hImc); + if (this.inputModeIcon == (char)SeIconChar.ImeKoreanHangul) + this.UpdateCandidates(hImc); } finally { @@ -326,23 +360,6 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType } } - private static string ImmGetCompositionString(HIMC hImc, uint comp) - { - var numBytes = ImmGetCompositionStringW(hImc, comp, null, 0); - if (numBytes == 0) - return string.Empty; - - var data = stackalloc char[numBytes / 2]; - _ = ImmGetCompositionStringW(hImc, comp, data, (uint)numBytes); - return new(data, 0, numBytes / 2); - } - - private void ReleaseUnmanagedResources() - { - if (ImGuiHelpers.IsImGuiInitialized) - ImGui.GetIO().SetPlatformImeDataFn = nint.Zero; - } - private void UpdateInputLanguage(HIMC hImc) { uint conv, sent; @@ -359,41 +376,39 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType { case LANG.LANG_KOREAN: if (native) - this.InputModeIcon = (char)SeIconChar.ImeKoreanHangul; + this.inputModeIcon = (char)SeIconChar.ImeKoreanHangul; else if (fullwidth) - this.InputModeIcon = (char)SeIconChar.ImeAlphanumeric; + this.inputModeIcon = (char)SeIconChar.ImeAlphanumeric; else - this.InputModeIcon = (char)SeIconChar.ImeAlphanumericHalfWidth; + this.inputModeIcon = (char)SeIconChar.ImeAlphanumericHalfWidth; break; case LANG.LANG_JAPANESE: // wtf // see the function called from: 48 8b 0d ?? ?? ?? ?? e8 ?? ?? ?? ?? 8b d8 e9 ?? 00 00 0 if (open && native && katakana && fullwidth) - this.InputModeIcon = (char)SeIconChar.ImeKatakana; + this.inputModeIcon = (char)SeIconChar.ImeKatakana; else if (open && native && katakana) - this.InputModeIcon = (char)SeIconChar.ImeKatakanaHalfWidth; + this.inputModeIcon = (char)SeIconChar.ImeKatakanaHalfWidth; else if (open && native) - this.InputModeIcon = (char)SeIconChar.ImeHiragana; + this.inputModeIcon = (char)SeIconChar.ImeHiragana; else if (open && fullwidth) - this.InputModeIcon = (char)SeIconChar.ImeAlphanumeric; + this.inputModeIcon = (char)SeIconChar.ImeAlphanumeric; else - this.InputModeIcon = (char)SeIconChar.ImeAlphanumericHalfWidth; + this.inputModeIcon = (char)SeIconChar.ImeAlphanumericHalfWidth; break; case LANG.LANG_CHINESE: if (native) - this.InputModeIcon = (char)SeIconChar.ImeChineseHan; + this.inputModeIcon = (char)SeIconChar.ImeChineseHan; else - this.InputModeIcon = (char)SeIconChar.ImeChineseLatin; + this.inputModeIcon = (char)SeIconChar.ImeChineseLatin; break; default: - this.InputModeIcon = default; + this.inputModeIcon = default; break; } - - this.UpdateImeWindowStatus(hImc); } private void ReplaceCompositionString(HIMC hImc, uint comp) @@ -425,14 +440,14 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType return; } - this.ImmComp = newString; - this.CompositionCursorOffset = ImmGetCompositionStringW(hImc, GCS.GCS_CURSORPOS, null, 0); + this.compositionString = newString; + this.compositionCursorOffset = ImmGetCompositionStringW(hImc, GCS.GCS_CURSORPOS, null, 0); if ((comp & GCS.GCS_COMPATTR) != 0) { var attrLength = ImmGetCompositionStringW(hImc, GCS.GCS_COMPATTR, null, 0); var attrPtr = stackalloc byte[attrLength]; - var attr = new Span(attrPtr, Math.Min(this.ImmComp.Length, attrLength)); + var attr = new Span(attrPtr, Math.Min(this.compositionString.Length, attrLength)); _ = ImmGetCompositionStringW(hImc, GCS.GCS_COMPATTR, attrPtr, (uint)attrLength); var l = 0; while (l < attr.Length && attr[l] is not ATTR_TARGET_CONVERTED and not ATTR_TARGET_NOTCONVERTED) @@ -442,37 +457,37 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType while (r < attr.Length && attr[r] is ATTR_TARGET_CONVERTED or ATTR_TARGET_NOTCONVERTED) r++; - if (r == 0 || l == this.ImmComp.Length) - (l, r) = (0, this.ImmComp.Length); + if (r == 0 || l == this.compositionString.Length) + (l, r) = (0, this.compositionString.Length); - (this.PartialConversionFrom, this.PartialConversionTo) = (l, r); + (this.partialConversionFrom, this.partialConversionTo) = (l, r); } else { - this.PartialConversionFrom = 0; - this.PartialConversionTo = this.ImmComp.Length; + this.partialConversionFrom = 0; + this.partialConversionTo = this.compositionString.Length; } - this.UpdateImeWindowStatus(hImc); + this.UpdateCandidates(hImc); } private void ClearState(HIMC hImc) { - this.ImmComp = string.Empty; - this.PartialConversionFrom = this.PartialConversionTo = 0; - this.CompositionCursorOffset = 0; + this.compositionString = string.Empty; + this.partialConversionFrom = this.partialConversionTo = 0; + this.compositionCursorOffset = 0; this.temporaryUndoSelection = null; TextState->Stb.SelectStart = TextState->Stb.Cursor = TextState->Stb.SelectEnd; ImmNotifyIME(hImc, NI.NI_COMPOSITIONSTR, CPS_CANCEL, 0); - this.UpdateImeWindowStatus(default); + this.UpdateCandidates(default); // Log.Information($"{nameof(this.ClearState)}"); } - private void LoadCand(HIMC hImc) + private void UpdateCandidates(HIMC hImc) { - this.ImmCand.Clear(); - this.ImmCandNative = default; + this.candidateStrings.Clear(); + this.immCandNative = default; if (hImc == default) return; @@ -486,7 +501,7 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType return; ref var candlist = ref *(CANDIDATELIST*)pStorage; - this.ImmCandNative = candlist; + this.immCandNative = candlist; if (candlist.dwPageSize == 0 || candlist.dwCount == 0) return; @@ -495,39 +510,250 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType (int)candlist.dwPageStart, (int)Math.Min(candlist.dwCount - candlist.dwPageStart, candlist.dwPageSize))) { - this.ImmCand.Add(new((char*)(pStorage + candlist.dwOffset[i]))); - this.ReflectCharacterEncounters(this.ImmCand[^1]); + this.candidateStrings.Add(new((char*)(pStorage + candlist.dwOffset[i]))); + this.ReflectCharacterEncounters(this.candidateStrings[^1]); } } - private void UpdateImeWindowStatus(HIMC hImc) - { - if (Service.GetNullable() is not { } di) - return; - - this.LoadCand(hImc); - if (this.ImmCand.Count != 0 || this.ShowPartialConversion || this.InputModeIcon != default) - di.OpenImeWindow(); - else - di.CloseImeWindow(); - } - private void ImGuiSetPlatformImeData(ImGuiViewportPtr viewport, ImGuiPlatformImeDataPtr data) { - this.CursorPos = data.InputPos; - this.AssociatedViewport = data.WantVisible ? viewport : default; + this.cursorScreenPos = data.InputPos; + this.associatedViewport = data.WantVisible ? viewport : default; } - [ServiceManager.CallWhenServicesReady("Effectively waiting for cimgui context initialization.")] - private void ContinueConstruction(InterfaceManager.InterfaceManagerWithScene interfaceManagerWithScene) + private void Draw() { - if (!ImGuiHelpers.IsImGuiInitialized) + if (!this.ShouldDraw) + return; + + if (Service.GetNullable() is not { } ime) + return; + + var viewport = ime.associatedViewport; + if (viewport.NativePtr is null) + return; + + var drawCand = ime.candidateStrings.Count != 0; + var drawConv = drawCand || ime.ShowPartialConversion; + var drawIme = ime.inputModeIcon != 0; + var imeIconFont = InterfaceManager.DefaultFont; + + var pad = ImGui.GetStyle().WindowPadding; + var candTextSize = ImGui.CalcTextSize(ime.compositionString == string.Empty ? " " : ime.compositionString); + + var native = ime.immCandNative; + var totalIndex = native.dwSelection + 1; + var totalSize = native.dwCount; + + var pageStart = native.dwPageStart; + var pageIndex = (pageStart / ImePageSize) + 1; + var pageCount = (totalSize / ImePageSize) + 1; + var pageInfo = $"{totalIndex}/{totalSize} ({pageIndex}/{pageCount})"; + + // Calc the window size. + var maxTextWidth = 0f; + for (var i = 0; i < ime.candidateStrings.Count; i++) { - throw new InvalidOperationException( - $"Expected {nameof(InterfaceManager.InterfaceManagerWithScene)} to have initialized ImGui."); + var textSize = ImGui.CalcTextSize($"{i + 1}. {ime.candidateStrings[i]}"); + maxTextWidth = maxTextWidth > textSize.X ? maxTextWidth : textSize.X; } - ImGui.GetIO().SetPlatformImeDataFn = Marshal.GetFunctionPointerForDelegate(this.setPlatformImeDataDelegate); + maxTextWidth = maxTextWidth > ImGui.CalcTextSize(pageInfo).X ? maxTextWidth : ImGui.CalcTextSize(pageInfo).X; + maxTextWidth = maxTextWidth > ImGui.CalcTextSize(ime.compositionString).X + ? maxTextWidth + : ImGui.CalcTextSize(ime.compositionString).X; + + var numEntries = (drawCand ? ime.candidateStrings.Count + 1 : 0) + 1 + (drawIme ? 1 : 0); + var spaceY = ImGui.GetStyle().ItemSpacing.Y; + var imeWindowHeight = (spaceY * (numEntries - 1)) + (candTextSize.Y * numEntries); + var windowSize = new Vector2(maxTextWidth, imeWindowHeight) + (pad * 2); + + // 1. Figure out the expanding direction. + var expandUpward = ime.cursorScreenPos.Y + windowSize.Y > viewport.WorkPos.Y + viewport.WorkSize.Y; + var windowPos = ime.cursorScreenPos - pad; + if (expandUpward) + { + windowPos.Y -= windowSize.Y - candTextSize.Y - (pad.Y * 2); + if (drawIme) + windowPos.Y += candTextSize.Y + spaceY; + } + else + { + if (drawIme) + windowPos.Y -= candTextSize.Y + spaceY; + } + + // 2. Contain within the viewport. Do not use clamp, as the target window might be too small. + if (windowPos.X < viewport.WorkPos.X) + windowPos.X = viewport.WorkPos.X; + else if (windowPos.X + windowSize.X > viewport.WorkPos.X + viewport.WorkSize.X) + windowPos.X = (viewport.WorkPos.X + viewport.WorkSize.X) - windowSize.X; + if (windowPos.Y < viewport.WorkPos.Y) + windowPos.Y = viewport.WorkPos.Y; + else if (windowPos.Y + windowSize.Y > viewport.WorkPos.Y + viewport.WorkSize.Y) + windowPos.Y = (viewport.WorkPos.Y + viewport.WorkSize.Y) - windowSize.Y; + + var cursor = windowPos + pad; + + // Draw the ime window. + var drawList = ImGui.GetForegroundDrawList(viewport); + + // Draw the background rect for candidates. + if (drawCand) + { + Vector2 candRectLt, candRectRb; + if (!expandUpward) + { + candRectLt = windowPos + candTextSize with { X = 0 } + pad with { X = 0 }; + candRectRb = windowPos + windowSize; + if (drawIme) + candRectLt.Y += spaceY + candTextSize.Y; + } + else + { + candRectLt = windowPos; + candRectRb = windowPos + (windowSize - candTextSize with { X = 0 } - pad with { X = 0 }); + if (drawIme) + candRectRb.Y -= spaceY + candTextSize.Y; + } + + drawList.AddRectFilled( + candRectLt, + candRectRb, + ImGui.GetColorU32(ImGuiCol.WindowBg), + ImGui.GetStyle().WindowRounding); + } + + if (!expandUpward && drawIme) + { + for (var dx = -2; dx <= 2; dx++) + { + for (var dy = -2; dy <= 2; dy++) + { + if (dx != 0 || dy != 0) + { + imeIconFont.RenderChar( + drawList, + imeIconFont.FontSize, + cursor + new Vector2(dx, dy), + ImGui.GetColorU32(ImGuiCol.WindowBg), + ime.inputModeIcon); + } + } + } + + imeIconFont.RenderChar( + drawList, + imeIconFont.FontSize, + cursor, + ImGui.GetColorU32(ImGuiCol.Text), + ime.inputModeIcon); + cursor.Y += candTextSize.Y + spaceY; + } + + if (!expandUpward && drawConv) + { + DrawTextBeingConverted(); + cursor.Y += candTextSize.Y + spaceY; + + // Add a separator. + drawList.AddLine(cursor, cursor + new Vector2(maxTextWidth, 0), ImGui.GetColorU32(ImGuiCol.Separator)); + } + + if (drawCand) + { + // Add the candidate words. + for (var i = 0; i < ime.candidateStrings.Count; i++) + { + var selected = i == (native.dwSelection % ImePageSize); + var color = ImGui.GetColorU32(ImGuiCol.Text); + if (selected) + color = ImGui.GetColorU32(ImGuiCol.NavHighlight); + + drawList.AddText(cursor, color, $"{i + 1}. {ime.candidateStrings[i]}"); + cursor.Y += candTextSize.Y + spaceY; + } + + // Add a separator + drawList.AddLine(cursor, cursor + new Vector2(maxTextWidth, 0), ImGui.GetColorU32(ImGuiCol.Separator)); + + // Add the pages infomation. + drawList.AddText(cursor, ImGui.GetColorU32(ImGuiCol.Text), pageInfo); + cursor.Y += candTextSize.Y + spaceY; + } + + if (expandUpward && drawConv) + { + // Add a separator. + drawList.AddLine(cursor, cursor + new Vector2(maxTextWidth, 0), ImGui.GetColorU32(ImGuiCol.Separator)); + + DrawTextBeingConverted(); + cursor.Y += candTextSize.Y + spaceY; + } + + if (expandUpward && drawIme) + { + for (var dx = -2; dx <= 2; dx++) + { + for (var dy = -2; dy <= 2; dy++) + { + if (dx != 0 || dy != 0) + { + imeIconFont.RenderChar( + drawList, + imeIconFont.FontSize, + cursor + new Vector2(dx, dy), + ImGui.GetColorU32(ImGuiCol.WindowBg), + ime.inputModeIcon); + } + } + } + + imeIconFont.RenderChar( + drawList, + imeIconFont.FontSize, + cursor, + ImGui.GetColorU32(ImGuiCol.Text), + ime.inputModeIcon); + } + + return; + + void DrawTextBeingConverted() + { + // Draw the text background. + drawList.AddRectFilled( + cursor - (pad / 2), + cursor + candTextSize + (pad / 2), + ImGui.GetColorU32(ImGuiCol.WindowBg)); + + // If only a part of the full text is marked for conversion, then draw background for the part being edited. + if (ime.partialConversionFrom != 0 || ime.partialConversionTo != ime.compositionString.Length) + { + var part1 = ime.compositionString[..ime.partialConversionFrom]; + var part2 = ime.compositionString[..ime.partialConversionTo]; + var size1 = ImGui.CalcTextSize(part1); + var size2 = ImGui.CalcTextSize(part2); + drawList.AddRectFilled( + cursor + size1 with { Y = 0 }, + cursor + size2, + ImGui.GetColorU32(ImGuiCol.TextSelectedBg)); + } + + // Add the text being converted. + drawList.AddText(cursor, ImGui.GetColorU32(ImGuiCol.Text), ime.compositionString); + + // Draw the caret inside the composition string. + if (DalamudIme.ShowCursorInInputText) + { + var partBeforeCaret = ime.compositionString[..ime.compositionCursorOffset]; + var sizeBeforeCaret = ImGui.CalcTextSize(partBeforeCaret); + drawList.AddLine( + cursor + sizeBeforeCaret with { Y = 0 }, + cursor + sizeBeforeCaret, + ImGui.GetColorU32(ImGuiCol.Text)); + } + } } /// diff --git a/Dalamud/Interface/Internal/DalamudInterface.cs b/Dalamud/Interface/Internal/DalamudInterface.cs index 00bef19af..1a07cd6ae 100644 --- a/Dalamud/Interface/Internal/DalamudInterface.cs +++ b/Dalamud/Interface/Internal/DalamudInterface.cs @@ -61,7 +61,6 @@ internal class DalamudInterface : IDisposable, IServiceType private readonly ComponentDemoWindow componentDemoWindow; private readonly DataWindow dataWindow; private readonly GamepadModeNotifierWindow gamepadModeNotifierWindow; - private readonly DalamudImeWindow imeWindow; private readonly ConsoleWindow consoleWindow; private readonly PluginStatWindow pluginStatWindow; private readonly PluginInstallerWindow pluginWindow; @@ -114,7 +113,6 @@ internal class DalamudInterface : IDisposable, IServiceType this.componentDemoWindow = new ComponentDemoWindow() { IsOpen = false }; this.dataWindow = new DataWindow() { IsOpen = false }; this.gamepadModeNotifierWindow = new GamepadModeNotifierWindow() { IsOpen = false }; - this.imeWindow = new DalamudImeWindow() { IsOpen = false }; this.consoleWindow = new ConsoleWindow(configuration) { IsOpen = configuration.LogOpenAtStartup }; this.pluginStatWindow = new PluginStatWindow() { IsOpen = false }; this.pluginWindow = new PluginInstallerWindow(pluginImageCache, configuration) { IsOpen = false }; @@ -142,7 +140,6 @@ internal class DalamudInterface : IDisposable, IServiceType this.WindowSystem.AddWindow(this.componentDemoWindow); this.WindowSystem.AddWindow(this.dataWindow); this.WindowSystem.AddWindow(this.gamepadModeNotifierWindow); - this.WindowSystem.AddWindow(this.imeWindow); this.WindowSystem.AddWindow(this.consoleWindow); this.WindowSystem.AddWindow(this.pluginStatWindow); this.WindowSystem.AddWindow(this.pluginWindow); @@ -265,11 +262,6 @@ internal class DalamudInterface : IDisposable, IServiceType /// public void OpenGamepadModeNotifierWindow() => this.gamepadModeNotifierWindow.IsOpen = true; - /// - /// Opens the . - /// - public void OpenImeWindow() => this.imeWindow.IsOpen = true; - /// /// Opens the . /// @@ -365,11 +357,6 @@ internal class DalamudInterface : IDisposable, IServiceType #region Close - /// - /// Closes the . - /// - public void CloseImeWindow() => this.imeWindow.IsOpen = false; - /// /// Closes the . /// @@ -417,11 +404,6 @@ internal class DalamudInterface : IDisposable, IServiceType /// public void ToggleGamepadModeNotifierWindow() => this.gamepadModeNotifierWindow.Toggle(); - /// - /// Toggles the . - /// - public void ToggleImeWindow() => this.imeWindow.Toggle(); - /// /// Toggles the . /// diff --git a/Dalamud/Interface/Internal/InterfaceManager.cs b/Dalamud/Interface/Internal/InterfaceManager.cs index 3db799be0..126097ed3 100644 --- a/Dalamud/Interface/Internal/InterfaceManager.cs +++ b/Dalamud/Interface/Internal/InterfaceManager.cs @@ -67,9 +67,6 @@ internal class InterfaceManager : IDisposable, IServiceType [ServiceManager.ServiceDependency] private readonly WndProcHookManager wndProcHookManager = Service.Get(); - - [ServiceManager.ServiceDependency] - private readonly DalamudIme dalamudIme = Service.Get(); private readonly SwapChainVtableResolver address = new(); private readonly Hook setCursorHook; @@ -627,8 +624,6 @@ internal class InterfaceManager : IDisposable, IServiceType var r = this.scene?.ProcessWndProcW(args.Hwnd, (User32.WindowMessage)args.Message, args.WParam, args.LParam); if (r is not null) args.SuppressWithValue(r.Value); - - this.dalamudIme.ProcessImeMessage(args); } /* diff --git a/Dalamud/Interface/Internal/Windows/DalamudImeWindow.cs b/Dalamud/Interface/Internal/Windows/DalamudImeWindow.cs deleted file mode 100644 index ecaa522e5..000000000 --- a/Dalamud/Interface/Internal/Windows/DalamudImeWindow.cs +++ /dev/null @@ -1,266 +0,0 @@ -using System.Numerics; - -using Dalamud.Interface.Windowing; - -using ImGuiNET; - -namespace Dalamud.Interface.Internal.Windows; - -/// -/// A window for displaying IME details. -/// -internal unsafe class DalamudImeWindow : Window -{ - private const int ImePageSize = 9; - - /// - /// Initializes a new instance of the class. - /// - public DalamudImeWindow() - : base( - "Dalamud IME", - ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoBackground) - { - this.Size = default(Vector2); - - this.RespectCloseHotkey = false; - } - - /// - public override void Draw() - { - } - - /// - public override void PostDraw() - { - if (Service.GetNullable() is not { } ime) - return; - - var viewport = ime.AssociatedViewport; - if (viewport.NativePtr is null) - return; - - var drawCand = ime.ImmCand.Count != 0; - var drawConv = drawCand || ime.ShowPartialConversion; - var drawIme = ime.InputModeIcon != 0; - var imeIconFont = InterfaceManager.DefaultFont; - - var pad = ImGui.GetStyle().WindowPadding; - var candTextSize = ImGui.CalcTextSize(ime.ImmComp == string.Empty ? " " : ime.ImmComp); - - var native = ime.ImmCandNative; - var totalIndex = native.dwSelection + 1; - var totalSize = native.dwCount; - - var pageStart = native.dwPageStart; - var pageIndex = (pageStart / ImePageSize) + 1; - var pageCount = (totalSize / ImePageSize) + 1; - var pageInfo = $"{totalIndex}/{totalSize} ({pageIndex}/{pageCount})"; - - // Calc the window size. - var maxTextWidth = 0f; - for (var i = 0; i < ime.ImmCand.Count; i++) - { - var textSize = ImGui.CalcTextSize($"{i + 1}. {ime.ImmCand[i]}"); - maxTextWidth = maxTextWidth > textSize.X ? maxTextWidth : textSize.X; - } - - maxTextWidth = maxTextWidth > ImGui.CalcTextSize(pageInfo).X ? maxTextWidth : ImGui.CalcTextSize(pageInfo).X; - maxTextWidth = maxTextWidth > ImGui.CalcTextSize(ime.ImmComp).X - ? maxTextWidth - : ImGui.CalcTextSize(ime.ImmComp).X; - - var numEntries = (drawCand ? ime.ImmCand.Count + 1 : 0) + 1 + (drawIme ? 1 : 0); - var spaceY = ImGui.GetStyle().ItemSpacing.Y; - var imeWindowHeight = (spaceY * (numEntries - 1)) + (candTextSize.Y * numEntries); - var windowSize = new Vector2(maxTextWidth, imeWindowHeight) + (pad * 2); - - // 1. Figure out the expanding direction. - var expandUpward = ime.CursorPos.Y + windowSize.Y > viewport.WorkPos.Y + viewport.WorkSize.Y; - var windowPos = ime.CursorPos - pad; - if (expandUpward) - { - windowPos.Y -= windowSize.Y - candTextSize.Y - (pad.Y * 2); - if (drawIme) - windowPos.Y += candTextSize.Y + spaceY; - } - else - { - if (drawIme) - windowPos.Y -= candTextSize.Y + spaceY; - } - - // 2. Contain within the viewport. Do not use clamp, as the target window might be too small. - if (windowPos.X < viewport.WorkPos.X) - windowPos.X = viewport.WorkPos.X; - else if (windowPos.X + windowSize.X > viewport.WorkPos.X + viewport.WorkSize.X) - windowPos.X = (viewport.WorkPos.X + viewport.WorkSize.X) - windowSize.X; - if (windowPos.Y < viewport.WorkPos.Y) - windowPos.Y = viewport.WorkPos.Y; - else if (windowPos.Y + windowSize.Y > viewport.WorkPos.Y + viewport.WorkSize.Y) - windowPos.Y = (viewport.WorkPos.Y + viewport.WorkSize.Y) - windowSize.Y; - - var cursor = windowPos + pad; - - // Draw the ime window. - var drawList = ImGui.GetForegroundDrawList(viewport); - - // Draw the background rect for candidates. - if (drawCand) - { - Vector2 candRectLt, candRectRb; - if (!expandUpward) - { - candRectLt = windowPos + candTextSize with { X = 0 } + pad with { X = 0 }; - candRectRb = windowPos + windowSize; - if (drawIme) - candRectLt.Y += spaceY + candTextSize.Y; - } - else - { - candRectLt = windowPos; - candRectRb = windowPos + (windowSize - candTextSize with { X = 0 } - pad with { X = 0 }); - if (drawIme) - candRectRb.Y -= spaceY + candTextSize.Y; - } - - drawList.AddRectFilled( - candRectLt, - candRectRb, - ImGui.GetColorU32(ImGuiCol.WindowBg), - ImGui.GetStyle().WindowRounding); - } - - if (!expandUpward && drawIme) - { - for (var dx = -2; dx <= 2; dx++) - { - for (var dy = -2; dy <= 2; dy++) - { - if (dx != 0 || dy != 0) - { - imeIconFont.RenderChar( - drawList, - imeIconFont.FontSize, - cursor + new Vector2(dx, dy), - ImGui.GetColorU32(ImGuiCol.WindowBg), - ime.InputModeIcon); - } - } - } - - imeIconFont.RenderChar( - drawList, - imeIconFont.FontSize, - cursor, - ImGui.GetColorU32(ImGuiCol.Text), - ime.InputModeIcon); - cursor.Y += candTextSize.Y + spaceY; - } - - if (!expandUpward && drawConv) - { - DrawTextBeingConverted(); - cursor.Y += candTextSize.Y + spaceY; - - // Add a separator. - drawList.AddLine(cursor, cursor + new Vector2(maxTextWidth, 0), ImGui.GetColorU32(ImGuiCol.Separator)); - } - - if (drawCand) - { - // Add the candidate words. - for (var i = 0; i < ime.ImmCand.Count; i++) - { - var selected = i == (native.dwSelection % ImePageSize); - var color = ImGui.GetColorU32(ImGuiCol.Text); - if (selected) - color = ImGui.GetColorU32(ImGuiCol.NavHighlight); - - drawList.AddText(cursor, color, $"{i + 1}. {ime.ImmCand[i]}"); - cursor.Y += candTextSize.Y + spaceY; - } - - // Add a separator - drawList.AddLine(cursor, cursor + new Vector2(maxTextWidth, 0), ImGui.GetColorU32(ImGuiCol.Separator)); - - // Add the pages infomation. - drawList.AddText(cursor, ImGui.GetColorU32(ImGuiCol.Text), pageInfo); - cursor.Y += candTextSize.Y + spaceY; - } - - if (expandUpward && drawConv) - { - // Add a separator. - drawList.AddLine(cursor, cursor + new Vector2(maxTextWidth, 0), ImGui.GetColorU32(ImGuiCol.Separator)); - - DrawTextBeingConverted(); - cursor.Y += candTextSize.Y + spaceY; - } - - if (expandUpward && drawIme) - { - for (var dx = -2; dx <= 2; dx++) - { - for (var dy = -2; dy <= 2; dy++) - { - if (dx != 0 || dy != 0) - { - imeIconFont.RenderChar( - drawList, - imeIconFont.FontSize, - cursor + new Vector2(dx, dy), - ImGui.GetColorU32(ImGuiCol.WindowBg), - ime.InputModeIcon); - } - } - } - - imeIconFont.RenderChar( - drawList, - imeIconFont.FontSize, - cursor, - ImGui.GetColorU32(ImGuiCol.Text), - ime.InputModeIcon); - } - - return; - - void DrawTextBeingConverted() - { - // Draw the text background. - drawList.AddRectFilled( - cursor - (pad / 2), - cursor + candTextSize + (pad / 2), - ImGui.GetColorU32(ImGuiCol.WindowBg)); - - // If only a part of the full text is marked for conversion, then draw background for the part being edited. - if (ime.PartialConversionFrom != 0 || ime.PartialConversionTo != ime.ImmComp.Length) - { - var part1 = ime.ImmComp[..ime.PartialConversionFrom]; - var part2 = ime.ImmComp[..ime.PartialConversionTo]; - var size1 = ImGui.CalcTextSize(part1); - var size2 = ImGui.CalcTextSize(part2); - drawList.AddRectFilled( - cursor + size1 with { Y = 0 }, - cursor + size2, - ImGui.GetColorU32(ImGuiCol.TextSelectedBg)); - } - - // Add the text being converted. - drawList.AddText(cursor, ImGui.GetColorU32(ImGuiCol.Text), ime.ImmComp); - - // Draw the caret inside the composition string. - if (DalamudIme.ShowCursorInInputText) - { - var partBeforeCaret = ime.ImmComp[..ime.CompositionCursorOffset]; - var sizeBeforeCaret = ImGui.CalcTextSize(partBeforeCaret); - drawList.AddLine( - cursor + sizeBeforeCaret with { Y = 0 }, - cursor + sizeBeforeCaret, - ImGui.GetColorU32(ImGuiCol.Text)); - } - } - } -} diff --git a/Dalamud/ServiceManager.cs b/Dalamud/ServiceManager.cs index 3ff7cde76..acd7c2b6f 100644 --- a/Dalamud/ServiceManager.cs +++ b/Dalamud/ServiceManager.cs @@ -165,6 +165,7 @@ internal static class ServiceManager var earlyLoadingServices = new HashSet(); var blockingEarlyLoadingServices = new HashSet(); + var providedServices = new HashSet(); var dependencyServicesMap = new Dictionary>(); var getAsyncTaskMap = new Dictionary(); @@ -197,7 +198,10 @@ internal static class ServiceManager // We don't actually need to load provided services, something else does if (serviceKind.HasFlag(ServiceKind.ProvidedService)) + { + providedServices.Add(serviceType); continue; + } Debug.Assert( serviceKind.HasFlag(ServiceKind.EarlyLoadedService) || @@ -340,7 +344,16 @@ internal static class ServiceManager } if (!tasks.Any()) - throw new InvalidOperationException("Unresolvable dependency cycle detected"); + { + // No more services we can start loading for now. + // Either we're waiting for provided services, or there's a dependency cycle. + providedServices.RemoveWhere(x => getAsyncTaskMap[x].IsCompleted); + if (providedServices.Any()) + await Task.WhenAny(providedServices.Select(x => getAsyncTaskMap[x])); + else + throw new InvalidOperationException("Unresolvable dependency cycle detected"); + continue; + } if (servicesToLoad.Any()) { From 4c0f7b7eba4613f3444afdb276f121e952d97052 Mon Sep 17 00:00:00 2001 From: bleatbot <106497096+bleatbot@users.noreply.github.com> Date: Fri, 8 Mar 2024 02:13:30 +0100 Subject: [PATCH 06/13] Update ClientStructs (#1691) Co-authored-by: github-actions[bot] --- lib/FFXIVClientStructs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/FFXIVClientStructs b/lib/FFXIVClientStructs index 722a2c512..ac2ced26f 160000 --- a/lib/FFXIVClientStructs +++ b/lib/FFXIVClientStructs @@ -1 +1 @@ -Subproject commit 722a2c512238ac4b5324e3d343b316d8c8633a02 +Subproject commit ac2ced26fc98153c65f5b8f0eaf0f464258ff683 From 88a8d457989bab22b06295192382a0eccfce2eab Mon Sep 17 00:00:00 2001 From: srkizer Date: Fri, 8 Mar 2024 10:47:11 +0900 Subject: [PATCH 07/13] Accommodate nested AddonLifecycle event calls (#1698) * Accommodate nested AddonLifecycle event calls The game is free to call event handlers of another addon from one addon, but the previous code was written under the assumption that only one function may be called at a time. This changes the recycled addon args into pooled args. * Always clear addon name cache --- Dalamud/Dalamud.csproj | 4 - .../Game/Addon/AddonLifecyclePooledArgs.cs | 107 ++++++++++++++++++ .../Game/Addon/Events/AddonEventManager.cs | 2 +- .../Lifecycle/AddonArgTypes/AddonArgs.cs | 6 +- .../Game/Addon/Lifecycle/AddonLifecycle.cs | 84 +++++++------- .../AddonLifecycleReceiveEventListener.cs | 31 +++-- 6 files changed, 165 insertions(+), 69 deletions(-) create mode 100644 Dalamud/Game/Addon/AddonLifecyclePooledArgs.cs diff --git a/Dalamud/Dalamud.csproj b/Dalamud/Dalamud.csproj index 205681cb8..7e166d8b3 100644 --- a/Dalamud/Dalamud.csproj +++ b/Dalamud/Dalamud.csproj @@ -112,10 +112,6 @@
- - - - diff --git a/Dalamud/Game/Addon/AddonLifecyclePooledArgs.cs b/Dalamud/Game/Addon/AddonLifecyclePooledArgs.cs new file mode 100644 index 000000000..14def2036 --- /dev/null +++ b/Dalamud/Game/Addon/AddonLifecyclePooledArgs.cs @@ -0,0 +1,107 @@ +using System.Runtime.CompilerServices; +using System.Threading; + +using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; + +namespace Dalamud.Game.Addon; + +/// Argument pool for Addon Lifecycle services. +[ServiceManager.EarlyLoadedService] +internal sealed class AddonLifecyclePooledArgs : IServiceType +{ + private readonly AddonSetupArgs?[] addonSetupArgPool = new AddonSetupArgs?[64]; + private readonly AddonFinalizeArgs?[] addonFinalizeArgPool = new AddonFinalizeArgs?[64]; + private readonly AddonDrawArgs?[] addonDrawArgPool = new AddonDrawArgs?[64]; + private readonly AddonUpdateArgs?[] addonUpdateArgPool = new AddonUpdateArgs?[64]; + private readonly AddonRefreshArgs?[] addonRefreshArgPool = new AddonRefreshArgs?[64]; + private readonly AddonRequestedUpdateArgs?[] addonRequestedUpdateArgPool = new AddonRequestedUpdateArgs?[64]; + private readonly AddonReceiveEventArgs?[] addonReceiveEventArgPool = new AddonReceiveEventArgs?[64]; + + [ServiceManager.ServiceConstructor] + private AddonLifecyclePooledArgs() + { + } + + /// Rents an instance of an argument. + /// The rented instance. + /// The returner. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public PooledEntry Rent(out AddonSetupArgs arg) => new(out arg, this.addonSetupArgPool); + + /// Rents an instance of an argument. + /// The rented instance. + /// The returner. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public PooledEntry Rent(out AddonFinalizeArgs arg) => new(out arg, this.addonFinalizeArgPool); + + /// Rents an instance of an argument. + /// The rented instance. + /// The returner. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public PooledEntry Rent(out AddonDrawArgs arg) => new(out arg, this.addonDrawArgPool); + + /// Rents an instance of an argument. + /// The rented instance. + /// The returner. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public PooledEntry Rent(out AddonUpdateArgs arg) => new(out arg, this.addonUpdateArgPool); + + /// Rents an instance of an argument. + /// The rented instance. + /// The returner. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public PooledEntry Rent(out AddonRefreshArgs arg) => new(out arg, this.addonRefreshArgPool); + + /// Rents an instance of an argument. + /// The rented instance. + /// The returner. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public PooledEntry Rent(out AddonRequestedUpdateArgs arg) => + new(out arg, this.addonRequestedUpdateArgPool); + + /// Rents an instance of an argument. + /// The rented instance. + /// The returner. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public PooledEntry Rent(out AddonReceiveEventArgs arg) => + new(out arg, this.addonReceiveEventArgPool); + + /// Returns the object to the pool on dispose. + /// The type. + public readonly ref struct PooledEntry + where T : AddonArgs, new() + { + private readonly Span pool; + private readonly T obj; + + /// Initializes a new instance of the struct. + /// An instance of the argument. + /// The pool to rent from and return to. + public PooledEntry(out T arg, Span pool) + { + this.pool = pool; + foreach (ref var item in pool) + { + if (Interlocked.Exchange(ref item, null) is { } v) + { + this.obj = arg = v; + return; + } + } + + this.obj = arg = new(); + } + + /// Returns the item to the pool. + public void Dispose() + { + var tmp = this.obj; + foreach (ref var item in this.pool) + { + if (Interlocked.Exchange(ref item, tmp) is not { } tmp2) + return; + tmp = tmp2; + } + } + } +} diff --git a/Dalamud/Game/Addon/Events/AddonEventManager.cs b/Dalamud/Game/Addon/Events/AddonEventManager.cs index 4231b0d09..8ee09bed8 100644 --- a/Dalamud/Game/Addon/Events/AddonEventManager.cs +++ b/Dalamud/Game/Addon/Events/AddonEventManager.cs @@ -18,7 +18,7 @@ namespace Dalamud.Game.Addon.Events; /// Service provider for addon event management. /// [InterfaceVersion("1.0")] -[ServiceManager.BlockingEarlyLoadedService] +[ServiceManager.EarlyLoadedService] internal unsafe class AddonEventManager : IDisposable, IServiceType { /// diff --git a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonArgs.cs b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonArgs.cs index 4ab3de5ca..1095202cc 100644 --- a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonArgs.cs +++ b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonArgs.cs @@ -44,10 +44,10 @@ public abstract unsafe class AddonArgs get => this.addon; set { - if (this.addon == value) - return; - this.addon = value; + + // Note: always clear addonName on updating the addon being pointed. + // Same address may point to a different addon. this.addonName = null; } } diff --git a/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs b/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs index beaab7fcd..37f12ce3a 100644 --- a/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs +++ b/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; @@ -19,7 +18,7 @@ namespace Dalamud.Game.Addon.Lifecycle; /// This class provides events for in-game addon lifecycles. /// [InterfaceVersion("1.0")] -[ServiceManager.BlockingEarlyLoadedService] +[ServiceManager.EarlyLoadedService] internal unsafe class AddonLifecycle : IDisposable, IServiceType { private static readonly ModuleLog Log = new("AddonLifecycle"); @@ -27,6 +26,9 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType [ServiceManager.ServiceDependency] private readonly Framework framework = Service.Get(); + [ServiceManager.ServiceDependency] + private readonly AddonLifecyclePooledArgs argsPool = Service.Get(); + private readonly nint disallowedReceiveEventAddress; private readonly AddonLifecycleAddressResolver address; @@ -38,18 +40,6 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType private readonly Hook onAddonRefreshHook; private readonly CallHook onAddonRequestedUpdateHook; - // Note: these can be sourced from ObjectPool of appropriate types instead, but since we don't import that NuGet - // package, and these events are always called from the main thread, this is fine. -#pragma warning disable CS0618 // Type or member is obsolete - // TODO: turn constructors of these internal - private readonly AddonSetupArgs recyclingSetupArgs = new(); - private readonly AddonFinalizeArgs recyclingFinalizeArgs = new(); - private readonly AddonDrawArgs recyclingDrawArgs = new(); - private readonly AddonUpdateArgs recyclingUpdateArgs = new(); - private readonly AddonRefreshArgs recyclingRefreshArgs = new(); - private readonly AddonRequestedUpdateArgs recyclingRequestedUpdateArgs = new(); -#pragma warning restore CS0618 // Type or member is obsolete - [ServiceManager.ServiceConstructor] private AddonLifecycle(TargetSigScanner sigScanner) { @@ -253,12 +243,13 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType Log.Error(e, "Exception in OnAddonSetup ReceiveEvent Registration."); } - this.recyclingSetupArgs.AddonInternal = (nint)addon; - this.recyclingSetupArgs.AtkValueCount = valueCount; - this.recyclingSetupArgs.AtkValues = (nint)values; - this.InvokeListenersSafely(AddonEvent.PreSetup, this.recyclingSetupArgs); - valueCount = this.recyclingSetupArgs.AtkValueCount; - values = (AtkValue*)this.recyclingSetupArgs.AtkValues; + using var returner = this.argsPool.Rent(out AddonSetupArgs arg); + arg.AddonInternal = (nint)addon; + arg.AtkValueCount = valueCount; + arg.AtkValues = (nint)values; + this.InvokeListenersSafely(AddonEvent.PreSetup, arg); + valueCount = arg.AtkValueCount; + values = (AtkValue*)arg.AtkValues; try { @@ -269,7 +260,7 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType Log.Error(e, "Caught exception when calling original AddonSetup. This may be a bug in the game or another plugin hooking this method."); } - this.InvokeListenersSafely(AddonEvent.PostSetup, this.recyclingSetupArgs); + this.InvokeListenersSafely(AddonEvent.PostSetup, arg); } private void OnAddonFinalize(AtkUnitManager* unitManager, AtkUnitBase** atkUnitBase) @@ -284,8 +275,9 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType Log.Error(e, "Exception in OnAddonFinalize ReceiveEvent Removal."); } - this.recyclingFinalizeArgs.AddonInternal = (nint)atkUnitBase[0]; - this.InvokeListenersSafely(AddonEvent.PreFinalize, this.recyclingFinalizeArgs); + using var returner = this.argsPool.Rent(out AddonFinalizeArgs arg); + arg.AddonInternal = (nint)atkUnitBase[0]; + this.InvokeListenersSafely(AddonEvent.PreFinalize, arg); try { @@ -299,8 +291,9 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType private void OnAddonDraw(AtkUnitBase* addon) { - this.recyclingDrawArgs.AddonInternal = (nint)addon; - this.InvokeListenersSafely(AddonEvent.PreDraw, this.recyclingDrawArgs); + using var returner = this.argsPool.Rent(out AddonDrawArgs arg); + arg.AddonInternal = (nint)addon; + this.InvokeListenersSafely(AddonEvent.PreDraw, arg); try { @@ -311,14 +304,15 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType Log.Error(e, "Caught exception when calling original AddonDraw. This may be a bug in the game or another plugin hooking this method."); } - this.InvokeListenersSafely(AddonEvent.PostDraw, this.recyclingDrawArgs); + this.InvokeListenersSafely(AddonEvent.PostDraw, arg); } private void OnAddonUpdate(AtkUnitBase* addon, float delta) { - this.recyclingUpdateArgs.AddonInternal = (nint)addon; - this.recyclingUpdateArgs.TimeDeltaInternal = delta; - this.InvokeListenersSafely(AddonEvent.PreUpdate, this.recyclingUpdateArgs); + using var returner = this.argsPool.Rent(out AddonUpdateArgs arg); + arg.AddonInternal = (nint)addon; + arg.TimeDeltaInternal = delta; + this.InvokeListenersSafely(AddonEvent.PreUpdate, arg); try { @@ -329,19 +323,20 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType Log.Error(e, "Caught exception when calling original AddonUpdate. This may be a bug in the game or another plugin hooking this method."); } - this.InvokeListenersSafely(AddonEvent.PostUpdate, this.recyclingUpdateArgs); + this.InvokeListenersSafely(AddonEvent.PostUpdate, arg); } private byte OnAddonRefresh(AtkUnitManager* atkUnitManager, AtkUnitBase* addon, uint valueCount, AtkValue* values) { byte result = 0; - this.recyclingRefreshArgs.AddonInternal = (nint)addon; - this.recyclingRefreshArgs.AtkValueCount = valueCount; - this.recyclingRefreshArgs.AtkValues = (nint)values; - this.InvokeListenersSafely(AddonEvent.PreRefresh, this.recyclingRefreshArgs); - valueCount = this.recyclingRefreshArgs.AtkValueCount; - values = (AtkValue*)this.recyclingRefreshArgs.AtkValues; + using var returner = this.argsPool.Rent(out AddonRefreshArgs arg); + arg.AddonInternal = (nint)addon; + arg.AtkValueCount = valueCount; + arg.AtkValues = (nint)values; + this.InvokeListenersSafely(AddonEvent.PreRefresh, arg); + valueCount = arg.AtkValueCount; + values = (AtkValue*)arg.AtkValues; try { @@ -352,18 +347,19 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType Log.Error(e, "Caught exception when calling original AddonRefresh. This may be a bug in the game or another plugin hooking this method."); } - this.InvokeListenersSafely(AddonEvent.PostRefresh, this.recyclingRefreshArgs); + this.InvokeListenersSafely(AddonEvent.PostRefresh, arg); return result; } private void OnRequestedUpdate(AtkUnitBase* addon, NumberArrayData** numberArrayData, StringArrayData** stringArrayData) { - this.recyclingRequestedUpdateArgs.AddonInternal = (nint)addon; - this.recyclingRequestedUpdateArgs.NumberArrayData = (nint)numberArrayData; - this.recyclingRequestedUpdateArgs.StringArrayData = (nint)stringArrayData; - this.InvokeListenersSafely(AddonEvent.PreRequestedUpdate, this.recyclingRequestedUpdateArgs); - numberArrayData = (NumberArrayData**)this.recyclingRequestedUpdateArgs.NumberArrayData; - stringArrayData = (StringArrayData**)this.recyclingRequestedUpdateArgs.StringArrayData; + using var returner = this.argsPool.Rent(out AddonRequestedUpdateArgs arg); + arg.AddonInternal = (nint)addon; + arg.NumberArrayData = (nint)numberArrayData; + arg.StringArrayData = (nint)stringArrayData; + this.InvokeListenersSafely(AddonEvent.PreRequestedUpdate, arg); + numberArrayData = (NumberArrayData**)arg.NumberArrayData; + stringArrayData = (StringArrayData**)arg.StringArrayData; try { @@ -374,7 +370,7 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType Log.Error(e, "Caught exception when calling original AddonRequestedUpdate. This may be a bug in the game or another plugin hooking this method."); } - this.InvokeListenersSafely(AddonEvent.PostRequestedUpdate, this.recyclingRequestedUpdateArgs); + this.InvokeListenersSafely(AddonEvent.PostRequestedUpdate, arg); } } diff --git a/Dalamud/Game/Addon/Lifecycle/AddonLifecycleReceiveEventListener.cs b/Dalamud/Game/Addon/Lifecycle/AddonLifecycleReceiveEventListener.cs index 43aa71661..fd3b5d79d 100644 --- a/Dalamud/Game/Addon/Lifecycle/AddonLifecycleReceiveEventListener.cs +++ b/Dalamud/Game/Addon/Lifecycle/AddonLifecycleReceiveEventListener.cs @@ -16,12 +16,8 @@ internal unsafe class AddonLifecycleReceiveEventListener : IDisposable { private static readonly ModuleLog Log = new("AddonLifecycle"); - // Note: these can be sourced from ObjectPool of appropriate types instead, but since we don't import that NuGet - // package, and these events are always called from the main thread, this is fine. -#pragma warning disable CS0618 // Type or member is obsolete - // TODO: turn constructors of these internal - private readonly AddonReceiveEventArgs recyclingReceiveEventArgs = new(); -#pragma warning restore CS0618 // Type or member is obsolete + [ServiceManager.ServiceDependency] + private readonly AddonLifecyclePooledArgs argsPool = Service.Get(); /// /// Initializes a new instance of the class. @@ -82,16 +78,17 @@ internal unsafe class AddonLifecycleReceiveEventListener : IDisposable return; } - this.recyclingReceiveEventArgs.AddonInternal = (nint)addon; - this.recyclingReceiveEventArgs.AtkEventType = (byte)eventType; - this.recyclingReceiveEventArgs.EventParam = eventParam; - this.recyclingReceiveEventArgs.AtkEvent = (IntPtr)atkEvent; - this.recyclingReceiveEventArgs.Data = data; - this.AddonLifecycle.InvokeListenersSafely(AddonEvent.PreReceiveEvent, this.recyclingReceiveEventArgs); - eventType = (AtkEventType)this.recyclingReceiveEventArgs.AtkEventType; - eventParam = this.recyclingReceiveEventArgs.EventParam; - atkEvent = (AtkEvent*)this.recyclingReceiveEventArgs.AtkEvent; - data = this.recyclingReceiveEventArgs.Data; + using var returner = this.argsPool.Rent(out AddonReceiveEventArgs arg); + arg.AddonInternal = (nint)addon; + arg.AtkEventType = (byte)eventType; + arg.EventParam = eventParam; + arg.AtkEvent = (IntPtr)atkEvent; + arg.Data = data; + this.AddonLifecycle.InvokeListenersSafely(AddonEvent.PreReceiveEvent, arg); + eventType = (AtkEventType)arg.AtkEventType; + eventParam = arg.EventParam; + atkEvent = (AtkEvent*)arg.AtkEvent; + data = arg.Data; try { @@ -102,6 +99,6 @@ internal unsafe class AddonLifecycleReceiveEventListener : IDisposable Log.Error(e, "Caught exception when calling original AddonReceiveEvent. This may be a bug in the game or another plugin hooking this method."); } - this.AddonLifecycle.InvokeListenersSafely(AddonEvent.PostReceiveEvent, this.recyclingReceiveEventArgs); + this.AddonLifecycle.InvokeListenersSafely(AddonEvent.PostReceiveEvent, arg); } } From 637ba78956553714d66004913b4c939527c580bf Mon Sep 17 00:00:00 2001 From: Soreepeong Date: Sat, 9 Mar 2024 00:01:20 +0900 Subject: [PATCH 08/13] At least make it not drop character after conversion with google IME --- Dalamud/Interface/Internal/DalamudIme.cs | 166 +++++++++++++++++++---- 1 file changed, 138 insertions(+), 28 deletions(-) diff --git a/Dalamud/Interface/Internal/DalamudIme.cs b/Dalamud/Interface/Internal/DalamudIme.cs index 6c01b74d7..bbfe819a8 100644 --- a/Dalamud/Interface/Internal/DalamudIme.cs +++ b/Dalamud/Interface/Internal/DalamudIme.cs @@ -18,7 +18,9 @@ using Dalamud.Interface.Utility; using ImGuiNET; +#if IMEDEBUG using Serilog; +#endif using TerraFX.Interop.Windows; @@ -267,6 +269,50 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType { var invalidTarget = TextState->Id == 0 || (TextState->Flags & ImGuiInputTextFlags.ReadOnly) != 0; +#if IMEDEBUG + switch (args.Message) + { + case WM.WM_IME_NOTIFY: + Log.Verbose($"{nameof(WM.WM_IME_NOTIFY)}({ImeDebug.ImnName((int)args.WParam)}, 0x{args.LParam:X})"); + break; + case WM.WM_IME_CONTROL: + Log.Verbose( + $"{nameof(WM.WM_IME_CONTROL)}({ImeDebug.ImcName((int)args.WParam)}, 0x{args.LParam:X})"); + break; + case WM.WM_IME_REQUEST: + Log.Verbose( + $"{nameof(WM.WM_IME_REQUEST)}({ImeDebug.ImrName((int)args.WParam)}, 0x{args.LParam:X})"); + break; + case WM.WM_IME_SELECT: + Log.Verbose($"{nameof(WM.WM_IME_SELECT)}({(int)args.WParam != 0}, 0x{args.LParam:X})"); + break; + case WM.WM_IME_STARTCOMPOSITION: + Log.Verbose($"{nameof(WM.WM_IME_STARTCOMPOSITION)}()"); + break; + case WM.WM_IME_COMPOSITION: + Log.Verbose( + $"{nameof(WM.WM_IME_COMPOSITION)}({(char)args.WParam}, {ImeDebug.GcsName((int)args.LParam)})"); + break; + case WM.WM_IME_COMPOSITIONFULL: + Log.Verbose($"{nameof(WM.WM_IME_COMPOSITIONFULL)}()"); + break; + case WM.WM_IME_ENDCOMPOSITION: + Log.Verbose($"{nameof(WM.WM_IME_ENDCOMPOSITION)}()"); + break; + case WM.WM_IME_CHAR: + Log.Verbose($"{nameof(WM.WM_IME_CHAR)}({(char)args.WParam}, 0x{args.LParam:X})"); + break; + case WM.WM_IME_KEYDOWN: + Log.Verbose($"{nameof(WM.WM_IME_KEYDOWN)}({(char)args.WParam}, 0x{args.LParam:X})"); + break; + case WM.WM_IME_KEYUP: + Log.Verbose($"{nameof(WM.WM_IME_KEYUP)}({(char)args.WParam}, 0x{args.LParam:X})"); + break; + case WM.WM_IME_SETCONTEXT: + Log.Verbose($"{nameof(WM.WM_IME_SETCONTEXT)}({(int)args.WParam != 0}, 0x{args.LParam:X})"); + break; + } +#endif switch (args.Message) { case WM.WM_IME_NOTIFY @@ -286,22 +332,15 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType else this.ReplaceCompositionString(hImc, (uint)args.LParam); - // Log.Verbose($"{nameof(WM.WM_IME_COMPOSITION)}({(nint)args.LParam:X}): {this.compositionString}"); args.SuppressWithValue(0); break; case WM.WM_IME_ENDCOMPOSITION: - // Log.Verbose($"{nameof(WM.WM_IME_ENDCOMPOSITION)}({(nint)args.WParam:X}, {(nint)args.LParam:X}): {this.compositionString}"); + this.ClearState(hImc, false); args.SuppressWithValue(0); break; - case WM.WM_IME_CONTROL: - // Log.Verbose($"{nameof(WM.WM_IME_CONTROL)}({(nint)args.WParam:X}, {(nint)args.LParam:X}): {this.compositionString}"); - args.SuppressWithValue(0); - break; - case WM.WM_IME_REQUEST: - // Log.Verbose($"{nameof(WM.WM_IME_REQUEST)}({(nint)args.WParam:X}, {(nint)args.LParam:X}): {this.compositionString}"); args.SuppressWithValue(0); break; @@ -309,14 +348,9 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType // Hide candidate and composition windows. args.LParam = (LPARAM)((nint)args.LParam & ~(ISC_SHOWUICOMPOSITIONWINDOW | 0xF)); - // Log.Verbose($"{nameof(WM.WM_IME_SETCONTEXT)}({(nint)args.WParam:X}, {(nint)args.LParam:X}): {this.compositionString}"); args.SuppressWithDefault(); break; - case WM.WM_IME_NOTIFY: - // Log.Verbose($"{nameof(WM.WM_IME_NOTIFY)}({(nint)args.WParam:X}): {this.compositionString}"); - break; - case WM.WM_KEYDOWN when (int)args.WParam is VK.VK_TAB or VK.VK_PRIOR @@ -335,7 +369,11 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType } this.UpdateCandidates(hImc); + break; + case WM.WM_KEYDOWN when (int)args.WParam is VK.VK_ESCAPE && this.candidateStrings.Count != 0: + this.ClearState(hImc); + args.SuppressWithDefault(); break; case WM.WM_LBUTTONDOWN: @@ -344,15 +382,14 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType case WM.WM_XBUTTONDOWN: ImmNotifyIME(hImc, NI.NI_COMPOSITIONSTR, CPS_COMPLETE, 0); break; - - // default: - // Log.Verbose($"{(WmNames.TryGetValue((int)args.Message, out var v) ? v : args.Message.ToString())}({(nint)args.WParam:X}, {(nint)args.LParam:X})"); - // break; } - this.UpdateInputLanguage(hImc); - if (this.inputModeIcon == (char)SeIconChar.ImeKoreanHangul) - this.UpdateCandidates(hImc); + if (args.Message != WM.WM_MOUSEMOVE) + { + this.UpdateInputLanguage(hImc); + if (this.inputModeIcon == (char)SeIconChar.ImeKoreanHangul) + this.UpdateCandidates(hImc); + } } finally { @@ -367,8 +404,6 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType var lang = GetKeyboardLayout(0); var open = ImmGetOpenStatus(hImc) != false; - // Log.Verbose($"{nameof(this.UpdateInputLanguage)}: conv={conv:X} sent={sent:X} open={open} lang={lang:X}"); - var native = (conv & 1) != 0; var katakana = (conv & 2) != 0; var fullwidth = (conv & 8) != 0; @@ -418,6 +453,10 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType ? ImmGetCompositionString(hImc, GCS.GCS_RESULTSTR) : ImmGetCompositionString(hImc, GCS.GCS_COMPSTR); +#if IMEDEBUG + Log.Verbose($"{nameof(this.ReplaceCompositionString)}({newString})"); +#endif + this.ReflectCharacterEncounters(newString); if (this.temporaryUndoSelection is not null) @@ -436,8 +475,8 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType if (finalCommit) { - this.ClearState(hImc); - return; + this.ClearState(hImc, false); + newString = string.Empty; } this.compositionString = newString; @@ -471,17 +510,21 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType this.UpdateCandidates(hImc); } - private void ClearState(HIMC hImc) + private void ClearState(HIMC hImc, bool invokeCancel = true) { this.compositionString = string.Empty; this.partialConversionFrom = this.partialConversionTo = 0; this.compositionCursorOffset = 0; this.temporaryUndoSelection = null; TextState->Stb.SelectStart = TextState->Stb.Cursor = TextState->Stb.SelectEnd; - ImmNotifyIME(hImc, NI.NI_COMPOSITIONSTR, CPS_CANCEL, 0); - this.UpdateCandidates(default); + this.candidateStrings.Clear(); + this.immCandNative = default; + if (invokeCancel) + ImmNotifyIME(hImc, NI.NI_COMPOSITIONSTR, CPS_CANCEL, 0); - // Log.Information($"{nameof(this.ClearState)}"); +#if IMEDEBUG + Log.Information($"{nameof(this.ClearState)}({invokeCancel})"); +#endif } private void UpdateCandidates(HIMC hImc) @@ -932,4 +975,71 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType return true; } } + +#if IMEDEBUG + private static class ImeDebug + { + private static readonly (int Value, string Name)[] GcsFields = + { + (GCS.GCS_COMPREADSTR, nameof(GCS.GCS_COMPREADSTR)), + (GCS.GCS_COMPREADATTR, nameof(GCS.GCS_COMPREADATTR)), + (GCS.GCS_COMPREADCLAUSE, nameof(GCS.GCS_COMPREADCLAUSE)), + (GCS.GCS_COMPSTR, nameof(GCS.GCS_COMPSTR)), + (GCS.GCS_COMPATTR, nameof(GCS.GCS_COMPATTR)), + (GCS.GCS_COMPCLAUSE, nameof(GCS.GCS_COMPCLAUSE)), + (GCS.GCS_CURSORPOS, nameof(GCS.GCS_CURSORPOS)), + (GCS.GCS_DELTASTART, nameof(GCS.GCS_DELTASTART)), + (GCS.GCS_RESULTREADSTR, nameof(GCS.GCS_RESULTREADSTR)), + (GCS.GCS_RESULTREADCLAUSE, nameof(GCS.GCS_RESULTREADCLAUSE)), + (GCS.GCS_RESULTSTR, nameof(GCS.GCS_RESULTSTR)), + (GCS.GCS_RESULTCLAUSE, nameof(GCS.GCS_RESULTCLAUSE)), + }; + + private static readonly IReadOnlyDictionary ImnFields = + typeof(IMN) + .GetFields(BindingFlags.Static | BindingFlags.Public) + .Where(x => x.IsLiteral) + .ToDictionary(x => (int)x.GetRawConstantValue()!, x => x.Name); + + public static string GcsName(int val) + { + var sb = new StringBuilder(); + foreach (var (value, name) in GcsFields) + { + if ((val & value) != 0) + { + if (sb.Length != 0) + sb.Append(" | "); + sb.Append(name); + val &= ~value; + } + } + + if (val != 0) + { + if (sb.Length != 0) + sb.Append(" | "); + sb.Append($"0x{val:X}"); + } + + return sb.ToString(); + } + + public static string ImcName(int val) => ImnFields.TryGetValue(val, out var name) ? name : $"0x{val:X}"; + + public static string ImnName(int val) => ImnFields.TryGetValue(val, out var name) ? name : $"0x{val:X}"; + + public static string ImrName(int val) => val switch + { + IMR_CANDIDATEWINDOW => nameof(IMR_CANDIDATEWINDOW), + IMR_COMPOSITIONFONT => nameof(IMR_COMPOSITIONFONT), + IMR_COMPOSITIONWINDOW => nameof(IMR_COMPOSITIONWINDOW), + IMR_CONFIRMRECONVERTSTRING => nameof(IMR_CONFIRMRECONVERTSTRING), + IMR_DOCUMENTFEED => nameof(IMR_DOCUMENTFEED), + IMR_QUERYCHARPOSITION => nameof(IMR_QUERYCHARPOSITION), + IMR_RECONVERTSTRING => nameof(IMR_RECONVERTSTRING), + _ => $"0x{val:X}", + }; + } +#endif } From e7815c59d551645e3a5fe5a5ecfd9d189101202b Mon Sep 17 00:00:00 2001 From: Soreepeong Date: Sat, 9 Mar 2024 00:16:46 +0900 Subject: [PATCH 09/13] fix? --- Dalamud/Interface/Internal/DalamudIme.cs | 64 +++++++++++++++++++----- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/Dalamud/Interface/Internal/DalamudIme.cs b/Dalamud/Interface/Internal/DalamudIme.cs index bbfe819a8..caf014885 100644 --- a/Dalamud/Interface/Internal/DalamudIme.cs +++ b/Dalamud/Interface/Internal/DalamudIme.cs @@ -1,3 +1,5 @@ +// #define IMEDEBUG + using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; @@ -108,6 +110,9 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType /// Undo range for modifying the buffer while composition is in progress. private (int Start, int End, int Cursor)? temporaryUndoSelection; + private bool updateInputLanguage = true; + private bool updateImeStatusAgain; + [SuppressMessage("StyleCop.CSharp.SpacingRules", "SA1003:Symbols should be spaced correctly", Justification = ".")] static DalamudIme() { @@ -255,15 +260,24 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType private void WndProcHookManagerOnPreWndProc(WndProcEventArgs args) { if (!ImGuiHelpers.IsImGuiInitialized) + { + this.updateInputLanguage = true; return; + } // Are we not the target of text input? if (!ImGui.GetIO().WantTextInput) + { + this.updateInputLanguage = true; return; + } var hImc = ImmGetContext(args.Hwnd); if (hImc == nint.Zero) + { + this.updateInputLanguage = true; return; + } try { @@ -313,16 +327,36 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType break; } #endif + if (this.updateInputLanguage + || (args.Message == WM.WM_IME_NOTIFY + && (int)args.WParam + is IMN.IMN_SETCONVERSIONMODE + or IMN.IMN_OPENSTATUSWINDOW + or IMN.IMN_CLOSESTATUSWINDOW)) + { + this.UpdateInputLanguage(hImc); + this.updateInputLanguage = false; + } + + if (this.updateImeStatusAgain) + { + this.ReplaceCompositionString(hImc, false); + this.UpdateCandidates(hImc); + this.updateImeStatusAgain = false; + } + switch (args.Message) { case WM.WM_IME_NOTIFY when (nint)args.WParam is IMN.IMN_OPENCANDIDATE or IMN.IMN_CLOSECANDIDATE or IMN.IMN_CHANGECANDIDATE: this.UpdateCandidates(hImc); + this.updateImeStatusAgain = true; args.SuppressWithValue(0); break; case WM.WM_IME_STARTCOMPOSITION: + this.updateImeStatusAgain = true; args.SuppressWithValue(0); break; @@ -330,17 +364,24 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType if (invalidTarget) ImmNotifyIME(hImc, NI.NI_COMPOSITIONSTR, CPS_CANCEL, 0); else - this.ReplaceCompositionString(hImc, (uint)args.LParam); + this.ReplaceCompositionString(hImc, ((int)args.LParam & GCS.GCS_RESULTSTR) != 0); + this.updateImeStatusAgain = true; args.SuppressWithValue(0); break; case WM.WM_IME_ENDCOMPOSITION: this.ClearState(hImc, false); + this.updateImeStatusAgain = true; args.SuppressWithValue(0); break; + + case WM.WM_IME_CHAR: + case WM.WM_IME_KEYDOWN: + case WM.WM_IME_KEYUP: case WM.WM_IME_CONTROL: case WM.WM_IME_REQUEST: + this.updateImeStatusAgain = true; args.SuppressWithValue(0); break; @@ -348,9 +389,16 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType // Hide candidate and composition windows. args.LParam = (LPARAM)((nint)args.LParam & ~(ISC_SHOWUICOMPOSITIONWINDOW | 0xF)); + this.updateImeStatusAgain = true; args.SuppressWithDefault(); break; + case WM.WM_IME_NOTIFY: + case WM.WM_IME_COMPOSITIONFULL: + case WM.WM_IME_SELECT: + this.updateImeStatusAgain = true; + break; + case WM.WM_KEYDOWN when (int)args.WParam is VK.VK_TAB or VK.VK_PRIOR @@ -383,13 +431,6 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType ImmNotifyIME(hImc, NI.NI_COMPOSITIONSTR, CPS_COMPLETE, 0); break; } - - if (args.Message != WM.WM_MOUSEMOVE) - { - this.UpdateInputLanguage(hImc); - if (this.inputModeIcon == (char)SeIconChar.ImeKoreanHangul) - this.UpdateCandidates(hImc); - } } finally { @@ -446,9 +487,8 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType } } - private void ReplaceCompositionString(HIMC hImc, uint comp) + private void ReplaceCompositionString(HIMC hImc, bool finalCommit) { - var finalCommit = (comp & GCS.GCS_RESULTSTR) != 0; var newString = finalCommit ? ImmGetCompositionString(hImc, GCS.GCS_RESULTSTR) : ImmGetCompositionString(hImc, GCS.GCS_COMPSTR); @@ -482,9 +522,9 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType this.compositionString = newString; this.compositionCursorOffset = ImmGetCompositionStringW(hImc, GCS.GCS_CURSORPOS, null, 0); - if ((comp & GCS.GCS_COMPATTR) != 0) + var attrLength = ImmGetCompositionStringW(hImc, GCS.GCS_COMPATTR, null, 0); + if (attrLength > 0) { - var attrLength = ImmGetCompositionStringW(hImc, GCS.GCS_COMPATTR, null, 0); var attrPtr = stackalloc byte[attrLength]; var attr = new Span(attrPtr, Math.Min(this.compositionString.Length, attrLength)); _ = ImmGetCompositionStringW(hImc, GCS.GCS_COMPATTR, attrPtr, (uint)attrLength); From 14a5e5b652e4bf00de6ff470cf81a3155f725374 Mon Sep 17 00:00:00 2001 From: Soreepeong Date: Sat, 9 Mar 2024 04:09:29 +0900 Subject: [PATCH 10/13] ConsoleWindow racecon fix and highlight RollingList is not thread safe, but the lock around it was inconsistent, resulting in occasional null value in the log list. Fixed by utilizing ConcurrentQueue so that logs can be added from any thread without locks, and reading from the queue and adding to the list from the framework thread. Also, added log line highlight feature. --- .../Internal/Windows/ConsoleWindow.cs | 789 ++++++++++++------ Dalamud/Utility/ThreadSafety.cs | 12 + 2 files changed, 537 insertions(+), 264 deletions(-) diff --git a/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs b/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs index f36d79222..1957ab720 100644 --- a/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs +++ b/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs @@ -1,24 +1,28 @@ +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; -using System.Drawing; using System.Linq; using System.Numerics; using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; -using System.Threading; using Dalamud.Configuration.Internal; +using Dalamud.Game; using Dalamud.Game.Command; using Dalamud.Interface.Colors; using Dalamud.Interface.Components; +using Dalamud.Interface.Internal.Notifications; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Windowing; using Dalamud.Logging.Internal; using Dalamud.Plugin.Internal; +using Dalamud.Plugin.Services; using Dalamud.Utility; + using ImGuiNET; + using Serilog; using Serilog.Events; @@ -31,39 +35,48 @@ internal class ConsoleWindow : Window, IDisposable { private const int LogLinesMinimum = 100; private const int LogLinesMaximum = 1000000; - + + // Only this field may be touched from any thread. + private readonly ConcurrentQueue<(string Line, LogEvent LogEvent)> newLogEntries; + + // Fields below should be touched only from the main thread. private readonly RollingList logText; - private volatile int newRolledLines; - private readonly object renderLock = new(); + private readonly RollingList filteredLogEntries; private readonly List history = new(); private readonly List pluginFilters = new(); + private int newRolledLines; + private bool pendingRefilter; + private bool pendingClearLog; + private bool? lastCmdSuccess; + private ImGuiListClipperPtr clipperPtr; private string commandText = string.Empty; private string textFilter = string.Empty; + private string textHighlight = string.Empty; private string selectedSource = "DalamudInternal"; private string pluginFilter = string.Empty; + private Regex? compiledLogFilter; + private Regex? compiledLogHighlight; + private Exception? exceptionLogFilter; + private Exception? exceptionLogHighlight; + private bool filterShowUncaughtExceptions; private bool settingsPopupWasOpen; private bool showFilterToolbar; - private bool clearLog; - private bool copyLog; private bool copyMode; private bool killGameArmed; private bool autoScroll; private int logLinesLimit; private bool autoOpen; - private bool regexError; private int historyPos; private int copyStart = -1; - /// - /// Initializes a new instance of the class. - /// + /// Initializes a new instance of the class. /// An instance of . public ConsoleWindow(DalamudConfiguration configuration) : base("Dalamud Console", ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse) @@ -72,6 +85,8 @@ internal class ConsoleWindow : Window, IDisposable this.autoOpen = configuration.LogOpenAtStartup; SerilogEventSink.Instance.LogLine += this.OnLogLine; + Service.GetAsync().ContinueWith(r => r.Result.Update += this.FrameworkOnUpdate); + this.Size = new Vector2(500, 400); this.SizeCondition = ImGuiCond.FirstUseEver; @@ -85,13 +100,17 @@ internal class ConsoleWindow : Window, IDisposable this.logLinesLimit = configuration.LogLinesLimit; var limit = Math.Max(LogLinesMinimum, this.logLinesLimit); + this.newLogEntries = new(); this.logText = new(limit); - this.FilteredLogEntries = new(limit); + this.filteredLogEntries = new(limit); configuration.DalamudConfigurationSaved += this.OnDalamudConfigurationSaved; - } - private RollingList FilteredLogEntries { get; set; } + unsafe + { + this.clipperPtr = new(ImGuiNative.ImGuiListClipper_ImGuiListClipper()); + } + } /// public override void OnOpen() @@ -100,58 +119,16 @@ internal class ConsoleWindow : Window, IDisposable base.OnOpen(); } - /// - /// Dispose of managed and unmanaged resources. - /// + /// public void Dispose() { SerilogEventSink.Instance.LogLine -= this.OnLogLine; Service.Get().DalamudConfigurationSaved -= this.OnDalamudConfigurationSaved; - } + if (Service.GetNullable() is { } framework) + framework.Update -= this.FrameworkOnUpdate; - /// - /// Clear the window of all log entries. - /// - public void Clear() - { - lock (this.renderLock) - { - this.logText.Clear(); - this.FilteredLogEntries.Clear(); - this.clearLog = false; - } - } - - /// - /// Copies the entire log contents to clipboard. - /// - public void CopyLog() - { - ImGui.LogToClipboard(); - } - - /// - /// Add a single log line to the display. - /// - /// The line to add. - /// The Serilog event associated with this line. - public void HandleLogLine(string line, LogEvent logEvent) - { - if (line.IndexOfAny(new[] { '\n', '\r' }) != -1) - { - var subLines = line.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.RemoveEmptyEntries); - - this.AddAndFilter(subLines[0], logEvent, false); - - for (var i = 1; i < subLines.Length; i++) - { - this.AddAndFilter(subLines[i], logEvent, true); - } - } - else - { - this.AddAndFilter(line, logEvent, false); - } + this.clipperPtr.Destroy(); + this.clipperPtr = default; } /// @@ -161,112 +138,126 @@ internal class ConsoleWindow : Window, IDisposable this.DrawFilterToolbar(); - if (this.regexError) + if (this.exceptionLogFilter is not null) { - const string regexErrorString = "Regex Filter Error"; - ImGui.SetCursorPosX(ImGui.GetContentRegionMax().X / 2.0f - ImGui.CalcTextSize(regexErrorString).X / 2.0f); - ImGui.TextColored(ImGuiColors.DalamudRed, regexErrorString); + ImGui.TextColored( + ImGuiColors.DalamudRed, + $"Regex Filter Error: {this.exceptionLogFilter.GetType().Name}"); + ImGui.TextUnformatted(this.exceptionLogFilter.Message); + } + + if (this.exceptionLogHighlight is not null) + { + ImGui.TextColored( + ImGuiColors.DalamudRed, + $"Regex Highlight Error: {this.exceptionLogHighlight.GetType().Name}"); + ImGui.TextUnformatted(this.exceptionLogHighlight.Message); } var sendButtonSize = ImGui.CalcTextSize("Send") + ((new Vector2(16, 0) + (ImGui.GetStyle().FramePadding * 2)) * ImGuiHelpers.GlobalScale); var scrollingHeight = ImGui.GetContentRegionAvail().Y - sendButtonSize.Y; - ImGui.BeginChild("scrolling", new Vector2(0, scrollingHeight), false, ImGuiWindowFlags.AlwaysHorizontalScrollbar | ImGuiWindowFlags.AlwaysVerticalScrollbar); - - if (this.clearLog) this.Clear(); - - if (this.copyLog) this.CopyLog(); + ImGui.BeginChild( + "scrolling", + new Vector2(0, scrollingHeight), + false, + ImGuiWindowFlags.AlwaysHorizontalScrollbar | ImGuiWindowFlags.AlwaysVerticalScrollbar); ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, Vector2.Zero); - ImGuiListClipperPtr clipper; - unsafe - { - clipper = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper_ImGuiListClipper()); - } - ImGui.PushFont(InterfaceManager.MonoFont); var childPos = ImGui.GetWindowPos(); var childDrawList = ImGui.GetWindowDrawList(); var childSize = ImGui.GetWindowSize(); - var cursorDiv = ImGui.CalcTextSize("00:00:00.000 ").X; - var cursorLogLevel = ImGui.CalcTextSize("00:00:00.000 | ").X; - var dividerOffset = ImGui.CalcTextSize("00:00:00.000 | AAA ").X + (ImGui.CalcTextSize(" ").X / 2); - var cursorLogLine = ImGui.CalcTextSize("00:00:00.000 | AAA | ").X; + var timestampWidth = ImGui.CalcTextSize("00:00:00.000").X; + var levelWidth = ImGui.CalcTextSize("AAA").X; + var separatorWidth = ImGui.CalcTextSize(" | ").X; + var cursorLogLevel = timestampWidth + separatorWidth; + var cursorLogLine = cursorLogLevel + levelWidth + separatorWidth; var lastLinePosY = 0.0f; var logLineHeight = 0.0f; - lock (this.renderLock) + this.clipperPtr.Begin(this.filteredLogEntries.Count); + while (this.clipperPtr.Step()) { - clipper.Begin(this.FilteredLogEntries.Count); - while (clipper.Step()) + for (var i = this.clipperPtr.DisplayStart; i < this.clipperPtr.DisplayEnd; i++) { - for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) + var index = Math.Max( + i - this.newRolledLines, + 0); // Prevents flicker effect. Also workaround to avoid negative indexes. + var line = this.filteredLogEntries[index]; + + if (!line.IsMultiline) + ImGui.Separator(); + + if (line.SelectedForCopy) { - var index = Math.Max(i - this.newRolledLines, 0); // Prevents flicker effect. Also workaround to avoid negative indexes. - var line = this.FilteredLogEntries[index]; + ImGui.PushStyleColor(ImGuiCol.Header, ImGuiColors.ParsedGrey); + ImGui.PushStyleColor(ImGuiCol.HeaderActive, ImGuiColors.ParsedGrey); + ImGui.PushStyleColor(ImGuiCol.HeaderHovered, ImGuiColors.ParsedGrey); + } + else + { + ImGui.PushStyleColor(ImGuiCol.Header, GetColorForLogEventLevel(line.Level)); + ImGui.PushStyleColor(ImGuiCol.HeaderActive, GetColorForLogEventLevel(line.Level)); + ImGui.PushStyleColor(ImGuiCol.HeaderHovered, GetColorForLogEventLevel(line.Level)); + } - if (!line.IsMultiline && !this.copyLog) - ImGui.Separator(); - - if (line.SelectedForCopy) - { - ImGui.PushStyleColor(ImGuiCol.Header, ImGuiColors.ParsedGrey); - ImGui.PushStyleColor(ImGuiCol.HeaderActive, ImGuiColors.ParsedGrey); - ImGui.PushStyleColor(ImGuiCol.HeaderHovered, ImGuiColors.ParsedGrey); - } - else - { - ImGui.PushStyleColor(ImGuiCol.Header, this.GetColorForLogEventLevel(line.Level)); - ImGui.PushStyleColor(ImGuiCol.HeaderActive, this.GetColorForLogEventLevel(line.Level)); - ImGui.PushStyleColor(ImGuiCol.HeaderHovered, this.GetColorForLogEventLevel(line.Level)); - } + ImGui.Selectable( + "###console_null", + true, + ImGuiSelectableFlags.AllowItemOverlap | ImGuiSelectableFlags.SpanAllColumns); - ImGui.Selectable("###console_null", true, ImGuiSelectableFlags.AllowItemOverlap | ImGuiSelectableFlags.SpanAllColumns); + // This must be after ImGui.Selectable, it uses ImGui.IsItem... functions + this.HandleCopyMode(i, line); - // This must be after ImGui.Selectable, it uses ImGui.IsItem... functions - this.HandleCopyMode(i, line); - + ImGui.SameLine(); + + ImGui.PopStyleColor(3); + + if (!line.IsMultiline) + { + ImGui.TextUnformatted(line.TimestampString); ImGui.SameLine(); - ImGui.PopStyleColor(3); - - if (!line.IsMultiline) - { - ImGui.TextUnformatted(line.TimeStamp.ToString("HH:mm:ss.fff")); - ImGui.SameLine(); - ImGui.SetCursorPosX(cursorDiv); - ImGui.TextUnformatted("|"); - ImGui.SameLine(); - ImGui.SetCursorPosX(cursorLogLevel); - ImGui.TextUnformatted(this.GetTextForLogEventLevel(line.Level)); - ImGui.SameLine(); - } - - ImGui.SetCursorPosX(cursorLogLine); - ImGui.TextUnformatted(line.Line); - - var currentLinePosY = ImGui.GetCursorPosY(); - logLineHeight = currentLinePosY - lastLinePosY; - lastLinePosY = currentLinePosY; + ImGui.SetCursorPosX(cursorLogLevel); + ImGui.TextUnformatted(GetTextForLogEventLevel(line.Level)); + ImGui.SameLine(); } - } - clipper.End(); - clipper.Destroy(); + ImGui.SetCursorPosX(cursorLogLine); + line.HighlightMatches ??= (this.compiledLogHighlight ?? this.compiledLogFilter)?.Matches(line.Line); + if (line.HighlightMatches is { } matches) + { + this.DrawHighlighted( + line.Line, + matches, + ImGui.GetColorU32(ImGuiCol.Text), + ImGui.GetColorU32(ImGuiColors.HealerGreen)); + } + else + { + ImGui.TextUnformatted(line.Line); + } + + var currentLinePosY = ImGui.GetCursorPosY(); + logLineHeight = currentLinePosY - lastLinePosY; + lastLinePosY = currentLinePosY; + } } + this.clipperPtr.End(); + ImGui.PopFont(); ImGui.PopStyleVar(); - var newRolledLinesCount = Interlocked.Exchange(ref this.newRolledLines, 0); if (!this.autoScroll || ImGui.GetScrollY() < ImGui.GetScrollMaxY()) { - ImGui.SetScrollY(ImGui.GetScrollY() - (logLineHeight * newRolledLinesCount)); + ImGui.SetScrollY(ImGui.GetScrollY() - (logLineHeight * this.newRolledLines)); } if (this.autoScroll && ImGui.GetScrollY() >= ImGui.GetScrollMaxY()) @@ -274,8 +265,19 @@ internal class ConsoleWindow : Window, IDisposable ImGui.SetScrollHereY(1.0f); } - // Draw dividing line - childDrawList.AddLine(new Vector2(childPos.X + dividerOffset, childPos.Y), new Vector2(childPos.X + dividerOffset, childPos.Y + childSize.Y), 0x4FFFFFFF, 1.0f); + // Draw dividing lines + var div1Offset = MathF.Round((timestampWidth + (separatorWidth / 2)) - ImGui.GetScrollX()); + var div2Offset = MathF.Round((cursorLogLevel + levelWidth + (separatorWidth / 2)) - ImGui.GetScrollX()); + childDrawList.AddLine( + new(childPos.X + div1Offset, childPos.Y), + new(childPos.X + div1Offset, childPos.Y + childSize.Y), + 0x4FFFFFFF, + 1.0f); + childDrawList.AddLine( + new(childPos.X + div2Offset, childPos.Y), + new(childPos.X + div2Offset, childPos.Y + childSize.Y), + 0x4FFFFFFF, + 1.0f); ImGui.EndChild(); @@ -293,12 +295,20 @@ internal class ConsoleWindow : Window, IDisposable } } - ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X - sendButtonSize.X - (ImGui.GetStyle().ItemSpacing.X * ImGuiHelpers.GlobalScale)); + ImGui.SetNextItemWidth( + ImGui.GetContentRegionAvail().X - sendButtonSize.X - + (ImGui.GetStyle().ItemSpacing.X * ImGuiHelpers.GlobalScale)); var getFocus = false; unsafe { - if (ImGui.InputText("##command_box", ref this.commandText, 255, ImGuiInputTextFlags.EnterReturnsTrue | ImGuiInputTextFlags.CallbackCompletion | ImGuiInputTextFlags.CallbackHistory, this.CommandInputCallback)) + if (ImGui.InputText( + "##command_box", + ref this.commandText, + 255, + ImGuiInputTextFlags.EnterReturnsTrue | ImGuiInputTextFlags.CallbackCompletion | + ImGuiInputTextFlags.CallbackHistory, + this.CommandInputCallback)) { this.ProcessCommand(); getFocus = true; @@ -316,14 +326,62 @@ internal class ConsoleWindow : Window, IDisposable { this.ProcessCommand(); } - - this.copyLog = false; } - + + private static string GetTextForLogEventLevel(LogEventLevel level) => level switch + { + LogEventLevel.Error => "ERR", + LogEventLevel.Verbose => "VRB", + LogEventLevel.Debug => "DBG", + LogEventLevel.Information => "INF", + LogEventLevel.Warning => "WRN", + LogEventLevel.Fatal => "FTL", + _ => "???", + }; + + private static uint GetColorForLogEventLevel(LogEventLevel level) => level switch + { + LogEventLevel.Error => 0x800000EE, + LogEventLevel.Verbose => 0x00000000, + LogEventLevel.Debug => 0x00000000, + LogEventLevel.Information => 0x00000000, + LogEventLevel.Warning => 0x8A0070EE, + LogEventLevel.Fatal => 0xFF00000A, + _ => 0x30FFFFFF, + }; + + private void FrameworkOnUpdate(IFramework framework) + { + if (this.pendingClearLog) + { + this.pendingClearLog = false; + this.logText.Clear(); + this.filteredLogEntries.Clear(); + this.newLogEntries.Clear(); + } + + if (this.pendingRefilter) + { + this.pendingRefilter = false; + this.filteredLogEntries.Clear(); + foreach (var log in this.logText) + { + if (this.IsFilterApplicable(log)) + this.filteredLogEntries.Add(log); + } + } + + var numPrevFilteredLogEntries = this.filteredLogEntries.Count; + var addedLines = 0; + while (this.newLogEntries.TryDequeue(out var logLine)) + addedLines += this.HandleLogLine(logLine.Line, logLine.LogEvent); + this.newRolledLines = addedLines - (this.filteredLogEntries.Count - numPrevFilteredLogEntries); + } + private void HandleCopyMode(int i, LogEntry line) { var selectionChanged = false; - + // If copyStart is -1, it means a drag has not been started yet, let's start one, and select the starting spot. if (this.copyMode && this.copyStart == -1 && ImGui.IsItemClicked()) { @@ -334,19 +392,20 @@ internal class ConsoleWindow : Window, IDisposable } // Update the selected range when dragging over entries - if (this.copyMode && this.copyStart != -1 && ImGui.IsItemHovered() && ImGui.IsMouseDragging(ImGuiMouseButton.Left)) + if (this.copyMode && this.copyStart != -1 && ImGui.IsItemHovered() && + ImGui.IsMouseDragging(ImGuiMouseButton.Left)) { if (!line.SelectedForCopy) { - foreach (var index in Enumerable.Range(0, this.FilteredLogEntries.Count)) + foreach (var index in Enumerable.Range(0, this.filteredLogEntries.Count)) { if (this.copyStart < i) { - this.FilteredLogEntries[index].SelectedForCopy = index >= this.copyStart && index <= i; + this.filteredLogEntries[index].SelectedForCopy = index >= this.copyStart && index <= i; } else { - this.FilteredLogEntries[index].SelectedForCopy = index >= i && index <= this.copyStart; + this.filteredLogEntries[index].SelectedForCopy = index >= i && index <= this.copyStart; } } @@ -355,19 +414,37 @@ internal class ConsoleWindow : Window, IDisposable } // Finish the drag, we should have already marked all dragged entries as selected by now. - if (this.copyMode && this.copyStart != -1 && ImGui.IsItemHovered() && ImGui.IsMouseReleased(ImGuiMouseButton.Left)) + if (this.copyMode && this.copyStart != -1 && ImGui.IsItemHovered() && + ImGui.IsMouseReleased(ImGuiMouseButton.Left)) { this.copyStart = -1; } if (selectionChanged) - { - var allSelectedLines = this.FilteredLogEntries - .Where(entry => entry.SelectedForCopy) - .Select(entry => $"{entry.TimeStamp:HH:mm:ss.fff} {this.GetTextForLogEventLevel(entry.Level)} | {entry.Line}"); + this.CopyFilteredLogEntries(true); + } - ImGui.SetClipboardText(string.Join("\n", allSelectedLines)); + private void CopyFilteredLogEntries(bool selectedOnly) + { + var sb = new StringBuilder(); + var n = 0; + foreach (var entry in this.filteredLogEntries) + { + if (selectedOnly && !entry.SelectedForCopy) + continue; + + n++; + sb.AppendLine(entry.ToString()); } + + if (n == 0) + return; + + ImGui.SetClipboardText(sb.ToString()); + Service.Get().AddNotification( + $"{n:n0} line(s) copied.", + this.WindowName, + NotificationType.Success); } private void DrawOptionsToolbar() @@ -384,7 +461,7 @@ internal class ConsoleWindow : Window, IDisposable EntryPoint.LogLevelSwitch.MinimumLevel = value; configuration.LogLevel = value; configuration.QueueSave(); - this.Refilter(); + this.QueueRefilter(); } } @@ -407,18 +484,27 @@ internal class ConsoleWindow : Window, IDisposable this.settingsPopupWasOpen = settingsPopup; - if (this.DrawToggleButtonWithTooltip("show_settings", "Show settings", FontAwesomeIcon.List, ref settingsPopup)) ImGui.OpenPopup("##console_settings"); + if (this.DrawToggleButtonWithTooltip("show_settings", "Show settings", FontAwesomeIcon.List, ref settingsPopup)) + ImGui.OpenPopup("##console_settings"); ImGui.SameLine(); - if (this.DrawToggleButtonWithTooltip("show_filters", "Show filter toolbar", FontAwesomeIcon.Search, ref this.showFilterToolbar)) + if (this.DrawToggleButtonWithTooltip( + "show_filters", + "Show filter toolbar", + FontAwesomeIcon.Search, + ref this.showFilterToolbar)) { this.showFilterToolbar = !this.showFilterToolbar; } ImGui.SameLine(); - if (this.DrawToggleButtonWithTooltip("show_uncaught_exceptions", "Show uncaught exception while filtering", FontAwesomeIcon.Bug, ref this.filterShowUncaughtExceptions)) + if (this.DrawToggleButtonWithTooltip( + "show_uncaught_exceptions", + "Show uncaught exception while filtering", + FontAwesomeIcon.Bug, + ref this.filterShowUncaughtExceptions)) { this.filterShowUncaughtExceptions = !this.filterShowUncaughtExceptions; } @@ -427,28 +513,33 @@ internal class ConsoleWindow : Window, IDisposable if (ImGuiComponents.IconButton("clear_log", FontAwesomeIcon.Trash)) { - this.clearLog = true; + this.QueueClear(); } if (ImGui.IsItemHovered()) ImGui.SetTooltip("Clear Log"); ImGui.SameLine(); - if (this.DrawToggleButtonWithTooltip("copy_mode", "Enable Copy Mode\nRight-click to copy entire log", FontAwesomeIcon.Copy, ref this.copyMode)) + if (this.DrawToggleButtonWithTooltip( + "copy_mode", + "Enable Copy Mode\nRight-click to copy entire log", + FontAwesomeIcon.Copy, + ref this.copyMode)) { this.copyMode = !this.copyMode; if (!this.copyMode) { - foreach (var entry in this.FilteredLogEntries) + foreach (var entry in this.filteredLogEntries) { entry.SelectedForCopy = false; } } } - if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) this.copyLog = true; - + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + this.CopyFilteredLogEntries(false); + ImGui.SameLine(); if (this.killGameArmed) { @@ -464,16 +555,59 @@ internal class ConsoleWindow : Window, IDisposable if (ImGui.IsItemHovered()) ImGui.SetTooltip("Kill game"); ImGui.SameLine(); - ImGui.SetCursorPosX(ImGui.GetContentRegionMax().X - (200.0f * ImGuiHelpers.GlobalScale)); + ImGui.SetCursorPosX( + ImGui.GetContentRegionMax().X - (2 * 200.0f * ImGuiHelpers.GlobalScale) - ImGui.GetStyle().ItemSpacing.X); + ImGui.PushItemWidth(200.0f * ImGuiHelpers.GlobalScale); - if (ImGui.InputTextWithHint("##global_filter", "regex global filter", ref this.textFilter, 2048, ImGuiInputTextFlags.EnterReturnsTrue | ImGuiInputTextFlags.AutoSelectAll)) + if (ImGui.InputTextWithHint( + "##textHighlight", + "regex highlight", + ref this.textHighlight, + 2048, + ImGuiInputTextFlags.EnterReturnsTrue | ImGuiInputTextFlags.AutoSelectAll) + || ImGui.IsItemDeactivatedAfterEdit()) { - this.Refilter(); + this.compiledLogHighlight = null; + this.exceptionLogHighlight = null; + try + { + if (this.textHighlight != string.Empty) + this.compiledLogHighlight = new(this.textHighlight, RegexOptions.IgnoreCase); + } + catch (Exception e) + { + this.exceptionLogHighlight = e; + } + + foreach (var log in this.logText) + log.HighlightMatches = null; } - if (ImGui.IsItemDeactivatedAfterEdit()) + ImGui.SameLine(); + ImGui.PushItemWidth(200.0f * ImGuiHelpers.GlobalScale); + if (ImGui.InputTextWithHint( + "##textFilter", + "regex global filter", + ref this.textFilter, + 2048, + ImGuiInputTextFlags.EnterReturnsTrue | ImGuiInputTextFlags.AutoSelectAll) + || ImGui.IsItemDeactivatedAfterEdit()) { - this.Refilter(); + this.compiledLogFilter = null; + this.exceptionLogFilter = null; + try + { + this.compiledLogFilter = new(this.textFilter, RegexOptions.IgnoreCase); + + this.QueueRefilter(); + } + catch (Exception e) + { + this.exceptionLogFilter = e; + } + + foreach (var log in this.logText) + log.HighlightMatches = null; } } @@ -509,9 +643,12 @@ internal class ConsoleWindow : Window, IDisposable if (!this.showFilterToolbar) return; PluginFilterEntry? removalEntry = null; - using var table = ImRaii.Table("plugin_filter_entries", 4, ImGuiTableFlags.Resizable | ImGuiTableFlags.BordersInnerV); + using var table = ImRaii.Table( + "plugin_filter_entries", + 4, + ImGuiTableFlags.Resizable | ImGuiTableFlags.BordersInnerV); if (!table) return; - + ImGui.TableSetupColumn("##remove_button", ImGuiTableColumnFlags.WidthFixed, 25.0f * ImGuiHelpers.GlobalScale); ImGui.TableSetupColumn("##source_name", ImGuiTableColumnFlags.WidthFixed, 150.0f * ImGuiHelpers.GlobalScale); ImGui.TableSetupColumn("##log_level", ImGuiTableColumnFlags.WidthFixed, 150.0f * ImGuiHelpers.GlobalScale); @@ -522,15 +659,16 @@ internal class ConsoleWindow : Window, IDisposable { if (this.pluginFilters.All(entry => entry.Source != this.selectedSource)) { - this.pluginFilters.Add(new PluginFilterEntry - { - Source = this.selectedSource, - Filter = string.Empty, - Level = LogEventLevel.Debug, - }); + this.pluginFilters.Add( + new PluginFilterEntry + { + Source = this.selectedSource, + Filter = string.Empty, + Level = LogEventLevel.Debug, + }); } - this.Refilter(); + this.QueueRefilter(); } ImGui.TableNextColumn(); @@ -541,13 +679,17 @@ internal class ConsoleWindow : Window, IDisposable .Select(p => p.Manifest.InternalName) .OrderBy(s => s) .Prepend("DalamudInternal") - .Where(name => this.pluginFilter is "" || new FuzzyMatcher(this.pluginFilter.ToLowerInvariant(), MatchMode.Fuzzy).Matches(name.ToLowerInvariant()) != 0) + .Where( + name => this.pluginFilter is "" || new FuzzyMatcher( + this.pluginFilter.ToLowerInvariant(), + MatchMode.Fuzzy).Matches(name.ToLowerInvariant()) != + 0) .ToList(); ImGui.PushItemWidth(ImGui.GetContentRegionAvail().X); ImGui.InputTextWithHint("##PluginSearchFilter", "Filter Plugin List", ref this.pluginFilter, 2048); ImGui.Separator(); - + if (!sourceNames.Any()) { ImGui.TextColored(ImGuiColors.DalamudRed, "No Results"); @@ -569,25 +711,27 @@ internal class ConsoleWindow : Window, IDisposable foreach (var entry in this.pluginFilters) { + ImGui.PushID(entry.Source); + ImGui.TableNextColumn(); - if (ImGuiComponents.IconButton($"remove{entry.Source}", FontAwesomeIcon.Trash)) + if (ImGuiComponents.IconButton(FontAwesomeIcon.Trash)) { removalEntry = entry; } ImGui.TableNextColumn(); ImGui.Text(entry.Source); - + ImGui.TableNextColumn(); ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); - if (ImGui.BeginCombo($"##levels{entry.Source}", $"{entry.Level}+")) + if (ImGui.BeginCombo("##levels", $"{entry.Level}+")) { foreach (var value in Enum.GetValues()) { if (ImGui.Selectable(value.ToString(), value == entry.Level)) { entry.Level = value; - this.Refilter(); + this.QueueRefilter(); } } @@ -597,19 +741,26 @@ internal class ConsoleWindow : Window, IDisposable ImGui.TableNextColumn(); ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); var entryFilter = entry.Filter; - if (ImGui.InputTextWithHint($"##filter{entry.Source}", $"{entry.Source} regex filter", ref entryFilter, 2048, ImGuiInputTextFlags.EnterReturnsTrue | ImGuiInputTextFlags.AutoSelectAll)) + if (ImGui.InputTextWithHint( + "##filter", + $"{entry.Source} regex filter", + ref entryFilter, + 2048, + ImGuiInputTextFlags.EnterReturnsTrue | ImGuiInputTextFlags.AutoSelectAll) + || ImGui.IsItemDeactivatedAfterEdit()) { entry.Filter = entryFilter; - this.Refilter(); + if (entry.FilterException is null) + this.QueueRefilter(); } - if (ImGui.IsItemDeactivatedAfterEdit()) this.Refilter(); + ImGui.PopID(); } if (removalEntry is { } toRemove) { this.pluginFilters.Remove(toRemove); - this.Refilter(); + this.QueueRefilter(); } } @@ -636,7 +787,7 @@ internal class ConsoleWindow : Window, IDisposable if (this.commandText is "clear" or "cls") { - this.Clear(); + this.QueueClear(); return; } @@ -717,16 +868,22 @@ internal class ConsoleWindow : Window, IDisposable return 0; } - private void AddAndFilter(string line, LogEvent logEvent, bool isMultiline) + /// Add a log entry to the display. + /// The line to add. + /// The Serilog event associated with this line. + /// Number of lines added to . + private int HandleLogLine(string line, LogEvent logEvent) { - if (line.StartsWith("TROUBLESHOOTING:") || line.StartsWith("LASTEXCEPTION:")) - return; + ThreadSafety.DebugAssertMainThread(); + // These lines are too huge, and only useful for troubleshooting after the game exist. + if (line.StartsWith("TROUBLESHOOTING:") || line.StartsWith("LASTEXCEPTION:")) + return 0; + + // Create a log entry template. var entry = new LogEntry { - IsMultiline = isMultiline, Level = logEvent.Level, - Line = line, TimeStamp = logEvent.Timestamp, HasException = logEvent.Exception != null, }; @@ -741,98 +898,118 @@ internal class ConsoleWindow : Window, IDisposable entry.Source = sourceValue; } + var ssp = line.AsSpan(); + var numLines = 0; + while (true) + { + var next = ssp.IndexOfAny('\r', '\n'); + if (next == -1) + { + // Last occurrence; transfer the ownership of the new entry to the queue. + entry.Line = ssp.ToString(); + numLines += this.AddAndFilter(entry); + break; + } + + // There will be more; create a clone of the entry with the current line. + numLines += this.AddAndFilter(entry with { Line = ssp[..next].ToString() }); + + // Mark further lines as multiline. + entry.IsMultiline = true; + + // Skip the detected line break. + ssp = ssp[next..]; + ssp = ssp.StartsWith("\r\n") ? ssp[2..] : ssp[1..]; + } + + return numLines; + } + + /// Adds a line to the log list and the filtered log list accordingly. + /// The new log entry to add. + /// Number of lines added to . + private int AddAndFilter(LogEntry entry) + { + ThreadSafety.DebugAssertMainThread(); + this.logText.Add(entry); - var avoidScroll = this.FilteredLogEntries.Count == this.FilteredLogEntries.Size; - if (this.IsFilterApplicable(entry)) - { - this.FilteredLogEntries.Add(entry); - if (avoidScroll) Interlocked.Increment(ref this.newRolledLines); - } + if (!this.IsFilterApplicable(entry)) + return 0; + + this.filteredLogEntries.Add(entry); + return 1; } + /// Determines if a log entry passes the user-specified filter. + /// The entry to test. + /// true if it passes the filter. private bool IsFilterApplicable(LogEntry entry) { - if (this.regexError) + ThreadSafety.DebugAssertMainThread(); + + if (this.exceptionLogFilter is not null) return false; - try + // If this entry is below a newly set minimum level, fail it + if (EntryPoint.LogLevelSwitch.MinimumLevel > entry.Level) + return false; + + // Show exceptions that weren't properly tagged with a Source (generally meaning they were uncaught) + // After log levels because uncaught exceptions should *never* fall below Error. + if (this.filterShowUncaughtExceptions && entry.HasException && entry.Source == null) + return true; + + // (global filter) && (plugin filter) must be satisfied. + var wholeCond = true; + + // If we have a global filter, check that first + if (this.compiledLogFilter is { } logFilter) { - // If this entry is below a newly set minimum level, fail it - if (EntryPoint.LogLevelSwitch.MinimumLevel > entry.Level) - return false; - - // Show exceptions that weren't properly tagged with a Source (generally meaning they were uncaught) - // After log levels because uncaught exceptions should *never* fall below Error. - if (this.filterShowUncaughtExceptions && entry.HasException && entry.Source == null) - return true; + // Someone will definitely try to just text filter a source without using the actual filters, should allow that. + var matchesSource = entry.Source is not null && logFilter.IsMatch(entry.Source); + var matchesContent = logFilter.IsMatch(entry.Line); - // If we have a global filter, check that first - if (!this.textFilter.IsNullOrEmpty()) + wholeCond &= matchesSource || matchesContent; + } + + // If this entry has a filter, check the filter + if (this.pluginFilters.Count > 0) + { + var matchesAny = false; + + foreach (var filterEntry in this.pluginFilters) { - // Someone will definitely try to just text filter a source without using the actual filters, should allow that. - var matchesSource = entry.Source is not null && Regex.IsMatch(entry.Source, this.textFilter, RegexOptions.IgnoreCase); - var matchesContent = Regex.IsMatch(entry.Line, this.textFilter, RegexOptions.IgnoreCase); + if (!string.Equals(filterEntry.Source, entry.Source, StringComparison.InvariantCultureIgnoreCase)) + continue; - return matchesSource || matchesContent; - } - - // If this entry has a filter, check the filter - if (this.pluginFilters.FirstOrDefault(filter => string.Equals(filter.Source, entry.Source, StringComparison.InvariantCultureIgnoreCase)) is { } filterEntry) - { var allowedLevel = filterEntry.Level <= entry.Level; - var matchesContent = filterEntry.Filter.IsNullOrEmpty() || Regex.IsMatch(entry.Line, filterEntry.Filter, RegexOptions.IgnoreCase); + var matchesContent = filterEntry.FilterRegex?.IsMatch(entry.Line) is not false; - return allowedLevel && matchesContent; + matchesAny |= allowedLevel && matchesContent; + if (matchesAny) + break; } - } - catch (Exception) - { - this.regexError = true; - return false; + + wholeCond &= matchesAny; } - // else we couldn't find a filter for this entry, if we have any filters, we need to block this entry. - return !this.pluginFilters.Any(); + return wholeCond; } - private void Refilter() - { - lock (this.renderLock) - { - this.regexError = false; - this.FilteredLogEntries = new RollingList(this.logText.Where(this.IsFilterApplicable), Math.Max(LogLinesMinimum, this.logLinesLimit)); - } - } + /// Queues clearing the window of all log entries, before next call to . + private void QueueClear() => this.pendingClearLog = true; - private string GetTextForLogEventLevel(LogEventLevel level) => level switch - { - LogEventLevel.Error => "ERR", - LogEventLevel.Verbose => "VRB", - LogEventLevel.Debug => "DBG", - LogEventLevel.Information => "INF", - LogEventLevel.Warning => "WRN", - LogEventLevel.Fatal => "FTL", - _ => throw new ArgumentOutOfRangeException(level.ToString(), "Invalid LogEventLevel"), - }; + /// Queues filtering the log entries again, before next call to . + private void QueueRefilter() => this.pendingRefilter = true; - private uint GetColorForLogEventLevel(LogEventLevel level) => level switch - { - LogEventLevel.Error => 0x800000EE, - LogEventLevel.Verbose => 0x00000000, - LogEventLevel.Debug => 0x00000000, - LogEventLevel.Information => 0x00000000, - LogEventLevel.Warning => 0x8A0070EE, - LogEventLevel.Fatal => 0xFF00000A, - _ => throw new ArgumentOutOfRangeException(level.ToString(), "Invalid LogEventLevel"), - }; + /// Enqueues the new log line to the log-to-be-processed queue. + /// See for the handler for the queued log entries. + private void OnLogLine(object sender, (string Line, LogEvent LogEvent) logEvent) => + this.newLogEntries.Enqueue(logEvent); - private void OnLogLine(object sender, (string Line, LogEvent LogEvent) logEvent) - { - this.HandleLogLine(logEvent.Line, logEvent.LogEvent); - } - - private bool DrawToggleButtonWithTooltip(string buttonId, string tooltip, FontAwesomeIcon icon, ref bool enabledState) + private bool DrawToggleButtonWithTooltip( + string buttonId, string tooltip, FontAwesomeIcon icon, ref bool enabledState) { var result = false; @@ -855,36 +1032,120 @@ internal class ConsoleWindow : Window, IDisposable this.logLinesLimit = dalamudConfiguration.LogLinesLimit; var limit = Math.Max(LogLinesMinimum, this.logLinesLimit); this.logText.Size = limit; - this.FilteredLogEntries.Size = limit; + this.filteredLogEntries.Size = limit; } - private class LogEntry + private unsafe void DrawHighlighted( + ReadOnlySpan line, + MatchCollection matches, + uint col, + uint highlightCol) { - public string Line { get; init; } = string.Empty; + Span charOffsets = stackalloc int[(matches.Count * 2) + 2]; + var charOffsetsIndex = 1; + for (var j = 0; j < matches.Count; j++) + { + var g = matches[j].Groups[0]; + charOffsets[charOffsetsIndex++] = g.Index; + charOffsets[charOffsetsIndex++] = g.Index + g.Length; + } + + charOffsets[charOffsetsIndex++] = line.Length; + + var screenPos = ImGui.GetCursorScreenPos(); + var drawList = ImGui.GetWindowDrawList().NativePtr; + var font = ImGui.GetFont(); + var size = ImGui.GetFontSize(); + var scale = size / font.FontSize; + var hotData = font.IndexedHotDataWrapped(); + var lookup = font.IndexLookupWrapped(); + var kern = (ImGui.GetIO().ConfigFlags & ImGuiConfigFlags.NoKerning) == 0; + var lastc = '\0'; + for (var i = 0; i < charOffsetsIndex - 1; i++) + { + var begin = charOffsets[i]; + var end = charOffsets[i + 1]; + if (begin == end) + continue; + + for (var j = begin; j < end; j++) + { + var currc = line[j]; + if (currc >= lookup.Length || lookup[currc] == ushort.MaxValue) + currc = (char)font.FallbackChar; + + if (kern) + screenPos.X += scale * ImGui.GetFont().GetDistanceAdjustmentForPair(lastc, currc); + font.RenderChar(drawList, size, screenPos, i % 2 == 1 ? highlightCol : col, currc); + + screenPos.X += scale * hotData[currc].AdvanceX; + lastc = currc; + } + } + } + + private record LogEntry + { + public string Line { get; set; } = string.Empty; public LogEventLevel Level { get; init; } public DateTimeOffset TimeStamp { get; init; } - public bool IsMultiline { get; init; } + public bool IsMultiline { get; set; } /// /// Gets or sets the system responsible for generating this log entry. Generally will be a plugin's /// InternalName. /// public string? Source { get; set; } - + public bool SelectedForCopy { get; set; } public bool HasException { get; init; } + + public MatchCollection? HighlightMatches { get; set; } + + public string TimestampString => this.TimeStamp.ToString("HH:mm:ss.fff"); + + public override string ToString() => + this.IsMultiline + ? $"\t{this.Line}" + : $"{this.TimestampString} | {GetTextForLogEventLevel(this.Level)} | {this.Line}"; } private class PluginFilterEntry { + private string filter = string.Empty; + public string Source { get; init; } = string.Empty; - public string Filter { get; set; } = string.Empty; - + public string Filter + { + get => this.filter; + set + { + this.filter = value; + this.FilterRegex = null; + this.FilterException = null; + if (value == string.Empty) + return; + + try + { + this.FilterRegex = new(value, RegexOptions.IgnoreCase); + } + catch (Exception e) + { + this.FilterException = e; + } + } + } + public LogEventLevel Level { get; set; } + + public Regex? FilterRegex { get; private set; } + + public Exception? FilterException { get; private set; } } } diff --git a/Dalamud/Utility/ThreadSafety.cs b/Dalamud/Utility/ThreadSafety.cs index 7c4b0dfcb..ce3ddc602 100644 --- a/Dalamud/Utility/ThreadSafety.cs +++ b/Dalamud/Utility/ThreadSafety.cs @@ -1,4 +1,5 @@ using System; +using System.Runtime.CompilerServices; namespace Dalamud.Utility; @@ -19,6 +20,7 @@ public static class ThreadSafety /// Throws an exception when the current thread is not the main thread. /// /// Thrown when the current thread is not the main thread. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void AssertMainThread() { if (!threadStaticIsMainThread) @@ -31,6 +33,7 @@ public static class ThreadSafety /// Throws an exception when the current thread is the main thread. /// /// Thrown when the current thread is the main thread. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void AssertNotMainThread() { if (threadStaticIsMainThread) @@ -39,6 +42,15 @@ public static class ThreadSafety } } + /// , but only on debug compilation mode. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void DebugAssertMainThread() + { +#if DEBUG + AssertMainThread(); +#endif + } + /// /// Marks a thread as the main thread. /// From 666feede4c444ede746399bd2e7c085a95ae2e56 Mon Sep 17 00:00:00 2001 From: srkizer Date: Thu, 14 Mar 2024 07:36:43 +0900 Subject: [PATCH 11/13] Suppress DAssetM dispose exceptions (#1707) Whether an asset being unavailable should be an error is decided on Dalamud startup time. This suppresses assets unavailable exceptions on Dispose. --- Dalamud/Storage/Assets/DalamudAssetManager.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dalamud/Storage/Assets/DalamudAssetManager.cs b/Dalamud/Storage/Assets/DalamudAssetManager.cs index 69c7c32e8..68be78352 100644 --- a/Dalamud/Storage/Assets/DalamudAssetManager.cs +++ b/Dalamud/Storage/Assets/DalamudAssetManager.cs @@ -75,7 +75,7 @@ internal sealed class DalamudAssetManager : IServiceType, IDisposable, IDalamudA .Where(x => x is not DalamudAsset.Empty4X4) .Where(x => x.GetAttribute()?.Required is false) .Select(this.CreateStreamAsync) - .Select(x => x.ToContentDisposedTask())) + .Select(x => x.ToContentDisposedTask(true))) .ContinueWith(r => Log.Verbose($"Optional assets load state: {r}")); } @@ -99,6 +99,7 @@ internal sealed class DalamudAssetManager : IServiceType, IDisposable, IDalamudA .Concat(this.fileStreams.Values) .Concat(this.textureWraps.Values) .Where(x => x is not null) + .Select(x => x.ContinueWith(r => { _ = r.Exception; })) .ToArray()); this.scopedFinalizer.Dispose(); } From a26bb58fdbb79032b26b85488a04d49d0e40b8d0 Mon Sep 17 00:00:00 2001 From: srkizer Date: Thu, 14 Mar 2024 08:36:38 +0900 Subject: [PATCH 12/13] Use custom TaskScheduler for Framework.RunOnTick (#1597) * Use custom TaskScheduler for Framework.RunOnTick * TaskSchedulerWidget: add example --- Dalamud/Game/Framework.cs | 262 +++++++----------- .../Data/Widgets/TaskSchedulerWidget.cs | 157 ++++++++++- Dalamud/Plugin/Services/IFramework.cs | 15 + Dalamud/Utility/ThreadBoundTaskScheduler.cs | 90 ++++++ 4 files changed, 353 insertions(+), 171 deletions(-) create mode 100644 Dalamud/Utility/ThreadBoundTaskScheduler.cs diff --git a/Dalamud/Game/Framework.cs b/Dalamud/Game/Framework.cs index ce34f2c06..6520ca5c8 100644 --- a/Dalamud/Game/Framework.cs +++ b/Dalamud/Game/Framework.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -41,11 +42,13 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework [ServiceManager.ServiceDependency] private readonly DalamudConfiguration configuration = Service.Get(); - private readonly object runOnNextTickTaskListSync = new(); - private List runOnNextTickTaskList = new(); - private List runOnNextTickTaskList2 = new(); + private readonly CancellationTokenSource frameworkDestroy; + private readonly ThreadBoundTaskScheduler frameworkThreadTaskScheduler; - private Thread? frameworkUpdateThread; + private readonly ConcurrentDictionary + tickDelayedTaskCompletionSources = new(); + + private ulong tickCounter; [ServiceManager.ServiceConstructor] private Framework(TargetSigScanner sigScanner, GameLifecycle lifecycle) @@ -56,6 +59,14 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework this.addressResolver = new FrameworkAddressResolver(); this.addressResolver.Setup(sigScanner); + this.frameworkDestroy = new(); + this.frameworkThreadTaskScheduler = new(); + this.FrameworkThreadTaskFactory = new( + this.frameworkDestroy.Token, + TaskCreationOptions.None, + TaskContinuationOptions.None, + this.frameworkThreadTaskScheduler); + this.updateHook = Hook.FromAddress(this.addressResolver.TickAddress, this.HandleFrameworkUpdate); this.destroyHook = Hook.FromAddress(this.addressResolver.DestroyAddress, this.HandleFrameworkDestroy); @@ -92,14 +103,17 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework /// public DateTime LastUpdateUTC { get; private set; } = DateTime.MinValue; + /// + public TaskFactory FrameworkThreadTaskFactory { get; } + /// public TimeSpan UpdateDelta { get; private set; } = TimeSpan.Zero; /// - public bool IsInFrameworkUpdateThread => Thread.CurrentThread == this.frameworkUpdateThread; + public bool IsInFrameworkUpdateThread => this.frameworkThreadTaskScheduler.IsOnBoundThread; /// - public bool IsFrameworkUnloading { get; internal set; } + public bool IsFrameworkUnloading => this.frameworkDestroy.IsCancellationRequested; /// /// Gets the list of update sub-delegates that didn't get updated this frame. @@ -111,6 +125,19 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework /// internal bool DispatchUpdateEvents { get; set; } = true; + /// + public Task DelayTicks(long numTicks, CancellationToken cancellationToken = default) + { + if (this.frameworkDestroy.IsCancellationRequested) + return Task.FromCanceled(this.frameworkDestroy.Token); + if (numTicks <= 0) + return Task.CompletedTask; + + var tcs = new TaskCompletionSource(); + this.tickDelayedTaskCompletionSources[tcs] = (this.tickCounter + (ulong)numTicks, cancellationToken); + return tcs.Task; + } + /// public Task RunOnFrameworkThread(Func func) => this.IsInFrameworkUpdateThread || this.IsFrameworkUnloading ? Task.FromResult(func()) : this.RunOnTick(func); @@ -157,20 +184,16 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework return Task.FromCanceled(cts.Token); } - var tcs = new TaskCompletionSource(); - lock (this.runOnNextTickTaskListSync) - { - this.runOnNextTickTaskList.Add(new RunOnNextTickTaskFunc() + if (cancellationToken == default) + cancellationToken = this.FrameworkThreadTaskFactory.CancellationToken; + return this.FrameworkThreadTaskFactory.ContinueWhenAll( + new[] { - RemainingTicks = delayTicks, - RunAfterTickCount = Environment.TickCount64 + (long)Math.Ceiling(delay.TotalMilliseconds), - CancellationToken = cancellationToken, - TaskCompletionSource = tcs, - Func = func, - }); - } - - return tcs.Task; + Task.Delay(delay, cancellationToken), + this.DelayTicks(delayTicks, cancellationToken), + }, + _ => func(), + cancellationToken); } /// @@ -186,20 +209,16 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework return Task.FromCanceled(cts.Token); } - var tcs = new TaskCompletionSource(); - lock (this.runOnNextTickTaskListSync) - { - this.runOnNextTickTaskList.Add(new RunOnNextTickTaskAction() + if (cancellationToken == default) + cancellationToken = this.FrameworkThreadTaskFactory.CancellationToken; + return this.FrameworkThreadTaskFactory.ContinueWhenAll( + new[] { - RemainingTicks = delayTicks, - RunAfterTickCount = Environment.TickCount64 + (long)Math.Ceiling(delay.TotalMilliseconds), - CancellationToken = cancellationToken, - TaskCompletionSource = tcs, - Action = action, - }); - } - - return tcs.Task; + Task.Delay(delay, cancellationToken), + this.DelayTicks(delayTicks, cancellationToken), + }, + _ => action(), + cancellationToken); } /// @@ -215,20 +234,16 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework return Task.FromCanceled(cts.Token); } - var tcs = new TaskCompletionSource>(); - lock (this.runOnNextTickTaskListSync) - { - this.runOnNextTickTaskList.Add(new RunOnNextTickTaskFunc>() + if (cancellationToken == default) + cancellationToken = this.FrameworkThreadTaskFactory.CancellationToken; + return this.FrameworkThreadTaskFactory.ContinueWhenAll( + new[] { - RemainingTicks = delayTicks, - RunAfterTickCount = Environment.TickCount64 + (long)Math.Ceiling(delay.TotalMilliseconds), - CancellationToken = cancellationToken, - TaskCompletionSource = tcs, - Func = func, - }); - } - - return tcs.Task.ContinueWith(x => x.Result, cancellationToken).Unwrap(); + Task.Delay(delay, cancellationToken), + this.DelayTicks(delayTicks, cancellationToken), + }, + _ => func(), + cancellationToken).Unwrap(); } /// @@ -244,20 +259,16 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework return Task.FromCanceled(cts.Token); } - var tcs = new TaskCompletionSource(); - lock (this.runOnNextTickTaskListSync) - { - this.runOnNextTickTaskList.Add(new RunOnNextTickTaskFunc() + if (cancellationToken == default) + cancellationToken = this.FrameworkThreadTaskFactory.CancellationToken; + return this.FrameworkThreadTaskFactory.ContinueWhenAll( + new[] { - RemainingTicks = delayTicks, - RunAfterTickCount = Environment.TickCount64 + (long)Math.Ceiling(delay.TotalMilliseconds), - CancellationToken = cancellationToken, - TaskCompletionSource = tcs, - Func = func, - }); - } - - return tcs.Task.ContinueWith(x => x.Result, cancellationToken).Unwrap(); + Task.Delay(delay, cancellationToken), + this.DelayTicks(delayTicks, cancellationToken), + }, + _ => func(), + cancellationToken).Unwrap(); } /// @@ -333,23 +344,9 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework } } - private void RunPendingTickTasks() - { - if (this.runOnNextTickTaskList.Count == 0 && this.runOnNextTickTaskList2.Count == 0) - return; - - for (var i = 0; i < 2; i++) - { - lock (this.runOnNextTickTaskListSync) - (this.runOnNextTickTaskList, this.runOnNextTickTaskList2) = (this.runOnNextTickTaskList2, this.runOnNextTickTaskList); - - this.runOnNextTickTaskList2.RemoveAll(x => x.Run()); - } - } - private bool HandleFrameworkUpdate(IntPtr framework) { - this.frameworkUpdateThread ??= Thread.CurrentThread; + this.frameworkThreadTaskScheduler.BoundThread ??= Thread.CurrentThread; ThreadSafety.MarkMainThread(); @@ -381,18 +378,30 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework this.LastUpdate = DateTime.Now; this.LastUpdateUTC = DateTime.UtcNow; + this.tickCounter++; + foreach (var (k, (expiry, ct)) in this.tickDelayedTaskCompletionSources) + { + if (ct.IsCancellationRequested) + k.SetCanceled(ct); + else if (expiry <= this.tickCounter) + k.SetResult(); + else + continue; + + this.tickDelayedTaskCompletionSources.Remove(k, out _); + } if (StatsEnabled) { StatsStopwatch.Restart(); - this.RunPendingTickTasks(); + this.frameworkThreadTaskScheduler.Run(); StatsStopwatch.Stop(); - AddToStats(nameof(this.RunPendingTickTasks), StatsStopwatch.Elapsed.TotalMilliseconds); + AddToStats(nameof(this.frameworkThreadTaskScheduler), StatsStopwatch.Elapsed.TotalMilliseconds); } else { - this.RunPendingTickTasks(); + this.frameworkThreadTaskScheduler.Run(); } if (StatsEnabled && this.Update != null) @@ -404,7 +413,7 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework // Cleanup handlers that are no longer being called foreach (var key in this.NonUpdatedSubDelegates) { - if (key == nameof(this.RunPendingTickTasks)) + if (key == nameof(this.FrameworkThreadTaskFactory)) continue; if (StatsHistory[key].Count > 0) @@ -431,8 +440,11 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework private bool HandleFrameworkDestroy(IntPtr framework) { - this.IsFrameworkUnloading = true; + this.frameworkDestroy.Cancel(); this.DispatchUpdateEvents = false; + foreach (var k in this.tickDelayedTaskCompletionSources.Keys) + k.SetCanceled(this.frameworkDestroy.Token); + this.tickDelayedTaskCompletionSources.Clear(); // All the same, for now... this.lifecycle.SetShuttingDown(); @@ -440,95 +452,12 @@ internal sealed class Framework : IDisposable, IServiceType, IFramework Log.Information("Framework::Destroy!"); Service.Get().Unload(); - this.RunPendingTickTasks(); + this.frameworkThreadTaskScheduler.Run(); ServiceManager.WaitForServiceUnload(); Log.Information("Framework::Destroy OK!"); return this.destroyHook.OriginalDisposeSafe(framework); } - - private abstract class RunOnNextTickTaskBase - { - internal int RemainingTicks { get; set; } - - internal long RunAfterTickCount { get; init; } - - internal CancellationToken CancellationToken { get; init; } - - internal bool Run() - { - if (this.CancellationToken.IsCancellationRequested) - { - this.CancelImpl(); - return true; - } - - if (this.RemainingTicks > 0) - this.RemainingTicks -= 1; - if (this.RemainingTicks > 0) - return false; - - if (this.RunAfterTickCount > Environment.TickCount64) - return false; - - this.RunImpl(); - - return true; - } - - protected abstract void RunImpl(); - - protected abstract void CancelImpl(); - } - - private class RunOnNextTickTaskFunc : RunOnNextTickTaskBase - { - internal TaskCompletionSource TaskCompletionSource { get; init; } - - internal Func Func { get; init; } - - protected override void RunImpl() - { - try - { - this.TaskCompletionSource.SetResult(this.Func()); - } - catch (Exception ex) - { - this.TaskCompletionSource.SetException(ex); - } - } - - protected override void CancelImpl() - { - this.TaskCompletionSource.SetCanceled(); - } - } - - private class RunOnNextTickTaskAction : RunOnNextTickTaskBase - { - internal TaskCompletionSource TaskCompletionSource { get; init; } - - internal Action Action { get; init; } - - protected override void RunImpl() - { - try - { - this.Action(); - this.TaskCompletionSource.SetResult(); - } - catch (Exception ex) - { - this.TaskCompletionSource.SetException(ex); - } - } - - protected override void CancelImpl() - { - this.TaskCompletionSource.SetCanceled(); - } - } } /// @@ -561,7 +490,10 @@ internal class FrameworkPluginScoped : IDisposable, IServiceType, IFramework /// public DateTime LastUpdateUTC => this.frameworkService.LastUpdateUTC; - + + /// + public TaskFactory FrameworkThreadTaskFactory => this.frameworkService.FrameworkThreadTaskFactory; + /// public TimeSpan UpdateDelta => this.frameworkService.UpdateDelta; @@ -579,6 +511,10 @@ internal class FrameworkPluginScoped : IDisposable, IServiceType, IFramework this.Update = null; } + /// + public Task DelayTicks(long numTicks, CancellationToken cancellationToken = default) => + this.frameworkService.DelayTicks(numTicks, cancellationToken); + /// public Task RunOnFrameworkThread(Func func) => this.frameworkService.RunOnFrameworkThread(func); diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/TaskSchedulerWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/TaskSchedulerWidget.cs index d1ac51ad5..c6d8c4e8b 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/TaskSchedulerWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/TaskSchedulerWidget.cs @@ -1,13 +1,22 @@ // ReSharper disable MethodSupportsCancellation // Using alternative method of cancelling tasks by throwing exceptions. +using System.IO; +using System.Linq; +using System.Net.Http; using System.Reflection; +using System.Text; using System.Threading; using System.Threading.Tasks; using Dalamud.Game; using Dalamud.Interface.Colors; +using Dalamud.Interface.Components; +using Dalamud.Interface.ImGuiFileDialog; using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; using Dalamud.Logging.Internal; +using Dalamud.Utility; + using ImGuiNET; using Serilog; @@ -18,6 +27,12 @@ namespace Dalamud.Interface.Internal.Windows.Data.Widgets; /// internal class TaskSchedulerWidget : IDataWindowWidget { + private readonly FileDialogManager fileDialogManager = new(); + private readonly byte[] urlBytes = new byte[2048]; + private readonly byte[] localPathBytes = new byte[2048]; + + private Task? downloadTask = null; + private (long Downloaded, long Total, float Percentage) downloadState; private CancellationTokenSource taskSchedulerCancelSource = new(); /// @@ -33,11 +48,16 @@ internal class TaskSchedulerWidget : IDataWindowWidget public void Load() { this.Ready = true; + Encoding.UTF8.GetBytes( + "https://geo.mirror.pkgbuild.com/iso/2024.01.01/archlinux-2024.01.01-x86_64.iso", + this.urlBytes); } /// public void Draw() { + var framework = Service.Get(); + if (ImGui.Button("Clear list")) { TaskTracker.Clear(); @@ -84,8 +104,7 @@ internal class TaskSchedulerWidget : IDataWindowWidget { Thread.Sleep(200); - string a = null; - a.Contains("dalamud"); // Intentional null exception. + _ = ((string)null)!.Contains("dalamud"); // Intentional null exception. }); } @@ -94,36 +113,156 @@ internal class TaskSchedulerWidget : IDataWindowWidget if (ImGui.Button("ASAP")) { - Task.Run(async () => await Service.Get().RunOnTick(() => { }, cancellationToken: this.taskSchedulerCancelSource.Token)); + _ = framework.RunOnTick(() => Log.Information("Framework.Update - ASAP"), cancellationToken: this.taskSchedulerCancelSource.Token); } ImGui.SameLine(); if (ImGui.Button("In 1s")) { - Task.Run(async () => await Service.Get().RunOnTick(() => { }, cancellationToken: this.taskSchedulerCancelSource.Token, delay: TimeSpan.FromSeconds(1))); + _ = framework.RunOnTick(() => Log.Information("Framework.Update - In 1s"), cancellationToken: this.taskSchedulerCancelSource.Token, delay: TimeSpan.FromSeconds(1)); } ImGui.SameLine(); if (ImGui.Button("In 60f")) { - Task.Run(async () => await Service.Get().RunOnTick(() => { }, cancellationToken: this.taskSchedulerCancelSource.Token, delayTicks: 60)); + _ = framework.RunOnTick(() => Log.Information("Framework.Update - In 60f"), cancellationToken: this.taskSchedulerCancelSource.Token, delayTicks: 60); + } + + ImGui.SameLine(); + + if (ImGui.Button("In 1s+120f")) + { + _ = framework.RunOnTick(() => Log.Information("Framework.Update - In 1s+120f"), cancellationToken: this.taskSchedulerCancelSource.Token, delay: TimeSpan.FromSeconds(1), delayTicks: 120); + } + + ImGui.SameLine(); + + if (ImGui.Button("In 2s+60f")) + { + _ = framework.RunOnTick(() => Log.Information("Framework.Update - In 2s+60f"), cancellationToken: this.taskSchedulerCancelSource.Token, delay: TimeSpan.FromSeconds(2), delayTicks: 60); + } + + ImGui.SameLine(); + + if (ImGui.Button("Every 60 frames")) + { + _ = framework.RunOnTick( + async () => + { + for (var i = 0L; ; i++) + { + Log.Information($"Loop #{i}; MainThread={ThreadSafety.IsMainThread}"); + await framework.DelayTicks(60, this.taskSchedulerCancelSource.Token); + } + }, + cancellationToken: this.taskSchedulerCancelSource.Token); } ImGui.SameLine(); if (ImGui.Button("Error in 1s")) { - Task.Run(async () => await Service.Get().RunOnTick(() => throw new Exception("Test Exception"), cancellationToken: this.taskSchedulerCancelSource.Token, delay: TimeSpan.FromSeconds(1))); + _ = framework.RunOnTick(() => throw new Exception("Test Exception"), cancellationToken: this.taskSchedulerCancelSource.Token, delay: TimeSpan.FromSeconds(1)); } ImGui.SameLine(); if (ImGui.Button("As long as it's in Framework Thread")) { - Task.Run(async () => await Service.Get().RunOnFrameworkThread(() => { Log.Information("Task dispatched from non-framework.update thread"); })); - Service.Get().RunOnFrameworkThread(() => { Log.Information("Task dispatched from framework.update thread"); }).Wait(); + Task.Run(async () => await framework.RunOnFrameworkThread(() => { Log.Information("Task dispatched from non-framework.update thread"); })); + framework.RunOnFrameworkThread(() => { Log.Information("Task dispatched from framework.update thread"); }).Wait(); + } + + if (ImGui.CollapsingHeader("Download")) + { + ImGui.InputText("URL", this.urlBytes, (uint)this.urlBytes.Length); + ImGui.InputText("Local Path", this.localPathBytes, (uint)this.localPathBytes.Length); + ImGui.SameLine(); + + if (ImGuiComponents.IconButton("##localpathpicker", FontAwesomeIcon.File)) + { + var defaultFileName = Encoding.UTF8.GetString(this.urlBytes).Split('\0', 2)[0].Split('/').Last(); + this.fileDialogManager.SaveFileDialog( + "Choose a local path", + "*", + defaultFileName, + string.Empty, + (accept, newPath) => + { + if (accept) + { + this.localPathBytes.AsSpan().Clear(); + Encoding.UTF8.GetBytes(newPath, this.localPathBytes.AsSpan()); + } + }); + } + + ImGui.TextUnformatted($"{this.downloadState.Downloaded:##,###}/{this.downloadState.Total:##,###} ({this.downloadState.Percentage:0.00}%)"); + + using var disabled = + ImRaii.Disabled(this.downloadTask?.IsCompleted is false || this.localPathBytes[0] == 0); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Download"); + ImGui.SameLine(); + var downloadUsingGlobalScheduler = ImGui.Button("using default scheduler"); + ImGui.SameLine(); + var downloadUsingFramework = ImGui.Button("using Framework.Update"); + if (downloadUsingGlobalScheduler || downloadUsingFramework) + { + var url = Encoding.UTF8.GetString(this.urlBytes).Split('\0', 2)[0]; + var localPath = Encoding.UTF8.GetString(this.localPathBytes).Split('\0', 2)[0]; + var ct = this.taskSchedulerCancelSource.Token; + this.downloadState = default; + var factory = downloadUsingGlobalScheduler + ? Task.Factory + : framework.FrameworkThreadTaskFactory; + this.downloadState = default; + this.downloadTask = factory.StartNew( + async () => + { + try + { + await using var to = File.Create(localPath); + using var client = new HttpClient(); + using var conn = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); + this.downloadState.Total = conn.Content.Headers.ContentLength ?? -1L; + await using var from = conn.Content.ReadAsStream(ct); + var buffer = new byte[8192]; + while (true) + { + if (downloadUsingFramework) + ThreadSafety.AssertMainThread(); + if (downloadUsingGlobalScheduler) + ThreadSafety.AssertNotMainThread(); + var len = await from.ReadAsync(buffer, ct); + if (len == 0) + break; + await to.WriteAsync(buffer.AsMemory(0, len), ct); + this.downloadState.Downloaded += len; + if (this.downloadState.Total >= 0) + { + this.downloadState.Percentage = + (100f * this.downloadState.Downloaded) / this.downloadState.Total; + } + } + } + catch (Exception e) + { + Log.Error(e, "Failed to download {from} to {to}.", url, localPath); + try + { + File.Delete(localPath); + } + catch + { + // ignore + } + } + }, + cancellationToken: ct).Unwrap(); + } } if (ImGui.Button("Drown in tasks")) @@ -244,6 +383,8 @@ internal class TaskSchedulerWidget : IDataWindowWidget ImGui.PopStyleColor(1); } + + this.fileDialogManager.Draw(); } private async Task TestTaskInTaskDelay(CancellationToken token) diff --git a/Dalamud/Plugin/Services/IFramework.cs b/Dalamud/Plugin/Services/IFramework.cs index ca33c5867..a93abd252 100644 --- a/Dalamud/Plugin/Services/IFramework.cs +++ b/Dalamud/Plugin/Services/IFramework.cs @@ -29,6 +29,11 @@ public interface IFramework /// public DateTime LastUpdateUTC { get; } + /// + /// Gets a that runs tasks during Framework Update event. + /// + public TaskFactory FrameworkThreadTaskFactory { get; } + /// /// Gets the delta between the last Framework Update and the currently executing one. /// @@ -44,6 +49,14 @@ public interface IFramework /// public bool IsFrameworkUnloading { get; } + /// + /// Returns a task that completes after the given number of ticks. + /// + /// Number of ticks to delay. + /// The cancellation token. + /// A new that gets resolved after specified number of ticks happen. + public Task DelayTicks(long numTicks, CancellationToken cancellationToken = default); + /// /// Run given function right away if this function has been called from game's Framework.Update thread, or otherwise run on next Framework.Update call. /// @@ -65,6 +78,7 @@ public interface IFramework /// Return type. /// Function to call. /// Task representing the pending or already completed function. + [Obsolete($"Use {nameof(RunOnTick)} instead.")] public Task RunOnFrameworkThread(Func> func); /// @@ -72,6 +86,7 @@ public interface IFramework /// /// Function to call. /// Task representing the pending or already completed function. + [Obsolete($"Use {nameof(RunOnTick)} instead.")] public Task RunOnFrameworkThread(Func func); /// diff --git a/Dalamud/Utility/ThreadBoundTaskScheduler.cs b/Dalamud/Utility/ThreadBoundTaskScheduler.cs new file mode 100644 index 000000000..4b6de29ff --- /dev/null +++ b/Dalamud/Utility/ThreadBoundTaskScheduler.cs @@ -0,0 +1,90 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Dalamud.Utility; + +/// +/// A task scheduler that runs tasks on a specific thread. +/// +internal class ThreadBoundTaskScheduler : TaskScheduler +{ + private const byte Scheduled = 0; + private const byte Running = 1; + + private readonly ConcurrentDictionary scheduledTasks = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The thread to bind this task scheduelr to. + public ThreadBoundTaskScheduler(Thread? boundThread = null) + { + this.BoundThread = boundThread; + } + + /// + /// Gets or sets the thread this task scheduler is bound to. + /// + public Thread? BoundThread { get; set; } + + /// + /// Gets a value indicating whether we're on the bound thread. + /// + public bool IsOnBoundThread => Thread.CurrentThread == this.BoundThread; + + /// + /// Runs queued tasks. + /// + public void Run() + { + foreach (var task in this.scheduledTasks.Keys) + { + if (!this.scheduledTasks.TryUpdate(task, Running, Scheduled)) + continue; + + _ = this.TryExecuteTask(task); + } + } + + /// + protected override IEnumerable GetScheduledTasks() + { + return this.scheduledTasks.Keys; + } + + /// + protected override void QueueTask(Task task) + { + this.scheduledTasks[task] = Scheduled; + } + + /// + protected override bool TryDequeue(Task task) + { + if (!this.scheduledTasks.TryRemove(task, out _)) + return false; + return true; + } + + /// + protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) + { + if (!this.IsOnBoundThread) + return false; + + if (taskWasPreviouslyQueued && !this.scheduledTasks.TryUpdate(task, Running, Scheduled)) + return false; + + _ = this.TryExecuteTask(task); + return true; + } + + private new bool TryExecuteTask(Task task) + { + var r = base.TryExecuteTask(task); + this.scheduledTasks.Remove(task, out _); + return r; + } +} From cf4a9e305597501d43722139dfba671ad0d06e77 Mon Sep 17 00:00:00 2001 From: srkizer Date: Thu, 14 Mar 2024 08:57:30 +0900 Subject: [PATCH 13/13] Easier SingleFontChooserDialog ctor, window pos/size/flags, and more docs (#1704) * Make SingleFontChooserDialog ctor less confusing The current constructor expects a new fresh instance of IFontAtlas, which can be easy to miss, resulting in wasted time troubleshooting without enough clues. New constructor is added that directly takes an instance of UiBuilder, and the old constructor has been obsoleted and should be changed to private on api 10. * Add position, size, and window flags conf to SFCD * Improve documentations * Add test for PopupPosition/Size --------- Co-authored-by: goat <16760685+goaaats@users.noreply.github.com> --- .../SingleFontChooserDialog.cs | 243 ++++++++++++++++-- .../Widgets/GamePrebakedFontsTestWidget.cs | 89 +++++-- .../Windows/Settings/Tabs/SettingsTabLook.cs | 5 +- .../Interface/ManagedFontAtlas/IFontAtlas.cs | 20 +- .../Interface/ManagedFontAtlas/IFontHandle.cs | 21 +- .../ManagedFontAtlas/Internals/FontHandle.cs | 9 +- 6 files changed, 327 insertions(+), 60 deletions(-) diff --git a/Dalamud/Interface/ImGuiFontChooserDialog/SingleFontChooserDialog.cs b/Dalamud/Interface/ImGuiFontChooserDialog/SingleFontChooserDialog.cs index ca75e5ce0..9420fe42c 100644 --- a/Dalamud/Interface/ImGuiFontChooserDialog/SingleFontChooserDialog.cs +++ b/Dalamud/Interface/ImGuiFontChooserDialog/SingleFontChooserDialog.cs @@ -9,6 +9,7 @@ using Dalamud.Configuration.Internal; using Dalamud.Interface.Colors; using Dalamud.Interface.FontIdentifier; using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.ManagedFontAtlas.Internals; using Dalamud.Interface.Utility; using Dalamud.Utility; @@ -84,11 +85,22 @@ public sealed class SingleFontChooserDialog : IDisposable private IFontHandle? fontHandle; private SingleFontSpec selectedFont; - /// - /// Initializes a new instance of the class. - /// + private bool popupPositionChanged; + private bool popupSizeChanged; + private Vector2 popupPosition = new(float.NaN); + private Vector2 popupSize = new(float.NaN); + + /// Initializes a new instance of the class. /// A new instance of created using /// as its auto-rebuild mode. + /// The passed instance of will be disposed after use. If you pass an atlas + /// that is already being used, then all the font handles under the passed atlas will be invalidated upon disposing + /// this font chooser. Consider using for automatic + /// handling of font atlas derived from a , or even for automatic + /// registration and unregistration of event handler in addition to automatic disposal of this + /// class and the temporary font atlas for this font chooser dialog. + [Obsolete("See remarks, and use the other constructor.", false)] + [Api10ToDo("Make private.")] public SingleFontChooserDialog(IFontAtlas newAsyncAtlas) { this.counter = Interlocked.Increment(ref counterStatic); @@ -99,6 +111,39 @@ public sealed class SingleFontChooserDialog : IDisposable Encoding.UTF8.GetBytes("Font preview.\n0123456789!", this.fontPreviewText); } +#pragma warning disable CS0618 // Type or member is obsolete + // TODO: Api10ToDo; Remove this pragma warning disable line + + /// Initializes a new instance of the class. + /// The relevant instance of UiBuilder. + /// Whether the fonts in the atlas is global scaled. + /// Atlas name for debugging purposes. + /// + /// The passed is only used for creating a temporary font atlas. It will not + /// automatically register a hander for . + /// Consider using for automatic registration and unregistration of + /// event handler in addition to automatic disposal of this class and the temporary font atlas + /// for this font chooser dialog. + /// + public SingleFontChooserDialog(UiBuilder uiBuilder, bool isGlobalScaled = true, string? debugAtlasName = null) + : this(uiBuilder.CreateFontAtlas(FontAtlasAutoRebuildMode.Async, isGlobalScaled, debugAtlasName)) + { + } + + /// Initializes a new instance of the class. + /// An instance of . + /// The temporary atlas name. + internal SingleFontChooserDialog(FontAtlasFactory factory, string debugAtlasName) + : this(factory.CreateFontAtlas(debugAtlasName, FontAtlasAutoRebuildMode.Async)) + { + } + +#pragma warning restore CS0618 // Type or member is obsolete + // TODO: Api10ToDo; Remove this pragma warning restore line + + /// Called when the selected font spec has changed. + public event Action? SelectedFontSpecChanged; + /// /// Gets or sets the title of this font chooser dialog popup. /// @@ -153,6 +198,8 @@ public sealed class SingleFontChooserDialog : IDisposable this.useAdvancedOptions |= Math.Abs(value.LineHeight - 1f) > 0.000001; this.useAdvancedOptions |= value.GlyphOffset != default; this.useAdvancedOptions |= value.LetterSpacing != 0f; + + this.SelectedFontSpecChanged?.Invoke(this.selectedFont); } } @@ -166,15 +213,55 @@ public sealed class SingleFontChooserDialog : IDisposable /// public bool IgnorePreviewGlobalScale { get; set; } - /// - /// Creates a new instance of that will automatically draw and dispose itself as - /// needed. + /// Gets or sets a value indicating whether this popup should be modal, blocking everything behind from + /// being interacted. + /// If true, then will be + /// used. Otherwise, will be used. + public bool IsModal { get; set; } = true; + + /// Gets or sets the window flags. + public ImGuiWindowFlags WindowFlags { get; set; } + + /// Gets or sets the popup window position. + /// + /// Setting the position only works before the first call to . + /// If any of the coordinates are , default position will be used. + /// The position will be clamped into the work area of the selected monitor. + /// + public Vector2 PopupPosition + { + get => this.popupPosition; + set + { + this.popupPositionChanged = true; + this.popupPosition = value; + } + } + + /// Gets or sets the popup window size. + /// + /// Setting the size only works before the first call to . + /// If any of the coordinates are , default size will be used. + /// The size will be clamped into the work area of the selected monitor. + /// + public Vector2 PopupSize + { + get => this.popupSize; + set + { + this.popupSizeChanged = true; + this.popupSize = value; + } + } + + /// Creates a new instance of that will automatically draw and + /// dispose itself as needed; calling and are handled automatically. /// /// An instance of . /// The new instance of . public static SingleFontChooserDialog CreateAuto(UiBuilder uiBuilder) { - var fcd = new SingleFontChooserDialog(uiBuilder.CreateFontAtlas(FontAtlasAutoRebuildMode.Async)); + var fcd = new SingleFontChooserDialog(uiBuilder); uiBuilder.Draw += fcd.Draw; fcd.tcs.Task.ContinueWith( r => @@ -187,6 +274,14 @@ public sealed class SingleFontChooserDialog : IDisposable return fcd; } + /// Gets the default popup size before clamping to monitor work area. + /// The default popup size. + public static Vector2 GetDefaultPopupSizeNonClamped() + { + ThreadSafety.AssertMainThread(); + return new Vector2(40, 30) * ImGui.GetTextLineHeight(); + } + /// public void Dispose() { @@ -204,13 +299,28 @@ public sealed class SingleFontChooserDialog : IDisposable ImGui.GetIO().WantTextInput = false; } + /// Sets and to be at the center of the current window + /// being drawn. + /// The preferred popup size. + public void SetPopupPositionAndSizeToCurrentWindowCenter(Vector2 preferredPopupSize) + { + ThreadSafety.AssertMainThread(); + this.PopupSize = preferredPopupSize; + this.PopupPosition = ImGui.GetWindowPos() + ((ImGui.GetWindowSize() - preferredPopupSize) / 2); + } + + /// Sets and to be at the center of the current window + /// being drawn. + public void SetPopupPositionAndSizeToCurrentWindowCenter() => + this.SetPopupPositionAndSizeToCurrentWindowCenter(GetDefaultPopupSizeNonClamped()); + /// /// Draws this dialog. /// public void Draw() { - if (this.firstDraw) - ImGui.OpenPopup(this.popupImGuiName); + const float popupMinWidth = 320; + const float popupMinHeight = 240; ImGui.GetIO().WantCaptureKeyboard = true; ImGui.GetIO().WantTextInput = true; @@ -220,12 +330,70 @@ public sealed class SingleFontChooserDialog : IDisposable return; } - var open = true; - ImGui.SetNextWindowSize(new(640, 480), ImGuiCond.Appearing); - if (!ImGui.BeginPopupModal(this.popupImGuiName, ref open) || !open) + if (this.firstDraw) { - this.Cancel(); - return; + if (this.IsModal) + ImGui.OpenPopup(this.popupImGuiName); + } + + if (this.firstDraw || this.popupPositionChanged || this.popupSizeChanged) + { + var preferProvidedSize = !float.IsNaN(this.popupSize.X) && !float.IsNaN(this.popupSize.Y); + var size = preferProvidedSize ? this.popupSize : GetDefaultPopupSizeNonClamped(); + size.X = Math.Max(size.X, popupMinWidth); + size.Y = Math.Max(size.Y, popupMinHeight); + + var preferProvidedPos = !float.IsNaN(this.popupPosition.X) && !float.IsNaN(this.popupPosition.Y); + var monitorLocatorPos = preferProvidedPos ? this.popupPosition + (size / 2) : ImGui.GetMousePos(); + + var monitors = ImGui.GetPlatformIO().Monitors; + var preferredMonitor = 0; + var preferredDistance = GetDistanceFromMonitor(monitorLocatorPos, monitors[0]); + for (var i = 1; i < monitors.Size; i++) + { + var distance = GetDistanceFromMonitor(monitorLocatorPos, monitors[i]); + if (distance < preferredDistance) + { + preferredMonitor = i; + preferredDistance = distance; + } + } + + var lt = monitors[preferredMonitor].WorkPos; + var workSize = monitors[preferredMonitor].WorkSize; + size.X = Math.Min(size.X, workSize.X); + size.Y = Math.Min(size.Y, workSize.Y); + var rb = (lt + workSize) - size; + + var pos = + preferProvidedPos + ? new(Math.Clamp(this.PopupPosition.X, lt.X, rb.X), Math.Clamp(this.PopupPosition.Y, lt.Y, rb.Y)) + : (lt + rb) / 2; + + ImGui.SetNextWindowSize(size, ImGuiCond.Always); + ImGui.SetNextWindowPos(pos, ImGuiCond.Always); + this.popupPositionChanged = this.popupSizeChanged = false; + } + + ImGui.SetNextWindowSizeConstraints(new(popupMinWidth, popupMinHeight), new(float.MaxValue)); + if (this.IsModal) + { + var open = true; + if (!ImGui.BeginPopupModal(this.popupImGuiName, ref open, this.WindowFlags) || !open) + { + this.Cancel(); + return; + } + } + else + { + var open = true; + if (!ImGui.Begin(this.popupImGuiName, ref open, this.WindowFlags) || !open) + { + ImGui.End(); + this.Cancel(); + return; + } } var framePad = ImGui.GetStyle().FramePadding; @@ -261,12 +429,36 @@ public sealed class SingleFontChooserDialog : IDisposable ImGui.EndChild(); - ImGui.EndPopup(); + this.popupPosition = ImGui.GetWindowPos(); + this.popupSize = ImGui.GetWindowSize(); + if (this.IsModal) + ImGui.EndPopup(); + else + ImGui.End(); this.firstDraw = false; this.firstDrawAfterRefresh = false; } + private static float GetDistanceFromMonitor(Vector2 point, ImGuiPlatformMonitorPtr monitor) + { + var lt = monitor.MainPos; + var rb = monitor.MainPos + monitor.MainSize; + var xoff = + point.X < lt.X + ? lt.X - point.X + : point.X > rb.X + ? point.X - rb.X + : 0; + var yoff = + point.Y < lt.Y + ? lt.Y - point.Y + : point.Y > rb.Y + ? point.Y - rb.Y + : 0; + return MathF.Sqrt((xoff * xoff) + (yoff * yoff)); + } + private void DrawChoices() { var lineHeight = ImGui.GetTextLineHeight(); @@ -338,15 +530,20 @@ public sealed class SingleFontChooserDialog : IDisposable } } - if (this.IgnorePreviewGlobalScale) + if (this.fontHandle is null) { - this.fontHandle ??= this.selectedFont.CreateFontHandle( - this.atlas, - tk => tk.OnPreBuild(e => e.SetFontScaleMode(e.Font, FontScaleMode.UndoGlobalScale))); - } - else - { - this.fontHandle ??= this.selectedFont.CreateFontHandle(this.atlas); + if (this.IgnorePreviewGlobalScale) + { + this.fontHandle = this.selectedFont.CreateFontHandle( + this.atlas, + tk => tk.OnPreBuild(e => e.SetFontScaleMode(e.Font, FontScaleMode.UndoGlobalScale))); + } + else + { + this.fontHandle = this.selectedFont.CreateFontHandle(this.atlas); + } + + this.SelectedFontSpecChanged?.InvokeSafely(this.selectedFont); } if (this.fontHandle is null) diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/GamePrebakedFontsTestWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/GamePrebakedFontsTestWidget.cs index 8bb999557..469ef3dc3 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/GamePrebakedFontsTestWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/GamePrebakedFontsTestWidget.cs @@ -44,6 +44,8 @@ internal class GamePrebakedFontsTestWidget : IDataWindowWidget, IDisposable private bool useBold; private bool useMinimumBuild; + private SingleFontChooserDialog? chooserDialog; + /// public string[]? CommandShortcuts { get; init; } @@ -126,32 +128,75 @@ internal class GamePrebakedFontsTestWidget : IDataWindowWidget, IDisposable if (ImGui.Button("Test Lock")) Task.Run(this.TestLock); - ImGui.SameLine(); if (ImGui.Button("Choose Editor Font")) { - var fcd = new SingleFontChooserDialog( - Service.Get().CreateFontAtlas( - $"{nameof(GamePrebakedFontsTestWidget)}:EditorFont", - FontAtlasAutoRebuildMode.Async)); - fcd.SelectedFont = this.fontSpec; - fcd.IgnorePreviewGlobalScale = !this.atlasScaleMode; - Service.Get().Draw += fcd.Draw; - fcd.ResultTask.ContinueWith( - r => Service.Get().RunOnFrameworkThread( - () => - { - Service.Get().Draw -= fcd.Draw; - fcd.Dispose(); + if (this.chooserDialog is null) + { + DoNext(); + } + else + { + this.chooserDialog.Cancel(); + this.chooserDialog.ResultTask.ContinueWith(_ => Service.Get().RunOnFrameworkThread(DoNext)); + this.chooserDialog = null; + } - _ = r.Exception; - if (!r.IsCompletedSuccessfully) - return; + void DoNext() + { + var fcd = new SingleFontChooserDialog( + Service.Get(), + $"{nameof(GamePrebakedFontsTestWidget)}:EditorFont"); + this.chooserDialog = fcd; + fcd.SelectedFont = this.fontSpec; + fcd.IgnorePreviewGlobalScale = !this.atlasScaleMode; + fcd.IsModal = false; + Service.Get().Draw += fcd.Draw; + var prevSpec = this.fontSpec; + fcd.SelectedFontSpecChanged += spec => + { + this.fontSpec = spec; + Log.Information("Selected font: {font}", this.fontSpec); + this.fontDialogHandle?.Dispose(); + this.fontDialogHandle = null; + }; + fcd.ResultTask.ContinueWith( + r => Service.Get().RunOnFrameworkThread( + () => + { + Service.Get().Draw -= fcd.Draw; + fcd.Dispose(); - this.fontSpec = r.Result; - Log.Information("Selected font: {font}", this.fontSpec); - this.fontDialogHandle?.Dispose(); - this.fontDialogHandle = null; - })); + _ = r.Exception; + var spec = r.IsCompletedSuccessfully ? r.Result : prevSpec; + if (this.fontSpec != spec) + { + this.fontSpec = spec; + this.fontDialogHandle?.Dispose(); + this.fontDialogHandle = null; + } + + this.chooserDialog = null; + })); + } + } + + if (this.chooserDialog is not null) + { + ImGui.SameLine(); + ImGui.TextUnformatted($"{this.chooserDialog.PopupPosition}, {this.chooserDialog.PopupSize}"); + + ImGui.SameLine(); + if (ImGui.Button("Random Location")) + { + var monitors = ImGui.GetPlatformIO().Monitors; + var monitor = monitors[Random.Shared.Next() % monitors.Size]; + this.chooserDialog.PopupPosition = monitor.WorkPos + (monitor.WorkSize * new Vector2( + Random.Shared.NextSingle(), + Random.Shared.NextSingle())); + this.chooserDialog.PopupSize = monitor.WorkSize * new Vector2( + Random.Shared.NextSingle(), + Random.Shared.NextSingle()); + } } this.privateAtlas ??= diff --git a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabLook.cs b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabLook.cs index ea6400121..5ccace850 100644 --- a/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabLook.cs +++ b/Dalamud/Interface/Internal/Windows/Settings/Tabs/SettingsTabLook.cs @@ -12,7 +12,6 @@ using Dalamud.Interface.GameFonts; using Dalamud.Interface.ImGuiFontChooserDialog; using Dalamud.Interface.Internal.Windows.PluginInstaller; using Dalamud.Interface.Internal.Windows.Settings.Widgets; -using Dalamud.Interface.ManagedFontAtlas; using Dalamud.Interface.ManagedFontAtlas.Internals; using Dalamud.Interface.Utility; using Dalamud.Utility; @@ -199,10 +198,10 @@ public class SettingsTabLook : SettingsTab if (ImGui.Button(Loc.Localize("DalamudSettingChooseDefaultFont", "Choose Default Font"))) { var faf = Service.Get(); - var fcd = new SingleFontChooserDialog( - faf.CreateFontAtlas($"{nameof(SettingsTabLook)}:Default", FontAtlasAutoRebuildMode.Async)); + var fcd = new SingleFontChooserDialog(faf, $"{nameof(SettingsTabLook)}:Default"); fcd.SelectedFont = (SingleFontSpec)this.defaultFontSpec; fcd.FontFamilyExcludeFilter = x => x is DalamudDefaultFontAndFamilyId; + fcd.SetPopupPositionAndSizeToCurrentWindowCenter(); interfaceManager.Draw += fcd.Draw; fcd.ResultTask.ContinueWith( r => Service.Get().RunOnFrameworkThread( diff --git a/Dalamud/Interface/ManagedFontAtlas/IFontAtlas.cs b/Dalamud/Interface/ManagedFontAtlas/IFontAtlas.cs index 0445499c8..a79ab099d 100644 --- a/Dalamud/Interface/ManagedFontAtlas/IFontAtlas.cs +++ b/Dalamud/Interface/ManagedFontAtlas/IFontAtlas.cs @@ -82,21 +82,25 @@ public interface IFontAtlas : IDisposable /// public IDisposable SuppressAutoRebuild(); - /// - /// Creates a new from game's built-in fonts. - /// + /// Creates a new from game's built-in fonts. /// Font to use. /// Handle to a font that may or may not be ready yet. + /// This function does not throw. will be populated instead, if + /// the build procedure has failed. can be used regardless of the state of the font + /// handle. public IFontHandle NewGameFontHandle(GameFontStyle style); - /// - /// Creates a new IFontHandle using your own callbacks. - /// + /// Creates a new IFontHandle using your own callbacks. /// Callback for . /// Handle to a font that may or may not be ready yet. /// - /// Consider calling to support - /// glyphs that are not supplied by the game by default; this mostly affects Chinese and Korean language users. + /// Consider calling to + /// support glyphs that are not supplied by the game by default; this mostly affects Chinese and Korean language + /// users. + /// This function does not throw, even if would throw exceptions. + /// Instead, if it fails, the returned handle will contain an property + /// containing the exception happened during the build process. can be used even if + /// the build process has not been completed yet or failed. /// /// /// On initialization: diff --git a/Dalamud/Interface/ManagedFontAtlas/IFontHandle.cs b/Dalamud/Interface/ManagedFontAtlas/IFontHandle.cs index 70799bb9c..0a9e9072e 100644 --- a/Dalamud/Interface/ManagedFontAtlas/IFontHandle.cs +++ b/Dalamud/Interface/ManagedFontAtlas/IFontHandle.cs @@ -58,10 +58,27 @@ public interface IFontHandle : IDisposable /// A disposable object that will pop the font on dispose. /// If called outside of the main thread. /// - /// This function uses , and may do extra things. + /// This function uses , and may do extra things. /// Use or to undo this operation. - /// Do not use . + /// Do not use . /// + /// + /// Push a font with `using` clause. + /// + /// using (fontHandle.Push()) + /// ImGui.TextUnformatted("Test"); + /// + /// Push a font with a matching call to . + /// + /// fontHandle.Push(); + /// ImGui.TextUnformatted("Test 2"); + /// + /// Push a font between two choices. + /// + /// using ((someCondition ? myFontHandle : dalamudPluginInterface.UiBuilder.MonoFontHandle).Push()) + /// ImGui.TextUnformatted("Test 3"); + /// + /// IDisposable Push(); /// diff --git a/Dalamud/Interface/ManagedFontAtlas/Internals/FontHandle.cs b/Dalamud/Interface/ManagedFontAtlas/Internals/FontHandle.cs index 47254a5c9..89d968158 100644 --- a/Dalamud/Interface/ManagedFontAtlas/Internals/FontHandle.cs +++ b/Dalamud/Interface/ManagedFontAtlas/Internals/FontHandle.cs @@ -136,13 +136,18 @@ internal abstract class FontHandle : IFontHandle /// An instance of that must be disposed after use on success; /// null with populated on failure. /// - /// Still may be thrown. public ILockedImFont? TryLock(out string? errorMessage) { IFontHandleSubstance? prevSubstance = default; while (true) { - var substance = this.Manager.Substance; + if (this.manager is not { } nonDisposedManager) + { + errorMessage = "The font handle has been disposed."; + return null; + } + + var substance = nonDisposedManager.Substance; // Does the associated IFontAtlas have a built substance? if (substance is null)