C2/C3: restore rotation keybinds (REPLY/LS-cycle) + focus contract routing on the focused chat surface

This commit is contained in:
2026-06-16 14:09:28 +02:00
parent ea3f00f107
commit fa20b53455
5 changed files with 213 additions and 30 deletions
+11
View File
@@ -451,6 +451,17 @@ internal sealed unsafe class Chat : IDisposable
uint currentIndex,
RotateMode rotate,
Func<uint, bool> 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<uint, bool> validFn
)
{
if (rotate == RotateMode.None)
+162 -28
View File
@@ -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);
+13 -1
View File
@@ -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
@@ -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();
}
+13 -1
View File
@@ -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).