Files
HellionChat/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs
T
JonKazama-Hellion 56a9f3f474 fix(privacy): the header gave away what the log was hiding
Screenshot mode anonymises sender names in the message list. The channel header
I added yesterday sat above that list and showed two things it should not.

The home world, on the right. It narrows a player down almost as far as the
character name does, and the mode exists so a picture can be shared.

Worse, the tab name on the left. AutoTellTabsService builds a tell tab's name as
"Player@World", so a tell conversation had the partner's name and world set in
tracked caps directly above a log where every message had been anonymised. The
one place a reader looks first was the one place still naming them.

The name is suppressed only where it actually names someone -- a tab with a tell
target set. General or Trade stay readable, because they identify nobody, and a
self-named tab is the user's own text.

Found by asking what the new surface shows rather than by a test failing. Nothing
here was failing.
2026-08-19 11:42:13 +02:00

232 lines
8.9 KiB
C#

using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface;
using Dalamud.Interface.ManagedFontAtlas;
using HellionChat.Util;
namespace HellionChat.Ui.StyleEngine.Widgets;
// The band above the conversation: which channel you are in on the left, where
// you are and what time it is on the right.
//
// Set apart by small caps with wide tracking rather than by size. That is a
// deliberate departure from the mockup, which asks for one pixel smaller than
// body text: one pixel would cost a whole additional font handle at full glyph
// range, because tab names are free user input and can be CJK. Tracking carries
// the same weight in every palette and costs nothing.
//
// Which face draws what is not a style choice here, it is a constraint. The meta
// face has a glyph range of ASCII plus a middle dot, so only the world name and
// the clock can use it. The tab name and the translated "no world" stand-in go
// through the body face, or they come out as rows of question marks.
internal static class ChannelHeader
{
private const float InsetRaw = 14f;
private const float PadYRaw = 7f;
private const float TrackRaw = 1.8f;
private const float DetailTrackRaw = 0.9f;
private const float IconGapRaw = 8f;
// Measured, never a fixed 32px: the band has to hold a line of text, and the
// font comes from Config, which display scaling does not feed into.
internal static float Height =>
ImGui.GetTextLineHeight() + MathF.Round(PadYRaw * 2f * Metrics.Scale);
// The body face follows the same switch every other push site follows. Two
// settings decide it, and reading only one of them is how a window ends up
// half in the game font and half in the bundled one.
private static IFontHandle BodyFace(FontManager fonts) =>
Plugin.Config.FontsEnabled || Plugin.Config.UseHellionFont
? fonts.RegularFont!
: fonts.Axis;
// Same switch for the meta face. With the game font selected there is no
// stepped-down variant to fall back to, so the size distinction is simply
// dropped -- the same honest limitation the sender weight has.
private static IFontHandle MetaFace(FontManager fonts) =>
Plugin.Config.FontsEnabled || Plugin.Config.UseHellionFont ? fonts.MetaFont! : fonts.Axis;
internal static void Draw(
Tab tab,
ChannelHeaderMode mode,
FontManager fonts,
ChannelHeaderDetailParts detail,
float reservedBelow
)
{
var scale = Metrics.Scale;
var width = ImGui.GetContentRegionAvail().X;
var height = Height;
var origin = ImGui.GetCursorScreenPos();
var theme = Plugin.Instance.ThemeRegistry.Active;
var surface = theme.Colors.Surface;
var body = BodyFace(fonts);
var meta = MetaFace(fonts);
var icon = Components.Sidebar.ResolveTabIcon(tab);
var track = TrackRaw * scale;
var detailTrack = DetailTrackRaw * scale;
// Screenshot mode hides the name of a tell tab, because that name IS the
// conversation partner: AutoTellTabsService builds it as "Player@World".
// Drawing it in tracked caps above a log whose messages are anonymised
// would give away in the header exactly what the log is hiding.
var namesAPartner = tab.TellTarget?.IsSet() == true;
var showName =
mode is ChannelHeaderMode.Full && !(Plugin.Config.ScreenshotMode && namesAPartner);
// ToUpperInvariant allocates, so only where the name is actually drawn.
var name = showName ? tab.Name.ToUpperInvariant() : string.Empty;
Vector2 iconSize;
using (fonts.FontAwesome.Push())
iconSize = ImGui.CalcTextSize(icon.ToIconString());
var nameRun = 0f;
if (showName)
{
using (body.Push())
nameRun = DrawListExtensions.MeasureTrackedText(name, track);
nameRun += iconSize.X + IconGapRaw * scale;
}
// Measured under the face that will draw it, which is the whole point of
// splitting the detail in two: the stand-in is translated and the meta
// face cannot render most of those translations.
float whereRun;
using ((detail.WhereIsTranslated ? body : meta).Push())
whereRun = DrawListExtensions.MeasureTrackedText(detail.Where, detailTrack);
// No separator with nothing in front of it -- screenshot mode leaves the
// clock standing alone.
var rest =
detail.Where.Length == 0
? detail.Clock
: ChannelHeaderDetailParts.Separator + detail.Clock;
float restRun;
using (meta.Push())
restRun = DrawListExtensions.MeasureTrackedText(rest, detailTrack);
var detailRun = whereRun + restRun;
var inset = InsetRaw * scale;
var plan = ChannelHeaderLayout.Plan(
showName ? ChannelHeaderMode.Full : ChannelHeaderMode.DetailOnly,
width - inset * 2f,
ImGui.GetContentRegionAvail().Y - height - reservedBelow,
nameRun,
detailRun,
scale
);
if (!plan.ShowHeader)
return;
var dl = ImGui.GetWindowDrawList();
var bottomRight = origin + new Vector2(width, height);
dl.AddRectFilled(origin, bottomRight, ColourUtil.RgbaToAbgr(surface));
dl.AddLine(
new Vector2(origin.X, bottomRight.Y - 1f),
new Vector2(bottomRight.X, bottomRight.Y - 1f),
ColourUtil.RgbaToAbgr(theme.Colors.Border),
MathF.Max(1f, scale)
);
var textY = origin.Y + MathF.Round(PadYRaw * scale);
if (plan.ShowName)
{
var accent = ColourUtil.RgbaToAbgr(
ColourUtil.EnsureContrast(theme.Colors.Accent, surface, 4.5f)
);
var x = origin.X + inset;
// Centred against the band, not aligned to the text baseline:
// FontAwesome is a fixed-width handle built at Dalamud's own size and
// does not follow Config.FontSizeV2, so the text line height would
// misplace it at any other body size. Same reasoning as the sidebar.
using (fonts.FontAwesome.Push())
dl.AddText(
new Vector2(x, origin.Y + MetricsMath.CenterY(height, iconSize.Y)),
accent,
icon.ToIconString()
);
x += iconSize.X + IconGapRaw * scale;
using (body.Push())
dl.DrawTrackedText(new Vector2(x, textY), name, accent, track);
}
if (plan.ShowDetail)
{
var muted = ColourUtil.RgbaToAbgr(
ColourUtil.EnsureContrast(theme.Colors.TextMuted, surface, 4.5f)
);
var whereFace = detail.WhereIsTranslated ? body : meta;
var x = bottomRight.X - inset - detailRun;
using (whereFace.Push())
dl.DrawTrackedText(
new Vector2(x, textY + DropFor(body, whereFace, scale)),
detail.Where,
muted,
detailTrack
);
x += whereRun;
using (meta.Push())
dl.DrawTrackedText(
new Vector2(x, textY + DropFor(body, meta, scale)),
rest,
muted,
detailTrack
);
}
// ItemSize rather than a cursor move: it advances the cursor AND extends
// CursorMaxPos, which is what the enclosing layout measures.
ImGui.SetCursorScreenPos(origin);
ImGuiP.ItemSize(new Vector2(width, height - ImGui.GetStyle().ItemSpacing.Y));
}
internal static ChannelHeaderDetailParts CurrentDetail()
{
// IsValid guards the row reference, but the case that actually happens is
// subtler: logged out resolves to row zero, which exists and carries an
// empty name. Format treats blank as missing, which covers both.
var world = Plugin.PlayerState.HomeWorld.IsValid
? Plugin.PlayerState.HomeWorld.Value.Name.ExtractText()
: null;
return ChannelHeaderDetail.Format(
world,
DateTimeOffset.Now,
Plugin.Config.Use24HourClock,
Resources.HellionStrings.ChannelHeader_NotLoggedIn,
Plugin.Config.ScreenshotMode
);
}
// Zero whenever both runs use the same handle, which is the common case.
private static float DropFor(IFontHandle body, IFontHandle other, float scale)
{
if (ReferenceEquals(body, other))
return 0f;
float bodyAscent;
using (body.Push())
bodyAscent = ImGui.GetFont().Ascent;
float otherAscent;
using (other.Push())
otherAscent = ImGui.GetFont().Ascent;
return BaselineMath.OffsetFor(bodyAscent, otherAscent, scale);
}
}