diff --git a/Dalamud/Dalamud.csproj b/Dalamud/Dalamud.csproj index f6ac5b151..01634b328 100644 --- a/Dalamud/Dalamud.csproj +++ b/Dalamud/Dalamud.csproj @@ -111,10 +111,6 @@ - - - - diff --git a/Dalamud/Game/Addon/AddonLifecyclePooledArgs.cs b/Dalamud/Game/Addon/AddonLifecyclePooledArgs.cs new file mode 100644 index 000000000..14def2036 --- /dev/null +++ b/Dalamud/Game/Addon/AddonLifecyclePooledArgs.cs @@ -0,0 +1,107 @@ +using System.Runtime.CompilerServices; +using System.Threading; + +using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; + +namespace Dalamud.Game.Addon; + +/// Argument pool for Addon Lifecycle services. +[ServiceManager.EarlyLoadedService] +internal sealed class AddonLifecyclePooledArgs : IServiceType +{ + private readonly AddonSetupArgs?[] addonSetupArgPool = new AddonSetupArgs?[64]; + private readonly AddonFinalizeArgs?[] addonFinalizeArgPool = new AddonFinalizeArgs?[64]; + private readonly AddonDrawArgs?[] addonDrawArgPool = new AddonDrawArgs?[64]; + private readonly AddonUpdateArgs?[] addonUpdateArgPool = new AddonUpdateArgs?[64]; + private readonly AddonRefreshArgs?[] addonRefreshArgPool = new AddonRefreshArgs?[64]; + private readonly AddonRequestedUpdateArgs?[] addonRequestedUpdateArgPool = new AddonRequestedUpdateArgs?[64]; + private readonly AddonReceiveEventArgs?[] addonReceiveEventArgPool = new AddonReceiveEventArgs?[64]; + + [ServiceManager.ServiceConstructor] + private AddonLifecyclePooledArgs() + { + } + + /// Rents an instance of an argument. + /// The rented instance. + /// The returner. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public PooledEntry Rent(out AddonSetupArgs arg) => new(out arg, this.addonSetupArgPool); + + /// Rents an instance of an argument. + /// The rented instance. + /// The returner. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public PooledEntry Rent(out AddonFinalizeArgs arg) => new(out arg, this.addonFinalizeArgPool); + + /// Rents an instance of an argument. + /// The rented instance. + /// The returner. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public PooledEntry Rent(out AddonDrawArgs arg) => new(out arg, this.addonDrawArgPool); + + /// Rents an instance of an argument. + /// The rented instance. + /// The returner. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public PooledEntry Rent(out AddonUpdateArgs arg) => new(out arg, this.addonUpdateArgPool); + + /// Rents an instance of an argument. + /// The rented instance. + /// The returner. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public PooledEntry Rent(out AddonRefreshArgs arg) => new(out arg, this.addonRefreshArgPool); + + /// Rents an instance of an argument. + /// The rented instance. + /// The returner. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public PooledEntry Rent(out AddonRequestedUpdateArgs arg) => + new(out arg, this.addonRequestedUpdateArgPool); + + /// Rents an instance of an argument. + /// The rented instance. + /// The returner. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public PooledEntry Rent(out AddonReceiveEventArgs arg) => + new(out arg, this.addonReceiveEventArgPool); + + /// Returns the object to the pool on dispose. + /// The type. + public readonly ref struct PooledEntry + where T : AddonArgs, new() + { + private readonly Span pool; + private readonly T obj; + + /// Initializes a new instance of the struct. + /// An instance of the argument. + /// The pool to rent from and return to. + public PooledEntry(out T arg, Span pool) + { + this.pool = pool; + foreach (ref var item in pool) + { + if (Interlocked.Exchange(ref item, null) is { } v) + { + this.obj = arg = v; + return; + } + } + + this.obj = arg = new(); + } + + /// Returns the item to the pool. + public void Dispose() + { + var tmp = this.obj; + foreach (ref var item in this.pool) + { + if (Interlocked.Exchange(ref item, tmp) is not { } tmp2) + return; + tmp = tmp2; + } + } + } +} diff --git a/Dalamud/Game/Addon/Events/AddonEventManager.cs b/Dalamud/Game/Addon/Events/AddonEventManager.cs index 4231b0d09..8ee09bed8 100644 --- a/Dalamud/Game/Addon/Events/AddonEventManager.cs +++ b/Dalamud/Game/Addon/Events/AddonEventManager.cs @@ -18,7 +18,7 @@ namespace Dalamud.Game.Addon.Events; /// Service provider for addon event management. /// [InterfaceVersion("1.0")] -[ServiceManager.BlockingEarlyLoadedService] +[ServiceManager.EarlyLoadedService] internal unsafe class AddonEventManager : IDisposable, IServiceType { /// diff --git a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonArgs.cs b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonArgs.cs index 4ab3de5ca..1095202cc 100644 --- a/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonArgs.cs +++ b/Dalamud/Game/Addon/Lifecycle/AddonArgTypes/AddonArgs.cs @@ -44,10 +44,10 @@ public abstract unsafe class AddonArgs get => this.addon; set { - if (this.addon == value) - return; - this.addon = value; + + // Note: always clear addonName on updating the addon being pointed. + // Same address may point to a different addon. this.addonName = null; } } diff --git a/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs b/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs index beaab7fcd..37f12ce3a 100644 --- a/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs +++ b/Dalamud/Game/Addon/Lifecycle/AddonLifecycle.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; @@ -19,7 +18,7 @@ namespace Dalamud.Game.Addon.Lifecycle; /// This class provides events for in-game addon lifecycles. /// [InterfaceVersion("1.0")] -[ServiceManager.BlockingEarlyLoadedService] +[ServiceManager.EarlyLoadedService] internal unsafe class AddonLifecycle : IDisposable, IServiceType { private static readonly ModuleLog Log = new("AddonLifecycle"); @@ -27,6 +26,9 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType [ServiceManager.ServiceDependency] private readonly Framework framework = Service.Get(); + [ServiceManager.ServiceDependency] + private readonly AddonLifecyclePooledArgs argsPool = Service.Get(); + private readonly nint disallowedReceiveEventAddress; private readonly AddonLifecycleAddressResolver address; @@ -38,18 +40,6 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType private readonly Hook onAddonRefreshHook; private readonly CallHook onAddonRequestedUpdateHook; - // Note: these can be sourced from ObjectPool of appropriate types instead, but since we don't import that NuGet - // package, and these events are always called from the main thread, this is fine. -#pragma warning disable CS0618 // Type or member is obsolete - // TODO: turn constructors of these internal - private readonly AddonSetupArgs recyclingSetupArgs = new(); - private readonly AddonFinalizeArgs recyclingFinalizeArgs = new(); - private readonly AddonDrawArgs recyclingDrawArgs = new(); - private readonly AddonUpdateArgs recyclingUpdateArgs = new(); - private readonly AddonRefreshArgs recyclingRefreshArgs = new(); - private readonly AddonRequestedUpdateArgs recyclingRequestedUpdateArgs = new(); -#pragma warning restore CS0618 // Type or member is obsolete - [ServiceManager.ServiceConstructor] private AddonLifecycle(TargetSigScanner sigScanner) { @@ -253,12 +243,13 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType Log.Error(e, "Exception in OnAddonSetup ReceiveEvent Registration."); } - this.recyclingSetupArgs.AddonInternal = (nint)addon; - this.recyclingSetupArgs.AtkValueCount = valueCount; - this.recyclingSetupArgs.AtkValues = (nint)values; - this.InvokeListenersSafely(AddonEvent.PreSetup, this.recyclingSetupArgs); - valueCount = this.recyclingSetupArgs.AtkValueCount; - values = (AtkValue*)this.recyclingSetupArgs.AtkValues; + using var returner = this.argsPool.Rent(out AddonSetupArgs arg); + arg.AddonInternal = (nint)addon; + arg.AtkValueCount = valueCount; + arg.AtkValues = (nint)values; + this.InvokeListenersSafely(AddonEvent.PreSetup, arg); + valueCount = arg.AtkValueCount; + values = (AtkValue*)arg.AtkValues; try { @@ -269,7 +260,7 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType Log.Error(e, "Caught exception when calling original AddonSetup. This may be a bug in the game or another plugin hooking this method."); } - this.InvokeListenersSafely(AddonEvent.PostSetup, this.recyclingSetupArgs); + this.InvokeListenersSafely(AddonEvent.PostSetup, arg); } private void OnAddonFinalize(AtkUnitManager* unitManager, AtkUnitBase** atkUnitBase) @@ -284,8 +275,9 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType Log.Error(e, "Exception in OnAddonFinalize ReceiveEvent Removal."); } - this.recyclingFinalizeArgs.AddonInternal = (nint)atkUnitBase[0]; - this.InvokeListenersSafely(AddonEvent.PreFinalize, this.recyclingFinalizeArgs); + using var returner = this.argsPool.Rent(out AddonFinalizeArgs arg); + arg.AddonInternal = (nint)atkUnitBase[0]; + this.InvokeListenersSafely(AddonEvent.PreFinalize, arg); try { @@ -299,8 +291,9 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType private void OnAddonDraw(AtkUnitBase* addon) { - this.recyclingDrawArgs.AddonInternal = (nint)addon; - this.InvokeListenersSafely(AddonEvent.PreDraw, this.recyclingDrawArgs); + using var returner = this.argsPool.Rent(out AddonDrawArgs arg); + arg.AddonInternal = (nint)addon; + this.InvokeListenersSafely(AddonEvent.PreDraw, arg); try { @@ -311,14 +304,15 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType Log.Error(e, "Caught exception when calling original AddonDraw. This may be a bug in the game or another plugin hooking this method."); } - this.InvokeListenersSafely(AddonEvent.PostDraw, this.recyclingDrawArgs); + this.InvokeListenersSafely(AddonEvent.PostDraw, arg); } private void OnAddonUpdate(AtkUnitBase* addon, float delta) { - this.recyclingUpdateArgs.AddonInternal = (nint)addon; - this.recyclingUpdateArgs.TimeDeltaInternal = delta; - this.InvokeListenersSafely(AddonEvent.PreUpdate, this.recyclingUpdateArgs); + using var returner = this.argsPool.Rent(out AddonUpdateArgs arg); + arg.AddonInternal = (nint)addon; + arg.TimeDeltaInternal = delta; + this.InvokeListenersSafely(AddonEvent.PreUpdate, arg); try { @@ -329,19 +323,20 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType Log.Error(e, "Caught exception when calling original AddonUpdate. This may be a bug in the game or another plugin hooking this method."); } - this.InvokeListenersSafely(AddonEvent.PostUpdate, this.recyclingUpdateArgs); + this.InvokeListenersSafely(AddonEvent.PostUpdate, arg); } private byte OnAddonRefresh(AtkUnitManager* atkUnitManager, AtkUnitBase* addon, uint valueCount, AtkValue* values) { byte result = 0; - this.recyclingRefreshArgs.AddonInternal = (nint)addon; - this.recyclingRefreshArgs.AtkValueCount = valueCount; - this.recyclingRefreshArgs.AtkValues = (nint)values; - this.InvokeListenersSafely(AddonEvent.PreRefresh, this.recyclingRefreshArgs); - valueCount = this.recyclingRefreshArgs.AtkValueCount; - values = (AtkValue*)this.recyclingRefreshArgs.AtkValues; + using var returner = this.argsPool.Rent(out AddonRefreshArgs arg); + arg.AddonInternal = (nint)addon; + arg.AtkValueCount = valueCount; + arg.AtkValues = (nint)values; + this.InvokeListenersSafely(AddonEvent.PreRefresh, arg); + valueCount = arg.AtkValueCount; + values = (AtkValue*)arg.AtkValues; try { @@ -352,18 +347,19 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType Log.Error(e, "Caught exception when calling original AddonRefresh. This may be a bug in the game or another plugin hooking this method."); } - this.InvokeListenersSafely(AddonEvent.PostRefresh, this.recyclingRefreshArgs); + this.InvokeListenersSafely(AddonEvent.PostRefresh, arg); return result; } private void OnRequestedUpdate(AtkUnitBase* addon, NumberArrayData** numberArrayData, StringArrayData** stringArrayData) { - this.recyclingRequestedUpdateArgs.AddonInternal = (nint)addon; - this.recyclingRequestedUpdateArgs.NumberArrayData = (nint)numberArrayData; - this.recyclingRequestedUpdateArgs.StringArrayData = (nint)stringArrayData; - this.InvokeListenersSafely(AddonEvent.PreRequestedUpdate, this.recyclingRequestedUpdateArgs); - numberArrayData = (NumberArrayData**)this.recyclingRequestedUpdateArgs.NumberArrayData; - stringArrayData = (StringArrayData**)this.recyclingRequestedUpdateArgs.StringArrayData; + using var returner = this.argsPool.Rent(out AddonRequestedUpdateArgs arg); + arg.AddonInternal = (nint)addon; + arg.NumberArrayData = (nint)numberArrayData; + arg.StringArrayData = (nint)stringArrayData; + this.InvokeListenersSafely(AddonEvent.PreRequestedUpdate, arg); + numberArrayData = (NumberArrayData**)arg.NumberArrayData; + stringArrayData = (StringArrayData**)arg.StringArrayData; try { @@ -374,7 +370,7 @@ internal unsafe class AddonLifecycle : IDisposable, IServiceType Log.Error(e, "Caught exception when calling original AddonRequestedUpdate. This may be a bug in the game or another plugin hooking this method."); } - this.InvokeListenersSafely(AddonEvent.PostRequestedUpdate, this.recyclingRequestedUpdateArgs); + this.InvokeListenersSafely(AddonEvent.PostRequestedUpdate, arg); } } diff --git a/Dalamud/Game/Addon/Lifecycle/AddonLifecycleReceiveEventListener.cs b/Dalamud/Game/Addon/Lifecycle/AddonLifecycleReceiveEventListener.cs index 43aa71661..fd3b5d79d 100644 --- a/Dalamud/Game/Addon/Lifecycle/AddonLifecycleReceiveEventListener.cs +++ b/Dalamud/Game/Addon/Lifecycle/AddonLifecycleReceiveEventListener.cs @@ -16,12 +16,8 @@ internal unsafe class AddonLifecycleReceiveEventListener : IDisposable { private static readonly ModuleLog Log = new("AddonLifecycle"); - // Note: these can be sourced from ObjectPool of appropriate types instead, but since we don't import that NuGet - // package, and these events are always called from the main thread, this is fine. -#pragma warning disable CS0618 // Type or member is obsolete - // TODO: turn constructors of these internal - private readonly AddonReceiveEventArgs recyclingReceiveEventArgs = new(); -#pragma warning restore CS0618 // Type or member is obsolete + [ServiceManager.ServiceDependency] + private readonly AddonLifecyclePooledArgs argsPool = Service.Get(); /// /// Initializes a new instance of the class. @@ -82,16 +78,17 @@ internal unsafe class AddonLifecycleReceiveEventListener : IDisposable return; } - this.recyclingReceiveEventArgs.AddonInternal = (nint)addon; - this.recyclingReceiveEventArgs.AtkEventType = (byte)eventType; - this.recyclingReceiveEventArgs.EventParam = eventParam; - this.recyclingReceiveEventArgs.AtkEvent = (IntPtr)atkEvent; - this.recyclingReceiveEventArgs.Data = data; - this.AddonLifecycle.InvokeListenersSafely(AddonEvent.PreReceiveEvent, this.recyclingReceiveEventArgs); - eventType = (AtkEventType)this.recyclingReceiveEventArgs.AtkEventType; - eventParam = this.recyclingReceiveEventArgs.EventParam; - atkEvent = (AtkEvent*)this.recyclingReceiveEventArgs.AtkEvent; - data = this.recyclingReceiveEventArgs.Data; + using var returner = this.argsPool.Rent(out AddonReceiveEventArgs arg); + arg.AddonInternal = (nint)addon; + arg.AtkEventType = (byte)eventType; + arg.EventParam = eventParam; + arg.AtkEvent = (IntPtr)atkEvent; + arg.Data = data; + this.AddonLifecycle.InvokeListenersSafely(AddonEvent.PreReceiveEvent, arg); + eventType = (AtkEventType)arg.AtkEventType; + eventParam = arg.EventParam; + atkEvent = (AtkEvent*)arg.AtkEvent; + data = arg.Data; try { @@ -102,6 +99,6 @@ internal unsafe class AddonLifecycleReceiveEventListener : IDisposable Log.Error(e, "Caught exception when calling original AddonReceiveEvent. This may be a bug in the game or another plugin hooking this method."); } - this.AddonLifecycle.InvokeListenersSafely(AddonEvent.PostReceiveEvent, this.recyclingReceiveEventArgs); + this.AddonLifecycle.InvokeListenersSafely(AddonEvent.PostReceiveEvent, arg); } } 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/Network/Internal/NetworkHandlers.cs b/Dalamud/Game/Network/Internal/NetworkHandlers.cs index 76d3b5659..8d5ec1344 100644 --- a/Dalamud/Game/Network/Internal/NetworkHandlers.cs +++ b/Dalamud/Game/Network/Internal/NetworkHandlers.cs @@ -12,6 +12,7 @@ using Dalamud.Game.Gui; using Dalamud.Game.Network.Internal.MarketBoardUploaders; using Dalamud.Game.Network.Internal.MarketBoardUploaders.Universalis; using Dalamud.Game.Network.Structures; +using Dalamud.Game.Text.SeStringHandling; using Dalamud.Hooking; using Dalamud.Networking.Http; using Dalamud.Utility; @@ -268,8 +269,8 @@ internal unsafe class NetworkHandlers : IDisposable, IServiceType return result; } - var cfcName = cfCondition.Name.ToString(); - if (cfcName.IsNullOrEmpty()) + var cfcName = cfCondition.Name.ToDalamudString(); + if (cfcName.Payloads.Count == 0) { cfcName = "Duty Roulette"; cfCondition.Image = 112324; @@ -279,7 +280,10 @@ internal unsafe class NetworkHandlers : IDisposable, IServiceType { if (this.configuration.DutyFinderChatMessage) { - Service.GetNullable()?.Print($"Duty pop: {cfcName}"); + var b = new SeStringBuilder(); + b.Append("Duty pop: "); + b.Append(cfcName); + Service.GetNullable()?.Print(b.Build()); } this.CfPop.InvokeSafely(cfCondition); 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 3db799be0..126097ed3 100644 --- a/Dalamud/Interface/Internal/InterfaceManager.cs +++ b/Dalamud/Interface/Internal/InterfaceManager.cs @@ -67,9 +67,6 @@ internal class InterfaceManager : IDisposable, IServiceType [ServiceManager.ServiceDependency] private readonly WndProcHookManager wndProcHookManager = Service.Get(); - - [ServiceManager.ServiceDependency] - private readonly DalamudIme dalamudIme = Service.Get(); private readonly SwapChainVtableResolver address = new(); private readonly Hook setCursorHook; @@ -627,8 +624,6 @@ internal class InterfaceManager : IDisposable, IServiceType var r = this.scene?.ProcessWndProcW(args.Hwnd, (User32.WindowMessage)args.Message, args.WParam, args.LParam); if (r is not null) args.SuppressWithValue(r.Value); - - this.dalamudIme.ProcessImeMessage(args); } /* diff --git a/Dalamud/Interface/Internal/Windows/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/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/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. /// diff --git a/lib/FFXIVClientStructs b/lib/FFXIVClientStructs index 722a2c512..ac2ced26f 160000 --- a/lib/FFXIVClientStructs +++ b/lib/FFXIVClientStructs @@ -1 +1 @@ -Subproject commit 722a2c512238ac4b5324e3d343b316d8c8633a02 +Subproject commit ac2ced26fc98153c65f5b8f0eaf0f464258ff683