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:
186
src/ui/schema/color_var_resolver.cpp
Normal file
186
src/ui/schema/color_var_resolver.cpp
Normal 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
|
||||
75
src/ui/schema/color_var_resolver.h
Normal file
75
src/ui/schema/color_var_resolver.h
Normal 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
|
||||
294
src/ui/schema/element_styles.cpp
Normal file
294
src/ui/schema/element_styles.cpp
Normal 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
|
||||
273
src/ui/schema/element_styles.h
Normal file
273
src/ui/schema/element_styles.h
Normal 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
|
||||
800
src/ui/schema/skin_manager.cpp
Normal file
800
src/ui/schema/skin_manager.cpp
Normal 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
|
||||
192
src/ui/schema/skin_manager.h
Normal file
192
src/ui/schema/skin_manager.h
Normal 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
852
src/ui/schema/ui_schema.cpp
Normal 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
375
src/ui/schema/ui_schema.h
Normal 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
|
||||
Reference in New Issue
Block a user