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,
|
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)
|
||||||
|
|||||||
@@ -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).
|
||||||
|
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 (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();
|
Plugin.Instance.MainWindow?.ActivateChat();
|
||||||
|
|
||||||
// Direct channel-switch binds (CMD_SAY/PARTY/numbered linkshells/…): switch the
|
// The routing tail makes native game calls (GetTellHistoryInfo, UIModule,
|
||||||
// game channel AND mirror it onto the active tab so the input pill shows the
|
// RotateLinkshellHistory) on the framework tick — wrap it so one bad frame logs
|
||||||
// real send target (pill-sync, Flo decision 2026-06-15). Rotation binds (REPLY /
|
// instead of throwing into Dalamud's update loop (v1.5.6 parity).
|
||||||
// linkshell-cycle, Rotate != None) are skipped; the temp-vs-permanent distinction
|
try
|
||||||
// (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)
|
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, Flo decision 2026-06-15).
|
||||||
Plugin.Instance.Functions.Chat.SetChannel(channel);
|
Plugin.Instance.Functions.Chat.SetChannel(channel);
|
||||||
// Only mirror onto the tab when the game actually accepted the switch — an
|
// 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
|
// 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.
|
// stay put rather than show a target the game will not send to.
|
||||||
if (
|
if (Chat.IsChannelOrExistingLinkshell(channel) && targetTab is { } directTab)
|
||||||
Chat.IsChannelOrExistingLinkshell(channel)
|
|
||||||
&& Plugin.Instance.MainWindow?.ActiveTab is { } activeTab
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
activeTab.CurrentChannel.SetChannel(channel);
|
directTab.CurrentChannel.SetChannel(channel);
|
||||||
activeTab.CurrentChannel.TellTarget = null;
|
directTab.CurrentChannel.TellTarget = null;
|
||||||
activeTab.CurrentChannel.ResetTempChannel();
|
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;
|
||||||
|
|
||||||
|
// 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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefill text binds (CMD_COMMAND seeds "/"): drop the token into our input.
|
// 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)
|
if (info.Text is { } text)
|
||||||
Plugin.Instance.InputBar.SetPendingMessage(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,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();
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user