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 );