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>
This commit is contained in:
salanth357 2025-05-29 14:24:21 -04:00 committed by GitHub
parent 944c3700db
commit 84d121c7bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 438 additions and 4 deletions

View file

@ -45,6 +45,16 @@ internal sealed unsafe class CommandManager : IInternalDisposableService, IComma
this.console.Invoke += this.ConsoleOnInvoke;
}
/// <summary>
/// Published whenever a command is registered
/// </summary>
public event EventHandler<CommandEventArgs>? CommandAdded;
/// <summary>
/// Published whenever a command is unregistered
/// </summary>
public event EventHandler<CommandEventArgs>? CommandRemoved;
/// <inheritdoc/>
public ReadOnlyDictionary<string, IReadOnlyCommandInfo> 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;
}
/// <summary>
@ -204,6 +236,20 @@ internal sealed unsafe class CommandManager : IInternalDisposableService, IComma
return this.ProcessCommand(command->ToString()) ? 0 : result;
}
/// <inheritdoc />
public class CommandEventArgs : EventArgs
{
/// <summary>
/// Gets the command string
/// </summary>
public string Command { get; init; }
/// <summary>
/// Gets the command info
/// </summary>
public IReadOnlyCommandInfo CommandInfo { get; init; }
}
}
/// <summary>
@ -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;

View file

@ -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;
/// <summary>
/// This class adds dalamud and plugin commands to the chat box's autocompletion.
/// </summary>
[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<CommandManager>.Get();
[ServiceManager.ServiceDependency]
private readonly Framework framework = Service<Framework>.Get();
private readonly EntryStrings dalamudCategory = new("【Dalamud】");
private readonly Dictionary<string, EntryStrings> cachedCommands = [];
private readonly ConcurrentQueue<string> addedCommands = [];
private Hook<CompletionModule.Delegates.GetSelection>? 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;
/// <summary>
/// Initializes a new instance of the <see cref="Completion"/> class.
/// </summary>
[ServiceManager.ServiceConstructor]
internal Completion()
{
this.commandManager.CommandAdded += this.OnCommandAdded;
this.commandManager.CommandRemoved += this.OnCommandRemoved;
this.framework.Update += this.OnUpdate;
}
/// <summary>Finalizes an instance of the <see cref="Completion"/> class.</summary>
~Completion() => this.Dispose(false);
/// <inheritdoc/>
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<TargetSigScanner>.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<CompletionModule.Delegates.GetSelection>.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);
}
}

View file

@ -57,7 +57,8 @@ internal class SelfTestWindow : Window
new SheetRedirectResolverSelfTestStep(),
new NounProcessorSelfTestStep(),
new SeStringEvaluatorSelfTestStep(),
new LogoutEventSelfTestStep()
new LogoutEventSelfTestStep(),
new CompletionSelfTestStep()
];
private readonly Dictionary<int, (SelfTestStepResult Result, TimeSpan? Duration)> testIndexToResult = new();

View file

@ -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;
/// <summary>
/// Test setup for Chat.
/// </summary>
internal class CompletionSelfTestStep : ISelfTestStep
{
private int step = 0;
private bool registered;
private bool commandRun;
/// <inheritdoc/>
public string Name => "Test Completion";
/// <inheritdoc/>
public SelfTestStepResult RunStep()
{
var cmdManager = Service<CommandManager>.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;
}
/// <inheritdoc/>
public void CleanUp()
{
Service<CommandManager>.Get().RemoveHandler("/completionselftest");
}
}