// DragonX Wallet - ImGui Edition // Copyright 2024-2026 The Hush Developers // Released under the GPLv3 #include "skin_manager.h" #include "ui_schema.h" #include "../../util/platform.h" #include "../../resources/embedded_resources.h" #include "../theme.h" #include "../material/color_theme.h" #include "../effects/theme_effects.h" #include "../effects/imgui_acrylic.h" #include #include #include #include #include #include "../../util/logger.h" namespace fs = std::filesystem; namespace dragonx { namespace ui { namespace schema { // ============================================================================ // Singleton // ============================================================================ SkinManager& SkinManager::instance() { static SkinManager s; return s; } // ============================================================================ // Directory helpers // ============================================================================ std::string SkinManager::getBundledSkinsDirectory() { // Bundled skins live in res/themes/ next to the executable fs::path exe_dir = util::getExecutableDirectory(); fs::path themes_dir = exe_dir / "res" / "themes"; if (fs::exists(themes_dir)) { // Update any stale overlay themes from embedded versions int updated = resources::updateBundledThemes(themes_dir.string()); if (updated > 0) DEBUG_LOGF("[SkinManager] Updated %d stale theme(s) in %s\n", updated, themes_dir.string().c_str()); return themes_dir.string(); } // Fallback: current working directory themes_dir = fs::current_path() / "res" / "themes"; if (fs::exists(themes_dir)) { return themes_dir.string(); } // No on-disk themes dir found (single-file Windows distribution). // Extract embedded overlay themes to the config directory. fs::path configDir = util::Platform::getObsidianDragonDir(); fs::path extractedDir = configDir / "bundled-themes"; int extracted = resources::extractBundledThemes(extractedDir.string()); if (extracted > 0) { DEBUG_LOGF("[SkinManager] Extracted %d embedded bundled themes to %s\n", extracted, extractedDir.string().c_str()); } if (fs::exists(extractedDir)) { return extractedDir.string(); } return (exe_dir / "res" / "themes").string(); } std::string SkinManager::getUserSkinsDirectory() { // User themes in ObsidianDragon config directory — folder-based fs::path configDir = util::Platform::getObsidianDragonDir(); return (configDir / "themes").string(); } // ============================================================================ // Scan for skins // ============================================================================ void SkinManager::scanDirectory(const std::string& dir, bool bundled) { if (!fs::exists(dir) || !fs::is_directory(dir)) { DEBUG_LOGF("[SkinManager] Directory does not exist: %s\n", dir.c_str()); return; } DEBUG_LOGF("[SkinManager] Scanning %s directory: %s\n", bundled ? "bundled" : "user", dir.c_str()); if (bundled) { // Bundled skins: all .toml files in res/themes/ except ui.toml // (ui.toml is the base theme and is always loaded as "dragonx") for (const auto& entry : fs::directory_iterator(dir)) { if (!entry.is_regular_file()) continue; fs::path p = entry.path(); if (p.extension() != ".toml") continue; std::string stem = p.stem().string(); // Skip ui.toml - it's the base theme handled separately as "dragonx" if (stem == "ui") continue; // Try to parse and extract metadata toml::table root; try { root = toml::parse_file(p.string()); } catch (...) { DEBUG_LOGF("[SkinManager] Skipping '%s': invalid TOML\n", p.filename().string().c_str()); continue; } auto* theme = root["theme"].as_table(); if (!theme) { DEBUG_LOGF("[SkinManager] Skipping '%s': no [theme] section\n", p.filename().string().c_str()); continue; } SkinInfo info; info.path = p.string(); info.bundled = true; // ID = filename stem (e.g. "dark" from dark.toml) info.id = stem; if (auto name = (*theme)["name"].value()) { info.name = *name; } else { info.name = info.id; } if (auto author = (*theme)["author"].value()) { info.author = *author; } if (auto dark = (*theme)["dark"].value()) { info.dark = *dark; } // Resolve image paths from theme.images (bundled: res/img/) fs::path imgDir = p.parent_path().parent_path() / "img"; std::string bgFilename; std::string logoFilename; if (auto* images = (*theme)["images"].as_table()) { if (auto bg = (*images)["background_image"].value()) { bgFilename = *bg; } if (auto logo = (*images)["logo"].value()) { logoFilename = *logo; } } if (!bgFilename.empty()) { fs::path bgPath = imgDir / bgFilename; if (fs::exists(bgPath)) { info.backgroundImagePath = bgPath.string(); } } if (!logoFilename.empty()) { fs::path logoImgPath = imgDir / logoFilename; if (fs::exists(logoImgPath)) { info.logoPath = logoImgPath.string(); } } skins_.push_back(std::move(info)); } } else { // User themes: each subfolder must contain a theme.toml for (const auto& entry : fs::directory_iterator(dir)) { if (!entry.is_directory()) continue; fs::path themeDir = entry.path(); fs::path themeToml = themeDir / "theme.toml"; if (!fs::exists(themeToml)) { DEBUG_LOGF("[SkinManager] Skipping folder '%s': no theme.toml found\n", themeDir.filename().string().c_str()); continue; } DEBUG_LOGF("[SkinManager] Found theme folder: %s (theme.toml exists)\n", themeDir.filename().string().c_str()); // Validate the theme file auto validation = validateSkinFile(themeToml.string()); // Parse metadata even from invalid themes (so they show in the list) toml::table root; try { root = toml::parse_file(themeToml.string()); } catch (...) { // Still add as invalid SkinInfo info; info.id = themeDir.filename().string(); info.name = info.id; info.path = themeToml.string(); info.directory = themeDir.string(); info.bundled = false; info.valid = false; info.validationError = "Invalid TOML"; skins_.push_back(std::move(info)); continue; } SkinInfo info; info.id = themeDir.filename().string(); info.path = themeToml.string(); info.directory = themeDir.string(); info.bundled = false; info.valid = validation.valid; info.validationError = validation.error; // Extract metadata from theme section if (auto* theme = root["theme"].as_table()) { if (auto name = (*theme)["name"].value()) { info.name = *name; } else { info.name = info.id; } if (auto author = (*theme)["author"].value()) { info.author = *author; } if (auto dark = (*theme)["dark"].value()) { info.dark = *dark; } // Resolve image paths (from TOML) fs::path imgDir = themeDir / "img"; std::string bgFilename; std::string logoFilename; if (auto* images = (*theme)["images"].as_table()) { if (auto bg = (*images)["background_image"].value()) { bgFilename = *bg; } if (auto logo = (*images)["logo"].value()) { logoFilename = *logo; } } // Check if image files exist if (!bgFilename.empty()) { fs::path bgPath = imgDir / bgFilename; if (fs::exists(bgPath)) { info.backgroundImagePath = bgPath.string(); } } if (!logoFilename.empty()) { fs::path logoImgPath = imgDir / logoFilename; if (fs::exists(logoImgPath)) { info.logoPath = logoImgPath.string(); } } } else { info.name = info.id; } skins_.push_back(std::move(info)); } // Also scan for loose .toml files (unified format with [theme.palette]) for (const auto& entry : fs::directory_iterator(dir)) { if (!entry.is_regular_file()) continue; fs::path p = entry.path(); if (p.extension() != ".toml") continue; toml::table root; try { root = toml::parse_file(p.string()); } catch (...) { DEBUG_LOGF("[SkinManager] Skipping '%s': invalid TOML\n", p.filename().string().c_str()); continue; } SkinInfo info; info.path = p.string(); info.id = p.stem().string(); info.bundled = false; // Check for unified format ([theme] with [theme.palette]) auto* theme = root["theme"].as_table(); if (theme) { if (auto name = (*theme)["name"].value()) { info.name = *name; } else { info.name = info.id; } if (auto author = (*theme)["author"].value()) { info.author = *author; } if (auto dark = (*theme)["dark"].value()) { info.dark = *dark; } auto validation = validateSkinFile(p.string()); info.valid = validation.valid; info.validationError = validation.error; // Resolve image paths (look in same directory as the .toml file) fs::path imgDir = p.parent_path(); std::string bgFilename; std::string logoFilename; if (auto* images = (*theme)["images"].as_table()) { if (auto bg = (*images)["background_image"].value()) { bgFilename = *bg; } if (auto logo = (*images)["logo"].value()) { logoFilename = *logo; } } if (!bgFilename.empty()) { fs::path bgPath = imgDir / bgFilename; if (fs::exists(bgPath)) { info.backgroundImagePath = bgPath.string(); } } if (!logoFilename.empty()) { fs::path logoImgPath = imgDir / logoFilename; if (fs::exists(logoImgPath)) { info.logoPath = logoImgPath.string(); } } } else { DEBUG_LOGF("[SkinManager] Skipping '%s': unrecognized TOML format\n", p.filename().string().c_str()); continue; } skins_.push_back(std::move(info)); } } } void SkinManager::refresh() { skins_.clear(); // Scan bundled skins (res/ directory) scanDirectory(getBundledSkinsDirectory(), true); // Scan user skins std::string userDir = getUserSkinsDirectory(); if (fs::exists(userDir)) { scanDirectory(userDir, false); } // Ensure the base "dragonx" theme always appears (it's ui.toml, the main theme). // Other bundled themes are discovered automatically from res/themes/*.toml. { bool found = false; for (const auto& s : skins_) { if (s.id == "dragonx") { found = true; break; } } if (!found) { SkinInfo info; info.id = "dragonx"; info.name = "DragonX"; info.author = "The Hush Developers"; info.dark = true; info.bundled = true; info.valid = true; // Try to set path to ui.toml if it exists fs::path uiPath = fs::path(getBundledSkinsDirectory()) / "ui.toml"; if (fs::exists(uiPath)) { info.path = uiPath.string(); } skins_.push_back(std::move(info)); DEBUG_LOGF("[SkinManager] Injected base theme: dragonx\n"); } } // Sort: "dragonx" first, then bundled grouped by mode (dark then light), then user std::sort(skins_.begin(), skins_.end(), [](const SkinInfo& a, const SkinInfo& b) { // DragonX always first if (a.id == "dragonx") return true; if (b.id == "dragonx") return false; // Bundled before user if (a.bundled != b.bundled) return a.bundled; // Group: dark themes first, then light themes if (a.dark != b.dark) return a.dark; // Alphabetical by name within each group return a.name < b.name; }); DEBUG_LOGF("[SkinManager] Found %zu skins\n", skins_.size()); } // ============================================================================ // Find // ============================================================================ const SkinManager::SkinInfo* SkinManager::findById(const std::string& id) const { for (const auto& skin : skins_) { if (skin.id == id) return &skin; } return nullptr; } // ============================================================================ // Validation // ============================================================================ SkinManager::ValidationResult SkinManager::validateSkinFile(const std::string& path) { ValidationResult result; // 1. Must be valid TOML toml::table root; try { root = toml::parse_file(path); } catch (const toml::parse_error& e) { result.error = std::string("Invalid TOML: ") + e.what(); return result; } // 3. Must contain "theme" table auto* theme = root["theme"].as_table(); if (!theme) { result.error = "Missing or invalid 'theme' section"; return result; } // 4. theme.name must be a non-empty string auto name = (*theme)["name"].value(); if (!name || name->empty()) { result.error = "theme.name must be a non-empty string"; return result; } // 5. theme.palette must exist with at least --primary and --background auto* palette = (*theme)["palette"].as_table(); if (!palette) { result.error = "Missing theme.palette table"; return result; } if (!palette->contains("--primary")) { result.error = "Palette missing required '--primary' color"; return result; } if (!palette->contains("--background")) { result.error = "Palette missing required '--background' color"; return result; } // 6. If globals exists, must be a table if (root.contains("globals") && !root["globals"].is_table()) { result.error = "'globals' must be a table"; return result; } result.valid = true; return result; } // ============================================================================ // Import // ============================================================================ bool SkinManager::importSkin(const std::string& sourcePath) { fs::path srcPath(sourcePath); std::string userDir = getUserSkinsDirectory(); try { fs::create_directories(userDir); } catch (const fs::filesystem_error& e) { DEBUG_LOGF("[SkinManager] Failed to create themes directory: %s\n", e.what()); return false; } if (fs::is_directory(srcPath)) { // Import a theme folder — copy entire folder into themes/ fs::path themeToml = srcPath / "theme.toml"; if (!fs::exists(themeToml)) { DEBUG_LOGF("[SkinManager] Import folder has no theme.toml: %s\n", sourcePath.c_str()); return false; } auto validation = validateSkinFile(themeToml.string()); if (!validation.valid) { DEBUG_LOGF("[SkinManager] Import validation failed: %s\n", validation.error.c_str()); return false; } fs::path destDir = fs::path(userDir) / srcPath.filename(); try { fs::copy(srcPath, destDir, fs::copy_options::recursive | fs::copy_options::overwrite_existing); } catch (const fs::filesystem_error& e) { DEBUG_LOGF("[SkinManager] Failed to copy theme folder: %s\n", e.what()); return false; } DEBUG_LOGF("[SkinManager] Imported theme folder: %s → %s\n", sourcePath.c_str(), destDir.string().c_str()); } else { // Import a single .toml file — create a folder for it auto validation = validateSkinFile(sourcePath); if (!validation.valid) { DEBUG_LOGF("[SkinManager] Import validation failed: %s\n", validation.error.c_str()); return false; } std::string folderName = srcPath.stem().string(); fs::path destDir = fs::path(userDir) / folderName; try { fs::create_directories(destDir); fs::copy_file(srcPath, destDir / "theme.toml", fs::copy_options::overwrite_existing); } catch (const fs::filesystem_error& e) { DEBUG_LOGF("[SkinManager] Failed to import skin file: %s\n", e.what()); return false; } DEBUG_LOGF("[SkinManager] Imported skin file as folder: %s → %s\n", sourcePath.c_str(), destDir.string().c_str()); } refresh(); return true; } // ============================================================================ // Remove // ============================================================================ bool SkinManager::removeSkin(const std::string& id) { const SkinInfo* skin = findById(id); if (!skin) { DEBUG_LOGF("[SkinManager] Skin not found: %s\n", id.c_str()); return false; } if (skin->bundled) { DEBUG_LOGF("[SkinManager] Cannot remove bundled skin: %s\n", id.c_str()); return false; } try { if (!skin->directory.empty() && fs::is_directory(skin->directory)) { // Folder-based theme — remove the entire directory fs::remove_all(skin->directory); } else { // Legacy flat file fs::remove(skin->path); } } catch (const fs::filesystem_error& e) { DEBUG_LOGF("[SkinManager] Failed to remove skin: %s\n", e.what()); return false; } DEBUG_LOGF("[SkinManager] Removed skin: %s\n", id.c_str()); // If we removed the active skin, fall back to default if (activeSkinId_ == id) { setActiveSkin("dragonx"); } refresh(); return true; } // ============================================================================ // Activate skin // ============================================================================ bool SkinManager::setActiveSkin(const std::string& id) { const SkinInfo* skin = findById(id); if (!skin) { DEBUG_LOGF("[SkinManager] Skin not found: %s\n", id.c_str()); return false; } if (!skin->valid) { DEBUG_LOGF("[SkinManager] Skin is invalid: %s (%s)\n", id.c_str(), skin->validationError.c_str()); return false; } bool loaded = false; // For skin files: always reload base layout first, then merge visual // overlay on top. This ensures overlays only change palette + backdrop // while inheriting all layout values from ui.toml. if (!skin->path.empty()) { auto& schema = UISchema::instance(); std::string basePath = schema.basePath(); if (!basePath.empty() || schema.hasEmbeddedBase()) { if (!basePath.empty() && basePath == skin->path) { // Switching back to the base theme: full reload, clear overlay schema.reloadBase(); schema.reapplyColorsToImGui(); loaded = true; } else { // Switching to a non-base skin: reload base then merge overlay if (schema.reloadBase()) { if (schema.mergeOverlayFromFile(skin->path)) { schema.reapplyColorsToImGui(); loaded = true; } } } } // Fallback: no base path or embedded data, full load of skin file if (!loaded && schema.loadFromFile(skin->path)) { schema.reapplyColorsToImGui(); loaded = true; } } else if (!id.empty()) { // Skin with no path (e.g., "dragonx" on Windows with embedded ui.toml): // just reload the base to restore the default theme auto& schema = UISchema::instance(); if (schema.hasEmbeddedBase() || !schema.basePath().empty()) { schema.reloadBase(); schema.reapplyColorsToImGui(); loaded = true; } } // Fall back to built-in C++ themes (works even without theme files) if (!loaded) { if (!SetThemeById(id)) { DEBUG_LOGF("[SkinManager] Failed to load skin: %s\n", id.c_str()); return false; } DEBUG_LOGF("[SkinManager] Loaded via built-in theme fallback: %s\n", id.c_str()); loaded = true; } activeSkinId_ = id; DEBUG_LOGF("[SkinManager] Activated skin: %s (%s)\n", id.c_str(), skin->name.c_str()); // Reload theme visual effects config from the new skin's [effects] section effects::ThemeEffects::instance().loadFromTheme(); // Resolve image paths from UISchema (which parsed [theme] images from the TOML). // The UISchema stores relative filenames (e.g. "backgrounds/texture/drgx_bg.png"); // resolve them to absolute paths using the theme's directory structure. { // Use a mutable reference to update the SkinInfo SkinInfo* mutableSkin = nullptr; for (auto& s : skins_) { if (s.id == id) { mutableSkin = &s; break; } } fs::path imgDir = resolveImgDir(skin); resolveAndFireCallback(mutableSkin, imgDir); } // Force acrylic to re-capture the background with new theme colors/images. // This must happen AFTER images are reloaded so the next frame renders the // updated background before capture. effects::ImGuiAcrylic::InvalidateCapture(); return true; } void SkinManager::resolveAndReloadImages(const std::string& skinId, const std::string& tomlPath) { // Find the skin and update its image paths from the current UISchema values SkinInfo* skin = nullptr; for (auto& s : skins_) { if (s.id == skinId) { skin = &s; break; } } fs::path imgDir = resolveImgDir(skin); resolveAndFireCallback(skin, imgDir); } // --------------------------------------------------------------------------- // Gradient mode // --------------------------------------------------------------------------- void SkinManager::setGradientMode(bool enabled) { if (gradientMode_ == enabled) return; gradientMode_ = enabled; // Re-resolve + reload for the currently active skin const SkinInfo* skin = findById(activeSkinId_); if (!skin) return; SkinInfo* mutableSkin = nullptr; for (auto& s : skins_) { if (s.id == activeSkinId_) { mutableSkin = &s; break; } } fs::path imgDir = resolveImgDir(skin); resolveAndFireCallback(mutableSkin, imgDir); effects::ImGuiAcrylic::InvalidateCapture(); } // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- fs::path SkinManager::resolveImgDir(const SkinInfo* skin) const { if (!skin) { return fs::path(getBundledSkinsDirectory()).parent_path() / "img"; } if (skin->bundled) { fs::path themeDir = skin->path.empty() ? fs::path(getBundledSkinsDirectory()) : fs::path(skin->path).parent_path(); return themeDir.parent_path() / "img"; } if (!skin->directory.empty()) { return fs::path(skin->directory) / "img"; } if (!skin->path.empty()) { return fs::path(skin->path).parent_path(); } return fs::path(getBundledSkinsDirectory()).parent_path() / "img"; } std::string SkinManager::resolveGradientBg(const std::string& bgFilename, const fs::path& imgDir, bool isDark) const { // Given bgFilename like "backgrounds/texture/drgx_bg.png", // look for "backgrounds/gradient/gradient_drgx_bg.png". fs::path bgRel(bgFilename); std::string stem = bgRel.stem().string(); // "drgx_bg" std::string ext = bgRel.extension().string(); // ".png" // Build the gradient candidate: backgrounds/gradient/gradient_ std::string gradientRel = "backgrounds/gradient/gradient_" + stem + ext; fs::path gradientPath = imgDir / gradientRel; if (fs::exists(gradientPath)) { return gradientPath.string(); } // Fallback: dark_gradient.png or light_gradient.png std::string fallbackName = isDark ? "dark_gradient.png" : "light_gradient.png"; std::string fallbackRel = "backgrounds/gradient/" + fallbackName; fs::path fallbackPath = imgDir / fallbackRel; if (fs::exists(fallbackPath)) { return fallbackPath.string(); } // Last resort: pass the relative gradient filename for embedded lookup (Windows) return gradientRel; } void SkinManager::resolveAndFireCallback(SkinInfo* skin, const fs::path& imgDir) { auto& schema = UISchema::instance(); std::string bgFilename = schema.backgroundImagePath(); std::string logoFilename = schema.logoImagePath(); std::string resolvedBg; std::string resolvedLogo; if (!bgFilename.empty()) { if (gradientMode_) { resolvedBg = resolveGradientBg(bgFilename, imgDir, schema.isDarkTheme()); } else { fs::path bgPath = imgDir / bgFilename; if (fs::exists(bgPath)) { resolvedBg = bgPath.string(); } else { resolvedBg = bgFilename; } } } if (!logoFilename.empty()) { fs::path logoPath = imgDir / logoFilename; if (fs::exists(logoPath)) { resolvedLogo = logoPath.string(); } else { resolvedLogo = logoFilename; } } if (skin) { skin->backgroundImagePath = resolvedBg; skin->logoPath = resolvedLogo; } DEBUG_LOGF("[SkinManager] Resolved images (gradient=%s): bg='%s', logo='%s'\n", gradientMode_ ? "on" : "off", resolvedBg.c_str(), resolvedLogo.c_str()); if (imageReloadCb_) { imageReloadCb_(resolvedBg, resolvedLogo); } } } // namespace schema } // namespace ui } // namespace dragonx