/* * 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; /// Store last hovered action when ActionMenu is visible and shift held, so we have it at click time. 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(); private void OnFrameworkUpdate(IFramework framework) { try { 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; } catch (Exception ex) { _pendingActionId = 0; Plugin.Logger.Warning($"[ActionChatLink] OnFrameworkUpdate: {ex.Message}"); } } /// Read action ID from AddonActionMenu. Tries ActionList and TraitList, HoveredItemIndex and HeldItemIndex. private static unsafe bool TryGetHoveredActionFromAddon(IntPtr addonAddress, out uint actionId) { actionId = 0; if (addonAddress == IntPtr.Zero) return false; try { 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; } } } catch (Exception ex) { Plugin.Logger.Verbose($"[ActionChatLink] TryGetHoveredActionFromAddon: {ex.Message}"); } return false; } /// True if mouse is within ActionMenu addon bounds (so we can safely use HoveredAction — excludes hotbars). private static unsafe bool IsMouseOverActionMenu(IntPtr addonAddress) { if (addonAddress == IntPtr.Zero) return false; try { 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; } catch (Exception ex) { Plugin.Logger.Verbose($"[ActionChatLink] IsMouseOverActionMenu: {ex.Message}"); return false; } } private static string? GetActionName(uint actionId) { try { var sheet = Plugin.DataManager.GetExcelSheet(); 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; } } } }