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
+18
View File
@@ -45,6 +45,7 @@ namespace HSUI.Config
private MainConfigWindow _mainConfigWindow;
private ChangelogWindow _changelogWindow;
private GridWindow _gridWindow;
private ControllerBarKeybindsWindow _controllerBarKeybindsWindow;
public bool IsConfigWindowOpened => _mainConfigWindow.IsOpen;
public bool IsChangelogWindowOpened => _changelogWindow.IsOpen;
@@ -145,11 +146,13 @@ namespace HSUI.Config
string changelog = LoadChangelog();
_changelogWindow = new ChangelogWindow("HSUI Changelog v" + Plugin.Version, changelog);
_gridWindow = new GridWindow("Grid ##HSUI");
_controllerBarKeybindsWindow = new ControllerBarKeybindsWindow();
_windowSystem = new WindowSystem("HSUI_Windows");
_windowSystem.AddWindow(_mainConfigWindow);
_windowSystem.AddWindow(_changelogWindow);
_windowSystem.AddWindow(_gridWindow);
_windowSystem.AddWindow(_controllerBarKeybindsWindow);
CheckVersion();
@@ -336,6 +339,11 @@ namespace HSUI.Config
_changelogWindow.IsOpen = true;
}
public void OpenControllerBarKeybindsWindow()
{
_controllerBarKeybindsWindow.IsOpen = true;
}
public void Draw()
{
_windowSystem.Draw();
@@ -687,6 +695,16 @@ namespace HSUI.Config
typeof(Hotbar8BarConfig),
typeof(Hotbar9BarConfig),
typeof(Hotbar10BarConfig),
typeof(ControllerHotbarsConfig),
typeof(ControllerBar1Config),
typeof(ControllerBar2Config),
typeof(ControllerBar3Config),
typeof(ControllerBar4Config),
typeof(ControllerBar5Config),
typeof(ControllerBar6Config),
typeof(ControllerBar7Config),
typeof(ControllerBar8Config),
typeof(ControllerBarKeybindsConfig),
typeof(PullTimerConfig),
typeof(LimitBreakConfig),
typeof(MPTickerConfig),
@@ -0,0 +1,245 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using Dalamud.Game.ClientState.GamePad;
using Dalamud.Game.ClientState.Keys;
using Dalamud.Interface.Windowing;
using HSUI.Config;
using HSUI.Interface.GeneralElements;
using Dalamud.Bindings.ImGui;
namespace HSUI.Config.Windows
{
public class ControllerBarKeybindsWindow : Window
{
private ControllerBarKeybindsConfig? _config;
private int _selectedBar = 1;
private int _listeningBar = -1;
private int _listeningSlot = -1;
private HashSet<VirtualKey>? _listeningLastKeys;
private HashSet<GamepadButtons>? _listeningLastPads;
private const int SlotsPerBar = 8;
private static readonly GamepadButtons[] ModifierButtons = { GamepadButtons.L1, GamepadButtons.L2, GamepadButtons.R1, GamepadButtons.R2 };
private static readonly VirtualKey[] ModifierKeys = { VirtualKey.LSHIFT, VirtualKey.RSHIFT, VirtualKey.LCONTROL, VirtualKey.RCONTROL, VirtualKey.LMENU, VirtualKey.RMENU };
private const float CellSize = 44;
private const float Pad = 4;
private const float Gap = 8;
public ControllerBarKeybindsWindow() : base("Controller bar keybinds")
{
Size = new Vector2(520, 380);
SizeCondition = ImGuiCond.FirstUseEver;
Flags = ImGuiWindowFlags.NoScrollbar;
}
public override void Draw()
{
_config = ConfigurationManager.Instance?.GetConfigObject<ControllerBarKeybindsConfig>();
if (_config == null) return;
ImGui.Text("Assign keyboard or gamepad buttons to controller cross bar slots.");
ImGui.Spacing();
// Bar selector
ImGui.Text("Cross bar:");
ImGui.SameLine(100);
for (int b = 1; b <= 8; b++)
{
if (b > 1) ImGui.SameLine();
if (ImGui.Button($"{(b == _selectedBar ? "[" : " ")}{b}{(b == _selectedBar ? "]" : " ")}", new Vector2(32, 0)))
_selectedBar = b;
}
ImGui.Spacing();
ImGui.Separator();
ImGui.Spacing();
if (_listeningBar >= 0 && _listeningSlot >= 0)
{
ImGui.TextColored(new Vector4(1, 0.8f, 0, 1), "Hold trigger (L2/R2/L1/R1) or modifier (Shift/Ctrl/Alt), then press a key or button. Or press a key/button alone.");
ImGui.SameLine();
if (ImGui.Button("Cancel"))
{
_listeningBar = -1;
_listeningSlot = -1;
_listeningLastKeys = null;
_listeningLastPads = null;
}
PollAndAssign();
}
else
{
ImGui.Text($"Bar {_selectedBar} — click a slot, then hold modifiers (e.g. L2) and press a button. Right-click to clear.");
}
ImGui.Spacing();
// Two crosses side by side (same layout as CrossBarHud)
float leftCrossW = 3 * (CellSize + Pad);
float startX = ImGui.GetCursorScreenPos().X;
float startY = ImGui.GetCursorScreenPos().Y;
for (int slot = 0; slot < SlotsPerBar; slot++)
{
(int col, int row) = GetCrossSlotLayout(slot);
float x = startX + (slot < 4 ? col * (CellSize + Pad) : leftCrossW + Gap + col * (CellSize + Pad));
float y = startY + row * (CellSize + Pad);
ImGui.SetCursorScreenPos(new Vector2(x, y));
string current = _config.GetKeybind(_selectedBar, slot);
string label = string.IsNullOrEmpty(current) ? "None" : GetKeybindDisplayName(current);
bool listening = _listeningBar == _selectedBar && _listeningSlot == slot;
if (listening)
ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(0.4f, 0.6f, 0.2f, 0.8f));
if (ImGui.Button($"##bar{_selectedBar}_slot{slot}", new Vector2(CellSize, CellSize)))
{
_listeningBar = _selectedBar;
_listeningSlot = slot;
}
if (listening) ImGui.PopStyleColor();
if (ImGui.IsItemHovered())
{
if (ImGui.IsMouseClicked(ImGuiMouseButton.Right))
{
_config.SetKeybind(_selectedBar, slot, "");
ConfigurationManager.Instance?.SaveConfigurations();
}
}
string shortLabel = label.Length > 6 ? label.Substring(0, 6) + "…" : label;
Vector2 textSize = ImGui.CalcTextSize(shortLabel);
ImGui.SetCursorScreenPos(new Vector2(x + (CellSize - textSize.X) * 0.5f, y + (CellSize - textSize.Y) * 0.5f));
ImGui.Text(shortLabel);
}
ImGui.SetCursorScreenPos(new Vector2(startX, startY + 3 * (CellSize + Pad) + 20));
ImGui.Spacing();
if (ImGui.Button("Close"))
IsOpen = false;
}
private static (int col, int row) GetCrossSlotLayout(int slotIndex)
{
if (slotIndex < 4)
return slotIndex switch { 0 => (1, 0), 1 => (0, 1), 2 => (2, 1), _ => (1, 2) };
int i = slotIndex - 4;
return i switch { 0 => (1, 0), 1 => (0, 1), 2 => (2, 1), _ => (1, 2) };
}
private void PollAndAssign()
{
var currentKeys = new HashSet<VirtualKey>();
var currentPads = new HashSet<GamepadButtons>();
if (Plugin.KeyState != null)
{
foreach (var vk in Plugin.KeyState.GetValidVirtualKeys())
{
if (vk == VirtualKey.ESCAPE) continue;
if (Plugin.KeyState[vk])
currentKeys.Add(vk);
}
}
if (Plugin.GamepadState != null)
{
foreach (var btn in GetGamepadButtonsToPoll())
{
if (Plugin.GamepadState.Raw(btn) > 0.5f)
currentPads.Add(btn);
}
}
// First frame of listening: record state as "last" and wait for next frame
if (_listeningLastKeys == null)
{
_listeningLastKeys = new HashSet<VirtualKey>(currentKeys);
_listeningLastPads = new HashSet<GamepadButtons>(currentPads);
return;
}
var modifierKeySet = new HashSet<VirtualKey>(ModifierKeys);
var modifierPadSet = new HashSet<GamepadButtons>(ModifierButtons);
var heldModifierKeys = new HashSet<VirtualKey>(currentKeys.Where(k => modifierKeySet.Contains(k) && _listeningLastKeys.Contains(k)));
var heldModifierPads = new HashSet<GamepadButtons>(currentPads.Where(b => modifierPadSet.Contains(b) && _listeningLastPads!.Contains(b)));
string? triggerPart = null;
foreach (var vk in Plugin.KeyState?.GetValidVirtualKeys() ?? Array.Empty<VirtualKey>())
{
if (vk == VirtualKey.ESCAPE) continue;
if (currentKeys.Contains(vk) && !_listeningLastKeys.Contains(vk))
{
triggerPart = "Key:" + vk;
break;
}
}
if (triggerPart == null && Plugin.GamepadState != null)
{
foreach (var btn in GetGamepadButtonsToPoll())
{
if (currentPads.Contains(btn) && !_listeningLastPads!.Contains(btn))
{
triggerPart = "Pad:" + btn;
break;
}
}
}
_listeningLastKeys = new HashSet<VirtualKey>(currentKeys);
_listeningLastPads = new HashSet<GamepadButtons>(currentPads);
if (triggerPart == null) return;
var parts = new List<string>();
foreach (var vk in heldModifierKeys.OrderBy(k => k.ToString()))
parts.Add("Key:" + vk);
foreach (var btn in heldModifierPads.OrderBy(b => b.ToString()))
parts.Add("Pad:" + btn);
parts.Add(triggerPart);
string assigned = string.Join("+", parts);
if (_listeningBar >= 0 && _listeningSlot >= 0 && _config != null)
{
_config.SetKeybind(_listeningBar, _listeningSlot, assigned);
ConfigurationManager.Instance?.SaveConfigurations();
_listeningBar = -1;
_listeningSlot = -1;
_listeningLastKeys = null;
_listeningLastPads = null;
}
}
private static IEnumerable<GamepadButtons> GetGamepadButtonsToPoll()
{
return new[]
{
GamepadButtons.DpadUp, GamepadButtons.DpadDown, GamepadButtons.DpadLeft, GamepadButtons.DpadRight,
GamepadButtons.North, GamepadButtons.South, GamepadButtons.West, GamepadButtons.East,
GamepadButtons.L1, GamepadButtons.L2, GamepadButtons.L3,
GamepadButtons.R1, GamepadButtons.R2, GamepadButtons.R3,
GamepadButtons.Start, GamepadButtons.Select
};
}
private static string GetKeybindDisplayName(string value)
{
if (string.IsNullOrEmpty(value)) return "None";
var parts = value.Split('+');
var display = new List<string>();
foreach (var p in parts)
{
string s = p.Trim();
if (s.StartsWith("Key:", StringComparison.OrdinalIgnoreCase))
display.Add(s.Length > 4 ? s.Substring(4).Replace("VK_", "") : s);
else if (s.StartsWith("Pad:", StringComparison.OrdinalIgnoreCase))
display.Add(s.Length > 4 ? s.Substring(4) : s);
else
display.Add(s);
}
return string.Join(" + ", display);
}
}
}