diff --git a/HellionChat/FontManager.cs b/HellionChat/FontManager.cs index e0b7453..88d9a42 100644 --- a/HellionChat/FontManager.cs +++ b/HellionChat/FontManager.cs @@ -1,26 +1,42 @@ -using Dalamud; +using Dalamud; using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.FontIdentifier; using Dalamud.Interface.GameFonts; using Dalamud.Interface.ManagedFontAtlas; using Dalamud.Interface.Utility; +using Dalamud.Plugin; namespace HellionChat; // Two LogProxy sites live in static methods (TryGetHellionFontBytes, // AddFontWithFallback); a ctor-injected ILogger would not be reachable // from those scopes, so the class stays on Plugin.LogProxy. -public class FontManager +// +// Hybrid handle model: Axis, AxisItalic and FontAwesome are tied to the +// game's current font state and never change for the lifetime of the +// plugin, so they are init-only. RegularFont and ItalicFont depend on +// user-toggleable settings and get replaced live via RebuildDelegateFonts +// when those settings change; they stay as mutable nullable fields. +// +// All five handles register inside a single SuppressAutoRebuild block in +// the ctor so the font atlas only rebuilds once for the whole plugin start +// instead of once per handle. +public sealed class FontManager : IDisposable { - internal IFontHandle Axis = null!; - internal IFontHandle AxisItalic = null!; + private readonly IDalamudPluginInterface _pluginInterface; - internal IFontHandle RegularFont = null!; + internal IFontHandle Axis { get; init; } + internal IFontHandle AxisItalic { get; init; } + internal IFontHandle FontAwesome { get; init; } + + // Mutable because the live font settings replace these via + // RebuildDelegateFonts. Reference replacement is atomic for reference + // types, so push sites that read the field once per frame see at most + // one stale handle. + internal IFontHandle? RegularFont; internal IFontHandle? ItalicFont; - internal IFontHandle FontAwesome = null!; - private ushort[] Ranges = []; private ushort[] JpRange = []; @@ -47,10 +63,133 @@ public class FontManager // Hellion font bytes (Exo 2, OFL-1.1); lazily loaded from manifest resources private static byte[]? HellionFontBytes; - // Returns null when the embedded font resource is missing. Should never + public FontManager(IDalamudPluginInterface pluginInterface) + { + _pluginInterface = pluginInterface; + SetUpRanges(); + + var atlas = _pluginInterface.UiBuilder.FontAtlas; + + using (atlas.SuppressAutoRebuild()) + { + Axis = atlas.NewGameFontHandle( + new GameFontStyle(GameFontFamily.Axis, SizeInPx(Plugin.Config.FontSizeV2)) + ); + + AxisItalic = atlas.NewGameFontHandle( + new GameFontStyle(GameFontFamily.Axis, SizeInPx(Plugin.Config.FontSizeV2)) + { + SkewStrength = SizeInPx(Plugin.Config.FontSizeV2) / 6, + } + ); + + FontAwesome = atlas.NewDelegateFontHandle(e => + { + e.OnPreBuild(tk => + tk.AddFontAwesomeIconFont(new SafeFontConfig { SizePx = GetFontSize() }) + ); + e.OnPostBuild(tk => tk.FitRatio(tk.Font)); + }); + + RegularFont = BuildRegularFontHandle(atlas); + + if (Plugin.Config.ItalicEnabled) + ItalicFont = BuildItalicFontHandle(atlas); + } + } + + // Called from the settings save path when one of the font-related + // settings changed. Game fonts and FontAwesome stay untouched because + // none of those settings affect them. + // + // Thread model: the settings save path runs on the ImGui draw thread, + // same as every push site. The rebuild finishes synchronously before + // the next push reads the field in the same frame, so there is no + // cross-thread race on the handle reference. + public void RebuildDelegateFonts() + { + SetUpRanges(); + + var atlas = _pluginInterface.UiBuilder.FontAtlas; + + RegularFont?.Dispose(); + RegularFont = BuildRegularFontHandle(atlas); + + ItalicFont?.Dispose(); + ItalicFont = Plugin.Config.ItalicEnabled ? BuildItalicFontHandle(atlas) : null; + } + + // Instance method so Ranges / JpRange are reachable without parameter + // plumbing; PascalCase field names follow the existing class style. + private IFontHandle BuildRegularFontHandle(IFontAtlas atlas) => + atlas.NewDelegateFontHandle(e => + e.OnPreBuild(tk => + { + // UseHellionFont swaps the source font but keeps the size + // selector tied to FontSizeV2 (the Hellion 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 }; + // Missing embedded resource falls back to the configured + // system font instead of taking the whole UiBuilder down. + var hellionBytes = Plugin.Config.UseHellionFont ? TryGetHellionFontBytes() : null; + config.MergeFont = hellionBytes is not null + ? tk.AddFontFromMemory(hellionBytes, config, "Hellion-Exo2") + : AddFontWithFallback(tk, Plugin.Config.GlobalFontV2.FontId, config, "global"); + + config.SizePt = Plugin.Config.JapaneseFontV2.SizePt; + config.GlyphRanges = JpRange; + AddFontWithFallback(tk, Plugin.Config.JapaneseFontV2.FontId, config, "japanese"); + + config.SizePt = Plugin.Config.SymbolsFontSizeV2; + tk.AddGameSymbol(config); + + tk.Font = config.MergeFont; + }) + ); + + private IFontHandle BuildItalicFontHandle(IFontAtlas atlas) => + atlas.NewDelegateFontHandle(e => + e.OnPreBuild(tk => + { + var config = new SafeFontConfig + { + SizePt = Plugin.Config.ItalicFontV2.SizePt, + GlyphRanges = Ranges, + }; + config.MergeFont = AddFontWithFallback( + tk, + Plugin.Config.ItalicFontV2.FontId, + config, + "italic" + ); + + config.SizePt = Plugin.Config.JapaneseFontV2.SizePt; + config.GlyphRanges = JpRange; + AddFontWithFallback(tk, Plugin.Config.JapaneseFontV2.FontId, config, "japanese"); + + config.SizePt = Plugin.Config.SymbolsFontSizeV2; + tk.AddGameSymbol(config); + + tk.Font = config.MergeFont; + }) + ); + + public void Dispose() + { + Axis.Dispose(); + AxisItalic.Dispose(); + FontAwesome.Dispose(); + RegularFont?.Dispose(); + ItalicFont?.Dispose(); + } + + // Returns null when the embedded font resource is missing. Should not // happen on a signed release build, but a broken csproj or hand-rolled - // dev build can land here. Caller falls back to the system font path so - // the plugin still loads instead of crashing the whole UiBuilder. + // dev build can land here. Caller falls back to the system font path + // so the plugin still loads instead of crashing the whole UiBuilder. private static byte[]? TryGetHellionFontBytes() { if (HellionFontBytes is not null) @@ -98,10 +237,8 @@ public class FontManager foreach (var c in reader.Glyphs) builder.AddChar(c.Char); - // various symbols // French // Romanian - // builder.AddText("←→↑↓《》■※☀★★☆♥♡ヅツッシ☀☁☂℃℉°♀♂♠♣♦♣♧®©™€$£♯♭♪✓√◎◆◇♦■□〇●△▽▼▲‹›≤≥<«“”─\~"); builder.AddText("Œœ"); builder.AddText("ĂăÂâÎîȘșȚț"); @@ -122,100 +259,6 @@ public class FontManager JpRange = BuildRange(GlyphRangesJapanese.GlyphRanges); } - // CPU-bound build offloaded to Task.Run; runs parallel with theme init - public async Task BuildFontsAsync(CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - await Task.Run(BuildFonts, cancellationToken).ConfigureAwait(false); - } - - public void BuildFonts() - { - SetUpRanges(); - - Axis = Plugin.Interface.UiBuilder.FontAtlas.NewGameFontHandle( - new GameFontStyle(GameFontFamily.Axis, SizeInPx(Plugin.Config.FontSizeV2)) - ); - AxisItalic = Plugin.Interface.UiBuilder.FontAtlas.NewGameFontHandle( - new GameFontStyle(GameFontFamily.Axis, SizeInPx(Plugin.Config.FontSizeV2)) - { - SkewStrength = SizeInPx(Plugin.Config.FontSizeV2) / 6, - } - ); - - FontAwesome = Plugin.Interface.UiBuilder.FontAtlas.NewDelegateFontHandle(e => - { - e.OnPreBuild(tk => - tk.AddFontAwesomeIconFont(new SafeFontConfig { SizePx = GetFontSize() }) - ); - e.OnPostBuild(tk => tk.FitRatio(tk.Font)); - }); - - RegularFont = Plugin.Interface.UiBuilder.FontAtlas.NewDelegateFontHandle(e => - e.OnPreBuild(tk => - { - // v1.2.0: UseHellionFont controls font size selection - var basePt = Plugin.Config.UseHellionFont - ? Plugin.Config.FontSizeV2 - : Plugin.Config.GlobalFontV2.SizePt; - var config = new SafeFontConfig { SizePt = basePt, GlyphRanges = Ranges }; - // F10.2: if the embedded font is missing, drop to the system font - // path rather than letting the UiBuilder throw. - var hellionBytes = Plugin.Config.UseHellionFont ? TryGetHellionFontBytes() : null; - config.MergeFont = hellionBytes is not null - ? tk.AddFontFromMemory(hellionBytes, config, "Hellion-Exo2") - : AddFontWithFallback(tk, Plugin.Config.GlobalFontV2.FontId, config, "global"); - - config.SizePt = Plugin.Config.JapaneseFontV2.SizePt; - config.GlyphRanges = JpRange; - AddFontWithFallback(tk, Plugin.Config.JapaneseFontV2.FontId, config, "japanese"); - - config.SizePt = Plugin.Config.SymbolsFontSizeV2; - tk.AddGameSymbol(config); - - tk.Font = config.MergeFont; - }) - ); - - if (Plugin.Config.ItalicEnabled) - { - ItalicFont = Plugin.Interface.UiBuilder.FontAtlas.NewDelegateFontHandle(e => - e.OnPreBuild(tk => - { - var config = new SafeFontConfig - { - SizePt = Plugin.Config.ItalicFontV2.SizePt, - GlyphRanges = Ranges, - }; - config.MergeFont = AddFontWithFallback( - tk, - Plugin.Config.ItalicFontV2.FontId, - config, - "italic" - ); - - config.SizePt = Plugin.Config.JapaneseFontV2.SizePt; - config.GlyphRanges = JpRange; - AddFontWithFallback( - tk, - Plugin.Config.JapaneseFontV2.FontId, - config, - "japanese" - ); - - config.SizePt = Plugin.Config.SymbolsFontSizeV2; - tk.AddGameSymbol(config); - - tk.Font = config.MergeFont; - }) - ); - } - else - { - ItalicFont = null; - } - } - // Add font with fallback to NotoSansCjkRegular if unavailable private static ImFontPtr AddFontWithFallback( IFontAtlasBuildToolkitPreBuild tk, diff --git a/HellionChat/Infrastructure/Hosting/InitHostedServices.cs b/HellionChat/Infrastructure/Hosting/InitHostedServices.cs index e43f482..5d2a772 100644 --- a/HellionChat/Infrastructure/Hosting/InitHostedServices.cs +++ b/HellionChat/Infrastructure/Hosting/InitHostedServices.cs @@ -11,17 +11,6 @@ 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 FontManagerInitHostedService(FontManager fontManager) : IHostedService -{ - public Task StartAsync(CancellationToken cancellationToken) - { - fontManager.BuildFonts(); - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; -} - internal sealed class ThemeRegistryInitHostedService(ThemeRegistry registry) : IHostedService { public Task StartAsync(CancellationToken cancellationToken) diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 31ceff3..14e30ec 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -307,11 +307,13 @@ public sealed class Plugin : IAsyncDalamudPlugin cancellationToken.ThrowIfCancellationRequested(); // Container drives service init now: Host.StartAsync triggers the - // IHostedService adapters (FontManager.BuildFonts, ThemeRegistry - // cache warmup + Switch, IPC eager-resolve, MessageManager - // FilterAllTabsAsync, AutoTellTabsService.Initialize). Window - // registration with WindowSystem runs on the framework thread - // inside PluginLifecycle.LoadAsync after StartAsync returns. + // remaining IHostedService adapters (ThemeRegistry cache warmup + + // Switch, IPC eager-resolve, MessageManager FilterAllTabsAsync, + // AutoTellTabsService.Initialize). FontManager runs its own init + // inline inside the ctor's SuppressAutoRebuild block on eager + // resolve. Window registration with WindowSystem runs on the + // framework thread inside PluginLifecycle.LoadAsync after + // StartAsync returns. if (_lifecycle is not null) await _lifecycle.LoadAsync(cancellationToken).ConfigureAwait(false); @@ -911,7 +913,10 @@ public sealed class Plugin : IAsyncDalamudPlugin Interface.UiBuilder.DisableUserUiHide = !Config.HideWhenUiHidden; ChatLogWindow.DefaultText = ImGui.GetStyle().Colors[(int)ImGuiCol.Text]; - using ((Config.FontsEnabled ? FontManager.RegularFont : FontManager.Axis).Push()) + // RegularFont is nullable only because the live rebuild path + // disposes it before reassigning; both ends of that swap happen on + // this same draw thread, so it cannot be null here. + using ((Config.FontsEnabled ? FontManager.RegularFont! : FontManager.Axis).Push()) WindowSystem.Draw(); ChatLogWindow.FinalizeFrame(); diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index c8781bc..f0c3375 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -77,7 +77,9 @@ internal static class PluginHostFactory )); services.AddSingleton(_ => new FileDialogManager()); services.AddSingleton(sp => new Commands(sp.GetRequiredService>())); - services.AddSingleton(_ => new FontManager()); + services.AddSingleton(sp => new FontManager( + sp.GetRequiredService() + )); services.AddSingleton(_ => new StatusBar()); services.AddSingleton(sp => new IpcManager(sp.GetRequiredService>())); services.AddSingleton(sp => new ExtraChat(sp.GetRequiredService>())); @@ -148,10 +150,9 @@ internal static class PluginHostFactory services.AddSingleton(sp => new FirstRunWizard(sp.GetRequiredService())); // Hosted-service adapters: thin wrappers around the existing init - // methods so the service class bodies stay unchanged. - services.AddHostedService(sp => new FontManagerInitHostedService( - sp.GetRequiredService() - )); + // methods so the service class bodies stay unchanged. FontManager + // 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() )); diff --git a/HellionChat/Ui/Settings.cs b/HellionChat/Ui/Settings.cs index 2fc611b..ad28f8b 100755 --- a/HellionChat/Ui/Settings.cs +++ b/HellionChat/Ui/Settings.cs @@ -229,7 +229,7 @@ public sealed class SettingsWindow : Dalamud.Interface.Windowing.Window } if (fontChanged || fontSizeChanged || italicStateChanged) - Plugin.FontManager.BuildFonts(); + Plugin.FontManager.RebuildDelegateFonts(); if (languageChanged) Plugin.LanguageChanged(Plugin.Interface.UiLanguage);