Files
HellionChat/HellionChat/GameFunctions/KeybindManager.cs
T
JonKazama-Hellion 16557213cd chore: comments, second pass -- the task codes the first pass missed
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.
2026-08-19 22:03:12 +02:00

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,
};
}
}