diff --git a/HellionChat/MessageManager.cs b/HellionChat/MessageManager.cs index 3838632..2acf577 100644 --- a/HellionChat/MessageManager.cs +++ b/HellionChat/MessageManager.cs @@ -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 tabs, Tab currentTab, diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 5f8e9d9..f00ab25 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -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), ]); diff --git a/HellionChat/SelfTests/SidebarUnreadDotStep.cs b/HellionChat/SelfTests/SidebarUnreadDotStep.cs new file mode 100644 index 0000000..fc8489d --- /dev/null +++ b/HellionChat/SelfTests/SidebarUnreadDotStep.cs @@ -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.Say] = (ChatSourceExt.All, ChatSourceExt.All), + }, + }; + var list = new List { 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() { } +} diff --git a/HellionChat/SelfTests/UnreadDecisionStep.cs b/HellionChat/SelfTests/UnreadDecisionStep.cs new file mode 100644 index 0000000..82222e9 --- /dev/null +++ b/HellionChat/SelfTests/UnreadDecisionStep.cs @@ -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() { } +} diff --git a/HellionChat/Ui/Components/Sidebar.cs b/HellionChat/Ui/Components/Sidebar.cs index 1511b86..3bacbee 100644 --- a/HellionChat/Ui/Components/Sidebar.cs +++ b/HellionChat/Ui/Components/Sidebar.cs @@ -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 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); diff --git a/HellionChat/Ui/Components/TopTabBar.cs b/HellionChat/Ui/Components/TopTabBar.cs index a0148c5..767417f 100644 --- a/HellionChat/Ui/Components/TopTabBar.cs +++ b/HellionChat/Ui/Components/TopTabBar.cs @@ -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); } diff --git a/HellionChat/Ui/Windows/MainWindow.cs b/HellionChat/Ui/Windows/MainWindow.cs index 8c6b671..2dd3cbf 100644 --- a/HellionChat/Ui/Windows/MainWindow.cs +++ b/HellionChat/Ui/Windows/MainWindow.cs @@ -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)))