From 3b5001e616eaa63082a2345dad423095c3ab13d5 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 17:29:20 +0200 Subject: [PATCH] feat(chat): local and server time side by side, and a quieter header Both clocks in the status bar, in the game's own LT/ST notation. Anyone agreeing on a time across regions reads them off one line instead of doing the arithmetic in their head. Server time is not computed here and must not be. Framework.GetServerTime() hands it over, so the plugin follows whatever Square Enix does with it -- a local UTC conversion would be right today and quietly wrong the day that stops holding. Umbra reads it the same way. The slot drops out on its own like every other one, and it is empty while logged out, because then there is no server to read a clock off. The header gives up its clock in exchange. With both times in the status bar it would have been the third copy of the same number on one screen, and the header's job is to answer where you are, not what time it is. Its title also moves down to the meta size. Tracked caps at body size read like a headline, and the header is meant to answer a question rather than announce one. That only works because the meta face now carries the full glyph range: it was built with an ASCII-sized one on the assumption it would only ever draw clocks and world names, and a single umlaut in a tab name would have broken it. All three delegate handles rasterise the full set now -- the honest cost of the change, and the reason the size distinction is worth having at all. Two things fell out along the way. The detail no longer needs a flag saying "draw me in the body face", because there is no glyph the meta face cannot reach. And a culture-pinning test lost its subject when the clock left, so it asserted nothing and is gone rather than repaired. --- HellionChat/FontManager.cs | 18 ++--- HellionChat/Ui/Components/ClockPair.cs | 43 +++++++++++ HellionChat/Ui/Components/StatusBar.cs | 27 +++++++ .../Ui/StyleEngine/Widgets/ChannelHeader.cs | 59 ++++++--------- .../Widgets/ChannelHeaderDetail.cs | 72 ++++--------------- HellionChat/Ui/Windows/WidgetGalleryWindow.cs | 2 - 6 files changed, 111 insertions(+), 110 deletions(-) create mode 100644 HellionChat/Ui/Components/ClockPair.cs diff --git a/HellionChat/FontManager.cs b/HellionChat/FontManager.cs index fd09a69..b9e6013 100644 --- a/HellionChat/FontManager.cs +++ b/HellionChat/FontManager.cs @@ -51,6 +51,12 @@ public sealed class FontManager : IDisposable // rasterisation of the same outline. 1.0 is the SafeFontConfig default; // below ~1.2 the difference is not visible, above ~1.4 the glyphs smear. // + // Note on cost: all three delegate handles now carry the full glyph range. + // The meta face started with an ASCII-sized one, on the assumption it would + // only ever draw clocks and world names -- then the channel header wanted + // to use it, and tab names are free user input. An umlaut would have been + // enough to break it. + // // Not const: the smoke test compares three values side by side, and the // widget gallery exposes it. internal static float SenderWeight = 1.3f; @@ -93,13 +99,6 @@ public sealed class FontManager : IDisposable // by the global font, so the fallback no longer re-merges the full Ranges array. private ushort[] CjkFallbackGlyphRange = []; - // The meta role draws clock faces, world names and a separator. FFXIV world - // names are Latin in every client, so ASCII plus the middle dot covers it. - // A full range here would rasterise the whole CJK set a second time for - // eighty glyphs' worth of use. Anything translated -- the header's stand-in - // when no world is known -- goes through the body face instead. - private static readonly ushort[] MetaRange = [0x0020, 0x007E, 0x00B7, 0x00B7, 0]; - // Report accessor for the ctor self-test: built glyph-range array lengths so // the step can show the B1 dedup effect (a small trimmed fallback vs the large // primary range) in its on-disk report instead of a bare Pass. @@ -330,13 +329,14 @@ public sealed class FontManager : IDisposable e.OnPreBuild(tk => { var basePt = TypeScale.SizePtOf(TypeRole.Meta, ResolveGlobalFontPt()); - var config = new SafeFontConfig { SizePt = basePt, GlyphRanges = MetaRange }; + var config = new SafeFontConfig { SizePt = basePt, GlyphRanges = Ranges }; var bundledBytes = Plugin.Config.UseHellionFont ? TryGetBundledFontBytes() : null; config.MergeFont = bundledBytes is not null ? tk.AddFontFromMemory(bundledBytes, config, "Inter-Light-Meta") : AddFontWithFallback(tk, Plugin.Config.GlobalFontV2.FontId, config, "meta"); - // No CJK merge on purpose: MetaRange cannot reach those glyphs. + AddCjkAndSymbols(tk, config, basePt); + tk.Font = config.MergeFont; }) ); diff --git a/HellionChat/Ui/Components/ClockPair.cs b/HellionChat/Ui/Components/ClockPair.cs new file mode 100644 index 0000000..4131dbd --- /dev/null +++ b/HellionChat/Ui/Components/ClockPair.cs @@ -0,0 +1,43 @@ +using System.Globalization; + +namespace HellionChat.Ui.Components; + +// TEST-MIRROR: Ui/ClockPairTests.cs +// +// Local time and server time side by side, in the notation the game itself uses: +// LT and ST. Anyone coordinating across regions reads both off one line instead +// of doing the arithmetic in their head. +// +// Server time is not computed here and must not be. The game hands it over +// through Framework.GetServerTime(), and taking it from there means the plugin +// follows whatever Square Enix does with it. A local UTC conversion would be +// right today and silently wrong the day that stops holding. +// +// Culture is pinned for the same reason the message timestamps pin it: a German +// machine renders PM as nachm. under its own culture, and the two clocks sit +// next to each other. +internal static class ClockPair +{ + internal const string Separator = " · "; + + internal static string Format(DateTimeOffset local, TimeSpan server, bool use24Hour) + { + var localText = FormatOne(local.Hour, local.Minute, use24Hour); + var serverText = FormatOne(server.Hours, server.Minutes, use24Hour); + + return $"LT {localText}{Separator}ST {serverText}"; + } + + private static string FormatOne(int hour, int minute, bool use24Hour) + { + if (use24Hour) + return $"{hour:00}:{minute:00}"; + + var suffix = hour < 12 ? "AM" : "PM"; + var shown = hour % 12; + if (shown == 0) + shown = 12; + + return string.Create(CultureInfo.InvariantCulture, $"{shown}:{minute:00} {suffix}"); + } +} diff --git a/HellionChat/Ui/Components/StatusBar.cs b/HellionChat/Ui/Components/StatusBar.cs index a83d6d6..b7c17b1 100644 --- a/HellionChat/Ui/Components/StatusBar.cs +++ b/HellionChat/Ui/Components/StatusBar.cs @@ -43,6 +43,7 @@ internal sealed class StatusBar private long _lastUpdateMs = -UpdateIntervalMs; private string _cachedCountsText = string.Empty; private string _cachedTellsText = string.Empty; + private string _cachedClockText = string.Empty; public StatusBar(ThemeRegistry themes, FontManager fonts) { @@ -105,6 +106,21 @@ internal sealed class StatusBar return (_cachedCountsText, _cachedTellsText); } + // Server time comes from the game rather than from a UTC conversion here. + // Framework.GetServerTime() is a unix stamp; only the time of day is + // wanted, so the date part is dropped. Empty while not logged in -- + // there is no server to read a clock off. + private static string FormatClocks() + { + if (!Plugin.ClientState.IsLoggedIn) + return string.Empty; + + var stamp = FFXIVClientStructs.FFXIV.Client.System.Framework.Framework.GetServerTime(); + var server = TimeSpan.FromSeconds(stamp % 86400); + + return ClockPair.Format(DateTimeOffset.Now, server, Plugin.Config.Use24HourClock); + } + private void UpdateCacheIfDue(long now, int tabs, int messages, int tells) { if (now - _lastUpdateMs < UpdateIntervalMs) @@ -128,6 +144,11 @@ internal sealed class StatusBar { var (messages, tells) = AggregateForStatusBar(tabs); UpdateCacheIfDue(now, tabs.Count, messages, tells); + + // Deliberately not inside UpdateCacheIfDue: that path is a pure + // helper the build suite exercises without a running game, and + // reading the client state there throws on a null service. + _cachedClockText = FormatClocks(); } // Top border via DrawList — ImGui.Separator has too much padding for @@ -199,6 +220,12 @@ internal sealed class StatusBar if (!string.IsNullOrEmpty(_cachedTellsText) && Fits(_cachedTellsText, withDot: false)) x += DrawSlot(new Vector2(x, top), _cachedTellsText, pillFill, pillText, null) + gap; + // Both clocks in the game's own LT/ST notation, so nobody has to do the + // arithmetic while agreeing on a time across regions. Drops out on its + // own like every other slot. + if (!string.IsNullOrEmpty(_cachedClockText) && Fits(_cachedClockText, withDot: false)) + x += DrawSlot(new Vector2(x, top), _cachedClockText, pillFill, mutedText, null) + gap; + // Slot 5: version + brand, right-aligned. Dropped when the left-hand run // would actually collide with it -- the old check compared against a flat // 200px and never measured the left slots at all. diff --git a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs index 12584d6..8668c7c 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs @@ -114,12 +114,16 @@ internal static class ChannelHeader var nameRun = 0f; if (showName) { + // Meta rather than body: tracked caps at body size read as loud as a + // headline, and the header is meant to answer a question, not + // announce one. Only possible because the meta face carries the full + // glyph range now -- tab names are free user input. // Tab names are free user input, and an auto-tell name is // "Firstname Lastname@World". Left alone it runs past the band and // gets cut mid-glyph at the window edge; the drop-out rule only // handles the two runs overlapping, not one of them overflowing. var room = width - inset * 2f - iconRun; - using (body.Push()) + using (meta.Push()) { if (DrawListExtensions.MeasureTrackedText(name, track) > room) name = StringUtil.TruncateToFitWidth(name, room); @@ -130,25 +134,9 @@ internal static class ChannelHeader nameRun += iconRun; } - // 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; + float detailRun; using (meta.Push()) - restRun = DrawListExtensions.MeasureTrackedText(rest, detailTrack); - - var detailRun = whereRun + restRun; + detailRun = DrawListExtensions.MeasureTrackedText(detail.Where, detailTrack); var plan = ChannelHeaderLayout.Plan( showName ? ChannelHeaderMode.Full : ChannelHeaderMode.DetailOnly, @@ -200,11 +188,16 @@ internal static class ChannelHeader x += iconSize.X + IconGapRaw * scale; - using (body.Push()) - dl.DrawTrackedText(new Vector2(x, textY), name, accent, track); + using (meta.Push()) + dl.DrawTrackedText( + new Vector2(x, textY + DropFor(body, meta, scale)), + name, + accent, + track + ); } - if (plan.ShowDetail) + if (plan.ShowDetail && detail.Where.Length > 0) { var muted = ColourUtil.EnsureContrast( ColourUtil.RgbaToAbgr(theme.Colors.TextMuted), @@ -212,23 +205,13 @@ internal static class ChannelHeader 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, + new Vector2( + bottomRight.X - inset - detailRun, + textY + DropFor(body, meta, scale) + ), + detail.Where, muted, detailTrack ); @@ -251,8 +234,6 @@ internal static class ChannelHeader return ChannelHeaderDetail.Format( world, - DateTimeOffset.Now, - Plugin.Config.Use24HourClock, Resources.HellionStrings.ChannelHeader_NotLoggedIn, Plugin.Config.ScreenshotMode ); diff --git a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderDetail.cs b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderDetail.cs index 8932830..04ac4b8 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderDetail.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderDetail.cs @@ -1,75 +1,27 @@ -using System.Globalization; - namespace HellionChat.Ui.StyleEngine.Widgets; // TEST-MIRROR: Ui/ChannelHeaderDetailTests.cs // -// Where you are and what time it is, as two parts rather than one string. +// Which world the header shows on the right, and whether it may show one at all. // -// They are split because they cannot share a face. The clock and a world name are -// Latin in every client, which is why the meta face gets away with a glyph range -// of ASCII plus a middle dot. The stand-in for "no world known" is translated -// into 25 languages, and fifteen of those reach outside that range -- drawn in the -// meta face they would come out as rows of question marks. So the caller draws -// WhereIsTranslated text in the body face and the rest in the meta face. -// -// WhereIsTranslated is really "this needs the body face". Two things set it: the -// stand-in for an unknown world, which is translated into 25 languages, and a -// world name that is not plain ASCII. The CN and KR clients have those, and the -// plugin ships zh-Hans, zh-Hant and ko -- so "world names are Latin everywhere" -// would have been true for four regions and wrong for two. -// -// The clock follows the same Use24HourClock setting the message timestamps do. -// Two clock formats in one window, with the header sitting directly above a -// column of timestamps, would be a defect rather than a preference. Culture is -// pinned for the same reason it is pinned in the message list: a German machine -// renders "PM" as "nachm." under its own culture. -internal readonly record struct ChannelHeaderDetailParts( - string Where, - string Clock, - bool WhereIsTranslated -) -{ - internal const string Separator = " · "; -} +// It used to carry a flag saying "this text needs the body face", because the +// meta face only had an ASCII glyph range and most translations of the +// not-logged-in stand-in reach past that. The meta face carries the full range +// now, so the flag is gone with it. +internal readonly record struct ChannelHeaderDetailParts(string Where); internal static class ChannelHeaderDetail { - internal static ChannelHeaderDetailParts Format( - string? world, - DateTimeOffset now, - bool use24Hour, - string fallback, - bool hideWhere - ) + internal static ChannelHeaderDetailParts Format(string? world, string fallback, bool hideWhere) { - var clock = use24Hour - ? now.ToString("HH:mm", CultureInfo.InvariantCulture) - : now.ToString("h:mm tt", CultureInfo.InvariantCulture); - // Screenshot mode. A home world names the player almost as precisely as // the character name does, and the whole point of that mode is that a - // picture can be shared. The clock stays -- it identifies nobody. + // picture can be shared. if (hideWhere) - return new ChannelHeaderDetailParts(string.Empty, clock, false); + return new ChannelHeaderDetailParts(string.Empty); - var missing = string.IsNullOrWhiteSpace(world); - if (missing) - return new ChannelHeaderDetailParts(fallback, clock, true); - - return new ChannelHeaderDetailParts(world!, clock, !IsPlainAscii(world!)); - } - - // The meta face carries ASCII plus a middle dot and nothing else. Eight - // characters to scan, once per frame, against a row of question marks. - private static bool IsPlainAscii(string text) - { - foreach (var c in text) - { - if (c is < ' ' or > '~') - return false; - } - - return true; + // Logged out resolves to world row zero, which exists and carries an + // empty name -- so a validity check on the row alone would not catch it. + return new ChannelHeaderDetailParts(string.IsNullOrWhiteSpace(world) ? fallback : world); } } diff --git a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs index 6dff68f..23c5394 100644 --- a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs +++ b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs @@ -94,8 +94,6 @@ internal sealed class WidgetGalleryWindow : Window ImGui.Checkbox("logged out##hdr", ref _headerLoggedOut); var detail = ChannelHeaderDetail.Format( _headerLoggedOut ? null : "Ravana", - DateTimeOffset.Now, - Plugin.Config.Use24HourClock, Resources.HellionStrings.ChannelHeader_NotLoggedIn, Plugin.Config.ScreenshotMode );