diff --git a/HellionChat/Services/TellRouterService.cs b/HellionChat/Services/TellRouterService.cs index ce081d0..4133c86 100644 --- a/HellionChat/Services/TellRouterService.cs +++ b/HellionChat/Services/TellRouterService.cs @@ -75,36 +75,38 @@ internal sealed class TellRouterService : IDisposable 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(); - } + // 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. A + // tab that is already popped out needs no reveal at all -- it is on + // screen, and pulling the main window onto it costs the user the tab + // they were reading. + var reveal = TabLifecycleHelpers.PlanTellReveal( + mode, + Plugin.Config.TellAutoOpenSwitchAlways, + Plugin.Instance.ChannelPopoutPool.IsOpen(tab.Identifier) + ); - Plugin.Instance.MainWindow?.ActivateTab(tab); + switch (reveal) + { + case TabLifecycleHelpers.TellReveal.MainWindow: + // The mode also picks the layout, so Sidebar vs TopTab are + // actually distinct outcomes, not the same ActivateTab. + 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); + + case TabLifecycleHelpers.TellReveal.Popout: + Plugin.Instance.ChannelPopoutPool.TryOpen(tab); break; } }); diff --git a/HellionChat/Ui/Windows/MainWindow.cs b/HellionChat/Ui/Windows/MainWindow.cs index b162b93..737d73c 100644 --- a/HellionChat/Ui/Windows/MainWindow.cs +++ b/HellionChat/Ui/Windows/MainWindow.cs @@ -162,6 +162,14 @@ internal sealed class MainWindow : Window, IFocusableChatWindow // header pick strips tell-state and resets unread the way a real click does. internal void ActivateTab(Tab tab) { + // A popped-out tab is not a surface this window owns. Taking it as + // active does not show it -- PickMainActiveTab re-anchors on the next + // frame, and it anchors to the first non-popped tab, which is not the + // one the user was reading. Callers that mean "bring it forward" have + // to reach for the pool instead. + if (_pool.IsOpen(tab.Identifier)) + return; + if (ReferenceEquals(_activeTab, tab)) return; diff --git a/HellionChat/Util/TabLifecycleHelpers.cs b/HellionChat/Util/TabLifecycleHelpers.cs index b8fb729..fb0a4f3 100644 --- a/HellionChat/Util/TabLifecycleHelpers.cs +++ b/HellionChat/Util/TabLifecycleHelpers.cs @@ -180,6 +180,39 @@ internal static class TabLifecycleHelpers return null; } + // What an incoming tell should do about the tab it belongs to. + internal enum TellReveal + { + None, + MainWindow, + Popout, + } + + // POP-1f: the alreadyPopped case is the whole reason this is a function. + // + // Revealing a popped-out tab in the main window looks like it does nothing, + // and then does something worse: PickMainActiveTab re-anchors on the next + // frame, and it anchors to the FIRST non-popped tab, not to the one the user + // was reading. So a tell from a partner whose tab is popped out threw the + // main window back to the first tab every single time. + // + // The tab is already on screen in its own window. There is nothing to + // reveal. + // + // Pure + Dalamud-free. + // TEST-MIRROR: ../../../Hellion Build test/_Helpers/PlanTellRevealTests.cs + internal static TellReveal PlanTellReveal( + TellAutoOpenMode mode, + bool switchAlways, + bool alreadyPopped + ) => + mode switch + { + TellAutoOpenMode.Off => TellReveal.None, + TellAutoOpenMode.Popout => alreadyPopped ? TellReveal.None : TellReveal.Popout, + _ => switchAlways && !alreadyPopped ? TellReveal.MainWindow : TellReveal.None, + }; + // 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