Files
HellionChat/HellionChat/FontManager.cs
T
JonKazama-Hellion b9feb8650f chore(comments): drop the spec task codes the last pass missed
Codes like POP-1c or B4b-2 name a task in a planning document, not
anything in the code. A reader has no way to resolve them and they age
into noise the moment the document is closed. Where a code was used as a
reference, the sentence now names the function it meant.
2026-08-20 07:54:45 +02:00

550 lines
21 KiB
C#

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;
using HellionChat.Themes;
using HellionChat.Ui.StyleEngine;
namespace HellionChat;
// Two LogProxy sites live in static methods (TryGetBundledFontBytes,
// AddFontWithFallback); a ctor-injected ILogger would not be reachable
// from those scopes, so the class stays on Plugin.LogProxy.
//
// Hybrid handle model: Axis and AxisItalic mirror the game's current
// font state and are init-only. FontAwesome reuses Dalamud's UiBuilder
// fixed-width icon handle and is likewise 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.
//
// The four atlas-owned handles register inside a single
// SuppressAutoRebuild block so the font atlas only rebuilds once for the
// whole plugin start instead of once per handle. FontAwesome lives
// outside that accounting because the UiBuilder already owns it.
public sealed class FontManager : IDisposable
{
private readonly IDalamudPluginInterface _pluginInterface;
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;
// v1.13.0: one handle per type role that needs a face of its own. Sender
// carries extra weight, Meta a smaller size on a tiny glyph range.
internal IFontHandle? SenderFont;
internal IFontHandle? MetaFont;
// The mockup asks for weight 600 on the sender. There is no bold face in the
// plugin and none in the bundled file, so the weight comes from a denser
// rasterisation of the same outline. 1.0 is the SafeFontConfig default;
// below ~1.2 the difference is not visible, above ~1.4 the glyphs smear.
//
// Note on cost: all three delegate handles now carry the full glyph range.
// The meta face started with an ASCII-sized one, on the assumption it would
// only ever draw clocks and world names -- then the channel header wanted
// to use it, and tab names are free user input. An umlaut would have been
// enough to break it.
//
// Not const: the smoke test compares three values side by side, and the
// widget gallery exposes it.
internal static float SenderWeight = 1.3f;
// Wired post-build; 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,
float Sender,
float Meta,
float Italic
) _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
// finishes building. ItalicFont being null means italics are disabled in
// config, which is a ready state, not a pending one.
public bool FontsReady =>
Axis.Available
&& AxisItalic.Available
&& FontAwesome.Available
&& RegularFont is { Available: true }
&& (ItalicFont is null || ItalicFont.Available)
// Unconditional, unlike ItalicFont: these two are always built. A handle
// that is not ready yet makes SimplePushedFont push nothing at all --
// silently -- so the first frame after a rebuild would measure the wrong
// face and write those heights into the row cache.
&& SenderFont is { Available: true }
&& MetaFont is { Available: true };
private ushort[] Ranges = [];
private ushort[] JpRange = [];
// Trimmed remainder the NotoSansCjk fallback is the sole source for
// (Hangul + full Han); excludes the Default/Latin block already merged
// by the global font, so the fallback no longer re-merges the full Ranges array.
private ushort[] CjkFallbackGlyphRange = [];
// Report accessor for the ctor self-test: built glyph-range array lengths so
// the step can show the dedup effect (a small trimmed fallback vs the large
// primary range) in its on-disk report instead of a bare Pass.
internal (int Ranges, int JpRange, int CjkFallback) GlyphRangeLengths =>
(Ranges.Length, JpRange.Length, CjkFallbackGlyphRange.Length);
public static readonly HashSet<float> AxisFontSizeList =
[
9.6f,
10f,
12f,
14f,
16f,
18f,
18.4f,
20f,
23f,
34f,
36f,
40f,
45f,
46f,
68f,
90f,
];
// Bundled UI font bytes (Inter Light, OFL-1.1); lazily loaded from manifest resources
private static byte[]? BundledFontBytes;
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 = _pluginInterface.UiBuilder.IconFontFixedWidthHandle;
RegularFont = BuildRegularFontHandle(atlas);
if (Plugin.Config.ItalicEnabled)
ItalicFont = BuildItalicFontHandle(atlas);
SenderFont = BuildSenderFontHandle(atlas);
MetaFont = BuildMetaFontHandle(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
// 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;
// Without the suppression each handle triggers its own atlas rebuild.
// With two handles that was tolerable; with four it is four rebuilds for
// one size change.
using (atlas.SuppressAutoRebuild())
{
RegularFont?.Dispose();
RegularFont = BuildRegularFontHandle(atlas);
ItalicFont?.Dispose();
ItalicFont = Plugin.Config.ItalicEnabled ? BuildItalicFontHandle(atlas) : null;
SenderFont?.Dispose();
SenderFont = BuildSenderFontHandle(atlas);
MetaFont?.Dispose();
MetaFont = BuildMetaFontHandle(atlas);
}
_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
);
// Every size that can land in one message row. Roles follow the base size
// arithmetically, but the resolved value is what the height cache has to key
// on -- a theme override moves the base without moving any factor.
internal (
float Global,
float Symbols,
float Sender,
float Meta,
float Italic
) EffectiveFontFingerprint()
{
var basePt = ResolveGlobalFontPt();
return (
basePt,
ResolveSymbolsFontPt(),
TypeScale.SizePtOf(TypeRole.Sender, basePt),
TypeScale.SizePtOf(TypeRole.Meta, basePt),
Plugin.Config.ItalicFontV2.SizePt
);
}
// 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
// plumbing; PascalCase field names follow the existing class style.
// Shared CJK + symbols tail for both the regular and italic delegate
// fonts. Earlier-merged fonts win for shared codepoints (imgui MergeMode),
// so this runs AFTER the primary font is set as config.MergeFont. The CJK
// fallback is the sole Hangul/Simplified-Han source when UseHellionFont=true
// (global=Inter-Light), so it stays in the chain — only its glyph range is
// trimmed (CjkFallbackGlyphRange) to drop the Default-block/endonym overlap.
// The Japanese merge keeps its own configured size and the full JpRange (which
// owns Traditional Han such as 體 U+9AD4), so japanese↔fallback no longer overlap.
private void AddCjkAndSymbols(
IFontAtlasBuildToolkitPreBuild tk,
SafeFontConfig config,
float basePt
)
{
config.SizePt = Plugin.Config.JapaneseFontV2.SizePt;
config.GlyphRanges = JpRange;
AddFontWithFallback(tk, Plugin.Config.JapaneseFontV2.FontId, config, "japanese");
// NotoSansCjk fallback, trimmed to CjkFallbackGlyphRange. Merged last so earlier fonts win.
config.SizePt = basePt;
config.GlyphRanges = CjkFallbackGlyphRange;
AddFontWithFallback(
tk,
new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkRegular),
config,
"noto-cjk-fallback"
);
config.SizePt = ResolveSymbolsFontPt();
tk.AddGameSymbol(config);
}
private IFontHandle BuildRegularFontHandle(IFontAtlas atlas) =>
atlas.NewDelegateFontHandle(e =>
e.OnPreBuild(tk =>
{
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.
var bundledBytes = Plugin.Config.UseHellionFont ? TryGetBundledFontBytes() : null;
config.MergeFont = bundledBytes is not null
? tk.AddFontFromMemory(bundledBytes, config, "Inter-Light")
: AddFontWithFallback(tk, Plugin.Config.GlobalFontV2.FontId, config, "global");
AddCjkAndSymbols(tk, config, basePt);
tk.Font = config.MergeFont;
})
);
// Same outline as the body face, rasterised denser. Only works on the
// delegate path: with FontsEnabled and UseHellionFont both off the game's own
// Axis handle draws, and a game font handle has no such knob. The sender then
// leans on channel colour alone, which is a deliberate limitation.
private IFontHandle BuildSenderFontHandle(IFontAtlas atlas) =>
atlas.NewDelegateFontHandle(e =>
e.OnPreBuild(tk =>
{
var basePt = TypeScale.SizePtOf(TypeRole.Sender, ResolveGlobalFontPt());
var config = new SafeFontConfig
{
SizePt = basePt,
GlyphRanges = Ranges,
RasterizerMultiply = SenderWeight,
};
var bundledBytes = Plugin.Config.UseHellionFont ? TryGetBundledFontBytes() : null;
config.MergeFont = bundledBytes is not null
? tk.AddFontFromMemory(bundledBytes, config, "Inter-Light-Sender")
: AddFontWithFallback(tk, Plugin.Config.GlobalFontV2.FontId, config, "sender");
AddCjkAndSymbols(tk, config, basePt);
tk.Font = config.MergeFont;
})
);
private IFontHandle BuildMetaFontHandle(IFontAtlas atlas) =>
atlas.NewDelegateFontHandle(e =>
e.OnPreBuild(tk =>
{
var basePt = TypeScale.SizePtOf(TypeRole.Meta, ResolveGlobalFontPt());
var config = new SafeFontConfig { SizePt = basePt, GlyphRanges = Ranges };
var bundledBytes = Plugin.Config.UseHellionFont ? TryGetBundledFontBytes() : null;
config.MergeFont = bundledBytes is not null
? tk.AddFontFromMemory(bundledBytes, config, "Inter-Light-Meta")
: AddFontWithFallback(tk, Plugin.Config.GlobalFontV2.FontId, config, "meta");
AddCjkAndSymbols(tk, config, basePt);
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"
);
AddCjkAndSymbols(tk, config, Plugin.Config.ItalicFontV2.SizePt);
tk.Font = config.MergeFont;
})
);
public void Dispose()
{
Axis.Dispose();
AxisItalic.Dispose();
// FontAwesome is shared with the UiBuilder; the host owns its
// lifetime, so the plugin must not dispose it.
RegularFont?.Dispose();
ItalicFont?.Dispose();
SenderFont?.Dispose();
MetaFont?.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.
private static byte[]? TryGetBundledFontBytes()
{
if (BundledFontBytes is not null)
return BundledFontBytes;
using var stream = typeof(FontManager).Assembly.GetManifestResourceStream(
"Inter-Light.ttf"
);
if (stream is null)
{
Plugin.LogProxy.Warning(
"Bundled Inter Light font resource missing, falling back to system default font."
);
return null;
}
using var ms = new MemoryStream();
stream.CopyTo(ms);
BundledFontBytes = ms.ToArray();
return BundledFontBytes;
}
private unsafe void SetUpRanges()
{
ushort[] BuildRange(
IReadOnlyList<ushort>? chars,
bool includeCommonExtras,
params nint[] ranges
)
{
var builder = new ImFontGlyphRangesBuilderPtr(ImGuiNative.ImFontGlyphRangesBuilder());
foreach (var range in ranges)
builder.AddRanges((ushort*)range);
if (chars != null)
{
for (var i = 0; i < chars.Count; i += 2)
{
if (chars[i] == 0)
break;
for (var j = (uint)chars[i]; j <= chars[i + 1]; j++)
builder.AddChar((ushort)j);
}
}
// Common extras (Axis ingame glyphs, endonyms, enclosed alphanumerics)
// belong to the primary/Japanese ranges only. The trimmed CJK fallback
// skips them so it stays a pure Hangul/Simplified-Han remainder and
// does not re-merge the Default-block work the global font already did.
if (includeCommonExtras)
{
// Ingame supported ranges
var reader = new FdtReader(
Plugin.DataManager.GetFile("common/font/axis_12.fdt")!.Data
);
foreach (var c in reader.Glyphs)
builder.AddChar(c.Char);
// French
// Romanian
builder.AddText("Œœ");
builder.AddText("ĂăÂâÎîȘșȚț");
// v1.5.3: language-dropdown endonyms. The dropdown renders
// with the currently active font range; without these glyphs
// a user on an English UI cannot read non-Latin language names
// before switching. Auto-activation in Settings.Apply then
// pulls in the full ExtraGlyphRange for the chosen locale.
builder.AddText(
"Català Čeština Dansk Deutsch Ελληνικά English Español Suomi"
+ " Français Magyar Italiano 日本語 한국어 Norsk bokmål Nederlands"
+ " Polski Português Brasil (Portugal) Română Русский Svenska"
+ " Türkçe Українська 简体中文 繁體中文"
);
// "Enclosed Alphanumerics" (partial) https://www.compart.com/en/unicode/block/U+2460
for (var i = 0x2460; i <= 0x24B5; i++)
builder.AddChar((char)i);
builder.AddChar('⓪');
}
return builder.BuildRangesToArray();
}
var ranges = new List<nint> { (nint)ImGui.GetIO().Fonts.GetGlyphRangesDefault() };
var customChars = new List<ushort>();
foreach (var extraRange in Enum.GetValues<ExtraGlyphRanges>())
{
if (!Plugin.Config.ExtraGlyphRanges.HasFlag(extraRange))
continue;
// LatinExtended and Greek use AddChar pairs because they have no
// built-in ImGui range helper; everything else points to a native
// ImGui glyph-range table.
switch (extraRange)
{
case ExtraGlyphRanges.LatinExtended:
customChars.AddRange(ExtraGlyphRangesExt.LatinExtendedPairs);
break;
case ExtraGlyphRanges.Greek:
customChars.AddRange(ExtraGlyphRangesExt.GreekPairs);
break;
default:
var ptr = extraRange.Range();
if (ptr != 0)
ranges.Add(ptr);
break;
}
}
Ranges = BuildRange(
customChars.Count > 0 ? customChars : null,
includeCommonExtras: true,
ranges.ToArray()
);
JpRange = BuildRange(GlyphRangesJapanese.GlyphRanges, includeCommonExtras: true);
// The fallback gets only the trimmed Hangul/Simplified-Han remainder.
// No Default block, no endonyms — those are already merged by the global and
// Japanese fonts, so re-merging them on the fallback was wasted atlas work.
CjkFallbackGlyphRange = BuildRange(CjkFallbackRange.Pairs, includeCommonExtras: false);
}
// Add font with fallback to NotoSansCjkRegular if unavailable
private static ImFontPtr AddFontWithFallback(
IFontAtlasBuildToolkitPreBuild tk,
IFontId fontId,
SafeFontConfig config,
string slot
)
{
try
{
return fontId.AddToBuildToolkit(tk, config);
}
catch (Exception e)
when (e
is FileNotFoundException
or DirectoryNotFoundException
or IOException
or InvalidOperationException
or ArgumentException
)
{
// Atlas-toolkit throws span IO and validation failures; routing
// the wider set through the fallback keeps a corrupt font config
// from taking down the whole atlas build.
Plugin.LogProxy.Warning(
e,
$"Configured {slot} font failed to load ({e.GetType().Name}), "
+ "falling back to NotoSansCjkRegular"
);
var fallback = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkRegular);
return fallback.AddToBuildToolkit(tk, config);
}
}
public static float SizeInPt(float px) => (float)(px * 3.0 / 4.0);
public static float SizeInPx(float pt) => (float)(pt * 4.0 / 3.0);
public static float GetFontSize() =>
Plugin.Config.FontsEnabled
? Plugin.Config.GlobalFontV2.SizePx
: SizeInPx(Plugin.Config.FontSizeV2);
}