Files
HellionChat/HellionChat/GameFunctions/KeybindManager.cs
T
JonKazama-Hellion 6f71b09331 fix(closeout): address closure-review findings
- gate keybind pill-sync on IsChannelOrExistingLinkshell so an empty
  linkshell slot no longer desyncs the pill from the real send channel
- close manually-popped pop-out windows on logout via an IsOpen filter
  instead of the PopOut flag (which manual pops never set)
- read the router's tell-tab lookup through a lock-wrapped accessor so the
  framework thread cannot enumerate Config.Tabs mid worker-thread mutation
- add a "switch on every tell" toggle (default on) and make the auto-open
  mode pick the matching layout, so Sidebar vs Top-tab are distinct
- comment corrections (stale/contradictory text, TEST-MIRROR path depth)
2026-06-16 09:04:18 +02:00

576 lines
21 KiB
C#

using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Game.ClientState.Keys;
using Dalamud.Game.Config;
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.System.String;
using FFXIVClientStructs.FFXIV.Client.UI;
using HellionChat.Code;
using HellionChat.GameFunctions.Types;
using HellionChat.Util;
using Microsoft.Extensions.Logging;
using ModifierFlag = HellionChat.GameFunctions.Types.ModifierFlag;
namespace HellionChat.GameFunctions;
internal enum KeyboardSource
{
Game,
ImGui,
}
internal unsafe class KeybindManager : IDisposable
{
private Plugin Plugin { get; }
internal bool DirectChat;
private long LastRefresh;
private bool VanillaTextInputHasFocus;
private readonly Dictionary<string, Keybind> Keybinds = new();
private static readonly IReadOnlyDictionary<string, ChannelSwitchInfo> KeybindsToIntercept =
new Dictionary<string, ChannelSwitchInfo>
{
["CMD_CHAT"] = new(null),
["CMD_COMMAND"] = new(null, text: "/"),
["CMD_REPLY"] = new(InputChannel.Tell, rotate: RotateMode.Forward),
["CMD_REPLY_REV"] = new(InputChannel.Tell, rotate: RotateMode.Reverse),
["CMD_SAY"] = new(InputChannel.Say),
["CMD_YELL"] = new(InputChannel.Yell),
["CMD_SHOUT"] = new(InputChannel.Shout),
["CMD_PARTY"] = new(InputChannel.Party),
["CMD_ALLIANCE"] = new(InputChannel.Alliance),
["CMD_FREECOM"] = new(InputChannel.FreeCompany),
["PVPTEAM_CHAT"] = new(InputChannel.PvpTeam),
["CMD_CWLINKSHELL"] = new(InputChannel.CrossLinkshell1, rotate: RotateMode.Forward),
["CMD_CWLINKSHELL_REV"] = new(InputChannel.CrossLinkshell1, rotate: RotateMode.Reverse),
["CMD_CWLINKSHELL_1"] = new(InputChannel.CrossLinkshell1),
["CMD_CWLINKSHELL_2"] = new(InputChannel.CrossLinkshell2),
["CMD_CWLINKSHELL_3"] = new(InputChannel.CrossLinkshell3),
["CMD_CWLINKSHELL_4"] = new(InputChannel.CrossLinkshell4),
["CMD_CWLINKSHELL_5"] = new(InputChannel.CrossLinkshell5),
["CMD_CWLINKSHELL_6"] = new(InputChannel.CrossLinkshell6),
["CMD_CWLINKSHELL_7"] = new(InputChannel.CrossLinkshell7),
["CMD_CWLINKSHELL_8"] = new(InputChannel.CrossLinkshell8),
["CMD_LINKSHELL"] = new(InputChannel.Linkshell1, rotate: RotateMode.Forward),
["CMD_LINKSHELL_REV"] = new(InputChannel.Linkshell1, rotate: RotateMode.Reverse),
["CMD_LINKSHELL_1"] = new(InputChannel.Linkshell1),
["CMD_LINKSHELL_2"] = new(InputChannel.Linkshell2),
["CMD_LINKSHELL_3"] = new(InputChannel.Linkshell3),
["CMD_LINKSHELL_4"] = new(InputChannel.Linkshell4),
["CMD_LINKSHELL_5"] = new(InputChannel.Linkshell5),
["CMD_LINKSHELL_6"] = new(InputChannel.Linkshell6),
["CMD_LINKSHELL_7"] = new(InputChannel.Linkshell7),
["CMD_LINKSHELL_8"] = new(InputChannel.Linkshell8),
["CMD_BEGINNER"] = new(InputChannel.NoviceNetwork),
["CMD_REPLY_ALWAYS"] = new(InputChannel.Tell, true, RotateMode.Forward),
["CMD_REPLY_REV_ALWAYS"] = new(InputChannel.Tell, true, RotateMode.Reverse),
["CMD_SAY_ALWAYS"] = new(InputChannel.Say, true),
["CMD_YELL_ALWAYS"] = new(InputChannel.Yell, true),
["CMD_PARTY_ALWAYS"] = new(InputChannel.Party, true),
["CMD_ALLIANCE_ALWAYS"] = new(InputChannel.Alliance, true),
["CMD_FREECOM_ALWAYS"] = new(InputChannel.FreeCompany, true),
["PVPTEAM_CHAT_ALWAYS"] = new(InputChannel.PvpTeam, true),
["CMD_CWLINKSHELL_ALWAYS"] = new(
InputChannel.CrossLinkshell1,
true,
RotateMode.Forward
),
["CMD_CWLINKSHELL_ALWAYS_REV"] = new(
InputChannel.CrossLinkshell1,
true,
RotateMode.Reverse
),
["CMD_CWLINKSHELL_1_ALWAYS"] = new(InputChannel.CrossLinkshell1, true),
["CMD_CWLINKSHELL_2_ALWAYS"] = new(InputChannel.CrossLinkshell2, true),
["CMD_CWLINKSHELL_3_ALWAYS"] = new(InputChannel.CrossLinkshell3, true),
["CMD_CWLINKSHELL_4_ALWAYS"] = new(InputChannel.CrossLinkshell4, true),
["CMD_CWLINKSHELL_5_ALWAYS"] = new(InputChannel.CrossLinkshell5, true),
["CMD_CWLINKSHELL_6_ALWAYS"] = new(InputChannel.CrossLinkshell6, true),
["CMD_CWLINKSHELL_7_ALWAYS"] = new(InputChannel.CrossLinkshell7, true),
["CMD_CWLINKSHELL_8_ALWAYS"] = new(InputChannel.CrossLinkshell8, true),
["CMD_LINKSHELL_ALWAYS"] = new(InputChannel.Linkshell1, true, RotateMode.Forward),
["CMD_LINKSHELL_REV_ALWAYS"] = new(InputChannel.Linkshell1, true, RotateMode.Reverse),
["CMD_LINKSHELL_1_ALWAYS"] = new(InputChannel.Linkshell1, true),
["CMD_LINKSHELL_2_ALWAYS"] = new(InputChannel.Linkshell2, true),
["CMD_LINKSHELL_3_ALWAYS"] = new(InputChannel.Linkshell3, true),
["CMD_LINKSHELL_4_ALWAYS"] = new(InputChannel.Linkshell4, true),
["CMD_LINKSHELL_5_ALWAYS"] = new(InputChannel.Linkshell5, true),
["CMD_LINKSHELL_6_ALWAYS"] = new(InputChannel.Linkshell6, true),
["CMD_LINKSHELL_7_ALWAYS"] = new(InputChannel.Linkshell7, true),
["CMD_LINKSHELL_8_ALWAYS"] = new(InputChannel.Linkshell8, true),
["CMD_BEGINNER_ALWAYS"] = new(InputChannel.NoviceNetwork, true),
};
// List of keys that can be used as a part of keybinds while the chat is
// focused WITHOUT modifiers. All other keys can only be used if their
// configured keybind contains modifiers (except only SHIFT). This allows
// for using e.g. F11 to change chat channel while typing.
private static readonly IReadOnlyCollection<VirtualKey> ModifierlessChatKeys = new[]
{
// VirtualKey.NO_KEY,
// VirtualKey.LBUTTON,
// VirtualKey.RBUTTON,
// VirtualKey.CANCEL,
// VirtualKey.MBUTTON,
// VirtualKey.XBUTTON1,
// VirtualKey.XBUTTON2,
// VirtualKey.BACK,
// VirtualKey.TAB, // handled by ChatLogWindow
// VirtualKey.CLEAR,
// VirtualKey.RETURN, // handled by imgui
// VirtualKey.SHIFT,
// VirtualKey.CONTROL,
// VirtualKey.MENU,
VirtualKey.PAUSE,
// VirtualKey.CAPITAL,
// VirtualKey.KANA,
// VirtualKey.HANGUL,
// VirtualKey.JUNJA,
// VirtualKey.FINAL,
// VirtualKey.HANJA,
// VirtualKey.KANJI,
VirtualKey.ESCAPE,
// VirtualKey.CONVERT,
// VirtualKey.NONCONVERT,
// VirtualKey.ACCEPT,
// VirtualKey.MODECHANGE,
// VirtualKey.SPACE,
VirtualKey.PRIOR,
VirtualKey.NEXT,
// VirtualKey.END,
// VirtualKey.HOME,
// VirtualKey.LEFT, // handled by imgui
// VirtualKey.UP, // handled by ChatLogWindow
// VirtualKey.RIGHT, // handled by imgui
// VirtualKey.DOWN, // handled by ChatLogWindow
// VirtualKey.SELECT,
VirtualKey.PRINT,
VirtualKey.EXECUTE,
VirtualKey.SNAPSHOT,
// VirtualKey.INSERT,
// VirtualKey.DELETE,
VirtualKey.HELP,
// VirtualKey.KEY_0,
// VirtualKey.KEY_1,
// VirtualKey.KEY_2,
// VirtualKey.KEY_3,
// VirtualKey.KEY_4,
// VirtualKey.KEY_5,
// VirtualKey.KEY_6,
// VirtualKey.KEY_7,
// VirtualKey.KEY_8,
// VirtualKey.KEY_9,
// VirtualKey.A,
// VirtualKey.B,
// VirtualKey.C,
// VirtualKey.D,
// VirtualKey.E,
// VirtualKey.F,
// VirtualKey.G,
// VirtualKey.H,
// VirtualKey.I,
// VirtualKey.J,
// VirtualKey.K,
// VirtualKey.L,
// VirtualKey.M,
// VirtualKey.N,
// VirtualKey.O,
// VirtualKey.P,
// VirtualKey.Q,
// VirtualKey.R,
// VirtualKey.S,
// VirtualKey.T,
// VirtualKey.U,
// VirtualKey.V,
// VirtualKey.W,
// VirtualKey.X,
// VirtualKey.Y,
// VirtualKey.Z,
// VirtualKey.LWIN,
// VirtualKey.RWIN,
VirtualKey.APPS,
VirtualKey.SLEEP,
// VirtualKey.NUMPAD0,
// VirtualKey.NUMPAD1,
// VirtualKey.NUMPAD2,
// VirtualKey.NUMPAD3,
// VirtualKey.NUMPAD4,
// VirtualKey.NUMPAD5,
// VirtualKey.NUMPAD6,
// VirtualKey.NUMPAD7,
// VirtualKey.NUMPAD8,
// VirtualKey.NUMPAD9,
// VirtualKey.MULTIPLY,
// VirtualKey.ADD,
// VirtualKey.SEPARATOR,
// VirtualKey.SUBTRACT,
// VirtualKey.DECIMAL,
// VirtualKey.DIVIDE,
VirtualKey.F1,
VirtualKey.F2,
VirtualKey.F3,
VirtualKey.F4,
VirtualKey.F5,
VirtualKey.F6,
VirtualKey.F7,
VirtualKey.F8,
VirtualKey.F9,
VirtualKey.F10,
VirtualKey.F11,
VirtualKey.F12,
VirtualKey.F13,
VirtualKey.F14,
VirtualKey.F15,
VirtualKey.F16,
VirtualKey.F17,
VirtualKey.F18,
VirtualKey.F19,
VirtualKey.F20,
VirtualKey.F21,
VirtualKey.F22,
VirtualKey.F23,
VirtualKey.F24,
// VirtualKey.NUMLOCK,
// VirtualKey.SCROLL,
// VirtualKey.OEM_FJ_JISHO,
// VirtualKey.OEM_NEC_EQUAL,
// VirtualKey.OEM_FJ_MASSHOU,
// VirtualKey.OEM_FJ_TOUROKU,
// VirtualKey.OEM_FJ_LOYA,
// VirtualKey.OEM_FJ_ROYA,
// VirtualKey.LSHIFT,
// VirtualKey.RSHIFT,
// VirtualKey.LCONTROL,
// VirtualKey.RCONTROL,
// VirtualKey.LMENU,
// VirtualKey.RMENU,
VirtualKey.BROWSER_BACK,
VirtualKey.BROWSER_FORWARD,
VirtualKey.BROWSER_REFRESH,
VirtualKey.BROWSER_STOP,
VirtualKey.BROWSER_SEARCH,
VirtualKey.BROWSER_FAVORITES,
VirtualKey.BROWSER_HOME,
VirtualKey.VOLUME_MUTE,
VirtualKey.VOLUME_DOWN,
VirtualKey.VOLUME_UP,
VirtualKey.MEDIA_NEXT_TRACK,
VirtualKey.MEDIA_PREV_TRACK,
VirtualKey.MEDIA_STOP,
VirtualKey.MEDIA_PLAY_PAUSE,
VirtualKey.LAUNCH_MAIL,
VirtualKey.LAUNCH_MEDIA_SELECT,
VirtualKey.LAUNCH_APP1,
VirtualKey.LAUNCH_APP2,
// VirtualKey.OEM_1,
// VirtualKey.OEM_PLUS,
// VirtualKey.OEM_COMMA,
// VirtualKey.OEM_MINUS,
// VirtualKey.OEM_PERIOD,
// VirtualKey.OEM_2,
// VirtualKey.OEM_3,
// VirtualKey.OEM_4, // [{
// VirtualKey.OEM_5, // \"
// VirtualKey.OEM_6, // ]}
// VirtualKey.OEM_7, // '"
// VirtualKey.OEM_8,
// VirtualKey.OEM_AX,
// VirtualKey.OEM_102,
// VirtualKey.ICO_HELP,
// VirtualKey.ICO_00,
// VirtualKey.PROCESSKEY,
// VirtualKey.ICO_CLEAR,
// VirtualKey.PACKET,
// VirtualKey.OEM_RESET,
// VirtualKey.OEM_JUMP,
// VirtualKey.OEM_PA1,
// VirtualKey.OEM_PA2,
// VirtualKey.OEM_PA3,
// VirtualKey.OEM_WSCTRL,
// VirtualKey.OEM_CUSEL,
// VirtualKey.OEM_ATTN,
// VirtualKey.OEM_FINISH,
// VirtualKey.OEM_COPY,
// VirtualKey.OEM_AUTO,
// VirtualKey.OEM_ENLW,
// VirtualKey.OEM_BACKTAB,
// VirtualKey.ATTN,
// VirtualKey.CRSEL,
// VirtualKey.EXSEL,
// VirtualKey.EREOF,
// VirtualKey.PLAY,
// VirtualKey.ZOOM,
// VirtualKey.NONAME,
// VirtualKey.PA1,
// VirtualKey.OEM_CLEAR,
};
private readonly ILogger<KeybindManager> _logger;
internal KeybindManager(Plugin plugin, ILogger<KeybindManager> logger)
{
Plugin = plugin;
_logger = logger;
Plugin.GameInteropProvider.InitializeFromAttributes(this);
// Handle keybinds from the game on every tick.
Plugin.Framework.Update += HandleKeybinds;
}
public void Dispose()
{
Plugin.Framework.Update -= HandleKeybinds;
}
private void UpdateKeybinds()
{
foreach (var name in KeybindsToIntercept.Keys)
Keybinds[name] = GetKeybind(name);
}
private static ModifierFlag GetModifiers(KeyboardSource source)
{
var modifierState = ModifierFlag.None;
if (source == KeyboardSource.Game)
{
if (Plugin.KeyState[VirtualKey.MENU])
modifierState |= ModifierFlag.Alt;
if (Plugin.KeyState[VirtualKey.CONTROL])
modifierState |= ModifierFlag.Ctrl;
if (Plugin.KeyState[VirtualKey.SHIFT])
modifierState |= ModifierFlag.Shift;
return modifierState;
}
if (ImGui.GetIO().KeyAlt)
modifierState |= ModifierFlag.Alt;
if (ImGui.GetIO().KeyCtrl)
modifierState |= ModifierFlag.Ctrl;
if (ImGui.GetIO().KeyShift)
modifierState |= ModifierFlag.Shift;
return modifierState;
}
private static bool KeyPressed(KeyboardSource source, VirtualKey key)
{
if (key == VirtualKey.NO_KEY)
return false;
if (!Plugin.KeyState.IsVirtualKeyValid(key))
return false;
if (source == KeyboardSource.Game)
return Plugin.KeyState[key];
return key.TryToImGui(out var imguiKey) && ImGui.IsKeyPressed(imguiKey);
}
private static bool ComboPressed(
KeyboardSource source,
VirtualKey key,
ModifierFlag modifier,
ModifierFlag? modifierState = null,
bool modifiersOnly = false
)
{
// When we're in an input, we don't want to process any keybinds that
// don't have a modifier (or only use shift) and are not explicitly
// whitelisted.
if (
modifiersOnly
&& !ModifierlessChatKeys.Contains(key)
&& modifier is ModifierFlag.None or ModifierFlag.Shift
)
return false;
modifierState ??= GetModifiers(source);
var modifierPressed = Plugin.Config.KeybindMode switch
{
KeybindMode.Strict => modifier == modifierState.Value,
KeybindMode.Flexible => modifierState.Value.HasFlag(modifier),
_ => false,
};
return KeyPressed(source, key) && modifierPressed;
}
private static bool ConfigKeybindPressed(
KeyboardSource source,
ConfigKeyBind? bind,
ModifierFlag? modifierState = null,
bool modifiersOnly = false
)
{
return bind != null
&& ComboPressed(
source,
bind.Key,
bind.Modifier,
modifierState: modifierState,
modifiersOnly: modifiersOnly
);
}
private void HandleKeybinds(IFramework _) => HandleKeybinds(KeyboardSource.Game);
internal void HandleKeybinds(
KeyboardSource source,
bool ignoreChatOpen = false,
bool modifiersOnly = false
)
{
// Refresh current keybinds every 5s
if (LastRefresh + 5 * 1000 < Environment.TickCount64)
{
UpdateKeybinds();
DirectChat =
Plugin.GameConfig.TryGet(UiControlOption.DirectChat, out bool option) && option;
LastRefresh = Environment.TickCount64;
}
// Vanilla text input has focus
if (RaptureAtkModule.Instance()->AtkModule.IsTextInputActive())
{
VanillaTextInputHasFocus = true;
return;
}
// If the vanilla text input has just lost focus, clear all non-modifier
// keys so we don't try to process them immediately on the next frame.
if (VanillaTextInputHasFocus)
{
foreach (var key in Plugin.KeyState.GetValidVirtualKeys())
if (key is not VirtualKey.CONTROL and not VirtualKey.SHIFT and not VirtualKey.MENU)
Plugin.KeyState[key] = false;
VanillaTextInputHasFocus = false;
return;
}
var modifierState = GetModifiers(source);
// Test for custom keybinds for changing chat tabs before checking
// vanilla keybinds.
if (ConfigKeybindPressed(source, Plugin.Config.ChatTabForward))
{
Plugin.KeyState[Plugin.Config.ChatTabForward!.Key] = false;
DispatchTabDelta(1);
return;
}
if (ConfigKeybindPressed(source, Plugin.Config.ChatTabBackward))
{
Plugin.KeyState[Plugin.Config.ChatTabBackward!.Key] = false;
DispatchTabDelta(-1);
return;
}
// Only process the active combo with the most modifiers.
var currentBest = (VirtualKey.NO_KEY, "", 0);
foreach (var (toIntercept, keybind) in Keybinds)
{
if (toIntercept is "CMD_CHAT" or "CMD_COMMAND" && (ignoreChatOpen || DirectChat))
continue;
void Intercept(VirtualKey vk, ModifierFlag modifier)
{
if (
!ComboPressed(
source,
vk,
modifier,
modifierState: modifierState,
modifiersOnly: modifiersOnly
)
)
return;
var bits = BitOperations.PopCount((uint)modifier);
if (bits < currentBest.Item3)
return;
currentBest = (vk, toIntercept, bits);
}
Intercept(keybind.Key1, keybind.Modifier1);
Intercept(keybind.Key2, keybind.Modifier2);
}
if (currentBest.Item1 == VirtualKey.NO_KEY)
return;
Plugin.KeyState[currentBest.Item1] = false;
if (!KeybindsToIntercept.TryGetValue(currentBest.Item2, out var info))
return;
// Re-surface the chat-activation entry point retired in v1.6.0: a chat-open
// keybind shows + focuses the window, restoring it from a user-hide or a
// closed state.
Plugin.Instance.MainWindow?.ActivateChat();
// Direct channel-switch binds (CMD_SAY/PARTY/numbered linkshells/…): switch the
// game channel AND mirror it onto the active tab so the input pill shows the
// real send target (pill-sync, Flo decision 2026-06-15). Rotation binds (REPLY /
// linkshell-cycle, Rotate != None) are skipped; the temp-vs-permanent distinction
// (v1.5.6's UseTempChannel / info.Permanent) collapses to one permanent-style
// switch here — restoring it is the keybind-routing follow-cycle.
if (info.Channel is { } channel && info.Rotate == RotateMode.None)
{
Plugin.Instance.Functions.Chat.SetChannel(channel);
// Only mirror onto the tab when the game actually accepted the switch — an
// empty linkshell slot leaves the game channel untouched, so the pill must
// stay put rather than show a target the game will not send to.
if (
Chat.IsChannelOrExistingLinkshell(channel)
&& Plugin.Instance.MainWindow?.ActiveTab is { } activeTab
)
{
activeTab.CurrentChannel.SetChannel(channel);
activeTab.CurrentChannel.TellTarget = null;
activeTab.CurrentChannel.ResetTempChannel();
}
}
// Prefill text binds (CMD_COMMAND seeds "/"): drop the token into our input.
if (info.Text is { } text)
Plugin.Instance.InputBar.SetPendingMessage(text);
}
// Pop-out input-bar focus-forward stays deferred (no focus contract yet) —
// main-window tabs only.
private void DispatchTabDelta(int delta)
{
Plugin.Instance.MainWindow?.ChangeTabDelta(delta);
}
private static Keybind GetKeybind(string id)
{
var outData = new FFXIVClientStructs.FFXIV.Client.System.Input.Keybind();
var idString = Utf8String.FromString(id);
UIInputData.Instance()->GetKeybindByName(idString, &outData);
idString->Dtor(true);
var key1 = outData.KeySettings[0];
var key2 = outData.KeySettings[1];
return new Keybind
{
Key1 = RemapInvalidVirtualKey((VirtualKey)key1.Key),
Modifier1 = (ModifierFlag)key1.KeyModifier,
Key2 = RemapInvalidVirtualKey((VirtualKey)key2.Key),
Modifier2 = (ModifierFlag)key2.KeyModifier,
};
}
private static VirtualKey RemapInvalidVirtualKey(VirtualKey key)
{
return key switch
{
VirtualKey.F23 => VirtualKey.OEM_2, // /?
(VirtualKey)140 => VirtualKey.OEM_7, // '"
_ => key,
};
}
}