From ad892cbcb6b236db092877914593293472e108a4 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Fri, 29 May 2026 14:06:41 +0200 Subject: [PATCH] feat(layout): add top-tabs layout mode and shared channel resolver --- HellionChat/PluginHostFactory.cs | 4 ++ .../Ui/Components/Settings/Tabs/WindowTab.cs | 14 +++-- HellionChat/Ui/Components/Sidebar.cs | 21 +------- HellionChat/Ui/Components/TopTabBar.cs | 51 +++++++++++++++++++ HellionChat/Ui/Windows/MainWindow.cs | 14 +++++ HellionChat/Util/TabLifecycleHelpers.cs | 19 +++++++ 6 files changed, 98 insertions(+), 25 deletions(-) create mode 100644 HellionChat/Ui/Components/TopTabBar.cs diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index a78a221..5fc04ec 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -201,9 +201,13 @@ internal static class PluginHostFactory sp.GetRequiredService(), sp.GetRequiredService() )); + services.AddSingleton(sp => new Ui.Components.TopTabBar( + sp.GetRequiredService() + )); services.AddSingleton(sp => new Ui.Windows.MainWindow( sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), diff --git a/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs b/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs index 76aba36..0da2b9c 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs @@ -1,5 +1,4 @@ using Dalamud.Bindings.ImGui; -using Dalamud.Interface.Utility.Raii; namespace HellionChat.Ui.Components.Settings.Tabs; @@ -16,11 +15,16 @@ internal sealed class WindowTab { if (ImGui.CollapsingHeader("Layout mode", ImGuiTreeNodeFlags.DefaultOpen)) { - // Sidebar is the v1.7.0 default; TopTabs is a v1.8.0 teaser. - ImGui.RadioButton("Sidebar", true); - using (ImRaii.Disabled(true)) + var mode = Plugin.Config.MainWindowLayoutMode; + if (ImGui.RadioButton("Sidebar", mode == MainWindowLayoutMode.Sidebar)) { - ImGui.RadioButton("Top tabs (lands in v1.8.0)", false); + Plugin.Config.MainWindowLayoutMode = MainWindowLayoutMode.Sidebar; + _plugin.SaveConfig(); + } + if (ImGui.RadioButton("Top tabs", mode == MainWindowLayoutMode.TopTabs)) + { + Plugin.Config.MainWindowLayoutMode = MainWindowLayoutMode.TopTabs; + _plugin.SaveConfig(); } } diff --git a/HellionChat/Ui/Components/Sidebar.cs b/HellionChat/Ui/Components/Sidebar.cs index 4805829..e99c55e 100644 --- a/HellionChat/Ui/Components/Sidebar.cs +++ b/HellionChat/Ui/Components/Sidebar.cs @@ -134,7 +134,7 @@ internal sealed class Sidebar if (ImGui.IsItemClicked()) { activeTab = tab; - EnsureCurrentChannel(tab); + TabLifecycleHelpers.EnsureCurrentChannel(tab); } dl.DrawHoverSheen( @@ -252,23 +252,4 @@ internal sealed class Sidebar ChatType.CustomEmote or ChatType.StandardEmote => FontAwesomeIcon.Comments, _ => FontAwesomeIcon.Comment, }; - - // Pick a sensible input channel for the tab if it has none yet — - // walking SelectedChannels for the first key with a ToInputChannel - // mapping lets the channel pill render the tab's actual channel - // instead of falling back to "—" on first activation. - private static void EnsureCurrentChannel(Tab tab) - { - if (tab.CurrentChannel.Channel != InputChannel.Invalid) - return; - - foreach (var chatType in tab.SelectedChannels.Keys) - { - if (chatType.ToInputChannel() is { } input) - { - tab.CurrentChannel.SetChannel(input); - return; - } - } - } } diff --git a/HellionChat/Ui/Components/TopTabBar.cs b/HellionChat/Ui/Components/TopTabBar.cs new file mode 100644 index 0000000..71291e7 --- /dev/null +++ b/HellionChat/Ui/Components/TopTabBar.cs @@ -0,0 +1,51 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using HellionChat.Util; + +namespace HellionChat.Ui.Components; + +// Horizontal tab strip — the alternative MainWindow layout to the Sidebar. +// Selection drives the same shared EnsureCurrentChannel path; pop-out is the +// same pool.TryOpen affordance as the sidebar (right-click context menu). +internal sealed class TopTabBar +{ + private readonly Windows.ChannelPopoutPool _pool; + + public TopTabBar(Windows.ChannelPopoutPool pool) + { + _pool = pool; + } + + public void Draw(IList tabs, ref Tab? activeTab) + { + for (var i = 0; i < tabs.Count; i++) + { + var tab = tabs[i]; + if (i > 0) + ImGui.SameLine(); + + var selected = ReferenceEquals(tab, activeTab); + if ( + ImGui.Selectable( + $"{tab.Name}###hellion_toptab_{i}", + selected, + ImGuiSelectableFlags.None, + new Vector2(0, 0) + ) + ) + { + activeTab = tab; + TabLifecycleHelpers.EnsureCurrentChannel(tab); + } + + if (ImGui.BeginPopupContextItem($"toptab_ctx_{i}")) + { + if (ImGui.MenuItem("Pop Out")) + _pool.TryOpen(tab); + ImGui.EndPopup(); + } + } + + ImGui.Separator(); + } +} diff --git a/HellionChat/Ui/Windows/MainWindow.cs b/HellionChat/Ui/Windows/MainWindow.cs index 5fcd4da..d9251ba 100644 --- a/HellionChat/Ui/Windows/MainWindow.cs +++ b/HellionChat/Ui/Windows/MainWindow.cs @@ -25,6 +25,7 @@ internal sealed class MainWindow : Window private readonly Components.HonorificHeader _honorific; private readonly Components.Sidebar _sidebar; + private readonly Components.TopTabBar _topTabs; private readonly Components.MessageList _messages; private readonly Components.InputBar _input; private readonly Components.StatusBar _status; @@ -39,6 +40,7 @@ internal sealed class MainWindow : Window public MainWindow( Components.HonorificHeader honorific, Components.Sidebar sidebar, + Components.TopTabBar topTabs, Components.MessageList messages, Components.InputBar input, Components.StatusBar status, @@ -48,6 +50,7 @@ internal sealed class MainWindow : Window { _honorific = honorific; _sidebar = sidebar; + _topTabs = topTabs; _messages = messages; _input = input; _status = status; @@ -121,6 +124,17 @@ internal sealed class MainWindow : Window var bodyWidth = ImGui.GetContentRegionAvail().X; _honorific.Draw(bodyWidth); + if (Plugin.Config.MainWindowLayoutMode == MainWindowLayoutMode.TopTabs) + { + _topTabs.Draw(Plugin.Config.Tabs, ref _activeTab); + using (ImRaii.Group()) + { + DrawMainArea(); + } + return; + } + + // Sidebar layout (default). using (ImRaii.Group()) { _sidebar.Draw(bodyWidth, Plugin.Config.Tabs, ref _activeTab); diff --git a/HellionChat/Util/TabLifecycleHelpers.cs b/HellionChat/Util/TabLifecycleHelpers.cs index 058bdfb..16145c8 100644 --- a/HellionChat/Util/TabLifecycleHelpers.cs +++ b/HellionChat/Util/TabLifecycleHelpers.cs @@ -1,3 +1,5 @@ +using HellionChat.Code; + namespace HellionChat.Util; // Pure predicates for the TempTab pin lifecycle. Extracted from the strip @@ -13,4 +15,21 @@ internal static class TabLifecycleHelpers public static bool ShouldStripOnLoad(Tab t) => IsInUnpinnedPool(t); public static bool ShouldStripOnSave(Tab t) => IsInUnpinnedPool(t); + + // Shared by the click paths (Sidebar, TopTabBar) and the keybind tab-cycle + // path so every entry point resolves a tab's channel identically (no drift). + internal static void EnsureCurrentChannel(Tab tab) + { + if (tab.CurrentChannel.Channel != InputChannel.Invalid) + return; + + foreach (var chatType in tab.SelectedChannels.Keys) + { + if (chatType.ToInputChannel() is { } input) + { + tab.CurrentChannel.SetChannel(input); + return; + } + } + } }