diff --git a/HellionChat/Ui/Components/Sidebar.cs b/HellionChat/Ui/Components/Sidebar.cs index 1a2c890..64bc5cc 100644 --- a/HellionChat/Ui/Components/Sidebar.cs +++ b/HellionChat/Ui/Components/Sidebar.cs @@ -394,7 +394,15 @@ internal sealed class Sidebar ImGuiUtil.Tooltip(HellionStrings.PinTab_PinnedTooltip); if (expanded) - dl.AddText(origin + new Vector2(iconRight + 6f * scale, contentY), textAbgr, tab.Name); + dl.AddText( + origin + new Vector2(iconRight + 6f * scale, contentY), + textAbgr, + TabDisplayName.Resolve( + tab.Name, + tab.NameCameFromPartner, + Plugin.Config.ScreenshotMode + ) + ); // Unread count. Drawn outside the icon-font scope on purpose: the // FontAwesome atlas carries no ASCII digits, so the number would come out diff --git a/HellionChat/Ui/Components/TopTabBar.cs b/HellionChat/Ui/Components/TopTabBar.cs index 2411aa8..afa7100 100644 --- a/HellionChat/Ui/Components/TopTabBar.cs +++ b/HellionChat/Ui/Components/TopTabBar.cs @@ -71,11 +71,19 @@ internal sealed class TopTabBar !ReferenceEquals(tab, activeTab) && tab.UnreadMode != UnreadMode.None && tab.Unread > 0; + // Resolved once: measuring one string and drawing another would size + // every tab wrong the moment screenshot mode is on. + var label = Util.TabDisplayName.Resolve( + tab.Name, + tab.NameCameFromPartner, + Plugin.Config.ScreenshotMode + ); + var unread = showUnread ? (int)Math.Min(tab.Unread, int.MaxValue) : 0; var badgeSize = showUnread ? Badge.CalcSize(unread, TabBadge) : Vector2.Zero; var width = - ImGui.CalcTextSize(tab.Name).X + ImGui.CalcTextSize(label).X + padX * 2f + (showUnread ? badgeSize.X + Metrics.TopTabUnreadInset : 0f); var size = WidgetGeometry.IconButton(width, height); @@ -102,7 +110,7 @@ internal sealed class TopTabBar dl, origin, size, - tab.Name, + label, selected, hoverAmount, surfaceActive, diff --git a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs index 5901d7c..9bb8a1b 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs @@ -75,17 +75,20 @@ internal static class ChannelHeader var track = TrackRaw * scale; var detailTrack = DetailTrackRaw * scale; - // Screenshot mode hides a name that came from a conversation partner: + // Screenshot mode replaces a name that came from a conversation partner: // AutoTellTabsService builds those as "Player@World", and drawing one in // tracked caps above a log whose messages are anonymised would give away // in the header exactly what the log is hiding. // - // Reading TellTarget or IsTempTab here would miss the case that matters - // most. StripTellBindingOnPromote clears both and keeps the name, so a - // promoted tell tab is called "Player@World" for good while carrying - // neither marker. The flag is set where the name is built and survives - // that. + // Same helper the sidebar, the tab strip and the pop-out title use, so + // one conversation shows the same placeholder everywhere rather than + // vanishing on one surface and staying put on three. var namesAPartner = tab.NameCameFromPartner; + var shownName = TabDisplayName.Resolve( + tab.Name, + namesAPartner, + Plugin.Config.ScreenshotMode + ); // The tab icon for an auto-tell tab is derived from the partner and is // stable across sessions, so it is three bits of linkable information on @@ -95,11 +98,10 @@ internal static class ChannelHeader Plugin.Config.ScreenshotMode && namesAPartner ? Dalamud.Interface.FontAwesomeIcon.Envelope : Components.Sidebar.ResolveTabIcon(tab); - var showName = - mode is ChannelHeaderMode.Full && !(Plugin.Config.ScreenshotMode && namesAPartner); + var showName = mode is ChannelHeaderMode.Full; // ToUpperInvariant allocates, so only where the name is actually drawn. - var name = showName ? tab.Name.ToUpperInvariant() : string.Empty; + var name = showName ? shownName.ToUpperInvariant() : string.Empty; Vector2 iconSize; using (fonts.FontAwesome.Push()) diff --git a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs index 5c01d54..834f7cd 100644 --- a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs +++ b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs @@ -84,7 +84,14 @@ internal sealed class ChannelPopoutWindow : Window, IFocusableChatWindow // Visible label tracks the bound tab; the ###id stays slot-stable so // ImGui keeps this slot's position/size across binds. - WindowName = $"{tab.Name}###hellion_popout_{_slotIndex}"; + // The title bar is a surface too, and the header deliberately stays + // silent in this mode because the title already carries the name. + var label = Util.TabDisplayName.Resolve( + tab.Name, + tab.NameCameFromPartner, + Plugin.Config.ScreenshotMode + ); + WindowName = $"{label}###hellion_popout_{_slotIndex}"; IsOpen = true; } diff --git a/HellionChat/Util/TabDisplayName.cs b/HellionChat/Util/TabDisplayName.cs new file mode 100644 index 0000000..963471a --- /dev/null +++ b/HellionChat/Util/TabDisplayName.cs @@ -0,0 +1,41 @@ +namespace HellionChat.Util; + +// TEST-MIRROR: Util/TabDisplayNameTests.cs +// +// One place that knows whether a tab name may be shown. Four surfaces draw it -- +// the sidebar, the top-tab strip, a pop-out's window title and the channel header +// -- and before this each of them decided on its own, which meant three of them +// decided nothing at all. +// +// The name matters because an auto-tell tab is called "Player@World". In +// screenshot mode the message list anonymises every sender, so a tab name left +// alone puts the conversation partner back on screen in the one place a reader +// looks first. +// +// Replaced rather than blanked: a nameless tab in a strip of tabs is worse to use +// than a placeholder, and the sidebar has no room to explain itself. The salt is +// the same idea the message path uses -- fresh per plugin load, so two +// screenshots taken weeks apart cannot be tied together by a stable label. +internal static class TabDisplayName +{ + private static readonly string Salt = new Random().Next().ToString(); + + internal static string Resolve(string name, bool cameFromPartner, bool screenshotMode) => + Resolve(name, cameFromPartner, screenshotMode, Salt); + + // Salt as a parameter so the rule can be tested without depending on a value + // that is random by design. + internal static string Resolve( + string name, + bool cameFromPartner, + bool screenshotMode, + string salt + ) + { + if (!screenshotMode || !cameFromPartner) + return name; + + var hash = $"{salt}{name}".GetHashCode(); + return $"Player {hash:X8}"; + } +}