feat(ui): themed DragonX logo — runtime-rasterized SVG recolored per skin

Replace the shared app logo (Overview header, first-run wizard, lock screen) with
the DragonX mark rendered from res/img/logos/logo_dragonx.svg, recolored to each
theme: the dragon body takes the theme accent (Primary()), the inner detail stays
light. Vendor nanosvg (memononen, zlib/public-domain) in libs/nanosvg/ and add
util::LoadTextureFromSvg (parse a copy, recolor shapes by luminance, rasterize to
RGBA, upload via the existing CreateRawTexture — GL + DX11). The SVG is embedded as
a string (src/embedded/logo_dragonx_svg.h) so it's available in every build.

The load moves into App::ensureLogoTexture(), called at the top of render() BEFORE
the wizard/lock early-returns (so those screens get the logo too, which they never
did before), and re-rasterizes when the accent or the dark/light variant changes.
The per-skin PNG remains a fallback if rasterization ever fails; logo-texture
lifetime is now freed on replace + on theme switch (was leaked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 13:28:51 -05:00
parent 267d839d5b
commit 17525003c5
9 changed files with 4991 additions and 58 deletions

View File

@@ -555,6 +555,7 @@ set(APP_SOURCES
src/util/platform.cpp
src/util/payment_uri.cpp
src/util/texture_loader.cpp
src/util/svg_texture.cpp
src/util/noise_texture.cpp
src/daemon/embedded_daemon.cpp
src/daemon/seed_wallet_creator.cpp

3278
libs/nanosvg/nanosvg.h Normal file

File diff suppressed because it is too large Load Diff

1472
libs/nanosvg/nanosvgrast.h Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<style>
.cls-1 {
fill: #fff;
}
.cls-2 {
fill: #d82652;
}
</style>
</defs>
<path class="cls-2" d="M103.98,128s-6.29-24.7-18.73-34.43c-8.53-8.03-15.63-16.49-21.25-24.16-5.62,7.68-12.72,16.17-21.25,24.16-12.4,9.74-18.73,34.43-18.73,34.43-2.38-24.34,7.85-35.82,12.87-41.29,7.82-8.5,15.31-16.56,21.75-25.02-7.64-11.44-11.41-19.72-11.41-19.72-.89-3.27-3.84-6.64-3.84-6.64,6.08-8.1-1.6-16.98-1.6-16.98,5.79-5.62,6.71-10.02,8.32-18.34-1.96,22.35,4.02,39.09,13.93,54.12,9.84-15.03,15.81-31.77,13.86-54.12,1.6,8.35,2.52,12.72,8.32,18.34,0,0-7.68,8.88-1.6,16.98,0,0-2.95,3.38-3.84,6.64,0,0-3.77,8.28-11.37,19.72,6.43,8.45,13.97,16.56,21.75,25.02,4.97,5.47,15.21,16.95,12.83,41.29h0Z"/>
<g>
<path class="cls-1" d="M55.33,61.62c-3.55,4.55-7.39,8.99-11.44,13.47-5.29-4.48-11.23-5.33-11.23-5.33,22.92-7.82,2.81-15.17.28-16.31C9.28,42.78,9.1,14.78,9.1,14.78c11.51,34.15,36.42,32.3,36.42,32.3.35-.21.67-.46.92-.71,1.64,3.27,4.58,8.67,8.88,15.24h0Z"/>
<g>
<path class="cls-1" d="M68.62,40.41c-1.35,2.98-2.91,5.83-4.62,8.63-1.71-2.81-3.23-5.69-4.62-8.63,1.74-3.45,4.62-20.58,4.62-20.58,0,0,2.88,17.13,4.62,20.58Z"/>
<path class="cls-1" d="M76.01,97.93l-3.48,2.34s-.1-4.44-3.52-1.84c-.42.32-2.38,2.21-.03,4.27,0,0-4.05,4.08-4.97,8.21-.92-4.12-4.97-8.21-4.97-8.21,2.34-2.06.39-3.95-.03-4.27-3.41-2.59-3.52,1.84-3.52,1.84l-3.48-2.34c.28-3.55.1-6.68-.46-9.42,4.69-4.94,8.85-9.88,12.47-14.61,3.66,4.72,7.78,9.67,12.47,14.61-.57,2.74-.75,5.86-.46,9.42Z"/>
<path class="cls-1" d="M95.34,69.76s-5.94.85-11.23,5.33c-4.02-4.48-7.89-8.92-11.44-13.47,4.3-6.57,7.25-11.98,8.88-15.24.25.25.57.5.92.71,0,0,24.91,1.84,36.42-32.3,0,0-.18,28-23.84,38.66-2.52,1.14-22.64,8.5.28,16.31h0Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -73,6 +73,9 @@
#include "util/text_format.h"
#include "util/payment_uri.h"
#include "util/texture_loader.h"
#include "util/svg_texture.h"
#include "ui/material/colors.h"
#include "logo_dragonx_svg.h"
#include "util/bootstrap.h"
#include "util/secure_vault.h"
#include "resources/embedded_resources.h"
@@ -1353,6 +1356,70 @@ void App::handleGlobalShortcuts()
}
}
// Load (and recolor per theme) the DragonX header logo texture. Called at the top of render() — BEFORE
// the first-run-wizard / lock-screen paths — so every screen shows the mark. The SVG is recolored to the
// theme accent, so it re-rasterizes whenever the accent or the dark↔light variant changes.
void App::ensureLogoTexture()
{
const bool wantDark = ui::material::IsDarkTheme();
const ImU32 logoAccent = ui::material::Primary();
if (logo_loaded_ && wantDark == logo_is_dark_variant_ && logoAccent == logo_accent_)
return; // already current
logo_loaded_ = true;
logo_is_dark_variant_ = wantDark;
logo_accent_ = logoAccent;
if (logo_tex_) util::DestroyTexture(logo_tex_); // free the previous texture before replacing
logo_tex_ = 0; logo_w_ = 0; logo_h_ = 0;
// 0) DragonX mark — rasterize the embedded SVG recolored to the theme (body = accent, detail = white)
// at ~2x the 128px viewBox for crisp downscaling. This is the branding on every skin; the per-skin
// PNG path below is only a fallback if rasterization ever fails.
if (util::LoadTextureFromSvg(embedded::kLogoDragonXSvg, 256, logoAccent,
IM_COL32(255, 255, 255, 255), &logo_tex_, &logo_w_, &logo_h_)) {
DEBUG_LOGF("Rendered DragonX SVG logo (%dx%d, accent %08X)\n", logo_w_, logo_h_, logoAccent);
return;
}
// 1) Fallback — theme-override logo from the active skin
const auto* activeSkin = ui::schema::SkinManager::instance().findById(
ui::schema::SkinManager::instance().activeSkinId());
std::string logoPath;
if (activeSkin && !activeSkin->logoPath.empty()) {
logoPath = activeSkin->logoPath;
} else {
// 2) Read icon filename from ui.toml (dark/light variant)
auto iconElem = ui::schema::UI().drawElement("components.main-window", "header-icon");
const char* iconKey = wantDark ? "icon-dark" : "icon-light";
auto it = iconElem.extraColors.find(iconKey);
std::string iconFile;
if (it != iconElem.extraColors.end() && !it->second.empty())
iconFile = it->second;
else
iconFile = wantDark ? "logos/logo_ObsidianDragon_dark.png" : "logos/logo_ObsidianDragon_light.png";
logoPath = util::getExecutableDirectory() + "/res/img/" + iconFile;
}
// Only attempt the disk read when the file is actually present (dev build / theme drop-in). The
// portable single-file build has no res/img/ beside it, so skip straight to the embedded copy.
std::error_code logoEc;
if (std::filesystem::exists(logoPath, logoEc) &&
util::LoadTextureFromFile(logoPath.c_str(), &logo_tex_, &logo_w_, &logo_h_)) {
DEBUG_LOGF("Loaded header logo from %s (%dx%d)\n", logoPath.c_str(), logo_w_, logo_h_);
} else {
std::string embeddedName = std::filesystem::path(logoPath).filename().string();
const auto* logoRes = resources::getEmbeddedResource(embeddedName);
if (!logoRes || !logoRes->data || logoRes->size == 0)
logoRes = resources::getEmbeddedResource(resources::RESOURCE_LOGO);
if (logoRes && logoRes->data && logoRes->size > 0) {
if (util::LoadTextureFromMemory(logoRes->data, logoRes->size, &logo_tex_, &logo_w_, &logo_h_))
DEBUG_LOGF("Loaded header logo from embedded: %s (%dx%d)\n", embeddedName.c_str(), logo_w_, logo_h_);
else
DEBUG_LOGF("Note: Failed to decode embedded logo (text-only header)\n");
} else {
DEBUG_LOGF("Note: Header logo not found at %s (text-only header)\n", logoPath.c_str());
}
}
}
void App::render()
{
// Advance the screenshot sweep FIRST — before the first-run-wizard early-return below — so the
@@ -1360,6 +1427,9 @@ void App::render()
// (Pins current_page_ too, before the sidebar reads it further down.)
updateScreenshotSweep();
// DragonX logo — load/recolor before the wizard/lock early-returns so every screen shows it.
ensureLogoTexture();
// First-run wizard gate — blocks all normal UI
if (wizard_phase_ != WizardPhase::None && wizard_phase_ != WizardPhase::Done) {
renderFirstRunWizard();
@@ -1560,63 +1630,7 @@ void App::render()
sbStatus.miningActive = state_.mining.generate || state_.pool_mining.xmrig_running;
sbStatus.chatUnreadCount = chatUnreadCount(); // unread badge on the Chat nav item (Q1)
// Load logo texture lazily on first frame (or after theme change)
// Also reload when dark↔light mode changes so the correct variant shows
{
bool wantDark = ui::material::IsDarkTheme();
if (!logo_loaded_ || (wantDark != logo_is_dark_variant_)) {
logo_loaded_ = true;
logo_is_dark_variant_ = wantDark;
logo_tex_ = 0; logo_w_ = 0; logo_h_ = 0;
// 1) Check for theme-override logo from active skin
const auto* activeSkin = ui::schema::SkinManager::instance().findById(
ui::schema::SkinManager::instance().activeSkinId());
std::string logoPath;
if (activeSkin && !activeSkin->logoPath.empty()) {
logoPath = activeSkin->logoPath;
} else {
// 2) Read icon filename from ui.toml (dark/light variant)
auto iconElem = ui::schema::UI().drawElement("components.main-window", "header-icon");
const char* iconKey = wantDark ? "icon-dark" : "icon-light";
auto it = iconElem.extraColors.find(iconKey);
std::string iconFile;
if (it != iconElem.extraColors.end() && !it->second.empty()) {
iconFile = it->second;
} else {
// Fallback filenames
iconFile = wantDark ? "logos/logo_ObsidianDragon_dark.png" : "logos/logo_ObsidianDragon_light.png";
}
logoPath = util::getExecutableDirectory() + "/res/img/" + iconFile;
}
// Only attempt the disk read when the file is actually present (dev build / theme drop-in).
// The portable single-file build has no res/img/ beside it, so skip straight to the
// embedded copy instead of logging a spurious "failed to read".
std::error_code logoEc;
if (std::filesystem::exists(logoPath, logoEc) &&
util::LoadTextureFromFile(logoPath.c_str(), &logo_tex_, &logo_w_, &logo_h_)) {
DEBUG_LOGF("Loaded header logo from %s (%dx%d)\n", logoPath.c_str(), logo_w_, logo_h_);
} else {
// Try embedded data fallback — use actual filename from path
// so light/dark variants resolve correctly on Windows single-file
std::string embeddedName = std::filesystem::path(logoPath).filename().string();
const auto* logoRes = resources::getEmbeddedResource(embeddedName);
if (!logoRes || !logoRes->data || logoRes->size == 0) {
// Final fallback: try the default dark logo constant
logoRes = resources::getEmbeddedResource(resources::RESOURCE_LOGO);
}
if (logoRes && logoRes->data && logoRes->size > 0) {
if (util::LoadTextureFromMemory(logoRes->data, logoRes->size, &logo_tex_, &logo_w_, &logo_h_)) {
DEBUG_LOGF("Loaded header logo from embedded: %s (%dx%d)\n", embeddedName.c_str(), logo_w_, logo_h_);
} else {
DEBUG_LOGF("Note: Failed to decode embedded logo (text-only header)\n");
}
} else {
DEBUG_LOGF("Note: Header logo not found at %s (text-only header)\n", logoPath.c_str());
}
}
}
}
// (DragonX logo is loaded at the top of render() via ensureLogoTexture().)
// Load coin logo texture lazily (DragonX currency icon for balance tab)
if (!coin_logo_loaded_) {
@@ -2429,7 +2443,9 @@ void App::reloadThemeImages(const std::string& bgPath, const std::string& logoPa
gradient_tex_ = 0;
}
// Reset logo loaded flags — will reload on next render frame
// Reset logo loaded flags — will reload on next render frame (ensureLogoTexture re-rasterizes the
// SVG for the new theme). Free the current texture first so a theme switch doesn't leak it.
if (logo_tex_) util::DestroyTexture(logo_tex_);
logo_loaded_ = false;
logo_tex_ = 0;
logo_w_ = 0;

View File

@@ -486,6 +486,10 @@ public:
*/
void reloadThemeImages(const std::string& bgPath, const std::string& logoPath);
// Load / recolor-per-theme the DragonX header logo (SVG rasterized to the theme accent). Called at
// the top of render() so the wizard, lock screen, and main header all show it.
void ensureLogoTexture();
// Wizard / first-run
WizardPhase getWizardPhase() const { return wizard_phase_; }
bool isFirstRun() const;
@@ -1080,6 +1084,7 @@ private:
int logo_h_ = 0;
bool logo_loaded_ = false;
bool logo_is_dark_variant_ = true; // tracks which variant is currently loaded
ImU32 logo_accent_ = 0; // theme accent the SVG logo was last rasterized with (re-render on change)
// Coin logo texture (DragonX currency icon, separate from wallet branding)
ImTextureID coin_logo_tex_ = 0;

View File

@@ -0,0 +1,39 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Embedded source of the DragonX mark (res/img/logos/logo_dragonx.svg). Kept as a string so the logo
// is available in every build (dev + portable single-file) with no resource-pipeline or file dependency.
// It is rasterized + recolored per theme at runtime (see util/svg_texture.*). Two fills: the crimson
// body (.cls-2 #d82652) becomes the theme accent; the white detail (.cls-1 #fff) becomes the light tone.
#pragma once
// Global `embedded` namespace to match the generated embedded resources (embedded::ui_toml_data, etc.).
namespace embedded {
inline constexpr const char* kLogoDragonXSvg = R"SVG(<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<style>
.cls-1 {
fill: #fff;
}
.cls-2 {
fill: #d82652;
}
</style>
</defs>
<path class="cls-2" d="M103.98,128s-6.29-24.7-18.73-34.43c-8.53-8.03-15.63-16.49-21.25-24.16-5.62,7.68-12.72,16.17-21.25,24.16-12.4,9.74-18.73,34.43-18.73,34.43-2.38-24.34,7.85-35.82,12.87-41.29,7.82-8.5,15.31-16.56,21.75-25.02-7.64-11.44-11.41-19.72-11.41-19.72-.89-3.27-3.84-6.64-3.84-6.64,6.08-8.1-1.6-16.98-1.6-16.98,5.79-5.62,6.71-10.02,8.32-18.34-1.96,22.35,4.02,39.09,13.93,54.12,9.84-15.03,15.81-31.77,13.86-54.12,1.6,8.35,2.52,12.72,8.32,18.34,0,0-7.68,8.88-1.6,16.98,0,0-2.95,3.38-3.84,6.64,0,0-3.77,8.28-11.37,19.72,6.43,8.45,13.97,16.56,21.75,25.02,4.97,5.47,15.21,16.95,12.83,41.29h0Z"/>
<g>
<path class="cls-1" d="M55.33,61.62c-3.55,4.55-7.39,8.99-11.44,13.47-5.29-4.48-11.23-5.33-11.23-5.33,22.92-7.82,2.81-15.17.28-16.31C9.28,42.78,9.1,14.78,9.1,14.78c11.51,34.15,36.42,32.3,36.42,32.3.35-.21.67-.46.92-.71,1.64,3.27,4.58,8.67,8.88,15.24h0Z"/>
<g>
<path class="cls-1" d="M68.62,40.41c-1.35,2.98-2.91,5.83-4.62,8.63-1.71-2.81-3.23-5.69-4.62-8.63,1.74-3.45,4.62-20.58,4.62-20.58,0,0,2.88,17.13,4.62,20.58Z"/>
<path class="cls-1" d="M76.01,97.93l-3.48,2.34s-.1-4.44-3.52-1.84c-.42.32-2.38,2.21-.03,4.27,0,0-4.05,4.08-4.97,8.21-.92-4.12-4.97-8.21-4.97-8.21,2.34-2.06.39-3.95-.03-4.27-3.41-2.59-3.52,1.84-3.52,1.84l-3.48-2.34c.28-3.55.1-6.68-.46-9.42,4.69-4.94,8.85-9.88,12.47-14.61,3.66,4.72,7.78,9.67,12.47,14.61-.57,2.74-.75,5.86-.46,9.42Z"/>
<path class="cls-1" d="M95.34,69.76s-5.94.85-11.23,5.33c-4.02-4.48-7.89-8.92-11.44-13.47,4.3-6.57,7.25-11.98,8.88-15.24.25.25.57.5.92.71,0,0,24.91,1.84,36.42-32.3,0,0-.18,28-23.84,38.66-2.52,1.14-22.64,8.5.28,16.31h0Z"/>
</g>
</g>
</svg>)SVG";
} // namespace embedded

75
src/util/svg_texture.cpp Normal file
View File

@@ -0,0 +1,75 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "svg_texture.h"
#include "texture_loader.h" // CreateRawTexture
#include "logger.h"
// nanosvg (memononen, zlib/public-domain, vendored in libs/nanosvg). Implementations compiled ONLY here.
#define NANOSVG_IMPLEMENTATION
#include "nanosvg/nanosvg.h"
#define NANOSVGRAST_IMPLEMENTATION
#include "nanosvg/nanosvgrast.h"
#include <algorithm>
#include <string>
#include <vector>
namespace dragonx {
namespace util {
// Perceptual brightness of a nanosvg color (byte order R,G,B,A — matches ImU32/IM_COL32).
static inline float svgLuma(unsigned int c) {
const float r = (c & 0xFFu) / 255.0f;
const float g = ((c >> 8) & 0xFFu) / 255.0f;
const float b = ((c >> 16) & 0xFFu) / 255.0f;
return 0.299f * r + 0.587f * g + 0.114f * b;
}
bool LoadTextureFromSvg(const char* svgText, int pxSize, ImU32 bodyColor, ImU32 detailColor,
ImTextureID* outTex, int* outW, int* outH) {
if (!svgText || pxSize <= 0 || !outTex) return false;
// nsvgParse mutates its input buffer — parse a copy.
std::string buf(svgText);
NSVGimage* img = nsvgParse(&buf[0], "px", 96.0f);
if (!img || img->width <= 0.0f || img->height <= 0.0f) {
if (img) nsvgDelete(img);
DEBUG_LOGF("LoadTextureFromSvg: parse failed / empty image\n");
return false;
}
// Recolor by brightness: light fills -> detail, darker fills -> body. Force opaque so a theme color
// with partial alpha can't make the mark translucent. ImU32 and nanosvg color share byte order.
const unsigned int body = (static_cast<unsigned int>(bodyColor) & 0x00FFFFFFu) | 0xFF000000u;
const unsigned int detail = (static_cast<unsigned int>(detailColor) & 0x00FFFFFFu) | 0xFF000000u;
for (NSVGshape* s = img->shapes; s; s = s->next) {
if (s->fill.type == NSVG_PAINT_COLOR)
s->fill.color = (svgLuma(s->fill.color) > 0.6f) ? detail : body;
if (s->stroke.type == NSVG_PAINT_COLOR)
s->stroke.color = (svgLuma(s->stroke.color) > 0.6f) ? detail : body;
}
const float scale = static_cast<float>(pxSize) / img->width;
const int w = pxSize;
const int h = std::max(1, static_cast<int>(img->height * scale + 0.5f));
std::vector<unsigned char> rgba(static_cast<size_t>(w) * h * 4, 0);
NSVGrasterizer* rast = nsvgCreateRasterizer();
if (!rast) { nsvgDelete(img); return false; }
nsvgRasterize(rast, img, 0.0f, 0.0f, scale, rgba.data(), w, h, w * 4);
nsvgDeleteRasterizer(rast);
nsvgDelete(img);
if (!CreateRawTexture(rgba.data(), w, h, /*repeat=*/false, outTex)) {
DEBUG_LOGF("LoadTextureFromSvg: CreateRawTexture failed (%dx%d)\n", w, h);
return false;
}
if (outW) *outW = w;
if (outH) *outH = h;
return true;
}
} // namespace util
} // namespace dragonx

24
src/util/svg_texture.h Normal file
View File

@@ -0,0 +1,24 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Rasterize a (two-tone) SVG to a themed RGBA GPU texture via nanosvg. Used for the DragonX logo /
// custom emoji: the darker fill becomes `bodyColor` (the theme accent), the light fill `detailColor`.
#pragma once
#include "imgui.h"
namespace dragonx {
namespace util {
// Rasterize `svgText` (nul-terminated SVG markup) to a texture of width `pxSize` (height follows the
// SVG aspect). Fills are recolored by brightness — light fills -> detailColor, darker fills -> bodyColor
// (both forced opaque). Returns false on parse/rasterize/upload failure; on success *outTex is a texture
// the caller owns (release via util::DestroyTexture). Safe to call off the initial frame (needs a live
// GL/DX device, like the other texture loaders).
bool LoadTextureFromSvg(const char* svgText, int pxSize, ImU32 bodyColor, ImU32 detailColor,
ImTextureID* outTex, int* outW, int* outH);
} // namespace util
} // namespace dragonx