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.
This commit is contained in:
@@ -51,6 +51,12 @@ public sealed class FontManager : IDisposable
|
|||||||
// rasterisation of the same outline. 1.0 is the SafeFontConfig default;
|
// 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.
|
// 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
|
// Not const: the smoke test compares three values side by side, and the
|
||||||
// widget gallery exposes it.
|
// widget gallery exposes it.
|
||||||
internal static float SenderWeight = 1.3f;
|
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.
|
// by the global font, so the fallback no longer re-merges the full Ranges array.
|
||||||
private ushort[] CjkFallbackGlyphRange = [];
|
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
|
// 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
|
// 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.
|
// primary range) in its on-disk report instead of a bare Pass.
|
||||||
@@ -330,13 +329,14 @@ public sealed class FontManager : IDisposable
|
|||||||
e.OnPreBuild(tk =>
|
e.OnPreBuild(tk =>
|
||||||
{
|
{
|
||||||
var basePt = TypeScale.SizePtOf(TypeRole.Meta, ResolveGlobalFontPt());
|
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;
|
var bundledBytes = Plugin.Config.UseHellionFont ? TryGetBundledFontBytes() : null;
|
||||||
config.MergeFont = bundledBytes is not null
|
config.MergeFont = bundledBytes is not null
|
||||||
? tk.AddFontFromMemory(bundledBytes, config, "Inter-Light-Meta")
|
? tk.AddFontFromMemory(bundledBytes, config, "Inter-Light-Meta")
|
||||||
: AddFontWithFallback(tk, Plugin.Config.GlobalFontV2.FontId, config, "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;
|
tk.Font = config.MergeFont;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ internal sealed class StatusBar
|
|||||||
private long _lastUpdateMs = -UpdateIntervalMs;
|
private long _lastUpdateMs = -UpdateIntervalMs;
|
||||||
private string _cachedCountsText = string.Empty;
|
private string _cachedCountsText = string.Empty;
|
||||||
private string _cachedTellsText = string.Empty;
|
private string _cachedTellsText = string.Empty;
|
||||||
|
private string _cachedClockText = string.Empty;
|
||||||
|
|
||||||
public StatusBar(ThemeRegistry themes, FontManager fonts)
|
public StatusBar(ThemeRegistry themes, FontManager fonts)
|
||||||
{
|
{
|
||||||
@@ -105,6 +106,21 @@ internal sealed class StatusBar
|
|||||||
return (_cachedCountsText, _cachedTellsText);
|
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)
|
private void UpdateCacheIfDue(long now, int tabs, int messages, int tells)
|
||||||
{
|
{
|
||||||
if (now - _lastUpdateMs < UpdateIntervalMs)
|
if (now - _lastUpdateMs < UpdateIntervalMs)
|
||||||
@@ -128,6 +144,11 @@ internal sealed class StatusBar
|
|||||||
{
|
{
|
||||||
var (messages, tells) = AggregateForStatusBar(tabs);
|
var (messages, tells) = AggregateForStatusBar(tabs);
|
||||||
UpdateCacheIfDue(now, tabs.Count, messages, tells);
|
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
|
// 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))
|
if (!string.IsNullOrEmpty(_cachedTellsText) && Fits(_cachedTellsText, withDot: false))
|
||||||
x += DrawSlot(new Vector2(x, top), _cachedTellsText, pillFill, pillText, null) + gap;
|
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
|
// Slot 5: version + brand, right-aligned. Dropped when the left-hand run
|
||||||
// would actually collide with it -- the old check compared against a flat
|
// would actually collide with it -- the old check compared against a flat
|
||||||
// 200px and never measured the left slots at all.
|
// 200px and never measured the left slots at all.
|
||||||
|
|||||||
@@ -114,12 +114,16 @@ internal static class ChannelHeader
|
|||||||
var nameRun = 0f;
|
var nameRun = 0f;
|
||||||
if (showName)
|
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
|
// Tab names are free user input, and an auto-tell name is
|
||||||
// "Firstname Lastname@World". Left alone it runs past the band and
|
// "Firstname Lastname@World". Left alone it runs past the band and
|
||||||
// gets cut mid-glyph at the window edge; the drop-out rule only
|
// gets cut mid-glyph at the window edge; the drop-out rule only
|
||||||
// handles the two runs overlapping, not one of them overflowing.
|
// handles the two runs overlapping, not one of them overflowing.
|
||||||
var room = width - inset * 2f - iconRun;
|
var room = width - inset * 2f - iconRun;
|
||||||
using (body.Push())
|
using (meta.Push())
|
||||||
{
|
{
|
||||||
if (DrawListExtensions.MeasureTrackedText(name, track) > room)
|
if (DrawListExtensions.MeasureTrackedText(name, track) > room)
|
||||||
name = StringUtil.TruncateToFitWidth(name, room);
|
name = StringUtil.TruncateToFitWidth(name, room);
|
||||||
@@ -130,25 +134,9 @@ internal static class ChannelHeader
|
|||||||
nameRun += iconRun;
|
nameRun += iconRun;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Measured under the face that will draw it, which is the whole point of
|
float detailRun;
|
||||||
// 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())
|
using (meta.Push())
|
||||||
restRun = DrawListExtensions.MeasureTrackedText(rest, detailTrack);
|
detailRun = DrawListExtensions.MeasureTrackedText(detail.Where, detailTrack);
|
||||||
|
|
||||||
var detailRun = whereRun + restRun;
|
|
||||||
|
|
||||||
var plan = ChannelHeaderLayout.Plan(
|
var plan = ChannelHeaderLayout.Plan(
|
||||||
showName ? ChannelHeaderMode.Full : ChannelHeaderMode.DetailOnly,
|
showName ? ChannelHeaderMode.Full : ChannelHeaderMode.DetailOnly,
|
||||||
@@ -200,11 +188,16 @@ internal static class ChannelHeader
|
|||||||
|
|
||||||
x += iconSize.X + IconGapRaw * scale;
|
x += iconSize.X + IconGapRaw * scale;
|
||||||
|
|
||||||
using (body.Push())
|
using (meta.Push())
|
||||||
dl.DrawTrackedText(new Vector2(x, textY), name, accent, track);
|
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(
|
var muted = ColourUtil.EnsureContrast(
|
||||||
ColourUtil.RgbaToAbgr(theme.Colors.TextMuted),
|
ColourUtil.RgbaToAbgr(theme.Colors.TextMuted),
|
||||||
@@ -212,23 +205,13 @@ internal static class ChannelHeader
|
|||||||
4.5f
|
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())
|
using (meta.Push())
|
||||||
dl.DrawTrackedText(
|
dl.DrawTrackedText(
|
||||||
new Vector2(x, textY + DropFor(body, meta, scale)),
|
new Vector2(
|
||||||
rest,
|
bottomRight.X - inset - detailRun,
|
||||||
|
textY + DropFor(body, meta, scale)
|
||||||
|
),
|
||||||
|
detail.Where,
|
||||||
muted,
|
muted,
|
||||||
detailTrack
|
detailTrack
|
||||||
);
|
);
|
||||||
@@ -251,8 +234,6 @@ internal static class ChannelHeader
|
|||||||
|
|
||||||
return ChannelHeaderDetail.Format(
|
return ChannelHeaderDetail.Format(
|
||||||
world,
|
world,
|
||||||
DateTimeOffset.Now,
|
|
||||||
Plugin.Config.Use24HourClock,
|
|
||||||
Resources.HellionStrings.ChannelHeader_NotLoggedIn,
|
Resources.HellionStrings.ChannelHeader_NotLoggedIn,
|
||||||
Plugin.Config.ScreenshotMode
|
Plugin.Config.ScreenshotMode
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,75 +1,27 @@
|
|||||||
using System.Globalization;
|
|
||||||
|
|
||||||
namespace HellionChat.Ui.StyleEngine.Widgets;
|
namespace HellionChat.Ui.StyleEngine.Widgets;
|
||||||
|
|
||||||
// TEST-MIRROR: Ui/ChannelHeaderDetailTests.cs
|
// 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
|
// It used to carry a flag saying "this text needs the body face", because the
|
||||||
// Latin in every client, which is why the meta face gets away with a glyph range
|
// meta face only had an ASCII glyph range and most translations of the
|
||||||
// of ASCII plus a middle dot. The stand-in for "no world known" is translated
|
// not-logged-in stand-in reach past that. The meta face carries the full range
|
||||||
// into 25 languages, and fifteen of those reach outside that range -- drawn in the
|
// now, so the flag is gone with it.
|
||||||
// meta face they would come out as rows of question marks. So the caller draws
|
internal readonly record struct ChannelHeaderDetailParts(string Where);
|
||||||
// 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 = " · ";
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static class ChannelHeaderDetail
|
internal static class ChannelHeaderDetail
|
||||||
{
|
{
|
||||||
internal static ChannelHeaderDetailParts Format(
|
internal static ChannelHeaderDetailParts Format(string? world, string fallback, bool hideWhere)
|
||||||
string? world,
|
|
||||||
DateTimeOffset now,
|
|
||||||
bool use24Hour,
|
|
||||||
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
|
// 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
|
// 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)
|
if (hideWhere)
|
||||||
return new ChannelHeaderDetailParts(string.Empty, clock, false);
|
return new ChannelHeaderDetailParts(string.Empty);
|
||||||
|
|
||||||
var missing = string.IsNullOrWhiteSpace(world);
|
// Logged out resolves to world row zero, which exists and carries an
|
||||||
if (missing)
|
// empty name -- so a validity check on the row alone would not catch it.
|
||||||
return new ChannelHeaderDetailParts(fallback, clock, true);
|
return new ChannelHeaderDetailParts(string.IsNullOrWhiteSpace(world) ? fallback : world);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,8 +94,6 @@ internal sealed class WidgetGalleryWindow : Window
|
|||||||
ImGui.Checkbox("logged out##hdr", ref _headerLoggedOut);
|
ImGui.Checkbox("logged out##hdr", ref _headerLoggedOut);
|
||||||
var detail = ChannelHeaderDetail.Format(
|
var detail = ChannelHeaderDetail.Format(
|
||||||
_headerLoggedOut ? null : "Ravana",
|
_headerLoggedOut ? null : "Ravana",
|
||||||
DateTimeOffset.Now,
|
|
||||||
Plugin.Config.Use24HourClock,
|
|
||||||
Resources.HellionStrings.ChannelHeader_NotLoggedIn,
|
Resources.HellionStrings.ChannelHeader_NotLoggedIn,
|
||||||
Plugin.Config.ScreenshotMode
|
Plugin.Config.ScreenshotMode
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user