2315f10d91
Drop the custom NewDelegateFontHandle that built our own FontAwesome atlas slot and reuse Dalamud's UiBuilder.IconFontFixedWidthHandle instead. One less delegate-build step in the ctor, and the handle is host-managed so Dispose() leaves it alone. The pre-cycle icon inventory verified that every site we push the FontAwesome font for renders an icon that is present in the host's fixed-width handle glyph range, so no rendering site changes.
302 lines
11 KiB
C#
302 lines
11 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;
|
|
|
|
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.
|
|
//
|
|
// 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;
|
|
|
|
private ushort[] Ranges = [];
|
|
private ushort[] JpRange = [];
|
|
|
|
public static readonly HashSet<float> AxisFontSizeList =
|
|
[
|
|
9.6f,
|
|
10f,
|
|
12f,
|
|
14f,
|
|
16f,
|
|
18f,
|
|
18.4f,
|
|
20f,
|
|
23f,
|
|
34f,
|
|
36f,
|
|
40f,
|
|
45f,
|
|
46f,
|
|
68f,
|
|
90f,
|
|
];
|
|
|
|
// Hellion font bytes (Exo 2, OFL-1.1); lazily loaded from manifest resources
|
|
private static byte[]? HellionFontBytes;
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 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 is shared with the UiBuilder; the host owns its
|
|
// lifetime, so the plugin must not dispose it.
|
|
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.
|
|
private static byte[]? TryGetHellionFontBytes()
|
|
{
|
|
if (HellionFontBytes is not null)
|
|
return HellionFontBytes;
|
|
|
|
using var stream = typeof(FontManager).Assembly.GetManifestResourceStream(
|
|
"HellionFont.ttf"
|
|
);
|
|
if (stream is null)
|
|
{
|
|
Plugin.LogProxy.Warning(
|
|
"Hellion font resource missing — falling back to system default font."
|
|
);
|
|
return null;
|
|
}
|
|
|
|
using var ms = new MemoryStream();
|
|
stream.CopyTo(ms);
|
|
HellionFontBytes = ms.ToArray();
|
|
return HellionFontBytes;
|
|
}
|
|
|
|
private unsafe void SetUpRanges()
|
|
{
|
|
ushort[] BuildRange(IReadOnlyList<ushort>? chars, 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);
|
|
}
|
|
}
|
|
|
|
// 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("ĂăÂâÎîȘșȚț");
|
|
|
|
// "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() };
|
|
foreach (var extraRange in Enum.GetValues<ExtraGlyphRanges>())
|
|
if (Plugin.Config.ExtraGlyphRanges.HasFlag(extraRange))
|
|
ranges.Add(extraRange.Range());
|
|
|
|
Ranges = BuildRange(null, ranges.ToArray());
|
|
JpRange = BuildRange(GlyphRangesJapanese.GlyphRanges);
|
|
}
|
|
|
|
// 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);
|
|
}
|