diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index 06a9c32..d700eaf 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -231,7 +231,8 @@ internal static class PluginHostFactory sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService>() + sp.GetRequiredService>(), + sp.GetRequiredService() )); services.AddSingleton(sp => new Integrations.FailedTellNotifier( sp.GetRequiredService>() diff --git a/HellionChat/Ui/Components/Sidebar.cs b/HellionChat/Ui/Components/Sidebar.cs index 9228044..19b255b 100644 --- a/HellionChat/Ui/Components/Sidebar.cs +++ b/HellionChat/Ui/Components/Sidebar.cs @@ -134,7 +134,10 @@ internal sealed class Sidebar // the tab list itself stays untouched and every row keeps its // ORIGINAL list index for PushID, so an open context-menu popup // stays bound to its tab when sectioning moves it visually. - var renderOrder = BuildRenderOrder(tabs); + var renderOrder = TabLifecycleHelpers.BuildRenderOrder( + tabs, + t => _pool.IsOpen(t.Identifier) + ); var pinnedHeaderRendered = false; var unpinnedHeaderRendered = false; foreach (var i in renderOrder) @@ -186,30 +189,6 @@ internal sealed class Sidebar LastDrawnSectionHeaderCount++; } - // Mirror of 1.5.6's BuildSidebarRenderOrder: returns indices into the - // live tab list grouped by section, so the list order itself is never - // mutated and headers gate on the first tab actually reached per pool - // (an empty pool draws neither separator nor header). - private static List BuildRenderOrder(IList tabs) - { - var persistent = new List(tabs.Count); - var pinned = new List(); - var unpinned = new List(); - for (var i = 0; i < tabs.Count; i++) - { - if (TabLifecycleHelpers.IsInPinnedPool(tabs[i])) - pinned.Add(i); - else if (TabLifecycleHelpers.IsInUnpinnedPool(tabs[i])) - unpinned.Add(i); - else - persistent.Add(i); - } - - persistent.AddRange(pinned); - persistent.AddRange(unpinned); - return persistent; - } - private void DrawRow( Tab tab, int index, diff --git a/HellionChat/Ui/Components/TopTabBar.cs b/HellionChat/Ui/Components/TopTabBar.cs index 5010335..c7c4e00 100644 --- a/HellionChat/Ui/Components/TopTabBar.cs +++ b/HellionChat/Ui/Components/TopTabBar.cs @@ -18,11 +18,18 @@ internal sealed class TopTabBar public void Draw(IList tabs, ref Tab? activeTab) { + var firstDrawn = true; for (var i = 0; i < tabs.Count; i++) { var tab = tabs[i]; - if (i > 0) + // POP-1b: a popped-out tab is owned by its pop-out window, not the main + // strip (1.5.6 exclusivity). Gate on the pool, not Tab.PopOut (stale flag). + if (_pool.IsOpen(tab.Identifier)) + continue; + + if (!firstDrawn) ImGui.SameLine(); + firstDrawn = false; var selected = ReferenceEquals(tab, activeTab); // Size the selectable to its own label width. A zero width makes ImGui diff --git a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs index 7d0aa3b..a7fa4e1 100644 --- a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs +++ b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs @@ -111,6 +111,11 @@ internal sealed class ChannelPopoutWindow : Window, IFocusableChatWindow if (Bound is null) return; + // POP-1f: the bound tab is live-visible in this pop-out, so it carries no + // unread badge — mirror MainWindow's per-frame zero for the active tab. + // View-state reset only (tab.Messages store is untouched). + Bound.Unread = 0; + var inputHeight = InputBar.Height; using ( var body = ImRaii.Child( diff --git a/HellionChat/Ui/Windows/MainWindow.cs b/HellionChat/Ui/Windows/MainWindow.cs index d8c2900..6c5dcac 100644 --- a/HellionChat/Ui/Windows/MainWindow.cs +++ b/HellionChat/Ui/Windows/MainWindow.cs @@ -30,6 +30,7 @@ internal sealed class MainWindow : Window, IFocusableChatWindow private readonly Components.InputBar _input; private readonly Components.StatusBar _status; private readonly Lender _handlerLender; + private readonly ChannelPopoutPool _pool; private Tab? _activeTab; @@ -51,7 +52,8 @@ internal sealed class MainWindow : Window, IFocusableChatWindow Components.MessageList messages, Components.InputBar input, Components.StatusBar status, - Lender handlerLender + Lender handlerLender, + ChannelPopoutPool pool ) : base($"{Plugin.PluginName}###hellion-main") { @@ -62,6 +64,7 @@ internal sealed class MainWindow : Window, IFocusableChatWindow _input = input; _status = status; _handlerLender = handlerLender; + _pool = pool; Size = new Vector2(DefaultWidth, DefaultHeight); SizeCondition = ImGuiCond.FirstUseEver; @@ -176,7 +179,13 @@ internal sealed class MainWindow : Window, IFocusableChatWindow 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)]); + var nextIndex = TabLifecycleHelpers.NextMainTabIndex( + idx, + delta, + tabs, + t => _pool.IsOpen(t.Identifier) + ); + ActivateTab(tabs[nextIndex]); } // Internal accessors for self-tests so the probes can reach the live @@ -270,6 +279,25 @@ internal sealed class MainWindow : Window, IFocusableChatWindow TabLifecycleHelpers.OnTabActivated(reseed, active); } + // POP-1c: a popped-out tab must not stay the main window's active surface + // (1.5.6 exclusivity). Re-anchor to the first non-popped tab the moment the + // active one is popped; null when every tab is popped (POP-1d guards Draw). + // Runs post-seed, before the sidebar/top-tab draw, so the popped tab never + // renders. Idempotent: PickMainActiveTab returns the same reference once + // settled, so OnTabActivated fires only on the pop frame. + var visibleActive = TabLifecycleHelpers.PickMainActiveTab( + _activeTab, + Plugin.Config.Tabs, + t => _pool.IsOpen(t.Identifier) + ); + if (!ReferenceEquals(visibleActive, _activeTab)) + { + var previousActive = _activeTab; + _activeTab = visibleActive; + if (visibleActive is not null) + TabLifecycleHelpers.OnTabActivated(visibleActive, previousActive); + } + // The active tab's messages are on screen, so it carries no unread badge // (1.5.6 convention: zero the current tab every frame so the dot only ever // shows on tabs you are NOT looking at). @@ -336,8 +364,8 @@ internal sealed class MainWindow : Window, IFocusableChatWindow ) ) { - if (messages.Success) - _messages.Draw(_activeTab!); + if (messages.Success && _activeTab is not null) + _messages.Draw(_activeTab); } // Inside-mode inline render: measure first so PreviewHeight is fresh diff --git a/HellionChat/Util/TabLifecycleHelpers.cs b/HellionChat/Util/TabLifecycleHelpers.cs index d95ddcb..103f482 100644 --- a/HellionChat/Util/TabLifecycleHelpers.cs +++ b/HellionChat/Util/TabLifecycleHelpers.cs @@ -107,4 +107,82 @@ internal static class TabLifecycleHelpers return 0; return ((current + delta) % count + count) % count; } + + // Sectioned sidebar render order (1.5.6 parity): persistent → pinned TempTabs → + // unpinned TempTabs. Returns indices into the live tab list so the list order is + // never mutated and DrawRow keeps each tab's ORIGINAL index for PushID. POP-1a: + // isPoppedOut excludes tabs bound to a pop-out window — an excluded tab draws no + // row and its pool's section header gates on the first tab actually reached. Pure + // + Dalamud-free so the Build-Suite can pin it. + // TEST-MIRROR: ../../../Hellion Build test/_Helpers/SidebarRenderOrderTests.cs + internal static List BuildRenderOrder(IList tabs, Func isPoppedOut) + { + var persistent = new List(tabs.Count); + var pinned = new List(); + var unpinned = new List(); + for (var i = 0; i < tabs.Count; i++) + { + if (isPoppedOut(tabs[i])) + continue; + if (IsInPinnedPool(tabs[i])) + pinned.Add(i); + else if (IsInUnpinnedPool(tabs[i])) + unpinned.Add(i); + else + persistent.Add(i); + } + + persistent.AddRange(pinned); + persistent.AddRange(unpinned); + return persistent; + } + + // POP-1c: returns the tab the main window should display — the current tab if it + // is not popped out, else the FIRST non-popped tab in list order, else null when + // every tab is popped. NOTE: deliberately NOT ResetActiveTabIfRemoved's + // unconditional Tabs[0] — Tabs[0] may itself be popped and would re-trigger the + // re-anchor every frame. Pure + Dalamud-free. + // TEST-MIRROR: ../../../Hellion Build test/_Helpers/PickMainActiveTabTests.cs + internal static Tab? PickMainActiveTab( + Tab? current, + IList tabs, + Func isPoppedOut + ) + { + if (current is not null && !isPoppedOut(current)) + return current; + foreach (var tab in tabs) + if (!isPoppedOut(tab)) + return tab; + return null; + } + + // POP-1e: popout-aware sibling of WrapTabIndex for the ChatTabForward/Backward + // keybind. Steps from current by delta's sign (±1), wrapping, and returns the + // first index whose tab is NOT popped out within tabs.Count steps; returns current + // when every other tab is popped (no-op), the list is empty, or a single tab. + // Skipping popped tabs here is what stops the POP-1c re-anchor from making the + // cycle stick (CYCLE-1). Pure + Dalamud-free. + // TEST-MIRROR: ../../../Hellion Build test/_Helpers/NextMainTabIndexTests.cs + internal static int NextMainTabIndex( + int current, + int delta, + IList tabs, + Func isPoppedOut + ) + { + if (tabs.Count == 0) + return current; + var step = delta >= 0 ? 1 : -1; + var idx = current; + for (var n = 0; n < tabs.Count; n++) + { + idx = ((idx + step) % tabs.Count + tabs.Count) % tabs.Count; + if (idx == current) + break; + if (!isPoppedOut(tabs[idx])) + return idx; + } + return current; + } }