From fc67c0852a854d76df4577e5180a721e792d3a6f Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 09:43:19 +0200 Subject: [PATCH 01/29] fix(text): give the wrap calculation the scale imgui actually asks for CalcWordWrapPositionA takes a scale, and imgui means size / FontSize by that -- the ratio between the size being rendered and the size the face was baked at (imgui_draw.cpp, CalcTextSizeA). We were handing it ImGuiHelpers.GlobalScale. That is the same number today, but by coincidence rather than by design. Dalamud bakes every font at SizePx * GlobalScale and then divides the metrics back down, so g.FontSize / font->FontSize lands on GlobalScale for every handle regardless of its size. The coincidence holds only while one face draws a line. The typography work starting with this cycle puts a second size into the same line, and there the two numbers separate: the wrap would be computed for the wrong size while CalcTextSizeA keeps measuring with the right one. Measured height and drawn wrap would drift apart, and the virtualised clipper plans against the measured value. No behaviour change expected here -- the expression evaluates to what the old constant already was. --- HellionChat/Util/ImGuiUtil.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/HellionChat/Util/ImGuiUtil.cs b/HellionChat/Util/ImGuiUtil.cs index 6714aef..db36fe0 100755 --- a/HellionChat/Util/ImGuiUtil.cs +++ b/HellionChat/Util/ImGuiUtil.cs @@ -786,9 +786,14 @@ internal static class ImGuiUtil private static unsafe int CalcWordWrap(byte* basePtr, int start, int end, float width) { + var font = ImGui.GetFont(); + // ImGui reads this as size / FontSize (imgui_draw.cpp, CalcTextSizeA), not + // as UI scale. The two match only while every font is built at + // SizePx * GlobalScale, which stops holding the moment a second size + // enters a line. var result = ImGuiNative.CalcWordWrapPositionA( - ImGui.GetFont().Handle, - ImGuiHelpers.GlobalScale, + font.Handle, + ImGui.GetFontSize() / font.FontSize, basePtr + start, basePtr + end, width From 58830aecec51006a9a572a348fa78ec894cc132b Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 09:44:05 +0200 Subject: [PATCH 02/29] feat(style): the arithmetic behind a type scale Quarter-point rounding, not whole points. The base size is 12.75pt, so rounding a derived role to whole points would move it further than the step between two adjacent base sizes -- the scale would quantise away the difference it exists to express. The floor is what keeps the meta role legible when someone runs a small base size; below about seven points a timestamp stops being readable at any display scale. --- HellionChat/Ui/StyleEngine/TypeScaleMath.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 HellionChat/Ui/StyleEngine/TypeScaleMath.cs diff --git a/HellionChat/Ui/StyleEngine/TypeScaleMath.cs b/HellionChat/Ui/StyleEngine/TypeScaleMath.cs new file mode 100644 index 0000000..79a6fb4 --- /dev/null +++ b/HellionChat/Ui/StyleEngine/TypeScaleMath.cs @@ -0,0 +1,13 @@ +namespace HellionChat.Ui.StyleEngine; + +// TEST-MIRROR: Ui/TypeScaleMathTests.cs +// +// Rounds to quarter points rather than whole ones. The base size itself is +// 12.75pt, so whole-point rounding would move a role by more than the step +// between two adjacent base sizes -- the scale would quantise away the very +// difference it exists to express. +internal static class TypeScaleMath +{ + internal static float Resolve(float basePt, float factor, float minPt) => + MathF.Max(minPt, MathF.Round(basePt * factor * 4f) / 4f); +} From 6d2bb95528dee9f303c036e0023812e36c989125 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 09:44:31 +0200 Subject: [PATCH 03/29] feat(style): name the four type roles Body, Sender, Header, Meta. Three of them share the base size, which looks like an oversight and is not: the sender is set apart by weight and the header by small caps with wide tracking. Neither of those is a size, and solving them with size instead would turn the log into a ransom note. Only meta steps down, because it is meant to be skipped over rather than read. Factors sit in a static array rather than as consts. The master spec puts typography under theme control, and ThemeTypography is already the declared extension point for it -- a const would wall that off before anyone gets there. No caller yet. FontManager takes the first one in the next commit; that is the one place in this cycle where a piece lands before its call site, and it closes inside the same block. --- HellionChat/Ui/StyleEngine/TypeScale.cs | 42 +++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 HellionChat/Ui/StyleEngine/TypeScale.cs diff --git a/HellionChat/Ui/StyleEngine/TypeScale.cs b/HellionChat/Ui/StyleEngine/TypeScale.cs new file mode 100644 index 0000000..ca8f3a5 --- /dev/null +++ b/HellionChat/Ui/StyleEngine/TypeScale.cs @@ -0,0 +1,42 @@ +namespace HellionChat.Ui.StyleEngine; + +internal enum TypeRole +{ + Body, + Sender, + Header, + Meta, +} + +// Named sizes derived from one base, so a role means the same thing wherever it +// is drawn. +// +// Three of the four share the base size. That is deliberate: what sets the +// sender apart is weight and what sets the header apart is small caps with wide +// tracking, and neither is a size. Solving those with size instead would make +// the log look like a ransom note. Only the meta role -- timestamps and the +// header's trailing detail -- steps down, because it is meant to be skipped over +// rather than read. +// +// The factors are defaults, not constants. The master spec puts typography under +// theme control rather than user control, and ThemeTypography already exists as +// the extension point for exactly that. A const would wall it off. What is +// deliberately absent either way is a user-facing slider per role. +internal static class TypeScale +{ + // Below this a timestamp stops being readable at any display scale. + internal const float MinPt = 7f; + + private static readonly float[] Factors = + [ + 1.00f, // Body -- the reference every other role is stated against + 1.00f, // Sender -- set apart by weight, see FontManager.SenderWeight + 1.00f, // Header -- set apart by small caps and tracking + 0.82f, // Meta -- timestamps, and the world and clock in the header + ]; + + internal static float FactorOf(TypeRole role) => Factors[(int)role]; + + internal static float SizePtOf(TypeRole role, float basePt) => + TypeScaleMath.Resolve(basePt, FactorOf(role), MinPt); +} From 34e343d8f2ecf3307549d8485760708cdddfe238 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 09:46:34 +0200 Subject: [PATCH 04/29] feat(fonts): a handle for the sender and one for the meta line Two roles need a face of their own, and they need it for opposite reasons. The sender is meant to carry weight. The mockup says 600, and there is no bold face anywhere in the plugin -- the bundled file is Inter-Light and the game's Axis is a single weight. So the weight comes from rasterising the same outline denser, via RasterizerMultiply. That only works on the delegate path: with both font toggles off the game font handle draws and has no such knob, and the sender falls back to leaning on channel colour alone. Deliberate limitation, not an oversight. The meta face goes the other way: smaller, and on a glyph range of about eighty entries instead of the full set. Timestamps and world names are Latin in every client FFXIV ships, so ASCII plus the middle dot covers what this face will ever be asked to draw. A full range would have rasterised the whole CJK block a second time for nothing. Anything translated stays on the body face. Two things in the rebuild path had to change with them. The handles now go up inside a SuppressAutoRebuild block -- without it, one size change meant four separate atlas rebuilds instead of one. And FontsReady checks both new handles unconditionally, unlike ItalicFont which is allowed to be null: a handle that is not ready makes SimplePushedFont push nothing at all, silently, and the first frame after a rebuild would measure the wrong face and write those heights into the row cache. Both font self-tests were extended to match. A handle nobody asserts is a handle that can go missing for a release without anyone noticing. --- HellionChat/FontManager.cs | 110 +++++++++++++++++- .../SelfTests/FontManagerCtorSmokeStep.cs | 28 +++++ HellionChat/SelfTests/FontPushSmokeStep.cs | 8 ++ 3 files changed, 141 insertions(+), 5 deletions(-) diff --git a/HellionChat/FontManager.cs b/HellionChat/FontManager.cs index a01764c..86d9495 100644 --- a/HellionChat/FontManager.cs +++ b/HellionChat/FontManager.cs @@ -7,6 +7,7 @@ using Dalamud.Interface.ManagedFontAtlas; using Dalamud.Interface.Utility; using Dalamud.Plugin; using HellionChat.Themes; +using HellionChat.Ui.StyleEngine; namespace HellionChat; @@ -40,6 +41,20 @@ public sealed class FontManager : IDisposable internal IFontHandle? RegularFont; internal IFontHandle? ItalicFont; + // v1.13.0: one handle per type role that needs a face of its own. Sender + // carries extra weight, Meta a smaller size on a tiny glyph range. + internal IFontHandle? SenderFont; + internal IFontHandle? MetaFont; + + // The mockup asks for weight 600 on the sender. There is no bold face in the + // plugin and none in the bundled file, so the weight comes from a denser + // 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. + // + // Not const: the smoke test compares three values side by side, and the + // widget gallery exposes it. + internal static float SenderWeight = 1.3f; + // Wired post-build (B4b-3); a Func keeps FontManager off the theme layer. private Func? _typographySource; @@ -56,7 +71,13 @@ public sealed class FontManager : IDisposable && AxisItalic.Available && FontAwesome.Available && RegularFont is { Available: true } - && (ItalicFont is null || ItalicFont.Available); + && (ItalicFont is null || ItalicFont.Available) + // Unconditional, unlike ItalicFont: these two are always built. A handle + // that is not ready yet makes SimplePushedFont push nothing at all -- + // silently -- so the first frame after a rebuild would measure the wrong + // face and write those heights into the row cache. + && SenderFont is { Available: true } + && MetaFont is { Available: true }; private ushort[] Ranges = []; private ushort[] JpRange = []; @@ -66,6 +87,20 @@ 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. @@ -121,6 +156,9 @@ public sealed class FontManager : IDisposable if (Plugin.Config.ItalicEnabled) ItalicFont = BuildItalicFontHandle(atlas); + + SenderFont = BuildSenderFontHandle(atlas); + MetaFont = BuildMetaFontHandle(atlas); } // Source is still null here, so this is the config-only baseline. @@ -141,11 +179,23 @@ public sealed class FontManager : IDisposable var atlas = _pluginInterface.UiBuilder.FontAtlas; - RegularFont?.Dispose(); - RegularFont = BuildRegularFontHandle(atlas); + // Without the suppression each handle triggers its own atlas rebuild. + // With two handles that was tolerable; with four it is four rebuilds for + // one size change. + using (atlas.SuppressAutoRebuild()) + { + RegularFont?.Dispose(); + RegularFont = BuildRegularFontHandle(atlas); - ItalicFont?.Dispose(); - ItalicFont = Plugin.Config.ItalicEnabled ? BuildItalicFontHandle(atlas) : null; + ItalicFont?.Dispose(); + ItalicFont = Plugin.Config.ItalicEnabled ? BuildItalicFontHandle(atlas) : null; + + SenderFont?.Dispose(); + SenderFont = BuildSenderFontHandle(atlas); + + MetaFont?.Dispose(); + MetaFont = BuildMetaFontHandle(atlas); + } _lastBuiltFingerprint = EffectiveFontFingerprint(); } @@ -232,6 +282,54 @@ public sealed class FontManager : IDisposable }) ); + // Same outline as the body face, rasterised denser. Only works on the + // delegate path: with FontsEnabled and UseHellionFont both off the game's own + // Axis handle draws, and a game font handle has no such knob. The sender then + // leans on channel colour alone, which is a deliberate limitation. + private IFontHandle BuildSenderFontHandle(IFontAtlas atlas) => + atlas.NewDelegateFontHandle(e => + e.OnPreBuild(tk => + { + var basePt = TypeScale.SizePtOf( + TypeRole.Sender, + ResolveGlobalFontPt() + ); + var config = new SafeFontConfig + { + SizePt = basePt, + GlyphRanges = Ranges, + RasterizerMultiply = SenderWeight, + }; + var bundledBytes = Plugin.Config.UseHellionFont ? TryGetBundledFontBytes() : null; + config.MergeFont = bundledBytes is not null + ? tk.AddFontFromMemory(bundledBytes, config, "Inter-Light-Sender") + : AddFontWithFallback(tk, Plugin.Config.GlobalFontV2.FontId, config, "sender"); + + AddCjkAndSymbols(tk, config, basePt); + + tk.Font = config.MergeFont; + }) + ); + + private IFontHandle BuildMetaFontHandle(IFontAtlas atlas) => + atlas.NewDelegateFontHandle(e => + e.OnPreBuild(tk => + { + var basePt = TypeScale.SizePtOf( + TypeRole.Meta, + ResolveGlobalFontPt() + ); + var config = new SafeFontConfig { SizePt = basePt, GlyphRanges = MetaRange }; + 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. + tk.Font = config.MergeFont; + }) + ); + private IFontHandle BuildItalicFontHandle(IFontAtlas atlas) => atlas.NewDelegateFontHandle(e => e.OnPreBuild(tk => @@ -262,6 +360,8 @@ public sealed class FontManager : IDisposable // lifetime, so the plugin must not dispose it. RegularFont?.Dispose(); ItalicFont?.Dispose(); + SenderFont?.Dispose(); + MetaFont?.Dispose(); } // Returns null when the embedded font resource is missing. Should not diff --git a/HellionChat/SelfTests/FontManagerCtorSmokeStep.cs b/HellionChat/SelfTests/FontManagerCtorSmokeStep.cs index c0e8f8c..f9a30fe 100644 --- a/HellionChat/SelfTests/FontManagerCtorSmokeStep.cs +++ b/HellionChat/SelfTests/FontManagerCtorSmokeStep.cs @@ -56,6 +56,20 @@ internal sealed class FontManagerCtorSmokeStep : ISelfTestStep return SelfTestStepResult.Fail; } + // v1.13.0: unconditional, unlike ItalicFont. Both are always built, and a + // handle that never arrives makes SimplePushedFont push nothing silently. + if (fm.SenderFont is null) + { + ImGui.Text("SenderFont handle is null"); + return SelfTestStepResult.Fail; + } + + if (fm.MetaFont is null) + { + ImGui.Text("MetaFont handle is null"); + return SelfTestStepResult.Fail; + } + if (fm.Axis.LoadException is { } e1) { ImGui.Text($"Axis load exception: {e1.Message}"); @@ -86,6 +100,18 @@ internal sealed class FontManagerCtorSmokeStep : ISelfTestStep return SelfTestStepResult.Fail; } + if (fm.SenderFont.LoadException is { } e6) + { + ImGui.Text($"SenderFont load exception: {e6.Message}"); + return SelfTestStepResult.Fail; + } + + if (fm.MetaFont.LoadException is { } e7) + { + ImGui.Text($"MetaFont load exception: {e7.Message}"); + return SelfTestStepResult.Fail; + } + // B1: assert the atlas actually finished building all required handles, // not just that the references are non-null. FontsReady is the observable // state the trimmed-fallback rebuild must still reach; a half-built atlas @@ -119,6 +145,8 @@ internal sealed class FontManagerCtorSmokeStep : ISelfTestStep $"FontAwesome available: {fm.FontAwesome.Available}", $"RegularFont available: {fm.RegularFont.Available}", $"ItalicFont: {italicState}", + $"SenderFont available: {fm.SenderFont.Available} (weight {FontManager.SenderWeight:0.00})", + $"MetaFont available: {fm.MetaFont.Available}", $"FontsReady: {fm.FontsReady}", $"Glyph-range entries: primary={counts.Ranges}, jp={counts.JpRange}, " + $"cjk-fallback={counts.CjkFallback} (B1 trimmed)", diff --git a/HellionChat/SelfTests/FontPushSmokeStep.cs b/HellionChat/SelfTests/FontPushSmokeStep.cs index 71e561d..b58706d 100644 --- a/HellionChat/SelfTests/FontPushSmokeStep.cs +++ b/HellionChat/SelfTests/FontPushSmokeStep.cs @@ -28,10 +28,18 @@ internal sealed class FontPushSmokeStep : ISelfTestStep return SelfTestStepResult.Fail; } + if (fm.SenderFont is null || fm.MetaFont is null) + { + ImGui.Text("SenderFont or MetaFont missing - see FontManager ctor smoke"); + return SelfTestStepResult.Fail; + } + try { using (fm.RegularFont.Push()) { } using (fm.FontAwesome.Push()) { } + using (fm.SenderFont.Push()) { } + using (fm.MetaFont.Push()) { } } catch (Exception e) { From 147034bda566c7cdcdfcc351e5d311dedcbcfef8 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 09:48:35 +0200 Subject: [PATCH 05/29] fix(layout): four axes that could always stale the cache, and the new roles The height cache keys on a fingerprint of everything that can change a row's height. Four things that can were never in it: - ItalicFontV2.SizePt. ChunkRenderer pushes the italic face mid-row for emphasis, so there have been mixed sizes in a single line all along -- nobody called it that. Changing only the italic size moved every wrapped row and left the cache untouched. - ItalicEnabled, which swaps between ItalicFont at its own size and AxisItalic at the base size. - FontsEnabled and UseHellionFont, which swap the face outright. This pair is the quiet one: both size fields default to 12.75f, so the fingerprint did not move at all while the glyph widths underneath it did. On top of those, the two new role sizes. They follow the base arithmetically, but the resolved value is what belongs in the fingerprint -- a theme override moves the base without moving any factor. The three toggles go in the discrete half so they bypass the settle window, the same way density already does. The sizes are sliders and wait it out. Falsified rather than assumed: dropping FontsEnabled back out of Discrete turns the new test red, so it is measuring the axis and not just passing. --- HellionChat/FontManager.cs | 19 ++++++++++++++++--- HellionChat/Ui/Components/MessageList.cs | 18 +++++++++++++++--- HellionChat/Util/LayoutFingerprint.cs | 9 ++++++++- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/HellionChat/FontManager.cs b/HellionChat/FontManager.cs index 86d9495..79fcb6a 100644 --- a/HellionChat/FontManager.cs +++ b/HellionChat/FontManager.cs @@ -59,7 +59,7 @@ public sealed class FontManager : IDisposable private Func? _typographySource; // Lets RebuildDelegateFontsIfChanged skip rebuilds when the size is unchanged. - private (float Global, float Symbols) _lastBuiltFingerprint; + private (float Global, float Symbols, float Sender, float Meta, float Italic) _lastBuiltFingerprint; // True once every required atlas-owned handle reports Available. Components // gate their first-frame draw on this — without it the layout math would @@ -216,8 +216,21 @@ public sealed class FontManager : IDisposable Plugin.Config.SymbolsFontSizeV2 ); - internal (float Global, float Symbols) EffectiveFontFingerprint() => - (ResolveGlobalFontPt(), ResolveSymbolsFontPt()); + // Every size that can land in one message row. Roles follow the base size + // arithmetically, but the resolved value is what the height cache has to key + // on -- a theme override moves the base without moving any factor. + internal (float Global, float Symbols, float Sender, float Meta, float Italic) + EffectiveFontFingerprint() + { + var basePt = ResolveGlobalFontPt(); + return ( + basePt, + ResolveSymbolsFontPt(), + TypeScale.SizePtOf(TypeRole.Sender, basePt), + TypeScale.SizePtOf(TypeRole.Meta, basePt), + Plugin.Config.ItalicFontV2.SizePt + ); + } // Rebuilds only when the effective size changed (live fingerprint, TOCTOU-free). // The atlas rebuild must run on the framework/draw thread — callers ensure that. diff --git a/HellionChat/Ui/Components/MessageList.cs b/HellionChat/Ui/Components/MessageList.cs index e9fc90a..6024736 100644 --- a/HellionChat/Ui/Components/MessageList.cs +++ b/HellionChat/Ui/Components/MessageList.cs @@ -93,13 +93,25 @@ internal sealed class MessageList // Width is passed in (ContentRegionAvail is only valid inside the draw child); // enum modes widened to int so the record stays comparable. UiScale is in here // because it feeds CalcWordWrapPositionA -- a scale change rewraps every row. + // + // v1.13.0 added four axes that could always stale this cache and never did: + // the italic size (pushed mid-row by ChunkRenderer), ItalicEnabled (which + // swaps between two differently sized faces), FontsEnabled and UseHellionFont + // (both swap the face outright, and their two size fields default to the same + // 12.75f -- so the fingerprint did not move while the glyph widths did). private LayoutFingerprint BuildLayoutFingerprint(float contentWidth) { - var (global, symbols) = _fonts.EffectiveFontFingerprint(); + var fonts = _fonts.EffectiveFontFingerprint(); return new LayoutFingerprint( - global, - symbols, + fonts.Global, + fonts.Symbols, + fonts.Sender, + fonts.Meta, + fonts.Italic, Plugin.Config.UseCompactDensity, + Plugin.Config.FontsEnabled, + Plugin.Config.UseHellionFont, + Plugin.Config.ItalicEnabled, (int)Plugin.Config.NameFormMode, (int)Plugin.Config.WorldSuffixMode, contentWidth, diff --git a/HellionChat/Util/LayoutFingerprint.cs b/HellionChat/Util/LayoutFingerprint.cs index f6c6785..be2db40 100644 --- a/HellionChat/Util/LayoutFingerprint.cs +++ b/HellionChat/Util/LayoutFingerprint.cs @@ -5,7 +5,13 @@ namespace HellionChat.Util; internal readonly record struct LayoutFingerprint( float FontGlobal, float FontSymbols, + float FontSender, + float FontMeta, + float FontItalic, bool Compact, + bool FontsEnabled, + bool UseHellionFont, + bool ItalicEnabled, int NameForm, int WorldSuffix, float Width, @@ -15,7 +21,8 @@ internal readonly record struct LayoutFingerprint( // Toggles: they land on a new value in one frame and stay there. Waiting on // them would leave the planner running against the previous density's // heights while the rows are already painted the new way. - internal (bool, int, int) Discrete => (Compact, NameForm, WorldSuffix); + internal (bool, bool, bool, bool, int, int) Discrete => + (Compact, FontsEnabled, UseHellionFont, ItalicEnabled, NameForm, WorldSuffix); } // Dragging a window edge or the Dalamud UI-scale slider moves the continuous From 0916f8d1fbac33f7f2f9ac64e2452de620441ab0 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 09:49:21 +0200 Subject: [PATCH 06/29] feat(style): put mixed sizes on a shared baseline ImGui lines items up by their top edge. ItemSize only shifts anything when CurrLineTextBaseOffset is non-zero, and that stays zero unless AlignTextToFramePadding ran -- so a meta timestamp beside a body-sized name would sit flush at the top and float above the baseline. The correction is the difference of the two ascents, scaled. The scaling looks like it is applied twice and is not: Dalamud rasterises at SizePx * GlobalScale and then divides the metrics back down, so ImFont.Ascent comes out logical. Drawing multiplies it up again. The comment says so, because the first reviewer to see this file read it the other way. No call site yet -- the self-test in the next commit takes it, and the message list takes it in block C. --- HellionChat/Ui/StyleEngine/BaselineMath.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 HellionChat/Ui/StyleEngine/BaselineMath.cs diff --git a/HellionChat/Ui/StyleEngine/BaselineMath.cs b/HellionChat/Ui/StyleEngine/BaselineMath.cs new file mode 100644 index 0000000..4fdb87b --- /dev/null +++ b/HellionChat/Ui/StyleEngine/BaselineMath.cs @@ -0,0 +1,19 @@ +namespace HellionChat.Ui.StyleEngine; + +// TEST-MIRROR: Ui/BaselineMathTests.cs +// +// ImGui lines items up on a row by their top edge, not their baseline: ItemSize +// only shifts anything when CurrLineTextBaseOffset is non-zero, and that stays at +// zero unless AlignTextToFramePadding ran. So a smaller face beside a larger one +// hangs, flush at the top and floating above the baseline. +// +// The correction is the difference between the two ascents. It is easy to assume +// the display scale is applied twice here and it is not: Dalamud rasterises at +// SizePx * GlobalScale and then divides the metrics straight back down +// (ImGuiHelpers.AdjustGlyphMetrics(1 / scale, ...)), so ImFont.Ascent is a +// logical value. Drawing multiplies it up again -- and so must this. +internal static class BaselineMath +{ + internal static float OffsetFor(float ascentLarge, float ascentSmall, float uiScale) => + MathF.Max(0f, (ascentLarge - ascentSmall) * uiScale); +} From 81456c981bc53b7659358eb5e28a33566302abb5 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 09:50:56 +0200 Subject: [PATCH 07/29] test(selftest): show what each type role actually resolves to Block A ends with no call site in the message list -- that arrives in block C -- so without this step there would be nothing to look at and two helpers with no caller at all. It draws rather than asserts, because asserting proves the wrong thing here. SimplePushedFont pushes nothing at all when a handle is not ready, silently, and the text then renders in whatever face was already active. A step that compares two numbers and reports Pass would sail straight past that. So this one puts a timestamp, a sender and a body line next to each other and lets them be looked at, with expected-against-actual printed underneath. The three weight buttons exist because the alternative was three builds and a plugin restart between each, and nobody compares a typeface across a restart. RebuildDelegateFonts is synchronous on this thread, so the sample row picks up the new rasterisation on the next frame. --- HellionChat/Plugin.cs | 1 + HellionChat/SelfTests/TypeScaleStep.cs | 152 +++++++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 HellionChat/SelfTests/TypeScaleStep.cs 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)"); + } +} From 64f7d9b9759a9ec43aa8d19c50f5b2c13b827a22 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 09:51:53 +0200 Subject: [PATCH 08/29] style: let csharpier reflow the widened tuples Formatting only. The five-element fingerprint tuple and its field declaration ran past the line limit, and preflight block E is stricter than the check I had been running per task -- it caught what the narrower filter did not. --- HellionChat/FontManager.cs | 36 ++++++++++++-------------- HellionChat/SelfTests/TypeScaleStep.cs | 9 ++----- 2 files changed, 19 insertions(+), 26 deletions(-) diff --git a/HellionChat/FontManager.cs b/HellionChat/FontManager.cs index 79fcb6a..fd09a69 100644 --- a/HellionChat/FontManager.cs +++ b/HellionChat/FontManager.cs @@ -59,7 +59,13 @@ public sealed class FontManager : IDisposable private Func? _typographySource; // Lets RebuildDelegateFontsIfChanged skip rebuilds when the size is unchanged. - private (float Global, float Symbols, float Sender, float Meta, float Italic) _lastBuiltFingerprint; + private ( + float Global, + float Symbols, + float Sender, + float Meta, + float Italic + ) _lastBuiltFingerprint; // True once every required atlas-owned handle reports Available. Components // gate their first-frame draw on this — without it the layout math would @@ -92,14 +98,7 @@ public sealed class FontManager : IDisposable // 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, - ]; + 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 @@ -219,8 +218,13 @@ public sealed class FontManager : IDisposable // Every size that can land in one message row. Roles follow the base size // arithmetically, but the resolved value is what the height cache has to key // on -- a theme override moves the base without moving any factor. - internal (float Global, float Symbols, float Sender, float Meta, float Italic) - EffectiveFontFingerprint() + internal ( + float Global, + float Symbols, + float Sender, + float Meta, + float Italic + ) EffectiveFontFingerprint() { var basePt = ResolveGlobalFontPt(); return ( @@ -303,10 +307,7 @@ public sealed class FontManager : IDisposable atlas.NewDelegateFontHandle(e => e.OnPreBuild(tk => { - var basePt = TypeScale.SizePtOf( - TypeRole.Sender, - ResolveGlobalFontPt() - ); + var basePt = TypeScale.SizePtOf(TypeRole.Sender, ResolveGlobalFontPt()); var config = new SafeFontConfig { SizePt = basePt, @@ -328,10 +329,7 @@ public sealed class FontManager : IDisposable atlas.NewDelegateFontHandle(e => 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 bundledBytes = Plugin.Config.UseHellionFont ? TryGetBundledFontBytes() : null; config.MergeFont = bundledBytes is not null diff --git a/HellionChat/SelfTests/TypeScaleStep.cs b/HellionChat/SelfTests/TypeScaleStep.cs index 0de8c73..1456f97 100644 --- a/HellionChat/SelfTests/TypeScaleStep.cs +++ b/HellionChat/SelfTests/TypeScaleStep.cs @@ -96,12 +96,7 @@ internal sealed class TypeScaleStep : ISelfTestStep } } - private static void ReportRole( - FontManager fm, - TypeRole role, - float basePt, - IFontHandle handle - ) + private static void ReportRole(FontManager fm, TypeRole role, float basePt, IFontHandle handle) { var expected = TypeScale.SizePtOf(role, basePt); using (handle.Push()) @@ -110,7 +105,7 @@ internal sealed class TypeScaleStep : ISelfTestStep 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) " + $"{role, -7} expected {expected:0.00}pt ({expectedPx:0.0}px) " + $"| actual {actualPx:0.0}px {(agrees ? "OK" : "MISMATCH")}" ); } From 0551116a0c9ca1e61550aa8177decb0a521e2749 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 09:52:49 +0200 Subject: [PATCH 09/29] refactor(sidebar): let the channel header resolve the same icon Visibility only. The header has to show the icon the sidebar row shows, and the lookup table alone would not do it: it only answers for a tab with an explicitly chosen icon, and the default is none. Most tabs reach their icon through the derivation this method wraps. --- HellionChat/Ui/Components/Sidebar.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/HellionChat/Ui/Components/Sidebar.cs b/HellionChat/Ui/Components/Sidebar.cs index b0b4a02..1a2c890 100644 --- a/HellionChat/Ui/Components/Sidebar.cs +++ b/HellionChat/Ui/Components/Sidebar.cs @@ -486,7 +486,11 @@ internal sealed class Sidebar ImGui.PopID(); } - private static FontAwesomeIcon ResolveTabIcon(Tab tab) + // internal since v1.13.0: the channel header shows the same icon as the row + // in here, and it has to resolve it the same way. IconByName alone would not + // do -- it only answers for a tab with an explicitly chosen icon, and the + // default is none, so most tabs fall through to the derivation below. + internal static FontAwesomeIcon ResolveTabIcon(Tab tab) { if ( !string.IsNullOrWhiteSpace(tab.Icon) && IconByName.TryGetValue(tab.Icon, out var mapped) From 74f9ff7f16e49e1b8a2cc3901702ab1d73cd40a3 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 09:55:13 +0200 Subject: [PATCH 10/29] feat(chat): a header that says which channel you are in Small caps with wide tracking, not a smaller size. The mockup asks for one pixel below body text, and one pixel would have cost a whole additional font handle at full glyph range -- tab names are free user input and can be CJK. Tracking reads the same in every palette and costs nothing, which is the same argument that settled the section headings in the settings window. Only the world and the clock use the meta face. Its glyph range is ASCII plus a middle dot, so anything that gets translated has to stay on the body face. Two things it will not do. It drops the tab name where one is already on screen: a pop-out with its title bar on carries the name in the title, and the top-tab strip carries it too. Repeating it one line below is the exact defect that got an earlier header row removed, and the comment left behind at that removal is what made this rule. And it disappears entirely below a minimum message-area height -- the window minimum is 260px, already shared with the honorific header, the input row and the status bar, and all of it scales together. A header that leaves two readable lines is worse than no header. Both decisions are arithmetic and sit in ChannelHeaderLayout with tests. The gallery gets an entry despite the header needing a tab and the font handles, because that window exists precisely because pieces once shipped without a call site. --- .../Ui/StyleEngine/Widgets/ChannelHeader.cs | 139 ++++++++++++++++++ .../Widgets/ChannelHeaderLayout.cs | 51 +++++++ HellionChat/Ui/Windows/WidgetGalleryWindow.cs | 36 +++++ 3 files changed, 226 insertions(+) create mode 100644 HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs create mode 100644 HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderLayout.cs diff --git a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs new file mode 100644 index 0000000..53c75df --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs @@ -0,0 +1,139 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using HellionChat.Util; + +namespace HellionChat.Ui.StyleEngine.Widgets; + +// The band above the conversation: which channel you are in on the left, where +// you are and what time it is on the right. +// +// Set apart by small caps with wide tracking rather than by size. That is a +// deliberate departure from the mockup, which asks for one pixel smaller than +// body text: one pixel would cost a whole additional font handle at full glyph +// 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. +internal static class ChannelHeader +{ + private const float InsetRaw = 14f; + private const float PadYRaw = 7f; + private const float TrackRaw = 1.8f; + private const float DetailTrackRaw = 0.9f; + private const float IconGapRaw = 8f; + + // Measured, never a fixed 32px: the band has to hold a line of text, and the + // font comes from Config, which display scaling does not feed into. + internal static float Height => + ImGui.GetTextLineHeight() + MathF.Round(PadYRaw * 2f * Metrics.Scale); + + internal static void Draw(Tab tab, ChannelHeaderMode mode, FontManager fonts, string detail) + { + var scale = Metrics.Scale; + var width = ImGui.GetContentRegionAvail().X; + var height = Height; + var origin = ImGui.GetCursorScreenPos(); + + var theme = Plugin.Instance.ThemeRegistry.Active; + var surface = theme.Colors.Surface; + + var name = tab.Name.ToUpperInvariant(); + var track = TrackRaw * scale; + var detailTrack = DetailTrackRaw * scale; + + // 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); + + var iconWidth = MeasureIcon(fonts, Components.Sidebar.ResolveTabIcon(tab)); + nameRun += iconWidth + IconGapRaw * scale; + + float detailRun; + using (fonts.MetaFont!.Push()) + detailRun = DrawListExtensions.MeasureTrackedText(detail, detailTrack); + + var inset = InsetRaw * scale; + var plan = ChannelHeaderLayout.Plan( + mode, + width - inset * 2f, + ImGui.GetContentRegionAvail().Y - height, + nameRun, + detailRun + ); + + if (!plan.ShowHeader) + return; + + var dl = ImGui.GetWindowDrawList(); + var bottomRight = origin + new Vector2(width, height); + dl.AddRectFilled(origin, bottomRight, ColourUtil.RgbaToAbgr(surface)); + dl.AddLine( + new Vector2(origin.X, bottomRight.Y - 1f), + new Vector2(bottomRight.X, bottomRight.Y - 1f), + ColourUtil.RgbaToAbgr(theme.Colors.Border), + MathF.Max(1f, scale) + ); + + var textY = origin.Y + MathF.Round(PadYRaw * scale); + + if (plan.ShowName) + { + var accent = ColourUtil.RgbaToAbgr( + ColourUtil.EnsureContrast(theme.Colors.Accent, surface, 4.5f) + ); + var x = origin.X + inset; + + 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; + } + + using (fonts.RegularFont.Push()) + dl.DrawTrackedText(new Vector2(x, textY), name, accent, track); + } + + if (plan.ShowDetail) + { + var muted = ColourUtil.RgbaToAbgr( + 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; + + float metaAscent; + using (fonts.MetaFont.Push()) + metaAscent = ImGui.GetFont().Ascent; + + var drop = BaselineMath.OffsetFor(bodyAscent, metaAscent, scale); + + using (fonts.MetaFont.Push()) + dl.DrawTrackedText( + new Vector2(bottomRight.X - inset - detailRun, textY + drop), + detail, + muted, + detailTrack + ); + } + + // ItemSize rather than a cursor move: it advances the cursor AND extends + // CursorMaxPos, which is what the enclosing layout measures. + ImGui.SetCursorScreenPos(origin); + ImGuiP.ItemSize(new Vector2(width, height - ImGui.GetStyle().ItemSpacing.Y)); + } + + private static float MeasureIcon(FontManager fonts, FontAwesomeIcon icon) + { + using (fonts.FontAwesome.Push()) + return ImGui.CalcTextSize(icon.ToIconString()).X; + } +} diff --git a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderLayout.cs b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderLayout.cs new file mode 100644 index 0000000..3e6a302 --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderLayout.cs @@ -0,0 +1,51 @@ +namespace HellionChat.Ui.StyleEngine.Widgets; + +internal enum ChannelHeaderMode +{ + // Sidebar layout: nothing else on screen carries the tab name. + Full, + + // Top-tab layout, and pop-outs with their title bar on. The name is already + // one line up; repeating it there is the defect that got an earlier header + // row removed. + DetailOnly, +} + +internal readonly record struct ChannelHeaderPlan(bool ShowHeader, bool ShowName, bool ShowDetail); + +// TEST-MIRROR: Ui/ChannelHeaderLayoutTests.cs +// +// Two decisions, both arithmetic, both kept out of the draw call. +// +// Horizontally this follows the status bar: measure each part, then leave out +// what will not fit rather than letting it run off the edge. The name has +// priority over the trailing detail -- it is the reason the header exists. +// +// Vertically it can decide to disappear entirely, which the status bar never +// does. The window minimum is 260px tall and already shared with the honorific +// header, the input row and the status bar, and all of it scales together. A +// header that leaves two readable lines of chat is worse than no header. +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; + + internal static ChannelHeaderPlan Plan( + ChannelHeaderMode mode, + float availableWidth, + float availableHeight, + float nameWidth, + float detailWidth + ) + { + if (availableHeight < MinMessageAreaHeight) + return new ChannelHeaderPlan(false, false, false); + + var showName = mode is ChannelHeaderMode.Full; + var used = showName ? nameWidth : 0f; + var showDetail = used + detailWidth <= availableWidth; + + return new ChannelHeaderPlan(true, showName, showDetail); + } +} diff --git a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs index 3d51571..a3ebd03 100644 --- a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs +++ b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs @@ -23,6 +23,8 @@ internal sealed class WidgetGalleryWindow : Window private readonly WidgetPalette _palette; private int _badgeCount = 3; + private Tab? _headerSample; + private int _headerMode; private bool _rowActive = true; private bool _toggleA = true; private bool _toggleB; @@ -54,6 +56,40 @@ internal sealed class WidgetGalleryWindow : Window DrawPillSection(c); DrawIconButtonSection(c); DrawDividerSection(c); + DrawChannelHeaderSection(c); + } + + // Unlike every other entry here the header is not a stateless primitive -- it + // needs a tab and the font handles. It is in the gallery anyway: this window + // exists because three style-engine pieces once shipped with no call site at + // all, and a header nobody can look at outside a live chat is exactly how + // that happens again. + private void DrawChannelHeaderSection(ThemeColors c) + { + ImGui.TextUnformatted("ChannelHeader"); + + var fonts = _plugin.FontManager; + if (fonts is null || !fonts.FontsReady) + { + ImGui.TextDisabled("fonts not ready"); + return; + } + + _headerSample ??= new Tab { Name = "General", Icon = "comment" }; + + ImGui.RadioButton("full##hdr", ref _headerMode, 0); + ImGui.SameLine(); + ImGui.RadioButton("detail only##hdr", ref _headerMode, 1); + + ImGui.SliderFloat("sender weight##hdr", ref FontManager.SenderWeight, 1.0f, 1.6f, "%.2f"); + ImGui.SameLine(); + if (ImGui.Button("apply##hdr-weight")) + fonts.RebuildDelegateFonts(); + + var mode = _headerMode == 0 ? ChannelHeaderMode.Full : ChannelHeaderMode.DetailOnly; + ChannelHeader.Draw(_headerSample, mode, fonts, "Ravana · 14:32"); + + ImGui.Spacing(); } private void DrawRowSection(ThemeColors c) From 100290ea4d0db954eebae723b276350d7176af9e Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 09:58:29 +0200 Subject: [PATCH 11/29] feat(chat): put the channel header above both windows Drawn before the scroll child in each, so it stays put while the log moves. The child's height is deliberately left alone: it is given as a negative value, and ImGui resolves those against the space still available from the current cursor -- which the header has already reduced. Subtracting it a second time would have opened a gap of exactly the header's height above the input row, in both windows. Where the name shows follows one rule: never twice on the same screen. The sidebar layout is the only place nothing else carries it, so that is the only place the header says it. The top-tab strip carries it, and a pop-out with its title bar on carries it in the title. The pop-out's plain title row is gone, replaced by the header. The comment it left behind is worth keeping in mind -- an earlier header row was removed exactly because it repeated the tab name one line below the title bar. That warning is now the rule rather than a reason to have no header at all. The trailing detail is the home world and the clock, and 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. --- .../Resources/HellionStrings.Designer.cs | 1 + HellionChat/Resources/HellionStrings.ca.resx | 3 ++ HellionChat/Resources/HellionStrings.cs.resx | 3 ++ HellionChat/Resources/HellionStrings.da.resx | 3 ++ HellionChat/Resources/HellionStrings.de.resx | 3 ++ HellionChat/Resources/HellionStrings.el.resx | 3 ++ HellionChat/Resources/HellionStrings.es.resx | 3 ++ HellionChat/Resources/HellionStrings.fi.resx | 3 ++ HellionChat/Resources/HellionStrings.fr.resx | 3 ++ HellionChat/Resources/HellionStrings.hu.resx | 3 ++ HellionChat/Resources/HellionStrings.it.resx | 3 ++ HellionChat/Resources/HellionStrings.ja.resx | 3 ++ HellionChat/Resources/HellionStrings.ko.resx | 3 ++ HellionChat/Resources/HellionStrings.nb.resx | 3 ++ HellionChat/Resources/HellionStrings.nl.resx | 3 ++ HellionChat/Resources/HellionStrings.pl.resx | 3 ++ .../Resources/HellionStrings.pt-BR.resx | 3 ++ .../Resources/HellionStrings.pt-PT.resx | 3 ++ HellionChat/Resources/HellionStrings.resx | 3 ++ HellionChat/Resources/HellionStrings.ro.resx | 3 ++ HellionChat/Resources/HellionStrings.ru.resx | 3 ++ HellionChat/Resources/HellionStrings.sv.resx | 3 ++ HellionChat/Resources/HellionStrings.tr.resx | 3 ++ HellionChat/Resources/HellionStrings.uk.resx | 3 ++ .../Resources/HellionStrings.zh-Hans.resx | 3 ++ .../Resources/HellionStrings.zh-Hant.resx | 3 ++ .../Ui/StyleEngine/Widgets/ChannelHeader.cs | 18 ++++++++++ .../Widgets/ChannelHeaderDetail.cs | 34 +++++++++++++++++++ HellionChat/Ui/Windows/ChannelPopoutWindow.cs | 30 +++++++++------- HellionChat/Ui/Windows/MainWindow.cs | 20 +++++++++++ 30 files changed, 165 insertions(+), 13 deletions(-) create mode 100644 HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderDetail.cs diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs index d1682c0..f72737d 100644 --- a/HellionChat/Resources/HellionStrings.Designer.cs +++ b/HellionChat/Resources/HellionStrings.Designer.cs @@ -388,6 +388,7 @@ internal class HellionStrings internal static string StatusBar_MessagesThousands => Get(nameof(StatusBar_MessagesThousands)); internal static string Settings_Preview_TitleMock => Get(nameof(Settings_Preview_TitleMock)); internal static string Settings_Preview_StatusOpen => Get(nameof(Settings_Preview_StatusOpen)); + internal static string ChannelHeader_NotLoggedIn => Get(nameof(ChannelHeader_NotLoggedIn)); internal static string Settings_Section_Links => Get(nameof(Settings_Section_Links)); internal static string Settings_Section_Behaviour => Get(nameof(Settings_Section_Behaviour)); internal static string Settings_Section_Keybinds => Get(nameof(Settings_Section_Keybinds)); diff --git a/HellionChat/Resources/HellionStrings.ca.resx b/HellionChat/Resources/HellionStrings.ca.resx index d2d0148..1cedf9c 100644 --- a/HellionChat/Resources/HellionStrings.ca.resx +++ b/HellionChat/Resources/HellionStrings.ca.resx @@ -1358,4 +1358,7 @@ obert + + Sense sessió + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.cs.resx b/HellionChat/Resources/HellionStrings.cs.resx index ea554e1..ca16c67 100644 --- a/HellionChat/Resources/HellionStrings.cs.resx +++ b/HellionChat/Resources/HellionStrings.cs.resx @@ -1357,4 +1357,7 @@ otevřeno + + Nepřihlášen + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.da.resx b/HellionChat/Resources/HellionStrings.da.resx index 29802a1..b0c3a86 100644 --- a/HellionChat/Resources/HellionStrings.da.resx +++ b/HellionChat/Resources/HellionStrings.da.resx @@ -1357,4 +1357,7 @@ åben + + Ikke logget ind + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx index de48bc0..d4a2e8c 100644 --- a/HellionChat/Resources/HellionStrings.de.resx +++ b/HellionChat/Resources/HellionStrings.de.resx @@ -1352,4 +1352,7 @@ offen + + Nicht eingeloggt + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.el.resx b/HellionChat/Resources/HellionStrings.el.resx index abfe8ef..7aad3ea 100644 --- a/HellionChat/Resources/HellionStrings.el.resx +++ b/HellionChat/Resources/HellionStrings.el.resx @@ -1357,4 +1357,7 @@ ανοιχτό + + Εκτός σύνδεσης + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.es.resx b/HellionChat/Resources/HellionStrings.es.resx index dfb57c8..1a867ff 100644 --- a/HellionChat/Resources/HellionStrings.es.resx +++ b/HellionChat/Resources/HellionStrings.es.resx @@ -1358,4 +1358,7 @@ abierto + + Sin sesión + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fi.resx b/HellionChat/Resources/HellionStrings.fi.resx index 2bdefc6..fc6cbc9 100644 --- a/HellionChat/Resources/HellionStrings.fi.resx +++ b/HellionChat/Resources/HellionStrings.fi.resx @@ -1357,4 +1357,7 @@ auki + + Ei kirjautunut sisään + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.fr.resx b/HellionChat/Resources/HellionStrings.fr.resx index 956f1e5..c1679c2 100644 --- a/HellionChat/Resources/HellionStrings.fr.resx +++ b/HellionChat/Resources/HellionStrings.fr.resx @@ -1358,4 +1358,7 @@ ouvert + + Non connecté + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.hu.resx b/HellionChat/Resources/HellionStrings.hu.resx index f47efee..edcab59 100644 --- a/HellionChat/Resources/HellionStrings.hu.resx +++ b/HellionChat/Resources/HellionStrings.hu.resx @@ -1357,4 +1357,7 @@ nyitva + + Nincs bejelentkezve + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.it.resx b/HellionChat/Resources/HellionStrings.it.resx index 7a372ef..f821293 100644 --- a/HellionChat/Resources/HellionStrings.it.resx +++ b/HellionChat/Resources/HellionStrings.it.resx @@ -1358,4 +1358,7 @@ aperto + + Non connesso + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ja.resx b/HellionChat/Resources/HellionStrings.ja.resx index a84ed43..67029bb 100644 --- a/HellionChat/Resources/HellionStrings.ja.resx +++ b/HellionChat/Resources/HellionStrings.ja.resx @@ -1358,4 +1358,7 @@ 開いています + + 未ログイン + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ko.resx b/HellionChat/Resources/HellionStrings.ko.resx index d6e4303..8be4681 100644 --- a/HellionChat/Resources/HellionStrings.ko.resx +++ b/HellionChat/Resources/HellionStrings.ko.resx @@ -1358,4 +1358,7 @@ 열림 + + 로그인하지 않음 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nb.resx b/HellionChat/Resources/HellionStrings.nb.resx index d5f6a01..bd4eba9 100644 --- a/HellionChat/Resources/HellionStrings.nb.resx +++ b/HellionChat/Resources/HellionStrings.nb.resx @@ -1357,4 +1357,7 @@ åpen + + Ikke innlogget + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.nl.resx b/HellionChat/Resources/HellionStrings.nl.resx index 08ac71a..a98ec25 100644 --- a/HellionChat/Resources/HellionStrings.nl.resx +++ b/HellionChat/Resources/HellionStrings.nl.resx @@ -1358,4 +1358,7 @@ open + + Niet ingelogd + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pl.resx b/HellionChat/Resources/HellionStrings.pl.resx index d88313d..1002fb9 100644 --- a/HellionChat/Resources/HellionStrings.pl.resx +++ b/HellionChat/Resources/HellionStrings.pl.resx @@ -1357,4 +1357,7 @@ otwarte + + Niezalogowany + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-BR.resx b/HellionChat/Resources/HellionStrings.pt-BR.resx index b37e345..3c17c35 100644 --- a/HellionChat/Resources/HellionStrings.pt-BR.resx +++ b/HellionChat/Resources/HellionStrings.pt-BR.resx @@ -1358,4 +1358,7 @@ aberto + + Não conectado + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.pt-PT.resx b/HellionChat/Resources/HellionStrings.pt-PT.resx index 1eb606a..708b673 100644 --- a/HellionChat/Resources/HellionStrings.pt-PT.resx +++ b/HellionChat/Resources/HellionStrings.pt-PT.resx @@ -1357,4 +1357,7 @@ aberto + + Sem sessão iniciada + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx index d6ebc8a..c0dfed0 100644 --- a/HellionChat/Resources/HellionStrings.resx +++ b/HellionChat/Resources/HellionStrings.resx @@ -1369,4 +1369,7 @@ open + + Not logged in + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ro.resx b/HellionChat/Resources/HellionStrings.ro.resx index d7b6900..adae878 100644 --- a/HellionChat/Resources/HellionStrings.ro.resx +++ b/HellionChat/Resources/HellionStrings.ro.resx @@ -1358,4 +1358,7 @@ deschis + + Neconectat + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.ru.resx b/HellionChat/Resources/HellionStrings.ru.resx index 365e56d..8cb69a7 100644 --- a/HellionChat/Resources/HellionStrings.ru.resx +++ b/HellionChat/Resources/HellionStrings.ru.resx @@ -1358,4 +1358,7 @@ открыт + + Не выполнен вход + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.sv.resx b/HellionChat/Resources/HellionStrings.sv.resx index 276879b..b175ce6 100644 --- a/HellionChat/Resources/HellionStrings.sv.resx +++ b/HellionChat/Resources/HellionStrings.sv.resx @@ -1358,4 +1358,7 @@ öppen + + Inte inloggad + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.tr.resx b/HellionChat/Resources/HellionStrings.tr.resx index 3b310af..b99d3e7 100644 --- a/HellionChat/Resources/HellionStrings.tr.resx +++ b/HellionChat/Resources/HellionStrings.tr.resx @@ -1357,4 +1357,7 @@ açık + + Oturum açılmadı + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.uk.resx b/HellionChat/Resources/HellionStrings.uk.resx index 23e35f6..6942e83 100644 --- a/HellionChat/Resources/HellionStrings.uk.resx +++ b/HellionChat/Resources/HellionStrings.uk.resx @@ -1357,4 +1357,7 @@ відкрито + + Вхід не виконано + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hans.resx b/HellionChat/Resources/HellionStrings.zh-Hans.resx index 66299f2..ea43134 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hans.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hans.resx @@ -1358,4 +1358,7 @@ 打开 + + 未登录 + \ No newline at end of file diff --git a/HellionChat/Resources/HellionStrings.zh-Hant.resx b/HellionChat/Resources/HellionStrings.zh-Hant.resx index e000264..d06e35e 100644 --- a/HellionChat/Resources/HellionStrings.zh-Hant.resx +++ b/HellionChat/Resources/HellionStrings.zh-Hant.resx @@ -1358,4 +1358,7 @@ 開啟 + + 未登入 + \ No newline at end of file diff --git a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs index 53c75df..935be04 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs @@ -131,6 +131,24 @@ 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() + { + var world = Plugin.PlayerState.HomeWorld.IsValid + ? Plugin.PlayerState.HomeWorld.Value.Name.ExtractText() + : null; + + return ChannelHeaderDetail.Format( + world, + DateTimeOffset.Now, + Plugin.Config.Use24HourClock, + Resources.HellionStrings.ChannelHeader_NotLoggedIn + ); + } + private static float MeasureIcon(FontManager fonts, FontAwesomeIcon icon) { using (fonts.FontAwesome.Push()) diff --git a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderDetail.cs b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderDetail.cs new file mode 100644 index 0000000..765f1b5 --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderDetail.cs @@ -0,0 +1,34 @@ +using System.Globalization; + +namespace HellionChat.Ui.StyleEngine.Widgets; + +// TEST-MIRROR: Ui/ChannelHeaderDetailTests.cs +// +// The trailing half of the channel header: where you are and what time it is. +// +// 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 +{ + internal const string Separator = " · "; + + internal static string Format( + string? world, + DateTimeOffset now, + bool use24Hour, + string fallback + ) + { + var where = string.IsNullOrWhiteSpace(world) ? fallback : world; + var clock = use24Hour + ? now.ToString("HH:mm", CultureInfo.InvariantCulture) + : now.ToString("h:mm tt", CultureInfo.InvariantCulture); + + return string.Concat(where, Separator, clock); + } +} diff --git a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs index 8ad82cb..d0ffa97 100644 --- a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs +++ b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs @@ -123,14 +123,23 @@ internal sealed class ChannelPopoutWindow : Window, IFocusableChatWindow if (Bound is null) return; - // No header row at all any more. With the title bar on it repeated the - // tab name one line below itself; with the bar off, hiding it took the - // only way out of the window with it, because the title bar carries no - // close button either -- closing has to go through the pool so the slot - // is released. Pop-in lives in the input row now, where the other window - // actions already are. - if (!Plugin.Config.ShowPopOutTitleBar) - DrawTitle(Bound); + // v1.13.0: the plain title row became the channel header. The warning it + // left behind still holds and is now the rule the header follows -- with + // the title bar on, the window title already carries the tab name, so the + // header drops the name and shows only the world and the clock. + // + // Drawn before the body child on purpose, and the child's height is left + // alone: it is a negative value, which ImGui resolves against the space + // still available from the current cursor, and the header has already + // taken its share. + StyleEngine.Widgets.ChannelHeader.Draw( + Bound, + Plugin.Config.ShowPopOutTitleBar + ? StyleEngine.Widgets.ChannelHeaderMode.DetailOnly + : StyleEngine.Widgets.ChannelHeaderMode.Full, + Plugin.Instance.FontManager, + StyleEngine.Widgets.ChannelHeader.CurrentDetail() + ); // The header close button can unbind us mid-frame (CloseRequested -> // pool.TryClose -> Unbind nulls Bound). Re-check before the body so we @@ -165,9 +174,4 @@ internal sealed class ChannelPopoutWindow : Window, IFocusableChatWindow } // Name only. Shown when the window has no title bar to carry it. - private void DrawTitle(Tab tab) - { - ImGui.TextUnformatted(tab.Name); - ImGui.Separator(); - } } diff --git a/HellionChat/Ui/Windows/MainWindow.cs b/HellionChat/Ui/Windows/MainWindow.cs index 9684f62..909579f 100644 --- a/HellionChat/Ui/Windows/MainWindow.cs +++ b/HellionChat/Ui/Windows/MainWindow.cs @@ -388,6 +388,26 @@ internal sealed class MainWindow : Window, IFocusableChatWindow ? Plugin.InputPreview.PreviewHeight : 0f; + // Before the child, so it does not scroll away with the log. The child's + // height is left alone on purpose: it is given as a negative value, and + // ImGui resolves those against the space still available from the current + // cursor -- which the header has already reduced. Subtracting it a second + // time would open a gap of exactly the header's height above the input row. + if (_activeTab is { } headerTab) + { + var mode = + Plugin.Config.MainWindowLayoutMode == MainWindowLayoutMode.TopTabs + ? StyleEngine.Widgets.ChannelHeaderMode.DetailOnly + : StyleEngine.Widgets.ChannelHeaderMode.Full; + + StyleEngine.Widgets.ChannelHeader.Draw( + headerTab, + mode, + Plugin.Instance.FontManager, + StyleEngine.Widgets.ChannelHeader.CurrentDetail() + ); + } + using ( var messages = ImRaii.Child( "##hellion-main-area", From dfc0cda8066a0d9cf0f19d247e130249991f47f8 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 10:21:03 +0200 Subject: [PATCH 12/29] fix(chat): four things the header review found, all of them visible The self-test was the worst of them, because it is the only tool that makes block A judgeable at all and it destroyed itself on use. It returned Fail while the atlas was not ready, and its own weight buttons trigger a rebuild -- which is asynchronous, not synchronous as the comment claimed. Click a weight, watch the step go red. It waits now, like the two existing steps that had already worked this out. The translated stand-in was drawn in the meta face, whose glyph range is ASCII plus a middle dot. Fifteen of the twenty-five translations reach outside that, so "not logged in" would have rendered as a row of question marks in Japanese, Russian, Korean, Greek and eleven others -- and the measured width would have been the width of the question marks, so the right edge would have drifted too. The plan said to keep it on the body face and the comment in FontManager says so as well; the code simply did not. The detail is two parts now rather than one string, and each part is measured under the face that draws it. The header was the only place in the UI pushing RegularFont directly, without the FontsEnabled-or-UseHellionFont check every other push site makes. With both toggles off the window draws in AXIS and the header would have drawn in Inter-Light, at a different size, in a band measured against a third one. And the icon sat on the text baseline. FontAwesome is a fixed-width handle built at Dalamud's own size and does not follow the plugin's font setting, so at any other body size it hangs. The sidebar already knew this and centres against the row; the header does the same now. Two smaller ones came along: the minimum-height threshold was compared unscaled, which would have dissolved it at higher display scales, and the height passed into that check included the input row -- so "is there still room to read" was measuring the wrong thing. Both callers now say what sits below them. --- HellionChat/Resources/HellionStrings.ca.resx | 2 +- HellionChat/Resources/HellionStrings.es.resx | 2 +- HellionChat/Resources/HellionStrings.fi.resx | 2 +- HellionChat/Resources/HellionStrings.ko.resx | 2 +- HellionChat/Resources/HellionStrings.ru.resx | 2 +- HellionChat/SelfTests/TypeScaleStep.cs | 23 ++- .../Ui/StyleEngine/Widgets/ChannelHeader.cs | 150 +++++++++++++----- .../Widgets/ChannelHeaderDetail.cs | 34 ++-- .../Widgets/ChannelHeaderLayout.cs | 18 ++- HellionChat/Ui/Windows/ChannelPopoutWindow.cs | 3 +- HellionChat/Ui/Windows/MainWindow.cs | 6 +- HellionChat/Ui/Windows/WidgetGalleryWindow.cs | 14 +- 12 files changed, 183 insertions(+), 75 deletions(-) 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(); } From e810d2479de31b2f7315cc376feb4b5aa81d0771 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 10:22:25 +0200 Subject: [PATCH 13/29] refactor(style): drop the type role that could never be pushed TypeRole.Header had no call site and could not get one. The channel header is set apart by small caps and tracking, not by size, so the role resolved to exactly Body -- a value that would sit in the enum being equal to another value forever. That is the precise thing this style track exists to prevent. Five widgets shipped once with no call site at all, and the rule that came out of it says no piece lands without one in the same pass. Writing a caller just to satisfy the rule would have been worse than the rule. Also cleans two comments the pop-out rewrite left pointing at things that are gone: a close button that lived on the removed title row, and a method description with no method under it. --- HellionChat/Ui/StyleEngine/TypeScale.cs | 18 ++++++++++-------- HellionChat/Ui/Windows/ChannelPopoutWindow.cs | 4 +--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/HellionChat/Ui/StyleEngine/TypeScale.cs b/HellionChat/Ui/StyleEngine/TypeScale.cs index ca8f3a5..fc504e6 100644 --- a/HellionChat/Ui/StyleEngine/TypeScale.cs +++ b/HellionChat/Ui/StyleEngine/TypeScale.cs @@ -4,19 +4,22 @@ internal enum TypeRole { Body, Sender, - Header, Meta, } // Named sizes derived from one base, so a role means the same thing wherever it // is drawn. // -// Three of the four share the base size. That is deliberate: what sets the -// sender apart is weight and what sets the header apart is small caps with wide -// tracking, and neither is a size. Solving those with size instead would make -// the log look like a ransom note. Only the meta role -- timestamps and the -// header's trailing detail -- steps down, because it is meant to be skipped over -// rather than read. +// Two of the three share the base size. That is deliberate: what sets the sender +// apart is weight, not size. Solving that with size instead would make the log +// look like a ransom note. Only the meta role -- timestamps and the header's +// trailing detail -- steps down, because it is meant to be skipped over rather +// than read. +// +// There is no Header role. The channel header is set apart by small caps with +// wide tracking and runs at the body size, so a role for it would resolve to +// exactly Body and never be pushed -- a value with no call site, which is the one +// thing this whole style track exists to stop. // // The factors are defaults, not constants. The master spec puts typography under // theme control rather than user control, and ThemeTypography already exists as @@ -31,7 +34,6 @@ internal static class TypeScale [ 1.00f, // Body -- the reference every other role is stated against 1.00f, // Sender -- set apart by weight, see FontManager.SenderWeight - 1.00f, // Header -- set apart by small caps and tracking 0.82f, // Meta -- timestamps, and the world and clock in the header ]; diff --git a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs index b82dcf0..5c01d54 100644 --- a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs +++ b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs @@ -142,7 +142,7 @@ internal sealed class ChannelPopoutWindow : Window, IFocusableChatWindow InputBar.Height ); - // The header close button can unbind us mid-frame (CloseRequested -> + // Anything drawn above can unbind us mid-frame (CloseRequested -> // pool.TryClose -> Unbind nulls Bound). Re-check before the body so we // never hand a null tab to MessageList/InputBar in this same Draw call. if (Bound is null) @@ -173,6 +173,4 @@ internal sealed class ChannelPopoutWindow : Window, IFocusableChatWindow _input.Draw(Bound); } - - // Name only. Shown when the window has no title bar to carry it. } From 56a9f3f4742eb84d2e8fee401819be82e166908a Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 11:42:13 +0200 Subject: [PATCH 14/29] fix(privacy): the header gave away what the log was hiding Screenshot mode anonymises sender names in the message list. The channel header I added yesterday sat above that list and showed two things it should not. The home world, on the right. It narrows a player down almost as far as the character name does, and the mode exists so a picture can be shared. Worse, the tab name on the left. AutoTellTabsService builds a tell tab's name as "Player@World", so a tell conversation had the partner's name and world set in tracked caps directly above a log where every message had been anonymised. The one place a reader looks first was the one place still naming them. The name is suppressed only where it actually names someone -- a tab with a tell target set. General or Trade stay readable, because they identify nobody, and a self-named tab is the user's own text. Found by asking what the new surface shows rather than by a test failing. Nothing here was failing. --- .../Ui/StyleEngine/Widgets/ChannelHeader.cs | 20 +++++++++++++++---- .../Widgets/ChannelHeaderDetail.cs | 11 ++++++++-- HellionChat/Ui/Windows/WidgetGalleryWindow.cs | 3 ++- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs index c773066..9a82176 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs @@ -68,7 +68,13 @@ internal static class ChannelHeader var icon = Components.Sidebar.ResolveTabIcon(tab); var track = TrackRaw * scale; var detailTrack = DetailTrackRaw * scale; - var showName = mode is ChannelHeaderMode.Full; + // Screenshot mode hides the name of a tell tab, because that name IS the + // conversation partner: AutoTellTabsService builds it as "Player@World". + // Drawing it in tracked caps above a log whose messages are anonymised + // would give away in the header exactly what the log is hiding. + var namesAPartner = tab.TellTarget?.IsSet() == true; + var showName = + mode is ChannelHeaderMode.Full && !(Plugin.Config.ScreenshotMode && namesAPartner); // ToUpperInvariant allocates, so only where the name is actually drawn. var name = showName ? tab.Name.ToUpperInvariant() : string.Empty; @@ -92,7 +98,12 @@ internal static class ChannelHeader using ((detail.WhereIsTranslated ? body : meta).Push()) whereRun = DrawListExtensions.MeasureTrackedText(detail.Where, detailTrack); - var rest = ChannelHeaderDetailParts.Separator + detail.Clock; + // 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()) @@ -102,7 +113,7 @@ internal static class ChannelHeader var inset = InsetRaw * scale; var plan = ChannelHeaderLayout.Plan( - mode, + showName ? ChannelHeaderMode.Full : ChannelHeaderMode.DetailOnly, width - inset * 2f, ImGui.GetContentRegionAvail().Y - height - reservedBelow, nameRun, @@ -196,7 +207,8 @@ internal static class ChannelHeader world, DateTimeOffset.Now, Plugin.Config.Use24HourClock, - Resources.HellionStrings.ChannelHeader_NotLoggedIn + Resources.HellionStrings.ChannelHeader_NotLoggedIn, + Plugin.Config.ScreenshotMode ); } diff --git a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderDetail.cs b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderDetail.cs index e2b6c3c..d9b41d0 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderDetail.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderDetail.cs @@ -31,14 +31,21 @@ internal static class ChannelHeaderDetail string? world, DateTimeOffset now, bool use24Hour, - string fallback + string fallback, + bool hideWhere ) { - var missing = string.IsNullOrWhiteSpace(world); 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. + if (hideWhere) + return new ChannelHeaderDetailParts(string.Empty, clock, false); + + var missing = string.IsNullOrWhiteSpace(world); return new ChannelHeaderDetailParts(missing ? fallback : world!, clock, missing); } } diff --git a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs index 7704d31..6dff68f 100644 --- a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs +++ b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs @@ -96,7 +96,8 @@ internal sealed class WidgetGalleryWindow : Window _headerLoggedOut ? null : "Ravana", DateTimeOffset.Now, Plugin.Config.Use24HourClock, - Resources.HellionStrings.ChannelHeader_NotLoggedIn + Resources.HellionStrings.ChannelHeader_NotLoggedIn, + Plugin.Config.ScreenshotMode ); ChannelHeader.Draw(_headerSample, mode, fonts, detail, 0f); From ad7421fbed721bd978bf11600797d32fbf476eb9 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 11:43:38 +0200 Subject: [PATCH 15/29] feat(chat): the sample the timestamp column measures against Eights rather than zeroes. In most faces 8 is the widest digit, and a column measured from 00:00 gets undercut by a 10:38 -- which would shove the name column right on exactly that row, the misalignment the column exists to remove. Two samples because the twelve-hour form is wider. That difference is why Use24HourClock has to enter the layout fingerprint: switching it moves every wrap position after the stamp, and the height cache never knew. --- HellionChat/Ui/Components/TimestampColumn.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 HellionChat/Ui/Components/TimestampColumn.cs diff --git a/HellionChat/Ui/Components/TimestampColumn.cs b/HellionChat/Ui/Components/TimestampColumn.cs new file mode 100644 index 0000000..66631d7 --- /dev/null +++ b/HellionChat/Ui/Components/TimestampColumn.cs @@ -0,0 +1,17 @@ +namespace HellionChat.Ui.Components; + +// TEST-MIRROR: Ui/TimestampColumnTests.cs +// +// The timestamp sits in a column of fixed width so the sender names below each +// other actually line up. The mockup asks for tabular figures; ImGui exposes no +// font features, and whether the bundled face even has equal-width digits is +// unverified. So the width comes from measuring the widest shape the format can +// produce, once per layout change rather than per row. +// +// Eights, not zeroes: in most faces 8 is the widest digit. A column measured +// from 00:00 would be undercut by a 10:38 and shove the name column right on +// that one row -- exactly the misalignment this exists to remove. +internal static class TimestampColumn +{ + internal static string SampleFor(bool use24Hour) => use24Hour ? "88:88" : "88:88 PM"; +} From a841942b41a63046beb5bbba94ccf69943b552d5 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 11:44:30 +0200 Subject: [PATCH 16/29] fix(layout): the clock format and the per-tab timestamp switch move row heights Two more axes the height cache never knew about, and the second one is not even wired up yet -- it is about to be. Use24HourClock changes the stamp from 15:45 to 3:45 PM. The stamp sits at the head of the line with SameLine(0,0), so the wrap width every following word is measured against shrinks. Rows that wrap near that boundary get a different height, and nothing dropped the cache. DisplayTimestamp is per tab, which is why BuildLayoutFingerprint now takes the tab. It only ever read Plugin.Config before -- the per-tab part of this system is the gate, one per tab, comparing fingerprints that knew nothing about tabs. The switch is dead today and gets its reader in the next commit; the axis goes in first so the cache is right the moment it starts doing something. Both are toggles, so they sit in the discrete half and skip the settle window. --- HellionChat/Ui/Components/MessageList.cs | 6 ++++-- HellionChat/Util/LayoutFingerprint.cs | 15 +++++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/HellionChat/Ui/Components/MessageList.cs b/HellionChat/Ui/Components/MessageList.cs index 6024736..c0189da 100644 --- a/HellionChat/Ui/Components/MessageList.cs +++ b/HellionChat/Ui/Components/MessageList.cs @@ -99,7 +99,7 @@ internal sealed class MessageList // swaps between two differently sized faces), FontsEnabled and UseHellionFont // (both swap the face outright, and their two size fields default to the same // 12.75f -- so the fingerprint did not move while the glyph widths did). - private LayoutFingerprint BuildLayoutFingerprint(float contentWidth) + private LayoutFingerprint BuildLayoutFingerprint(Tab tab, float contentWidth) { var fonts = _fonts.EffectiveFontFingerprint(); return new LayoutFingerprint( @@ -112,6 +112,8 @@ internal sealed class MessageList Plugin.Config.FontsEnabled, Plugin.Config.UseHellionFont, Plugin.Config.ItalicEnabled, + Plugin.Config.Use24HourClock, + tab.DisplayTimestamp, (int)Plugin.Config.NameFormMode, (int)Plugin.Config.WorldSuffixMode, contentWidth, @@ -131,7 +133,7 @@ internal sealed class MessageList _fingerprintGates[tab.Identifier] = gate; } - if (!gate.ShouldInvalidate(BuildLayoutFingerprint(contentWidth), nowMs)) + if (!gate.ShouldInvalidate(BuildLayoutFingerprint(tab, contentWidth), nowMs)) return; using var messages = tab.Messages.GetReadOnly(3); diff --git a/HellionChat/Util/LayoutFingerprint.cs b/HellionChat/Util/LayoutFingerprint.cs index be2db40..f507d07 100644 --- a/HellionChat/Util/LayoutFingerprint.cs +++ b/HellionChat/Util/LayoutFingerprint.cs @@ -12,6 +12,8 @@ internal readonly record struct LayoutFingerprint( bool FontsEnabled, bool UseHellionFont, bool ItalicEnabled, + bool Use24Hour, + bool ShowTimestamp, int NameForm, int WorldSuffix, float Width, @@ -21,8 +23,17 @@ internal readonly record struct LayoutFingerprint( // Toggles: they land on a new value in one frame and stay there. Waiting on // them would leave the planner running against the previous density's // heights while the rows are already painted the new way. - internal (bool, bool, bool, bool, int, int) Discrete => - (Compact, FontsEnabled, UseHellionFont, ItalicEnabled, NameForm, WorldSuffix); + internal (bool, bool, bool, bool, bool, bool, int, int) Discrete => + ( + Compact, + FontsEnabled, + UseHellionFont, + ItalicEnabled, + Use24Hour, + ShowTimestamp, + NameForm, + WorldSuffix + ); } // Dragging a window edge or the Dalamud UI-scale slider moves the continuous From 611dd368cb7bb8bba18c4137afc023518669c8b4 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 11:46:01 +0200 Subject: [PATCH 17/29] feat(chat): give the timestamp its own column, and the show-timestamps box its effect back The stamp used to be text at the head of the line with two spaces after it, so every sender name started wherever the previous stamp happened to end. It sits in a fixed column now, measured once per draw from the widest shape the current format can produce, and the names line up. The column stays reserved when the stamp is hidden. Collapsing it would make a per-tab switch change every row height in that tab, and the height cache would have to carry wrap positions rather than just the format. tab.DisplayTimestamp has a reader again. It was in 1.5.6 at two call sites and lost both when cf4705e retired the old chat window; the tab editor has been writing a setting nobody read since. Same class of defect the last cycle spent itself on, found in passing here. The sender draws in the heavier face and the stamp in the smaller one, both dropped onto the body baseline -- ImGui aligns a row by its top edge, so without that the stamp would hang. All three faces follow the same FontsEnabled or UseHellionFont pair every other push site follows; with the game font selected there is no heavier or smaller variant and the row falls back to one face. Card density gets the two-line treatment only where there is a sender. A system message has none, so a header row would be a stamp alone on a line -- an empty gesture. Those stay single-line in both densities. --- HellionChat/Ui/Components/MessageList.cs | 122 +++++++++++++++++++---- 1 file changed, 104 insertions(+), 18 deletions(-) diff --git a/HellionChat/Ui/Components/MessageList.cs b/HellionChat/Ui/Components/MessageList.cs index c0189da..a29eb49 100644 --- a/HellionChat/Ui/Components/MessageList.cs +++ b/HellionChat/Ui/Components/MessageList.cs @@ -43,6 +43,12 @@ internal sealed class MessageList // config field that had stopped bounding anything. private float[] _heightScratch = []; + // Measured once per Draw rather than per row: it only moves when the clock + // format or the font does, and both of those are in the layout fingerprint. + private float _stampColumnWidth; + private bool _stampVisible; + private float _metaDrop; + // §6.2: setter-injection breaks the PayloadHandler → MainWindow → MessageList → PayloadHandler 3-cycle. // Wired by PayloadHandlerInitHostedService.StartAsync after both singletons exist. internal void AttachPayloadHandler(PayloadHandler handler) @@ -157,6 +163,8 @@ internal sealed class MessageList // and a runaway content-height computation. var compact = Plugin.Config.UseCompactDensity; + MeasureTimestampColumn(tab); + // B2: drop stale cached heights before the snapshot draw. Both densities // need this now -- compact rows are not constant height either, they wrap. // Width read here while it is valid. @@ -193,6 +201,72 @@ internal sealed class MessageList _handler?.Draw(); } + // The stamp column is fixed width so sender names line up under each other. + // It stays reserved even when the stamp is hidden -- otherwise a per-tab + // switch would change every row height in the tab, and the height cache would + // need to carry the wrap position rather than just the format. + private void MeasureTimestampColumn(Tab tab) + { + _stampVisible = tab.DisplayTimestamp; + + var meta = MetaFace(); + float sample; + using (meta.Push()) + sample = ImGui.CalcTextSize(TimestampColumn.SampleFor(Plugin.Config.Use24HourClock)).X; + + _stampColumnWidth = sample + ImGui.CalcTextSize(" ").X * 2f; + + // ImGui aligns a row by its top edge, so the smaller meta face would hang + // above the baseline of the body text beside it. + float bodyAscent; + using (BodyFace().Push()) + bodyAscent = ImGui.GetFont().Ascent; + + float metaAscent; + using (meta.Push()) + metaAscent = ImGui.GetFont().Ascent; + + _metaDrop = StyleEngine.BaselineMath.OffsetFor( + bodyAscent, + metaAscent, + StyleEngine.Metrics.Scale + ); + } + + // Both follow the same pair of settings every other push site follows. + private Dalamud.Interface.ManagedFontAtlas.IFontHandle BodyFace() => + Plugin.Config.FontsEnabled || Plugin.Config.UseHellionFont + ? _fonts.RegularFont! + : _fonts.Axis; + + private Dalamud.Interface.ManagedFontAtlas.IFontHandle MetaFace() => + Plugin.Config.FontsEnabled || Plugin.Config.UseHellionFont ? _fonts.MetaFont! : _fonts.Axis; + + // Same size as the body face, drawn heavier. With the game font selected + // there is no heavier variant, so the sender leans on channel colour alone. + private Dalamud.Interface.ManagedFontAtlas.IFontHandle SenderFace() => + Plugin.Config.FontsEnabled || Plugin.Config.UseHellionFont + ? _fonts.SenderFont! + : _fonts.Axis; + + // Draws the stamp into its column and leaves the cursor at the text column, + // whether or not anything was drawn. + private void DrawTimestampCell(Message message) + { + var origin = ImGui.GetCursorPos(); + + if (_stampVisible) + { + ImGui.SetCursorPosY(origin.Y + _metaDrop); + using (MetaFace().Push()) + ImGui.TextUnformatted(FormatTimestamp(message.Date)); + + ImGui.SameLine(0f, 0f); + } + + ImGui.SetCursorPos(origin with { X = origin.X + _stampColumnWidth }); + } + // B3-5: Discord-style full-width bar pinned to the bottom edge of the // visible region while the user is scrolled up. Geometry comes from window // pos + size (visible region), never from the content flow: when scrolled @@ -252,19 +326,20 @@ internal sealed class MessageList // channel brackets/colon as ChunkSource.None wrappers (MessageManager // .cs:300-314), so the separator is rendered by the chunks. 1.5.6 parity // (ChatLogWindow.cs:1965: DrawChunks(message.Sender) + SameLine). - var timestamp = FormatTimestamp(message.Date); + DrawTimestampCell(message); + if (message.Sender.Count > 0) { - ImGui.TextUnformatted($"{timestamp} "); - ImGui.SameLine(0f, 0f); - _chunkRenderer.DrawChunks(message.Sender, wrap: true, handler: _handler, lineWidth: 0f); - ImGui.SameLine(0f, 0f); - } - else - { - ImGui.TextUnformatted(timestamp); + using (SenderFace().Push()) + _chunkRenderer.DrawChunks( + message.Sender, + wrap: true, + handler: _handler, + lineWidth: 0f + ); ImGui.SameLine(0f, 0f); } + _chunkRenderer.DrawChunks(message.Content, wrap: true, handler: _handler, lineWidth: 0f); } @@ -383,18 +458,29 @@ internal sealed class MessageList // SameLine after the sender). The 1.5.6 channel-colour push on the // sender is deferred styling polish (masterplan §6 -> v1.9.0); plain // text here. - var timestamp = FormatTimestamp(message.Date); - if (message.Sender.Count > 0) + // A system message has no sender, so a header row would be a stamp on a + // line of its own -- an empty gesture. Those stay single-line in both + // densities; only a message with a sender gets the two-line treatment. + if (message.Sender.Count == 0) { - ImGui.TextUnformatted($"{timestamp} "); - ImGui.SameLine(0f, 0f); + DrawTimestampCell(message); + _chunkRenderer.DrawChunks( + message.Content, + wrap: true, + handler: _handler, + lineWidth: 0f + ); + return; + } + + DrawTimestampCell(message); + using (SenderFace().Push()) _chunkRenderer.DrawChunks(message.Sender, wrap: true, handler: _handler, lineWidth: 0f); - } - else - { - ImGui.TextUnformatted(timestamp); - } + + // Indented onto the text column so the body lines up under the name. + ImGui.Indent(_stampColumnWidth); _chunkRenderer.DrawChunks(message.Content, wrap: true, handler: _handler, lineWidth: 0f); + ImGui.Unindent(_stampColumnWidth); } private static string FormatTimestamp(DateTimeOffset date) From ba16ab59e3dfe82aacceefe605d6d4e6deadf98b Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 11:46:40 +0200 Subject: [PATCH 18/29] feat(style): a scope for drawing behind text that has not been measured yet Needed on exactly one frame: the one after the height cache is dropped, when no row knows its own height yet. Every other frame the cached height is already right -- a chat message does not change height after its first measurement -- and the caller paints the fill directly without coming near this. Modelled on LightlessSync's SettingsCardScope, which had already worked out the two things that make draw channels dangerous. Nesting a splitter into itself asserts, and Dalamud does not compile asserts out, so the user gets an error dialog rather than a glitch -- hence the depth count. And a forgotten merge is not a dropped frame but a permanent one: the commands stay in the channel buffers and never reach the draw list, then the next frame's split walks into the assert. Hence the finally, by way of the struct's Dispose. Fill switches to the background channel and switches straight back, so a caller cannot leave the channel hanging even by returning early. --- HellionChat/Ui/StyleEngine/RowSurfaceScope.cs | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 HellionChat/Ui/StyleEngine/RowSurfaceScope.cs diff --git a/HellionChat/Ui/StyleEngine/RowSurfaceScope.cs b/HellionChat/Ui/StyleEngine/RowSurfaceScope.cs new file mode 100644 index 0000000..825e792 --- /dev/null +++ b/HellionChat/Ui/StyleEngine/RowSurfaceScope.cs @@ -0,0 +1,75 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; + +namespace HellionChat.Ui.StyleEngine; + +// Draws a surface behind a row whose height is only known after the text has been +// drawn. Text goes into channel 1, the fill into channel 0, and the merge puts +// the fill underneath. +// +// Only needed on the frame after the height cache is dropped. Every other frame +// the cached height is already correct -- a chat message does not change height +// after its first measurement -- so the caller draws the fill directly and never +// comes near this. +// +// Three things make draw channels sharp: +// +// Nesting a splitter into itself asserts, and Dalamud does not compile asserts +// out. The user would get an error dialog, not a glitch. Hence the depth count. +// +// A forgotten merge is not a dropped frame, it is permanent: the commands stay +// in the channel buffers and never reach the draw list, and the next frame's +// split walks into the assert. Hence the finally. +// +// And the clip rect carries over on a channel switch, so the fill inherits +// whatever was clipping the text. +internal static class RowSurfaceScope +{ + [ThreadStatic] + private static int _depth; + + [ThreadStatic] + private static ImDrawListPtr _drawList; + + internal static bool IsActive => _depth > 0; + + internal static Scope Push() + { + if (_depth == 0) + { + _drawList = ImGui.GetWindowDrawList(); + _drawList.ChannelsSplit(2); + // Foreground is the resting state: everything that is not explicitly + // painting a surface draws where it expects to. + _drawList.ChannelsSetCurrent(1); + } + + _depth++; + return new Scope(); + } + + // Paints into the background channel and returns immediately to the + // foreground, so a caller can never leave the channel switched. + internal static void Fill(Vector2 min, Vector2 max, uint abgr, float rounding) + { + if (!IsActive) + return; + + _drawList.ChannelsSetCurrent(0); + _drawList.AddRectFilled(min, max, abgr, rounding); + _drawList.ChannelsSetCurrent(1); + } + + internal readonly struct Scope : IDisposable + { + public void Dispose() + { + _depth--; + if (_depth > 0) + return; + + _drawList.ChannelsMerge(); + _drawList = default; + } + } +} From 81e4c9367a3db374441d07702adeca1fac985fe5 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 11:47:46 +0200 Subject: [PATCH 19/29] feat(chat): give each row a surface to sit on A wash from the left at a tenth opacity, a two-pixel accent bar on the edge, both fading in and out on the held hover value rather than snapping. Two draw paths, because the height arrives at two different times. On a normal frame the cached height is already correct -- a chat message does not change height after its first measurement -- so the surface goes down before the text and costs nothing. On the frame after the cache is dropped no row knows its height yet, and that is the only time the draw-channel detour is needed. Without it the entire list would flash bare for one frame after every window resize, which is not rare: width and display scale are both in the fingerprint. The gradient and the rounding cannot be one call. AddRectFilledMultiColor writes four fixed vertices and takes no rounding parameter, so the rounded base goes down first with the gradient inside it. At two pixels the bar has no visible corners at all and needs neither. --- HellionChat/Ui/Components/MessageList.cs | 78 +++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/HellionChat/Ui/Components/MessageList.cs b/HellionChat/Ui/Components/MessageList.cs index a29eb49..368200a 100644 --- a/HellionChat/Ui/Components/MessageList.cs +++ b/HellionChat/Ui/Components/MessageList.cs @@ -414,6 +414,13 @@ internal sealed class MessageList { var msg = messages[i]; var before = ImGui.GetCursorPosY(); + + // The cached height is not an estimate here: a chat message does not + // change height after its first measurement, so last frame's value is + // this frame's value. That is what lets the surface go down before + // the text instead of needing a draw-channel detour. + DrawRowSurface(heights[i]); + drawRow(msg); if (frozen) continue; @@ -432,6 +439,63 @@ internal sealed class MessageList ); } + // Hover fill plus a 2px accent bar on the left edge. The gradient runs from + // the accent at a tenth opacity into nothing about seventy percent across, + // which is why it takes two rectangles: AddRectFilledMultiColor has no + // rounding parameter, so the rounded base goes down first and the gradient + // sits inside it. + private void DrawRowSurface(float height) + { + if (height <= 0f) + return; + + var top = ImGui.GetCursorScreenPos(); + PaintRowSurface(ImGui.GetWindowDrawList(), top, height, direct: true); + } + + private void FillRowSurface(Vector2 top, float height) + { + if (height <= 0f) + return; + + PaintRowSurface(ImGui.GetWindowDrawList(), top, height, direct: false); + } + + private void PaintRowSurface(ImDrawListPtr dl, Vector2 top, float height, bool direct) + { + var scale = StyleEngine.Metrics.Scale; + var width = ImGui.GetContentRegionAvail().X; + if (width <= 0f) + return; + + var min = top; + var max = top + new Vector2(width, height); + + var hovered = ImGui.IsWindowHovered() && ImGui.IsMouseHoveringRect(min, max); + var key = (uint)HashCode.Combine(top.Y, height); + var amount = StyleEngine.HoverState.Query(key, hovered); + if (amount <= 0.01f) + return; + + var theme = Plugin.Instance.ThemeRegistry.Active; + var accent = theme.Colors.Accent; + var rounding = 2f * scale; + + var wash = ColourUtil.ApplyAlpha(ColourUtil.RgbaToAbgr(accent), 0.07f * amount); + if (direct) + dl.AddRectFilled(min, max, wash, rounding); + else + StyleEngine.RowSurfaceScope.Fill(min, max, wash, rounding); + + // The bar is two pixels wide, so it has no visible corners to round. + var bar = ColourUtil.ApplyAlpha(ColourUtil.RgbaToAbgr(accent), amount); + var barMax = new Vector2(min.X + 2f * scale, max.Y); + if (direct) + dl.AddRectFilled(min, barMax, bar, 0f); + else + StyleEngine.RowSurfaceScope.Fill(min, barMax, bar, 0f); + } + // First-frame / post-invalidation fallback: draw + measure every row into the // cache so the next frame can take the planned path. The settle gate on the // layout fingerprint is what keeps a resize drag from landing here every frame. @@ -441,12 +505,24 @@ internal sealed class MessageList Action drawRow ) { + // No row has a cached height on this frame, so the surface cannot be + // drawn ahead of the text. Channels let it go down afterwards and still + // land underneath. Without this the whole list would flash bare for one + // frame after every resize. + using var surfaces = StyleEngine.RowSurfaceScope.Push(); + foreach (var msg in messages) { var before = ImGui.GetCursorPosY(); + var top = ImGui.GetCursorScreenPos(); + drawRow(msg); + var after = ImGui.GetCursorPosY(); - msg.Height[tabId] = after - before; + var height = after - before; + FillRowSurface(top, height); + + msg.Height[tabId] = height; msg.IsVisible[tabId] = ImGui.IsItemVisible(); } } From 63d1b34b00ab6bbeac803b306ee50f92523bfcf8 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 11:48:46 +0200 Subject: [PATCH 20/29] feat(chat): two densities that actually look different Card density puts the sender on its own line with the body indented onto the text column beneath it, and six pixels of air after each one. That air is what makes a card read as a card, and it goes through the measured row height so the clipper plans against it rather than around it. System messages go italic in both densities. Nobody said them -- it is the game talking -- and italics carry that in every palette. Colour would have been the obvious alternative and is the wrong tool twice over: the rule this cycle runs on says typography solves what typography can, and the channel colours already in those chunks come from the game and are not ours to dim. The italic face falls back to the game's own italic rather than to upright text when the custom one is switched off, so the distinction survives either setting. --- HellionChat/Ui/Components/MessageList.cs | 43 ++++++++++++++++++------ 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/HellionChat/Ui/Components/MessageList.cs b/HellionChat/Ui/Components/MessageList.cs index 368200a..184b290 100644 --- a/HellionChat/Ui/Components/MessageList.cs +++ b/HellionChat/Ui/Components/MessageList.cs @@ -244,6 +244,16 @@ internal sealed class MessageList // Same size as the body face, drawn heavier. With the game font selected // there is no heavier variant, so the sender leans on channel colour alone. + // From the mockup: the space between two messages in card density. + private const float CardGapRaw = 6f; + + // The italic handle is optional -- the setting can disable it -- so this + // falls back to the game's own italic rather than to upright text. + private Dalamud.Interface.ManagedFontAtlas.IFontHandle ItalicFace() => + Plugin.Config.FontsEnabled && _fonts.ItalicFont is not null + ? _fonts.ItalicFont + : _fonts.AxisItalic; + private Dalamud.Interface.ManagedFontAtlas.IFontHandle SenderFace() => Plugin.Config.FontsEnabled || Plugin.Config.UseHellionFont ? _fonts.SenderFont! @@ -328,18 +338,25 @@ internal sealed class MessageList // (ChatLogWindow.cs:1965: DrawChunks(message.Sender) + SameLine). DrawTimestampCell(message); - if (message.Sender.Count > 0) + if (message.Sender.Count == 0) { - using (SenderFace().Push()) + // Nobody said this -- it is the game talking. Italics carry that in + // every palette, which colour would not: the channel colours already + // in these chunks come from the game and are not ours to override. + using (ItalicFace().Push()) _chunkRenderer.DrawChunks( - message.Sender, + message.Content, wrap: true, handler: _handler, lineWidth: 0f ); - ImGui.SameLine(0f, 0f); + return; } + using (SenderFace().Push()) + _chunkRenderer.DrawChunks(message.Sender, wrap: true, handler: _handler, lineWidth: 0f); + + ImGui.SameLine(0f, 0f); _chunkRenderer.DrawChunks(message.Content, wrap: true, handler: _handler, lineWidth: 0f); } @@ -540,12 +557,14 @@ internal sealed class MessageList if (message.Sender.Count == 0) { DrawTimestampCell(message); - _chunkRenderer.DrawChunks( - message.Content, - wrap: true, - handler: _handler, - lineWidth: 0f - ); + using (ItalicFace().Push()) + _chunkRenderer.DrawChunks( + message.Content, + wrap: true, + handler: _handler, + lineWidth: 0f + ); + ImGui.Dummy(new Vector2(0f, CardGapRaw * StyleEngine.Metrics.Scale)); return; } @@ -557,6 +576,10 @@ internal sealed class MessageList ImGui.Indent(_stampColumnWidth); _chunkRenderer.DrawChunks(message.Content, wrap: true, handler: _handler, lineWidth: 0f); ImGui.Unindent(_stampColumnWidth); + + // Air between cards is what makes them read as cards. Measured into the + // row height, so the clipper plans against it. + ImGui.Dummy(new Vector2(0f, CardGapRaw * StyleEngine.Metrics.Scale)); } private static string FormatTimestamp(DateTimeOffset date) From 0399b68d8c84fd844e250505a7f8508a57f4ac48 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 11:50:19 +0200 Subject: [PATCH 21/29] fix(settings): let the preview show what the log shows The preview was four flat lines of text on a plain field. After this cycle the real log has a channel header above it, a fixed timestamp column, and system messages in italics -- so the preview had quietly become a picture of a window that no longer exists. That is the same defect as a widget with no call site, just pointing the other way: something on screen that stopped tracking what it describes. The reserved band grew with it. It is a fixed height that the sidebar mock also divides by, so adding a row inside without raising it would have pushed the last message out of the space -- the recurring drawing-into-unreserved-space mistake this project keeps stepping on. Preview stamps are fixed rather than live. A clock ticking inside a settings panel pulls the eye away from the setting being changed. --- .../Components/Settings/LivePreviewPanel.cs | 70 +++++++++++++++++-- 1 file changed, 66 insertions(+), 4 deletions(-) diff --git a/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs b/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs index 815db7f..1084f39 100644 --- a/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs +++ b/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs @@ -26,15 +26,25 @@ internal sealed class LivePreviewPanel : IDisposable internal static int InstanceCount; // Plan-mandated mock strings — international tester-ready, do not localise. - private const string MockSystem = "System: Connection established"; + private const string MockSystem = "Connection established"; private const string MockSay = "Say: Hello, world!"; private const string MockTell = "Tell → Player: Hey, want to party?"; private const string MockFc = "FC: Welcome aboard."; + // The stamps the preview shows against its own rows. Fixed rather than live: + // a clock ticking inside a settings preview draws the eye away from the + // setting being changed. + private static readonly string[] MockStamps = ["22:10", "22:14", "22:15", "22:17"]; + + private const string MockChannel = "GENERAL"; + // Crown/cog render via the FontAwesome font (FontManager) so the preview // matches the real header glyphs; the bundled text font has no crown glyph. - private const float MiddleBandHeight = 220f; + // Grew with the channel header: the band is reserved as a fixed height and + // the sidebar mock divides by it, so a row added inside without raising this + // would have pushed the last message out of the reserved space. + private const float MiddleBandHeight = 248f; private const float SidebarWidth = 70f; private readonly ThemeRegistry _themes; @@ -259,6 +269,29 @@ internal sealed class LivePreviewPanel : IDisposable draw.AddRectFilled(listOrigin, max, ColourUtil.RgbaToAbgr(theme.Colors.WindowBg)); + // The channel header the real window now carries above its log. Tracked + // caps, same as the widget draws them. + var headerHeight = ImGui.GetTextLineHeight() + 8f; + draw.AddRectFilled( + listOrigin, + new Vector2(max.X, listOrigin.Y + headerHeight), + ColourUtil.RgbaToAbgr(theme.Colors.Surface) + ); + draw.AddLine( + new Vector2(listOrigin.X, listOrigin.Y + headerHeight), + new Vector2(max.X, listOrigin.Y + headerHeight), + ColourUtil.RgbaToAbgr(theme.Colors.Border), + 1f + ); + draw.DrawTrackedText( + new Vector2(listOrigin.X + 6f, listOrigin.Y + 4f), + MockChannel, + ColourUtil.RgbaToAbgr( + ColourUtil.EnsureContrast(theme.Colors.Accent, theme.Colors.Surface, 4.5f) + ), + 1.8f + ); + var padMin = new Vector2(listOrigin.X + 2f, max.Y - 6f); draw.AddRectFilled( padMin, @@ -274,11 +307,40 @@ internal sealed class LivePreviewPanel : IDisposable (MockFc, theme.Colors.StatusSuccess), ]; + // A fixed stamp column, like the log has, so the senders line up in the + // preview the same way they line up for real. + var stampWidth = ImGui.CalcTextSize(TimestampColumn.SampleFor(true)).X + 8f; var lineHeight = ImGui.GetTextLineHeightWithSpacing(); + var textTop = listOrigin.Y + headerHeight + 4f; + + var fonts = Plugin.Instance.FontManager; + var italic = + Plugin.Config.FontsEnabled && fonts.ItalicFont is not null + ? fonts.ItalicFont + : fonts.AxisItalic; + for (var i = 0; i < rows.Length; i++) { - var pos = new Vector2(listOrigin.X + 6f, listOrigin.Y + 6f + i * lineHeight); - draw.AddText(pos, ColourUtil.RgbaToAbgr(rows[i].Rgba), rows[i].Text); + var y = textTop + i * lineHeight; + draw.AddText( + new Vector2(listOrigin.X + 6f, y), + ColourUtil.RgbaToAbgr(theme.Colors.TextDim), + MockStamps[i] + ); + + var textPos = new Vector2(listOrigin.X + 6f + stampWidth, y); + var colour = ColourUtil.RgbaToAbgr(rows[i].Rgba); + + // Row zero is the system line, and the log draws those in italics. + if (i == 0) + { + using (italic.Push()) + draw.AddText(textPos, colour, rows[i].Text); + } + else + { + draw.AddText(textPos, colour, rows[i].Text); + } } draw.AddLine( From 8fea9113b94ce0e1460c2ea8c2f56f60bcdd1c66 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 12:00:16 +0200 Subject: [PATCH 22/29] fix(privacy): the screenshot guard was reading a field that gets wiped on purpose This morning's fix hung on TellTarget, and TellTarget is routing state that the codebase clears deliberately. StripTellBindingOnPromote sets IsTempTab false, empties TellTarget, and keeps the name -- so a promoted tell tab is called "Player@World" permanently while carrying neither marker, and falls through both possible checks. That state survives restarts. A pinned tab whose binding did not survive a save is the same hole with a different cause; the auto-tell service logs that case as expected and repairs around it. The flag is set where the name is built from a partner and is not cleared by promotion. Renaming clears it, because at that point the user typed it. Config v26 carries it backwards for tabs that already exist: anything still holding a tell binding or the temp flag got its name from a partner. Tabs promoted before this version cannot be recovered -- nothing in the stored data says where their name came from -- and renaming one has the same effect anyway. Two more things the header was giving away. Its icon for an auto-tell tab is derived from the partner and stable across sessions, which is three bits of linkable information on a picture meant to be shareable; the message path re-salts its name hashes on every load precisely to avoid that, so screenshot mode now falls back to a plain envelope. And a world name that is not ASCII -- the CN and KR clients have those, and we ship translations for both -- was being drawn in the meta face, which carries ASCII and a middle dot. It would have come out as question marks, the same defect the split was built to prevent. Plus two that are not privacy: the header had no FontsReady gate, alone among the drawing components, so its band height and baseline offset were wrong in exactly the frames this cycle made more common. And a long tab name ran past the band and got cut mid-glyph at the window edge; it fits now, the way the honorific header already did it. --- HellionChat/AutoTellTabsService.cs | 1 + HellionChat/Configuration.cs | 9 +++- HellionChat/Plugin.cs | 44 +++++++++++++--- ...onV25Step.cs => ConfigMigrationV26Step.cs} | 10 ++-- HellionChat/Ui/Components/MessageList.cs | 28 +++++++--- .../Ui/Components/Settings/TabEditor.cs | 2 + .../Ui/StyleEngine/Widgets/ChannelHeader.cs | 51 ++++++++++++++++--- .../Widgets/ChannelHeaderDetail.cs | 24 ++++++++- 8 files changed, 138 insertions(+), 31 deletions(-) rename HellionChat/SelfTests/{ConfigMigrationV25Step.cs => ConfigMigrationV26Step.cs} (92%) diff --git a/HellionChat/AutoTellTabsService.cs b/HellionChat/AutoTellTabsService.cs index 6ea5682..66f4838 100644 --- a/HellionChat/AutoTellTabsService.cs +++ b/HellionChat/AutoTellTabsService.cs @@ -394,6 +394,7 @@ internal sealed class AutoTellTabsService : IDisposable return new Tab { Name = FormatTabName(playerName, worldRowId), + NameCameFromPartner = true, IsTempTab = true, AllSenderMessages = true, TellTarget = new TellTarget(playerName, worldRowId, 0, TellReason.Direct), diff --git a/HellionChat/Configuration.cs b/HellionChat/Configuration.cs index 731355f..c46c46d 100755 --- a/HellionChat/Configuration.cs +++ b/HellionChat/Configuration.cs @@ -35,7 +35,7 @@ public class ConfigKeyBind [Serializable] public class Configuration : IPluginConfiguration { - internal const int LatestVersion = 25; + internal const int LatestVersion = 26; public int Version { get; set; } = LatestVersion; @@ -409,6 +409,13 @@ public class Tab public bool AllSenderMessages; public TellTarget TellTarget = TellTarget.Empty(); + // Set once, where the name is built from a conversation partner. Never + // cleared by promotion, unlike IsTempTab and TellTarget -- both of those are + // routing state and are deliberately wiped when a tab is promoted, while the + // name they produced stays. Screenshot mode reads this, so it has to outlive + // every path that keeps the name but drops the binding. + public bool NameCameFromPartner; + // Per-tab notification sound for messages arriving in an inactive tab. public bool EnableNotificationSound; public uint NotificationSoundId = 1; diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 37883d4..1e01812 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -322,13 +322,41 @@ public sealed class Plugin : IAsyncDalamudPlugin ); } - // v25 carries no migration step. The schema gate above only refuses - // anything under 16, and Json.NET drops keys it does not recognise on - // load, so the fields v1.12.0 deleted simply stop being written on the - // next save. The bump is documentation, and it has to be consistent: - // the constant and this stamp are two separate places, and changing - // only one gives a config that re-stamps itself on every start. - Config.Version = 25; + // v25 carried no migration step; the bump was documentation. + // + // v26 does. NameCameFromPartner is what screenshot mode reads to decide + // whether a tab name is a person, and a config written before it existed + // has it false on every tab -- including pinned tell tabs, which survive + // reloads and are named "Player@World". Anything still carrying a tell + // binding or the temp flag got its name from a partner, so the flag is + // set from those two. + // + // Tabs promoted before this version are past saving: promotion clears + // both markers and keeps the name, so nothing in the stored data says + // where that name came from. Renaming one clears the flag anyway, which + // is the same outcome the user gets by editing it. + if (Config.Version < 26) + { + var carried = 0; + foreach (var tab in Config.Tabs) + { + if (tab.NameCameFromPartner || (!tab.IsTempTab && tab.TellTarget?.IsSet() != true)) + continue; + + tab.NameCameFromPartner = true; + carried++; + } + + if (carried > 0) + { + Log.Information( + $"Marked {carried} tab(s) as partner-named during the v26 migration, so " + + "screenshot mode hides them in the channel header." + ); + } + } + + Config.Version = 26; // Unpinned TempTabs are session-only and dropped on every load. Pinned // TempTabs survive reload — Jin's tester feedback (v1.4.7). @@ -491,7 +519,7 @@ public sealed class Plugin : IAsyncDalamudPlugin new SelfTests.SettingsWindowOpenStep(this), new SelfTests.OnOpenMainUiRoutesMainWindowStep(this), new SelfTests.TypingIpcStateStep(this), - new SelfTests.ConfigMigrationV25Step(this), + new SelfTests.ConfigMigrationV26Step(this), new SelfTests.DbGateWiringStep(this), new SelfTests.ChannelPopoutBindStep(this), new SelfTests.HoverStateFootprintStep(), diff --git a/HellionChat/SelfTests/ConfigMigrationV25Step.cs b/HellionChat/SelfTests/ConfigMigrationV26Step.cs similarity index 92% rename from HellionChat/SelfTests/ConfigMigrationV25Step.cs rename to HellionChat/SelfTests/ConfigMigrationV26Step.cs index 3219a13..8ac27f0 100644 --- a/HellionChat/SelfTests/ConfigMigrationV25Step.cs +++ b/HellionChat/SelfTests/ConfigMigrationV26Step.cs @@ -8,20 +8,20 @@ namespace HellionChat.SelfTests; // below must carry valid values here. This probe never rewrites config; the // migrations themselves are load-time and verified by the prepared-config smoke // in the plan. -internal sealed class ConfigMigrationV25Step : ISelfTestStep +internal sealed class ConfigMigrationV26Step : ISelfTestStep { - public ConfigMigrationV25Step(Plugin plugin) + public ConfigMigrationV26Step(Plugin plugin) { _ = plugin; } - public string Name => "Hellion Chat - Config v25 migration"; + public string Name => "Hellion Chat - Config v26 migration"; public SelfTestStepResult RunStep() { - if (Plugin.Config.Version != 25) + if (Plugin.Config.Version != 26) { - ImGui.Text($"Config.Version is {Plugin.Config.Version}, expected 25"); + ImGui.Text($"Config.Version is {Plugin.Config.Version}, expected 26"); return SelfTestStepResult.Fail; } diff --git a/HellionChat/Ui/Components/MessageList.cs b/HellionChat/Ui/Components/MessageList.cs index 184b290..8756ab6 100644 --- a/HellionChat/Ui/Components/MessageList.cs +++ b/HellionChat/Ui/Components/MessageList.cs @@ -436,7 +436,7 @@ internal sealed class MessageList // change height after its first measurement, so last frame's value is // this frame's value. That is what lets the surface go down before // the text instead of needing a draw-channel detour. - DrawRowSurface(heights[i]); + DrawRowSurface(msg, heights[i]); drawRow(msg); if (frozen) @@ -461,24 +461,30 @@ internal sealed class MessageList // which is why it takes two rectangles: AddRectFilledMultiColor has no // rounding parameter, so the rounded base goes down first and the gradient // sits inside it. - private void DrawRowSurface(float height) + private void DrawRowSurface(Message message, float height) { if (height <= 0f) return; var top = ImGui.GetCursorScreenPos(); - PaintRowSurface(ImGui.GetWindowDrawList(), top, height, direct: true); + PaintRowSurface(ImGui.GetWindowDrawList(), message, top, height, direct: true); } - private void FillRowSurface(Vector2 top, float height) + private void FillRowSurface(Message message, Vector2 top, float height) { if (height <= 0f) return; - PaintRowSurface(ImGui.GetWindowDrawList(), top, height, direct: false); + PaintRowSurface(ImGui.GetWindowDrawList(), message, top, height, direct: false); } - private void PaintRowSurface(ImDrawListPtr dl, Vector2 top, float height, bool direct) + private void PaintRowSurface( + ImDrawListPtr dl, + Message message, + Vector2 top, + float height, + bool direct + ) { var scale = StyleEngine.Metrics.Scale; var width = ImGui.GetContentRegionAvail().X; @@ -489,7 +495,13 @@ internal sealed class MessageList var max = top + new Vector2(width, height); var hovered = ImGui.IsWindowHovered() && ImGui.IsMouseHoveringRect(min, max); - var key = (uint)HashCode.Combine(top.Y, height); + + // Keyed on the message, not on where it happens to sit. A screen + // coordinate changes every frame while scrolling, so each row would get a + // fresh entry starting at zero and the highlight would never fade in at + // all. It would also let a row in the main window and one in a pop-out + // share an entry whenever their y and height matched. + var key = (uint)message.Id.GetHashCode(); var amount = StyleEngine.HoverState.Query(key, hovered); if (amount <= 0.01f) return; @@ -537,7 +549,7 @@ internal sealed class MessageList var after = ImGui.GetCursorPosY(); var height = after - before; - FillRowSurface(top, height); + FillRowSurface(msg, top, height); msg.Height[tabId] = height; msg.IsVisible[tabId] = ImGui.IsItemVisible(); diff --git a/HellionChat/Ui/Components/Settings/TabEditor.cs b/HellionChat/Ui/Components/Settings/TabEditor.cs index 524d297..023c96c 100644 --- a/HellionChat/Ui/Components/Settings/TabEditor.cs +++ b/HellionChat/Ui/Components/Settings/TabEditor.cs @@ -132,6 +132,8 @@ internal sealed class TabEditor if (ImGui.InputText(Language.Options_Tabs_Name, ref name, 512)) { tab.Name = name; + // The user typed this one, so it is no longer a partner's name. + tab.NameCameFromPartner = false; MarkDirty(); } diff --git a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs index 9a82176..5901d7c 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs @@ -54,6 +54,14 @@ internal static class ChannelHeader float reservedBelow ) { + // Every other drawing component gates on this. Without it the band is + // measured against whatever face happens to be active, and a handle that + // is not ready pushes nothing at all -- silently -- so the height, the + // drop-out rule and the baseline offset would all be wrong for those + // frames. This cycle made rebuilds more frequent, not less. + if (!fonts.FontsReady) + return; + var scale = Metrics.Scale; var width = ImGui.GetContentRegionAvail().X; var height = Height; @@ -65,14 +73,28 @@ internal static class ChannelHeader var body = BodyFace(fonts); var meta = MetaFace(fonts); - var icon = Components.Sidebar.ResolveTabIcon(tab); var track = TrackRaw * scale; var detailTrack = DetailTrackRaw * scale; - // Screenshot mode hides the name of a tell tab, because that name IS the - // conversation partner: AutoTellTabsService builds it as "Player@World". - // Drawing it in tracked caps above a log whose messages are anonymised - // would give away in the header exactly what the log is hiding. - var namesAPartner = tab.TellTarget?.IsSet() == true; + // Screenshot mode hides a name that came from a conversation partner: + // AutoTellTabsService builds those as "Player@World", and drawing one in + // tracked caps above a log whose messages are anonymised would give away + // in the header exactly what the log is hiding. + // + // Reading TellTarget or IsTempTab here would miss the case that matters + // most. StripTellBindingOnPromote clears both and keeps the name, so a + // promoted tell tab is called "Player@World" for good while carrying + // neither marker. The flag is set where the name is built and survives + // that. + var namesAPartner = tab.NameCameFromPartner; + + // The tab icon for an auto-tell tab is derived from the partner and is + // stable across sessions, so it is three bits of linkable information on + // a picture meant to be shareable. The message path guards against + // exactly that by re-salting its name hashes on every plugin load. + var icon = + Plugin.Config.ScreenshotMode && namesAPartner + ? Dalamud.Interface.FontAwesomeIcon.Envelope + : Components.Sidebar.ResolveTabIcon(tab); var showName = mode is ChannelHeaderMode.Full && !(Plugin.Config.ScreenshotMode && namesAPartner); @@ -83,12 +105,26 @@ internal static class ChannelHeader using (fonts.FontAwesome.Push()) iconSize = ImGui.CalcTextSize(icon.ToIconString()); + var inset = InsetRaw * scale; + var iconRun = iconSize.X + IconGapRaw * scale; + var nameRun = 0f; if (showName) { + // 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()) + { + if (DrawListExtensions.MeasureTrackedText(name, track) > room) + name = StringUtil.TruncateToFitWidth(name, room); + nameRun = DrawListExtensions.MeasureTrackedText(name, track); - nameRun += iconSize.X + IconGapRaw * scale; + } + + nameRun += iconRun; } // Measured under the face that will draw it, which is the whole point of @@ -110,7 +146,6 @@ internal static class ChannelHeader restRun = DrawListExtensions.MeasureTrackedText(rest, detailTrack); var detailRun = whereRun + restRun; - var inset = InsetRaw * scale; var plan = ChannelHeaderLayout.Plan( showName ? ChannelHeaderMode.Full : ChannelHeaderMode.DetailOnly, diff --git a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderDetail.cs b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderDetail.cs index d9b41d0..50b6e0a 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderDetail.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderDetail.cs @@ -11,6 +11,12 @@ namespace HellionChat.Ui.StyleEngine.Widgets; // 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 @@ -46,6 +52,22 @@ internal static class ChannelHeaderDetail return new ChannelHeaderDetailParts(string.Empty, clock, false); var missing = string.IsNullOrWhiteSpace(world); - return new ChannelHeaderDetailParts(missing ? fallback : world!, clock, missing); + 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; } } From 1604186aa1905f93e844936aef9812fc3f6e50a8 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 12:01:31 +0200 Subject: [PATCH 23/29] test(style): pin the type scale table, and mark three mirrors The arithmetic had tests from the first commit; the table in front of it did not. Factors is indexed by the enum, so a role inserted in the middle shifts every factor below it -- and each one still resolves to a plausible size, which is exactly why nothing would have failed. Three files were missing their TEST-MIRROR marker despite having mirrors. The marker is how the drift check finds them. --- HellionChat/Ui/StyleEngine/TypeScale.cs | 2 ++ HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderDetail.cs | 2 ++ HellionChat/Util/LayoutFingerprint.cs | 2 ++ 3 files changed, 6 insertions(+) diff --git a/HellionChat/Ui/StyleEngine/TypeScale.cs b/HellionChat/Ui/StyleEngine/TypeScale.cs index fc504e6..5316b12 100644 --- a/HellionChat/Ui/StyleEngine/TypeScale.cs +++ b/HellionChat/Ui/StyleEngine/TypeScale.cs @@ -7,6 +7,8 @@ internal enum TypeRole Meta, } +// TEST-MIRROR: Ui/TypeScaleTests.cs +// // Named sizes derived from one base, so a role means the same thing wherever it // is drawn. // diff --git a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderDetail.cs b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderDetail.cs index 50b6e0a..8932830 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderDetail.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderDetail.cs @@ -2,6 +2,8 @@ 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. // // They are split because they cannot share a face. The clock and a world name are diff --git a/HellionChat/Util/LayoutFingerprint.cs b/HellionChat/Util/LayoutFingerprint.cs index f507d07..160f22c 100644 --- a/HellionChat/Util/LayoutFingerprint.cs +++ b/HellionChat/Util/LayoutFingerprint.cs @@ -1,5 +1,7 @@ namespace HellionChat.Util; +// TEST-MIRROR: Util/LayoutFingerprintGateTests.cs +// // Layout inputs that make a tab's cached row heights stale. Kept as a plain // value type so the build suite can pin the gate without an ImGui frame. internal readonly record struct LayoutFingerprint( From b63e1eda9b651376c91bee8135c7870157bad6e3 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 12:04:55 +0200 Subject: [PATCH 24/29] fix(privacy): one rule for tab names, applied to all four surfaces The header was the only place that knew a tab name can be a person. The sidebar, the tab strip and a pop-out's window title drew the same "Player@World" string untouched, so a screenshot of the default view still named the partner while the messages underneath were anonymised. Guarding one surface out of four guards nobody. The rule sits in one place now and all four read it. Names are replaced rather than blanked: a nameless tab in a strip of tabs is worse to use than a placeholder, and the sidebar has no room to explain itself. The salt is drawn fresh on every plugin load, the same reasoning the message path uses -- a stable label would let two screenshots taken weeks apart be tied together. The tab strip resolves once and both measures and draws that value. Measuring one string and drawing another would have sized every tab wrong the moment the mode came on, which is the kind of thing that looks like a layout bug and gets fixed in the wrong place. --- HellionChat/Ui/Components/Sidebar.cs | 10 ++++- HellionChat/Ui/Components/TopTabBar.cs | 12 +++++- .../Ui/StyleEngine/Widgets/ChannelHeader.cs | 20 +++++---- HellionChat/Ui/Windows/ChannelPopoutWindow.cs | 9 +++- HellionChat/Util/TabDisplayName.cs | 41 +++++++++++++++++++ 5 files changed, 79 insertions(+), 13 deletions(-) create mode 100644 HellionChat/Util/TabDisplayName.cs diff --git a/HellionChat/Ui/Components/Sidebar.cs b/HellionChat/Ui/Components/Sidebar.cs index 1a2c890..64bc5cc 100644 --- a/HellionChat/Ui/Components/Sidebar.cs +++ b/HellionChat/Ui/Components/Sidebar.cs @@ -394,7 +394,15 @@ internal sealed class Sidebar ImGuiUtil.Tooltip(HellionStrings.PinTab_PinnedTooltip); if (expanded) - dl.AddText(origin + new Vector2(iconRight + 6f * scale, contentY), textAbgr, tab.Name); + dl.AddText( + origin + new Vector2(iconRight + 6f * scale, contentY), + textAbgr, + TabDisplayName.Resolve( + tab.Name, + tab.NameCameFromPartner, + Plugin.Config.ScreenshotMode + ) + ); // Unread count. Drawn outside the icon-font scope on purpose: the // FontAwesome atlas carries no ASCII digits, so the number would come out diff --git a/HellionChat/Ui/Components/TopTabBar.cs b/HellionChat/Ui/Components/TopTabBar.cs index 2411aa8..afa7100 100644 --- a/HellionChat/Ui/Components/TopTabBar.cs +++ b/HellionChat/Ui/Components/TopTabBar.cs @@ -71,11 +71,19 @@ internal sealed class TopTabBar !ReferenceEquals(tab, activeTab) && tab.UnreadMode != UnreadMode.None && tab.Unread > 0; + // Resolved once: measuring one string and drawing another would size + // every tab wrong the moment screenshot mode is on. + var label = Util.TabDisplayName.Resolve( + tab.Name, + tab.NameCameFromPartner, + Plugin.Config.ScreenshotMode + ); + var unread = showUnread ? (int)Math.Min(tab.Unread, int.MaxValue) : 0; var badgeSize = showUnread ? Badge.CalcSize(unread, TabBadge) : Vector2.Zero; var width = - ImGui.CalcTextSize(tab.Name).X + ImGui.CalcTextSize(label).X + padX * 2f + (showUnread ? badgeSize.X + Metrics.TopTabUnreadInset : 0f); var size = WidgetGeometry.IconButton(width, height); @@ -102,7 +110,7 @@ internal sealed class TopTabBar dl, origin, size, - tab.Name, + label, selected, hoverAmount, surfaceActive, diff --git a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs index 5901d7c..9bb8a1b 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs @@ -75,17 +75,20 @@ internal static class ChannelHeader var track = TrackRaw * scale; var detailTrack = DetailTrackRaw * scale; - // Screenshot mode hides a name that came from a conversation partner: + // Screenshot mode replaces a name that came from a conversation partner: // AutoTellTabsService builds those as "Player@World", and drawing one in // tracked caps above a log whose messages are anonymised would give away // in the header exactly what the log is hiding. // - // Reading TellTarget or IsTempTab here would miss the case that matters - // most. StripTellBindingOnPromote clears both and keeps the name, so a - // promoted tell tab is called "Player@World" for good while carrying - // neither marker. The flag is set where the name is built and survives - // that. + // Same helper the sidebar, the tab strip and the pop-out title use, so + // one conversation shows the same placeholder everywhere rather than + // vanishing on one surface and staying put on three. var namesAPartner = tab.NameCameFromPartner; + var shownName = TabDisplayName.Resolve( + tab.Name, + namesAPartner, + Plugin.Config.ScreenshotMode + ); // The tab icon for an auto-tell tab is derived from the partner and is // stable across sessions, so it is three bits of linkable information on @@ -95,11 +98,10 @@ internal static class ChannelHeader Plugin.Config.ScreenshotMode && namesAPartner ? Dalamud.Interface.FontAwesomeIcon.Envelope : Components.Sidebar.ResolveTabIcon(tab); - var showName = - mode is ChannelHeaderMode.Full && !(Plugin.Config.ScreenshotMode && namesAPartner); + var showName = mode is ChannelHeaderMode.Full; // ToUpperInvariant allocates, so only where the name is actually drawn. - var name = showName ? tab.Name.ToUpperInvariant() : string.Empty; + var name = showName ? shownName.ToUpperInvariant() : string.Empty; Vector2 iconSize; using (fonts.FontAwesome.Push()) diff --git a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs index 5c01d54..834f7cd 100644 --- a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs +++ b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs @@ -84,7 +84,14 @@ internal sealed class ChannelPopoutWindow : Window, IFocusableChatWindow // Visible label tracks the bound tab; the ###id stays slot-stable so // ImGui keeps this slot's position/size across binds. - WindowName = $"{tab.Name}###hellion_popout_{_slotIndex}"; + // The title bar is a surface too, and the header deliberately stays + // silent in this mode because the title already carries the name. + var label = Util.TabDisplayName.Resolve( + tab.Name, + tab.NameCameFromPartner, + Plugin.Config.ScreenshotMode + ); + WindowName = $"{label}###hellion_popout_{_slotIndex}"; IsOpen = true; } diff --git a/HellionChat/Util/TabDisplayName.cs b/HellionChat/Util/TabDisplayName.cs new file mode 100644 index 0000000..963471a --- /dev/null +++ b/HellionChat/Util/TabDisplayName.cs @@ -0,0 +1,41 @@ +namespace HellionChat.Util; + +// TEST-MIRROR: Util/TabDisplayNameTests.cs +// +// One place that knows whether a tab name may be shown. Four surfaces draw it -- +// the sidebar, the top-tab strip, a pop-out's window title and the channel header +// -- and before this each of them decided on its own, which meant three of them +// decided nothing at all. +// +// The name matters because an auto-tell tab is called "Player@World". In +// screenshot mode the message list anonymises every sender, so a tab name left +// alone puts the conversation partner back on screen in the one place a reader +// looks first. +// +// Replaced rather than blanked: a nameless tab in a strip of tabs is worse to use +// than a placeholder, and the sidebar has no room to explain itself. The salt is +// the same idea the message path uses -- fresh per plugin load, so two +// screenshots taken weeks apart cannot be tied together by a stable label. +internal static class TabDisplayName +{ + private static readonly string Salt = new Random().Next().ToString(); + + internal static string Resolve(string name, bool cameFromPartner, bool screenshotMode) => + Resolve(name, cameFromPartner, screenshotMode, Salt); + + // Salt as a parameter so the rule can be tested without depending on a value + // that is random by design. + internal static string Resolve( + string name, + bool cameFromPartner, + bool screenshotMode, + string salt + ) + { + if (!screenshotMode || !cameFromPartner) + return name; + + var hash = $"{salt}{name}".GetHashCode(); + return $"Player {hash:X8}"; + } +} From 80ec7450c81277e2624c59f93844dde3f184dbcd Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 12:07:39 +0200 Subject: [PATCH 25/29] feat(chat): stop repeating the same minute on every line The comparison value comes from the message data, not from a variable carried between rows. In 1.5.6 the loop walked every message and skipped invisible ones with a dummy, so what it remembered was the last *visible* stamp. The virtualised list only iterates the visible window, so the row above that window was never drawn at all -- a carried variable would hold whatever was on screen before the last scroll, and the first stamp after every jump would be wrong. No predecessor means draw. Scrolling into the middle of a log would otherwise swallow the only stamp on screen. Both draw paths now pass an index; the linear one was a foreach and had none. The setting is on by default and existed with translations in twenty-three languages -- Catalan and Italian had kept the English string, so those two are done now. It needs no fingerprint entry: the column stays reserved when the stamp is suppressed, so hiding one changes no row's height. --- HellionChat/Resources/Language.ca.resx | 4 +- HellionChat/Resources/Language.it.resx | 4 +- HellionChat/Ui/Components/MessageList.cs | 38 +++++++++++-------- .../Ui/Components/RepeatedTimestamp.cs | 20 ++++++++++ .../Ui/Components/Settings/Tabs/ChatTab.cs | 7 ++++ 5 files changed, 54 insertions(+), 19 deletions(-) create mode 100644 HellionChat/Ui/Components/RepeatedTimestamp.cs diff --git a/HellionChat/Resources/Language.ca.resx b/HellionChat/Resources/Language.ca.resx index 2eee568..7d47e2f 100644 --- a/HellionChat/Resources/Language.ca.resx +++ b/HellionChat/Resources/Language.ca.resx @@ -527,10 +527,10 @@ Finestra emergent - Hide timestamps when redundant + Amaga les marques de temps redundants - Hide timestamps when previous messages have the same timestamp. + Amaga la marca de temps quan el missatge anterior ja en té la mateixa. Show title bar for popped-out tabs diff --git a/HellionChat/Resources/Language.it.resx b/HellionChat/Resources/Language.it.resx index cfff142..5bae064 100644 --- a/HellionChat/Resources/Language.it.resx +++ b/HellionChat/Resources/Language.it.resx @@ -527,10 +527,10 @@ Pop out - Hide timestamps when redundant + Nascondi gli orari ridondanti - Hide timestamps when previous messages have the same timestamp. + Nasconde l'orario quando il messaggio precedente ha già lo stesso. Show title bar for popped-out tabs diff --git a/HellionChat/Ui/Components/MessageList.cs b/HellionChat/Ui/Components/MessageList.cs index 8756ab6..f11d73e 100644 --- a/HellionChat/Ui/Components/MessageList.cs +++ b/HellionChat/Ui/Components/MessageList.cs @@ -34,8 +34,8 @@ internal sealed class MessageList // Bound once. A method group off an instance method captures `this` and is // not cached by Roslyn, so `compact ? DrawCompactRow : DrawCardRow` would // allocate a delegate on every frame of every window. - private readonly Action _drawCompactRow; - private readonly Action _drawCardRow; + private readonly Action _drawCompactRow; + private readonly Action _drawCardRow; // Reused across frames: at MessageManager.MessageDisplayLimit a fresh array // per frame is 40 KB of garbage, and A2 put the default density on this @@ -261,15 +261,20 @@ internal sealed class MessageList // Draws the stamp into its column and leaves the cursor at the text column, // whether or not anything was drawn. - private void DrawTimestampCell(Message message) + private void DrawTimestampCell(Message message, string? previousStamp) { var origin = ImGui.GetCursorPos(); - if (_stampVisible) + var stamp = FormatTimestamp(message.Date); + var draw = + _stampVisible + && RepeatedTimestamp.ShouldDraw(Plugin.Config.HideSameTimestamps, stamp, previousStamp); + + if (draw) { ImGui.SetCursorPosY(origin.Y + _metaDrop); using (MetaFace().Push()) - ImGui.TextUnformatted(FormatTimestamp(message.Date)); + ImGui.TextUnformatted(stamp); ImGui.SameLine(0f, 0f); } @@ -328,7 +333,7 @@ internal sealed class MessageList _scrollToBottomRequested = true; } - private void DrawCompactRow(Message message) + private void DrawCompactRow(Message message, string? previousStamp) { // B2-1/B2-2: render the sender through DrawChunks (the name-aware path // that applies WorldSuffixMode/NameFormMode via ForDisplay), not as a @@ -336,7 +341,7 @@ internal sealed class MessageList // channel brackets/colon as ChunkSource.None wrappers (MessageManager // .cs:300-314), so the separator is rendered by the chunks. 1.5.6 parity // (ChatLogWindow.cs:1965: DrawChunks(message.Sender) + SameLine). - DrawTimestampCell(message); + DrawTimestampCell(message, previousStamp); if (message.Sender.Count == 0) { @@ -365,7 +370,7 @@ internal sealed class MessageList private void DrawRows( Tab tab, IReadOnlyList messages, - Action drawRow, + Action drawRow, bool frozen ) { @@ -438,7 +443,9 @@ internal sealed class MessageList // the text instead of needing a draw-channel detour. DrawRowSurface(msg, heights[i]); - drawRow(msg); + // From the data, not from a variable carried between rows: this loop + // starts at FirstVisible, so the row above the window was never drawn. + drawRow(msg, i > 0 ? FormatTimestamp(messages[i - 1].Date) : null); if (frozen) continue; @@ -531,7 +538,7 @@ internal sealed class MessageList private void DrawLinearAndMeasure( Guid tabId, IReadOnlyList messages, - Action drawRow + Action drawRow ) { // No row has a cached height on this frame, so the surface cannot be @@ -540,12 +547,13 @@ internal sealed class MessageList // frame after every resize. using var surfaces = StyleEngine.RowSurfaceScope.Push(); - foreach (var msg in messages) + for (var i = 0; i < messages.Count; i++) { + var msg = messages[i]; var before = ImGui.GetCursorPosY(); var top = ImGui.GetCursorScreenPos(); - drawRow(msg); + drawRow(msg, i > 0 ? FormatTimestamp(messages[i - 1].Date) : null); var after = ImGui.GetCursorPosY(); var height = after - before; @@ -556,7 +564,7 @@ internal sealed class MessageList } } - private void DrawCardRow(Message message) + private void DrawCardRow(Message message, string? previousStamp) { // B2-1/B2-2: sender via DrawChunks (name-aware path), on its own line // with content below — 1.5.6 card parity (ChatLogWindow.cs:1913, no @@ -568,7 +576,7 @@ internal sealed class MessageList // densities; only a message with a sender gets the two-line treatment. if (message.Sender.Count == 0) { - DrawTimestampCell(message); + DrawTimestampCell(message, previousStamp); using (ItalicFace().Push()) _chunkRenderer.DrawChunks( message.Content, @@ -580,7 +588,7 @@ internal sealed class MessageList return; } - DrawTimestampCell(message); + DrawTimestampCell(message, previousStamp); using (SenderFace().Push()) _chunkRenderer.DrawChunks(message.Sender, wrap: true, handler: _handler, lineWidth: 0f); diff --git a/HellionChat/Ui/Components/RepeatedTimestamp.cs b/HellionChat/Ui/Components/RepeatedTimestamp.cs new file mode 100644 index 0000000..a9f1a19 --- /dev/null +++ b/HellionChat/Ui/Components/RepeatedTimestamp.cs @@ -0,0 +1,20 @@ +namespace HellionChat.Ui.Components; + +// TEST-MIRROR: Ui/RepeatedTimestampTests.cs +// +// Whether a row draws its timestamp, given the one above it. +// +// The comparison value has to come from the message data, not from a drawing +// state carried between rows. In 1.5.6 the loop walked every message and skipped +// the invisible ones with a dummy, so the "last stamp" it remembered was the last +// visible one. The virtualised list only iterates the visible window, so a row at +// the top of that window has a predecessor in the data that was never drawn -- +// and a state variable would hold whatever was on screen before the scroll. +// +// No predecessor means draw. Scrolling into the middle of a log would otherwise +// swallow the only stamp on screen. +internal static class RepeatedTimestamp +{ + internal static bool ShouldDraw(bool enabled, string current, string? previous) => + !enabled || previous is null || !string.Equals(current, previous, StringComparison.Ordinal); +} diff --git a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs index 46a47fc..88fa1b2 100644 --- a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs +++ b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs @@ -39,6 +39,13 @@ internal sealed class ChatTab () => Plugin.Config.Use24HourClock, v => Plugin.Config.Use24HourClock = v ); + _w.ToggleRow( + ImGui.GetID("chat.display.samestamps"u8), + Language.Options_HideSameTimestamps_Name, + Language.Options_HideSameTimestamps_Description, + () => Plugin.Config.HideSameTimestamps, + v => Plugin.Config.HideSameTimestamps = v + ); // Descriptions move out of the help markers and onto the row. They // were written to be read; a (?) the user has to hover is where an From e2b6e7a992d5cc59c1bc7aaebb6e32e61c4b9112 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 17:09:05 +0200 Subject: [PATCH 26/29] fix(chat): the header measured contrast against the wrong colour space EnsureContrast works in ABGR. I handed it the theme's RGBA on both arguments, so it swapped red and blue in the foreground and in the background, measured a contrast between two colours that were never on screen, and returned a result in the wrong order -- which then went through RgbaToAbgr a second time. On a violet surface with a teal accent that came out as dark bordeaux on dark violet: the exact unreadable pairing the call was there to prevent. Reported from a real screenshot, not from a test, because nothing here is testable without a draw frame. Every existing caller in the codebase passes ABGR -- SettingsPalette hands over _palette.Abgr(...), SegmentedControl uses fields literally named LabelAbgr and TrackAbgr. Mine were the only three that did not, and all three were written in this cycle. --- .../Components/Settings/LivePreviewPanel.cs | 7 +++++-- .../Ui/StyleEngine/Widgets/ChannelHeader.cs | 19 ++++++++++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs b/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs index 1084f39..c11cb1d 100644 --- a/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs +++ b/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs @@ -286,8 +286,11 @@ internal sealed class LivePreviewPanel : IDisposable draw.DrawTrackedText( new Vector2(listOrigin.X + 6f, listOrigin.Y + 4f), MockChannel, - ColourUtil.RgbaToAbgr( - ColourUtil.EnsureContrast(theme.Colors.Accent, theme.Colors.Surface, 4.5f) + // ABGR in, ABGR out -- see the note in ChannelHeader. + ColourUtil.EnsureContrast( + ColourUtil.RgbaToAbgr(theme.Colors.Accent), + ColourUtil.RgbaToAbgr(theme.Colors.Surface), + 4.5f ), 1.8f ); diff --git a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs index 9bb8a1b..12584d6 100644 --- a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs +++ b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs @@ -69,6 +69,7 @@ internal static class ChannelHeader var theme = Plugin.Instance.ThemeRegistry.Active; var surface = theme.Colors.Surface; + var surfaceAbgr = ColourUtil.RgbaToAbgr(surface); var body = BodyFace(fonts); var meta = MetaFace(fonts); @@ -163,7 +164,7 @@ internal static class ChannelHeader var dl = ImGui.GetWindowDrawList(); var bottomRight = origin + new Vector2(width, height); - dl.AddRectFilled(origin, bottomRight, ColourUtil.RgbaToAbgr(surface)); + dl.AddRectFilled(origin, bottomRight, surfaceAbgr); dl.AddLine( new Vector2(origin.X, bottomRight.Y - 1f), new Vector2(bottomRight.X, bottomRight.Y - 1f), @@ -175,8 +176,14 @@ internal static class ChannelHeader if (plan.ShowName) { - var accent = ColourUtil.RgbaToAbgr( - ColourUtil.EnsureContrast(theme.Colors.Accent, surface, 4.5f) + // Converted first, then measured. EnsureContrast works in ABGR -- + // handing it the theme's RGBA swaps red and blue on both arguments, + // so it measures a contrast that has nothing to do with what ends up + // on screen and returns a colour in the wrong order on top. + var accent = ColourUtil.EnsureContrast( + ColourUtil.RgbaToAbgr(theme.Colors.Accent), + surfaceAbgr, + 4.5f ); var x = origin.X + inset; @@ -199,8 +206,10 @@ internal static class ChannelHeader if (plan.ShowDetail) { - var muted = ColourUtil.RgbaToAbgr( - ColourUtil.EnsureContrast(theme.Colors.TextMuted, surface, 4.5f) + var muted = ColourUtil.EnsureContrast( + ColourUtil.RgbaToAbgr(theme.Colors.TextMuted), + surfaceAbgr, + 4.5f ); var whereFace = detail.WhereIsTranslated ? body : meta; From 439c919d7752bc631670096ef2e5e21d34dda2da Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 17:13:06 +0200 Subject: [PATCH 27/29] feat(chat): a button for screenshot mode, where it can actually be found It has only ever lived in the right-click menu on a player name. For a privacy feature that is the same as not existing -- reported as missing by a tester who has been running the plugin for months and never found it. Now a camera in the input row, next to hide-window, and lit in the accent colour while active. A mode whose state you cannot see is worse than no mode: the whole point is knowing whether the names on your screen are real before you press the screenshot key. The button reserve goes from 130 to 156 to fit it. No new string -- the context menu's label is already translated into all 25 languages and says exactly what the button does. --- HellionChat/Ui/Components/InputBar.cs | 23 +++++++++++++++++++++++ HellionChat/Ui/StyleEngine/Metrics.cs | 6 +++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/HellionChat/Ui/Components/InputBar.cs b/HellionChat/Ui/Components/InputBar.cs index 60bac08..545c741 100644 --- a/HellionChat/Ui/Components/InputBar.cs +++ b/HellionChat/Ui/Components/InputBar.cs @@ -658,6 +658,29 @@ internal sealed class InputBar if (ImGui.IsItemHovered()) tooltip = HellionStrings.InputBar_Settings_Tooltip; + // Screenshot mode. It lived only in the right-click menu on a player + // name, which for a privacy feature is the same as not existing -- + // reported as missing by a tester who had been using the plugin for + // months. Lit when active, because a mode you cannot see the state of + // is worse than no mode. + ImGui.SameLine(); + var shooting = Plugin.Config.ScreenshotMode; + using ( + ImRaii.PushColor( + ImGuiCol.Text, + ColourUtil.RgbaToVector4( + _resolver.Resolve(Token.AccentEmber, _themes.Active.Colors) + ), + shooting + ) + ) + { + if (ImGui.Button(FontAwesomeIcon.Camera.ToIconString())) + Plugin.Config.ScreenshotMode = !Plugin.Config.ScreenshotMode; + } + if (ImGui.IsItemHovered()) + tooltip = Language.Context_ScreenshotMode; + // Hides the window (1.5.6 UserHide). One-way — Enter brings it back. // Main window only; last in the row there. if (Plugin.Config.ShowHideButton && _onHideWindow is not null) diff --git a/HellionChat/Ui/StyleEngine/Metrics.cs b/HellionChat/Ui/StyleEngine/Metrics.cs index 37426d3..2535881 100644 --- a/HellionChat/Ui/StyleEngine/Metrics.cs +++ b/HellionChat/Ui/StyleEngine/Metrics.cs @@ -26,7 +26,11 @@ internal static class Metrics // --- Input bar --- internal const float InputBarHeightRaw = 32f; - internal const float InputQuickButtonsReserveRaw = 130f; + + // Raised from 130 in v1.13.0 for the screenshot-mode button. Five buttons in + // the main window (symbols, theme, screenshot, settings, hide) and five in a + // pop-out, where pop-in takes the place of hide. + internal const float InputQuickButtonsReserveRaw = 156f; // --- Honorific header --- internal const float HonorificHeightRaw = 30f; From 3b5001e616eaa63082a2345dad423095c3ab13d5 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 17:29:20 +0200 Subject: [PATCH 28/29] 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 ); From 64f48b1131cd6716e703ff1240b55eea306a20bf Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 19 Aug 2026 17:34:49 +0200 Subject: [PATCH 29/29] chore(release): close the v1.13.0 cycle Version to 1.13.0 in the csproj, with the changelog and roadmap entries for the local state. Not published: the public release stays at v1.5.6, repo.json keeps its 1.5.6.0 manifest and all three download links are untouched. The changelog leads with the screenshot-mode gap rather than with the typography, because that is the part that changes what a user's own screenshots contain. An auto-tell tab is named "Player@World", and three of the four surfaces that draw a tab name had no rule about it -- so a picture of the default view named the conversation partner while every message below it was anonymised. Anyone who has shared a screenshot from an older build should know that. Config version 26 is in there for the same reason. Its migration marks existing tell tabs as partner-named, and it says plainly what it cannot do: a tab promoted to permanent before this version keeps its name and loses every marker, so nothing in the stored data says where that name came from. Known issues carry the honest tail: the header recomputes its widths every frame instead of on the status bar's tick, and all three font handles now rasterise the full glyph range -- the cost of letting the channel name use the smaller face without breaking on an umlaut. The migration self-test asserts what v26 actually does now, rather than only that the version number moved. --- HellionChat/HellionChat.csproj | 2 +- .../SelfTests/ConfigMigrationV26Step.cs | 17 ++++ docs/CHANGELOG.md | 80 +++++++++++++++++++ docs/ROADMAP.md | 9 ++- 4 files changed, 104 insertions(+), 4 deletions(-) diff --git a/HellionChat/HellionChat.csproj b/HellionChat/HellionChat.csproj index f3af9c2..aaf2c79 100644 --- a/HellionChat/HellionChat.csproj +++ b/HellionChat/HellionChat.csproj @@ -1,7 +1,7 @@ - 1.12.0 + 1.13.0 enable enable diff --git a/HellionChat/SelfTests/ConfigMigrationV26Step.cs b/HellionChat/SelfTests/ConfigMigrationV26Step.cs index 8ac27f0..a64d0ea 100644 --- a/HellionChat/SelfTests/ConfigMigrationV26Step.cs +++ b/HellionChat/SelfTests/ConfigMigrationV26Step.cs @@ -25,6 +25,23 @@ internal sealed class ConfigMigrationV26Step : ISelfTestStep return SelfTestStepResult.Fail; } + // What v26 exists to prevent: a tab whose name came from a conversation + // partner but is not marked as such, because screenshot mode reads that + // mark to decide whether the name may be shown. Anything still holding a + // tell binding or the temp flag should have been marked on load. + foreach (var tab in Plugin.Config.Tabs) + { + var looksLikeAPartner = tab.IsTempTab || tab.TellTarget?.IsSet() == true; + if (!looksLikeAPartner || tab.NameCameFromPartner) + continue; + + ImGui.Text( + $"Tab '{tab.Name}' carries a tell binding but is not marked partner-named " + + "— the v26 migration did not reach it, and screenshot mode will show it" + ); + return SelfTestStepResult.Fail; + } + // The state the v24 migration exists to prevent: filter on, failsafe on, // nothing picked. Under the corrected rule that combination stores no // messages at all, so reaching /xlperf in it means the migration did not diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7b11b20..f352c18 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,86 @@ releases as an overview and links to the release pages for details. --- +## [1.13.0] — unreleased (local only) + +Typography. The message list was the last surface still drawn in ImGui defaults, +and it is the only one a reader looks at line by line rather than in glances. It +now has named type roles instead of a single size, a header above the +conversation, and rows that read as rows. **Not published** — the public release +stays at v1.5.6. + +### Added + +- A header above the conversation: the channel name in tracked small caps with + the tab's own icon, and the home world on the right. It stays out of the way + where the name is already on screen — the top-tab strip carries it, and so does + a pop-out's title bar — and disappears entirely when the window is too short to + leave room for readable chat. +- Timestamps sit in a column of their own, so sender names line up underneath + each other instead of starting wherever the previous stamp happened to end. +- Local and server time in the status bar, in the game's own LT/ST notation. + Server time comes from the game rather than from a local UTC conversion, so it + follows whatever Square Enix does with it. +- Rows have a surface: a wash from the left and a two-pixel accent bar, fading in + and out rather than snapping. +- The sender is set in a heavier cut of the same face. There is no bold in the + plugin, so the weight comes from a denser rasterisation — which means it has no + effect when both font settings are off and the game's own face draws. +- System messages are italic. Nobody said them; it is the game talking. +- Card density finally differs from compact: the sender on its own line, the body + indented onto the text column, and air between messages. +- A camera button in the input row for screenshot mode, lit while active. The + setting had only ever existed in the right-click menu on a player name, which + for a privacy feature is close to not existing at all. +- `Hide timestamps when redundant` is reachable. It has shipped with translations + since 1.5.6 and never had a control. + +### Fixed + +- **Screenshot mode reached one surface out of four.** An auto-tell tab is named + `Player@World`, and the sidebar, the tab strip and a pop-out's window title all + drew that untouched — so a screenshot of the default view named the + conversation partner while every message below it was anonymised. All four + surfaces now share one rule, and the placeholder is re-salted on every plugin + load so two screenshots taken weeks apart cannot be tied together by it. +- `Show timestamps` in the tab editor does something again. It had two readers in + 1.5.6 and lost both when the old chat window was retired in May. +- Four settings could always leave the row-height cache stale and never did: the + italic size, the italic toggle, and the two font toggles. The last two were the + quiet ones — both size fields default to the same value, so nothing in the + cache key moved while the glyph widths underneath it did. The clock format and + the per-tab timestamp switch join them. +- The word-wrap calculation was handed the UI scale where ImGui wanted the ratio + between rendered and baked size. The two are the same number until a second + size enters a line, which is exactly what this release introduces. +- Emoji and other characters outside the basic plane no longer break a tab name + drawn in tracked caps. + +### Changed + +- **Config version 26.** The migration marks existing tell tabs as + partner-named, which is what screenshot mode reads. Tabs promoted to permanent + before this version cannot be recovered — promotion clears every marker and + keeps the name — but renaming one has the same effect. +- The header replaced the pop-out's plain title row. With the title bar on it + shows only the world, because the title already carries the name. +- The channel header gave up its clock when both times moved to the status bar. + Three copies of the same number on one screen is two too many. +- All three custom font handles now rasterise the full glyph range. The smaller + one started with an ASCII-sized range on the assumption it would only draw + clocks and world names; a single umlaut in a tab name would have broken it. + +### Known issues + +- The channel header recomputes its widths every frame rather than on the status + bar's one-second tick. Measurable, not measured. +- `Metrics.Scale` still calls a Dalamud property that is marked obsolete. +- The database viewer and the emoji picker are still English on every client. +- 245 orphaned resource keys remain as the inventory of what the v1.6.0 rebuild + lost. + +--- + ## [1.12.0] — unreleased (local only) The reconnection cycle. A commit during the v1.6.0 window layer rebuild removed the old tab system, diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1b0b2ee..2fddac7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -14,7 +14,7 @@ be a poor fit for the plugin's privacy-first scope during brainstorming. The published release is **v1.5.6**. Development since then runs as a UI rebuild towards v2.0.0 and is not published: the whole window layer is being rewritten from ImGui defaults to custom drawing. -Versions v1.6.0 through v1.12.0 are local development states, and `repo.json` deliberately keeps +Versions v1.6.0 through v1.13.0 are local development states, and `repo.json` deliberately keeps its download links on v1.5.6 so nobody updates into a partial state. Where it stands: @@ -32,8 +32,11 @@ Where it stands: database maintenance and pinning unreachable, and 433 of 824 translation keys with no caller. All four features are back, the settings window is translated into 25 languages, and the channel grid is authoritative over what gets stored. -- **v1.13.0 onwards** — typography and the message list: the font size ladder, channel headers and - message cards, then the sidebar moving from tab rows to channel rows. +- **v1.13.0** — typography. Named type roles instead of one size, a channel header above the + conversation, timestamps in a column of their own, and rows with a surface. Screenshot mode + reached one of four surfaces that draw a tab name and now reaches all of them. +- **v1.14.0 onwards** — the sidebar moving from tab rows to channel rows, then the first-run + wizard and the input bar, which are the last two surfaces still drawn in ImGui defaults. A tester beta follows once the visual pass is complete.