ObsidianDragon - DragonX ImGui Wallet

Full-node GUI wallet for DragonX cryptocurrency.
Built with Dear ImGui, SDL3, and OpenGL3/DX11.

Features:
- Send/receive shielded and transparent transactions
- Autoshield with merged transaction display
- Built-in CPU mining (xmrig)
- Peer management and network monitoring
- Wallet encryption with PIN lock
- QR code generation for receive addresses
- Transaction history with pagination
- Console for direct RPC commands
- Cross-platform (Linux, Windows)
This commit is contained in:
2026-02-26 02:31:52 -06:00
commit 3aee55b49c
306 changed files with 177789 additions and 0 deletions

View File

@@ -0,0 +1,186 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "color_var_resolver.h"
#include <cstdlib>
#include <cstring>
#include <cctype>
#include <cstdio>
namespace dragonx {
namespace ui {
namespace schema {
// ============================================================================
// Palette management
// ============================================================================
void ColorVarResolver::setPalette(const std::unordered_map<std::string, ImU32>& palette) {
palette_ = palette;
}
// ============================================================================
// Public API
// ============================================================================
ImU32 ColorVarResolver::resolve(const std::string& value, ImU32 fallback) const {
if (value.empty()) {
return fallback;
}
// "transparent"
if (value == "transparent") {
return IM_COL32(0, 0, 0, 0);
}
// var(--name) → palette lookup
if (value.size() > 6 && value.substr(0, 4) == "var(") {
// Extract variable name: var(--primary) → --primary
size_t close = value.find(')');
if (close == std::string::npos) {
return fallback;
}
std::string varName = value.substr(4, close - 4);
// Trim whitespace
while (!varName.empty() && varName.front() == ' ') varName.erase(varName.begin());
while (!varName.empty() && varName.back() == ' ') varName.pop_back();
auto it = palette_.find(varName);
if (it != palette_.end()) {
return it->second;
}
return fallback;
}
// #hex
if (value[0] == '#') {
ImU32 out = 0;
if (parseHex(value, out)) {
return out;
}
return fallback;
}
// rgba(r,g,b,a)
if (value.size() > 5 && value.substr(0, 5) == "rgba(") {
ImU32 out = 0;
if (parseRgba(value, out)) {
return out;
}
return fallback;
}
// 0xRRGGBB / 0xRRGGBBAA (legacy format support)
if (value.size() > 2 && value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
ImU32 out = 0;
if (parseHex(value, out)) {
return out;
}
return fallback;
}
return fallback;
}
bool ColorVarResolver::hasValue(const std::string& value) {
return !value.empty();
}
// ============================================================================
// Static parsers
// ============================================================================
bool ColorVarResolver::parseHex(const std::string& hexStr, ImU32& out) {
if (hexStr.empty()) return false;
std::string hex = hexStr;
// Remove leading # or 0x
if (hex[0] == '#') {
hex = hex.substr(1);
} else if (hex.size() > 2 && hex[0] == '0' && (hex[1] == 'x' || hex[1] == 'X')) {
hex = hex.substr(2);
}
// Validate length
if (hex.size() != 6 && hex.size() != 8) {
return false;
}
// Validate hex chars
for (char c : hex) {
if (!std::isxdigit(static_cast<unsigned char>(c))) {
return false;
}
}
unsigned long value = std::strtoul(hex.c_str(), nullptr, 16);
if (hex.size() == 6) {
// #RRGGBB → IM_COL32(R, G, B, 255)
uint8_t r = (value >> 16) & 0xFF;
uint8_t g = (value >> 8) & 0xFF;
uint8_t b = value & 0xFF;
out = IM_COL32(r, g, b, 255);
} else {
// #RRGGBBAA → IM_COL32(R, G, B, A)
uint8_t r = (value >> 24) & 0xFF;
uint8_t g = (value >> 16) & 0xFF;
uint8_t b = (value >> 8) & 0xFF;
uint8_t a = value & 0xFF;
out = IM_COL32(r, g, b, a);
}
return true;
}
bool ColorVarResolver::parseRgba(const std::string& rgba, ImU32& out) {
// Expected: "rgba(183,28,28,0.5)" or "rgba(183, 28, 28, 0.5)"
if (rgba.size() < 11) return false; // minimum: "rgba(0,0,0,0)"
// Find opening paren
size_t open = rgba.find('(');
size_t close = rgba.rfind(')');
if (open == std::string::npos || close == std::string::npos || close <= open) {
return false;
}
std::string inner = rgba.substr(open + 1, close - open - 1);
// Parse 4 comma-separated values
float values[4] = {0, 0, 0, 0};
int count = 0;
const char* p = inner.c_str();
for (int i = 0; i < 4 && *p; ++i) {
// Skip whitespace
while (*p == ' ' || *p == '\t') ++p;
char* end = nullptr;
values[i] = std::strtof(p, &end);
if (end == p) break; // failed to parse
count++;
p = end;
// Skip whitespace and comma
while (*p == ' ' || *p == '\t') ++p;
if (*p == ',') ++p;
}
if (count != 4) return false;
// r,g,b are 0-255 integers, a is 0.0-1.0 float
uint8_t r = static_cast<uint8_t>(values[0] < 0 ? 0 : (values[0] > 255 ? 255 : values[0]));
uint8_t g = static_cast<uint8_t>(values[1] < 0 ? 0 : (values[1] > 255 ? 255 : values[1]));
uint8_t b = static_cast<uint8_t>(values[2] < 0 ? 0 : (values[2] > 255 ? 255 : values[2]));
uint8_t a = static_cast<uint8_t>(values[3] < 0 ? 0 : (values[3] > 1.0f ? 255 : values[3] * 255.0f));
out = IM_COL32(r, g, b, a);
return true;
}
} // namespace schema
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,75 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
#include "imgui.h"
#include <string>
#include <unordered_map>
namespace dragonx {
namespace ui {
namespace schema {
/**
* @brief CSS custom property resolver for color values
*
* Resolves color strings in multiple formats:
* - var(--name) → lookup from theme palette
* - #RRGGBB → direct hex (6 digits, alpha=255)
* - #RRGGBBAA → direct hex with alpha (8 digits)
* - rgba(r,g,b,a) → CSS-style with float alpha 0.0-1.0
* - transparent → fully transparent
* - empty string → returns fallback
*/
class ColorVarResolver {
public:
/**
* @brief Set the palette map (called when skin is loaded)
* @param palette Map of "--name" → ImU32 color values
*/
void setPalette(const std::unordered_map<std::string, ImU32>& palette);
/**
* @brief Get the current palette
*/
const std::unordered_map<std::string, ImU32>& palette() const { return palette_; }
/**
* @brief Resolve a color string to an ImU32 value
*
* @param value Color string (var ref, hex, rgba, or "transparent")
* @param fallback Value to return if resolution fails or value is empty
* @return Resolved ImU32 color
*/
ImU32 resolve(const std::string& value, ImU32 fallback = IM_COL32(0,0,0,0)) const;
/**
* @brief Check if a color string is non-empty (has an override)
*/
static bool hasValue(const std::string& value);
/**
* @brief Parse a hex color string (#RRGGBB or #RRGGBBAA)
* @param hex The hex string (with or without # prefix)
* @param[out] out Parsed color
* @return true if parsed successfully
*/
static bool parseHex(const std::string& hex, ImU32& out);
/**
* @brief Parse an rgba() color string
* @param rgba String like "rgba(183,28,28,0.5)"
* @param[out] out Parsed color
* @return true if parsed successfully
*/
static bool parseRgba(const std::string& rgba, ImU32& out);
private:
std::unordered_map<std::string, ImU32> palette_;
};
} // namespace schema
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,294 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "element_styles.h"
#include <toml++/toml.hpp>
namespace dragonx {
namespace ui {
namespace schema {
// ============================================================================
// TOML helper macros — read optional fields, leave sentinel if absent
// ============================================================================
#define READ_FLOAT(t, field, out) do { auto _v = (t)[field].value<double>(); if (_v) (out) = static_cast<float>(*_v); } while(0)
#define READ_INT(t, field, out) do { auto _v = (t)[field].value<int64_t>(); if (_v) (out) = static_cast<int>(*_v); } while(0)
#define READ_STRING(t, field, out) do { auto _v = (t)[field].value<std::string>(); if (_v) (out) = *_v; } while(0)
#define READ_PADDING(t, field, out) do { \
if ((t).contains(field)) { \
if (auto* _arr = (t)[field].as_array(); _arr && _arr->size() >= 2) { \
auto _v0 = (*_arr)[0].value<double>(); \
auto _v1 = (*_arr)[1].value<double>(); \
if (_v0) (out)[0] = static_cast<float>(*_v0); \
if (_v1) (out)[1] = static_cast<float>(*_v1); \
} else { \
auto _v = (t)[field].value<double>(); \
if (_v) (out)[0] = (out)[1] = static_cast<float>(*_v); \
} \
} \
} while(0)
// ============================================================================
// Parse functions — cast void* back to toml::table
// ============================================================================
namespace detail {
void parseElementColors(const void* dataObj, ElementColors& out) {
const toml::table& t = *static_cast<const toml::table*>(dataObj);
READ_STRING(t, "color", out.color);
READ_STRING(t, "background", out.background);
READ_STRING(t, "background-hover", out.backgroundHover);
READ_STRING(t, "background-active", out.backgroundActive);
READ_STRING(t, "border-color", out.borderColor);
}
void parseButtonStyle(const void* dataObj, ButtonStyle& out) {
const toml::table& t = *static_cast<const toml::table*>(dataObj);
READ_FLOAT(t, "width", out.width);
READ_FLOAT(t, "height", out.height);
READ_STRING(t, "font", out.font);
READ_PADDING(t, "padding", out.padding);
READ_FLOAT(t, "opacity", out.opacity);
READ_FLOAT(t, "border-radius", out.borderRadius);
READ_FLOAT(t, "border-width", out.borderWidth);
READ_FLOAT(t, "min-width", out.minWidth);
READ_STRING(t, "align", out.align);
READ_FLOAT(t, "gap", out.gap);
parseElementColors(dataObj, out.colors);
}
void parseInputStyle(const void* dataObj, InputStyle& out) {
const toml::table& t = *static_cast<const toml::table*>(dataObj);
READ_FLOAT(t, "width", out.width);
READ_FLOAT(t, "height", out.height);
READ_INT(t, "lines", out.lines);
READ_STRING(t, "font", out.font);
READ_PADDING(t, "padding", out.padding);
READ_FLOAT(t, "border-radius", out.borderRadius);
READ_FLOAT(t, "border-width", out.borderWidth);
READ_STRING(t, "border-color-focus", out.borderColorFocus);
READ_STRING(t, "placeholder-color", out.placeholderColor);
READ_FLOAT(t, "width-ratio", out.widthRatio);
READ_FLOAT(t, "max-width", out.maxWidth);
parseElementColors(dataObj, out.colors);
}
void parseLabelStyle(const void* dataObj, LabelStyle& out) {
const toml::table& t = *static_cast<const toml::table*>(dataObj);
READ_STRING(t, "font", out.font);
READ_STRING(t, "color", out.color);
READ_FLOAT(t, "opacity", out.opacity);
READ_STRING(t, "align", out.align);
READ_INT(t, "truncate", out.truncate);
READ_FLOAT(t, "position", out.position);
}
void parseTableStyle(const void* dataObj, TableStyle& out) {
const toml::table& t = *static_cast<const toml::table*>(dataObj);
READ_FLOAT(t, "min-height", out.minHeight);
READ_FLOAT(t, "height-ratio", out.heightRatio);
READ_FLOAT(t, "bottom-reserve", out.bottomReserve);
READ_FLOAT(t, "row-height", out.rowHeight);
READ_STRING(t, "header-font", out.headerFont);
READ_STRING(t, "cell-font", out.cellFont);
READ_STRING(t, "border-color", out.borderColor);
READ_STRING(t, "stripe-color", out.stripeColor);
if (auto* cols = t["columns"].as_table()) {
for (auto&& [key, val] : *cols) {
auto* colTable = val.as_table();
if (!colTable) continue;
ColumnStyle col;
READ_FLOAT(*colTable, "width", col.width);
READ_STRING(*colTable, "font", col.font);
READ_STRING(*colTable, "align", col.align);
READ_INT(*colTable, "truncate", col.truncate);
out.columns[std::string(key.str())] = col;
}
}
}
void parseCheckboxStyle(const void* dataObj, CheckboxStyle& out) {
const toml::table& t = *static_cast<const toml::table*>(dataObj);
READ_STRING(t, "font", out.font);
READ_STRING(t, "color", out.color);
READ_STRING(t, "check-color", out.checkColor);
READ_STRING(t, "background", out.background);
}
void parseComboStyle(const void* dataObj, ComboStyle& out) {
const toml::table& t = *static_cast<const toml::table*>(dataObj);
READ_FLOAT(t, "width", out.width);
READ_STRING(t, "font", out.font);
READ_STRING(t, "color", out.color);
READ_STRING(t, "background", out.background);
READ_FLOAT(t, "border-radius", out.borderRadius);
READ_INT(t, "truncate", out.truncate);
}
void parseSliderStyle(const void* dataObj, SliderStyle& out) {
const toml::table& t = *static_cast<const toml::table*>(dataObj);
READ_FLOAT(t, "width", out.width);
READ_STRING(t, "track-color", out.trackColor);
READ_STRING(t, "fill-color", out.fillColor);
READ_STRING(t, "thumb-color", out.thumbColor);
READ_FLOAT(t, "thumb-radius", out.thumbRadius);
}
void parseWindowStyle(const void* dataObj, WindowStyle& out) {
const toml::table& t = *static_cast<const toml::table*>(dataObj);
READ_FLOAT(t, "width", out.width);
READ_FLOAT(t, "height", out.height);
READ_PADDING(t, "padding", out.padding);
READ_FLOAT(t, "border-radius", out.borderRadius);
READ_FLOAT(t, "border-width", out.borderWidth);
READ_STRING(t, "background", out.background);
READ_STRING(t, "border-color", out.borderColor);
READ_STRING(t, "title-font", out.titleFont);
}
void parseSeparatorStyle(const void* dataObj, SeparatorStyle& out) {
const toml::table& t = *static_cast<const toml::table*>(dataObj);
READ_STRING(t, "color", out.color);
READ_FLOAT(t, "thickness", out.thickness);
READ_PADDING(t, "margin", out.margin);
}
void parseDrawElementStyle(const void* dataObj, DrawElementStyle& out) {
const toml::table& t = *static_cast<const toml::table*>(dataObj);
READ_STRING(t, "color", out.color);
READ_STRING(t, "background", out.background);
READ_FLOAT(t, "thickness", out.thickness);
READ_FLOAT(t, "radius", out.radius);
READ_FLOAT(t, "opacity", out.opacity);
READ_FLOAT(t, "size", out.size);
READ_FLOAT(t, "height", out.height);
// Collect any extra color/float properties not in the standard set
static const char* knownKeys[] = {
"color", "background", "thickness", "radius", "opacity", "size", "height",
nullptr
};
for (auto&& [key, val] : t) {
// Skip known keys
bool known = false;
for (const char** k = knownKeys; *k; ++k) {
if (key.str() == *k) { known = true; break; }
}
if (known) continue;
if (val.is_string()) {
out.extraColors[std::string(key.str())] = *val.value<std::string>();
} else if (val.is_integer() || val.is_floating_point()) {
out.extraFloats[std::string(key.str())] = static_cast<float>(val.value<double>().value_or(0.0));
}
}
}
void parseBreakpointConfig(const void* dataObj, BreakpointConfig& out) {
const toml::table& t = *static_cast<const toml::table*>(dataObj);
if (auto* c = t["compact"].as_table()) {
READ_FLOAT(*c, "max-width", out.compact.maxWidth);
READ_FLOAT(*c, "max-height", out.compact.maxHeight);
READ_FLOAT(*c, "min-width", out.compact.minWidth);
READ_FLOAT(*c, "min-height", out.compact.minHeight);
}
if (auto* e = t["expanded"].as_table()) {
READ_FLOAT(*e, "max-width", out.expanded.maxWidth);
READ_FLOAT(*e, "max-height", out.expanded.maxHeight);
READ_FLOAT(*e, "min-width", out.expanded.minWidth);
READ_FLOAT(*e, "min-height", out.expanded.minHeight);
}
}
void parseResponsiveButtonOverride(const void* dataObj, ResponsiveButtonOverride& out) {
const toml::table& t = *static_cast<const toml::table*>(dataObj);
READ_FLOAT(t, "width", out.width);
READ_FLOAT(t, "height", out.height);
READ_STRING(t, "font", out.font);
READ_PADDING(t, "padding", out.padding);
}
} // namespace detail
// ============================================================================
// Merge helpers
// ============================================================================
void mergeButton(ButtonStyle& base, const ButtonStyle& overlay) {
if (overlay.width > 0) base.width = overlay.width;
if (overlay.height > 0) base.height = overlay.height;
if (!overlay.font.empty()) base.font = overlay.font;
if (overlay.padding[0] > 0) { base.padding[0] = overlay.padding[0]; base.padding[1] = overlay.padding[1]; }
if (overlay.opacity >= 0) base.opacity = overlay.opacity;
if (overlay.borderRadius >= 0) base.borderRadius = overlay.borderRadius;
if (overlay.borderWidth >= 0) base.borderWidth = overlay.borderWidth;
if (overlay.minWidth >= 0) base.minWidth = overlay.minWidth;
if (!overlay.align.empty()) base.align = overlay.align;
if (overlay.gap > 0) base.gap = overlay.gap;
// Colors: non-empty string overrides
if (!overlay.colors.color.empty()) base.colors.color = overlay.colors.color;
if (!overlay.colors.background.empty()) base.colors.background = overlay.colors.background;
if (!overlay.colors.backgroundHover.empty()) base.colors.backgroundHover = overlay.colors.backgroundHover;
if (!overlay.colors.backgroundActive.empty()) base.colors.backgroundActive = overlay.colors.backgroundActive;
if (!overlay.colors.borderColor.empty()) base.colors.borderColor = overlay.colors.borderColor;
}
void mergeInput(InputStyle& base, const InputStyle& overlay) {
if (overlay.width != 0) base.width = overlay.width;
if (overlay.height > 0) base.height = overlay.height;
if (overlay.lines > 0) base.lines = overlay.lines;
if (!overlay.font.empty()) base.font = overlay.font;
if (overlay.padding[0] > 0) { base.padding[0] = overlay.padding[0]; base.padding[1] = overlay.padding[1]; }
if (overlay.borderRadius >= 0) base.borderRadius = overlay.borderRadius;
if (overlay.borderWidth >= 0) base.borderWidth = overlay.borderWidth;
if (!overlay.borderColorFocus.empty()) base.borderColorFocus = overlay.borderColorFocus;
if (!overlay.placeholderColor.empty()) base.placeholderColor = overlay.placeholderColor;
if (overlay.widthRatio >= 0) base.widthRatio = overlay.widthRatio;
if (overlay.maxWidth >= 0) base.maxWidth = overlay.maxWidth;
if (!overlay.colors.color.empty()) base.colors.color = overlay.colors.color;
if (!overlay.colors.background.empty()) base.colors.background = overlay.colors.background;
if (!overlay.colors.borderColor.empty()) base.colors.borderColor = overlay.colors.borderColor;
}
void mergeLabel(LabelStyle& base, const LabelStyle& overlay) {
if (!overlay.font.empty()) base.font = overlay.font;
if (!overlay.color.empty()) base.color = overlay.color;
if (overlay.opacity >= 0) base.opacity = overlay.opacity;
if (!overlay.align.empty()) base.align = overlay.align;
if (overlay.truncate > 0) base.truncate = overlay.truncate;
if (overlay.position >= 0) base.position = overlay.position;
}
void mergeWindow(WindowStyle& base, const WindowStyle& overlay) {
if (overlay.width > 0) base.width = overlay.width;
if (overlay.height > 0) base.height = overlay.height;
if (overlay.padding[0] > 0) { base.padding[0] = overlay.padding[0]; base.padding[1] = overlay.padding[1]; }
if (overlay.borderRadius >= 0) base.borderRadius = overlay.borderRadius;
if (overlay.borderWidth >= 0) base.borderWidth = overlay.borderWidth;
if (!overlay.background.empty()) base.background = overlay.background;
if (!overlay.borderColor.empty()) base.borderColor = overlay.borderColor;
if (!overlay.titleFont.empty()) base.titleFont = overlay.titleFont;
}
void applyResponsiveButton(ButtonStyle& base, const ResponsiveButtonOverride& ovr) {
if (ovr.width > 0) base.width = ovr.width;
if (ovr.height > 0) base.height = ovr.height;
if (!ovr.font.empty()) base.font = ovr.font;
if (ovr.padding[0] > 0) { base.padding[0] = ovr.padding[0]; base.padding[1] = ovr.padding[1]; }
}
#undef READ_FLOAT
#undef READ_INT
#undef READ_STRING
#undef READ_PADDING
} // namespace schema
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,273 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
#include "imgui.h"
#include <string>
#include <map>
#include <optional>
namespace dragonx {
namespace ui {
namespace schema {
// ============================================================================
// Color properties shared by all styleable elements
// ============================================================================
struct ElementColors {
std::string color; // "var(--on-primary)" | "rgba(...)" | "#hex" | ""
std::string background;
std::string backgroundHover; // null = auto-derive
std::string backgroundActive; // null = auto-derive
std::string borderColor;
};
// ============================================================================
// Element Style Structs
// ============================================================================
/**
* @brief Style for button elements
*
* Sentinel values: 0 or -1 = inherit from globals, "" = inherit
*/
struct ButtonStyle {
float width = 0; // 0 = auto-size
float height = 0; // 0 = auto from font + padding
std::string font; // "" = inherit from globals
float padding[2] = {0, 0}; // [h, v] — 0 = inherit
float opacity = -1; // -1 = inherit, 0.0-1.0
float borderRadius = -1; // -1 = inherit
float borderWidth = -1; // -1 = inherit
float minWidth = -1; // -1 = inherit
std::string align; // "" | "left" | "center" | "right"
ElementColors colors;
// Gap between adjacent buttons (layout helper)
float gap = 0;
};
/**
* @brief Style for text input elements
*/
struct InputStyle {
float width = 0; // 0 = auto, -1 = fill remaining
float height = 0; // 0 = single-line auto
int lines = 0; // 0 = inherit, 1 = single, >1 = multiline
std::string font;
float padding[2] = {0, 0};
float borderRadius = -1;
float borderWidth = -1;
ElementColors colors;
std::string borderColorFocus; // "var(--primary)" for focus ring
std::string placeholderColor;
// Ratio-based width (alternative to fixed)
float widthRatio = -1; // -1 = not set, 0.0-1.0 = fraction of available
float maxWidth = -1; // -1 = no limit
};
/**
* @brief Style for text labels
*/
struct LabelStyle {
std::string font;
std::string color;
float opacity = -1;
std::string align;
int truncate = 0; // 0 = no truncation, >0 = max chars
float position = -1; // SameLine label position (-1 = not set)
};
/**
* @brief Style for table columns
*/
struct ColumnStyle {
float width = -1;
std::string font;
std::string align;
int truncate = 0;
};
/**
* @brief Style for table elements
*/
struct TableStyle {
float minHeight = -1;
float heightRatio = -1; // fraction of available space
float bottomReserve = -1;
float rowHeight = -1;
std::string headerFont;
std::string cellFont;
std::string borderColor;
std::string stripeColor;
std::map<std::string, ColumnStyle> columns;
};
/**
* @brief Style for checkbox elements
*/
struct CheckboxStyle {
std::string font;
std::string color;
std::string checkColor;
std::string background;
};
/**
* @brief Style for combo/dropdown elements
*/
struct ComboStyle {
float width = 0;
std::string font;
std::string color;
std::string background;
float borderRadius = -1;
int truncate = 0;
};
/**
* @brief Style for slider elements
*/
struct SliderStyle {
float width = 0;
std::string trackColor;
std::string fillColor;
std::string thumbColor;
float thumbRadius = -1;
};
/**
* @brief Style for windows and dialogs
*/
struct WindowStyle {
float width = 0;
float height = 0;
float padding[2] = {0, 0};
float borderRadius = -1;
float borderWidth = -1;
std::string background;
std::string borderColor;
std::string titleFont;
};
/**
* @brief Style for separator lines
*/
struct SeparatorStyle {
std::string color;
float thickness = -1;
float margin[2] = {0, 0}; // [top, bottom]
};
/**
* @brief Style for DrawList-driven custom elements (progress rings, sparklines, etc.)
*/
struct DrawElementStyle {
std::string color;
std::string background;
float thickness = -1;
float radius = -1;
float opacity = -1;
float size = -1; // generic size (e.g., QR code size)
float height = -1;
// Additional named properties for flexible DrawList elements
std::map<std::string, std::string> extraColors;
std::map<std::string, float> extraFloats;
/// Look up any extra float by key, returning fallback if absent.
float getFloat(const std::string& key, float fallback = 0.0f) const {
auto it = extraFloats.find(key);
return it != extraFloats.end() ? it->second : fallback;
}
/// Return size if set (>= 0), else fallback.
float sizeOr(float fallback) const { return size >= 0.0f ? size : fallback; }
};
/**
* @brief Style for spacing (layout helper)
*/
struct SpacingStyle {
float size = 0;
};
// ============================================================================
// Responsive overrides
// ============================================================================
/**
* @brief Per-breakpoint property overrides
*
* Only non-sentinel values override the base style. Applied on top of
* the element's base style when the current window size matches the
* breakpoint.
*/
struct ResponsiveButtonOverride {
float width = 0;
float height = 0;
std::string font;
float padding[2] = {0, 0};
};
struct ResponsiveInputOverride {
float width = 0;
float height = 0;
};
// ============================================================================
// Breakpoint definitions
// ============================================================================
struct BreakpointDef {
float maxWidth = -1; // -1 = no constraint
float maxHeight = -1;
float minWidth = -1;
float minHeight = -1;
};
struct BreakpointConfig {
BreakpointDef compact;
BreakpointDef expanded;
};
// ============================================================================
// JSON parsing helpers (implemented in element_styles.cpp)
// ============================================================================
// Forward declare nlohmann::json to avoid header dependency
namespace detail {
// Parse individual style types from a JSON object
// These are called by UISchema during load — not for external use
void parseButtonStyle(const void* jsonObj, ButtonStyle& out);
void parseInputStyle(const void* jsonObj, InputStyle& out);
void parseLabelStyle(const void* jsonObj, LabelStyle& out);
void parseTableStyle(const void* jsonObj, TableStyle& out);
void parseCheckboxStyle(const void* jsonObj, CheckboxStyle& out);
void parseComboStyle(const void* jsonObj, ComboStyle& out);
void parseSliderStyle(const void* jsonObj, SliderStyle& out);
void parseWindowStyle(const void* jsonObj, WindowStyle& out);
void parseSeparatorStyle(const void* jsonObj, SeparatorStyle& out);
void parseDrawElementStyle(const void* jsonObj, DrawElementStyle& out);
void parseBreakpointConfig(const void* jsonObj, BreakpointConfig& out);
void parseElementColors(const void* jsonObj, ElementColors& out);
void parseResponsiveButtonOverride(const void* jsonObj, ResponsiveButtonOverride& out);
}
// ============================================================================
// Merge helpers — apply overlay on top of base (non-sentinel values win)
// ============================================================================
void mergeButton(ButtonStyle& base, const ButtonStyle& overlay);
void mergeInput(InputStyle& base, const InputStyle& overlay);
void mergeLabel(LabelStyle& base, const LabelStyle& overlay);
void mergeWindow(WindowStyle& base, const WindowStyle& overlay);
void applyResponsiveButton(ButtonStyle& base, const ResponsiveButtonOverride& ovr);
} // namespace schema
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,800 @@
// 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 <toml++/toml.hpp>
#include <fstream>
#include <filesystem>
#include <algorithm>
#include <cstdio>
#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)) {
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<std::string>()) {
info.name = *name;
} else {
info.name = info.id;
}
if (auto author = (*theme)["author"].value<std::string>()) {
info.author = *author;
}
if (auto dark = (*theme)["dark"].value<bool>()) {
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<std::string>()) {
bgFilename = *bg;
}
if (auto logo = (*images)["logo"].value<std::string>()) {
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<std::string>()) {
info.name = *name;
} else {
info.name = info.id;
}
if (auto author = (*theme)["author"].value<std::string>()) {
info.author = *author;
}
if (auto dark = (*theme)["dark"].value<bool>()) {
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<std::string>()) {
bgFilename = *bg;
}
if (auto logo = (*images)["logo"].value<std::string>()) {
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<std::string>()) {
info.name = *name;
} else {
info.name = info.id;
}
if (auto author = (*theme)["author"].value<std::string>()) {
info.author = *author;
}
if (auto dark = (*theme)["dark"].value<bool>()) {
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<std::string>()) {
bgFilename = *bg;
}
if (auto logo = (*images)["logo"].value<std::string>()) {
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<std::string>();
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_<stem><ext>
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

View File

@@ -0,0 +1,192 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
#include <string>
#include <vector>
#include <functional>
#include <filesystem>
namespace dragonx {
namespace ui {
namespace schema {
/**
* @brief Manages bundled and user-installed skins (unified TOML files)
*
* Responsibilities:
* - Enumerate bundled skins from res/themes/ directory (any .toml except ui.toml)
* - Enumerate user themes from ~/.config/ObsidianDragon/themes/<folder>/theme.toml
* - Import / remove user skins
* - Validate skin TOML structure before import
* - Track active skin ID in settings
*/
class SkinManager {
public:
/**
* @brief Metadata about an available skin
*/
struct SkinInfo {
std::string id; ///< Unique identifier (folder name or filename stem)
std::string name; ///< Display name from theme.name
std::string author; ///< Author from theme.author
std::string path; ///< Full filesystem path to the TOML file
std::string directory; ///< Folder containing theme.toml (empty for bundled flat files)
std::string backgroundImagePath; ///< Resolved path to background image override (empty = use default)
std::string logoPath; ///< Resolved path to logo image override (empty = use default)
bool dark = true; ///< Dark mode flag from theme.dark
bool bundled = true; ///< true = shipped with app, false = user-installed
bool valid = true; ///< true if theme.toml passed validation
std::string validationError; ///< Error message if !valid
};
/**
* @brief Result of a skin validation
*/
struct ValidationResult {
bool valid = false;
std::string error; ///< Error message if !valid
};
/**
* @brief Get the singleton instance
*/
static SkinManager& instance();
/**
* @brief Scan for available skins (bundled + user)
*
* Re-scans both the bundled res/ directory and the user skins directory.
* Call this on startup and after import/remove operations.
*/
void refresh();
/**
* @brief Get the list of available skins
* @return Sorted list: bundled skins first (with "dragonx" at top), then user skins
*/
const std::vector<SkinInfo>& available() const { return skins_; }
/**
* @brief Find a skin by ID
* @return Pointer to SkinInfo, or nullptr if not found
*/
const SkinInfo* findById(const std::string& id) const;
/**
* @brief Validate a skin TOML file
* @param path Path to the TOML file
* @return Validation result with error message if invalid
*
* Validation rules:
* 1. File must be valid TOML
* 2. Must contain [theme] table
* 3. theme.name must be a non-empty string
* 4. theme.palette must be a table with at least --primary and --background
* 5. If [globals] exists, it must be a table
*/
static ValidationResult validateSkinFile(const std::string& path);
/**
* @brief Import a skin file into the user skins directory
* @param sourcePath Path to the source TOML file
* @return true if imported successfully
*
* Validates the file first. Copies to user skins directory.
* Calls refresh() on success.
*/
bool importSkin(const std::string& sourcePath);
/**
* @brief Remove a user-installed skin
* @param id Skin ID to remove
* @return true if removed successfully
*
* Cannot remove bundled skins. Calls refresh() on success.
*/
bool removeSkin(const std::string& id);
/**
* @brief Apply a skin by ID
* @param id Skin ID to activate
* @return true if skin was found and loaded
*
* Loads the skin file into UISchema and applies ImGui colors.
*/
bool setActiveSkin(const std::string& id);
/**
* @brief Get the currently active skin ID
*/
const std::string& activeSkinId() const { return activeSkinId_; }
/**
* @brief Get the bundled skins directory path
*/
static std::string getBundledSkinsDirectory();
/**
* @brief Get the user skins directory path
*/
static std::string getUserSkinsDirectory();
/**
* @brief Set callback invoked after skin changes (for image reloading)
* @param cb Callback receiving backgroundImagePath and logoPath (empty = use default)
*/
void setImageReloadCallback(std::function<void(const std::string& bgPath, const std::string& logoPath)> cb) {
imageReloadCb_ = std::move(cb);
}
/**
* @brief Re-resolve image paths from UISchema and trigger reload callback
*
* Called by UISchema hot-reload when [theme.images] values change.
* Updates the active SkinInfo's image paths and fires imageReloadCb_.
*
* @param skinId Active skin ID to update
* @param tomlPath Path to the TOML file whose images section changed
*/
void resolveAndReloadImages(const std::string& skinId, const std::string& tomlPath);
/**
* @brief Enable/disable gradient background mode
*
* When enabled, theme backgrounds are replaced with their gradient
* variants (e.g. "gradient_drgx_bg.png" instead of "drgx_bg.png").
* Falls back to dark_gradient.png or light_gradient.png when no
* theme-specific gradient exists.
*/
void setGradientMode(bool enabled);
bool isGradientMode() const { return gradientMode_; }
private:
SkinManager() = default;
~SkinManager() = default;
SkinManager(const SkinManager&) = delete;
SkinManager& operator=(const SkinManager&) = delete;
void scanDirectory(const std::string& dir, bool bundled);
/// Resolve the image directory for a given skin
std::filesystem::path resolveImgDir(const SkinInfo* skin) const;
/// Given an original bg relative path and img dir, resolve the gradient variant
std::string resolveGradientBg(const std::string& bgFilename,
const std::filesystem::path& imgDir,
bool isDark) const;
/// Common helper: resolve bg (optionally gradient) and logo, fire callback
void resolveAndFireCallback(SkinInfo* skin, const std::filesystem::path& imgDir);
std::vector<SkinInfo> skins_;
std::string activeSkinId_ = "dragonx";
bool gradientMode_ = false;
std::function<void(const std::string&, const std::string&)> imageReloadCb_;
};
} // namespace schema
} // namespace ui
} // namespace dragonx

852
src/ui/schema/ui_schema.cpp Normal file
View File

@@ -0,0 +1,852 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "ui_schema.h"
#include "skin_manager.h"
#include "color_var_resolver.h"
#include "element_styles.h"
#include "../material/typography.h"
#include "../material/color_theme.h"
#include "../theme.h"
#include "../theme_loader.h"
#include "../effects/imgui_acrylic.h"
#include <toml++/toml.hpp>
#include <fstream>
#include <sstream>
#include <cstdio>
#include "../../util/logger.h"
namespace dragonx {
namespace ui {
namespace schema {
// ============================================================================
// Singleton
// ============================================================================
UISchema& UISchema::instance() {
static UISchema s;
return s;
}
// ============================================================================
// Load
// ============================================================================
bool UISchema::loadFromFile(const std::string& path) {
toml::table root;
try {
root = toml::parse_file(path);
} catch (const toml::parse_error& e) {
DEBUG_LOGF("[UISchema] TOML parse error in %s: %s\n", path.c_str(), e.what());
return false;
}
// Clear previous data
elements_.clear();
backgroundImagePath_.clear();
logoImagePath_.clear();
// Parse top-level sections
if (auto* theme = root["theme"].as_table()) {
parseTheme(static_cast<const void*>(theme));
}
if (auto* bp = root["breakpoints"].as_table()) {
parseBreakpoints(static_cast<const void*>(bp));
}
if (auto* globals = root["globals"].as_table()) {
parseGlobals(static_cast<const void*>(globals));
}
// Parse tabs, dialogs, components sections → store elements
if (auto* tabs = root["tabs"].as_table()) {
parseSections(static_cast<const void*>(tabs), "tabs");
}
if (auto* dialogs = root["dialogs"].as_table()) {
parseSections(static_cast<const void*>(dialogs), "dialogs");
}
if (auto* components = root["components"].as_table()) {
parseSections(static_cast<const void*>(components), "components");
}
// Parse flat sections (2-level: sectionName.elementName → {style object})
for (const auto& flatSection : {"business", "animations", "console",
"backdrop", "shutdown", "notifications", "status-bar",
"qr-code", "content-area", "style", "responsive",
"spacing", "spacing-tokens", "button", "input", "fonts",
"inline-dialogs", "sidebar", "panels", "typography", "effects"}) {
if (auto* sec = root[flatSection].as_table()) {
parseFlatSection(static_cast<const void*>(sec), flatSection);
}
}
currentPath_ = path;
if (basePath_.empty()) {
basePath_ = path; // Only set on first load (the true base)
}
overlayPath_.clear(); // No overlay yet
loaded_ = true;
dirty_ = false;
// Record initial modification time
try {
lastModTime_ = std::filesystem::last_write_time(path);
if (path == basePath_) {
baseModTime_ = lastModTime_;
}
} catch (const std::filesystem::filesystem_error&) {
// Non-fatal
}
DEBUG_LOGF("[UISchema] Loaded: %s (theme: %s, %s, %zu elements, gen=%u)\n",
path.c_str(), themeName_.c_str(),
darkTheme_ ? "dark" : "light",
elements_.size(), generation_);
return true;
}
bool UISchema::loadFromString(const std::string& tomlStr, const std::string& label) {
toml::table root;
try {
root = toml::parse(tomlStr);
} catch (const toml::parse_error& e) {
DEBUG_LOGF("[UISchema] TOML parse error (%s): %s\n", label.c_str(), e.what());
return false;
}
// Store the raw TOML string so the base can be reloaded later
// (needed when no file-based basePath_ is available, e.g., Windows embedded)
embeddedTomlStr_ = tomlStr;
// Clear previous data
elements_.clear();
backgroundImagePath_.clear();
logoImagePath_.clear();
// Parse top-level sections
if (auto* theme = root["theme"].as_table()) {
parseTheme(static_cast<const void*>(theme));
}
if (auto* bp = root["breakpoints"].as_table()) {
parseBreakpoints(static_cast<const void*>(bp));
}
if (auto* globals = root["globals"].as_table()) {
parseGlobals(static_cast<const void*>(globals));
}
if (auto* tabs = root["tabs"].as_table()) {
parseSections(static_cast<const void*>(tabs), "tabs");
}
if (auto* dialogs = root["dialogs"].as_table()) {
parseSections(static_cast<const void*>(dialogs), "dialogs");
}
if (auto* components = root["components"].as_table()) {
parseSections(static_cast<const void*>(components), "components");
}
// Parse flat sections (2-level: sectionName.elementName → {style object})
for (const auto& flatSection : {"business", "animations", "console",
"backdrop", "shutdown", "notifications", "status-bar",
"qr-code", "content-area", "style", "responsive",
"spacing", "spacing-tokens", "button", "input", "fonts",
"inline-dialogs", "sidebar", "panels", "typography", "effects"}) {
if (auto* sec = root[flatSection].as_table()) {
parseFlatSection(static_cast<const void*>(sec), flatSection);
}
}
overlayPath_.clear(); // No overlay when loading a full base
currentPath_ = label; // Track what is loaded (for logging)
loaded_ = true;
dirty_ = false;
++generation_;
DEBUG_LOGF("[UISchema] Loaded from %s (theme: %s, %s, %zu elements)\n",
label.c_str(), themeName_.c_str(),
darkTheme_ ? "dark" : "light",
elements_.size());
return true;
}
// ============================================================================
// Overlay merge (visual-only: theme + backdrop)
// ============================================================================
bool UISchema::mergeOverlayFromFile(const std::string& path) {
if (!loaded_) {
DEBUG_LOGF("[UISchema] mergeOverlay called before base load — falling back to full load\n");
return loadFromFile(path);
}
std::ifstream file(path);
if (!file.is_open()) {
DEBUG_LOGF("[UISchema] Failed to open overlay: %s\n", path.c_str());
return false;
}
toml::table root;
try {
root = toml::parse_file(path);
} catch (const toml::parse_error& e) {
DEBUG_LOGF("[UISchema] TOML parse error in overlay %s: %s\n", path.c_str(), e.what());
return false;
}
// Merge theme section (palette, elevation, images, dark flag, name)
if (auto* theme = root["theme"].as_table()) {
parseTheme(static_cast<const void*>(theme));
}
// Merge backdrop section (gradient colors, alpha/transparency values)
if (auto* sec = root["backdrop"].as_table()) {
parseFlatSection(static_cast<const void*>(sec), "backdrop");
}
// Merge effects section (theme visual effects configuration)
if (auto* sec = root["effects"].as_table()) {
parseFlatSection(static_cast<const void*>(sec), "effects");
}
overlayPath_ = path;
// Track overlay file for hot-reload
currentPath_ = path;
++generation_;
try {
lastModTime_ = std::filesystem::last_write_time(path);
} catch (const std::filesystem::filesystem_error&) {}
DEBUG_LOGF("[UISchema] Merged overlay: %s (theme: %s, %s)\n",
path.c_str(), themeName_.c_str(),
darkTheme_ ? "dark" : "light");
return true;
}
// ============================================================================
// Reload base theme (from file or embedded string)
// ============================================================================
bool UISchema::reloadBase() {
// Prefer file-based reload when a basePath is available
if (!basePath_.empty()) {
return loadFromFile(basePath_);
}
// Fallback: reload from stored embedded string
if (!embeddedTomlStr_.empty()) {
return loadFromString(embeddedTomlStr_, "embedded-reload");
}
DEBUG_LOGF("[UISchema] reloadBase: no base path or embedded data available\n");
return false;
}
// ============================================================================
// Theme parsing
// ============================================================================
void UISchema::parseTheme(const void* dataObj) {
const toml::table& t = *static_cast<const toml::table*>(dataObj);
if (auto name = t["name"].value<std::string>()) {
themeName_ = *name;
}
if (auto dark = t["dark"].value<bool>()) {
darkTheme_ = *dark;
}
// Parse palette → ColorVarResolver
if (auto* palette = t["palette"].as_table()) {
std::unordered_map<std::string, ImU32> paletteMap;
for (auto&& [key, val] : *palette) {
if (!val.is_string()) continue;
ImU32 color = 0;
std::string colorStr = *val.value<std::string>();
// Palette values are direct hex or rgba — not var() references
if (ColorVarResolver::parseHex(colorStr, color)) {
paletteMap[std::string(key.str())] = color;
} else if (ColorVarResolver::parseRgba(colorStr, color)) {
paletteMap[std::string(key.str())] = color;
} else if (colorStr == "transparent") {
paletteMap[std::string(key.str())] = IM_COL32(0, 0, 0, 0);
} else {
DEBUG_LOGF("[UISchema] Warning: unparseable palette color '%s': %s\n",
std::string(key.str()).c_str(), colorStr.c_str());
}
}
colorResolver_.setPalette(paletteMap);
}
// Parse elevation (optional)
if (auto* elevation = t["elevation"].as_table()) {
// Elevation colors go into the same palette
auto paletteMap = colorResolver_.palette();
for (auto&& [key, val] : *elevation) {
if (!val.is_string()) continue;
ImU32 color = 0;
std::string colorStr = *val.value<std::string>();
if (ColorVarResolver::parseHex(colorStr, color)) {
paletteMap[std::string(key.str())] = color;
}
}
colorResolver_.setPalette(paletteMap);
}
// Parse image overrides (resolved relative to theme directory by SkinManager)
if (auto* images = t["images"].as_table()) {
if (auto bg = (*images)["background_image"].value<std::string>()) {
backgroundImagePath_ = *bg;
}
if (auto logo = (*images)["logo"].value<std::string>()) {
logoImagePath_ = *logo;
}
}
}
// ============================================================================
// Globals parsing
// ============================================================================
void UISchema::parseGlobals(const void* dataObj) {
const toml::table& t = *static_cast<const toml::table*>(dataObj);
if (auto* btn = t["button"].as_table()) {
detail::parseButtonStyle(static_cast<const void*>(btn), globalButton_);
}
if (auto* inp = t["input"].as_table()) {
detail::parseInputStyle(static_cast<const void*>(inp), globalInput_);
}
if (auto* lbl = t["label"].as_table()) {
detail::parseLabelStyle(static_cast<const void*>(lbl), globalLabel_);
}
if (auto* tbl = t["table"].as_table()) {
detail::parseTableStyle(static_cast<const void*>(tbl), globalTable_);
}
if (auto* cb = t["checkbox"].as_table()) {
detail::parseCheckboxStyle(static_cast<const void*>(cb), globalCheckbox_);
}
if (auto* cmb = t["combo"].as_table()) {
detail::parseComboStyle(static_cast<const void*>(cmb), globalCombo_);
}
if (auto* sld = t["slider"].as_table()) {
detail::parseSliderStyle(static_cast<const void*>(sld), globalSlider_);
}
if (auto* win = t["window"].as_table()) {
detail::parseWindowStyle(static_cast<const void*>(win), globalWindow_);
}
if (auto* sep = t["separator"].as_table()) {
detail::parseSeparatorStyle(static_cast<const void*>(sep), globalSeparator_);
}
}
// ============================================================================
// Section parsing — store TOML tables for lazy access
// ============================================================================
void UISchema::parseSections(const void* dataObj, const std::string& prefix) {
const toml::table& t = *static_cast<const toml::table*>(dataObj);
for (auto&& [sectionName, sectionNode] : t) {
auto* sectionTable = sectionNode.as_table();
if (!sectionTable) continue;
std::string sectionPath = prefix + "." + std::string(sectionName.str());
for (auto&& [elemName, elemNode] : *sectionTable) {
auto* elemTable = elemNode.as_table();
if (!elemTable) continue;
std::string key = sectionPath + "." + std::string(elemName.str());
elements_[key] = StoredElement{ toml::table(*elemTable) };
// Recurse into nested sub-sections (3rd level)
// e.g., tabs.balance.classic.logo-opacity → stored as
// "tabs.balance.classic.logo-opacity"
for (auto&& [innerName, innerNode] : *elemTable) {
if (auto* innerTable = innerNode.as_table()) {
std::string innerKey = key + "." + std::string(innerName.str());
elements_[innerKey] = StoredElement{ toml::table(*innerTable) };
}
}
}
}
}
void UISchema::parseFlatSection(const void* dataObj, const std::string& prefix) {
const toml::table& t = *static_cast<const toml::table*>(dataObj);
for (auto&& [elemName, elemNode] : t) {
std::string key = prefix + "." + std::string(elemName.str());
if (auto* elemTable = elemNode.as_table()) {
// Check if this is a leaf element (values are primitives)
// or a nested sub-section (values are objects)
bool hasNestedObjects = false;
for (auto&& [innerName, innerNode] : *elemTable) {
if (innerNode.is_table()) {
hasNestedObjects = true;
break;
}
}
if (hasNestedObjects) {
// Nested sub-section (e.g., inline-dialogs.about.{width,height,...})
// Store each inner object as prefix.elemName.innerName
for (auto&& [innerName, innerNode] : *elemTable) {
if (auto* innerTable = innerNode.as_table()) {
std::string innerKey = key + "." + std::string(innerName.str());
elements_[innerKey] = StoredElement{ toml::table(*innerTable) };
}
}
// Also store the sub-section itself as a flat element
elements_[key] = StoredElement{ toml::table(*elemTable) };
} else {
// Leaf element (e.g., business.block-reward: {"size": 1.5625})
elements_[key] = StoredElement{ toml::table(*elemTable) };
}
} else if (elemNode.is_integer() || elemNode.is_floating_point()) {
// Auto-wrap scalar number → {size = value}
toml::table wrapped;
wrapped.insert("size", elemNode.value<double>().value_or(0.0));
elements_[key] = StoredElement{ std::move(wrapped) };
} else if (elemNode.is_string()) {
// Auto-wrap string → {font = value} or {color = value}
std::string s = *elemNode.value<std::string>();
toml::table wrapped;
if (s.find("var(") == 0 || s.find("#") == 0 || s.find("rgba") == 0) {
wrapped.insert("color", s);
} else {
wrapped.insert("font", s);
}
elements_[key] = StoredElement{ std::move(wrapped) };
} else if (auto* arr = elemNode.as_array(); arr && arr->size() >= 2) {
// Auto-wrap [x, y] array → {width = x, height = y}
toml::table wrapped;
wrapped.insert("width", (*arr)[0].value<double>().value_or(0.0));
wrapped.insert("height", (*arr)[1].value<double>().value_or(0.0));
elements_[key] = StoredElement{ std::move(wrapped) };
}
}
}
// ============================================================================
// Breakpoint parsing
// ============================================================================
void UISchema::parseBreakpoints(const void* jsonObj) {
detail::parseBreakpointConfig(jsonObj, breakpoints_);
}
// ============================================================================
// Element lookup — find stored TOML table by key
// ============================================================================
const void* UISchema::findElement(const std::string& section, const std::string& name) const {
std::string key = section + "." + name;
auto it = elements_.find(key);
if (it == elements_.end()) {
return nullptr;
}
// Return pointer to the stored toml::table via std::any_cast
return std::any_cast<toml::table>(&it->second.data);
}
// ============================================================================
// Hot-reload
// ============================================================================
void UISchema::pollForChanges() {
if (!loaded_ || currentPath_.empty()) return;
double now = ImGui::GetTime();
if (now - lastPollTime_ < pollInterval_) return;
lastPollTime_ = now;
try {
auto mtime = std::filesystem::last_write_time(currentPath_);
if (mtime != lastModTime_) {
lastModTime_ = mtime;
dirty_ = true;
}
// Also check base file when an overlay is active
if (!dirty_ && !overlayPath_.empty() && !basePath_.empty() && basePath_ != currentPath_) {
auto btime = std::filesystem::last_write_time(basePath_);
if (btime != baseModTime_) {
baseModTime_ = btime;
dirty_ = true;
}
}
} catch (const std::filesystem::filesystem_error&) {
// File might be mid-write — ignore
}
}
void UISchema::applyIfDirty() {
if (!dirty_) return;
dirty_ = false;
// Snapshot font sizes before reload for change detection
static const char* fontKeys[] = {
"h1", "h2", "h3", "h4", "h5", "h6",
"subtitle1", "subtitle2", "body1", "body2",
"button", "button-sm", "button-lg",
"caption", "overline", "scale"
};
float prevFonts[16];
for (int i = 0; i < 16; ++i) {
prevFonts[i] = drawElement("fonts", fontKeys[i]).size;
}
// Snapshot image paths before reload for change detection
std::string prevBgImage = backgroundImagePath_;
std::string prevLogoImage = logoImagePath_;
DEBUG_LOGF("[UISchema] Hot-reload: re-parsing %s\n", currentPath_.c_str());
// If an overlay is active, reload base first then re-merge overlay
if (!overlayPath_.empty() && !basePath_.empty()) {
loadFromFile(basePath_);
mergeOverlayFromFile(overlayPath_);
} else {
loadFromFile(currentPath_);
}
// Detect font size changes
for (int i = 0; i < 16; ++i) {
float cur = drawElement("fonts", fontKeys[i]).size;
if (cur != prevFonts[i]) {
fonts_changed_ = true;
DEBUG_LOGF("[UISchema] Font sizes changed, atlas rebuild needed\n");
break;
}
}
// Re-apply Material Design colors to ImGui from the reloaded palette
reapplyColorsToImGui();
// Detect image path changes and update SkinManager + trigger reload
if (backgroundImagePath_ != prevBgImage || logoImagePath_ != prevLogoImage) {
DEBUG_LOGF("[UISchema] Hot-reload: image paths changed (bg: '%s' → '%s', logo: '%s' → '%s')\n",
prevBgImage.c_str(), backgroundImagePath_.c_str(),
prevLogoImage.c_str(), logoImagePath_.c_str());
auto& skinMgr = SkinManager::instance();
std::string activeId = skinMgr.activeSkinId();
if (!activeId.empty()) {
// Re-resolve image paths from the new filenames
skinMgr.resolveAndReloadImages(activeId, currentPath_);
}
}
}
// ============================================================================
// Color resolution
// ============================================================================
ImU32 UISchema::resolveColor(const std::string& ref, ImU32 fallback) const {
return colorResolver_.resolve(ref, fallback);
}
void UISchema::reapplyColorsToImGui() {
// Build a ColorTheme from the current palette
const auto& pal = colorResolver_.palette();
auto get = [&](const std::string& key, ImU32 fallback = 0) -> ImU32 {
auto it = pal.find(key);
return it != pal.end() ? it->second : fallback;
};
material::ColorTheme theme{};
theme.primary = get("--primary");
theme.primaryVariant = get("--primary-variant");
theme.primaryLight = get("--primary-light");
theme.secondary = get("--secondary");
theme.secondaryVariant = get("--secondary-variant");
theme.secondaryLight = get("--secondary-light");
theme.background = get("--background");
theme.surface = get("--surface");
theme.surfaceVariant = get("--surface-variant");
theme.onPrimary = get("--on-primary");
theme.onSecondary = get("--on-secondary");
theme.onBackground = get("--on-background");
theme.onSurface = get("--on-surface");
theme.onSurfaceMedium = get("--on-surface-medium");
theme.onSurfaceDisabled= get("--on-surface-disabled");
theme.error = get("--error");
theme.onError = get("--on-error");
theme.success = get("--success");
theme.onSuccess = get("--on-success");
theme.warning = get("--warning");
theme.onWarning = get("--on-warning");
theme.divider = get("--divider");
theme.outline = get("--outline");
theme.scrim = get("--scrim");
// Fill missing fields with defaults
ThemeLoader::computeDefaults(theme, darkTheme_);
// Apply to ImGui and update acrylic theme
material::ApplyColorThemeToImGui(theme);
SetCurrentAcrylicTheme(ThemeLoader::deriveAcrylicTheme(theme));
// Background colors changed — re-capture on next frame
effects::ImGuiAcrylic::InvalidateCapture();
DEBUG_LOGF("[UISchema] Hot-reload: re-applied colors to ImGui\n");
}
// ============================================================================
// Font resolution
// ============================================================================
ImFont* UISchema::resolveFont(const std::string& fontName) const {
if (fontName.empty()) return nullptr;
auto& typo = material::Typography::instance();
if (!typo.isLoaded()) return nullptr;
// Match font name strings to Typography accessors
if (fontName == "h1") return typo.h1();
if (fontName == "h2") return typo.h2();
if (fontName == "h3") return typo.h3();
if (fontName == "h4") return typo.h4();
if (fontName == "h5") return typo.h5();
if (fontName == "h6") return typo.h6();
if (fontName == "subtitle1") return typo.subtitle1();
if (fontName == "subtitle2") return typo.subtitle2();
if (fontName == "body1") return typo.body1();
if (fontName == "body2") return typo.body2();
if (fontName == "button") return typo.button();
if (fontName == "button-sm") return typo.buttonSm();
if (fontName == "button-lg") return typo.buttonLg();
if (fontName == "caption") return typo.caption();
if (fontName == "overline") return typo.overline();
DEBUG_LOGF("[UISchema] Warning: unknown font name '%s'\n", fontName.c_str());
return nullptr;
}
// ============================================================================
// Responsive breakpoint
// ============================================================================
Breakpoint UISchema::currentBreakpoint() const {
ImVec2 size = ImGui::GetMainViewport()->Size;
// Check compact (must satisfy ALL specified constraints)
const auto& c = breakpoints_.compact;
bool isCompact = false;
if (c.maxWidth > 0 || c.maxHeight > 0) {
isCompact = true;
if (c.maxWidth > 0 && size.x > c.maxWidth) isCompact = false;
if (c.maxHeight > 0 && size.y > c.maxHeight) isCompact = false;
}
if (isCompact) return Breakpoint::Compact;
// Check expanded
const auto& e = breakpoints_.expanded;
bool isExpanded = false;
if (e.minWidth > 0 || e.minHeight > 0) {
isExpanded = true;
if (e.minWidth > 0 && size.x < e.minWidth) isExpanded = false;
if (e.minHeight > 0 && size.y < e.minHeight) isExpanded = false;
}
if (isExpanded) return Breakpoint::Expanded;
return Breakpoint::Normal;
}
// ============================================================================
// Element lookups with merge
// ============================================================================
ButtonStyle UISchema::button(const std::string& section, const std::string& name) const {
// Start with global defaults
ButtonStyle result = globalButton_;
// Overlay section-specific values
const void* elem = findElement(section, name);
if (elem) {
ButtonStyle sectionStyle;
detail::parseButtonStyle(elem, sectionStyle);
mergeButton(result, sectionStyle);
// Check for responsive overrides
const toml::table& t = *static_cast<const toml::table*>(elem);
Breakpoint bp = currentBreakpoint();
if (bp == Breakpoint::Compact) {
if (auto* compactTable = t["@compact"].as_table()) {
ResponsiveButtonOverride ovr;
detail::parseResponsiveButtonOverride(static_cast<const void*>(compactTable), ovr);
applyResponsiveButton(result, ovr);
}
} else if (bp == Breakpoint::Expanded) {
if (auto* expandedTable = t["@expanded"].as_table()) {
ResponsiveButtonOverride ovr;
detail::parseResponsiveButtonOverride(static_cast<const void*>(expandedTable), ovr);
applyResponsiveButton(result, ovr);
}
}
}
return result;
}
InputStyle UISchema::input(const std::string& section, const std::string& name) const {
InputStyle result = globalInput_;
const void* elem = findElement(section, name);
if (elem) {
InputStyle sectionStyle;
detail::parseInputStyle(elem, sectionStyle);
mergeInput(result, sectionStyle);
}
return result;
}
LabelStyle UISchema::label(const std::string& section, const std::string& name) const {
LabelStyle result = globalLabel_;
const void* elem = findElement(section, name);
if (elem) {
LabelStyle sectionStyle;
detail::parseLabelStyle(elem, sectionStyle);
mergeLabel(result, sectionStyle);
}
return result;
}
TableStyle UISchema::table(const std::string& section, const std::string& name) const {
TableStyle result = globalTable_;
const void* elem = findElement(section, name);
if (elem) {
TableStyle sectionStyle;
detail::parseTableStyle(elem, sectionStyle);
// Merge table fields
if (sectionStyle.minHeight >= 0) result.minHeight = sectionStyle.minHeight;
if (sectionStyle.heightRatio >= 0) result.heightRatio = sectionStyle.heightRatio;
if (sectionStyle.bottomReserve >= 0) result.bottomReserve = sectionStyle.bottomReserve;
if (sectionStyle.rowHeight >= 0) result.rowHeight = sectionStyle.rowHeight;
if (!sectionStyle.headerFont.empty()) result.headerFont = sectionStyle.headerFont;
if (!sectionStyle.cellFont.empty()) result.cellFont = sectionStyle.cellFont;
if (!sectionStyle.borderColor.empty()) result.borderColor = sectionStyle.borderColor;
if (!sectionStyle.stripeColor.empty()) result.stripeColor = sectionStyle.stripeColor;
// Columns: section columns override/extend global columns
for (auto& [colName, colStyle] : sectionStyle.columns) {
result.columns[colName] = colStyle;
}
}
return result;
}
CheckboxStyle UISchema::checkbox(const std::string& section, const std::string& name) const {
CheckboxStyle result = globalCheckbox_;
const void* elem = findElement(section, name);
if (elem) {
CheckboxStyle sectionStyle;
detail::parseCheckboxStyle(elem, sectionStyle);
if (!sectionStyle.font.empty()) result.font = sectionStyle.font;
if (!sectionStyle.color.empty()) result.color = sectionStyle.color;
if (!sectionStyle.checkColor.empty()) result.checkColor = sectionStyle.checkColor;
if (!sectionStyle.background.empty()) result.background = sectionStyle.background;
}
return result;
}
ComboStyle UISchema::combo(const std::string& section, const std::string& name) const {
ComboStyle result = globalCombo_;
const void* elem = findElement(section, name);
if (elem) {
ComboStyle sectionStyle;
detail::parseComboStyle(elem, sectionStyle);
if (sectionStyle.width > 0) result.width = sectionStyle.width;
if (!sectionStyle.font.empty()) result.font = sectionStyle.font;
if (!sectionStyle.color.empty()) result.color = sectionStyle.color;
if (!sectionStyle.background.empty()) result.background = sectionStyle.background;
if (sectionStyle.borderRadius >= 0) result.borderRadius = sectionStyle.borderRadius;
if (sectionStyle.truncate > 0) result.truncate = sectionStyle.truncate;
}
return result;
}
SliderStyle UISchema::slider(const std::string& section, const std::string& name) const {
SliderStyle result = globalSlider_;
const void* elem = findElement(section, name);
if (elem) {
SliderStyle sectionStyle;
detail::parseSliderStyle(elem, sectionStyle);
if (sectionStyle.width > 0) result.width = sectionStyle.width;
if (!sectionStyle.trackColor.empty()) result.trackColor = sectionStyle.trackColor;
if (!sectionStyle.fillColor.empty()) result.fillColor = sectionStyle.fillColor;
if (!sectionStyle.thumbColor.empty()) result.thumbColor = sectionStyle.thumbColor;
if (sectionStyle.thumbRadius >= 0) result.thumbRadius = sectionStyle.thumbRadius;
}
return result;
}
WindowStyle UISchema::window(const std::string& section, const std::string& name) const {
WindowStyle result = globalWindow_;
const void* elem = findElement(section, name);
if (elem) {
WindowStyle sectionStyle;
detail::parseWindowStyle(elem, sectionStyle);
mergeWindow(result, sectionStyle);
}
return result;
}
SeparatorStyle UISchema::separator(const std::string& section, const std::string& name) const {
SeparatorStyle result = globalSeparator_;
const void* elem = findElement(section, name);
if (elem) {
SeparatorStyle sectionStyle;
detail::parseSeparatorStyle(elem, sectionStyle);
if (!sectionStyle.color.empty()) result.color = sectionStyle.color;
if (sectionStyle.thickness >= 0) result.thickness = sectionStyle.thickness;
if (sectionStyle.margin[0] > 0 || sectionStyle.margin[1] > 0) {
result.margin[0] = sectionStyle.margin[0];
result.margin[1] = sectionStyle.margin[1];
}
}
return result;
}
DrawElementStyle UISchema::drawElement(const std::string& section, const std::string& name) const {
DrawElementStyle result;
const void* elem = findElement(section, name);
if (elem) {
detail::parseDrawElementStyle(elem, result);
}
return result;
}
} // namespace schema
} // namespace ui
} // namespace dragonx

375
src/ui/schema/ui_schema.h Normal file
View File

@@ -0,0 +1,375 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
#include "color_var_resolver.h"
#include "element_styles.h"
#include "imgui.h"
#include <string>
#include <unordered_map>
#include <any>
#include <filesystem>
namespace dragonx {
namespace ui {
namespace schema {
/**
* @brief Responsive breakpoint state
*/
enum class Breakpoint {
Compact, // Small window (e.g., < 500px wide)
Normal, // Default
Expanded // Large window (e.g., > 900px wide)
};
/**
* @brief Unified UI schema singleton
*
* Loads theme palette, globals, and per-section element styles from a single
* TOML file. Provides merged lookups (section → globals → fallback) and
* poll-based hot-reload.
*
* Usage:
* auto& UI = UISchema::instance();
* UI.loadFromFile("res/themes/ui.toml");
*
* // In main loop:
* UI.pollForChanges();
* UI.applyIfDirty();
*
* // Lookups:
* auto btn = UI.button("dialogs.about", "close-button");
* auto win = UI.window("dialogs.about");
* ImU32 col = UI.resolveColor("var(--primary)");
*/
class UISchema {
public:
/**
* @brief Get the singleton instance
*/
static UISchema& instance();
/**
* @brief Load a unified skin file
* @param path Path to TOML skin file (e.g., "res/themes/ui.toml")
* @return true if loaded successfully
*/
bool loadFromFile(const std::string& path);
/**
* @brief Load from a TOML string (e.g., embedded data fallback)
* @param tomlStr Raw TOML text
* @param label Human-readable label for log messages
* @return true if parsed successfully
*/
bool loadFromString(const std::string& tomlStr, const std::string& label = "embedded");
/**
* @brief Merge a theme overlay on top of the currently loaded base.
*
* Only replaces visual properties (theme palette/elevation/images and
* backdrop section). Layout values from the base are preserved.
*
* @param path Path to the overlay TOML (e.g., dark.toml, light.toml)
* @return true if overlay was parsed and merged successfully
*/
bool mergeOverlayFromFile(const std::string& path);
/**
* @brief Check if a schema file has been loaded successfully
*/
bool isLoaded() const { return loaded_; }
/**
* @brief Get the path of the currently loaded file
*/
const std::string& currentPath() const { return currentPath_; }
/**
* @brief Get the base layout file path (ui.toml)
*/
const std::string& basePath() const { return basePath_; }
/**
* @brief Check if the base theme was loaded from an embedded string.
* When true, reloadBase() uses the stored embedded data instead of a file.
*/
bool hasEmbeddedBase() const { return !embeddedTomlStr_.empty(); }
/**
* @brief Reload the base theme (ui.toml) from file or embedded data.
* Used by SkinManager before merging an overlay to ensure layout values
* are always restored from the authoritative base, even on platforms
* where ui.toml is embedded (e.g., single-file Windows distribution).
* @return true if base was reloaded successfully
*/
bool reloadBase();
/**
* @brief Get the active overlay file path (empty if no overlay)
*/
const std::string& overlayPath() const { return overlayPath_; }
// ====================================================================
// Hot-reload
// ====================================================================
/**
* @brief Poll for file changes (call from main loop)
*
* Checks file modification time every pollInterval_ seconds.
* Sets dirty flag if file has changed.
*/
void pollForChanges();
/**
* @brief Check if the schema needs re-applying
*/
bool isDirty() const { return dirty_; }
/**
* @brief Re-parse and apply if file has changed
*/
void applyIfDirty();
/**
* @brief Check and consume the fonts-changed flag.
* Returns true once after a hot-reload that changed font sizes.
*/
bool consumeFontsChanged() {
bool v = fonts_changed_;
fonts_changed_ = false;
return v;
}
/**
* @brief Re-apply Material Design colors from schema palette to ImGui
*
* Builds a ColorTheme from the current palette and applies to ImGui style.
* Called automatically during hot-reload.
*/
void reapplyColorsToImGui();
/**
* @brief Set the poll interval in seconds (default 0.5)
*/
void setPollInterval(float seconds) { pollInterval_ = seconds; }
// ====================================================================
// Color resolution
// ====================================================================
/**
* @brief Get the color variable resolver (for direct access)
*/
const ColorVarResolver& colors() const { return colorResolver_; }
/**
* @brief Resolve a color string to ImU32
* @param ref Color string: "var(--primary)", "#hex", "rgba(...)", etc.
* @param fallback Value if resolution fails
*/
ImU32 resolveColor(const std::string& ref, ImU32 fallback = IM_COL32(0,0,0,0)) const;
// ====================================================================
// Font resolution
// ====================================================================
/**
* @brief Resolve a font name string to ImFont*
* @param fontName e.g., "button", "button-sm", "button-lg", "h4", "body1"
* @return ImFont* pointer, or nullptr if name is empty/unknown
*/
ImFont* resolveFont(const std::string& fontName) const;
// ====================================================================
// Responsive
// ====================================================================
/**
* @brief Get current breakpoint based on window/viewport size
*/
Breakpoint currentBreakpoint() const;
/**
* @brief Get the breakpoint config
*/
const BreakpointConfig& breakpoints() const { return breakpoints_; }
// ====================================================================
// Element lookups — merged (section → globals → C++ default)
// ====================================================================
/**
* @brief Look up a button style
* @param section Dot-separated section path: "dialogs.about", "tabs.send"
* @param name Element name within the section: "close-button"
* @return Merged ButtonStyle (responsive overrides already applied)
*/
ButtonStyle button(const std::string& section, const std::string& name) const;
/**
* @brief Look up an input style
*/
InputStyle input(const std::string& section, const std::string& name) const;
/**
* @brief Look up a label style
*/
LabelStyle label(const std::string& section, const std::string& name) const;
/**
* @brief Look up a table style
*/
TableStyle table(const std::string& section, const std::string& name) const;
/**
* @brief Look up a checkbox style
*/
CheckboxStyle checkbox(const std::string& section, const std::string& name) const;
/**
* @brief Look up a combo style
*/
ComboStyle combo(const std::string& section, const std::string& name) const;
/**
* @brief Look up a slider style
*/
SliderStyle slider(const std::string& section, const std::string& name) const;
/**
* @brief Look up a window/dialog style
* @param section Section path: "dialogs.about"
* @param name Defaults to "window" — the window block within that section
*/
WindowStyle window(const std::string& section, const std::string& name = "window") const;
/**
* @brief Look up a separator style
*/
SeparatorStyle separator(const std::string& section, const std::string& name) const;
/**
* @brief Look up a DrawList custom element style
*/
DrawElementStyle drawElement(const std::string& section, const std::string& name) const;
/**
* @brief Find a raw stored element by section and name.
* Returns an opaque pointer to the stored data (toml::table* at runtime),
* or nullptr if not found. Consumer must cast via
* static_cast<const toml::table*>(ptr) after including <toml++/toml.hpp>.
*/
const void* findElement(const std::string& section, const std::string& name) const;
// ====================================================================
// Global defaults access
// ====================================================================
const ButtonStyle& defaultButton() const { return globalButton_; }
const InputStyle& defaultInput() const { return globalInput_; }
const LabelStyle& defaultLabel() const { return globalLabel_; }
const TableStyle& defaultTable() const { return globalTable_; }
const CheckboxStyle& defaultCheckbox() const { return globalCheckbox_; }
const ComboStyle& defaultCombo() const { return globalCombo_; }
const SliderStyle& defaultSlider() const { return globalSlider_; }
const WindowStyle& defaultWindow() const { return globalWindow_; }
const SeparatorStyle& defaultSeparator() const { return globalSeparator_; }
// ====================================================================
// Theme metadata
// ====================================================================
const std::string& themeName() const { return themeName_; }
bool isDarkTheme() const { return darkTheme_; }
/**
* @brief Monotonically increasing generation counter.
* Incremented on every loadFromFile / mergeOverlay / applyIfDirty.
* Downstream caches compare against this to invalidate.
*/
uint32_t generation() const { return generation_; }
/**
* @brief Get theme-specified background image path (empty = use default)
*/
const std::string& backgroundImagePath() const { return backgroundImagePath_; }
/**
* @brief Get theme-specified logo image path (empty = use default)
*/
const std::string& logoImagePath() const { return logoImagePath_; }
private:
UISchema() = default;
~UISchema() = default;
UISchema(const UISchema&) = delete;
UISchema& operator=(const UISchema&) = delete;
// Parse top-level sections from a TOML table (passed as opaque void*)
void parseTheme(const void* dataObj);
void parseGlobals(const void* dataObj);
void parseSections(const void* dataObj, const std::string& prefix);
void parseFlatSection(const void* dataObj, const std::string& prefix);
void parseBreakpoints(const void* dataObj);
// ====================================================================
// State
// ====================================================================
bool loaded_ = false;
std::string currentPath_; ///< Path currently loaded (base or overlay for hot-reload)
std::string basePath_; ///< Path to the base layout file (ui.toml)
std::string overlayPath_; ///< Path to active overlay file (empty if base-only)
std::string embeddedTomlStr_; ///< Raw TOML string from loadFromString (for reload)
// Hot-reload
float pollInterval_ = 0.5f;
double lastPollTime_ = 0;
std::filesystem::file_time_type lastModTime_;
std::filesystem::file_time_type baseModTime_; ///< Track base file changes when overlay active
bool dirty_ = false;
bool fonts_changed_ = false;
// Theme
std::string themeName_;
bool darkTheme_ = true;
std::string backgroundImagePath_;
std::string logoImagePath_;
ColorVarResolver colorResolver_;
uint32_t generation_ = 0; ///< bumped on every load/merge/reload
// Breakpoints
BreakpointConfig breakpoints_;
// Global defaults
ButtonStyle globalButton_;
InputStyle globalInput_;
LabelStyle globalLabel_;
TableStyle globalTable_;
CheckboxStyle globalCheckbox_;
ComboStyle globalCombo_;
SliderStyle globalSlider_;
WindowStyle globalWindow_;
SeparatorStyle globalSeparator_;
// Per-section element storage
// Key: "section.elementName" → stored TOML table (accessed via std::any_cast)
// This avoids storing every possible style struct up front —
// we only parse what's actually looked up.
struct StoredElement {
std::any data; // holds toml::table at runtime
};
std::unordered_map<std::string, StoredElement> elements_;
};
// Convenience alias
inline UISchema& UI() { return UISchema::instance(); }
} // namespace schema
} // namespace ui
} // namespace dragonx