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.
550 lines
21 KiB
C#
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);
|
|
}
|