Merge branch 'feature/v1.13.0' into main

Typography cycle. Named type roles instead of one size, a channel header above
the conversation, timestamps in a column of their own, rows with a surface, and
local plus server time in the status bar.

The heaviest finding was not typographic. Screenshot mode reached one of the four
surfaces that draw a tab name, and an auto-tell tab is named Player@World -- so a
picture of the default view named the conversation partner while every message
below it was anonymised. All four share one rule now.

Config version 26. Local state only; repo.json stays on 1.5.6.0.
This commit is contained in:
2026-08-19 17:40:26 +02:00
64 changed files with 1703 additions and 94 deletions
+1
View File
@@ -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),
+8 -1
View File
@@ -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;
+115 -4
View File
@@ -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<ThemeTypography?>? _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,12 +177,24 @@ public sealed class FontManager : IDisposable
var atlas = _pluginInterface.UiBuilder.FontAtlas;
// 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;
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
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Dalamud.NET.Sdk/15.0.0">
<PropertyGroup>
<!-- Independent versioning; see yaml changelog for upstream Chat 2 base -->
<Version>1.12.0</Version>
<Version>1.13.0</Version>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Use lock file to pin exact versions -->
+37 -8
View File
@@ -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),
+1
View File
@@ -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));
@@ -1358,4 +1358,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>obert</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Sessió no iniciada</value>
</data>
</root>
@@ -1357,4 +1357,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>otevřeno</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Nepřihlášen</value>
</data>
</root>
@@ -1357,4 +1357,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>åben</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Ikke logget ind</value>
</data>
</root>
@@ -1352,4 +1352,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>offen</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Nicht eingeloggt</value>
</data>
</root>
@@ -1357,4 +1357,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>ανοιχτό</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Εκτός σύνδεσης</value>
</data>
</root>
@@ -1358,4 +1358,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>abierto</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Sesión no iniciada</value>
</data>
</root>
@@ -1357,4 +1357,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>auki</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Ei kirjautuneena</value>
</data>
</root>
@@ -1358,4 +1358,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>ouvert</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Non connecté</value>
</data>
</root>
@@ -1357,4 +1357,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>nyitva</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Nincs bejelentkezve</value>
</data>
</root>
@@ -1358,4 +1358,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>aperto</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Non connesso</value>
</data>
</root>
@@ -1358,4 +1358,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>開いています</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>未ログイン</value>
</data>
</root>
@@ -1358,4 +1358,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>열림</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>로그인되지 않음</value>
</data>
</root>
@@ -1357,4 +1357,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>åpen</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Ikke innlogget</value>
</data>
</root>
@@ -1358,4 +1358,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>open</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Niet ingelogd</value>
</data>
</root>
@@ -1357,4 +1357,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>otwarte</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Niezalogowany</value>
</data>
</root>
@@ -1358,4 +1358,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>aberto</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Não conectado</value>
</data>
</root>
@@ -1357,4 +1357,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>aberto</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Sem sessão iniciada</value>
</data>
</root>
@@ -1369,4 +1369,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>open</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Not logged in</value>
</data>
</root>
@@ -1358,4 +1358,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>deschis</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Neconectat</value>
</data>
</root>
@@ -1358,4 +1358,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>открыт</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Вход не выполнен</value>
</data>
</root>
@@ -1358,4 +1358,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>öppen</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Inte inloggad</value>
</data>
</root>
@@ -1357,4 +1357,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>açık</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Oturum açılmadı</value>
</data>
</root>
@@ -1357,4 +1357,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>відкрито</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>Вхід не виконано</value>
</data>
</root>
@@ -1358,4 +1358,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>打开</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>未登录</value>
</data>
</root>
@@ -1358,4 +1358,7 @@
<data name="Settings_Preview_StatusOpen" xml:space="preserve">
<value>開啟</value>
</data>
<data name="ChannelHeader_NotLoggedIn" xml:space="preserve">
<value>未登入</value>
</data>
</root>
+2 -2
View File
@@ -527,10 +527,10 @@
<value>Finestra emergent</value>
</data>
<data name="Options_HideSameTimestamps_Name">
<value>Hide timestamps when redundant</value>
<value>Amaga les marques de temps redundants</value>
</data>
<data name="Options_HideSameTimestamps_Description">
<value>Hide timestamps when previous messages have the same timestamp.</value>
<value>Amaga la marca de temps quan el missatge anterior ja en té la mateixa.</value>
</data>
<data name="Options_ShowPopOutTitleBar_Name">
<value>Show title bar for popped-out tabs</value>
+2 -2
View File
@@ -527,10 +527,10 @@
<value>Pop out</value>
</data>
<data name="Options_HideSameTimestamps_Name">
<value>Hide timestamps when redundant</value>
<value>Nascondi gli orari ridondanti</value>
</data>
<data name="Options_HideSameTimestamps_Description">
<value>Hide timestamps when previous messages have the same timestamp.</value>
<value>Nasconde l'orario quando il messaggio precedente ha già lo stesso.</value>
</data>
<data name="Options_ShowPopOutTitleBar_Name">
<value>Show title bar for popped-out tabs</value>
@@ -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;
}
@@ -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)",
@@ -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)
{
+156
View File
@@ -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)");
}
}
+43
View File
@@ -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}");
}
}
+23
View File
@@ -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)
+253 -34
View File
@@ -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<Message> _drawCompactRow;
private readonly Action<Message> _drawCardRow;
private readonly Action<Message, string?> _drawCompactRow;
private readonly Action<Message, string?> _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);
}
_chunkRenderer.DrawChunks(message.Content, wrap: true, handler: _handler, lineWidth: 0f);
}
@@ -259,7 +370,7 @@ internal sealed class MessageList
private void DrawRows(
Tab tab,
IReadOnlyList<Message> messages,
Action<Message> drawRow,
Action<Message, string?> 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<Message> messages,
Action<Message> drawRow
Action<Message, string?> 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)
@@ -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);
}
@@ -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(
@@ -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();
}
@@ -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
+14 -2
View File
@@ -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)
+27
View File
@@ -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.
@@ -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";
}
+10 -2
View File
@@ -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,
@@ -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);
}
+5 -1
View File
@@ -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;
@@ -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;
}
}
}
+46
View File
@@ -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);
}
@@ -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);
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
+27 -17
View File
@@ -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();
}
}
+24
View File
@@ -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",
@@ -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)
+7 -2
View File
@@ -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
+21 -1
View File
@@ -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
+41
View File
@@ -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}";
}
}
+80
View File
@@ -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,
+6 -3
View File
@@ -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.