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
-
-
-
-
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);
}
}
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.
///
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/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
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/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..caf014885 100644
--- a/Dalamud/Interface/Internal/DalamudIme.cs
+++ b/Dalamud/Interface/Internal/DalamudIme.cs
@@ -1,8 +1,11 @@
+// #define IMEDEBUG
+
using System.Collections.Generic;
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 +20,10 @@ using Dalamud.Interface.Utility;
using ImGuiNET;
+#if IMEDEBUG
+using Serilog;
+#endif
+
using TerraFX.Interop.Windows;
using static TerraFX.Interop.Windows.Windows;
@@ -26,12 +33,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,10 +73,46 @@ 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;
+ private bool updateInputLanguage = true;
+ private bool updateImeStatusAgain;
+
[SuppressMessage("StyleCop.CSharp.SpacingRules", "SA1003:Symbols should be spaced correctly", Justification = ".")]
static DalamudIme()
{
@@ -87,7 +139,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 +171,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 +188,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 +215,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 +229,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,112 +240,6 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType
}
}
- ///
- /// Processes window messages.
- ///
- /// The arguments.
- public void ProcessImeMessage(WndProcEventArgs args)
- {
- if (!ImGuiHelpers.IsImGuiInitialized)
- return;
-
- // Are we not the target of text input?
- if (!ImGui.GetIO().WantTextInput)
- return;
-
- var hImc = ImmGetContext(args.Hwnd);
- if (hImc == nint.Zero)
- return;
-
- try
- {
- var invalidTarget = TextState->Id == 0 || (TextState->Flags & ImGuiInputTextFlags.ReadOnly) != 0;
-
- 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.UpdateImeWindowStatus(hImc);
- args.SuppressWithValue(0);
- break;
-
- case WM.WM_IME_STARTCOMPOSITION:
- args.SuppressWithValue(0);
- break;
-
- case WM.WM_IME_COMPOSITION:
- if (invalidTarget)
- ImmNotifyIME(hImc, NI.NI_COMPOSITIONSTR, CPS_CANCEL, 0);
- else
- this.ReplaceCompositionString(hImc, (uint)args.LParam);
-
- // Log.Verbose($"{nameof(WM.WM_IME_COMPOSITION)}({(nint)args.LParam:X}): {this.ImmComp}");
- 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}");
- 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}");
- 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}");
- args.SuppressWithValue(0);
- break;
-
- case WM.WM_IME_SETCONTEXT:
- // 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}");
- args.SuppressWithDefault();
- break;
-
- case WM.WM_IME_NOTIFY:
- // Log.Verbose($"{nameof(WM.WM_IME_NOTIFY)}({(nint)args.WParam:X}): {this.ImmComp}");
- break;
-
- case WM.WM_KEYDOWN when (int)args.WParam is
- VK.VK_TAB
- or VK.VK_PRIOR
- or VK.VK_NEXT
- or VK.VK_END
- or VK.VK_HOME
- or VK.VK_LEFT
- or VK.VK_UP
- or VK.VK_RIGHT
- or VK.VK_DOWN
- or VK.VK_RETURN:
- if (this.ImmCand.Count != 0)
- {
- this.ClearState(hImc);
- args.WParam = VK.VK_PROCESSKEY;
- }
-
- break;
-
- case WM.WM_LBUTTONDOWN:
- case WM.WM_RBUTTONDOWN:
- case WM.WM_MBUTTONDOWN:
- case WM.WM_XBUTTONDOWN:
- ImmNotifyIME(hImc, NI.NI_COMPOSITIONSTR, CPS_COMPLETE, 0);
- break;
- }
-
- this.UpdateInputLanguage(hImc);
- }
- finally
- {
- ImmReleaseContext(args.Hwnd, hImc);
- }
- }
-
private static string ImmGetCompositionString(HIMC hImc, uint comp)
{
var numBytes = ImmGetCompositionStringW(hImc, comp, null, 0);
@@ -343,6 +257,187 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType
ImGui.GetIO().SetPlatformImeDataFn = nint.Zero;
}
+ 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
+ {
+ 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
+ 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;
+
+ case WM.WM_IME_COMPOSITION:
+ if (invalidTarget)
+ ImmNotifyIME(hImc, NI.NI_COMPOSITIONSTR, CPS_CANCEL, 0);
+ else
+ 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;
+
+ case WM.WM_IME_SETCONTEXT:
+ // 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
+ or VK.VK_NEXT
+ or VK.VK_END
+ or VK.VK_HOME
+ or VK.VK_LEFT
+ or VK.VK_UP
+ or VK.VK_RIGHT
+ or VK.VK_DOWN
+ or VK.VK_RETURN:
+ if (this.candidateStrings.Count != 0)
+ {
+ this.ClearState(hImc);
+ args.WParam = VK.VK_PROCESSKEY;
+ }
+
+ 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:
+ case WM.WM_RBUTTONDOWN:
+ case WM.WM_MBUTTONDOWN:
+ case WM.WM_XBUTTONDOWN:
+ ImmNotifyIME(hImc, NI.NI_COMPOSITIONSTR, CPS_COMPLETE, 0);
+ break;
+ }
+ }
+ finally
+ {
+ ImmReleaseContext(args.Hwnd, hImc);
+ }
+ }
+
private void UpdateInputLanguage(HIMC hImc)
{
uint conv, sent;
@@ -350,8 +445,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;
@@ -359,50 +452,51 @@ 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)
+ 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);
+#if IMEDEBUG
+ Log.Verbose($"{nameof(this.ReplaceCompositionString)}({newString})");
+#endif
+
this.ReflectCharacterEncounters(newString);
if (this.temporaryUndoSelection is not null)
@@ -421,18 +515,18 @@ internal sealed unsafe class DalamudIme : IDisposable, IServiceType
if (finalCommit)
{
- this.ClearState(hImc);
- return;
+ this.ClearState(hImc, false);
+ newString = string.Empty;
}
- 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);
+ 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.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 +536,41 @@ 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)
+ private void ClearState(HIMC hImc, bool invokeCancel = true)
{
- 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.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 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 +584,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 +593,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));
+ }
+ }
}
///
@@ -706,4 +1015,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
}
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 67e444cbe..48ad653d2 100644
--- a/Dalamud/Interface/Internal/InterfaceManager.cs
+++ b/Dalamud/Interface/Internal/InterfaceManager.cs
@@ -68,9 +68,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;
@@ -628,8 +625,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/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/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/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/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/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/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 ba890f7c2..15e2803da 100644
--- a/Dalamud/Interface/ManagedFontAtlas/Internals/FontHandle.cs
+++ b/Dalamud/Interface/ManagedFontAtlas/Internals/FontHandle.cs
@@ -138,13 +138,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)
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/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/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())
{
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();
}
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
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;
+ }
+}
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.
///