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 Keybinds = new(); private static readonly IReadOnlyDictionary KeybindsToIntercept = new Dictionary { ["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 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 _logger; internal KeybindManager(Plugin plugin, ILogger 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, }; } }