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.ManagedFontAtlas;
using Dalamud.Interface.Utility; using Dalamud.Interface.Utility;
using Dalamud.Plugin; using Dalamud.Plugin;
using HellionChat.Themes;
namespace HellionChat; namespace HellionChat;
@@ -39,6 +40,12 @@ public sealed class FontManager : IDisposable
internal IFontHandle? RegularFont; internal IFontHandle? RegularFont;
internal IFontHandle? ItalicFont; 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 // True once every required atlas-owned handle reports Available. Components
// gate their first-frame draw on this — without it the layout math would // gate their first-frame draw on this — without it the layout math would
// run against placeholder font metrics and snap when the real atlas // run against placeholder font metrics and snap when the real atlas
@@ -104,6 +111,9 @@ public sealed class FontManager : IDisposable
if (Plugin.Config.ItalicEnabled) if (Plugin.Config.ItalicEnabled)
ItalicFont = BuildItalicFontHandle(atlas); 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 // Called from the settings save path when one of the font-related
@@ -125,6 +135,37 @@ public sealed class FontManager : IDisposable
ItalicFont?.Dispose(); ItalicFont?.Dispose();
ItalicFont = Plugin.Config.ItalicEnabled ? BuildItalicFontHandle(atlas) : null; 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 // Instance method so Ranges / JpRange are reachable without parameter
@@ -133,12 +174,7 @@ public sealed class FontManager : IDisposable
atlas.NewDelegateFontHandle(e => atlas.NewDelegateFontHandle(e =>
e.OnPreBuild(tk => e.OnPreBuild(tk =>
{ {
// UseHellionFont swaps the source font but keeps the size var basePt = ResolveGlobalFontPt();
// 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 config = new SafeFontConfig { SizePt = basePt, GlyphRanges = Ranges }; var config = new SafeFontConfig { SizePt = basePt, GlyphRanges = Ranges };
// Missing embedded resource falls back to the configured // Missing embedded resource falls back to the configured
// system font instead of taking the whole UiBuilder down. // system font instead of taking the whole UiBuilder down.
@@ -164,7 +200,7 @@ public sealed class FontManager : IDisposable
"noto-cjk-fallback" "noto-cjk-fallback"
); );
config.SizePt = Plugin.Config.SymbolsFontSizeV2; config.SizePt = ResolveSymbolsFontPt();
tk.AddGameSymbol(config); tk.AddGameSymbol(config);
tk.Font = config.MergeFont; tk.Font = config.MergeFont;
@@ -201,7 +237,7 @@ public sealed class FontManager : IDisposable
"noto-cjk-fallback" "noto-cjk-fallback"
); );
config.SizePt = Plugin.Config.SymbolsFontSizeV2; config.SizePt = ResolveSymbolsFontPt();
tk.AddGameSymbol(config); tk.AddGameSymbol(config);
tk.Font = config.MergeFont; 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 // at Build, which runs the service ctor (IPC subscribe etc.) right then
// instead of lazily on first GetRequiredService. // 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 // Materialise the lazy AllCustom enumerable so the slug lookup hits a
// warm cache; otherwise the first Switch falls through to the built-in // warm cache; otherwise the first Switch falls through to the built-in
// default when Config.Theme points at a custom slug. // default when Config.Theme points at a custom slug.
foreach (var _ in registry.AllCustom()) { } foreach (var _ in registry.AllCustom()) { }
registry.SwitchSilent(Plugin.Config.Theme); 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; 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 // does not need one — its ctor runs the init inline inside a single
// SuppressAutoRebuild block on eager resolve. // SuppressAutoRebuild block on eager resolve.
services.AddHostedService(sp => new ThemeRegistryInitHostedService( services.AddHostedService(sp => new ThemeRegistryInitHostedService(
sp.GetRequiredService<ThemeRegistry>() sp.GetRequiredService<ThemeRegistry>(),
sp.GetRequiredService<FontManager>()
)); ));
services.AddHostedService(sp => new IpcManagerInitHostedService( services.AddHostedService(sp => new IpcManagerInitHostedService(
sp.GetRequiredService<IpcManager>() sp.GetRequiredService<IpcManager>()
+19 -4
View File
@@ -47,6 +47,12 @@ public sealed class ThemeRegistry
public Theme? EditingThemeBuffer => _editingThemeBuffer; public Theme? EditingThemeBuffer => _editingThemeBuffer;
public event Action? OnEditingBufferChanged; 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. // Shared slug guard for any code path that turns a slug into a filename.
// Both SaveEditingBuffer (F1) and ImportFromPath (M6) call this so the // Both SaveEditingBuffer (F1) and ImportFromPath (M6) call this so the
// path-traversal/invalid-char rules live in exactly one place. // path-traversal/invalid-char rules live in exactly one place.
@@ -190,9 +196,9 @@ public sealed class ThemeRegistry
_active = builtin; _active = builtin;
_active.RecomputeAbgrCache(); _active.RecomputeAbgrCache();
_activeCustomPath = null; _activeCustomPath = null;
return;
} }
else
{
var customTheme = LoadCustomBySlug(slug, out var customPath); var customTheme = LoadCustomBySlug(slug, out var customPath);
if (customTheme is not null) if (customTheme is not null)
{ {
@@ -203,15 +209,20 @@ public sealed class ThemeRegistry
// Force a first-tick reload-check after the switch so the stamp // Force a first-tick reload-check after the switch so the stamp
// baseline is established on the next RefreshActiveIfStale call. // baseline is established on the next RefreshActiveIfStale call.
_lastActiveStamp = DateTime.MinValue; _lastActiveStamp = DateTime.MinValue;
return;
} }
else
{
// Fallback: neither built-in nor custom matched. Drop to default // Fallback: neither built-in nor custom matched. Drop to default
// and clear the active custom path so RefreshActiveIfStale stays idle. // and clear the active custom path so RefreshActiveIfStale stays idle.
_active = _builtIns[DefaultSlug]; _active = _builtIns[DefaultSlug];
_active.RecomputeAbgrCache(); _active.RecomputeAbgrCache();
_activeCustomPath = null; _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 // SwitchSilent is the plugin-load init path -- identical to Switch
// but does NOT arm the crossfade state. Called from // but does NOT arm the crossfade state. Called from
@@ -426,6 +437,9 @@ public sealed class ThemeRegistry
{ {
reloaded.RecomputeAbgrCache(); reloaded.RecomputeAbgrCache();
_active = reloaded; _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. // RecomputeAbgrCache happens inside RefreshCustomCache on cache miss.
var reloaded = Get(_active.Slug); var reloaded = Get(_active.Slug);
_active = reloaded; _active = reloaded;
_onActiveChanged?.Invoke();
} }
// 0x80070020 = SHARING_VIOLATION, 0x80070021 = LOCK_VIOLATION. // 0x80070020 = SHARING_VIOLATION, 0x80070021 = LOCK_VIOLATION.
+1
View File
@@ -1,6 +1,7 @@
namespace HellionChat.Themes; namespace HellionChat.Themes;
// Optional per-theme; reserved as an extension point for future theme slots. // 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( public sealed record ThemeTypography(
float? OverrideGlobalFontSizePt = null, float? OverrideGlobalFontSizePt = null,
float? OverrideSymbolsFontSizePt = null float? OverrideSymbolsFontSizePt = null