diff --git a/HellionChat/GameFunctions/Chat.cs b/HellionChat/GameFunctions/Chat.cs index 0ad0a74..2546d40 100755 --- a/HellionChat/GameFunctions/Chat.cs +++ b/HellionChat/GameFunctions/Chat.cs @@ -451,6 +451,17 @@ internal sealed unsafe class Chat : IDisposable uint currentIndex, RotateMode rotate, Func validFn + ) => RotateLinkshellIndex(currentIndex, rotate, validFn); + + // Pure index-stepper (Dalamud-free): wrap (8 + i + delta) % 8 and return the + // first index validFn accepts within 8 iterations, else null. Extracted so the + // modulo/termination logic is unit-testable with a synthetic predicate; the + // production caller binds validFn to InfoProxyLinkshell (in-game only). + // TEST-MIRROR: ../../Hellion Build test/_Helpers/RotateLinkshellIndexTests.cs + internal static uint? RotateLinkshellIndex( + uint currentIndex, + RotateMode rotate, + Func validFn ) { if (rotate == RotateMode.None) diff --git a/HellionChat/GameFunctions/KeybindManager.cs b/HellionChat/GameFunctions/KeybindManager.cs index 1b8f0a0..55a4a28 100644 --- a/HellionChat/GameFunctions/KeybindManager.cs +++ b/HellionChat/GameFunctions/KeybindManager.cs @@ -7,6 +7,7 @@ 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; @@ -504,41 +505,174 @@ internal unsafe class KeybindManager : IDisposable 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(); + // 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 (C3 full tail rebuild, OD-1). + var (targetWindow, targetTab) = ResolveKeybindTarget(); - // 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) + // 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 (OD-1: 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 { - 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 - ) + if (info.Channel is { } channel && info.Rotate == RotateMode.None) { - activeTab.CurrentChannel.SetChannel(channel); - activeTab.CurrentChannel.TellTarget = null; - activeTab.CurrentChannel.ResetTempChannel(); + // 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, Flo decision 2026-06-15). + 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 (C2/C3 shared target), not Plugin.CurrentTab. + if (targetTab is { } rotTab) + { + var targetChannel = (InputChannel?)rotateChannel; - // Prefill text binds (CMD_COMMAND seeds "/"): drop the token into our input. - if (info.Text is { } text) - Plugin.Instance.InputBar.SetPendingMessage(text); + // 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) + { + Plugin.Instance.Functions.Chat.SetChannel(targetChannel.Value); + 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 (documented scope limit, OD-1). + if (info.Text is { } text) + Plugin.Instance.InputBar.SetPendingMessage(text); + } + catch (Exception ex) + { + _logger.LogError(ex, "Keybind routing failed for channel {Channel}", info.Channel); + } } - // Pop-out input-bar focus-forward stays deferred (no focus contract yet) — - // main-window tabs only. + // Resolve which chat surface a keybind action targets: the open pop-out whose + // input currently has focus, otherwise the main window. C2/C3 share this 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 (OD-1). The focus contract is + // consumed by the channel-set/REPLY/prefill tail, not here. private void DispatchTabDelta(int delta) { Plugin.Instance.MainWindow?.ChangeTabDelta(delta); diff --git a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs index 606f82c..7d0aa3b 100644 --- a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs +++ b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs @@ -13,7 +13,7 @@ namespace HellionChat.Ui.Windows; // via ctor — see plan §B.2. The ###id carries the slot index so all N // instances are unique for WindowSystem.AddWindow and ImGui state is stable // per slot (not per bound tab). -internal sealed class ChannelPopoutWindow : Window +internal sealed class ChannelPopoutWindow : Window, IFocusableChatWindow { private readonly int _slotIndex; private readonly MessageList _messages; @@ -75,6 +75,18 @@ internal sealed class ChannelPopoutWindow : Window IsOpen = false; } + // IFocusableChatWindow — this pop-out's own InputBar carries the focus state + // the keybind tail checks when deciding whether to route at this surface (C3). + public bool HasFocusedInput => _input.IsFocused; + + // Arm-and-hold the one-frame Activate flag; the pop-out's Draw applies the + // ImGui focus next frame. Framework-thread safe (field write only). + public void RequestInputFocus() + { + BringToFront(); + _input.Activate = true; + } + public override void PreDraw() { // Gate the native title bar on the user toggle (1.5.6 parity). DrawHeader diff --git a/HellionChat/Ui/Windows/IFocusableChatWindow.cs b/HellionChat/Ui/Windows/IFocusableChatWindow.cs new file mode 100644 index 0000000..04bf634 --- /dev/null +++ b/HellionChat/Ui/Windows/IFocusableChatWindow.cs @@ -0,0 +1,14 @@ +namespace HellionChat.Ui.Windows; + +// Focus contract shared by the main window and each pop-out so the keybind tail +// can route channel-set / REPLY / prefill to whichever surface currently owns the +// input focus, without the KeybindManager reaching into either window's privates. +// HasFocusedInput reads the bound InputBar's per-frame focus state; RequestInputFocus +// only arms the one-frame Activate flag (ImGui focus is frame-bound — never call +// SetKeyboardFocusHere from the framework thread). +internal interface IFocusableChatWindow +{ + bool HasFocusedInput { get; } + + void RequestInputFocus(); +} diff --git a/HellionChat/Ui/Windows/MainWindow.cs b/HellionChat/Ui/Windows/MainWindow.cs index 524d3ad..d8c2900 100644 --- a/HellionChat/Ui/Windows/MainWindow.cs +++ b/HellionChat/Ui/Windows/MainWindow.cs @@ -16,7 +16,7 @@ namespace HellionChat.Ui.Windows; // Components are fully qualified through the Ui.Components prefix so the // old Ui.StatusBar type (still alive until the cleanup block removes it) // cannot shadow the new layer through parent-namespace resolution. -internal sealed class MainWindow : Window +internal sealed class MainWindow : Window, IFocusableChatWindow { private const float DefaultWidth = 620f; private const float DefaultHeight = 340f; @@ -205,6 +205,18 @@ internal sealed class MainWindow : Window _input.Activate = true; } + // IFocusableChatWindow — the keybind tail resolves which surface owns the + // input focus before routing a channel-set/REPLY/prefill at it (C3). + public bool HasFocusedInput => _input.IsFocused; + + // Arm-and-hold: field writes only, safe from the framework thread; the draw + // path applies the actual ImGui focus next frame (same path as ActivateChat). + public void RequestInputFocus() + { + BringToFront(); + _input.Activate = true; + } + // new-shadow on Window.Toggle so the open path also writes Config. A user-hide // counts as "not visible", so /hellion is a reliable one-press recovery even when // the Enter keybind can't fire (DirectChat / a focused game text field).