feat(sidebar): restore section headers and compact separators

This commit is contained in:
2026-06-10 16:22:49 +02:00
parent 540cb7ac52
commit dc51e295ea
3 changed files with 192 additions and 11 deletions
+1
View File
@@ -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
@@ -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<Tab>();
if (Plugin.Config.Tabs.All(t => t.IsTempTab))
{
injected.Add(
new Tab
{
Name = "Persistent Probe@SelfTest",
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
{
[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, (ChatSource, ChatSource)>
{
[ChatType.TellIncoming] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.TellOutgoing] = (ChatSourceExt.All, ChatSourceExt.All),
},
};
}
+73 -11
View File
@@ -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<string, FontAwesomeIcon> IconByName = new(
@@ -100,6 +105,7 @@ internal sealed class Sidebar
public void Draw(float windowWidth, IList<Tab> 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<int> BuildRenderOrder(IList<Tab> tabs)
{
var persistent = new List<int>(tabs.Count);
var pinned = new List<int>();
var unpinned = new List<int>();
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(