diff --git a/src/app.cpp b/src/app.cpp index ef70901..fb4d03f 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -348,6 +348,49 @@ private: 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(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(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(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() { DEBUG_LOGF("Initializing ObsidianDragon...\n"); @@ -363,9 +406,6 @@ bool App::init() if (!settings_->load()) { 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 if (settings_->needsUpgradeSave()) { 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_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 util::Platform::ensureObsidianDragonSetup(); @@ -442,43 +467,7 @@ bool App::init() // Initialize unified UI schema (loads TOML, drives all layout values) { - 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(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(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(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 - } + loadBaseUiSchema(); // no-op when main() already loaded it before the first font build // Initialize SkinManager and activate saved skin auto& skinMgr = ui::schema::SkinManager::instance(); @@ -685,6 +674,16 @@ void App::preFrame() typo.reload(io, typo.getDpiScale()); 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 { diff --git a/src/app.h b/src/app.h index 88de215..8402d70 100644 --- a/src/app.h +++ b/src/app.h @@ -277,6 +277,9 @@ public: // 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. 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). wallet::LiteWalletController* liteWallet() { return lite_wallet_.get(); } // HushChat service (identity + in-memory message store); the Chat tab reads its store. diff --git a/src/main.cpp b/src/main.cpp index f20e33e..439129f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -978,9 +978,14 @@ int main(int argc, char* argv[]) // 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. 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; - 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 sh = earlySettings.getWindowHeight(); 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 // --------------------------------------------------------------- diff --git a/src/ui/material/typography.cpp b/src/ui/material/typography.cpp index 900515d..42eab86 100644 --- a/src/ui/material/typography.cpp +++ b/src/ui/material/typography.cpp @@ -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(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", diff --git a/src/ui/material/typography.h b/src/ui/material/typography.h index 8667080..817500f 100644 --- a/src/ui/material/typography.h +++ b/src/ui/material/typography.h @@ -6,6 +6,7 @@ #include "imgui.h" #include +#include 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] = {};