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;
|
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 =>
|
var byTarget = Plugin.Config.Tabs.FirstOrDefault(t =>
|
||||||
t.IsTempTab
|
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()
|
internal void DropOldestTempTab()
|
||||||
{
|
{
|
||||||
// Pinned tabs live in their own bucket (MaxPinnedTempTabs) and are
|
// Pinned tabs live in their own bucket (MaxPinnedTempTabs) and are
|
||||||
@@ -256,21 +266,20 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
return;
|
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;
|
var dropped = victim.Tab;
|
||||||
Plugin.Config.Tabs.RemoveAt(victim.Index);
|
Plugin.Config.Tabs.RemoveAt(victim.Index);
|
||||||
|
|
||||||
// Re-anchor the UI selection if it pointed at the dropped tab. This runs on
|
// Re-anchor the UI selection if it pointed at the dropped tab, and close any
|
||||||
// the PendingMessage worker thread and the repair mutates the re-seeded
|
// pop-out window the dropped tab owned. Both run on the PendingMessage worker
|
||||||
// tab's channel via OnTabActivated, so marshal it onto the framework thread
|
// thread and touch window state the Draw path reads (OnTabActivated re-seed +
|
||||||
// to serialize with Draw (reference_dalamud_framework_thread) — otherwise a
|
// the pool's Unbind), so marshal onto the framework thread to serialize with
|
||||||
// half-applied strip could race the input bar's send-routing read.
|
// Draw (reference_dalamud_framework_thread). TryClose is idempotent: a tab that
|
||||||
|
// was never popped is a silent no-op.
|
||||||
Plugin.Framework.RunOnFrameworkThread(() =>
|
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)
|
private void SpawnTempTab((string Name, uint World) partner, Message currentMessage)
|
||||||
@@ -282,13 +291,29 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
|
|
||||||
tab.AddMessage(currentMessage, unread: true);
|
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)
|
if (Plugin.Config.AutoTellTabsOpenAsPopout)
|
||||||
{
|
{
|
||||||
tab.PopOut = true;
|
tab.PopOut = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Plugin.Config.Tabs.Add(tab);
|
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)
|
private static Tab BuildTempTab(string playerName, uint worldRowId)
|
||||||
@@ -424,11 +449,18 @@ internal sealed class AutoTellTabsService : IDisposable
|
|||||||
var active = _plugin.MainWindow?.ActiveTab;
|
var active = _plugin.MainWindow?.ActiveTab;
|
||||||
|
|
||||||
var poppedTempTabIds = Plugin
|
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)
|
.Select(t => t.Identifier)
|
||||||
.ToList();
|
.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);
|
Plugin.Config.Tabs.RemoveAll(TabLifecycleHelpers.IsInUnpinnedPool);
|
||||||
|
|
||||||
|
|||||||
@@ -268,6 +268,11 @@ public class Configuration : IPluginConfiguration
|
|||||||
public bool SettingsWindowOpen;
|
public bool SettingsWindowOpen;
|
||||||
public int MaxParallelPopouts = 8;
|
public int MaxParallelPopouts = 8;
|
||||||
public TellAutoOpenMode TellAutoOpenMode = TellAutoOpenMode.Sidebar;
|
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;
|
public int SidebarAutoSwitchThresholdPx = 800;
|
||||||
|
|
||||||
// v22 field: MainWindow layout mode (sidebar vs. horizontal top tabs).
|
// v22 field: MainWindow layout mode (sidebar vs. horizontal top tabs).
|
||||||
@@ -428,6 +433,7 @@ public class Configuration : IPluginConfiguration
|
|||||||
SettingsWindowOpen = other.SettingsWindowOpen;
|
SettingsWindowOpen = other.SettingsWindowOpen;
|
||||||
MaxParallelPopouts = other.MaxParallelPopouts;
|
MaxParallelPopouts = other.MaxParallelPopouts;
|
||||||
TellAutoOpenMode = other.TellAutoOpenMode;
|
TellAutoOpenMode = other.TellAutoOpenMode;
|
||||||
|
TellAutoOpenSwitchAlways = other.TellAutoOpenSwitchAlways;
|
||||||
SidebarAutoSwitchThresholdPx = other.SidebarAutoSwitchThresholdPx;
|
SidebarAutoSwitchThresholdPx = other.SidebarAutoSwitchThresholdPx;
|
||||||
MainWindowLayoutMode = other.MainWindowLayoutMode;
|
MainWindowLayoutMode = other.MainWindowLayoutMode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -232,9 +232,14 @@ internal sealed unsafe class Chat : IDisposable
|
|||||||
if (c != '\0' && !char.IsControl(c))
|
if (c != '\0' && !char.IsControl(c))
|
||||||
input = c.ToString();
|
input = c.ToString();
|
||||||
|
|
||||||
// Chat-window Activated integration is offline until the
|
// Seed the just-typed character into our input field and focus it, the
|
||||||
// new chat layer surfaces an Activated entry point.
|
// same InputBar.AppendPending + Activate prefill path inventory item-links
|
||||||
_ = input;
|
// 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)
|
if (playerName != null)
|
||||||
{
|
{
|
||||||
// Chat-window Activated integration is offline; tell-target
|
// Right-click -> Send Tell: prefill our input the same way our own
|
||||||
// routing returns when the new chat layer is wired up.
|
// "Send Tell" payload menu does (PayloadHandler), then focus. Prefill-
|
||||||
_ = playerName;
|
// only — no tab switch, no ChatActivatedArgs revival (Flo decision
|
||||||
_ = worldId;
|
// 2026-06-15). The game supplies worldName here, so no sheet lookup.
|
||||||
_ = contentId;
|
var tellName = playerName->ToString();
|
||||||
_ = reason;
|
var tellWorld = worldName != null ? worldName->ToString() : string.Empty;
|
||||||
_ = setChatType;
|
var tellCommand = $"/tell {tellName}";
|
||||||
|
if (!string.IsNullOrEmpty(tellWorld))
|
||||||
|
tellCommand += $"@{tellWorld}";
|
||||||
|
tellCommand += " ";
|
||||||
|
Plugin.InputBar.SetPendingMessage(tellCommand);
|
||||||
|
Plugin.InputBar.Activate = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return SetChatLogTellTargetHook!.Original(
|
return SetChatLogTellTargetHook!.Original(
|
||||||
@@ -361,12 +371,17 @@ internal sealed unsafe class Chat : IDisposable
|
|||||||
|
|
||||||
if (playerName != null)
|
if (playerName != null)
|
||||||
{
|
{
|
||||||
// Chat-window Activated integration is offline; tell-target
|
// In-foray right-click -> Send Tell: same prefill path as the non-foray
|
||||||
// routing returns when the new chat layer is wired up.
|
// tell. The foray-specific TellSpecial channel routing stays deferred
|
||||||
_ = playerName;
|
// (v1.8.1, SetEurekaTellChannel) — prefill-only here (Flo decision 2026-06-15).
|
||||||
_ = worldId;
|
var forayName = playerName->ToString();
|
||||||
_ = contentId;
|
var forayWorld = worldName != null ? worldName->ToString() : string.Empty;
|
||||||
_ = reason;
|
var forayCommand = $"/tell {forayName}";
|
||||||
|
if (!string.IsNullOrEmpty(forayWorld))
|
||||||
|
forayCommand += $"@{forayWorld}";
|
||||||
|
forayCommand += " ";
|
||||||
|
Plugin.InputBar.SetPendingMessage(forayCommand);
|
||||||
|
Plugin.InputBar.Activate = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
ContextMenuTellInForayHook!.Original(
|
ContextMenuTellInForayHook!.Original(
|
||||||
|
|||||||
@@ -501,20 +501,47 @@ internal unsafe class KeybindManager : IDisposable
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
Plugin.KeyState[currentBest.Item1] = false;
|
Plugin.KeyState[currentBest.Item1] = false;
|
||||||
if (!KeybindsToIntercept.ContainsKey(currentBest.Item2))
|
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
|
// 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
|
// 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();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tab-cycle dispatch is offline until the new chat layer surfaces a
|
// Prefill text binds (CMD_COMMAND seeds "/"): drop the token into our input.
|
||||||
// ChangeTabDelta entry point and pop-out input bars come back online.
|
if (info.Text is { } text)
|
||||||
|
Plugin.Instance.InputBar.SetPendingMessage(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pop-out input-bar focus-forward stays deferred (no focus contract yet) —
|
||||||
|
// main-window tabs only.
|
||||||
private void DispatchTabDelta(int delta)
|
private void DispatchTabDelta(int delta)
|
||||||
{
|
{
|
||||||
_ = delta;
|
Plugin.Instance.MainWindow?.ChangeTabDelta(delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Keybind GetKeybind(string id)
|
private static Keybind GetKeybind(string id)
|
||||||
|
|||||||
@@ -101,6 +101,18 @@ internal sealed class AutoTellTabsServiceInitHostedService(AutoTellTabsService s
|
|||||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
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
|
// Eager-resolve trigger: resolving FailedTellNotifier in this adapter's ctor
|
||||||
// enables its game hook during host startup. StartAsync itself is a no-op.
|
// enables its game hook during host startup. StartAsync itself is a no-op.
|
||||||
internal sealed class FailedTellNotifierInitHostedService(FailedTellNotifier notifier)
|
internal sealed class FailedTellNotifierInitHostedService(FailedTellNotifier notifier)
|
||||||
|
|||||||
@@ -388,6 +388,7 @@ public sealed class Plugin : IAsyncDalamudPlugin
|
|||||||
new SelfTests.OnOpenMainUiRoutesMainWindowStep(this),
|
new SelfTests.OnOpenMainUiRoutesMainWindowStep(this),
|
||||||
new SelfTests.TypingIpcStateStep(this),
|
new SelfTests.TypingIpcStateStep(this),
|
||||||
new SelfTests.ConfigMigrationV23Step(this),
|
new SelfTests.ConfigMigrationV23Step(this),
|
||||||
|
new SelfTests.ChannelPopoutBindStep(this),
|
||||||
new SelfTests.HoverSheenAllocStep(this),
|
new SelfTests.HoverSheenAllocStep(this),
|
||||||
new SelfTests.HonorificHeaderRenderStep(this),
|
new SelfTests.HonorificHeaderRenderStep(this),
|
||||||
new SelfTests.AboutIntegrationsStatusStep(this),
|
new SelfTests.AboutIntegrationsStatusStep(this),
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ internal static class PluginHostFactory
|
|||||||
sp.GetRequiredService<IFramework>()
|
sp.GetRequiredService<IFramework>()
|
||||||
));
|
));
|
||||||
services.AddSingleton(sp => new Services.TellRouterService(
|
services.AddSingleton(sp => new Services.TellRouterService(
|
||||||
sp.GetRequiredService<IChatGui>(),
|
sp.GetRequiredService<MessageManager>(),
|
||||||
sp.GetRequiredService<ILogger<Services.TellRouterService>>()
|
sp.GetRequiredService<ILogger<Services.TellRouterService>>()
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -369,6 +369,12 @@ internal static class PluginHostFactory
|
|||||||
services.AddHostedService(sp => new AutoTellTabsServiceInitHostedService(
|
services.AddHostedService(sp => new AutoTellTabsServiceInitHostedService(
|
||||||
sp.GetRequiredService<AutoTellTabsService>()
|
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(
|
services.AddHostedService(
|
||||||
sp => new Infrastructure.Hosting.FailedTellNotifierInitHostedService(
|
sp => new Infrastructure.Hosting.FailedTellNotifierInitHostedService(
|
||||||
sp.GetRequiredService<Integrations.FailedTellNotifier>()
|
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 HellionChat.Code;
|
||||||
using Dalamud.Plugin.Services;
|
using HellionChat.Util;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace HellionChat.Services;
|
namespace HellionChat.Services;
|
||||||
|
|
||||||
// Skeleton for the upcoming auto-open routing layer. Subscribes to IChatGui
|
// Routes an incoming tell to the configured TellAutoOpenMode (Off/Sidebar/
|
||||||
// up front so the DI graph and Plugin.cs registration stay frozen — when
|
// TopTab/Popout). Decoupled from AutoTellTabsService (Flo decision 2026-06-15):
|
||||||
// the routing logic lands, it drops into OnChatMessage without touching
|
// that service owns tab CREATION + lifecycle; this only REVEALS/pops the tab it
|
||||||
// anything else.
|
// 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
|
internal sealed class TellRouterService : IDisposable
|
||||||
{
|
{
|
||||||
private readonly IChatGui _chatGui;
|
private readonly MessageManager _messageManager;
|
||||||
private readonly ILogger<TellRouterService> _logger;
|
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;
|
_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()
|
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.Bindings.ImGui;
|
||||||
using Dalamud.Interface.Utility.Raii;
|
|
||||||
|
|
||||||
namespace HellionChat.Ui.Components.Settings.Tabs;
|
namespace HellionChat.Ui.Components.Settings.Tabs;
|
||||||
|
|
||||||
@@ -45,17 +44,21 @@ internal sealed class ChannelsTab
|
|||||||
() => Plugin.Config.AutoTellTabsShowGreetedToggle,
|
() => Plugin.Config.AutoTellTabsShowGreetedToggle,
|
||||||
v => Plugin.Config.AutoTellTabsShowGreetedToggle = v
|
v => Plugin.Config.AutoTellTabsShowGreetedToggle = v
|
||||||
);
|
);
|
||||||
// Popout is a v1.8.0 teaser — render disabled, do NOT persist.
|
DrawToggle(
|
||||||
using (ImRaii.Disabled(true))
|
"Open as popout",
|
||||||
{
|
() => Plugin.Config.AutoTellTabsOpenAsPopout,
|
||||||
var openAsPopout = Plugin.Config.AutoTellTabsOpenAsPopout;
|
v => Plugin.Config.AutoTellTabsOpenAsPopout = v
|
||||||
ImGui.Checkbox("Open as popout (lands in v1.8.0)", ref openAsPopout);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ImGui.CollapsingHeader("Tell auto-open mode", ImGuiTreeNodeFlags.DefaultOpen))
|
if (ImGui.CollapsingHeader("Tell auto-open mode", ImGuiTreeNodeFlags.DefaultOpen))
|
||||||
{
|
{
|
||||||
DrawTellAutoOpenModeCombo();
|
DrawTellAutoOpenModeCombo();
|
||||||
|
DrawToggle(
|
||||||
|
"Switch to the tab on every tell",
|
||||||
|
() => Plugin.Config.TellAutoOpenSwitchAlways,
|
||||||
|
v => Plugin.Config.TellAutoOpenSwitchAlways = v
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ImGui.CollapsingHeader("Sidebar"))
|
if (ImGui.CollapsingHeader("Sidebar"))
|
||||||
@@ -75,7 +78,7 @@ internal sealed class ChannelsTab
|
|||||||
|
|
||||||
private void DrawTellAutoOpenModeCombo()
|
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 values = Enum.GetValues<TellAutoOpenMode>();
|
||||||
var current = Plugin.Config.TellAutoOpenMode;
|
var current = Plugin.Config.TellAutoOpenMode;
|
||||||
var selected = 0;
|
var selected = 0;
|
||||||
@@ -91,9 +94,7 @@ internal sealed class ChannelsTab
|
|||||||
ImGui.SetNextItemWidth(220);
|
ImGui.SetNextItemWidth(220);
|
||||||
if (ImGui.Combo("Tell auto-open mode", ref selected, labels, labels.Length))
|
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
|
if (selected >= 0 && selected < values.Length)
|
||||||
// and skip SaveConfig.
|
|
||||||
if (selected >= 0 && selected < values.Length && selected != 3)
|
|
||||||
{
|
{
|
||||||
Plugin.Config.TellAutoOpenMode = values[selected];
|
Plugin.Config.TellAutoOpenMode = values[selected];
|
||||||
_plugin.SaveConfig();
|
_plugin.SaveConfig();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Dalamud.Bindings.ImGui;
|
using Dalamud.Bindings.ImGui;
|
||||||
|
using HellionChat.Util;
|
||||||
|
|
||||||
namespace HellionChat.Ui.Components.Settings.Tabs;
|
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))
|
if (ImGui.CollapsingHeader("Notifications", ImGuiTreeNodeFlags.DefaultOpen))
|
||||||
{
|
{
|
||||||
DrawToggle(
|
DrawToggle(
|
||||||
@@ -68,4 +86,27 @@ internal sealed class GeneralTab
|
|||||||
_plugin.SaveConfig();
|
_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();
|
ImGui.SameLine();
|
||||||
|
|
||||||
var selected = ReferenceEquals(tab, activeTab);
|
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 (
|
if (
|
||||||
ImGui.Selectable(
|
ImGui.Selectable(
|
||||||
$"{tab.Name}###hellion_toptab_{i}",
|
$"{tab.Name}###hellion_toptab_{i}",
|
||||||
selected,
|
selected,
|
||||||
ImGuiSelectableFlags.None,
|
ImGuiSelectableFlags.None,
|
||||||
new Vector2(0, 0)
|
new Vector2(tabWidth, 0)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -160,6 +160,25 @@ internal sealed class MainWindow : Window
|
|||||||
TabLifecycleHelpers.OnTabActivated(tab, previous);
|
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
|
// Internal accessors for self-tests so the probes can reach the live
|
||||||
// component without exposing them as public surface.
|
// component without exposing them as public surface.
|
||||||
internal Components.Sidebar GetSidebarForSelfTest() => _sidebar;
|
internal Components.Sidebar GetSidebarForSelfTest() => _sidebar;
|
||||||
|
|||||||
@@ -83,4 +83,15 @@ internal static class TabLifecycleHelpers
|
|||||||
tab.CurrentChannel.TellTarget = null;
|
tab.CurrentChannel.TellTarget = null;
|
||||||
tab.CurrentChannel.ResetTempChannel();
|
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