diff --git a/HellionChat/FontManager.cs b/HellionChat/FontManager.cs index e082e27..8b7fb6d 100644 --- a/HellionChat/FontManager.cs +++ b/HellionChat/FontManager.cs @@ -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? _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 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; diff --git a/HellionChat/FontSizeResolver.cs b/HellionChat/FontSizeResolver.cs new file mode 100644 index 0000000..60d1dbd --- /dev/null +++ b/HellionChat/FontSizeResolver.cs @@ -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; +} diff --git a/HellionChat/Infrastructure/Hosting/InitHostedServices.cs b/HellionChat/Infrastructure/Hosting/InitHostedServices.cs index bc837ea..3929753 100644 --- a/HellionChat/Infrastructure/Hosting/InitHostedServices.cs +++ b/HellionChat/Infrastructure/Hosting/InitHostedServices.cs @@ -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; diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index 3c0a7f1..9d53904 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -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() + sp.GetRequiredService(), + sp.GetRequiredService() )); services.AddHostedService(sp => new IpcManagerInitHostedService( sp.GetRequiredService() diff --git a/HellionChat/Themes/ThemeRegistry.cs b/HellionChat/Themes/ThemeRegistry.cs index 5089c6d..f8cb3f2 100644 --- a/HellionChat/Themes/ThemeRegistry.cs +++ b/HellionChat/Themes/ThemeRegistry.cs @@ -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. diff --git a/HellionChat/Themes/ThemeTypography.cs b/HellionChat/Themes/ThemeTypography.cs index 9f7a981..b889f56 100644 --- a/HellionChat/Themes/ThemeTypography.cs +++ b/HellionChat/Themes/ThemeTypography.cs @@ -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