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:
+54
-55
@@ -348,6 +348,49 @@ private:
|
|||||||
App& app_;
|
App& app_;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
bool App::loadBaseUiSchema()
|
||||||
|
{
|
||||||
|
if (ui::schema::UISchema::instance().isLoaded()) return true;
|
||||||
|
std::string schemaPath = util::Platform::getExecutableDirectory() + "/res/themes/ui.toml";
|
||||||
|
bool loaded = false;
|
||||||
|
|
||||||
|
#if HAS_EMBEDDED_UI_TOML
|
||||||
|
// If on-disk ui.toml exists but differs in size from the embedded
|
||||||
|
// version, a newer wallet binary is running against stale theme
|
||||||
|
// files. Overwrite the on-disk copy so layout matches the binary.
|
||||||
|
if (std::filesystem::exists(schemaPath)) {
|
||||||
|
std::error_code ec;
|
||||||
|
auto diskSize = std::filesystem::file_size(schemaPath, ec);
|
||||||
|
if (!ec && diskSize != static_cast<std::uintmax_t>(embedded::ui_toml_size)) {
|
||||||
|
DEBUG_LOGF("[INFO] ui.toml on disk (%ju bytes) differs from embedded (%zu bytes) — updating\n",
|
||||||
|
(uintmax_t)diskSize, embedded::ui_toml_size);
|
||||||
|
std::ofstream ofs(schemaPath, std::ios::binary | std::ios::trunc);
|
||||||
|
if (ofs.is_open()) {
|
||||||
|
ofs.write(reinterpret_cast<const char*>(embedded::ui_toml_data),
|
||||||
|
embedded::ui_toml_size);
|
||||||
|
ofs.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
if (std::filesystem::exists(schemaPath)) {
|
||||||
|
loaded = ui::schema::UISchema::instance().loadFromFile(schemaPath);
|
||||||
|
}
|
||||||
|
// Fallback: load from build-time embedded data when file not on disk
|
||||||
|
// (e.g., single-file Windows distribution without res/ directory)
|
||||||
|
if (!loaded) {
|
||||||
|
#if HAS_EMBEDDED_UI_TOML
|
||||||
|
std::string embedded(reinterpret_cast<const char*>(embedded::ui_toml_data),
|
||||||
|
embedded::ui_toml_size);
|
||||||
|
ui::schema::UISchema::instance().loadFromString(embedded, "embedded");
|
||||||
|
#else
|
||||||
|
DEBUG_LOGF("Warning: ui.toml not found at %s and no embedded fallback\n", schemaPath.c_str());
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
return ui::schema::UISchema::instance().isLoaded();
|
||||||
|
}
|
||||||
|
|
||||||
bool App::init()
|
bool App::init()
|
||||||
{
|
{
|
||||||
DEBUG_LOGF("Initializing ObsidianDragon...\n");
|
DEBUG_LOGF("Initializing ObsidianDragon...\n");
|
||||||
@@ -363,9 +406,6 @@ bool App::init()
|
|||||||
if (!settings_->load()) {
|
if (!settings_->load()) {
|
||||||
DEBUG_LOGF("Warning: Could not load settings, using defaults\n");
|
DEBUG_LOGF("Warning: Could not load settings, using defaults\n");
|
||||||
}
|
}
|
||||||
// The initial font atlas is built (in main, before App renders) with monochrome emoji. If the saved
|
|
||||||
// setting wants color, request a rebuild so the first preFrame switches to the FreeType color atlas.
|
|
||||||
if (settings_->getChatEmojiColor()) font_rebuild_requested_ = true;
|
|
||||||
// On upgrade (version mismatch), re-save to persist new defaults + current version
|
// On upgrade (version mismatch), re-save to persist new defaults + current version
|
||||||
if (settings_->needsUpgradeSave()) {
|
if (settings_->needsUpgradeSave()) {
|
||||||
DEBUG_LOGF("[INFO] Wallet upgraded — re-saving settings with new defaults\n");
|
DEBUG_LOGF("[INFO] Wallet upgraded — re-saving settings with new defaults\n");
|
||||||
@@ -392,21 +432,6 @@ bool App::init()
|
|||||||
ui::ConsoleTab::s_line_text_color_enabled = settings_->getConsoleTextColor();
|
ui::ConsoleTab::s_line_text_color_enabled = settings_->getConsoleTextColor();
|
||||||
ui::ConsoleTab::s_console_zoom = settings_->getConsoleZoom();
|
ui::ConsoleTab::s_console_zoom = settings_->getConsoleZoom();
|
||||||
|
|
||||||
// Apply saved user font scale so fonts are correct on first reload
|
|
||||||
{
|
|
||||||
float fs = settings_->getFontScale();
|
|
||||||
if (fs > 1.0f) {
|
|
||||||
ui::Layout::setUserFontScale(fs);
|
|
||||||
// Fonts were loaded at default scale in Init(); rebuild now.
|
|
||||||
auto& typo = ui::material::Typography::instance();
|
|
||||||
ImGuiIO& io = ImGui::GetIO();
|
|
||||||
typo.reload(io, typo.getDpiScale());
|
|
||||||
// Consume the flag so App::update() doesn't double-reload
|
|
||||||
ui::Layout::consumeUserFontReload();
|
|
||||||
DEBUG_LOGF("App: Applied saved font scale %.1fx\n", fs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure ObsidianDragon config directory and template files exist
|
// Ensure ObsidianDragon config directory and template files exist
|
||||||
util::Platform::ensureObsidianDragonSetup();
|
util::Platform::ensureObsidianDragonSetup();
|
||||||
|
|
||||||
@@ -442,43 +467,7 @@ bool App::init()
|
|||||||
|
|
||||||
// Initialize unified UI schema (loads TOML, drives all layout values)
|
// Initialize unified UI schema (loads TOML, drives all layout values)
|
||||||
{
|
{
|
||||||
std::string schemaPath = util::Platform::getExecutableDirectory() + "/res/themes/ui.toml";
|
loadBaseUiSchema(); // no-op when main() already loaded it before the first font build
|
||||||
bool loaded = false;
|
|
||||||
|
|
||||||
#if HAS_EMBEDDED_UI_TOML
|
|
||||||
// If on-disk ui.toml exists but differs in size from the embedded
|
|
||||||
// version, a newer wallet binary is running against stale theme
|
|
||||||
// files. Overwrite the on-disk copy so layout matches the binary.
|
|
||||||
if (std::filesystem::exists(schemaPath)) {
|
|
||||||
std::error_code ec;
|
|
||||||
auto diskSize = std::filesystem::file_size(schemaPath, ec);
|
|
||||||
if (!ec && diskSize != static_cast<std::uintmax_t>(embedded::ui_toml_size)) {
|
|
||||||
DEBUG_LOGF("[INFO] ui.toml on disk (%ju bytes) differs from embedded (%zu bytes) — updating\n",
|
|
||||||
(uintmax_t)diskSize, embedded::ui_toml_size);
|
|
||||||
std::ofstream ofs(schemaPath, std::ios::binary | std::ios::trunc);
|
|
||||||
if (ofs.is_open()) {
|
|
||||||
ofs.write(reinterpret_cast<const char*>(embedded::ui_toml_data),
|
|
||||||
embedded::ui_toml_size);
|
|
||||||
ofs.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
if (std::filesystem::exists(schemaPath)) {
|
|
||||||
loaded = ui::schema::UISchema::instance().loadFromFile(schemaPath);
|
|
||||||
}
|
|
||||||
// Fallback: load from build-time embedded data when file not on disk
|
|
||||||
// (e.g., single-file Windows distribution without res/ directory)
|
|
||||||
if (!loaded) {
|
|
||||||
#if HAS_EMBEDDED_UI_TOML
|
|
||||||
std::string embedded(reinterpret_cast<const char*>(embedded::ui_toml_data),
|
|
||||||
embedded::ui_toml_size);
|
|
||||||
ui::schema::UISchema::instance().loadFromString(embedded, "embedded");
|
|
||||||
#else
|
|
||||||
DEBUG_LOGF("Warning: ui.toml not found at %s and no embedded fallback\n", schemaPath.c_str());
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize SkinManager and activate saved skin
|
// Initialize SkinManager and activate saved skin
|
||||||
auto& skinMgr = ui::schema::SkinManager::instance();
|
auto& skinMgr = ui::schema::SkinManager::instance();
|
||||||
@@ -685,6 +674,16 @@ void App::preFrame()
|
|||||||
typo.reload(io, typo.getDpiScale());
|
typo.reload(io, typo.getDpiScale());
|
||||||
DEBUG_LOGF("App: Font atlas rebuilt on request (chat emoji style)\n");
|
DEBUG_LOGF("App: Font atlas rebuilt on request (chat emoji style)\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Safety net: schema font sizes (e.g. a user skin overlay defining [fonts]) or the emoji style differ from what the
|
||||||
|
// atlas was built with. Steady state is one integer compare; covers load-order skew without unconditional rebuilds.
|
||||||
|
// Build-expanded bundled skins carry an identical copy of [fonts] (expand_themes.py), so needsRebuild sees no size
|
||||||
|
// change; user skins with their own [fonts] trigger exactly one rebuild.
|
||||||
|
if (ui::material::Typography::instance().needsRebuild()) {
|
||||||
|
auto& typo = ui::material::Typography::instance();
|
||||||
|
typo.reload(io, typo.getDpiScale());
|
||||||
|
DEBUG_LOGF("App: Font atlas rebuilt (schema font sizes / emoji style changed)\n");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|||||||
@@ -277,6 +277,9 @@ public:
|
|||||||
// Request a font-atlas rebuild before the next frame (e.g. after toggling color emoji). Handled in
|
// Request a font-atlas rebuild before the next frame (e.g. after toggling color emoji). Handled in
|
||||||
// preFrame() via Typography::reload — safe to call from UI code mid-frame.
|
// preFrame() via Typography::reload — safe to call from UI code mid-frame.
|
||||||
void requestFontRebuild() { font_rebuild_requested_ = true; }
|
void requestFontRebuild() { font_rebuild_requested_ = true; }
|
||||||
|
/// Load the base ui.toml (disk, synced to the embedded copy when sizes differ; embedded fallback). Idempotent.
|
||||||
|
/// main() calls it before the first Typography::load so the atlas is built once at the final sizes.
|
||||||
|
static bool loadBaseUiSchema();
|
||||||
// Lite wallet controller (non-null only in lite builds with a linked backend).
|
// Lite wallet controller (non-null only in lite builds with a linked backend).
|
||||||
wallet::LiteWalletController* liteWallet() { return lite_wallet_.get(); }
|
wallet::LiteWalletController* liteWallet() { return lite_wallet_.get(); }
|
||||||
// HushChat service (identity + in-memory message store); the Chat tab reads its store.
|
// HushChat service (identity + in-memory message store); the Chat tab reads its store.
|
||||||
|
|||||||
+12
-1
@@ -978,9 +978,14 @@ int main(int argc, char* argv[])
|
|||||||
// Load saved window size early (before window creation, before App exists).
|
// Load saved window size early (before window creation, before App exists).
|
||||||
// Settings are loaded again later in app.init(), this is just for the size.
|
// Settings are loaded again later in app.init(), this is just for the size.
|
||||||
int savedWinW = 1200, savedWinH = 720;
|
int savedWinW = 1200, savedWinH = 720;
|
||||||
|
float earlyFontScale = 1.0f; // consumed below, before the first (and only) font-atlas build
|
||||||
|
bool earlyEmojiColor = false;
|
||||||
{
|
{
|
||||||
dragonx::config::Settings earlySettings;
|
dragonx::config::Settings earlySettings;
|
||||||
if (earlySettings.load()) {
|
const bool earlyLoaded = earlySettings.load();
|
||||||
|
earlyFontScale = earlySettings.getFontScale(); // defaults when load failed, same as App::init sees
|
||||||
|
earlyEmojiColor = earlySettings.getChatEmojiColor();
|
||||||
|
if (earlyLoaded) {
|
||||||
int sw = earlySettings.getWindowWidth();
|
int sw = earlySettings.getWindowWidth();
|
||||||
int sh = earlySettings.getWindowHeight();
|
int sh = earlySettings.getWindowHeight();
|
||||||
if (sw >= 1024 && sh >= 720) {
|
if (sw >= 1024 && sh >= 720) {
|
||||||
@@ -994,6 +999,12 @@ int main(int argc, char* argv[])
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Everything the first Typography::load() reads must be final BEFORE InitImGui (DX11 and GL paths below):
|
||||||
|
// ui.toml sizes, the saved font scale (non-flagging setter so the first preFrame does not reload), emoji style.
|
||||||
|
dragonx::App::loadBaseUiSchema();
|
||||||
|
dragonx::ui::Layout::setUserFontScaleVisual(earlyFontScale);
|
||||||
|
dragonx::ui::material::Typography::instance().setColorEmoji(earlyEmojiColor);
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// Platform-specific window creation and graphics context
|
// Platform-specific window creation and graphics context
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
|
|||||||
@@ -118,6 +118,38 @@ bool Typography::reload(ImGuiIO& io, float dpiScale)
|
|||||||
return load(io, 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)
|
bool Typography::load(ImGuiIO& io, float dpiScale)
|
||||||
{
|
{
|
||||||
if (loaded_) {
|
if (loaded_) {
|
||||||
@@ -215,6 +247,10 @@ bool Typography::load(ImGuiIO& io, float dpiScale)
|
|||||||
// instead of H1 (the first font loaded).
|
// instead of H1 (the first font loaded).
|
||||||
io.FontDefault = fonts_[static_cast<int>(TypeStyle::Body1)];
|
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;
|
loaded_ = true;
|
||||||
Layout::setFontAtlasScale(Layout::userFontScale());
|
Layout::setFontAtlasScale(Layout::userFontScale());
|
||||||
DEBUG_LOGF("Typography: Loaded %d font styles (default=Body1, atlasScale=%.2f)\n",
|
DEBUG_LOGF("Typography: Loaded %d font styles (default=Body1, atlasScale=%.2f)\n",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
#include "imgui.h"
|
#include "imgui.h"
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace ui {
|
namespace ui {
|
||||||
@@ -120,6 +121,12 @@ public:
|
|||||||
*/
|
*/
|
||||||
void setColorEmoji(bool enabled) { color_emoji_ = enabled; }
|
void setColorEmoji(bool enabled) { color_emoji_ = enabled; }
|
||||||
bool colorEmoji() const { return color_emoji_; }
|
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
|
* @brief Get font for a type style
|
||||||
@@ -271,6 +278,11 @@ private:
|
|||||||
float dpiScale_ = 1.0f;
|
float dpiScale_ = 1.0f;
|
||||||
bool color_emoji_ = false; // when true + FreeType present, merge the COLR color-emoji font
|
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
|
// Fonts for each type style
|
||||||
ImFont* fonts_[15] = {};
|
ImFont* fonts_[15] = {};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user