fix(unread): restore the tab unread badge and fix the post-F2 unread decision

The v1.8.x sidebar/top-bar rebuild never re-rendered the unread dot, so inactive tabs showed no badge even though the counter was tracked. Draw it again top-right of the tab icon in both Sidebar and TopTabBar, gated on !active && UnreadMode != None && Unread > 0, and zero the active tab's counter every frame (1.5.6 convention) so the dot only ever shows on tabs you are not looking at.

The unread decision moves to MessageManager.ShouldCountUnread and snapshots the active tab + whether it shows the message once before the loop: Unseen suppresses unread on an inactive tab only when the active (real, post-F2) tab also shows that message. Adds SidebarUnreadDotStep (render) and UnreadDecisionStep (decision) self-tests (step count 32 -> 34).
This commit is contained in:
2026-06-13 18:49:27 +02:00
parent 0ca8513065
commit 5dfe8e3b49
7 changed files with 221 additions and 8 deletions
+20 -6
View File
@@ -331,15 +331,15 @@ internal class MessageManager : IAsyncDisposable
if (Plugin.Config.DatabaseBattleMessages || !message.Code.IsBattle())
Store.UpsertMessage(message);
var currentMatches = Plugin.CurrentTab.Matches(message);
// Snapshot the active tab and whether it shows this message ONCE, so the
// whole loop sees a consistent value (the getter is a cross-thread read of
// MainWindow.ActiveTab).
var currentTab = Plugin.CurrentTab;
var currentTabMatches = currentTab.Matches(message);
foreach (var tab in Plugin.Config.Tabs)
{
var unread = !(
tab.UnreadMode == UnreadMode.Unseen && Plugin.CurrentTab != tab && currentMatches
);
if (tab.Matches(message))
tab.AddMessage(message, unread);
tab.AddMessage(message, ShouldCountUnread(tab, currentTab, currentTabMatches));
}
// Deliberate O(2n): the sound pick re-walks the tab list so the selection
@@ -385,6 +385,20 @@ internal class MessageManager : IAsyncDisposable
// match wins" semantics live here via the running 'picked is null' guard,
// keeping a message matching several background tabs from stacking sounds.
// TEST-MIRROR: ../_Helpers/TabSoundDecision.cs
// Unseen ("count only what you haven't seen") suppresses unread on an inactive
// tab when the active tab ALSO shows this message — you already saw it in the
// tab you're looking at (1.5.6 / upstream ChatTwo behavior). Pre-F2 the "active
// tab" was wrongly pinned to Tabs[0], so this fired against the wrong tab; F2
// recoupled CurrentTab to the REAL active tab, so currentTabMatches is now
// measured against the tab you actually see. All -> always counts; None ->
// counts here and is gated out at the display layer. Pure + SelfTest-able.
internal static bool ShouldCountUnread(Tab tab, Tab currentTab, bool currentTabMatches) =>
!(
tab.UnreadMode == UnreadMode.Unseen
&& !ReferenceEquals(currentTab, tab)
&& currentTabMatches
);
internal static uint? SelectNotificationSound(
IEnumerable<Tab> tabs,
Tab currentTab,
+2
View File
@@ -402,6 +402,8 @@ public sealed class Plugin : IAsyncDalamudPlugin
new SelfTests.ScrollSnapDecisionStep(this),
new SelfTests.TellResetOnActivateStep(),
new SelfTests.CurrentTabCouplingStep(this),
new SelfTests.SidebarUnreadDotStep(this),
new SelfTests.UnreadDecisionStep(),
new SelfTests.CurrentTabGuidedStep(this),
]);
@@ -0,0 +1,77 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Plugin.SelfTest;
using HellionChat.Code;
namespace HellionChat.SelfTests;
// F3: the unread dot the v1.8.x sidebar rebuild dropped. Drives the REAL
// Sidebar.Draw (render precedent: SidebarGreetedGlyphStep) with a probe tab that
// is inactive and carries Unread>0, then reads the render-observability counter
// so a regressed/absent dot fails. Asserts: dot drawn for an inactive Unseen tab;
// NOT drawn for UnreadMode.None. Uses a local one-tab list so the count is
// unambiguous; restores SidebarWidth in finally.
internal sealed class SidebarUnreadDotStep : ISelfTestStep
{
private readonly Plugin _plugin;
public SidebarUnreadDotStep(Plugin plugin) => _plugin = plugin;
public string Name => "Hellion Chat - Sidebar unread dot";
public SelfTestStepResult RunStep()
{
var sidebar = _plugin.MainWindow.GetSidebarForSelfTest();
if (sidebar is null)
{
ImGui.Text("Sidebar null");
return SelfTestStepResult.Fail;
}
var probe = new Tab
{
Name = "Unread Probe@SelfTest",
UnreadMode = UnreadMode.Unseen,
Unread = 3,
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
{
[ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All),
},
};
var list = new List<Tab> { probe };
Tab? active = null; // probe is NOT the active tab
var width = (float)Plugin.Config.SidebarAutoSwitchThresholdPx + 100f; // expanded
var savedWidth = Plugin.Config.SidebarWidth;
try
{
Plugin.Config.SidebarWidth = 220;
// (a) an inactive Unseen tab with Unread>0 draws exactly one dot
// (the one-tab list makes the expected count unambiguous).
sidebar.Draw(width, list, ref active);
if (sidebar.LastRenderedUnreadDotCount != 1)
{
ImGui.Text(
$"Expected exactly 1 unread dot, got {sidebar.LastRenderedUnreadDotCount}"
);
return SelfTestStepResult.Fail;
}
// (b) UnreadMode.None opts the tab out — no dot.
probe.UnreadMode = UnreadMode.None;
sidebar.Draw(width, list, ref active);
if (sidebar.LastRenderedUnreadDotCount != 0)
{
ImGui.Text("Unread dot drawn for an UnreadMode.None tab");
return SelfTestStepResult.Fail;
}
}
finally
{
Plugin.Config.SidebarWidth = savedWidth;
}
return SelfTestStepResult.Pass;
}
public void CleanUp() { }
}
@@ -0,0 +1,66 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Plugin.SelfTest;
namespace HellionChat.SelfTests;
// F3: the unread decision (MessageManager.ShouldCountUnread). Unseen suppresses
// unread on an inactive tab only when the active tab ALSO shows the message (you
// saw it there) — 1.5.6/upstream semantics, now measured against the REAL active
// tab thanks to F2. Asserts the truth table: suppressed when active tab also
// matches; counts when it does not (the Carla/Jin case); All always counts; None
// counts at the increment layer (the display gate hides it).
internal sealed class UnreadDecisionStep : ISelfTestStep
{
public string Name => "Hellion Chat - Unread decision (per active tab)";
public SelfTestStepResult RunStep()
{
var active = new Tab { Name = "active", UnreadMode = UnreadMode.Unseen };
var inactive = new Tab { Name = "inactive", UnreadMode = UnreadMode.Unseen };
// (a) inactive Unseen tab + the active tab ALSO shows the message
// (currentTabMatches=true) => suppressed (you saw it in the active tab).
if (MessageManager.ShouldCountUnread(inactive, active, currentTabMatches: true))
{
ImGui.Text("(a) inactive Unseen tab must be suppressed when active tab also shows it");
return SelfTestStepResult.Fail;
}
// (b) inactive Unseen tab + the active tab does NOT show the message
// (currentTabMatches=false) => counts (badge). The Carla/Jin case.
if (!MessageManager.ShouldCountUnread(inactive, active, currentTabMatches: false))
{
ImGui.Text("(b) inactive Unseen tab must count when the active tab does not show it");
return SelfTestStepResult.Fail;
}
// (c) the active tab itself counts here (current==tab short-circuits the
// suppression); the draw loop zeroes it so no dot is ever shown.
if (!MessageManager.ShouldCountUnread(active, active, currentTabMatches: true))
{
ImGui.Text("(c) active tab should count at the increment layer (draw loop zeroes it)");
return SelfTestStepResult.Fail;
}
// (d) All-mode always counts, regardless of currentTabMatches.
var all = new Tab { Name = "all", UnreadMode = UnreadMode.All };
if (!MessageManager.ShouldCountUnread(all, active, currentTabMatches: true))
{
ImGui.Text("(d) All-mode tab should always count unread");
return SelfTestStepResult.Fail;
}
// (e) None counts at the increment layer (the None opt-out lives in the
// display gate, not here).
var none = new Tab { Name = "none", UnreadMode = UnreadMode.None };
if (!MessageManager.ShouldCountUnread(none, active, currentTabMatches: true))
{
ImGui.Text("(e) None should count at the increment layer (display gates it)");
return SelfTestStepResult.Fail;
}
return SelfTestStepResult.Pass;
}
public void CleanUp() { }
}
+30 -2
View File
@@ -35,6 +35,7 @@ internal sealed class Sidebar
// Incremented ONLY in the real glyph branch in DrawRow; reset at Draw start.
// The SelfTest reads it after driving the real Draw — no dead service roundtrip.
internal int LastRenderedGreetedGlyphCount;
internal int LastRenderedUnreadDotCount;
// B3-4 render observability: section headers actually drawn this frame.
// Incremented only in the real header branch; reset at Draw start.
@@ -105,6 +106,7 @@ internal sealed class Sidebar
public void Draw(float windowWidth, IList<Tab> tabs, ref Tab? activeTab)
{
LastRenderedGreetedGlyphCount = 0;
LastRenderedUnreadDotCount = 0;
LastDrawnSectionHeaderCount = 0;
if (!_fonts.FontsReady)
@@ -124,6 +126,7 @@ internal sealed class Sidebar
var textAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary);
var mutedAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextMuted);
var dimAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextDim);
var dangerAbgr = ColourUtil.RgbaToAbgr(theme.Colors.StatusDanger);
var dl = ImGui.GetWindowDrawList();
// B3-4 sectioned render order (1.5.6 parity): persistent → pinned
@@ -154,7 +157,18 @@ internal sealed class Sidebar
unpinnedHeaderRendered = true;
}
DrawRow(tab, i, expanded, accentRgba, textAbgr, mutedAbgr, dimAbgr, dl, ref activeTab);
DrawRow(
tab,
i,
expanded,
accentRgba,
textAbgr,
mutedAbgr,
dimAbgr,
dangerAbgr,
dl,
ref activeTab
);
}
}
@@ -204,6 +218,7 @@ internal sealed class Sidebar
uint textAbgr,
uint mutedAbgr,
uint dimAbgr,
uint dangerAbgr,
ImDrawListPtr dl,
ref Tab? activeTab
)
@@ -276,7 +291,20 @@ internal sealed class Sidebar
// Icon and label shift right by the greeted slot when it is shown.
var contentX = showGreeted ? GreetedHitWidth : 0f;
using (_fonts.FontAwesome.Push())
dl.AddText(origin + new Vector2(10f + contentX, 8f), iconColor, icon.ToIconString());
{
var iconStr = icon.ToIconString();
dl.AddText(origin + new Vector2(10f + contentX, 8f), iconColor, iconStr);
// 1.5.6-parity unread dot, top-right of the icon. The active tab is
// zeroed every frame (MainWindow.Draw), so the dot never shows on the
// tab you're viewing; UnreadMode.None opts a tab out entirely.
if (!isCurrentTab && tab.UnreadMode != UnreadMode.None && tab.Unread > 0)
{
var iconRight = 10f + contentX + ImGui.CalcTextSize(iconStr).X;
dl.AddCircleFilled(origin + new Vector2(iconRight - 2f, 6f), 4f, dangerAbgr, 12);
LastRenderedUnreadDotCount++;
}
}
if (expanded)
dl.AddText(origin + new Vector2(32f + contentX, 8f), textAbgr, tab.Name);
+20
View File
@@ -39,6 +39,26 @@ internal sealed class TopTabBar
TabLifecycleHelpers.OnTabActivated(tab, previous);
}
// 1.5.6-parity unread dot at the item's top-right. Gate on the
// POST-click selection (not the frame-start 'selected') so clicking a
// tab suppresses its dot the same frame, like the sidebar. The active
// tab is also zeroed every frame (MainWindow.Draw).
if (
!ReferenceEquals(tab, activeTab)
&& tab.UnreadMode != UnreadMode.None
&& tab.Unread > 0
)
{
var max = ImGui.GetItemRectMax();
var min = ImGui.GetItemRectMin();
var danger = ColourUtil.RgbaToAbgr(
Plugin.Instance.ThemeRegistry.Active.Colors.StatusDanger
);
ImGui
.GetWindowDrawList()
.AddCircleFilled(new Vector2(max.X - 4f, min.Y + 4f), 3.5f, danger, 12);
}
TabContextMenu.Draw(tab, $"toptab_ctx_{i}", _pool);
}
+6
View File
@@ -192,6 +192,12 @@ internal sealed class MainWindow : Window
TabLifecycleHelpers.OnTabActivated(reseed, active);
}
// 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).
if (_activeTab is { } seenTab)
seenTab.Unread = 0;
var statusHeight = Components.StatusBar.Height;
using (var body = ImRaii.Child("##hellion-body", new Vector2(-1f, -statusHeight)))