Dalamud/Dalamud/Game/Gui/Dtr/DtrBar.cs
2025-08-05 23:32:58 -07:00

680 lines
25 KiB
C#

using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using Dalamud.Configuration.Internal;
using Dalamud.Game.Addon.Events;
using Dalamud.Game.Addon.Events.EventDataTypes;
using Dalamud.Game.Addon.Lifecycle;
using Dalamud.Game.Addon.Lifecycle.AddonArgTypes;
using Dalamud.Game.Text.SeStringHandling;
using Dalamud.IoC;
using Dalamud.IoC.Internal;
using Dalamud.Logging.Internal;
using Dalamud.Plugin.Internal.Types;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Graphics;
using FFXIVClientStructs.FFXIV.Client.System.Memory;
using FFXIVClientStructs.FFXIV.Client.System.String;
using FFXIVClientStructs.FFXIV.Component.GUI;
namespace Dalamud.Game.Gui.Dtr;
/// <summary>
/// Class used to interface with the server info bar.
/// </summary>
[ServiceManager.EarlyLoadedService]
internal sealed unsafe class DtrBar : IInternalDisposableService, IDtrBar
{
private const uint BaseNodeId = 1000;
private static readonly ModuleLog Log = new("DtrBar");
[ServiceManager.ServiceDependency]
private readonly Framework framework = Service<Framework>.Get();
[ServiceManager.ServiceDependency]
private readonly GameGui gameGui = Service<GameGui>.Get();
[ServiceManager.ServiceDependency]
private readonly DalamudConfiguration configuration = Service<DalamudConfiguration>.Get();
[ServiceManager.ServiceDependency]
private readonly AddonEventManager uiEventManager = Service<AddonEventManager>.Get();
[ServiceManager.ServiceDependency]
private readonly AddonLifecycle addonLifecycle = Service<AddonLifecycle>.Get();
private readonly AddonLifecycleEventListener dtrPostDrawListener;
private readonly AddonLifecycleEventListener dtrPostRequestedUpdateListener;
private readonly AddonLifecycleEventListener dtrPreFinalizeListener;
private readonly ReaderWriterLockSlim entriesLock = new();
private readonly List<DtrBarEntry> entries = [];
private readonly Dictionary<uint, List<IAddonEventHandle>> eventHandles = new();
private ImmutableList<IReadOnlyDtrBarEntry>? entriesReadOnlyCopy;
private Utf8String* emptyString;
private uint runningNodeIds = BaseNodeId;
private float entryStartPos = float.NaN;
[ServiceManager.ServiceConstructor]
private DtrBar()
{
this.dtrPostDrawListener = new AddonLifecycleEventListener(AddonEvent.PostDraw, "_DTR", this.FixCollision);
this.dtrPostRequestedUpdateListener = new AddonLifecycleEventListener(AddonEvent.PostRequestedUpdate, "_DTR", this.FixCollision);
this.dtrPreFinalizeListener = new AddonLifecycleEventListener(AddonEvent.PreFinalize, "_DTR", this.PreFinalize);
this.addonLifecycle.RegisterListener(this.dtrPostDrawListener);
this.addonLifecycle.RegisterListener(this.dtrPostRequestedUpdateListener);
this.addonLifecycle.RegisterListener(this.dtrPreFinalizeListener);
this.framework.Update += this.Update;
this.configuration.DtrOrder ??= [];
this.configuration.DtrIgnore ??= [];
this.configuration.QueueSave();
}
/// <inheritdoc/>
public IReadOnlyList<IReadOnlyDtrBarEntry> Entries
{
get
{
var erc = this.entriesReadOnlyCopy;
if (erc is null)
{
this.entriesLock.EnterReadLock();
this.entriesReadOnlyCopy = erc = [..this.entries];
this.entriesLock.ExitReadLock();
}
return erc;
}
}
/// <summary>
/// Get a DTR bar entry.
/// This allows you to add your own text, and users to sort it.
/// </summary>
/// <param name="plugin">Plugin that owns the DTR bar, or <c>null</c> if owned by Dalamud.</param>
/// <param name="title">A user-friendly name for sorting.</param>
/// <param name="text">The text the entry shows.</param>
/// <returns>The entry object used to update, hide and remove the entry.</returns>
/// <exception cref="ArgumentException">Thrown when an entry with the specified title exists.</exception>
public IDtrBarEntry Get(LocalPlugin? plugin, string title, SeString? text = null)
{
this.entriesLock.EnterUpgradeableReadLock();
foreach (var existingEntry in this.entries)
{
if (existingEntry.Title == title)
{
if (existingEntry.ShouldBeRemoved)
{
if (plugin == existingEntry.OwnerPlugin)
{
Log.Debug(
"Reviving entry: {what}; owner: {plugin}({pluginId})",
title,
plugin?.InternalName,
plugin?.EffectiveWorkingPluginId);
}
else
{
Log.Debug(
"Reviving entry: {what}; old owner: {old}({oldId}); new owner: {new}({newId})",
title,
existingEntry.OwnerPlugin?.InternalName,
existingEntry.OwnerPlugin?.EffectiveWorkingPluginId,
plugin?.InternalName,
plugin?.EffectiveWorkingPluginId);
existingEntry.OwnerPlugin = plugin;
}
existingEntry.ShouldBeRemoved = false;
}
this.entriesLock.ExitUpgradeableReadLock();
if (plugin == existingEntry.OwnerPlugin)
return existingEntry;
Log.Debug(
"Entry already has a different owner: {what}; owner: {old}({oldId}); requester: {new}({newId})",
title,
existingEntry.OwnerPlugin?.InternalName,
existingEntry.OwnerPlugin?.EffectiveWorkingPluginId,
plugin?.InternalName,
plugin?.EffectiveWorkingPluginId);
throw new ArgumentException("An entry with the same title already exists.");
}
}
this.entriesLock.EnterWriteLock();
var entry = new DtrBarEntry(this.configuration, title, null) { Text = text, OwnerPlugin = plugin };
this.entries.Add(entry);
Log.Debug(
"Adding entry: {what}; owner: {owner}({id})",
title,
plugin?.InternalName,
plugin?.EffectiveWorkingPluginId);
// Add the entry to the end of the order list, if it's not there already.
var dtrOrder = this.configuration.DtrOrder ??= [];
if (!dtrOrder.Contains(entry.Title))
dtrOrder.Add(entry.Title);
this.ApplySortUnsafe(dtrOrder);
this.entriesReadOnlyCopy = null;
this.entriesLock.ExitWriteLock();
this.entriesLock.ExitUpgradeableReadLock();
return entry;
}
/// <inheritdoc/>
public IDtrBarEntry Get(string title, SeString? text = null) => this.Get(null, title, text);
/// <summary>
/// Removes a DTR bar entry from the system.
/// </summary>
/// <param name="plugin">Plugin that owns the DTR bar, or <c>null</c> if owned by Dalamud.</param>
/// <param name="title">Title of the entry to remove, or <c>null</c> to remove all entries under the plugin.</param>
/// <remarks>Remove operation is not immediate. If you try to add right after removing, the operation may fail.
/// </remarks>
public void Remove(LocalPlugin? plugin, string? title)
{
this.entriesLock.EnterUpgradeableReadLock();
foreach (var entry in this.entries)
{
if ((title is null || entry.Title == title) && (plugin is null || entry.OwnerPlugin == plugin))
{
if (!entry.Added)
{
Log.Debug("Removing entry immediately because it is not added yet: {what}", entry.Title);
this.entriesLock.EnterWriteLock();
this.RemoveEntry(entry);
this.entries.Remove(entry);
this.entriesReadOnlyCopy = null;
this.entriesLock.ExitWriteLock();
}
else if (!entry.ShouldBeRemoved)
{
Log.Debug("Queueing entry for removal: {what}", entry.Title);
entry.Remove();
}
else
{
Log.Debug("Entry is already marked for removal: {what}", entry.Title);
}
break;
}
}
this.entriesLock.ExitUpgradeableReadLock();
}
/// <inheritdoc/>
public void Remove(string title) => this.Remove(null, title);
/// <inheritdoc/>
void IInternalDisposableService.DisposeService()
{
this.addonLifecycle.UnregisterListener(this.dtrPostDrawListener);
this.addonLifecycle.UnregisterListener(this.dtrPostRequestedUpdateListener);
this.addonLifecycle.UnregisterListener(this.dtrPreFinalizeListener);
this.framework.RunOnFrameworkThread(
() =>
{
this.entriesLock.EnterWriteLock();
foreach (var entry in this.entries)
this.RemoveEntry(entry);
this.entries.Clear();
this.entriesReadOnlyCopy = null;
this.entriesLock.ExitWriteLock();
}).Wait();
this.framework.Update -= this.Update;
if (this.emptyString != null)
{
this.emptyString->Dtor();
this.emptyString = null;
}
}
/// <summary>
/// Remove native resources for the specified entry.
/// </summary>
/// <param name="toRemove">The resources to remove.</param>
internal void RemoveEntry(DtrBarEntry toRemove)
{
this.RemoveNode(toRemove.TextNode);
if (toRemove.Storage != null)
{
toRemove.Storage->Dtor(true);
toRemove.Storage = null;
}
}
/// <summary>
/// Check whether an entry with the specified title exists.
/// </summary>
/// <param name="title">The title to check for.</param>
/// <returns>Whether an entry with that title is registered.</returns>
internal bool HasEntry(string title)
{
var found = false;
this.entriesLock.EnterReadLock();
for (var i = 0; i < this.entries.Count && !found; i++)
found = this.entries[i].Title == title;
this.entriesLock.ExitReadLock();
return found;
}
/// <summary>
/// Dirty the DTR bar entry with the specified title.
/// </summary>
/// <param name="title">Title of the entry to dirty.</param>
/// <returns>Whether the entry was found.</returns>
internal bool MakeDirty(string title)
{
var found = false;
this.entriesLock.EnterReadLock();
for (var i = 0; i < this.entries.Count && !found; i++)
{
found = this.entries[i].Title == title;
if (found)
this.entries[i].Dirty = true;
}
this.entriesLock.ExitReadLock();
return found;
}
/// <summary>
/// Reapply the DTR entry ordering from <see cref="DalamudConfiguration"/>.
/// </summary>
internal void ApplySort()
{
this.entriesLock.EnterWriteLock();
this.ApplySortUnsafe(this.configuration.DtrOrder ??= []);
this.entriesLock.ExitWriteLock();
}
private void ApplySortUnsafe(List<string> dtrOrder)
{
// Sort the current entry list, based on the order in the configuration.
var positions = dtrOrder
.Select(entry => (entry, index: dtrOrder.IndexOf(entry)))
.ToDictionary(x => x.entry, x => x.index);
this.entries.Sort((x, y) =>
{
var xPos = positions.TryGetValue(x.Title, out var xIndex) ? xIndex : int.MaxValue;
var yPos = positions.TryGetValue(y.Title, out var yIndex) ? yIndex : int.MaxValue;
return xPos.CompareTo(yPos);
});
this.entriesReadOnlyCopy = null;
}
private AtkUnitBase* GetDtr() => this.gameGui.GetAddonByName("_DTR").Struct;
private void Update(IFramework unused)
{
var dtr = this.GetDtr();
if (dtr == null || dtr->RootNode == null || dtr->RootNode->ChildNode == null) return;
// The collision node on the DTR element is always the width of its content
if (dtr->UldManager.NodeList == null) return;
// If we have an unmodified DTR but still have entries, we need to
// work to reset our state.
if (!this.CheckForDalamudNodes(dtr))
this.RecreateNodes();
var collisionNode = dtr->GetNodeById(17);
if (collisionNode == null) return;
// We haven't calculated the native size yet, so we don't know where to start positioning.
if (float.IsNaN(this.entryStartPos)) return;
var runningXPos = this.entryStartPos;
this.entriesLock.EnterUpgradeableReadLock();
for (var i = 0; i < this.entries.Count; i++)
{
var data = this.entries[i];
if (data.ShouldBeRemoved)
{
Log.Debug("Removing entry from Framework.Update: {what}", data.Title);
this.entriesLock.EnterWriteLock();
this.entries.RemoveAt(i);
this.RemoveEntry(data);
this.entriesReadOnlyCopy = null;
this.entriesLock.ExitWriteLock();
i--;
continue;
}
if (!data.Added)
data.Added = this.AddNode(data);
if (!data.Added || data.TextNode is null) // TextNode check is unnecessary, but just in case.
continue;
var isHide = !data.Shown || data.UserHidden;
var node = data.TextNode;
var nodeHidden = !node->AtkResNode.IsVisible();
if (!isHide)
{
if (nodeHidden)
node->AtkResNode.ToggleVisibility(true);
if (data is { Added: true, Text: not null, TextNode: not null } && (data.Dirty || nodeHidden))
{
if (data.Storage == null)
{
data.Storage = Utf8String.CreateEmpty();
}
data.Storage->SetString(data.Text.EncodeWithNullTerminator());
node->SetText(data.Storage->StringPtr);
ushort w = 0, h = 0;
node->GetTextDrawSize(&w, &h, node->NodeText.StringPtr);
node->AtkResNode.SetWidth(w);
}
var elementWidth = data.TextNode->AtkResNode.Width + this.configuration.DtrSpacing;
if (this.configuration.DtrSwapDirection)
{
data.TextNode->AtkResNode.SetPositionFloat(runningXPos + this.configuration.DtrSpacing, 2);
runningXPos += elementWidth;
}
else
{
runningXPos -= elementWidth;
data.TextNode->AtkResNode.SetPositionFloat(runningXPos, 2);
}
}
else if (!nodeHidden)
{
// If we want the node hidden, shift it up, to prevent collision conflicts
node->AtkResNode.SetYFloat(-collisionNode->Height * dtr->RootNode->ScaleX);
node->AtkResNode.ToggleVisibility(false);
}
data.Dirty = false;
}
this.entriesLock.ExitUpgradeableReadLock();
}
private void FixCollision(AddonEvent eventType, AddonArgs addonInfo)
{
var addon = addonInfo.Addon.Struct;
if (addon->RootNode is null || addon->UldManager.NodeList is null) return;
float minX = addon->RootNode->Width;
var additionalWidth = 0;
AtkResNode* collisionNode = null;
for (var index = 0; index < addon->UldManager.NodeListCount; index++)
{
var node = addon->UldManager.NodeList[index];
if (node->IsVisible())
{
var nodeId = node->NodeId;
var nodeType = node->Type;
if (nodeType == NodeType.Collision)
{
collisionNode = node;
}
else if (nodeId >= BaseNodeId)
{
// Dalamud-created node
additionalWidth += node->Width + this.configuration.DtrSpacing;
}
else if ((nodeType == NodeType.Res || (ushort)nodeType >= 1000) &&
(node->ChildNode == null || node->ChildNode->IsVisible()))
{
// Native top-level node. These are are either res nodes or button components.
// Both the node and its child (if it has one) must be visible for the node to be counted.
minX = MathF.Min(minX, node->X);
}
}
}
if (collisionNode == null) return;
var nativeWidth = addon->RootNode->Width - (int)minX;
var targetX = minX - (this.configuration.DtrSwapDirection ? 0 : additionalWidth);
var targetWidth = (ushort)(nativeWidth + additionalWidth);
if (collisionNode->Width != targetWidth || Math.Abs(collisionNode->X - targetX) > 0.0001)
{
collisionNode->SetWidth(targetWidth);
collisionNode->SetXFloat(targetX);
}
// If we are drawing backwards, we should start from the right side of the native nodes.
this.entryStartPos = this.configuration.DtrSwapDirection ? minX + nativeWidth : minX;
}
private void PreFinalize(AddonEvent type, AddonArgs args)
{
this.entryStartPos = float.NaN;
}
/// <summary>
/// Checks if there are any Dalamud nodes in the DTR.
/// </summary>
/// <returns>True if there are nodes with an ID > 1000.</returns>
private bool CheckForDalamudNodes(AtkUnitBase* dtr)
{
for (var i = 0; i < dtr->UldManager.NodeListCount; i++)
{
if (dtr->UldManager.NodeList[i]->NodeId > 1000)
return true;
}
return false;
}
private void RecreateNodes()
{
this.runningNodeIds = BaseNodeId;
this.entriesLock.EnterReadLock();
this.eventHandles.Clear();
foreach (var entry in this.entries)
entry.Added = false;
this.entriesLock.ExitReadLock();
}
private bool AddNode(DtrBarEntry data)
{
var dtr = this.GetDtr();
if (dtr == null || dtr->RootNode == null || dtr->UldManager.NodeList == null) return false;
var node = data.TextNode = this.MakeNode(++this.runningNodeIds);
this.eventHandles.TryAdd(node->AtkResNode.NodeId, new List<IAddonEventHandle>());
this.eventHandles[node->AtkResNode.NodeId].AddRange(new List<IAddonEventHandle>
{
this.uiEventManager.AddEvent(AddonEventManager.DalamudInternalKey, (nint)dtr, (nint)node, AddonEventType.MouseOver, this.DtrEventHandler),
this.uiEventManager.AddEvent(AddonEventManager.DalamudInternalKey, (nint)dtr, (nint)node, AddonEventType.MouseOut, this.DtrEventHandler),
this.uiEventManager.AddEvent(AddonEventManager.DalamudInternalKey, (nint)dtr, (nint)node, AddonEventType.MouseClick, this.DtrEventHandler),
});
var lastChild = dtr->RootNode->ChildNode;
while (lastChild->PrevSiblingNode != null) lastChild = lastChild->PrevSiblingNode;
Log.Debug($"Found last sibling: {(ulong)lastChild:X}");
lastChild->PrevSiblingNode = (AtkResNode*)node;
node->AtkResNode.ParentNode = lastChild->ParentNode;
node->AtkResNode.NextSiblingNode = lastChild;
dtr->RootNode->ChildCount = (ushort)(dtr->RootNode->ChildCount + 1);
Log.Debug("Set last sibling of DTR and updated child count");
dtr->UldManager.UpdateDrawNodeList();
dtr->UpdateCollisionNodeList(false);
Log.Debug("Updated node draw list");
data.Dirty = true;
return true;
}
private void RemoveNode(AtkTextNode* node)
{
var dtr = this.GetDtr();
if (dtr == null || dtr->RootNode == null || dtr->UldManager.NodeList == null || node == null) return;
this.eventHandles[node->AtkResNode.NodeId].ForEach(handle => this.uiEventManager.RemoveEvent(AddonEventManager.DalamudInternalKey, handle));
this.eventHandles[node->AtkResNode.NodeId].Clear();
var tmpPrevNode = node->AtkResNode.PrevSiblingNode;
var tmpNextNode = node->AtkResNode.NextSiblingNode;
// if (tmpNextNode != null)
tmpNextNode->PrevSiblingNode = tmpPrevNode;
if (tmpPrevNode != null)
tmpPrevNode->NextSiblingNode = tmpNextNode;
node->AtkResNode.Destroy(true);
dtr->RootNode->ChildCount = (ushort)(dtr->RootNode->ChildCount - 1);
Log.Debug("Set last sibling of DTR and updated child count");
dtr->UldManager.UpdateDrawNodeList();
dtr->UpdateCollisionNodeList(false);
Log.Debug("Updated node draw list");
}
private AtkTextNode* MakeNode(uint nodeId)
{
var newTextNode = IMemorySpace.GetUISpace()->Create<AtkTextNode>(); // AtkUldManager.CreateAtkTextNode();
if (newTextNode == null)
{
Log.Debug("Failed to allocate memory for AtkTextNode");
return null;
}
newTextNode->AtkResNode.NodeId = nodeId;
newTextNode->AtkResNode.Type = NodeType.Text;
newTextNode->AtkResNode.NodeFlags = NodeFlags.AnchorLeft | NodeFlags.AnchorTop | NodeFlags.Enabled | NodeFlags.RespondToMouse | NodeFlags.HasCollision | NodeFlags.EmitsEvents;
newTextNode->AtkResNode.DrawFlags = 12;
newTextNode->AtkResNode.SetWidth(22);
newTextNode->AtkResNode.SetHeight(22);
newTextNode->AtkResNode.SetPositionFloat(-200, 2);
newTextNode->LineSpacing = 12;
newTextNode->AlignmentFontType = 5;
newTextNode->FontSize = 14;
newTextNode->TextFlags = TextFlags.Edge;
if (this.emptyString == null)
this.emptyString = Utf8String.FromString(" ");
newTextNode->SetText(this.emptyString->StringPtr);
newTextNode->TextColor = new ByteColor { R = 255, G = 255, B = 255, A = 255 };
newTextNode->EdgeColor = new ByteColor { R = 142, G = 106, B = 12, A = 255 };
return newTextNode;
}
private void DtrEventHandler(AddonEventType atkEventType, AddonEventData eventData)
{
var addon = (AtkUnitBase*)eventData.AddonPointer;
var node = (AtkResNode*)eventData.NodeTargetPointer;
DtrBarEntry? dtrBarEntry = null;
this.entriesLock.EnterReadLock();
foreach (var entry in this.entries)
{
if (entry.TextNode == node)
dtrBarEntry = entry;
}
this.entriesLock.ExitReadLock();
if (dtrBarEntry is { Tooltip: not null })
{
switch (atkEventType)
{
case AddonEventType.MouseOver:
AtkStage.Instance()->TooltipManager.ShowTooltip(addon->Id, node, dtrBarEntry.Tooltip.Encode());
break;
case AddonEventType.MouseOut:
AtkStage.Instance()->TooltipManager.HideTooltip(addon->Id);
break;
}
}
if (dtrBarEntry is { OnClick: not null })
{
switch (atkEventType)
{
case AddonEventType.MouseOver:
this.uiEventManager.SetCursor(AddonCursorType.Clickable);
break;
case AddonEventType.MouseOut:
this.uiEventManager.ResetCursor();
break;
case AddonEventType.MouseClick:
dtrBarEntry.OnClick?.Invoke(new AddonMouseEventData(eventData));
break;
}
}
}
}
/// <summary>
/// Plugin-scoped version of a AddonEventManager service.
/// </summary>
[PluginInterface]
[ServiceManager.ScopedService]
#pragma warning disable SA1015
[ResolveVia<IDtrBar>]
#pragma warning restore SA1015
internal sealed class DtrBarPluginScoped : IInternalDisposableService, IDtrBar
{
private readonly LocalPlugin plugin;
[ServiceManager.ServiceDependency]
private readonly DtrBar dtrBarService = Service<DtrBar>.Get();
[ServiceManager.ServiceConstructor]
private DtrBarPluginScoped(LocalPlugin plugin) => this.plugin = plugin;
/// <inheritdoc/>
public IReadOnlyList<IReadOnlyDtrBarEntry> Entries => this.dtrBarService.Entries;
/// <inheritdoc/>
void IInternalDisposableService.DisposeService() => this.dtrBarService.Remove(this.plugin, null);
/// <inheritdoc/>
public IDtrBarEntry Get(string title, SeString? text = null) => this.dtrBarService.Get(this.plugin, title, text);
/// <inheritdoc/>
public void Remove(string title) => this.dtrBarService.Remove(this.plugin, title);
}