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, uint currentIndex,
RotateMode rotate, RotateMode rotate,
Func<uint, bool> validFn 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) if (rotate == RotateMode.None)
+162 -28
View File
@@ -7,6 +7,7 @@ using FFXIVClientStructs.FFXIV.Client.System.String;
using FFXIVClientStructs.FFXIV.Client.UI; using FFXIVClientStructs.FFXIV.Client.UI;
using HellionChat.Code; using HellionChat.Code;
using HellionChat.GameFunctions.Types; using HellionChat.GameFunctions.Types;
using HellionChat.Ui.Windows;
using HellionChat.Util; using HellionChat.Util;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ModifierFlag = HellionChat.GameFunctions.Types.ModifierFlag; using ModifierFlag = HellionChat.GameFunctions.Types.ModifierFlag;
@@ -504,41 +505,174 @@ internal unsafe class KeybindManager : IDisposable
if (!KeybindsToIntercept.TryGetValue(currentBest.Item2, out var info)) if (!KeybindsToIntercept.TryGetValue(currentBest.Item2, out var info))
return; return;
// Re-surface the chat-activation entry point retired in v1.6.0: a chat-open // Resolve the surface this keybind acts on FIRST: a focused pop-out otherwise
// keybind shows + focuses the window, restoring it from a user-hide or a // the main window. Channel-set/REPLY/prefill all write here so the action
// closed state. // follows the input the user is typing in (C3 full tail rebuild, OD-1).
Plugin.Instance.MainWindow?.ActivateChat(); var (targetWindow, targetTab) = ResolveKeybindTarget();
// Direct channel-switch binds (CMD_SAY/PARTY/numbered linkshells/…): switch the // Surface + focus the resolved target ONCE, before routing. Main: ActivateChat
// game channel AND mirror it onto the active tab so the input pill shows the // re-surfaces it from a hide/closed state (the chat-activation entry point
// real send target (pill-sync, Flo decision 2026-06-15). Rotation binds (REPLY / // retired in v1.6.0). Pop-out: arm only its focus — NOT ActivateChat, which
// linkshell-cycle, Rotate != None) are skipped; the temp-vs-permanent distinction // would yank the main window to front and un-hide it on every pop-out-targeted
// (v1.5.6's UseTempChannel / info.Permanent) collapses to one permanent-style // keybind (OD-1: stay where the user types). Exactly one window arms focus per
// switch here — restoring it is the keybind-routing follow-cycle. // keybind, so the next frame has no SetKeyboardFocusHere race.
if (info.Channel is { } channel && info.Rotate == RotateMode.None) 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); if (info.Channel is { } channel && info.Rotate == RotateMode.None)
// 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
)
{ {
activeTab.CurrentChannel.SetChannel(channel); // Direct channel-switch binds (CMD_SAY/PARTY/numbered linkshells/…): switch
activeTab.CurrentChannel.TellTarget = null; // the game channel AND mirror it onto the resolved tab so the input pill
activeTab.CurrentChannel.ResetTempChannel(); // 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. // REPLY rotation: the reply target is ALWAYS temp (never permanent —
if (info.Text is { } text) // a permanent reply would leak the partner onto the tab) and ALWAYS
Plugin.Instance.InputBar.SetPendingMessage(text); // 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) — // Resolve which chat surface a keybind action targets: the open pop-out whose
// main-window tabs only. // 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) private void DispatchTabDelta(int delta)
{ {
Plugin.Instance.MainWindow?.ChangeTabDelta(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 // 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 // instances are unique for WindowSystem.AddWindow and ImGui state is stable
// per slot (not per bound tab). // per slot (not per bound tab).
internal sealed class ChannelPopoutWindow : Window internal sealed class ChannelPopoutWindow : Window, IFocusableChatWindow
{ {
private readonly int _slotIndex; private readonly int _slotIndex;
private readonly MessageList _messages; private readonly MessageList _messages;
@@ -75,6 +75,18 @@ internal sealed class ChannelPopoutWindow : Window
IsOpen = false; 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() public override void PreDraw()
{ {
// Gate the native title bar on the user toggle (1.5.6 parity). DrawHeader // 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 // Components are fully qualified through the Ui.Components prefix so the
// old Ui.StatusBar type (still alive until the cleanup block removes it) // old Ui.StatusBar type (still alive until the cleanup block removes it)
// cannot shadow the new layer through parent-namespace resolution. // 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 DefaultWidth = 620f;
private const float DefaultHeight = 340f; private const float DefaultHeight = 340f;
@@ -205,6 +205,18 @@ internal sealed class MainWindow : Window
_input.Activate = true; _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 // 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 // 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). // the Enter keybind can't fire (DirectChat / a focused game text field).