78b37a3f15
Co-authored-by: Cursor <cursoragent@cursor.com>
209 lines
7.7 KiB
C#
209 lines
7.7 KiB
C#
/*
|
|
* ActionChatLinkHelper: Shift+click on actions in the Actions & Traits menu to insert
|
|
* "You should check out {ActionName}" into the chat input (or clipboard if chat not focused).
|
|
*/
|
|
|
|
using Dalamud.Bindings.ImGui;
|
|
using Dalamud.Plugin.Services;
|
|
using Dalamud.Game.Gui;
|
|
using FFXIVClientStructs.FFXIV.Client.UI;
|
|
using FFXIVClientStructs.FFXIV.Component.GUI;
|
|
using HSUI.Config;
|
|
using System;
|
|
using System.Numerics;
|
|
using LuminaAction = Lumina.Excel.Sheets.Action;
|
|
|
|
namespace HSUI.Helpers
|
|
{
|
|
public sealed class ActionChatLinkHelper : IDisposable
|
|
{
|
|
public static ActionChatLinkHelper? Instance { get; private set; }
|
|
|
|
private readonly IFramework _framework;
|
|
|
|
/// <summary>Store last hovered action when ActionMenu is visible and shift held, so we have it at click time.</summary>
|
|
private uint _pendingActionId;
|
|
|
|
private ActionChatLinkHelper(IFramework framework)
|
|
{
|
|
_framework = framework;
|
|
_framework.Update += OnFrameworkUpdate;
|
|
ConfigurationManager.Instance.ResetEvent += OnConfigReset;
|
|
OnConfigReset(ConfigurationManager.Instance);
|
|
}
|
|
|
|
private WorldObjectTooltipConfig _config = null!;
|
|
|
|
public static void Initialize()
|
|
{
|
|
if (Instance != null) return;
|
|
Instance = new ActionChatLinkHelper(Plugin.Framework);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_framework.Update -= OnFrameworkUpdate;
|
|
try { ConfigurationManager.Instance.ResetEvent -= OnConfigReset; } catch { }
|
|
Instance = null;
|
|
}
|
|
|
|
private void OnConfigReset(ConfigurationManager _) =>
|
|
_config = ConfigurationManager.Instance.GetConfigObject<WorldObjectTooltipConfig>();
|
|
|
|
private void OnFrameworkUpdate(IFramework framework)
|
|
{
|
|
if (!_config.ActionChatLinkEnabled) return;
|
|
|
|
bool shiftHeld = ImGui.IsKeyDown(ImGuiKey.LeftShift) || ImGui.IsKeyDown(ImGuiKey.RightShift);
|
|
bool leftClicked = ImGui.IsMouseClicked(ImGuiMouseButton.Left);
|
|
|
|
// ActionMenu must be visible
|
|
var addon = Plugin.GameGui.GetAddonByName("ActionMenu", 1);
|
|
if (addon == null || addon.Address == IntPtr.Zero)
|
|
{
|
|
_pendingActionId = 0;
|
|
return;
|
|
}
|
|
|
|
// Capture action: 1) Addon list (ActionList or TraitList); 2) HoveredAction only when mouse is over ActionMenu (excludes hotbars)
|
|
if (shiftHeld)
|
|
{
|
|
if (TryGetHoveredActionFromAddon(addon.Address, out uint fromAddon))
|
|
_pendingActionId = fromAddon;
|
|
else if (IsMouseOverActionMenu(addon.Address) && !ActionBarsHitTestHelper.IsMouseOverAnyHSUIHotbar())
|
|
{
|
|
var ha = Plugin.GameGui.HoveredAction;
|
|
if (ha != null && ha.ActionKind != HoverActionKind.None && ha.ActionID != 0)
|
|
_pendingActionId = ha.ActionID;
|
|
else
|
|
_pendingActionId = 0;
|
|
}
|
|
else
|
|
_pendingActionId = 0;
|
|
}
|
|
else
|
|
_pendingActionId = 0;
|
|
|
|
// On shift+click, use captured action or HoveredAction as fallback
|
|
if (!shiftHeld || !leftClicked) return;
|
|
|
|
if (_pendingActionId == 0) return;
|
|
uint idToUse = _pendingActionId;
|
|
|
|
string? actionName = GetActionName(idToUse);
|
|
if (string.IsNullOrWhiteSpace(actionName)) return;
|
|
|
|
string text = $"You should check out {actionName}";
|
|
InsertOrCopyToChat(text);
|
|
_pendingActionId = 0;
|
|
}
|
|
|
|
/// <summary>Read action ID from AddonActionMenu. Tries ActionList and TraitList, HoveredItemIndex and HeldItemIndex.</summary>
|
|
private static unsafe bool TryGetHoveredActionFromAddon(IntPtr addonAddress, out uint actionId)
|
|
{
|
|
actionId = 0;
|
|
if (addonAddress == IntPtr.Zero) return false;
|
|
|
|
var addon = (AddonActionMenu*)addonAddress;
|
|
byte* basePtr = (byte*)addonAddress;
|
|
|
|
foreach (var list in new[] { addon->ActionList, addon->TraitList })
|
|
{
|
|
if (list == null) continue;
|
|
foreach (int idx in new[] { list->HoveredItemIndex, list->HeldItemIndex })
|
|
{
|
|
if (idx < 0 || idx >= 80) continue;
|
|
actionId = *(uint*)(basePtr + 0x338 + idx * 0x38 + 0x14);
|
|
if (actionId != 0) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// <summary>True if mouse is within ActionMenu addon bounds (so we can safely use HoveredAction — excludes hotbars).</summary>
|
|
private static unsafe bool IsMouseOverActionMenu(IntPtr addonAddress)
|
|
{
|
|
if (addonAddress == IntPtr.Zero) return false;
|
|
var addon = (AtkUnitBase*)addonAddress;
|
|
var root = addon->RootNode;
|
|
if (root == null || !addon->IsVisible) return false;
|
|
|
|
var mp = ImGui.GetMousePos();
|
|
float x = root->ScreenX, y = root->ScreenY, w = root->Width, h = root->Height;
|
|
return mp.X >= x && mp.X < x + w && mp.Y >= y && mp.Y < y + h;
|
|
}
|
|
|
|
private static string? GetActionName(uint actionId)
|
|
{
|
|
try
|
|
{
|
|
var sheet = Plugin.DataManager.GetExcelSheet<LuminaAction>();
|
|
var row = sheet?.GetRow(actionId);
|
|
return row.HasValue ? row.Value.Name.ToString() : null;
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
private static void InsertOrCopyToChat(string text)
|
|
{
|
|
if (TryInsertIntoActiveTextInput(text))
|
|
return;
|
|
|
|
// Fallback: copy to clipboard and notify (leave text on clipboard for user to paste)
|
|
try
|
|
{
|
|
ImGui.SetClipboardText(text);
|
|
Plugin.Chat.Print("[HSUI] Copied to clipboard — open chat and press Ctrl+V to paste.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Plugin.Logger.Warning($"[ActionChatLink] Failed to copy to clipboard: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private static unsafe bool TryInsertIntoActiveTextInput(string text)
|
|
{
|
|
try
|
|
{
|
|
var raptureAtk = RaptureAtkModule.Instance();
|
|
if (raptureAtk == null || !raptureAtk->IsTextInputActive())
|
|
return false;
|
|
|
|
var stage = AtkStage.Instance();
|
|
if (stage == null) return false;
|
|
|
|
var focus = stage->GetFocus();
|
|
if (focus == null) return false;
|
|
|
|
// Traverse up to find the text input component
|
|
var node = focus;
|
|
while (node != null)
|
|
{
|
|
if (node->Type == NodeType.Component)
|
|
{
|
|
var componentNode = (AtkComponentNode*)node;
|
|
var component = componentNode->Component;
|
|
if (component != null)
|
|
{
|
|
if (component->GetComponentType() == ComponentType.TextInput)
|
|
{
|
|
var textInput = (AtkComponentTextInput*)component;
|
|
textInput->InsertText(text, false);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
node = node->ParentNode;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Plugin.Logger.Warning($"[ActionChatLink] InsertText failed: {ex.Message}");
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|