diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 85e9965..37883d4 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -497,6 +497,7 @@ public sealed class Plugin : IAsyncDalamudPlugin new SelfTests.HoverStateFootprintStep(), new SelfTests.HonorificHeaderRenderStep(this), new SelfTests.AboutIntegrationsStatusStep(this), + new SelfTests.TypeScaleStep(this), new SelfTests.PerformanceBaselineStep(this), new SelfTests.GlobalStyleScopeAllocStep(this), new SelfTests.MainWindowFocusOpacityStep(this), diff --git a/HellionChat/SelfTests/TypeScaleStep.cs b/HellionChat/SelfTests/TypeScaleStep.cs new file mode 100644 index 0000000..0de8c73 --- /dev/null +++ b/HellionChat/SelfTests/TypeScaleStep.cs @@ -0,0 +1,152 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.Utility; +using Dalamud.Plugin.SelfTest; +using HellionChat.Ui.StyleEngine; + +namespace HellionChat.SelfTests; + +// v1.13.0/A7: the type scale has no call site in the message list until block C, +// so without this step block A would end with nothing to look at and two helpers +// (TypeScale, BaselineMath) with no caller at all. +// +// It draws rather than asserts. A step that only reports Pass proves nothing about +// a font handle -- SimplePushedFont pushes nothing at all when a handle is not +// ready, silently, and the text then renders in whatever face was already active. +// The only way to see that is to look at it. +internal sealed class TypeScaleStep : ISelfTestStep +{ + private readonly Plugin plugin; + + public TypeScaleStep(Plugin plugin) + { + this.plugin = plugin; + } + + public string Name => "Hellion Chat - type scale"; + + public SelfTestStepResult RunStep() + { + var fm = this.plugin.FontManager; + if (fm is null) + { + ImGui.Text("FontManager is null"); + return SelfTestStepResult.Fail; + } + + if (!fm.FontsReady) + { + ImGui.Text("FontsReady is false - atlas still building, run again in a moment"); + return SelfTestStepResult.Fail; + } + + if (fm.SenderFont is null || fm.MetaFont is null || fm.RegularFont is null) + { + ImGui.Text("A role handle is missing - see FontManager ctor smoke"); + return SelfTestStepResult.Fail; + } + + var basePt = fm.ResolveGlobalFontPt(); + var scale = ImGuiHelpers.GlobalScale; + + ImGui.TextUnformatted($"base {basePt:0.00}pt, display scale {scale:0.00}"); + ImGui.Separator(); + + // Expected against actual, per role. The resolved point size is what the + // layout fingerprint keys on, so a mismatch here is a stale height cache + // waiting to happen. + ReportRole(fm, TypeRole.Body, basePt, fm.RegularFont); + ReportRole(fm, TypeRole.Sender, basePt, fm.SenderFont); + ReportRole(fm, TypeRole.Meta, basePt, fm.MetaFont); + + ImGui.Separator(); + ImGui.TextUnformatted("Baseline: timestamp, sender and body on one row."); + DrawSampleRow(fm, "14:32", "Julia Moon:", "Convoy approach vector confirmed."); + + ImGui.Separator(); + ImGui.TextUnformatted( + $"Sender weight is {FontManager.SenderWeight:0.00}. " + + "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. + DrawWeightPicker(fm, 1.2f); + ImGui.SameLine(); + DrawWeightPicker(fm, 1.3f); + ImGui.SameLine(); + DrawWeightPicker(fm, 1.4f); + + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } + + private static void DrawWeightPicker(FontManager fm, float weight) + { + var active = MathF.Abs(FontManager.SenderWeight - weight) < 0.001f; + if (ImGui.Button($"{(active ? "> " : "")}{weight:0.0}##sender-weight-{weight}")) + { + FontManager.SenderWeight = weight; + fm.RebuildDelegateFonts(); + } + } + + private static void ReportRole( + FontManager fm, + TypeRole role, + float basePt, + IFontHandle handle + ) + { + var expected = TypeScale.SizePtOf(role, basePt); + using (handle.Push()) + { + var actualPx = ImGui.GetFontSize(); + var expectedPx = FontManager.SizeInPx(expected) * ImGuiHelpers.GlobalScale; + var agrees = MathF.Abs(actualPx - expectedPx) < 1.5f; + ImGui.TextUnformatted( + $"{role,-7} expected {expected:0.00}pt ({expectedPx:0.0}px) " + + $"| actual {actualPx:0.0}px {(agrees ? "OK" : "MISMATCH")}" + ); + } + } + + // The one thing arithmetic cannot show: whether the three faces sit on the + // same baseline once they are next to each other. + private static void DrawSampleRow(FontManager fm, string stamp, string sender, string body) + { + var scale = ImGuiHelpers.GlobalScale; + var origin = ImGui.GetCursorScreenPos(); + + float bodyAscent; + using (fm.RegularFont!.Push()) + bodyAscent = ImGui.GetFont().Ascent; + + float metaAscent; + using (fm.MetaFont!.Push()) + metaAscent = ImGui.GetFont().Ascent; + + var metaDrop = BaselineMath.OffsetFor(bodyAscent, metaAscent, scale); + + ImGui.SetCursorScreenPos(origin with { Y = origin.Y + metaDrop }); + using (fm.MetaFont.Push()) + ImGui.TextUnformatted(stamp); + + ImGui.SameLine(); + ImGui.SetCursorScreenPos(ImGui.GetCursorScreenPos() with { Y = origin.Y }); + using (fm.SenderFont!.Push()) + ImGui.TextUnformatted(sender); + + ImGui.SameLine(); + ImGui.SetCursorScreenPos(ImGui.GetCursorScreenPos() with { Y = origin.Y }); + using (fm.RegularFont.Push()) + ImGui.TextUnformatted(body); + + ImGui.TextUnformatted($"(meta dropped {metaDrop:0.0}px onto the shared baseline)"); + } +}