feat: Controller hotbars with cross layout, separate storage, and sync with game

- Add controller hotbars: 8 cross bars (L2/R2 style), separate from normal hotbars 1-8
- Controller bar slot data stored in config (not game StandardHotbars) so layouts can differ per mode
- Drag-and-drop on controller bars: from game, shift+drag rearrange, release outside to clear
- Independent controller bar keybinds with modifier+trigger combinations (e.g. L2+South)
- Optional 'Sync bar mode with game client': follow Character Config Mouse/Gamepad toggle (PadMode)
- Clone/copy actions: normal hotbars ↔ controller bars
- Restore controller bar layout button; deploy to devPlugins on Release build

Made-with: Cursor
This commit is contained in:
Jorg
2026-02-26 22:18:40 -06:00
parent 369a770162
commit f3e10f27d2
13 changed files with 1706 additions and 21 deletions
+34 -18
View File
@@ -39,19 +39,19 @@ namespace HSUI.Interface.GeneralElements
/// <summary>When we had a game drag and the user released, suppress all slot clicks this frame (release is not a click).</summary>
private bool _suppressSlotClicksAfterDragRelease;
private const string SlotPayloadType = "HSUI_HOTBAR_SLOT";
internal const string SlotPayloadType = "HSUI_HOTBAR_SLOT";
private const int SlotsPerBar = 12;
private static int _imGuiDragSourceSlotId = -1;
private static bool _anyHotbarAcceptedDrop;
private static int _lastOverlayFrame = -1;
internal static int _imGuiDragSourceSlotId = -1;
internal static bool _anyHotbarAcceptedDrop;
internal static int _lastOverlayFrame = -1;
/// <summary>Deferred clear when release-outside: set when no overlay accepted, executed next frame.</summary>
private static int _pendingReleaseOutsideSlotId = -1;
internal static int _pendingReleaseOutsideSlotId = -1;
/// <summary>To avoid PICKUP log spam: only log when we first start dragging a new slot.</summary>
private static int _lastLoggedPickupSlotId = -1;
internal static int _lastLoggedPickupSlotId = -1;
/// <summary>Encode hotbar (1-10) and slot (0-11) into internal slot id (0-119).</summary>
private static int ToSlotId(int hotbarIndex, int slotIndex)
/// <summary>Encode hotbar (1-10) and slot (0-11) into internal slot id (0-119). Controller bars use 1-8 and 0-7.</summary>
internal static int ToSlotId(int hotbarIndex, int slotIndex)
{
int bar = Math.Clamp(hotbarIndex, 1, 10) - 1;
int slot = Math.Clamp(slotIndex, 0, 11);
@@ -59,14 +59,14 @@ namespace HSUI.Interface.GeneralElements
}
/// <summary>Decode internal slot id to hotbar (1-10) and slot (0-11). Returns (-1,-1) if invalid.</summary>
private static (int hotbarIndex, int slotIndex) FromSlotId(int slotId)
internal static (int hotbarIndex, int slotIndex) FromSlotId(int slotId)
{
if (slotId < 0 || slotId >= 10 * SlotsPerBar) return (-1, -1);
return (slotId / SlotsPerBar + 1, slotId % SlotsPerBar);
}
[StructLayout(LayoutKind.Sequential)]
private struct SlotDragPayload
internal struct SlotDragPayload
{
public int SlotId;
}
@@ -118,6 +118,9 @@ namespace HSUI.Interface.GeneralElements
{
if (!Config.Enabled || Actor == null)
return;
// When controller hotbars are effectively enabled (manual or sync with game), normal hotbars are hidden
if (ControllerHotbarsConfig.GetEffectiveControllerHotbarsEnabled())
return;
if (ActionBarsManager.Instance == null)
return;
@@ -496,7 +499,7 @@ namespace HSUI.Interface.GeneralElements
return true;
}
private static HotbarsConfig? GetHotbarsConfig() =>
internal static HotbarsConfig? GetHotbarsConfig() =>
ConfigurationManager.Instance?.GetConfigObject<HotbarsConfig>();
private static (int Type, int Int1, int Int2, double LastTime) _lastDragNoPayloadLog;
@@ -515,7 +518,7 @@ namespace HSUI.Interface.GeneralElements
return true;
}
private static unsafe bool IsGameDragging()
internal static unsafe bool IsGameDragging()
{
var stage = AtkStage.Instance();
if (stage == null) return false;
@@ -523,7 +526,7 @@ namespace HSUI.Interface.GeneralElements
return dm->IsDragging;
}
private static unsafe bool TryGetSlotPayload(ImGuiPayloadPtr payload, out SlotDragPayload src)
internal static unsafe bool TryGetSlotPayload(ImGuiPayloadPtr payload, out SlotDragPayload src)
{
if (payload.Data == null || payload.DataSize < sizeof(SlotDragPayload))
{
@@ -534,7 +537,7 @@ namespace HSUI.Interface.GeneralElements
return true;
}
private static void SetSlotPayload(SlotDragPayload pl)
internal static void SetSlotPayload(SlotDragPayload pl)
{
var type = new ImU8String(SlotPayloadType);
var data = MemoryMarshal.AsBytes(MemoryMarshal.CreateSpan(ref pl, 1));
@@ -840,10 +843,10 @@ namespace HSUI.Interface.GeneralElements
}
/// <summary>
/// Gets drag payload from game/Dalamud. When game reports Item drag, prioritize HoveredItem
/// (HoveredAction can be stale from previous hotbar hover). Preserves full item id including HQ flag.
/// Gets drag payload from game/Dalamud. When game reports Item drag, prioritize HoveredItem.
/// Used by ActionBarsHud and CrossBarHud for drag-drop.
/// </summary>
private static unsafe bool TryGetDragPayload(out RaptureHotbarModule.HotbarSlotType slotType, out uint id)
internal static unsafe bool TryGetDragPayload(out RaptureHotbarModule.HotbarSlotType slotType, out uint id)
{
slotType = RaptureHotbarModule.HotbarSlotType.Empty;
id = 0;
@@ -1094,7 +1097,7 @@ namespace HSUI.Interface.GeneralElements
};
}
private static unsafe uint GetIconIdForPayload(RaptureHotbarModule.HotbarSlotType slotType, uint id)
internal static unsafe uint GetIconIdForPayload(RaptureHotbarModule.HotbarSlotType slotType, uint id)
{
// id 0 is valid for GearSet (first gearset in list) and Macro; don't short-circuit for those
if (id == 0 && slotType != RaptureHotbarModule.HotbarSlotType.GearSet && slotType != RaptureHotbarModule.HotbarSlotType.Macro)
@@ -1155,6 +1158,19 @@ namespace HSUI.Interface.GeneralElements
catch { return 0; }
}
/// <summary>Public for CrossBarHud and other consumers. Returns (title, body) for a hotbar slot.</summary>
public static (string title, string text) GetSlotTooltipPublic(ActionBarsManager.SlotInfo slot)
{
try
{
return GetSlotTooltip(slot);
}
catch
{
return ("", "");
}
}
private static unsafe (string title, string text) GetSlotTooltip(ActionBarsManager.SlotInfo slot)
{
try