perf(startup): build the font atlas once at the final ui.toml sizes

main() built the font atlas before App::init() loaded ui.toml, so the
first build used the hard-coded fallback sizes (H1 24 px, Body2 12 px).
App::init() then rebuilt for a saved font scale above 1.0, still before
the schema load and so still at the fallback sizes. Only the color-emoji
rebuild requested for the first frame reached the ui.toml sizes, so
color-emoji users built the atlas twice at font scale 1.0 and three
times at 1.5.

Users with chat_emoji_color=false never got that last rebuild. They
built the atlas once or twice and ran the whole session at the fallback
sizes, e.g. Body2 at 12 px instead of 15 and H1 at 24 px instead of 20.

Now:
- App::loadBaseUiSchema(), the ui.toml load moved out of App::init()
  unchanged, runs in main() before both InitImGui paths (DX11 and GL),
  together with the saved font scale and emoji style from the early
  settings read. It is idempotent, so the call left in App::init() is a
  no-op;
- the two rebuild triggers in App::init() are gone;
- Typography records what the atlas was built from (the 15 schema sizes,
  the schema's font scale, the effective emoji style and the schema
  generation), and preFrame() rebuilds once when they differ. While the
  schema generation and the emoji style are unchanged this costs two
  compares per frame.

Measured on the Lite build (color and mono emoji, font scale 1.0 and
1.5, English and Japanese): every run builds the atlas exactly once, at
the ui.toml sizes times the scale (H1 20 px and Body2 15 px at 1.0,
30 px and 22 px at 1.5), and ui.toml is loaded before the window and GL
context are created. The packaged Lite zip and AppImage log one atlas
build instead of two. Color-emoji screenshot sweeps match the previous
build within noise.

Visible changes:
- users with chat_emoji_color=false had been rendering the whole UI at
  the fallback font sizes; they now get the designed ui.toml sizes, so
  every mono-emoji screen differs from before and matches the
  color-emoji layout;
- a user skin whose [fonts] differ from ui.toml now rebuilds the atlas
  once when it is activated or left; on a runtime switch the atlas used
  to keep the old sizes until a restart. With a test skin that sets
  Body2 to 18 px, startup builds twice and ends at 18 px; switching to a
  bundled skin rebuilds once, switching among the 9 bundled skins
  rebuilds nothing (they carry an identical copy of [fonts]), and
  switching back rebuilds once;
- editing ui.toml while running still gives exactly one rebuild.

Not verifiable here: the Windows DX11 path with the schema loaded before
its first FreeType build, macOS, and the interactive font-scale slider,
Alt+scroll and emoji toggle, which were checked by reading the code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 06:05:07 -05:00
co-authored by Claude Opus 5.5
parent e0f9d36780
commit b50d5ac31f
5 changed files with 117 additions and 56 deletions
+36
View File
@@ -118,6 +118,38 @@ bool Typography::reload(ImGuiIO& io, float dpiScale)
return load(io, dpiScale);
}
// The 15 schema font sizes (TypeStyle order) that load() builds from.
static void gatherFontSizes(float (&o)[15])
{
o[0] = Layout::kFontH1(); o[1] = Layout::kFontH2(); o[2] = Layout::kFontH3();
o[3] = Layout::kFontH4(); o[4] = Layout::kFontH5(); o[5] = Layout::kFontH6();
o[6] = Layout::kFontSubtitle1(); o[7] = Layout::kFontSubtitle2(); o[8] = Layout::kFontBody1();
o[9] = Layout::kFontBody2(); o[10] = Layout::kFontButton(); o[11] = Layout::kFontCaption();
o[12] = Layout::kFontOverline(); o[13] = Layout::kFontButtonSm(); o[14] = Layout::kFontButtonLg();
}
bool Typography::effectiveEmoji() const
{
#ifdef DRAGONX_HAVE_FREETYPE
return color_emoji_;
#else
return false; // no FreeType (osxcross): the flag changes nothing, never rebuild for it
#endif
}
bool Typography::needsRebuild()
{
if (!loaded_) return false;
const uint32_t gen = schema::UI().generation();
if (gen == built_.gen && effectiveEmoji() == built_.emoji) return false; // O(1) per frame in steady state
built_.gen = gen;
float cur[15];
gatherFontSizes(cur);
bool diff = effectiveEmoji() != built_.emoji || Layout::kFontScale() != built_.fontScale;
for (int i = 0; i < 15 && !diff; ++i) diff = cur[i] != built_.sizes[i];
return diff;
}
bool Typography::load(ImGuiIO& io, float dpiScale)
{
if (loaded_) {
@@ -215,6 +247,10 @@ bool Typography::load(ImGuiIO& io, float dpiScale)
// instead of H1 (the first font loaded).
io.FontDefault = fonts_[static_cast<int>(TypeStyle::Body1)];
gatherFontSizes(built_.sizes);
built_.fontScale = Layout::kFontScale();
built_.emoji = effectiveEmoji();
built_.gen = schema::UI().generation();
loaded_ = true;
Layout::setFontAtlasScale(Layout::userFontScale());
DEBUG_LOGF("Typography: Loaded %d font styles (default=Body1, atlasScale=%.2f)\n",
+12
View File
@@ -6,6 +6,7 @@
#include "imgui.h"
#include <string>
#include <cstdint>
namespace dragonx {
namespace ui {
@@ -120,6 +121,12 @@ public:
*/
void setColorEmoji(bool enabled) { color_emoji_ = enabled; }
bool colorEmoji() const { return color_emoji_; }
/**
* @brief True when the schema font sizes / kFontScale / effective emoji style differ from the last build.
* O(1) per frame while the schema generation and emoji style are unchanged (App::preFrame safety net).
*/
bool needsRebuild();
/**
* @brief Get font for a type style
@@ -271,6 +278,11 @@ private:
float dpiScale_ = 1.0f;
bool color_emoji_ = false; // when true + FreeType present, merge the COLR color-emoji font
// Inputs the current atlas was built from (recorded at the end of load(); read by needsRebuild()).
// userFontScale and DPI are deliberately not inputs: they have their own explicit reload paths.
struct BuiltInputs { float sizes[15]; float fontScale; bool emoji; uint32_t gen; } built_{};
bool effectiveEmoji() const;
// Fonts for each type style
ImFont* fonts_[15] = {};