diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index eb932a3..c4dc42b 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -403,6 +403,7 @@ public sealed class Plugin : IAsyncDalamudPlugin new SelfTests.TabRenamePersistStep(this), new SelfTests.NotificationSoundSelectStep(), new SelfTests.SidebarGreetedGlyphStep(this), + new SelfTests.SidebarSectionHeaderStep(this), ]); // Re-surface the wizard for existing users when a major UX diff --git a/HellionChat/SelfTests/SidebarSectionHeaderStep.cs b/HellionChat/SelfTests/SidebarSectionHeaderStep.cs new file mode 100644 index 0000000..dbaedd3 --- /dev/null +++ b/HellionChat/SelfTests/SidebarSectionHeaderStep.cs @@ -0,0 +1,118 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; +using HellionChat.Code; +using HellionChat.GameFunctions.Types; + +namespace HellionChat.SelfTests; + +// B3-4: section headers render once per non-empty temp-tab pool, and compact +// mode suppresses the header text (separators stay). Drives the REAL +// Sidebar.Draw inside the /xlperf window frame (same render precedent as +// SidebarGreetedGlyphStep) and reads the render observability counter. +// Injects a mixed tab set (persistent + unpinned temp + pinned temp) and +// restores config in finally. +internal sealed class SidebarSectionHeaderStep : ISelfTestStep +{ + private readonly Plugin plugin; + + public SidebarSectionHeaderStep(Plugin plugin) => this.plugin = plugin; + + public string Name => "Hellion Chat - Sidebar section header"; + + public SelfTestStepResult RunStep() + { + var sidebar = plugin.MainWindow.GetSidebarForSelfTest(); + if (sidebar is null) + { + ImGui.Text("Sidebar null"); + return SelfTestStepResult.Fail; + } + + var savedCompact = Plugin.Config.AutoTellTabsCompactDisplay; + var savedSidebarWidth = Plugin.Config.SidebarWidth; + + // Both headers need a populated pool behind them. Persistent tabs + // normally already exist — inject a probe only when the live config + // has none, so the section order has a real first section. + var injected = new List(); + if (Plugin.Config.Tabs.All(t => t.IsTempTab)) + { + injected.Add( + new Tab + { + Name = "Persistent Probe@SelfTest", + SelectedChannels = new Dictionary + { + [ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All), + }, + } + ); + } + injected.Add(BuildTempProbe("Tell Probe@SelfTest", pinned: false)); + injected.Add(BuildTempProbe("Pinned Probe@SelfTest", pinned: true)); + foreach (var tab in injected) + Plugin.Config.Tabs.Add(tab); + + Tab? active = null; + var width = (float)Plugin.Config.SidebarAutoSwitchThresholdPx + 100f; // expanded + try + { + // Headers are not width-gated, but the pinned width keeps the step + // uniform with SidebarGreetedGlyphStep (expanded rows, no min-drag + // row drops while the probes render). + Plugin.Config.SidebarWidth = 220; + + Plugin.Config.AutoTellTabsCompactDisplay = false; + sidebar.Draw(width, Plugin.Config.Tabs, ref active); + if (sidebar.LastDrawnSectionHeaderCount != 2) + { + ImGui.Text( + $"Expected 2 section headers with compact OFF, got {sidebar.LastDrawnSectionHeaderCount}" + ); + return SelfTestStepResult.Fail; + } + + Plugin.Config.AutoTellTabsCompactDisplay = true; + sidebar.Draw(width, Plugin.Config.Tabs, ref active); + if (sidebar.LastDrawnSectionHeaderCount != 0) + { + ImGui.Text( + $"Compact ON must suppress header text, got {sidebar.LastDrawnSectionHeaderCount}" + ); + return SelfTestStepResult.Fail; + } + } + finally + { + foreach (var tab in injected) + Plugin.Config.Tabs.Remove(tab); + Plugin.Config.AutoTellTabsCompactDisplay = savedCompact; + Plugin.Config.SidebarWidth = savedSidebarWidth; + } + + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } + + // Mirror of AutoTellTabsService.BuildTempTab (the real builder is + // private); only the sheet-based tab name is replaced with a literal. + private static Tab BuildTempProbe(string name, bool pinned) => + new() + { + Name = name, + IsTempTab = true, + IsPinned = pinned, + AllSenderMessages = true, + TellTarget = new TellTarget(name, 0, 0, TellReason.Direct), + Channel = InputChannel.Tell, + DisplayTimestamp = true, + UnreadMode = UnreadMode.Unseen, + HideWhenInactive = false, + SelectedChannels = new Dictionary + { + [ChatType.TellIncoming] = (ChatSourceExt.All, ChatSourceExt.All), + [ChatType.TellOutgoing] = (ChatSourceExt.All, ChatSourceExt.All), + }, + }; +} diff --git a/HellionChat/Ui/Components/Sidebar.cs b/HellionChat/Ui/Components/Sidebar.cs index b02ca2f..7a969d0 100644 --- a/HellionChat/Ui/Components/Sidebar.cs +++ b/HellionChat/Ui/Components/Sidebar.cs @@ -3,6 +3,7 @@ using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Utility.Raii; using HellionChat.Code; +using HellionChat.Resources; using HellionChat.Themes; using HellionChat.Ui.StyleEngine; using HellionChat.Util; @@ -35,6 +36,10 @@ internal sealed class Sidebar // The SelfTest reads it after driving the real Draw — no dead service roundtrip. internal int LastRenderedGreetedGlyphCount; + // B3-4 render observability: section headers actually drawn this frame. + // Incremented only in the real header branch; reset at Draw start. + internal int LastDrawnSectionHeaderCount; + // Inline mirror of the old TabIconMapping table so the Ui layer carries // its own glyph lookup once the standalone file is removed. private static readonly Dictionary IconByName = new( @@ -100,6 +105,7 @@ internal sealed class Sidebar public void Draw(float windowWidth, IList tabs, ref Tab? activeTab) { LastRenderedGreetedGlyphCount = 0; + LastDrawnSectionHeaderCount = 0; if (!_fonts.FontsReady) { @@ -120,18 +126,74 @@ internal sealed class Sidebar var dimAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextDim); var dl = ImGui.GetWindowDrawList(); + // B3-4 sectioned render order (1.5.6 parity): persistent → pinned + // TempTabs → unpinned TempTabs. Only the display sequence regroups; + // 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 pinnedHeaderRendered = false; + var unpinnedHeaderRendered = false; + foreach (var i in renderOrder) + { + var tab = tabs[i]; + if (TabLifecycleHelpers.IsInPinnedPool(tab) && !pinnedHeaderRendered) + { + DrawSectionHeader( + HellionStrings.PinTab_SectionHeader, + Plugin.Instance.AutoTellTabsService.PinnedTempTabCount + ); + pinnedHeaderRendered = true; + } + else if (TabLifecycleHelpers.IsInUnpinnedPool(tab) && !unpinnedHeaderRendered) + { + DrawSectionHeader( + HellionStrings.AutoTellTabs_SectionHeader, + Plugin.Instance.AutoTellTabsService.ActiveTempTabCount + ); + unpinnedHeaderRendered = true; + } + + DrawRow(tab, i, expanded, accentRgba, textAbgr, mutedAbgr, dimAbgr, dl, ref activeTab); + } + } + + // Section transition marker (1.5.6 parity): the separator always renders, + // compact mode suppresses only the header text. Real cursor-advancing + // widgets on purpose — rows advance the cursor via InvisibleButton, so a + // drawlist-only header would overlap the next row. + private void DrawSectionHeader(string header, int count) + { + ImGui.Separator(); + if (Plugin.Config.AutoTellTabsCompactDisplay) + return; + + ImGui.TextDisabled($"{header} ({count})"); + 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++) - DrawRow( - tabs[i], - i, - expanded, - accentRgba, - textAbgr, - mutedAbgr, - dimAbgr, - dl, - ref activeTab - ); + { + 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(