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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user