C2/C3: restore rotation keybinds (REPLY/LS-cycle) + focus contract routing on the focused chat surface
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,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();
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user