diff --git a/HellionChat/Resources/HellionStrings.ca.resx b/HellionChat/Resources/HellionStrings.ca.resx index 1cedf9c..bc4c079 100644 --- a/HellionChat/Resources/HellionStrings.ca.resx +++ b/HellionChat/Resources/HellionStrings.ca.resx @@ -1359,6 +1359,6 @@ obert - Sense sessió + Sessió no iniciada \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.es.resx b/HellionChat/Resources/HellionStrings.es.resx index 1a867ff..e274935 100644 --- a/HellionChat/Resources/HellionStrings.es.resx +++ b/HellionChat/Resources/HellionStrings.es.resx @@ -1359,6 +1359,6 @@ abierto - Sin sesión + Sesión no iniciada \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fi.resx b/HellionChat/Resources/HellionStrings.fi.resx index fc6cbc9..ec8548c 100644 --- a/HellionChat/Resources/HellionStrings.fi.resx +++ b/HellionChat/Resources/HellionStrings.fi.resx @@ -1358,6 +1358,6 @@ auki - Ei kirjautunut sisään + Ei kirjautuneena \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ko.resx b/HellionChat/Resources/HellionStrings.ko.resx index 8be4681..8bf133b 100644 --- a/HellionChat/Resources/HellionStrings.ko.resx +++ b/HellionChat/Resources/HellionStrings.ko.resx @@ -1359,6 +1359,6 @@ 열림 - 로그인하지 않음 + 로그인되지 않음 \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ru.resx b/HellionChat/Resources/HellionStrings.ru.resx index 8cb69a7..d7da149 100644 --- a/HellionChat/Resources/HellionStrings.ru.resx +++ b/HellionChat/Resources/HellionStrings.ru.resx @@ -1359,6 +1359,6 @@ открыт - Не выполнен вход + Вход не выполнен \ No newline at end of file diff --git a/HellionChat/SelfTests/TypeScaleStep.cs b/HellionChat/SelfTests/TypeScaleStep.cs index 1456f97..87c8ed3 100644 --- a/HellionChat/SelfTests/TypeScaleStep.cs +++ b/HellionChat/SelfTests/TypeScaleStep.cs @@ -35,10 +35,14 @@ internal sealed class TypeScaleStep : ISelfTestStep return SelfTestStepResult.Fail; } + // Waiting, not Fail. The atlas rebuild this step's own weight buttons + // trigger is asynchronous, so a Fail here would make the step destroy + // itself the moment it is used as intended. Same guard as + // AboutIntegrationsStatusStep and HonorificHeaderRenderStep. if (!fm.FontsReady) { - ImGui.Text("FontsReady is false - atlas still building, run again in a moment"); - return SelfTestStepResult.Fail; + ImGui.Text("Atlas still building - the step resumes on its own"); + return SelfTestStepResult.Waiting; } if (fm.SenderFont is null || fm.MetaFont is null || fm.RegularFont is null) @@ -70,11 +74,16 @@ internal sealed class TypeScaleStep : ISelfTestStep + "Pick one and watch the row above change." ); - // Three builds would be the alternative, and nobody compares a face - // across a restart. RebuildDelegateFonts runs synchronously on this - // thread -- self-test steps are on the draw thread, same as every push - // site -- so the sample row redraws in the next frame with the new - // rasterisation. + // Three builds would be the alternative, and nobody compares a typeface + // across a plugin restart. + // + // The rebuild is asynchronous -- it goes through + // Framework.RunOnFrameworkThread and BuildFontsAsync -- so FontsReady + // drops to false for a moment after each click and the step reports + // Waiting until the atlas is back. It does NOT show the three weights + // side by side, which is what the plan asked for; one handle can only + // carry one weight, and three throwaway handles for a self-test is a + // worse trade than clicking through them. DrawWeightPicker(fm, 1.2f); ImGui.SameLine(); DrawWeightPicker(fm, 1.3f); diff --git a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs index 935be04..c773066 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs @@ -1,6 +1,7 @@ using System.Numerics; using Dalamud.Bindings.ImGui; using Dalamud.Interface; +using Dalamud.Interface.ManagedFontAtlas; using HellionChat.Util; namespace HellionChat.Ui.StyleEngine.Widgets; @@ -14,8 +15,10 @@ namespace HellionChat.Ui.StyleEngine.Widgets; // range, because tab names are free user input and can be CJK. Tracking carries // the same weight in every palette and costs nothing. // -// The tab name draws in the body face for the same reason. Only the world and the -// clock use the meta face, whose glyph range is ASCII plus a middle dot. +// 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; @@ -29,7 +32,27 @@ internal static class ChannelHeader internal static float Height => ImGui.GetTextLineHeight() + MathF.Round(PadYRaw * 2f * Metrics.Scale); - internal static void Draw(Tab tab, ChannelHeaderMode mode, FontManager fonts, string detail) + // 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; @@ -39,30 +62,52 @@ internal static class ChannelHeader var theme = Plugin.Instance.ThemeRegistry.Active; var surface = theme.Colors.Surface; - var name = tab.Name.ToUpperInvariant(); + var body = BodyFace(fonts); + var meta = MetaFace(fonts); + + var icon = Components.Sidebar.ResolveTabIcon(tab); var track = TrackRaw * scale; var detailTrack = DetailTrackRaw * scale; + var showName = mode is ChannelHeaderMode.Full; - // Measure before deciding, the way the status bar does. Icon and gap are - // part of the name run because they are dropped together with it. - float nameRun; - using (fonts.RegularFont!.Push()) - nameRun = DrawListExtensions.MeasureTrackedText(name, track); + // ToUpperInvariant allocates, so only where the name is actually drawn. + var name = showName ? tab.Name.ToUpperInvariant() : string.Empty; - var iconWidth = MeasureIcon(fonts, Components.Sidebar.ResolveTabIcon(tab)); - nameRun += iconWidth + IconGapRaw * scale; + Vector2 iconSize; + using (fonts.FontAwesome.Push()) + iconSize = ImGui.CalcTextSize(icon.ToIconString()); - float detailRun; - using (fonts.MetaFont!.Push()) - detailRun = DrawListExtensions.MeasureTrackedText(detail, detailTrack); + 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); + + var rest = 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( mode, width - inset * 2f, - ImGui.GetContentRegionAvail().Y - height, + ImGui.GetContentRegionAvail().Y - height - reservedBelow, nameRun, - detailRun + detailRun, + scale ); if (!plan.ShowHeader) @@ -87,14 +132,20 @@ internal static class ChannelHeader ); 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()) - { - var glyph = Components.Sidebar.ResolveTabIcon(tab).ToIconString(); - dl.AddText(new Vector2(x, textY), accent, glyph); - x += ImGui.CalcTextSize(glyph).X + IconGapRaw * scale; - } + dl.AddText( + new Vector2(x, origin.Y + MetricsMath.CenterY(height, iconSize.Y)), + accent, + icon.ToIconString() + ); - using (fonts.RegularFont.Push()) + x += iconSize.X + IconGapRaw * scale; + + using (body.Push()) dl.DrawTrackedText(new Vector2(x, textY), name, accent, track); } @@ -104,22 +155,23 @@ internal static class ChannelHeader ColourUtil.EnsureContrast(theme.Colors.TextMuted, surface, 4.5f) ); - // The meta face is smaller, so it would hang from the top edge - // without the drop -- ImGui aligns by top, not by baseline. - float bodyAscent; - using (fonts.RegularFont.Push()) - bodyAscent = ImGui.GetFont().Ascent; + var whereFace = detail.WhereIsTranslated ? body : meta; + var x = bottomRight.X - inset - detailRun; - float metaAscent; - using (fonts.MetaFont.Push()) - metaAscent = ImGui.GetFont().Ascent; - - var drop = BaselineMath.OffsetFor(bodyAscent, metaAscent, scale); - - using (fonts.MetaFont.Push()) + using (whereFace.Push()) dl.DrawTrackedText( - new Vector2(bottomRight.X - inset - detailRun, textY + drop), - detail, + 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 ); @@ -131,12 +183,11 @@ internal static class ChannelHeader ImGuiP.ItemSize(new Vector2(width, height - ImGui.GetStyle().ItemSpacing.Y)); } - // Both windows need the same string, and only one of them should know how to - // get at the world. IsValid is the guard the payload handler already uses -- - // HomeWorld is a row reference that stays unresolved until a character is - // actually logged in. - internal static string CurrentDetail() + 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; @@ -149,9 +200,20 @@ internal static class ChannelHeader ); } - private static float MeasureIcon(FontManager fonts, FontAwesomeIcon icon) + // Zero whenever both runs use the same handle, which is the common case. + private static float DropFor(IFontHandle body, IFontHandle other, float scale) { - using (fonts.FontAwesome.Push()) - return ImGui.CalcTextSize(icon.ToIconString()).X; + 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); } } diff --git a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderDetail.cs b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderDetail.cs index 765f1b5..e2b6c3c 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderDetail.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderDetail.cs @@ -2,33 +2,43 @@ 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. // -// The trailing half of the channel header: where you are and what time it is. +// 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. // // The clock follows the same Use24HourClock setting the message timestamps do. -// Two clock formats in one window would be a defect rather than a preference, and -// the header sits directly above a column of timestamps. -// -// 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, and the two -// would then disagree on the same screen. -internal static class ChannelHeaderDetail +// 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 string Format( +internal static class ChannelHeaderDetail +{ + internal static ChannelHeaderDetailParts Format( string? world, DateTimeOffset now, bool use24Hour, string fallback ) { - var where = string.IsNullOrWhiteSpace(world) ? fallback : world; + var missing = string.IsNullOrWhiteSpace(world); var clock = use24Hour ? now.ToString("HH:mm", CultureInfo.InvariantCulture) : now.ToString("h:mm tt", CultureInfo.InvariantCulture); - return string.Concat(where, Separator, clock); + return new ChannelHeaderDetailParts(missing ? fallback : world!, clock, missing); } } diff --git a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderLayout.cs b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderLayout.cs index 3e6a302..314235c 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderLayout.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderLayout.cs @@ -29,21 +29,31 @@ internal static class ChannelHeaderLayout { // Below this the message area stops being a conversation and starts being a // peephole. Four body lines at a typical scale. - internal const float MinMessageAreaHeight = 90f; + // + // Raw, like every other layout value in the project: the caller passes the + // display scale so this stays arithmetic. Comparing an unscaled 90 against + // scaled pixels would dissolve the threshold as the scale goes up -- at 200% + // it would be worth 45 logical pixels. + internal const float MinMessageAreaHeightRaw = 90f; + + // Keeps the name and the trailing detail from touching at the exact pixel + // where they both still "fit". + internal const float MinGapRaw = 12f; internal static ChannelHeaderPlan Plan( ChannelHeaderMode mode, float availableWidth, float availableHeight, float nameWidth, - float detailWidth + float detailWidth, + float scale ) { - if (availableHeight < MinMessageAreaHeight) + if (availableHeight < MinMessageAreaHeightRaw * scale) return new ChannelHeaderPlan(false, false, false); var showName = mode is ChannelHeaderMode.Full; - var used = showName ? nameWidth : 0f; + var used = showName ? nameWidth + MinGapRaw * scale : 0f; var showDetail = used + detailWidth <= availableWidth; return new ChannelHeaderPlan(true, showName, showDetail); diff --git a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs index d0ffa97..b82dcf0 100644 --- a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs +++ b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs @@ -138,7 +138,8 @@ internal sealed class ChannelPopoutWindow : Window, IFocusableChatWindow ? StyleEngine.Widgets.ChannelHeaderMode.DetailOnly : StyleEngine.Widgets.ChannelHeaderMode.Full, Plugin.Instance.FontManager, - StyleEngine.Widgets.ChannelHeader.CurrentDetail() + StyleEngine.Widgets.ChannelHeader.CurrentDetail(), + InputBar.Height ); // The header close button can unbind us mid-frame (CloseRequested -> diff --git a/HellionChat/Ui/Windows/MainWindow.cs b/HellionChat/Ui/Windows/MainWindow.cs index 909579f..b496c3e 100644 --- a/HellionChat/Ui/Windows/MainWindow.cs +++ b/HellionChat/Ui/Windows/MainWindow.cs @@ -400,11 +400,15 @@ internal sealed class MainWindow : Window, IFocusableChatWindow ? StyleEngine.Widgets.ChannelHeaderMode.DetailOnly : StyleEngine.Widgets.ChannelHeaderMode.Full; + // What follows the log in this column, so the header can tell how + // much room the conversation is actually left with. Without it the + // drop-out rule would measure the input row as readable chat. StyleEngine.Widgets.ChannelHeader.Draw( headerTab, mode, Plugin.Instance.FontManager, - StyleEngine.Widgets.ChannelHeader.CurrentDetail() + StyleEngine.Widgets.ChannelHeader.CurrentDetail(), + inputHeight + previewHeight ); } diff --git a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs index a3ebd03..7704d31 100644 --- a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs +++ b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs @@ -25,6 +25,7 @@ internal sealed class WidgetGalleryWindow : Window private int _badgeCount = 3; private Tab? _headerSample; private int _headerMode; + private bool _headerLoggedOut; private bool _rowActive = true; private bool _toggleA = true; private bool _toggleB; @@ -87,7 +88,18 @@ internal sealed class WidgetGalleryWindow : Window fonts.RebuildDelegateFonts(); var mode = _headerMode == 0 ? ChannelHeaderMode.Full : ChannelHeaderMode.DetailOnly; - ChannelHeader.Draw(_headerSample, mode, fonts, "Ravana · 14:32"); + + // Both detail shapes, because they take different faces: a known world + // rides the meta face, the translated stand-in has to use the body face. + ImGui.Checkbox("logged out##hdr", ref _headerLoggedOut); + var detail = ChannelHeaderDetail.Format( + _headerLoggedOut ? null : "Ravana", + DateTimeOffset.Now, + Plugin.Config.Use24HourClock, + Resources.HellionStrings.ChannelHeader_NotLoggedIn + ); + + ChannelHeader.Draw(_headerSample, mode, fonts, detail, 0f); ImGui.Spacing(); }