Merge branch 'feature/v1.8.0-closeout' into feature/v1.8.0
This commit is contained in:
@@ -218,7 +218,7 @@ internal sealed class AutoTellTabsService : IDisposable
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Tab? FindTempTab(string name, uint world)
|
||||
internal static Tab? FindTempTab(string name, uint world)
|
||||
{
|
||||
var byTarget = Plugin.Config.Tabs.FirstOrDefault(t =>
|
||||
t.IsTempTab
|
||||
@@ -239,6 +239,16 @@ internal sealed class AutoTellTabsService : IDisposable
|
||||
);
|
||||
}
|
||||
|
||||
// Lock-protected lookup for the framework-thread caller (TellRouterService).
|
||||
// Config.Tabs is mutated under _tempTabsLock on the PendingMessage worker thread,
|
||||
// so a framework-tick reader must take the same lock to avoid enumerating the list
|
||||
// mid-mutation.
|
||||
internal Tab? FindTempTabSafe(string name, uint world)
|
||||
{
|
||||
lock (_tempTabsLock)
|
||||
return FindTempTab(name, world);
|
||||
}
|
||||
|
||||
internal void DropOldestTempTab()
|
||||
{
|
||||
// Pinned tabs live in their own bucket (MaxPinnedTempTabs) and are
|
||||
@@ -256,21 +266,20 @@ internal sealed class AutoTellTabsService : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
// Pop-out-window cleanup is offline while the channel-popout pool
|
||||
// is rebuilt — Tab.PopOut still flips on/off, the visible window
|
||||
// disappears once the new pool comes online.
|
||||
|
||||
var dropped = victim.Tab;
|
||||
Plugin.Config.Tabs.RemoveAt(victim.Index);
|
||||
|
||||
// Re-anchor the UI selection if it pointed at the dropped tab. This runs on
|
||||
// the PendingMessage worker thread and the repair mutates the re-seeded
|
||||
// tab's channel via OnTabActivated, so marshal it onto the framework thread
|
||||
// to serialize with Draw (reference_dalamud_framework_thread) — otherwise a
|
||||
// half-applied strip could race the input bar's send-routing read.
|
||||
// Re-anchor the UI selection if it pointed at the dropped tab, and close any
|
||||
// pop-out window the dropped tab owned. Both run on the PendingMessage worker
|
||||
// thread and touch window state the Draw path reads (OnTabActivated re-seed +
|
||||
// the pool's Unbind), so marshal onto the framework thread to serialize with
|
||||
// Draw (reference_dalamud_framework_thread). TryClose is idempotent: a tab that
|
||||
// was never popped is a silent no-op.
|
||||
Plugin.Framework.RunOnFrameworkThread(() =>
|
||||
_plugin.MainWindow?.ResetActiveTabIfRemoved(dropped)
|
||||
);
|
||||
{
|
||||
_plugin.ChannelPopoutPool.TryClose(dropped.Identifier);
|
||||
_plugin.MainWindow?.ResetActiveTabIfRemoved(dropped);
|
||||
});
|
||||
}
|
||||
|
||||
private void SpawnTempTab((string Name, uint World) partner, Message currentMessage)
|
||||
@@ -282,13 +291,29 @@ internal sealed class AutoTellTabsService : IDisposable
|
||||
|
||||
tab.AddMessage(currentMessage, unread: true);
|
||||
|
||||
// Open as pop-out if configured (set before Tabs.Add for next render-tick)
|
||||
// Flag the tab as a pop-out if configured; the marshalled TryOpen below reads
|
||||
// that flag to open the real window.
|
||||
if (Plugin.Config.AutoTellTabsOpenAsPopout)
|
||||
{
|
||||
tab.PopOut = true;
|
||||
}
|
||||
|
||||
Plugin.Config.Tabs.Add(tab);
|
||||
|
||||
// Actually open the pop-out window for the flagged tab — without this the
|
||||
// flag was dead (a PopOut tab with no window). SpawnTempTab runs on the
|
||||
// PendingMessage worker thread under _tempTabsLock; TryOpen does
|
||||
// OnTabActivated + Bind (window state Draw reads), so marshal onto the
|
||||
// framework thread. If the pool is full, drop the flag so it never claims a
|
||||
// window it didn't get (flag/window parity).
|
||||
if (tab.PopOut)
|
||||
{
|
||||
Plugin.Framework.RunOnFrameworkThread(() =>
|
||||
{
|
||||
if (!_plugin.ChannelPopoutPool.TryOpen(tab))
|
||||
tab.PopOut = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static Tab BuildTempTab(string playerName, uint worldRowId)
|
||||
@@ -424,11 +449,18 @@ internal sealed class AutoTellTabsService : IDisposable
|
||||
var active = _plugin.MainWindow?.ActiveTab;
|
||||
|
||||
var poppedTempTabIds = Plugin
|
||||
.Config.Tabs.Where(t => TabLifecycleHelpers.IsInUnpinnedPool(t) && t.PopOut)
|
||||
.Config.Tabs.Where(t =>
|
||||
TabLifecycleHelpers.IsInUnpinnedPool(t)
|
||||
&& _plugin.ChannelPopoutPool.IsOpen(t.Identifier)
|
||||
)
|
||||
.Select(t => t.Identifier)
|
||||
.ToList();
|
||||
// Pop-out-window cleanup is offline; see Disconnect path above.
|
||||
_ = poppedTempTabIds;
|
||||
|
||||
// Close any pop-out window an unpinned temp tab owns before the tabs leave
|
||||
// the list. Filtering on the live pool (not the PopOut flag) also catches
|
||||
// manually right-clicked pop-outs, which never set the flag.
|
||||
foreach (var id in poppedTempTabIds)
|
||||
_plugin.ChannelPopoutPool.TryClose(id);
|
||||
|
||||
Plugin.Config.Tabs.RemoveAll(TabLifecycleHelpers.IsInUnpinnedPool);
|
||||
|
||||
|
||||
@@ -268,6 +268,11 @@ public class Configuration : IPluginConfiguration
|
||||
public bool SettingsWindowOpen;
|
||||
public int MaxParallelPopouts = 8;
|
||||
public TellAutoOpenMode TellAutoOpenMode = TellAutoOpenMode.Sidebar;
|
||||
|
||||
// When true (default) the tell-auto-open router switches the active tab to the
|
||||
// incoming tell on every message; when false the tab is still created/revealed
|
||||
// with its unread badge but the active tab is left where the user is reading.
|
||||
public bool TellAutoOpenSwitchAlways = true;
|
||||
public int SidebarAutoSwitchThresholdPx = 800;
|
||||
|
||||
// v22 field: MainWindow layout mode (sidebar vs. horizontal top tabs).
|
||||
@@ -428,6 +433,7 @@ public class Configuration : IPluginConfiguration
|
||||
SettingsWindowOpen = other.SettingsWindowOpen;
|
||||
MaxParallelPopouts = other.MaxParallelPopouts;
|
||||
TellAutoOpenMode = other.TellAutoOpenMode;
|
||||
TellAutoOpenSwitchAlways = other.TellAutoOpenSwitchAlways;
|
||||
SidebarAutoSwitchThresholdPx = other.SidebarAutoSwitchThresholdPx;
|
||||
MainWindowLayoutMode = other.MainWindowLayoutMode;
|
||||
}
|
||||
|
||||
@@ -232,9 +232,14 @@ internal sealed unsafe class Chat : IDisposable
|
||||
if (c != '\0' && !char.IsControl(c))
|
||||
input = c.ToString();
|
||||
|
||||
// Chat-window Activated integration is offline until the
|
||||
// new chat layer surfaces an Activated entry point.
|
||||
_ = input;
|
||||
// Seed the just-typed character into our input field and focus it, the
|
||||
// same InputBar.AppendPending + Activate prefill path inventory item-links
|
||||
// use. Prefill-only — no tab switch (Flo decision 2026-06-15).
|
||||
if (input != null)
|
||||
{
|
||||
Plugin.InputBar.AppendPending(input);
|
||||
Plugin.InputBar.Activate = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -325,13 +330,18 @@ internal sealed unsafe class Chat : IDisposable
|
||||
{
|
||||
if (playerName != null)
|
||||
{
|
||||
// Chat-window Activated integration is offline; tell-target
|
||||
// routing returns when the new chat layer is wired up.
|
||||
_ = playerName;
|
||||
_ = worldId;
|
||||
_ = contentId;
|
||||
_ = reason;
|
||||
_ = setChatType;
|
||||
// Right-click -> Send Tell: prefill our input the same way our own
|
||||
// "Send Tell" payload menu does (PayloadHandler), then focus. Prefill-
|
||||
// only — no tab switch, no ChatActivatedArgs revival (Flo decision
|
||||
// 2026-06-15). The game supplies worldName here, so no sheet lookup.
|
||||
var tellName = playerName->ToString();
|
||||
var tellWorld = worldName != null ? worldName->ToString() : string.Empty;
|
||||
var tellCommand = $"/tell {tellName}";
|
||||
if (!string.IsNullOrEmpty(tellWorld))
|
||||
tellCommand += $"@{tellWorld}";
|
||||
tellCommand += " ";
|
||||
Plugin.InputBar.SetPendingMessage(tellCommand);
|
||||
Plugin.InputBar.Activate = true;
|
||||
}
|
||||
|
||||
return SetChatLogTellTargetHook!.Original(
|
||||
@@ -361,12 +371,17 @@ internal sealed unsafe class Chat : IDisposable
|
||||
|
||||
if (playerName != null)
|
||||
{
|
||||
// Chat-window Activated integration is offline; tell-target
|
||||
// routing returns when the new chat layer is wired up.
|
||||
_ = playerName;
|
||||
_ = worldId;
|
||||
_ = contentId;
|
||||
_ = reason;
|
||||
// In-foray right-click -> Send Tell: same prefill path as the non-foray
|
||||
// tell. The foray-specific TellSpecial channel routing stays deferred
|
||||
// (v1.8.1, SetEurekaTellChannel) — prefill-only here (Flo decision 2026-06-15).
|
||||
var forayName = playerName->ToString();
|
||||
var forayWorld = worldName != null ? worldName->ToString() : string.Empty;
|
||||
var forayCommand = $"/tell {forayName}";
|
||||
if (!string.IsNullOrEmpty(forayWorld))
|
||||
forayCommand += $"@{forayWorld}";
|
||||
forayCommand += " ";
|
||||
Plugin.InputBar.SetPendingMessage(forayCommand);
|
||||
Plugin.InputBar.Activate = true;
|
||||
}
|
||||
|
||||
ContextMenuTellInForayHook!.Original(
|
||||
|
||||
@@ -501,20 +501,47 @@ internal unsafe class KeybindManager : IDisposable
|
||||
return;
|
||||
|
||||
Plugin.KeyState[currentBest.Item1] = false;
|
||||
if (!KeybindsToIntercept.ContainsKey(currentBest.Item2))
|
||||
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. Channel/prefill routing from the bind stays out of scope.
|
||||
// closed state.
|
||||
Plugin.Instance.MainWindow?.ActivateChat();
|
||||
|
||||
// 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)
|
||||
{
|
||||
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
|
||||
)
|
||||
{
|
||||
activeTab.CurrentChannel.SetChannel(channel);
|
||||
activeTab.CurrentChannel.TellTarget = null;
|
||||
activeTab.CurrentChannel.ResetTempChannel();
|
||||
}
|
||||
}
|
||||
|
||||
// Prefill text binds (CMD_COMMAND seeds "/"): drop the token into our input.
|
||||
if (info.Text is { } text)
|
||||
Plugin.Instance.InputBar.SetPendingMessage(text);
|
||||
}
|
||||
|
||||
// Tab-cycle dispatch is offline until the new chat layer surfaces a
|
||||
// ChangeTabDelta entry point and pop-out input bars come back online.
|
||||
// Pop-out input-bar focus-forward stays deferred (no focus contract yet) —
|
||||
// main-window tabs only.
|
||||
private void DispatchTabDelta(int delta)
|
||||
{
|
||||
_ = delta;
|
||||
Plugin.Instance.MainWindow?.ChangeTabDelta(delta);
|
||||
}
|
||||
|
||||
private static Keybind GetKeybind(string id)
|
||||
|
||||
@@ -101,6 +101,18 @@ internal sealed class AutoTellTabsServiceInitHostedService(AutoTellTabsService s
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
internal sealed class TellRouterServiceInitHostedService(Services.TellRouterService service)
|
||||
: IHostedService
|
||||
{
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
service.Initialize();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Eager-resolve trigger: resolving FailedTellNotifier in this adapter's ctor
|
||||
// enables its game hook during host startup. StartAsync itself is a no-op.
|
||||
internal sealed class FailedTellNotifierInitHostedService(FailedTellNotifier notifier)
|
||||
|
||||
@@ -388,6 +388,7 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
||||
new SelfTests.OnOpenMainUiRoutesMainWindowStep(this),
|
||||
new SelfTests.TypingIpcStateStep(this),
|
||||
new SelfTests.ConfigMigrationV23Step(this),
|
||||
new SelfTests.ChannelPopoutBindStep(this),
|
||||
new SelfTests.HoverSheenAllocStep(this),
|
||||
new SelfTests.HonorificHeaderRenderStep(this),
|
||||
new SelfTests.AboutIntegrationsStatusStep(this),
|
||||
|
||||
@@ -122,7 +122,7 @@ internal static class PluginHostFactory
|
||||
sp.GetRequiredService<IFramework>()
|
||||
));
|
||||
services.AddSingleton(sp => new Services.TellRouterService(
|
||||
sp.GetRequiredService<IChatGui>(),
|
||||
sp.GetRequiredService<MessageManager>(),
|
||||
sp.GetRequiredService<ILogger<Services.TellRouterService>>()
|
||||
));
|
||||
|
||||
@@ -369,6 +369,12 @@ internal static class PluginHostFactory
|
||||
services.AddHostedService(sp => new AutoTellTabsServiceInitHostedService(
|
||||
sp.GetRequiredService<AutoTellTabsService>()
|
||||
));
|
||||
// Must come AFTER AutoTell's registration: both subscribe MessageProcessed,
|
||||
// and AutoTell subscribing first lets the router's IsOpen-guard see the
|
||||
// already-opened pop-out (FIFO framework-tick ordering, no double-pop).
|
||||
services.AddHostedService(sp => new TellRouterServiceInitHostedService(
|
||||
sp.GetRequiredService<Services.TellRouterService>()
|
||||
));
|
||||
services.AddHostedService(
|
||||
sp => new Infrastructure.Hosting.FailedTellNotifierInitHostedService(
|
||||
sp.GetRequiredService<Integrations.FailedTellNotifier>()
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Linq;
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Plugin.SelfTest;
|
||||
|
||||
namespace HellionChat.SelfTests;
|
||||
|
||||
// In-game behavioural check of the ChannelPopoutPool lifecycle (not a non-null-handle
|
||||
// check — feedback_hellion_chat_fontmanager_push_trap): pre-alloc count, unique slot
|
||||
// ids, a TryOpen->IsOpen->TryClose round-trip, idempotent close, and capacity refusal.
|
||||
// The pool is a live DI singleton, so the step works against the FREE slots (not full
|
||||
// capacity) and only closes ids it opened — it neither false-REDs on a non-empty pool
|
||||
// nor disturbs real pop-outs. Pure slot-map math is pinned by PopoutSlotMapTests.
|
||||
internal sealed class ChannelPopoutBindStep : ISelfTestStep
|
||||
{
|
||||
private readonly Plugin _plugin;
|
||||
|
||||
public ChannelPopoutBindStep(Plugin plugin)
|
||||
{
|
||||
_plugin = plugin;
|
||||
}
|
||||
|
||||
public string Name => "Hellion Chat - Channel popout pool lifecycle";
|
||||
|
||||
public SelfTestStepResult RunStep()
|
||||
{
|
||||
var pool = _plugin.ChannelPopoutPool;
|
||||
var capacity = Plugin.Config.MaxParallelPopouts;
|
||||
|
||||
if (pool.Instances.Count != capacity)
|
||||
{
|
||||
ImGui.Text(
|
||||
$"Expected {capacity} pre-allocated pop-out windows, found {pool.Instances.Count}."
|
||||
);
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
if (pool.Instances.Select(w => w.SlotIndex).Distinct().Count() != pool.Instances.Count)
|
||||
{
|
||||
ImGui.Text("Pop-out windows do not have unique slot indices.");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
// Free slots right now = capacity minus whatever real pop-outs are already
|
||||
// bound. Testing against this (not capacity) keeps the step state-independent.
|
||||
var free = capacity - pool.Instances.Count(w => w.Bound is not null);
|
||||
|
||||
// Round-trip on a throwaway tab, only when there's a slot to take. A bare Tab
|
||||
// has CurrentChannel.Channel == Invalid + an empty SelectedChannels, so the
|
||||
// pool's OnTabActivated strip is a no-op (no NRE), and the live active tab is
|
||||
// passed only as `previous`, so it is never mutated. We close before
|
||||
// returning, so the bound window never reaches a Draw frame.
|
||||
if (free > 0)
|
||||
{
|
||||
var probe = new Tab { Name = "##selftest-popout-probe" };
|
||||
if (pool.IsOpen(probe.Identifier))
|
||||
{
|
||||
ImGui.Text("Probe tab already open before TryOpen.");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
if (!pool.TryOpen(probe))
|
||||
{
|
||||
ImGui.Text("TryOpen returned false with a free slot.");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
if (!pool.IsOpen(probe.Identifier))
|
||||
{
|
||||
ImGui.Text("IsOpen is false right after a successful TryOpen.");
|
||||
pool.TryClose(probe.Identifier);
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
pool.TryClose(probe.Identifier);
|
||||
if (pool.IsOpen(probe.Identifier))
|
||||
{
|
||||
ImGui.Text("IsOpen is still true after TryClose.");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
// Idempotent: closing an already-closed id is a silent no-op.
|
||||
pool.TryClose(probe.Identifier);
|
||||
}
|
||||
|
||||
// Capacity guard: fill the remaining free slots, then one more open must be
|
||||
// refused (warn, no throw). Release everything we opened before reporting.
|
||||
var fillers = Enumerable
|
||||
.Range(0, free)
|
||||
.Select(_ => new Tab { Name = "##selftest-fill" })
|
||||
.ToList();
|
||||
var opened = fillers.Count(pool.TryOpen);
|
||||
var overflow = new Tab { Name = "##selftest-overflow" };
|
||||
var overflowRejected = !pool.TryOpen(overflow);
|
||||
|
||||
foreach (var filler in fillers)
|
||||
pool.TryClose(filler.Identifier);
|
||||
pool.TryClose(overflow.Identifier);
|
||||
|
||||
if (opened != free)
|
||||
{
|
||||
ImGui.Text($"Filled only {opened}/{free} free slots before TryOpen refused.");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
if (!overflowRejected)
|
||||
{
|
||||
ImGui.Text("Pool accepted an open beyond capacity instead of refusing.");
|
||||
return SelfTestStepResult.Fail;
|
||||
}
|
||||
|
||||
return SelfTestStepResult.Pass;
|
||||
}
|
||||
|
||||
public void CleanUp() { }
|
||||
}
|
||||
@@ -1,32 +1,112 @@
|
||||
using Dalamud.Game.Chat;
|
||||
using Dalamud.Plugin.Services;
|
||||
using HellionChat.Code;
|
||||
using HellionChat.Util;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace HellionChat.Services;
|
||||
|
||||
// Skeleton for the upcoming auto-open routing layer. Subscribes to IChatGui
|
||||
// up front so the DI graph and Plugin.cs registration stay frozen — when
|
||||
// the routing logic lands, it drops into OnChatMessage without touching
|
||||
// anything else.
|
||||
// Routes an incoming tell to the configured TellAutoOpenMode (Off/Sidebar/
|
||||
// TopTab/Popout). Decoupled from AutoTellTabsService (Flo decision 2026-06-15):
|
||||
// that service owns tab CREATION + lifecycle; this only REVEALS/pops the tab it
|
||||
// finds. Popout guards on pool.IsOpen so it never double-pops a tab the
|
||||
// AutoTellTabsOpenAsPopout path already opened. Subscribes to the resolved
|
||||
// MessageManager.MessageProcessed stream (a resolved Message), not the raw
|
||||
// IChatGui event, and defers the reveal one tick so the tab exists regardless of
|
||||
// subscriber order. Wired by TellRouterServiceInitHostedService.
|
||||
internal sealed class TellRouterService : IDisposable
|
||||
{
|
||||
private readonly IChatGui _chatGui;
|
||||
private readonly MessageManager _messageManager;
|
||||
private readonly ILogger<TellRouterService> _logger;
|
||||
private bool _initialized;
|
||||
|
||||
public TellRouterService(IChatGui chatGui, ILogger<TellRouterService> logger)
|
||||
public TellRouterService(MessageManager messageManager, ILogger<TellRouterService> logger)
|
||||
{
|
||||
_chatGui = chatGui;
|
||||
_messageManager = messageManager;
|
||||
_logger = logger;
|
||||
_chatGui.ChatMessageUnhandled += OnChatMessage;
|
||||
}
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
if (_initialized)
|
||||
return;
|
||||
|
||||
_messageManager.MessageProcessed += OnMessageProcessed;
|
||||
_initialized = true;
|
||||
_logger.LogDebug("TellRouterService online; routing incoming tells by TellAutoOpenMode.");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_chatGui.ChatMessageUnhandled -= OnChatMessage;
|
||||
if (!_initialized)
|
||||
return;
|
||||
|
||||
_messageManager.MessageProcessed -= OnMessageProcessed;
|
||||
_initialized = false;
|
||||
}
|
||||
|
||||
private void OnChatMessage(IChatMessage message)
|
||||
private void OnMessageProcessed(Message message)
|
||||
{
|
||||
// Intentional no-op until the routing implementation lands.
|
||||
var mode = Plugin.Config.TellAutoOpenMode;
|
||||
if (mode == TellAutoOpenMode.Off)
|
||||
return;
|
||||
|
||||
if (message.Code.Type != ChatType.TellIncoming)
|
||||
return;
|
||||
|
||||
// Partner = sender for an incoming tell. Same payload idiom AutoTellTabs uses
|
||||
// (AutoTellTabsService.ExtractTellPartner), so the lookup never diverges.
|
||||
var partner =
|
||||
ChunkUtil.TryGetPlayerPayload(message.Sender)
|
||||
?? ChunkUtil.TryGetPlayerPayload(message.SenderSource);
|
||||
if (partner == null)
|
||||
return;
|
||||
|
||||
var name = partner.PlayerName;
|
||||
var world = partner.World.RowId;
|
||||
|
||||
// Defer the reveal to the next framework tick. AutoTellTabsService also
|
||||
// handles this MessageProcessed (synchronously); by the next tick the tab
|
||||
// exists regardless of subscription order, and the reveal (ActivateTab / pool
|
||||
// mutation) is serialized with Draw (reference_dalamud_framework_thread).
|
||||
Plugin.Framework.RunOnFrameworkThread(() =>
|
||||
{
|
||||
// Lock-safe lookup: AutoTellTabs mutates Config.Tabs under its lock on the
|
||||
// worker thread, so we read through its guarded accessor, not the static.
|
||||
var tab = Plugin.Instance.AutoTellTabsService?.FindTempTabSafe(name, world);
|
||||
if (tab == null)
|
||||
return; // nothing to reveal (auto-tell-tabs off -> no tab created)
|
||||
|
||||
switch (mode)
|
||||
{
|
||||
case TellAutoOpenMode.Sidebar:
|
||||
case TellAutoOpenMode.TopTab:
|
||||
// Switching to the tab on every tell is user-gated
|
||||
// (TellAutoOpenSwitchAlways, default on); when off the tab still
|
||||
// appears with its unread badge but the active tab is left alone.
|
||||
// The mode also picks the layout, so Sidebar vs TopTab are actually
|
||||
// distinct outcomes, not the same ActivateTab.
|
||||
if (Plugin.Config.TellAutoOpenSwitchAlways)
|
||||
{
|
||||
var wantLayout =
|
||||
mode == TellAutoOpenMode.TopTab
|
||||
? MainWindowLayoutMode.TopTabs
|
||||
: MainWindowLayoutMode.Sidebar;
|
||||
if (Plugin.Config.MainWindowLayoutMode != wantLayout)
|
||||
{
|
||||
Plugin.Config.MainWindowLayoutMode = wantLayout;
|
||||
Plugin.Instance.SaveConfig();
|
||||
}
|
||||
|
||||
Plugin.Instance.MainWindow?.ActivateTab(tab);
|
||||
}
|
||||
|
||||
break;
|
||||
case TellAutoOpenMode.Popout:
|
||||
// IsOpen-guard: don't double-pop a tab the AutoTellTabsOpenAsPopout
|
||||
// path already opened (the two switches stay decoupled).
|
||||
if (!Plugin.Instance.ChannelPopoutPool.IsOpen(tab.Identifier))
|
||||
Plugin.Instance.ChannelPopoutPool.TryOpen(tab);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using Dalamud.Interface.Utility.Raii;
|
||||
|
||||
namespace HellionChat.Ui.Components.Settings.Tabs;
|
||||
|
||||
@@ -45,17 +44,21 @@ internal sealed class ChannelsTab
|
||||
() => Plugin.Config.AutoTellTabsShowGreetedToggle,
|
||||
v => Plugin.Config.AutoTellTabsShowGreetedToggle = v
|
||||
);
|
||||
// Popout is a v1.8.0 teaser — render disabled, do NOT persist.
|
||||
using (ImRaii.Disabled(true))
|
||||
{
|
||||
var openAsPopout = Plugin.Config.AutoTellTabsOpenAsPopout;
|
||||
ImGui.Checkbox("Open as popout (lands in v1.8.0)", ref openAsPopout);
|
||||
}
|
||||
DrawToggle(
|
||||
"Open as popout",
|
||||
() => Plugin.Config.AutoTellTabsOpenAsPopout,
|
||||
v => Plugin.Config.AutoTellTabsOpenAsPopout = v
|
||||
);
|
||||
}
|
||||
|
||||
if (ImGui.CollapsingHeader("Tell auto-open mode", ImGuiTreeNodeFlags.DefaultOpen))
|
||||
{
|
||||
DrawTellAutoOpenModeCombo();
|
||||
DrawToggle(
|
||||
"Switch to the tab on every tell",
|
||||
() => Plugin.Config.TellAutoOpenSwitchAlways,
|
||||
v => Plugin.Config.TellAutoOpenSwitchAlways = v
|
||||
);
|
||||
}
|
||||
|
||||
if (ImGui.CollapsingHeader("Sidebar"))
|
||||
@@ -75,7 +78,7 @@ internal sealed class ChannelsTab
|
||||
|
||||
private void DrawTellAutoOpenModeCombo()
|
||||
{
|
||||
var labels = new[] { "Off", "Sidebar", "Top tab", "Popout (lands in v1.8.0)" };
|
||||
var labels = new[] { "Off", "Sidebar", "Top tab", "Popout" };
|
||||
var values = Enum.GetValues<TellAutoOpenMode>();
|
||||
var current = Plugin.Config.TellAutoOpenMode;
|
||||
var selected = 0;
|
||||
@@ -91,9 +94,7 @@ internal sealed class ChannelsTab
|
||||
ImGui.SetNextItemWidth(220);
|
||||
if (ImGui.Combo("Tell auto-open mode", ref selected, labels, labels.Length))
|
||||
{
|
||||
// Popout (index 3) is a v1.8.0 teaser — revert to previous value
|
||||
// and skip SaveConfig.
|
||||
if (selected >= 0 && selected < values.Length && selected != 3)
|
||||
if (selected >= 0 && selected < values.Length)
|
||||
{
|
||||
Plugin.Config.TellAutoOpenMode = values[selected];
|
||||
_plugin.SaveConfig();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Dalamud.Bindings.ImGui;
|
||||
using HellionChat.Util;
|
||||
|
||||
namespace HellionChat.Ui.Components.Settings.Tabs;
|
||||
|
||||
@@ -27,6 +28,23 @@ internal sealed class GeneralTab
|
||||
);
|
||||
}
|
||||
|
||||
if (ImGui.CollapsingHeader("Keybinds", ImGuiTreeNodeFlags.DefaultOpen))
|
||||
{
|
||||
ImGui.TextDisabled("Click a button, then press the key combination. Esc clears.");
|
||||
DrawKeybind(
|
||||
"Cycle to next chat tab",
|
||||
"ChatTabForwardKeybind",
|
||||
() => Plugin.Config.ChatTabForward,
|
||||
v => Plugin.Config.ChatTabForward = v
|
||||
);
|
||||
DrawKeybind(
|
||||
"Cycle to previous chat tab",
|
||||
"ChatTabBackwardKeybind",
|
||||
() => Plugin.Config.ChatTabBackward,
|
||||
v => Plugin.Config.ChatTabBackward = v
|
||||
);
|
||||
}
|
||||
|
||||
if (ImGui.CollapsingHeader("Notifications", ImGuiTreeNodeFlags.DefaultOpen))
|
||||
{
|
||||
DrawToggle(
|
||||
@@ -68,4 +86,27 @@ internal sealed class GeneralTab
|
||||
_plugin.SaveConfig();
|
||||
}
|
||||
}
|
||||
|
||||
// Wires the already-present ImGuiUtil.KeybindInput capture widget (dead/unwired
|
||||
// since the v1.6.0 rewrite) back into the settings, so ChatTabForward/Backward
|
||||
// are bindable again. ConfigKeyBind is a reference type, so a capture (new
|
||||
// instance) or an Esc-clear (null) changes the reference — persist only then.
|
||||
private void DrawKeybind(
|
||||
string label,
|
||||
string id,
|
||||
Func<ConfigKeyBind?> get,
|
||||
Action<ConfigKeyBind?> set
|
||||
)
|
||||
{
|
||||
ImGui.TextUnformatted(label);
|
||||
ImGui.SetNextItemWidth(-1);
|
||||
var keybind = get();
|
||||
var before = keybind;
|
||||
ImGuiUtil.KeybindInput(id, ref keybind);
|
||||
if (!ReferenceEquals(before, keybind))
|
||||
{
|
||||
set(keybind);
|
||||
_plugin.SaveConfig();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,12 +25,17 @@ internal sealed class TopTabBar
|
||||
ImGui.SameLine();
|
||||
|
||||
var selected = ReferenceEquals(tab, activeTab);
|
||||
// Size the selectable to its own label width. A zero width makes ImGui
|
||||
// stretch the selectable's box to the full remaining window width
|
||||
// (imgui_widgets.cpp:7378), so in this SameLine row every tab overlaps
|
||||
// into one giant bar and clicking never lands on the intended tab.
|
||||
var tabWidth = ImGui.CalcTextSize(tab.Name).X;
|
||||
if (
|
||||
ImGui.Selectable(
|
||||
$"{tab.Name}###hellion_toptab_{i}",
|
||||
selected,
|
||||
ImGuiSelectableFlags.None,
|
||||
new Vector2(0, 0)
|
||||
new Vector2(tabWidth, 0)
|
||||
)
|
||||
)
|
||||
{
|
||||
|
||||
@@ -160,6 +160,25 @@ internal sealed class MainWindow : Window
|
||||
TabLifecycleHelpers.OnTabActivated(tab, previous);
|
||||
}
|
||||
|
||||
// Tab-cycle entry point for the ChatTabForward/Backward keybinds. Empty list is a
|
||||
// no-op; a null active tab seeds the index to 0; a single-tab cycle that lands on
|
||||
// the already-active tab is a no-op (ActivateTab early-returns on the same reference).
|
||||
// Routes through ActivateTab so the cycle strips stale tell state + re-derives the
|
||||
// channel exactly like a sidebar/top-tab click. Pop-out focus-forward stays
|
||||
// deferred (no focus contract) — main-window tabs only.
|
||||
internal void ChangeTabDelta(int delta)
|
||||
{
|
||||
var tabs = Plugin.Config.Tabs;
|
||||
if (tabs.Count == 0)
|
||||
return;
|
||||
|
||||
var idx = _activeTab is null ? 0 : tabs.IndexOf(_activeTab);
|
||||
if (idx < 0)
|
||||
idx = 0; // active tab not in the list (mid-strip) -> start from the first
|
||||
|
||||
ActivateTab(tabs[TabLifecycleHelpers.WrapTabIndex(idx, delta, tabs.Count)]);
|
||||
}
|
||||
|
||||
// Internal accessors for self-tests so the probes can reach the live
|
||||
// component without exposing them as public surface.
|
||||
internal Components.Sidebar GetSidebarForSelfTest() => _sidebar;
|
||||
|
||||
@@ -83,4 +83,15 @@ internal static class TabLifecycleHelpers
|
||||
tab.CurrentChannel.TellTarget = null;
|
||||
tab.CurrentChannel.ResetTempChannel();
|
||||
}
|
||||
|
||||
// Wrap-around tab index for keybind cycling. Pure so the Build-Suite can test the
|
||||
// wrap math without a live window. count == 0 returns 0 (the caller dead-zones
|
||||
// before activating); negative deltas wrap correctly via the double-mod.
|
||||
// TEST-MIRROR: ../../../Hellion Build test/_Helpers/TabLifecycleHelpersTests.cs
|
||||
internal static int WrapTabIndex(int current, int delta, int count)
|
||||
{
|
||||
if (count <= 0)
|
||||
return 0;
|
||||
return ((current + delta) % count + count) % count;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user