Merge branch 'feature/v1.8.0-closeout' into feature/v1.8.0

This commit is contained in:
2026-06-16 09:07:57 +02:00
14 changed files with 434 additions and 63 deletions
+48 -16
View File
@@ -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);
+6
View File
@@ -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;
}
+31 -16
View File
@@ -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(
+32 -5
View File
@@ -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)
+1
View File
@@ -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),
+7 -1
View File
@@ -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() { }
}
+93 -13
View File
@@ -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();
}
}
}
+6 -1
View File
@@ -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)
)
)
{
+19
View File
@@ -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;
+11
View File
@@ -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;
}
}