The first sweep matched a character class that swallowed the digit, so a bare B1 slipped through while B1-2 was caught. Searching the whole A-Z space instead of guessing prefixes turned up 130-odd more: B0 through B6, C2, C3, D1, H2, M6, P7, P8, T2, W2, plus GP-04, KB-01, OD-1, PM-1, PM-3, SEC-01, TR-4, TR-7, UI-11, UI-12, XC-8 and API-3. Kept deliberately: 41 B4 01 is a byte signature, "N0" a format string, #L119-L128 a source anchor, LS4/LS6 are linkshells, and A=FF B=0C G=41 R=C2 explains a colour-channel order. Those look like codes and are not. Also translated the eight German comments left in the theme files and ImGuiUtil. Seven of them described what a palette does to which channel, which is worth reading -- just not in a second language in an otherwise English codebase.
721 lines
28 KiB
C#
721 lines
28 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.Ui.Windows;
|
|
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;
|
|
|
|
// Resolve the surface this keybind acts on FIRST: a focused pop-out otherwise
|
|
// the main window. Channel-set/REPLY/prefill all write here so the action
|
|
// follows the input the user is typing in.
|
|
var (targetWindow, targetTab) = ResolveKeybindTarget();
|
|
|
|
// Surface + focus the resolved target ONCE, before routing. Main: ActivateChat
|
|
// re-surfaces it from a hide/closed state (the chat-activation entry point
|
|
// retired in v1.6.0). Pop-out: arm only its focus — NOT ActivateChat, which
|
|
// would yank the main window to front and un-hide it on every pop-out-targeted
|
|
// keybind: stay where the user types. Exactly one window arms focus per
|
|
// keybind, so the next frame has no SetKeyboardFocusHere race.
|
|
if (targetWindow is ChannelPopoutWindow)
|
|
targetWindow.RequestInputFocus();
|
|
else
|
|
Plugin.Instance.MainWindow?.ActivateChat();
|
|
|
|
// The routing tail makes native game calls (GetTellHistoryInfo, UIModule,
|
|
// RotateLinkshellHistory) on the framework tick — wrap it so one bad frame logs
|
|
// instead of throwing into Dalamud's update loop (v1.5.6 parity).
|
|
try
|
|
{
|
|
if (info.Channel is { } channel && info.Rotate == RotateMode.None)
|
|
{
|
|
// Direct channel-switch binds (CMD_SAY/PARTY/numbered linkshells/…): switch
|
|
// the game channel AND mirror it onto the resolved tab so the input pill
|
|
// shows the real send target (pill-sync).
|
|
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) && targetTab is { } directTab)
|
|
{
|
|
directTab.CurrentChannel.SetChannel(channel);
|
|
directTab.CurrentChannel.TellTarget = null;
|
|
directTab.CurrentChannel.ResetTempChannel();
|
|
}
|
|
}
|
|
else if (info.Channel is { } rotateChannel && info.Rotate != RotateMode.None)
|
|
{
|
|
// Rotation binds (REPLY / linkshell-cycle). Ported from v1.5.6's
|
|
// ChatLogWindow.Activated (1d3b429:240-334) without the ChatActivatedArgs
|
|
// indirection (gone in the rewrite). Writes onto the resolved surface's
|
|
// tab, not Plugin.CurrentTab.
|
|
if (targetTab is { } rotTab)
|
|
{
|
|
var targetChannel = (InputChannel?)rotateChannel;
|
|
|
|
// REPLY rotation: the reply target is ALWAYS temp (never permanent —
|
|
// a permanent reply would leak the partner onto the tab) and ALWAYS
|
|
// TellReason.Reply. info.Permanent does not gate this step; only the
|
|
// channel-set tail below honours the _ALWAYS binds' permanence.
|
|
if (rotateChannel == InputChannel.Tell)
|
|
{
|
|
var idx =
|
|
rotTab.CurrentChannel.TempChannel != InputChannel.Tell ? 0
|
|
: info.Rotate == RotateMode.Reverse ? -1
|
|
: 1;
|
|
|
|
var tellInfo = Plugin.Instance.Functions.Chat.GetTellHistoryInfo(idx);
|
|
if (tellInfo != null)
|
|
rotTab.CurrentChannel.TempTellTarget = new TellTarget(
|
|
tellInfo.Name,
|
|
tellInfo.World,
|
|
tellInfo.ContentId,
|
|
TellReason.Reply
|
|
);
|
|
}
|
|
else
|
|
{
|
|
// Cycling AWAY from Tell to a linkshell: drop any stale permanent
|
|
// tell target so a typed line cannot silently route to the old
|
|
// partner (v1.5.6 ChatLogWindow.cs:280, privacy guard).
|
|
rotTab.CurrentChannel.TellTarget = null;
|
|
}
|
|
|
|
// LS/CWLS cycle: permanent rotates the game's own history and reads the
|
|
// landed cycle index back; temp resolves the next valid linkshell index
|
|
// without touching game state. Both leave targetChannel null on failure
|
|
// (no valid linkshell in 8 iterations) so the tail below logs + skips.
|
|
if (rotateChannel is InputChannel.Linkshell1 or InputChannel.CrossLinkshell1)
|
|
{
|
|
var module = UIModule.Instance();
|
|
if (info.Permanent)
|
|
{
|
|
if (rotateChannel == InputChannel.Linkshell1)
|
|
{
|
|
Chat.RotateLinkshellHistory(info.Rotate);
|
|
targetChannel = rotateChannel + (uint)module->LinkshellCycle;
|
|
}
|
|
else
|
|
{
|
|
Chat.RotateCrossLinkshellHistory(info.Rotate);
|
|
targetChannel =
|
|
rotateChannel + (uint)module->CrossWorldLinkshellCycle;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
targetChannel = Chat.ResolveTempInputChannel(
|
|
rotTab.CurrentChannel.TempChannel,
|
|
rotateChannel,
|
|
info.Rotate
|
|
);
|
|
}
|
|
}
|
|
|
|
// Shared channel-set tail (runs for Tell too: IsChannelOrExistingLinkshell
|
|
// is true for Tell and targetChannel stays Tell). Permanent => commit the
|
|
// game channel; temp => arm UseTempChannel/TempChannel only. This is the
|
|
// ONLY place info.Permanent decides temp vs permanent for the channel.
|
|
if (
|
|
targetChannel is null
|
|
|| !Chat.IsChannelOrExistingLinkshell(targetChannel.Value)
|
|
)
|
|
{
|
|
_logger.LogWarning(
|
|
"Rotation channel resolved to an invalid value '{Channel}', ignoring",
|
|
targetChannel
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (info.Permanent)
|
|
{
|
|
// 1.5.6 parity (ChatLogWindow.SetChannel, 1d3b429:1476-1479):
|
|
// committing the game channel also pre-targets the game's native input.
|
|
// Forward the tab's reply target for Tell so the partner is armed
|
|
// game-side (ChangeChatChannel code 17); null for a linkshell —
|
|
// targetChannel is the FINAL resolved value (9..16/19..26 for LS, never
|
|
// 0=Tell), so a stale TempTellTarget can never flip an LS cycle to Tell.
|
|
var gameTarget =
|
|
targetChannel.Value == InputChannel.Tell
|
|
? rotTab.CurrentChannel.TempTellTarget
|
|
?? rotTab.CurrentChannel.TellTarget
|
|
: null;
|
|
Plugin.Instance.Functions.Chat.SetChannel(targetChannel.Value, gameTarget);
|
|
rotTab.CurrentChannel.SetChannel(targetChannel.Value);
|
|
}
|
|
else
|
|
{
|
|
rotTab.CurrentChannel.UseTempChannel = true;
|
|
rotTab.CurrentChannel.TempChannel = targetChannel.Value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Prefill text binds (CMD_COMMAND seeds "/"): the token always goes to the
|
|
// main InputBar (the focus contract does not expose pop-out buffers); a
|
|
// focused pop-out already received focus above, so only token routing matters
|
|
// here -- a documented scope limit.
|
|
if (info.Text is { } text)
|
|
Plugin.Instance.InputBar.SetPendingMessage(text);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Keybind routing failed for channel {Channel}", info.Channel);
|
|
}
|
|
}
|
|
|
|
// Resolve which chat surface a keybind action targets: the open pop-out whose
|
|
// input currently has focus, otherwise the main window. Both paths share it so a
|
|
// channel-switch/REPLY/prefill follows the surface the user is typing in. The
|
|
// returned tab is that surface's bound tab (pop-out: Bound; main: ActiveTab).
|
|
// Null tab => skip the tab-write (early-load window where no tab exists yet).
|
|
private (IFocusableChatWindow Window, Tab? Tab) ResolveKeybindTarget()
|
|
{
|
|
foreach (var popout in Plugin.Instance.ChannelPopoutPool.Instances)
|
|
{
|
|
if (popout.Bound is { } bound && popout.IsOpen && popout.HasFocusedInput)
|
|
return (popout, bound);
|
|
}
|
|
|
|
var main = Plugin.Instance.MainWindow;
|
|
return (main!, main?.ActiveTab);
|
|
}
|
|
|
|
// Tab-delta keybinds (ChatTabForward/Backward) stay main-window-only by design:
|
|
// a channel-bound pop-out has no tab list to cycle. The focus contract is
|
|
// consumed by the channel-set/REPLY/prefill tail, not here.
|
|
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,
|
|
};
|
|
}
|
|
}
|