From 84d121c7bc109199f351f7024b8f932ca9e337fb Mon Sep 17 00:00:00 2001 From: salanth357 Date: Thu, 29 May 2025 14:24:21 -0400 Subject: [PATCH] Add Completion module (#2274) * Add Completion module Dalamud and plugin commands will now be tab-completable in the ChatLog * PR feedback --------- Co-authored-by: goat <16760685+goaaats@users.noreply.github.com> --- Dalamud/Game/Command/CommandManager.cs | 52 +++- Dalamud/Game/Internal/Completion.cs | 293 ++++++++++++++++++ .../Windows/SelfTest/SelfTestWindow.cs | 3 +- .../SelfTest/Steps/CompletionSelfTestStep.cs | 94 ++++++ 4 files changed, 438 insertions(+), 4 deletions(-) create mode 100644 Dalamud/Game/Internal/Completion.cs create mode 100644 Dalamud/Interface/Internal/Windows/SelfTest/Steps/CompletionSelfTestStep.cs diff --git a/Dalamud/Game/Command/CommandManager.cs b/Dalamud/Game/Command/CommandManager.cs index fdaa5833b..09cd7877d 100644 --- a/Dalamud/Game/Command/CommandManager.cs +++ b/Dalamud/Game/Command/CommandManager.cs @@ -45,6 +45,16 @@ internal sealed unsafe class CommandManager : IInternalDisposableService, IComma this.console.Invoke += this.ConsoleOnInvoke; } + /// + /// Published whenever a command is registered + /// + public event EventHandler? CommandAdded; + + /// + /// Published whenever a command is unregistered + /// + public event EventHandler? CommandRemoved; + /// public ReadOnlyDictionary Commands => new(this.commandMap); @@ -122,6 +132,12 @@ internal sealed unsafe class CommandManager : IInternalDisposableService, IComma return false; } + this.CommandAdded?.Invoke(this, new CommandEventArgs + { + Command = command, + CommandInfo = info, + }); + if (!this.commandAssemblyNameMap.TryAdd((command, info), loaderAssemblyName)) { this.commandMap.Remove(command, out _); @@ -144,6 +160,12 @@ internal sealed unsafe class CommandManager : IInternalDisposableService, IComma return false; } + this.CommandAdded?.Invoke(this, new CommandEventArgs + { + Command = command, + CommandInfo = info, + }); + return true; } @@ -155,7 +177,17 @@ internal sealed unsafe class CommandManager : IInternalDisposableService, IComma this.commandAssemblyNameMap.TryRemove(assemblyKeyValuePair.Key, out _); } - return this.commandMap.Remove(command, out _); + var removed = this.commandMap.Remove(command, out var info); + if (removed) + { + this.CommandRemoved?.Invoke(this, new CommandEventArgs + { + Command = command, + CommandInfo = info, + }); + } + + return removed; } /// @@ -204,6 +236,20 @@ internal sealed unsafe class CommandManager : IInternalDisposableService, IComma return this.ProcessCommand(command->ToString()) ? 0 : result; } + + /// + public class CommandEventArgs : EventArgs + { + /// + /// Gets the command string + /// + public string Command { get; init; } + + /// + /// Gets the command info + /// + public IReadOnlyCommandInfo CommandInfo { get; init; } + } } /// @@ -268,7 +314,7 @@ internal class CommandManagerPluginScoped : IInternalDisposableService, ICommand } else { - Log.Error($"Command {command} is already registered."); + Log.Error("Command {Command} is already registered.", command); } return false; @@ -287,7 +333,7 @@ internal class CommandManagerPluginScoped : IInternalDisposableService, ICommand } else { - Log.Error($"Command {command} not found."); + Log.Error("Command {Command} not found.", command); } return false; diff --git a/Dalamud/Game/Internal/Completion.cs b/Dalamud/Game/Internal/Completion.cs new file mode 100644 index 000000000..05fae3514 --- /dev/null +++ b/Dalamud/Game/Internal/Completion.cs @@ -0,0 +1,293 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; + +using Dalamud.Game.Command; +using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Hooking; +using Dalamud.Plugin.Services; +using Dalamud.Utility; + +using FFXIVClientStructs.FFXIV.Client.System.String; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Component.Completion; +using FFXIVClientStructs.FFXIV.Component.GUI; + +namespace Dalamud.Game.Internal; + +/// +/// This class adds dalamud and plugin commands to the chat box's autocompletion. +/// +[ServiceManager.EarlyLoadedService] +internal sealed unsafe class Completion : IInternalDisposableService +{ + // 0xFF is a magic group number that causes CompletionModule's internals to treat entries + // as raw strings instead of as lookups into an EXD sheet + private const int GroupNumber = 0xFF; + + [ServiceManager.ServiceDependency] + private readonly CommandManager commandManager = Service.Get(); + + [ServiceManager.ServiceDependency] + private readonly Framework framework = Service.Get(); + + private readonly EntryStrings dalamudCategory = new("【Dalamud】"); + private readonly Dictionary cachedCommands = []; + private readonly ConcurrentQueue addedCommands = []; + + private Hook? getSelection; + + // This is marked volatile since we set and check it from different threads. Instead of using a synchronization + // primitive, a volatile is sufficient since the absolute worst case is that we delay one extra frame to reset + // the list, which is fine + private volatile bool needsClear; + private bool disposed; + private nint wantedVtblPtr; + + /// + /// Initializes a new instance of the class. + /// + [ServiceManager.ServiceConstructor] + internal Completion() + { + this.commandManager.CommandAdded += this.OnCommandAdded; + this.commandManager.CommandRemoved += this.OnCommandRemoved; + + this.framework.Update += this.OnUpdate; + } + + /// Finalizes an instance of the class. + ~Completion() => this.Dispose(false); + + /// + void IInternalDisposableService.DisposeService() => this.Dispose(true); + + private static AtkUnitBase* FindOwningAddon(AtkComponentTextInput* component) + { + if (component == null) return null; + + var node = (AtkResNode*)component->OwnerNode; + if (node == null) return null; + + while (node->ParentNode != null) + node = node->ParentNode; + + foreach (var addon in RaptureAtkUnitManager.Instance()->AllLoadedUnitsList.Entries) + { + if (addon.Value->RootNode == node) + return addon; + } + + return null; + } + + private AtkComponentTextInput* GetActiveTextInput() + { + var mod = RaptureAtkModule.Instance(); + if (mod == null) return null; + + var basePtr = mod->TextInput.TargetTextInputEventInterface; + if (basePtr == null) return null; + + // Once CS has an implementation for multiple inheritance, we can remove this sig from dalamud + // as well as the nasty pointer arithmetic below. But for now, we need to do this manually. + // The AtkTextInputEventInterface* is the secondary base class for AtkComponentTextInput* + // so the pointer is sizeof(AtkComponentInputBase) into the object. We verify that we're looking + // at the object we think we are by confirming the pointed-to vtbl matches the known secondary vtbl for + // AtkComponentTextInput, and if it does, we can shift the pointer back to get the start of our text input + if (this.wantedVtblPtr == 0) + { + this.wantedVtblPtr = + Service.Get().GetStaticAddressFromSig( + "48 89 01 48 8D 05 ?? ?? ?? ?? 48 89 81 ?? ?? ?? ?? 48 8D 05 ?? ?? ?? ?? 48 89 81 ?? ?? ?? ?? 48 8B 05 ?? ?? ?? ?? 48 8B 48 68", + 4); + } + + var vtblPtr = *(nint*)basePtr; + if (vtblPtr != this.wantedVtblPtr) return null; + + // This needs to be updated if the layout/base order of AtkComponentTextInput changes + return (AtkComponentTextInput*)((AtkComponentInputBase*)basePtr - 1); + } + + private bool AllowCompletion(string cmd) + { + // this is one of our commands, let's see if we should allow this to be completed + var component = this.GetActiveTextInput(); + + // ContainingAddon or ContainingAddon2 aren't always populated, but they + // seem to be in any case where this is actually a completable AtkComponentTextInput + // In the worst case, we can walk the AtkNode tree- but let's try the easy pointers first + var addon = component->ContainingAddon; + if (addon == null) addon = component->ContainingAddon2; + if (addon == null) addon = FindOwningAddon(component); + + if (addon == null || addon->NameString != "ChatLog") + { + // we don't know what addon is completing, or we know it isn't ChatLog + // either way, we should just reject this completion + return false; + } + + // We're in ChatLog, so check if this is the start of the text input + // AtkComponentTextInput->UnkText1 is the evaluated version of the current text + // so if the command starts with that, then either it's empty or a prefix completion. + // In either case, we're happy to allow completion. + return cmd.StartsWith(component->UnkText1.StringPtr.ExtractText()); + } + + private void Dispose(bool disposing) + { + if (this.disposed) + return; + + if (disposing) + { + this.getSelection?.Disable(); + this.getSelection?.Dispose(); + this.framework.Update -= this.OnUpdate; + this.commandManager.CommandAdded -= this.OnCommandAdded; + this.commandManager.CommandRemoved -= this.OnCommandRemoved; + } + + this.disposed = true; + } + + private void OnCommandAdded(object? sender, CommandManager.CommandEventArgs e) + { + if (e.CommandInfo.ShowInHelp) + this.addedCommands.Enqueue(e.Command); + } + + private void OnCommandRemoved(object? sender, CommandManager.CommandEventArgs e) => this.needsClear = true; + + private void OnUpdate(IFramework fw) + { + var atkModule = RaptureAtkModule.Instance(); + if (atkModule == null) return; + + var textInput = &atkModule->TextInput; + + if (textInput->CompletionModule == null) return; + + // Before we change _anything_ we need to check the state of the UI- if the completion list is open + // changes to the underlying data are extremely unsafe, so we'll just wait until the next frame + // worst case, someone tries to complete a command that _just_ got unloaded so it won't do anything + // but that's the same as making a typo, really + if (textInput->CompletionDepth > 0) return; + + this.LoadCommands(textInput->CompletionModule); + } + + private CategoryData* EnsureCategoryData(CompletionModule* module) + { + if (module == null) return null; + + if (this.getSelection == null) + { + this.getSelection = Hook.FromAddress( + (IntPtr)module->VirtualTable->GetSelection, + this.GetSelectionDetour); + this.getSelection.Enable(); + } + + for (var i = 0; i < module->CategoryNames.Count; i++) + { + if (module->CategoryNames[i].ExtractText() == "【Dalamud】") + { + return module->CategoryData[i]; + } + } + + // Create the category since we don't have one + var categoryData = (CategoryData*)Memory.MemoryHelper.GameAllocateDefault((ulong)sizeof(CategoryData)); + categoryData->Ctor(GroupNumber, 0xFF); + module->AddCategoryData(GroupNumber, this.dalamudCategory.Display->StringPtr, + this.dalamudCategory.Match->StringPtr, categoryData); + + return categoryData; + } + + private void LoadCommands(CompletionModule* completionModule) + { + if (completionModule == null) return; + if (completionModule->CategoryNames.Count == 0) return; // We want this data populated first + + if (this.needsClear && this.cachedCommands.Count > 0) + { + this.needsClear = false; + completionModule->ClearCompletionData(); + this.cachedCommands.Clear(); + return; + } + + var catData = this.EnsureCategoryData(completionModule); + if (catData == null) return; + + if (catData->CompletionData.Count == 0) + { + var inputCommands = this.commandManager.Commands.Where(pair => pair.Value.ShowInHelp).OrderBy(pair => pair.Key); + foreach (var (cmd, _) in inputCommands) + AddEntry(cmd); + + return; + } + + while (this.addedCommands.TryDequeue(out var cmd)) + AddEntry(cmd); + + catData->SortEntries(); + return; + + void AddEntry(string cmd) + { + if (this.cachedCommands.ContainsKey(cmd)) return; + + var cmdStr = new EntryStrings(cmd); + this.cachedCommands.Add(cmd, cmdStr); + completionModule->AddCompletionEntry( + GroupNumber, + 0xFF, + cmdStr.Display->StringPtr, + cmdStr.Match->StringPtr, + 0xFF); + } + } + + private int GetSelectionDetour(CompletionModule* thisPtr, CategoryData.CompletionDataStruct* dataStructs, int index, Utf8String* outputString, Utf8String* outputDisplayString) + { + var ret = this.getSelection!.Original.Invoke(thisPtr, dataStructs, index, outputString, outputDisplayString); + if (ret != -2 || outputString == null) return ret; + + // -2 means it was a plain text final selection, so it might be ours + // Unfortunately, the code that uses this string mangles the color macro for some reason... + // We'll just strip those out since we don't need the color in the chatbox + var txt = outputString->StringPtr.ExtractText(); + if (!this.cachedCommands.ContainsKey(txt)) + return ret; + + if (!this.AllowCompletion(txt)) + { + outputString->Clear(); + if (outputDisplayString != null) outputDisplayString->Clear(); + return ret; + } + + outputString->SetString(txt + " "); + return ret; + } + + private class EntryStrings(string command) + { + ~EntryStrings() + { + this.Display->Dtor(true); + this.Match->Dtor(true); + } + + public Utf8String* Display { get; } = + Utf8String.FromSequence(new SeStringBuilder().AddUiForeground(command, 539).Encode()); + + public Utf8String* Match { get; } = Utf8String.FromString(command); + } +} diff --git a/Dalamud/Interface/Internal/Windows/SelfTest/SelfTestWindow.cs b/Dalamud/Interface/Internal/Windows/SelfTest/SelfTestWindow.cs index da2aaff2d..e19aafbab 100644 --- a/Dalamud/Interface/Internal/Windows/SelfTest/SelfTestWindow.cs +++ b/Dalamud/Interface/Internal/Windows/SelfTest/SelfTestWindow.cs @@ -57,7 +57,8 @@ internal class SelfTestWindow : Window new SheetRedirectResolverSelfTestStep(), new NounProcessorSelfTestStep(), new SeStringEvaluatorSelfTestStep(), - new LogoutEventSelfTestStep() + new LogoutEventSelfTestStep(), + new CompletionSelfTestStep() ]; private readonly Dictionary testIndexToResult = new(); diff --git a/Dalamud/Interface/Internal/Windows/SelfTest/Steps/CompletionSelfTestStep.cs b/Dalamud/Interface/Internal/Windows/SelfTest/Steps/CompletionSelfTestStep.cs new file mode 100644 index 000000000..442131e14 --- /dev/null +++ b/Dalamud/Interface/Internal/Windows/SelfTest/Steps/CompletionSelfTestStep.cs @@ -0,0 +1,94 @@ +using Dalamud.Game.Command; +using Dalamud.Game.Gui; +using Dalamud.Game.Text; +using Dalamud.Game.Text.SeStringHandling; + +using ImGuiNET; + +namespace Dalamud.Interface.Internal.Windows.SelfTest.Steps; + +/// +/// Test setup for Chat. +/// +internal class CompletionSelfTestStep : ISelfTestStep +{ + private int step = 0; + private bool registered; + private bool commandRun; + + /// + public string Name => "Test Completion"; + + /// + public SelfTestStepResult RunStep() + { + var cmdManager = Service.Get(); + switch (this.step) + { + case 0: + this.step++; + + break; + + case 1: + ImGui.Text("[Chat Log]"); + ImGui.Text("Use the category menus to navigate to [Dalamud], then complete a command from the list. Did it work?"); + if (ImGui.Button("Yes")) + this.step++; + ImGui.SameLine(); + + if (ImGui.Button("No")) + return SelfTestStepResult.Fail; + break; + case 2: + ImGui.Text("[Chat Log]"); + ImGui.Text("Type /xl into the chat log and tab-complete a dalamud command. Did it work?"); + + if (ImGui.Button("Yes")) + this.step++; + ImGui.SameLine(); + + if (ImGui.Button("No")) + return SelfTestStepResult.Fail; + + break; + + case 3: + ImGui.Text("[Chat Log]"); + if (!this.registered) + { + cmdManager.AddHandler("/xlselftestcompletion", new CommandInfo((_, _) => this.commandRun = true)); + this.registered = true; + } + + ImGui.Text("Tab-complete /xlselftestcompletion in the chat log and send the command"); + + if (this.commandRun) + this.step++; + + break; + + case 4: + ImGui.Text("[Other text inputs]"); + ImGui.Text("Open the party finder recruitment criteria dialog and try to tab-complete /xldev in the text box."); + ImGui.Text("Did the command appear in the text box? (It should not have)"); + if (ImGui.Button("Yes")) + return SelfTestStepResult.Fail; + ImGui.SameLine(); + + if (ImGui.Button("No")) + this.step++; + break; + case 5: + return SelfTestStepResult.Pass; + } + + return SelfTestStepResult.Waiting; + } + + /// + public void CleanUp() + { + Service.Get().RemoveHandler("/completionselftest"); + } +}