feat(themes): apply theme typography font-size overrides on every activation path
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user