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:
@@ -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),
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
Generated
+2
-2
@@ -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>
|
||||
|
||||
Generated
+2
-2
@@ -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>
|
||||
|
||||
+22
-5
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user