v1.0.8.0: Action Chat Link, hotbar job-specific persistence fix
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -541,16 +541,32 @@ namespace HSUI.Helpers
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Sets a slot and persists to disk. For shared hotbars, explicitly writes to classJobId 0 to ensure persistence across job changes and teleports.</summary>
|
||||
/// <summary>Sets a slot and persists to disk. For shared hotbars, explicitly writes to classJobId 0.
|
||||
/// For job-specific hotbars, explicitly writes to current class job ID to ensure persistence when switching jobs.</summary>
|
||||
public static unsafe void SetAndSaveSlotInternal(RaptureHotbarModule* module, uint barId, uint slotId, RaptureHotbarModule.HotbarSlotType slotType, uint commandId, RaptureHotbarModule.HotbarSlot* slotPtr = null)
|
||||
{
|
||||
var ptr = slotPtr != null ? slotPtr : module->GetSlotById(barId, slotId);
|
||||
if (module->IsHotbarShared(barId) && ptr != null)
|
||||
if (ptr == null) return;
|
||||
|
||||
if (module->IsHotbarShared(barId))
|
||||
{
|
||||
// Shared hotbars: explicitly write to classJobId 0 (shared storage) for correct persistence across teleports/job changes
|
||||
// Shared hotbars: explicitly write to classJobId 0 (shared storage) for persistence across job changes and teleports
|
||||
module->WriteSavedSlot(0, barId, slotId, ptr, false, false);
|
||||
}
|
||||
// SetAndSaveSlot triggers file save; for shared bars also ensures save is queued (WriteSavedSlot may only update _savedHotbars)
|
||||
else
|
||||
{
|
||||
// Job-specific hotbars: explicitly write to current class job ID so saves persist when switching jobs
|
||||
uint classJobId = (uint)(module->ActiveHotbarClassJobId & 0x7F); // strip 0x80 flag if set
|
||||
if (classJobId == 0)
|
||||
{
|
||||
var player = Plugin.ObjectTable?.LocalPlayer;
|
||||
if (player != null)
|
||||
classJobId = player.ClassJob.RowId;
|
||||
}
|
||||
if (classJobId != 0)
|
||||
module->WriteSavedSlot(classJobId, barId, slotId, ptr, false, false);
|
||||
}
|
||||
// SetAndSaveSlot updates live slot and triggers file save
|
||||
module->SetAndSaveSlot(barId, slotId, slotType, commandId, ignoreSharedHotbars: false, allowSaveToPvP: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -467,8 +467,12 @@ namespace HSUI.Helpers
|
||||
[Order(3)]
|
||||
public bool ShowIcon = true;
|
||||
|
||||
[Checkbox("Shift+Click action in Actions & Traits → insert \"You should check out X\" in chat", help = "When chat is focused, inserts directly. Otherwise copies to clipboard.")]
|
||||
[Order(4)]
|
||||
public bool ActionChatLinkEnabled = true;
|
||||
|
||||
[Checkbox("Show Level", spacing = true)]
|
||||
[Order(5)]
|
||||
[Order(6)]
|
||||
public bool ShowLevel = true;
|
||||
|
||||
[Checkbox("Show HP")]
|
||||
|
||||
Reference in New Issue
Block a user