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/FontManager.cs b/HellionChat/FontManager.cs index a01764c..b9e6013 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,11 +41,37 @@ 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. + // + // 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; + // Wired post-build (B4b-3); a Func keeps FontManager off the theme layer. 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 @@ -56,7 +83,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 = []; @@ -121,6 +154,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 +177,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(); } @@ -166,8 +214,26 @@ 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. @@ -232,6 +298,49 @@ 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 = 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"); + + AddCjkAndSymbols(tk, config, basePt); + + tk.Font = config.MergeFont; + }) + ); + private IFontHandle BuildItalicFontHandle(IFontAtlas atlas) => atlas.NewDelegateFontHandle(e => e.OnPreBuild(tk => @@ -262,6 +371,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/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/Plugin.cs b/HellionChat/Plugin.cs index 85e9965..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,12 +519,13 @@ 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(), 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/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..bc4c079 100644 --- a/HellionChat/Resources/HellionStrings.ca.resx +++ b/HellionChat/Resources/HellionStrings.ca.resx @@ -1358,4 +1358,7 @@ obert + + Sessió no iniciada + \ 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..e274935 100644 --- a/HellionChat/Resources/HellionStrings.es.resx +++ b/HellionChat/Resources/HellionStrings.es.resx @@ -1358,4 +1358,7 @@ abierto + + 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 2bdefc6..ec8548c 100644 --- a/HellionChat/Resources/HellionStrings.fi.resx +++ b/HellionChat/Resources/HellionStrings.fi.resx @@ -1357,4 +1357,7 @@ auki + + Ei kirjautuneena + \ 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..8bf133b 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..d7da149 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/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/SelfTests/ConfigMigrationV25Step.cs b/HellionChat/SelfTests/ConfigMigrationV26Step.cs similarity index 71% rename from HellionChat/SelfTests/ConfigMigrationV25Step.cs rename to HellionChat/SelfTests/ConfigMigrationV26Step.cs index 3219a13..a64d0ea 100644 --- a/HellionChat/SelfTests/ConfigMigrationV25Step.cs +++ b/HellionChat/SelfTests/ConfigMigrationV26Step.cs @@ -8,20 +8,37 @@ 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; + } + + // 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; } 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) { diff --git a/HellionChat/SelfTests/TypeScaleStep.cs b/HellionChat/SelfTests/TypeScaleStep.cs new file mode 100644 index 0000000..87c8ed3 --- /dev/null +++ b/HellionChat/SelfTests/TypeScaleStep.cs @@ -0,0 +1,156 @@ +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; + } + + // 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("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) + { + 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 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); + 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)"); + } +} 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/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/Components/MessageList.cs b/HellionChat/Ui/Components/MessageList.cs index e9fc90a..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 @@ -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) @@ -93,13 +99,27 @@ 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. - private LayoutFingerprint BuildLayoutFingerprint(float contentWidth) + // + // 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(Tab tab, 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, + Plugin.Config.Use24HourClock, + tab.DisplayTimestamp, (int)Plugin.Config.NameFormMode, (int)Plugin.Config.WorldSuffixMode, contentWidth, @@ -119,7 +139,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); @@ -143,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. @@ -179,6 +201,87 @@ 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. + // 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! + : _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, string? previousStamp) + { + var origin = ImGui.GetCursorPos(); + + 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(stamp); + + 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 @@ -230,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 @@ -238,19 +341,27 @@ 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); - if (message.Sender.Count > 0) + DrawTimestampCell(message, previousStamp); + + if (message.Sender.Count == 0) { - ImGui.TextUnformatted($"{timestamp} "); - ImGui.SameLine(0f, 0f); + // 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.Content, + wrap: true, + handler: _handler, + lineWidth: 0f + ); + return; + } + + using (SenderFace().Push()) _chunkRenderer.DrawChunks(message.Sender, wrap: true, handler: _handler, lineWidth: 0f); - ImGui.SameLine(0f, 0f); - } - else - { - ImGui.TextUnformatted(timestamp); - ImGui.SameLine(0f, 0f); - } + + ImGui.SameLine(0f, 0f); _chunkRenderer.DrawChunks(message.Content, wrap: true, handler: _handler, lineWidth: 0f); } @@ -259,7 +370,7 @@ internal sealed class MessageList private void DrawRows( Tab tab, IReadOnlyList messages, - Action drawRow, + Action drawRow, bool frozen ) { @@ -325,7 +436,16 @@ internal sealed class MessageList { var msg = messages[i]; var before = ImGui.GetCursorPosY(); - drawRow(msg); + + // 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(msg, heights[i]); + + // 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; @@ -343,44 +463,143 @@ 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(Message message, float height) + { + if (height <= 0f) + return; + + var top = ImGui.GetCursorScreenPos(); + PaintRowSurface(ImGui.GetWindowDrawList(), message, top, height, direct: true); + } + + private void FillRowSurface(Message message, Vector2 top, float height) + { + if (height <= 0f) + return; + + PaintRowSurface(ImGui.GetWindowDrawList(), message, top, height, direct: false); + } + + private void PaintRowSurface( + ImDrawListPtr dl, + Message message, + 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); + + // 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; + + 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. private void DrawLinearAndMeasure( Guid tabId, IReadOnlyList messages, - Action drawRow + Action drawRow ) { - foreach (var msg in messages) + // 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(); + + for (var i = 0; i < messages.Count; i++) { + var msg = messages[i]; var before = ImGui.GetCursorPosY(); - drawRow(msg); + var top = ImGui.GetCursorScreenPos(); + + drawRow(msg, i > 0 ? FormatTimestamp(messages[i - 1].Date) : null); + var after = ImGui.GetCursorPosY(); - msg.Height[tabId] = after - before; + var height = after - before; + FillRowSurface(msg, top, height); + + msg.Height[tabId] = height; msg.IsVisible[tabId] = ImGui.IsItemVisible(); } } - 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 // 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, previousStamp); + using (ItalicFace().Push()) + _chunkRenderer.DrawChunks( + message.Content, + wrap: true, + handler: _handler, + lineWidth: 0f + ); + ImGui.Dummy(new Vector2(0f, CardGapRaw * StyleEngine.Metrics.Scale)); + return; + } + + DrawTimestampCell(message, previousStamp); + 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); + + // 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) 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/LivePreviewPanel.cs b/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs index 815db7f..c11cb1d 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,32 @@ 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, + // 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 + ); + var padMin = new Vector2(listOrigin.X + 2f, max.Y - 6f); draw.AddRectFilled( padMin, @@ -274,11 +310,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( 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/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 diff --git a/HellionChat/Ui/Components/Sidebar.cs b/HellionChat/Ui/Components/Sidebar.cs index b0b4a02..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 @@ -486,7 +494,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) 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/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"; +} 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/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); +} 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; 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; + } + } +} diff --git a/HellionChat/Ui/StyleEngine/TypeScale.cs b/HellionChat/Ui/StyleEngine/TypeScale.cs new file mode 100644 index 0000000..5316b12 --- /dev/null +++ b/HellionChat/Ui/StyleEngine/TypeScale.cs @@ -0,0 +1,46 @@ +namespace HellionChat.Ui.StyleEngine; + +internal enum TypeRole +{ + Body, + Sender, + Meta, +} + +// TEST-MIRROR: Ui/TypeScaleTests.cs +// +// Named sizes derived from one base, so a role means the same thing wherever it +// is drawn. +// +// 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 +// 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 + 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); +} 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); +} diff --git a/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs new file mode 100644 index 0000000..8668c7c --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeader.cs @@ -0,0 +1,258 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.ManagedFontAtlas; +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. +// +// 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; + 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); + + // 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 + ) + { + // 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; + var origin = ImGui.GetCursorScreenPos(); + + var theme = Plugin.Instance.ThemeRegistry.Active; + var surface = theme.Colors.Surface; + var surfaceAbgr = ColourUtil.RgbaToAbgr(surface); + + var body = BodyFace(fonts); + var meta = MetaFace(fonts); + + var track = TrackRaw * scale; + var detailTrack = DetailTrackRaw * scale; + // 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. + // + // 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 + // 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; + + // ToUpperInvariant allocates, so only where the name is actually drawn. + var name = showName ? shownName.ToUpperInvariant() : string.Empty; + + Vector2 iconSize; + using (fonts.FontAwesome.Push()) + iconSize = ImGui.CalcTextSize(icon.ToIconString()); + + var inset = InsetRaw * scale; + var iconRun = iconSize.X + IconGapRaw * scale; + + 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 (meta.Push()) + { + if (DrawListExtensions.MeasureTrackedText(name, track) > room) + name = StringUtil.TruncateToFitWidth(name, room); + + nameRun = DrawListExtensions.MeasureTrackedText(name, track); + } + + nameRun += iconRun; + } + + float detailRun; + using (meta.Push()) + detailRun = DrawListExtensions.MeasureTrackedText(detail.Where, detailTrack); + + var plan = ChannelHeaderLayout.Plan( + showName ? ChannelHeaderMode.Full : ChannelHeaderMode.DetailOnly, + width - inset * 2f, + ImGui.GetContentRegionAvail().Y - height - reservedBelow, + nameRun, + detailRun, + scale + ); + + if (!plan.ShowHeader) + return; + + var dl = ImGui.GetWindowDrawList(); + var bottomRight = origin + new Vector2(width, height); + dl.AddRectFilled(origin, bottomRight, surfaceAbgr); + 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) + { + // 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; + + // 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()) + dl.AddText( + new Vector2(x, origin.Y + MetricsMath.CenterY(height, iconSize.Y)), + accent, + icon.ToIconString() + ); + + x += iconSize.X + IconGapRaw * scale; + + using (meta.Push()) + dl.DrawTrackedText( + new Vector2(x, textY + DropFor(body, meta, scale)), + name, + accent, + track + ); + } + + if (plan.ShowDetail && detail.Where.Length > 0) + { + var muted = ColourUtil.EnsureContrast( + ColourUtil.RgbaToAbgr(theme.Colors.TextMuted), + surfaceAbgr, + 4.5f + ); + + using (meta.Push()) + dl.DrawTrackedText( + new Vector2( + bottomRight.X - inset - detailRun, + textY + DropFor(body, meta, scale) + ), + detail.Where, + 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)); + } + + 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; + + return ChannelHeaderDetail.Format( + world, + Resources.HellionStrings.ChannelHeader_NotLoggedIn, + Plugin.Config.ScreenshotMode + ); + } + + // Zero whenever both runs use the same handle, which is the common case. + private static float DropFor(IFontHandle body, IFontHandle other, float scale) + { + 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 new file mode 100644 index 0000000..04ac4b8 --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderDetail.cs @@ -0,0 +1,27 @@ +namespace HellionChat.Ui.StyleEngine.Widgets; + +// TEST-MIRROR: Ui/ChannelHeaderDetailTests.cs +// +// Which world the header shows on the right, and whether it may show one at all. +// +// 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, string fallback, bool hideWhere) + { + // 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. + if (hideWhere) + return new ChannelHeaderDetailParts(string.Empty); + + // 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/StyleEngine/Widgets/ChannelHeaderLayout.cs b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderLayout.cs new file mode 100644 index 0000000..314235c --- /dev/null +++ b/HellionChat/Ui/StyleEngine/Widgets/ChannelHeaderLayout.cs @@ -0,0 +1,61 @@ +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. + // + // 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 scale + ) + { + if (availableHeight < MinMessageAreaHeightRaw * scale) + return new ChannelHeaderPlan(false, false, false); + + var showName = mode is ChannelHeaderMode.Full; + 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 8ad82cb..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; } @@ -123,16 +130,26 @@ 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(), + 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) @@ -163,11 +180,4 @@ internal sealed class ChannelPopoutWindow : Window, IFocusableChatWindow _input.Draw(Bound); } - - // 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..b496c3e 100644 --- a/HellionChat/Ui/Windows/MainWindow.cs +++ b/HellionChat/Ui/Windows/MainWindow.cs @@ -388,6 +388,30 @@ 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; + + // 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(), + inputHeight + previewHeight + ); + } + using ( var messages = ImRaii.Child( "##hellion-main-area", diff --git a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs index 3d51571..23c5394 100644 --- a/HellionChat/Ui/Windows/WidgetGalleryWindow.cs +++ b/HellionChat/Ui/Windows/WidgetGalleryWindow.cs @@ -23,6 +23,9 @@ internal sealed class WidgetGalleryWindow : Window private readonly WidgetPalette _palette; private int _badgeCount = 3; + private Tab? _headerSample; + private int _headerMode; + private bool _headerLoggedOut; private bool _rowActive = true; private bool _toggleA = true; private bool _toggleB; @@ -54,6 +57,50 @@ 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; + + // 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", + Resources.HellionStrings.ChannelHeader_NotLoggedIn, + Plugin.Config.ScreenshotMode + ); + + ChannelHeader.Draw(_headerSample, mode, fonts, detail, 0f); + + ImGui.Spacing(); } private void DrawRowSection(ThemeColors c) 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 diff --git a/HellionChat/Util/LayoutFingerprint.cs b/HellionChat/Util/LayoutFingerprint.cs index f6c6785..160f22c 100644 --- a/HellionChat/Util/LayoutFingerprint.cs +++ b/HellionChat/Util/LayoutFingerprint.cs @@ -1,11 +1,21 @@ 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( float FontGlobal, float FontSymbols, + float FontSender, + float FontMeta, + float FontItalic, bool Compact, + bool FontsEnabled, + bool UseHellionFont, + bool ItalicEnabled, + bool Use24Hour, + bool ShowTimestamp, int NameForm, int WorldSuffix, float Width, @@ -15,7 +25,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, int, int) Discrete => (Compact, 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 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}"; + } +} 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.