feat(themes): apply theme typography font-size overrides on every activation path

This commit is contained in:
2026-06-15 18:19:08 +02:00
parent ec92cf2c04
commit fe0414dd2b
6 changed files with 110 additions and 29 deletions
+44 -8
View File
@@ -6,6 +6,7 @@ using Dalamud.Interface.GameFonts;
using Dalamud.Interface.ManagedFontAtlas;
using Dalamud.Interface.Utility;
using Dalamud.Plugin;
using HellionChat.Themes;
namespace HellionChat;
@@ -39,6 +40,12 @@ public sealed class FontManager : IDisposable
internal IFontHandle? RegularFont;
internal IFontHandle? ItalicFont;
// 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;
// True once every required atlas-owned handle reports Available. Components
// gate their first-frame draw on this — without it the layout math would
// run against placeholder font metrics and snap when the real atlas
@@ -104,6 +111,9 @@ public sealed class FontManager : IDisposable
if (Plugin.Config.ItalicEnabled)
ItalicFont = BuildItalicFontHandle(atlas);
}
// Source is still null here, so this is the config-only baseline.
_lastBuiltFingerprint = EffectiveFontFingerprint();
}
// Called from the settings save path when one of the font-related
@@ -125,6 +135,37 @@ public sealed class FontManager : IDisposable
ItalicFont?.Dispose();
ItalicFont = Plugin.Config.ItalicEnabled ? BuildItalicFontHandle(atlas) : null;
_lastBuiltFingerprint = EffectiveFontFingerprint();
}
public void SetTypographySource(Func<ThemeTypography?> source) => _typographySource = source;
internal float ResolveGlobalFontPt() =>
FontSizeResolver.ResolveGlobalPt(
_typographySource?.Invoke(),
Plugin.Config.UseHellionFont,
Plugin.Config.FontSizeV2,
Plugin.Config.GlobalFontV2.SizePt
);
internal float ResolveSymbolsFontPt() =>
FontSizeResolver.ResolveSymbolsPt(
_typographySource?.Invoke(),
Plugin.Config.SymbolsFontSizeV2
);
internal (float Global, float Symbols) EffectiveFontFingerprint() =>
(ResolveGlobalFontPt(), ResolveSymbolsFontPt());
// Rebuilds only when the effective size changed (live fingerprint, TOCTOU-free).
// The atlas rebuild must run on the framework/draw thread — callers ensure that.
internal void RebuildDelegateFontsIfChanged()
{
if (EffectiveFontFingerprint() != _lastBuiltFingerprint)
{
RebuildDelegateFonts();
}
}
// Instance method so Ranges / JpRange are reachable without parameter
@@ -133,12 +174,7 @@ public sealed class FontManager : IDisposable
atlas.NewDelegateFontHandle(e =>
e.OnPreBuild(tk =>
{
// UseHellionFont swaps the source font but keeps the size
// selector tied to FontSizeV2 (the bundled font ships as
// a single weight).
var basePt = Plugin.Config.UseHellionFont
? Plugin.Config.FontSizeV2
: Plugin.Config.GlobalFontV2.SizePt;
var basePt = ResolveGlobalFontPt();
var config = new SafeFontConfig { SizePt = basePt, GlyphRanges = Ranges };
// Missing embedded resource falls back to the configured
// system font instead of taking the whole UiBuilder down.
@@ -164,7 +200,7 @@ public sealed class FontManager : IDisposable
"noto-cjk-fallback"
);
config.SizePt = Plugin.Config.SymbolsFontSizeV2;
config.SizePt = ResolveSymbolsFontPt();
tk.AddGameSymbol(config);
tk.Font = config.MergeFont;
@@ -201,7 +237,7 @@ public sealed class FontManager : IDisposable
"noto-cjk-fallback"
);
config.SizePt = Plugin.Config.SymbolsFontSizeV2;
config.SizePt = ResolveSymbolsFontPt();
tk.AddGameSymbol(config);
tk.Font = config.MergeFont;
+18
View File
@@ -0,0 +1,18 @@
using HellionChat.Themes;
namespace HellionChat;
// Pure size resolution, split out of FontManager so it is unit-testable without
// building the font atlas. A typography override wins; null falls back to config.
internal static class FontSizeResolver
{
internal static float ResolveGlobalPt(
ThemeTypography? typography,
bool useHellionFont,
float fontSizeV2,
float globalSizePt
) => typography?.OverrideGlobalFontSizePt ?? (useHellionFont ? fontSizeV2 : globalSizePt);
internal static float ResolveSymbolsPt(ThemeTypography? typography, float symbolsSizePt) =>
typography?.OverrideSymbolsFontSizePt ?? symbolsSizePt;
}
@@ -16,16 +16,26 @@ namespace HellionChat.Infrastructure.Hosting;
// at Build, which runs the service ctor (IPC subscribe etc.) right then
// instead of lazily on first GetRequiredService.
internal sealed class ThemeRegistryInitHostedService(ThemeRegistry registry) : IHostedService
internal sealed class ThemeRegistryInitHostedService(
ThemeRegistry registry,
FontManager fontManager
) : IHostedService
{
public Task StartAsync(CancellationToken cancellationToken)
public async Task StartAsync(CancellationToken cancellationToken)
{
// Materialise the lazy AllCustom enumerable so the slug lookup hits a
// warm cache; otherwise the first Switch falls through to the built-in
// default when Config.Theme points at a custom slug.
foreach (var _ in registry.AllCustom()) { }
registry.SwitchSilent(Plugin.Config.Theme);
return Task.CompletedTask;
// B4b-3: point font sizes at the active theme's typography, wire future
// theme switches to the atlas rebuild, and apply the boot theme's override.
fontManager.SetTypographySource(() => registry.Active.Typography);
registry.SetActiveChangedCallback(() => fontManager.RebuildDelegateFontsIfChanged());
await Plugin.Framework.RunOnFrameworkThread(() =>
fontManager.RebuildDelegateFontsIfChanged()
);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+2 -1
View File
@@ -334,7 +334,8 @@ internal static class PluginHostFactory
// does not need one — its ctor runs the init inline inside a single
// SuppressAutoRebuild block on eager resolve.
services.AddHostedService(sp => new ThemeRegistryInitHostedService(
sp.GetRequiredService<ThemeRegistry>()
sp.GetRequiredService<ThemeRegistry>(),
sp.GetRequiredService<FontManager>()
));
services.AddHostedService(sp => new IpcManagerInitHostedService(
sp.GetRequiredService<IpcManager>()
+32 -17
View File
@@ -47,6 +47,12 @@ public sealed class ThemeRegistry
public Theme? EditingThemeBuffer => _editingThemeBuffer;
public event Action? OnEditingBufferChanged;
// Fired after _active changes (Switch / RefreshActiveIfStale); the init host
// wires it to the font-atlas rebuild. NOT fired by SwitchSilent (boot handles that).
private Action? _onActiveChanged;
internal void SetActiveChangedCallback(Action callback) => _onActiveChanged = callback;
// Shared slug guard for any code path that turns a slug into a filename.
// Both SaveEditingBuffer (F1) and ImportFromPath (M6) call this so the
// path-traversal/invalid-char rules live in exactly one place.
@@ -190,27 +196,32 @@ public sealed class ThemeRegistry
_active = builtin;
_active.RecomputeAbgrCache();
_activeCustomPath = null;
return;
}
var customTheme = LoadCustomBySlug(slug, out var customPath);
if (customTheme is not null)
else
{
_active = customTheme;
// Defensive — ensures any future theme source always gets a populated cache.
_active.RecomputeAbgrCache();
_activeCustomPath = customPath;
// Force a first-tick reload-check after the switch so the stamp
// baseline is established on the next RefreshActiveIfStale call.
_lastActiveStamp = DateTime.MinValue;
return;
var customTheme = LoadCustomBySlug(slug, out var customPath);
if (customTheme is not null)
{
_active = customTheme;
// Defensive — ensures any future theme source always gets a populated cache.
_active.RecomputeAbgrCache();
_activeCustomPath = customPath;
// Force a first-tick reload-check after the switch so the stamp
// baseline is established on the next RefreshActiveIfStale call.
_lastActiveStamp = DateTime.MinValue;
}
else
{
// Fallback: neither built-in nor custom matched. Drop to default
// and clear the active custom path so RefreshActiveIfStale stays idle.
_active = _builtIns[DefaultSlug];
_active.RecomputeAbgrCache();
_activeCustomPath = null;
}
}
// Fallback: neither built-in nor custom matched. Drop to default
// and clear the active custom path so RefreshActiveIfStale stays idle.
_active = _builtIns[DefaultSlug];
_active.RecomputeAbgrCache();
_activeCustomPath = null;
// Notify listeners (the init host wires the font-atlas rebuild here).
_onActiveChanged?.Invoke();
}
// SwitchSilent is the plugin-load init path -- identical to Switch
@@ -426,6 +437,9 @@ public sealed class ThemeRegistry
{
reloaded.RecomputeAbgrCache();
_active = reloaded;
// Same-slug save bypasses Switch's notify (it noop'd on same slug);
// fire here so a typography change applies (no-op if size unchanged).
_onActiveChanged?.Invoke();
}
}
@@ -568,6 +582,7 @@ public sealed class ThemeRegistry
// RecomputeAbgrCache happens inside RefreshCustomCache on cache miss.
var reloaded = Get(_active.Slug);
_active = reloaded;
_onActiveChanged?.Invoke();
}
// 0x80070020 = SHARING_VIOLATION, 0x80070021 = LOCK_VIOLATION.
+1
View File
@@ -1,6 +1,7 @@
namespace HellionChat.Themes;
// Optional per-theme; reserved as an extension point for future theme slots.
// Italic body-size override intentionally omitted (v1.9.0 Typography-Polish).
public sealed record ThemeTypography(
float? OverrideGlobalFontSizePt = null,
float? OverrideSymbolsFontSizePt = null