Files
HSUI/Interface/GeneralElements/CrossBarHud.cs
Jorg f3e10f27d2 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
2026-02-26 22:18:40 -06:00

511 lines
26 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Dalamud.Game.ClientState.Objects.Types;
using HSUI.Config;
using HSUI.Enums;
using HSUI.Helpers;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Utility;
using FFXIVClientStructs.FFXIV.Client.Game;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
using FFXIVClientStructs.FFXIV.Component.GUI;
using static FFXIVClientStructs.FFXIV.Client.Game.ActionManager;
using LuminaAction = Lumina.Excel.Sheets.Action;
namespace HSUI.Interface.GeneralElements
{
/// <summary>Draws one controller cross hotbar (8 slots in cross layout). Visible only when controller hotbars are enabled and trigger state matches.</summary>
public class CrossBarHud : DraggableHudElement, IHudElementWithActor, IHudElementWithVisibilityConfig
{
private ControllerBarConfig Config => (ControllerBarConfig)_config;
private bool _wasDragging;
private uint _dragId;
private RaptureHotbarModule.HotbarSlotType _dragSlotType;
private int _pendingSlotIconIndex = -1;
private uint _pendingSlotIconId;
private int _pendingSlotIconFramesLeft;
private int _lastFrameDroppedOnSlot = -1;
private bool _suppressSlotClicksAfterDragRelease;
public IGameObject? Actor { get; set; }
public VisibilityConfig VisibilityConfig => Config.VisibilityConfig;
public CrossBarHud(ControllerBarConfig config, string displayName) : base(config, displayName) { }
protected override (List<Vector2>, List<Vector2>) ChildrenPositionsAndSizes()
{
var size = BarSize();
return (new List<Vector2> { Config.Position }, new List<Vector2> { size });
}
private Vector2 BarSize()
{
// Two crosses side by side: each cross is 3 cells (slot+pad); gap between crosses
float cellW = Config.SlotSize.X + Config.SlotPadding;
float cellH = Config.SlotSize.Y + Config.SlotPadding;
float gap = Config.SlotPadding * 2;
float w = 3 * cellW + gap + 3 * cellW;
float h = 3 * cellH;
return new Vector2(w, h);
}
/// <summary>Cross layout like the game: left cross (slots 0-3) = top/left/right/bottom around center; right cross (slots 4-7) same.</summary>
private Vector2 GetSlotPositionCross(int slotIndex, Vector2 slotSize, int pad)
{
float cellW = slotSize.X + pad;
float cellH = slotSize.Y + pad;
float leftCrossWidth = 3 * cellW;
float gap = pad * 2f;
if (slotIndex < 4)
{
// Left cross: 3x3 grid, slots at (1,0), (0,1), (2,1), (1,2)
int col = slotIndex switch { 0 => 1, 1 => 0, 2 => 2, _ => 1 };
int row = slotIndex switch { 0 => 0, 1 => 1, 2 => 1, _ => 2 };
return new Vector2(col * cellW, row * cellH);
}
else
{
// Right cross: same 3x3 pattern, offset by left cross + gap
int i = slotIndex - 4;
int col = i switch { 0 => 1, 1 => 0, 2 => 2, _ => 1 };
int row = i switch { 0 => 0, 1 => 1, 2 => 1, _ => 2 };
return new Vector2(leftCrossWidth + gap + col * cellW, row * cellH);
}
}
private const float DropMargin = 24f;
/// <summary>Hit test: returns slot index 0-7 if pos is over a cross slot, else -1.</summary>
private int GetSlotIndexAtPositionCross(Vector2 topLeft, Vector2 slotSize, int pad, Vector2 pos)
{
Vector2 barSize = new Vector2(
3 * (slotSize.X + pad) + pad * 2f + 3 * (slotSize.X + pad),
3 * (slotSize.Y + pad));
if (pos.X < topLeft.X || pos.X >= topLeft.X + barSize.X || pos.Y < topLeft.Y || pos.Y >= topLeft.Y + barSize.Y)
return -1;
int best = -1;
float bestDistSq = float.MaxValue;
for (int i = 0; i < ControllerBarConfig.CrossSlotCount; i++)
{
var slotOffset = GetSlotPositionCross(i, slotSize, pad);
float cx = topLeft.X + slotOffset.X + slotSize.X * 0.5f;
float cy = topLeft.Y + slotOffset.Y + slotSize.Y * 0.5f;
float dx = pos.X - cx, dy = pos.Y - cy;
float slotHalfW = slotSize.X * 0.5f + DropMargin;
float slotHalfH = slotSize.Y * 0.5f + DropMargin;
if (Math.Abs(dx) <= slotHalfW && Math.Abs(dy) <= slotHalfH)
{
float distSq = dx * dx + dy * dy;
if (distSq < bestDistSq) { bestDistSq = distSq; best = i; }
}
}
return best;
}
private unsafe void HandleDragDropCross(Vector2 topLeft, Vector2 barSize, Vector2 slotSize, int pad)
{
if (_pendingSlotIconIndex >= 0 && _pendingSlotIconFramesLeft > 0)
{
_pendingSlotIconFramesLeft--;
if (_pendingSlotIconFramesLeft <= 0)
_pendingSlotIconIndex = -1;
}
else if (_pendingSlotIconIndex >= 0)
_pendingSlotIconIndex = -1;
var stage = AtkStage.Instance();
if (stage == null) return;
var dm = (AtkDragDropManager*)Unsafe.AsPointer(ref stage->DragDropManager);
bool dragging = dm->IsDragging;
if (dragging)
{
if (ActionBarsHud.GetHotbarsConfig()?.GeneralOptions?.EnableDragDropFromGame != false &&
ActionBarsHud.TryGetDragPayload(out var slotType, out var id))
{
_dragSlotType = slotType;
_dragId = id;
_wasDragging = true;
var mp = ImGui.GetMousePos();
int idx = GetSlotIndexAtPositionCross(topLeft, slotSize, pad, mp);
if (idx >= 0)
InputsHelper.SuppressExecuteSlotByIdAfterDrop((uint)(ActionBarsManager.ScratchBarIndex - 1), (uint)ActionBarsManager.ScratchSlotIndex, 500);
if (ImGui.IsMouseReleased(ImGuiMouseButton.Left))
{
int releaseIdx = GetSlotIndexAtPositionCross(topLeft, slotSize, pad, mp);
if (releaseIdx >= 0 && slotType != RaptureHotbarModule.HotbarSlotType.Empty &&
(id != 0 || slotType == RaptureHotbarModule.HotbarSlotType.Macro || slotType == RaptureHotbarModule.HotbarSlotType.GearSet))
{
if (!ImGui.GetIO().KeyShift)
{
try
{
ActionBarsManager.Instance?.PlacePayloadOnControllerSlot(Config.HotbarIndex, releaseIdx, slotType, id);
uint iconId = ActionBarsHud.GetIconIdForPayload(slotType, id);
if (iconId != 0)
{
_pendingSlotIconIndex = releaseIdx;
_pendingSlotIconId = iconId;
_pendingSlotIconFramesLeft = 300;
}
_lastFrameDroppedOnSlot = releaseIdx;
InputsHelper.SuppressExecuteSlotByIdAfterDrop((uint)(ActionBarsManager.ScratchBarIndex - 1), (uint)ActionBarsManager.ScratchSlotIndex, 300);
if (slotType == RaptureHotbarModule.HotbarSlotType.Action ||
slotType == RaptureHotbarModule.HotbarSlotType.GeneralAction ||
slotType == RaptureHotbarModule.HotbarSlotType.CraftAction ||
slotType == RaptureHotbarModule.HotbarSlotType.PetAction)
InputsHelper.SuppressUseActionAfterDrop(id, 300);
}
catch (Exception ex)
{
Plugin.Logger.Warning($"[HSUI CrossBar DragDrop] Drop failed: {ex.Message}");
}
}
try { dm->CancelDragDrop(true, true); }
catch { }
}
else
{
try { dm->CancelDragDrop(true, true); }
catch { }
}
_wasDragging = false;
}
}
else
{
_dragSlotType = RaptureHotbarModule.HotbarSlotType.Empty;
_dragId = 0;
_wasDragging = true;
if (ImGui.IsMouseReleased(ImGuiMouseButton.Left))
{
try { dm->CancelDragDrop(true, true); }
catch { }
_wasDragging = false;
}
}
return;
}
if (_wasDragging)
{
_suppressSlotClicksAfterDragRelease = true;
var mp = ImGui.GetMousePos();
int idx = GetSlotIndexAtPositionCross(topLeft, slotSize, pad, mp);
if (idx >= 0)
{
var slotType = _dragSlotType;
var id = _dragId;
bool hasValidPayload = slotType != RaptureHotbarModule.HotbarSlotType.Empty &&
(id != 0 || slotType == RaptureHotbarModule.HotbarSlotType.Macro || slotType == RaptureHotbarModule.HotbarSlotType.GearSet);
if (hasValidPayload)
{
if (ImGui.GetIO().KeyShift)
{
try { dm->CancelDragDrop(true, true); }
catch { }
ActionBarsManager.Instance?.ClearControllerSlot(Config.HotbarIndex, idx);
_lastFrameDroppedOnSlot = idx;
_wasDragging = false;
return;
}
try
{
ActionBarsManager.Instance?.PlacePayloadOnControllerSlot(Config.HotbarIndex, idx, slotType, id);
try { dm->CancelDragDrop(true, true); }
catch { }
uint iconId = ActionBarsHud.GetIconIdForPayload(slotType, id);
if (iconId != 0)
{
_pendingSlotIconIndex = idx;
_pendingSlotIconId = iconId;
_pendingSlotIconFramesLeft = 300;
}
_lastFrameDroppedOnSlot = idx;
InputsHelper.SuppressExecuteSlotByIdAfterDrop((uint)(ActionBarsManager.ScratchBarIndex - 1), (uint)ActionBarsManager.ScratchSlotIndex, 300);
if (slotType == RaptureHotbarModule.HotbarSlotType.Action ||
slotType == RaptureHotbarModule.HotbarSlotType.GeneralAction ||
slotType == RaptureHotbarModule.HotbarSlotType.CraftAction ||
slotType == RaptureHotbarModule.HotbarSlotType.PetAction)
InputsHelper.SuppressUseActionAfterDrop(id, 300);
}
catch (Exception ex)
{
Plugin.Logger.Warning($"[HSUI CrossBar DragDrop] Drop failed: {ex.Message}");
}
}
}
_wasDragging = false;
}
}
public override void DrawChildren(Vector2 origin)
{
if (!ControllerHotbarsConfig.GetEffectiveControllerHotbarsEnabled() || Actor == null)
return;
if (ControllerHotbarHelper.GetCurrentTrigger() != Config.Trigger)
return;
if (ActionBarsManager.Instance == null)
return;
_lastFrameDroppedOnSlot = -1;
_suppressSlotClicksAfterDragRelease = false;
var barSize = BarSize();
var topLeft = Utils.GetAnchoredPosition(origin + Config.Position, barSize, Config.Anchor);
var slotSize = Config.SlotSize;
int pad = Config.SlotPadding;
bool showCd = Config.ShowCooldownOverlay;
bool showCdNumbers = Config.ShowCooldownNumbers;
bool showBorder = Config.ShowBorder;
AddDrawAction(Config.StrataLevel, () =>
{
HandleDragDropCross(topLeft, barSize, slotSize, pad);
var slots = ActionBarsManager.Instance.GetControllerSlotData(Config.HotbarIndex, ControllerBarConfig.CrossSlotCount);
void DrawBarContent(ImDrawListPtr drawList)
{
using (FontsManager.Instance.PushFont(FontsConfig.DefaultSmallFontKey))
{
for (int i = 0; i < slots.Count; i++)
{
var slot = slots[i];
uint displayIconId = slot.IconId;
bool isEmpty = slot.IsEmpty;
bool usePending = _pendingSlotIconIndex == i && _pendingSlotIconId != 0;
if (usePending)
{
displayIconId = _pendingSlotIconId;
isEmpty = false;
}
var slotOffset = GetSlotPositionCross(i, slotSize, pad);
var pos = topLeft + slotOffset;
var size = new Vector2(slotSize.X, slotSize.Y);
bool hovered = !isEmpty && ImGui.IsMouseHoveringRect(pos, pos + size);
if (isEmpty)
{
drawList.AddRectFilled(pos, pos + size, 0x22000000);
if (showBorder)
drawList.AddRect(pos, pos + size, 0xFF444444);
}
else
{
uint color = hovered ? 0xFFFFFFFF : (slot.IsUsable ? 0xFFFFFFFF : 0xAA888888);
bool isComboNext = Config.ShowComboHighlight && IsComboNextAction(slot);
DrawHelper.DrawIcon(displayIconId, pos, size, showBorder && !hovered && !isComboNext, color, drawList);
if (hovered && showBorder)
drawList.AddRect(pos, pos + size, 0xFF88CCFF, 0, ImDrawFlags.None, 2);
else if (isComboNext)
{
var ch = Config.ComboHighlightConfig;
DrawHelper.DrawComboHighlightRect(drawList, pos, pos + size,
ch.Color.Base, ch.Thickness, ch.ShowGlow, ch.LineStyle);
}
if (showCd && slot.CooldownPercent > 0 && _pendingSlotIconIndex != i)
{
float total = 100f;
float elapsed = total - slot.CooldownPercent;
DrawHelper.DrawIconCooldown(pos, size, elapsed, total, drawList);
if (showCdNumbers && slot.CooldownSecondsLeft > 0)
{
string cdText = slot.CooldownSecondsLeft.ToString();
Vector2 textSize = ImGui.CalcTextSize(cdText);
Vector2 textPos = pos + (size - textSize) * 0.5f;
DrawHelper.DrawOutlinedText(cdText, textPos, 0xFFFFFFFF, 0xFF000000, drawList, 1);
}
}
if (Config.ShowKeypressFlash)
{
float flashAlpha = InputsHelper.GetKeypressFlashAlpha(Config.HotbarIndex, i, 0.2);
if (flashAlpha > 0.001f)
{
byte a = (byte)(flashAlpha * 0.5f * 255);
drawList.AddRectFilled(pos, pos + size, (uint)(a << 24 | 0xFFFFFF));
}
}
}
if (Config.ShowSlotNumbers && !slot.IsEmpty)
{
string label = !string.IsNullOrWhiteSpace(slot.KeybindHint)
? slot.KeybindHint
: ActionBarsManager.GetDefaultKeybindLabel(Config.HotbarIndex, i);
Vector2 labelSize = ImGui.CalcTextSize(label);
Vector2 labelPos = new Vector2(pos.X + size.X - labelSize.X - 2, pos.Y + size.Y - labelSize.Y - 2);
DrawHelper.DrawOutlinedText(label, labelPos, 0xFFFFFFFF, 0xFF000000, drawList, 1);
}
if (Config.ShowChargeCount && !slot.IsEmpty && slot.MaxCharges > 1)
{
string chargeText = slot.CurrentCharges.ToString();
Vector2 chargeSize = ImGui.CalcTextSize(chargeText);
Vector2 chargePos = new Vector2(pos.X + 2, pos.Y + 2);
DrawHelper.DrawOutlinedText(chargeText, chargePos, 0xFFFFFFFF, 0xFF000000, drawList, 1);
}
}
}
}
DrawHelper.DrawInWindow(ID + "_crossbar", topLeft, barSize, false, DrawBarContent);
});
// Overlay for click, drag-drop and tooltip
AddDrawAction(StrataLevel.HIGHEST, () =>
{
var slots = ActionBarsManager.Instance.GetControllerSlotData(Config.HotbarIndex, ControllerBarConfig.CrossSlotCount);
var flags = ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoBackground
| ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoSavedSettings
| ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.NoBringToFrontOnFocus
| ImGuiWindowFlags.NoNav;
string overlayId = $"HSUI_CrossBar{Config.HotbarIndex}_input";
ImGui.SetCursorScreenPos(topLeft);
if (!ImGui.BeginChild(overlayId, barSize, false, flags))
{
ImGui.EndChild();
return;
}
int frame = ImGui.GetFrameCount();
if (frame != ActionBarsHud._lastOverlayFrame)
{
ActionBarsHud._lastOverlayFrame = frame;
ActionBarsHud._anyHotbarAcceptedDrop = false;
if (ActionBarsHud._pendingReleaseOutsideSlotId >= 0 && ActionBarsHud.GetHotbarsConfig()?.GeneralOptions?.EnableReleaseOutsideToClear != false)
{
var (bar, slot) = ActionBarsHud.FromSlotId(ActionBarsHud._pendingReleaseOutsideSlotId);
if (bar >= 1 && bar <= 8 && slot >= 0 && slot < ControllerBarConfig.CrossSlotCount)
ActionBarsManager.Instance?.ClearControllerSlot(bar, slot);
ActionBarsHud._pendingReleaseOutsideSlotId = -1;
}
}
bool acceptedDrop = false;
for (int i = 0; i < ControllerBarConfig.CrossSlotCount; i++)
{
var slotOffset = GetSlotPositionCross(i, slotSize, pad);
var slotPos = topLeft + slotOffset;
ImGui.SetCursorScreenPos(slotPos);
bool clicked = ImGui.InvisibleButton($"##{overlayId}_slot_{i}", slotSize);
bool isHovered = ImGui.IsItemHovered();
bool isActive = ImGui.IsItemActive();
bool shiftHeld = ImGui.GetIO().KeyShift;
int targetSlotId = ActionBarsHud.ToSlotId(Config.HotbarIndex, i);
if (ActionBarsHud.GetHotbarsConfig()?.GeneralOptions?.EnableShiftDragToRearrange != false && ImGui.BeginDragDropTarget())
{
var payload = ImGui.AcceptDragDropPayload(ActionBarsHud.SlotPayloadType);
if (payload != ImGuiPayloadPtr.Null && payload.IsDataType(ActionBarsHud.SlotPayloadType) && ActionBarsHud.TryGetSlotPayload(payload, out var src))
{
acceptedDrop = true;
ActionBarsHud._anyHotbarAcceptedDrop = true;
ActionBarsHud._imGuiDragSourceSlotId = -1;
ActionBarsHud._pendingReleaseOutsideSlotId = -1;
ActionBarsHud._lastLoggedPickupSlotId = -1;
if (src.SlotId != targetSlotId)
{
var (srcBar, srcSlot) = ActionBarsHud.FromSlotId(src.SlotId);
if (srcBar >= 1 && srcSlot >= 0 && srcBar <= 8 && srcSlot < ControllerBarConfig.CrossSlotCount)
ActionBarsManager.Instance?.SwapControllerSlots(srcBar, srcSlot, Config.HotbarIndex, i);
}
}
ImGui.EndDragDropTarget();
}
if (ActionBarsHud.GetHotbarsConfig()?.GeneralOptions?.EnableShiftDragToRearrange != false && !ActionBarsHud.IsGameDragging() && shiftHeld && i < slots.Count && !slots[i].IsEmpty && isActive && ImGui.IsMouseDragging(ImGuiMouseButton.Left))
{
if (ImGui.BeginDragDropSource(ImGuiDragDropFlags.None))
{
ActionBarsHud._imGuiDragSourceSlotId = targetSlotId;
ActionBarsHud.SetSlotPayload(new ActionBarsHud.SlotDragPayload { SlotId = targetSlotId });
var icon = TexturesHelper.GetTextureFromIconId(slots[i].IconId);
if (icon != null)
ImGui.Image(icon.Handle, new Vector2(32, 32));
ImGui.EndDragDropSource();
}
}
if (clicked && !acceptedDrop && !ImGui.IsMouseDragging(ImGuiMouseButton.Left) && i < slots.Count && !slots[i].IsEmpty)
{
if (_suppressSlotClicksAfterDragRelease || i == _lastFrameDroppedOnSlot)
{
_suppressSlotClicksAfterDragRelease = false;
_lastFrameDroppedOnSlot = -1;
}
else if (shiftHeld)
{
ActionBarsManager.Instance?.ClearControllerSlot(Config.HotbarIndex, i);
}
else
{
ActionBarsManager.Instance?.ExecuteControllerSlot(Config.HotbarIndex, i);
}
}
if (Config.ShowTooltips && isHovered && i < slots.Count)
{
var slot = slots[i];
if (!slot.IsEmpty)
{
var (title, text) = ActionBarsHud.GetSlotTooltipPublic(slot);
if (!string.IsNullOrEmpty(title) || !string.IsNullOrEmpty(text))
{
string body = string.IsNullOrEmpty(text) ? title : text;
var tooltipConfig = ConfigurationManager.Instance?.GetConfigObject<TooltipsConfig>();
var formatted = tooltipConfig != null ? TooltipsHelper.BuildFormattedActionTooltipBody(body, tooltipConfig) : null;
TooltipsHelper.Instance.ShowTooltipOnCursor(body, title, slot.ActionId, "", slot.IconId > 0 ? slot.IconId : null, TooltipIdKind.Action, formatted);
}
}
}
}
if (ActionBarsHud.GetHotbarsConfig()?.GeneralOptions?.EnableReleaseOutsideToClear != false && ImGui.GetIO().KeyShift && ActionBarsHud._imGuiDragSourceSlotId >= 0 &&
ImGui.IsMouseReleased(ImGuiMouseButton.Left) && !ActionBarsHud._anyHotbarAcceptedDrop)
{
ActionBarsHud._pendingReleaseOutsideSlotId = ActionBarsHud._imGuiDragSourceSlotId;
ActionBarsHud._imGuiDragSourceSlotId = -1;
ActionBarsHud._lastLoggedPickupSlotId = -1;
}
ImGui.EndChild();
});
}
private static bool IsComboNextAction(ActionBarsManager.SlotInfo slot)
{
if (slot.IsEmpty || slot.ActionId == 0) return false;
try
{
var slotType = slot.SlotType;
var actionType = slotType switch
{
RaptureHotbarModule.HotbarSlotType.Action => ActionType.Action,
RaptureHotbarModule.HotbarSlotType.GeneralAction => ActionType.GeneralAction,
RaptureHotbarModule.HotbarSlotType.CraftAction => ActionType.CraftAction,
RaptureHotbarModule.HotbarSlotType.PetAction => ActionType.PetAction,
_ => (ActionType?)null
};
if (!actionType.HasValue) return false;
return SpellHelper.Instance.IsActionHighlighted(slot.ActionId, actionType.Value);
}
catch { return false; }
}
}
}