Files
ObsidianDragon/src/ui/windows/balance_tab.cpp
DanS c7c3440a7b feat(diagnostics): refresh-staleness badge on the Total Balance card (W6-2)
When the wallet is connected but the balance has quietly stopped refreshing — a busy
daemon can fail z_gettotalbalance without dropping the whole connection (only *both*
core RPCs failing 3x triggers a disconnect) — the old number sits on screen while the
node-status banner stays hidden. The Total Balance card now shows a small pill on its
status line ("Updated 2m ago", amber, escalating to red past 3 min) so the stale value
isn't silently trusted; hovering explains it and points at the node connection.

No refresh-path changes: WalletState::last_balance_update is already stamped only on a
successful fetch (network_refresh_service.cpp), so the badge reads it and computes age
against the same std::time clock via util::formatTimeAgoShort. The decision is a pure,
unit-tested helper (ui/staleness_badge.h::evaluateStalenessBadge, 45s/180s thresholds)
gated on connected so it never contradicts the banner.

Closes P2 (5/5). Build-clean; ctest 1/1 (adds testStalenessBadge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 20:48:56 -05:00

2139 lines
100 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "balance_tab.h"
#include "balance_address_list.h"
#include "balance_tab_helpers.h"
#include "balance_recent_tx.h"
#include "balance_components.h"
#include "mining_tab_helpers.h" // FormatHashrate (consistent MH/GH/.. scaling)
#include "key_export_dialog.h"
#include "qr_popup_dialog.h"
#include "address_label_dialog.h"
#include "address_transfer_dialog.h"
#include "send_tab.h"
#include "../../app.h"
#include "../../config/settings.h"
#include "../../config/version.h"
#include "../../util/i18n.h"
#include "../../util/text_format.h"
#include "../theme.h"
#include "../layout.h"
#include "../schema/ui_schema.h"
#include "../material/type.h"
#include "../material/draw_helpers.h"
#include "../effects/imgui_acrylic.h"
#include "../sidebar.h"
#include "../notifications.h"
#include "../staleness_badge.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "imgui.h"
#include <toml++/toml.hpp>
#include <algorithm>
#include <cstring>
#include <ctime>
#include <cmath>
#include "../../util/logger.h"
namespace dragonx {
namespace ui {
// Animated balance state — lerps smoothly toward target. Non-static (declared extern in
// balance_components.h) so the shared components in balance_components.cpp share the same values.
double s_dispTotal = 0.0;
double s_dispShielded = 0.0;
double s_dispTransparent = 0.0;
double s_dispUnconfirmed = 0.0;
// Forward declarations for all layout functions
static void RenderBalanceClassic(App* app);
static void RenderBalanceDonut(App* app);
static void RenderBalanceConsolidated(App* app);
static void RenderBalanceDashboard(App* app);
static void RenderBalanceVerticalStack(App* app);
static void RenderBalanceVertical2x2(App* app);
static void RenderBalanceShield(App* app);
static void RenderBalanceTimeline(App* app);
static void RenderBalanceTwoRow(App* app);
static void RenderBalanceMinimal(App* app);
// ============================================================================
// Layout config — parsed from ui.toml [tabs.balance.layouts]
// ============================================================================
// Legacy int→string ID mapping for old settings.json migration
static const char* s_legacyLayoutIds[] = {
"classic", "donut", "consolidated", "dashboard",
"vertical-stack", "vertical-2x2", "shield", "timeline", "two-row", "minimal"
};
static constexpr int s_legacyLayoutCount = 10;
static std::vector<BalanceLayoutEntry> s_balanceLayouts;
static std::string s_defaultLayoutId = "classic";
static bool s_layoutConfigLoaded = false;
bool s_generating_z_address = false; // external linkage — shared with balance_components.cpp
static void LoadBalanceLayoutConfig()
{
s_balanceLayouts.clear();
const void* elem = schema::UI().findElement("tabs.balance", "layouts");
if (elem) {
const auto& t = *static_cast<const toml::table*>(elem);
if (auto selected = t["selected"].value<std::string>())
s_defaultLayoutId = *selected;
else if (auto def = t["default"].value<std::string>())
s_defaultLayoutId = *def;
if (auto* options = t["options"].as_array()) {
for (auto& item : *options) {
auto* opt = item.as_table();
if (!opt) continue;
auto id = (*opt)["id"].value<std::string>();
auto name = (*opt)["name"].value<std::string>();
if (!id || !name) continue;
BalanceLayoutEntry entry;
entry.id = *id;
entry.name = *name;
entry.enabled = (*opt)["enabled"].value_or(true);
s_balanceLayouts.push_back(std::move(entry));
}
}
}
// Fallback if ui.toml had no layouts defined
if (s_balanceLayouts.empty()) {
for (int i = 0; i < s_legacyLayoutCount; i++) {
BalanceLayoutEntry entry;
entry.id = s_legacyLayoutIds[i];
// Capitalize first letter for display name
entry.name = entry.id;
if (!entry.name.empty())
entry.name[0] = (char)toupper((unsigned char)entry.name[0]);
s_balanceLayouts.push_back(std::move(entry));
}
}
s_layoutConfigLoaded = true;
}
const std::vector<BalanceLayoutEntry>& GetBalanceLayouts()
{
if (!s_layoutConfigLoaded) LoadBalanceLayoutConfig();
return s_balanceLayouts;
}
const std::string& GetDefaultBalanceLayout()
{
if (!s_layoutConfigLoaded) LoadBalanceLayoutConfig();
return s_defaultLayoutId;
}
void RefreshBalanceLayoutConfig()
{
s_layoutConfigLoaded = false;
}
std::string MigrateBalanceLayoutIndex(int index)
{
if (index >= 0 && index < s_legacyLayoutCount)
return s_legacyLayoutIds[index];
return "classic";
}
// Layout ID → render function dispatch
using LayoutRenderFn = void(*)(App*);
struct LayoutDispatchEntry { const char* id; LayoutRenderFn fn; };
static const LayoutDispatchEntry s_layoutDispatch[] = {
{ "classic", RenderBalanceClassic },
{ "donut", RenderBalanceDonut },
{ "consolidated", RenderBalanceConsolidated },
{ "dashboard", RenderBalanceDashboard },
{ "vertical-stack", RenderBalanceVerticalStack },
{ "vertical-2x2", RenderBalanceVertical2x2 },
{ "shield", RenderBalanceShield },
{ "timeline", RenderBalanceTimeline },
{ "two-row", RenderBalanceTwoRow },
{ "minimal", RenderBalanceMinimal },
};
void RenderBalanceTab(App* app)
{
std::string layoutId = GetDefaultBalanceLayout();
if (app->settings()) {
std::string saved = app->settings()->getBalanceLayout();
if (!saved.empty()) layoutId = saved;
}
// Left/Right arrows: cycle through enabled balance layouts
// (skip when Ctrl is held — Ctrl+Arrow cycles themes instead)
if (app->settings() && !ImGui::GetIO().WantTextInput && !ImGui::GetIO().KeyCtrl) {
bool cycleUp = ImGui::IsKeyPressed(ImGuiKey_LeftArrow);
bool cycleDown = ImGui::IsKeyPressed(ImGuiKey_RightArrow);
if (cycleUp || cycleDown) {
const auto& layouts = GetBalanceLayouts();
// Build list of enabled layout IDs
std::vector<std::string> enabled;
for (const auto& l : layouts)
if (l.enabled) enabled.push_back(l.id);
if (!enabled.empty()) {
int cur = 0;
for (int i = 0; i < (int)enabled.size(); i++) {
if (enabled[i] == layoutId) { cur = i; break; }
}
if (cycleUp)
cur = (cur - 1 + (int)enabled.size()) % (int)enabled.size();
else
cur = (cur + 1) % (int)enabled.size();
layoutId = enabled[cur];
app->settings()->setBalanceLayout(layoutId);
// Show toast with layout name
const auto& allLayouts = GetBalanceLayouts();
std::string displayName = layoutId;
for (const auto& l : allLayouts) {
if (l.id == layoutId) { displayName = l.name; break; }
}
Notifications::instance().info("Layout: " + displayName);
}
}
}
// Dispatch by string ID
for (const auto& entry : s_layoutDispatch) {
if (layoutId == entry.id) {
entry.fn(app);
return;
}
}
// Fallback to Classic
RenderBalanceClassic(app);
}
// ============================================================================
// Layout 0: Classic (original 3-card layout)
// ============================================================================
static void RenderBalanceClassic(App* app)
{
using namespace material;
const auto& S = schema::UISchema::instance();
const auto syncBar = S.drawElement("tabs.balance", "sync-bar");
// Read layout properties from schema
const float kBalanceLerpSpeed = S.drawElement("tabs.balance", "balance-lerp-speed").sizeOr(8.0f);
const float kHeroPadTop = S.drawElement("tabs.balance", "hero-pad-top").sizeOr(12.0f);
const auto& state = app->state();
// Responsive scale factors (recomputed every frame)
ImVec2 contentAvail = ImGui::GetContentRegionAvail();
const float hs = Layout::hScale(contentAvail.x);
const float vs = Layout::vScale(contentAvail.y);
const auto tier = Layout::currentTier(contentAvail.x, contentAvail.y);
const float glassRound = Layout::glassRounding();
const float cGap = Layout::cardGap();
const float dp = Layout::dpiScale();
// Lerp displayed balances toward actual values
{
float dt = ImGui::GetIO().DeltaTime;
float speed = (app->settings() && app->settings()->getReduceMotion()) ? 999.0f : kBalanceLerpSpeed;
auto lerp = [](double& disp, double target, float dt, float spd) {
double diff = target - disp;
if (std::abs(diff) < 1e-9) { disp = target; return; }
disp += diff * (double)(dt * spd);
// Snap when very close
if (std::abs(target - disp) < 1e-9) disp = target;
};
lerp(s_dispTotal, app->getTotalBalance(), dt, speed);
lerp(s_dispShielded, app->getShieldedBalance(), dt, speed);
lerp(s_dispTransparent, app->getTransparentBalance(), dt, speed);
lerp(s_dispUnconfirmed, state.unconfirmed_balance, dt, speed);
}
// ================================================================
// Card row — Total Balance | Shielded | Transparent | Market
// ================================================================
{
float topMargin = S.drawElement("tabs.balance.classic", "top-margin").size;
if (topMargin > 0.0f)
ImGui::Dummy(ImVec2(0, topMargin));
else if (topMargin < 0.0f) {
// auto: use hero-pad-top scaled by vertical factor
float autoPad = kHeroPadTop * vs;
if (autoPad > 0.0f)
ImGui::Dummy(ImVec2(0, autoPad));
}
// topMargin == 0 → no spacing at all
const float cardGap = cGap;
float availWidth = ImGui::GetContentRegionAvail().x;
// Responsive card columns: 4 normally, 2 in compact, 1 if very narrow
int numCols = (int)S.drawElement("tabs.balance.classic", "card-num-cols").sizeOr(4.0f);
if (tier == Layout::LayoutTier::Compact) {
if (availWidth < S.drawElement("tabs.balance.classic", "card-narrow-width").sizeOr(400.0f) * Layout::dpiScale())
numCols = (int)S.drawElement("tabs.balance.classic", "card-narrow-cols").sizeOr(1.0f);
else
numCols = (int)S.drawElement("tabs.balance.classic", "card-compact-cols").sizeOr(2.0f);
}
float cardWidth = (availWidth - (float)(numCols - 1) * cardGap) / (float)numCols;
ImDrawList* dl = ImGui::GetWindowDrawList();
ImVec2 origin = ImGui::GetCursorScreenPos();
GlassPanelSpec cardSpec;
cardSpec.rounding = glassRound;
char buf[64];
ImU32 greenCol = Success();
ImU32 goldCol = Warning();
ImU32 amberCol = Warning();
ImFont* ovFont = Type().overline();
ImFont* sub1 = Type().subtitle1();
ImFont* capFont = Type().caption();
float classicPadOverride = S.drawElement("tabs.balance.classic", "card-padding").size;
float cardPadLg = (classicPadOverride >= 0.0f) ? classicPadOverride : Layout::spacingLg();
// Card height: must fit the Market card's content (overline + price + 24h)
const float ovGap = S.drawElement("tabs.balance", "overline-value-gap").sizeOr(6.0f);
const float valGap = S.drawElement("tabs.balance", "value-caption-gap").sizeOr(4.0f);
const float tickGap = S.drawElement("tabs.balance.classic", "ticker-gap").sizeOr(4.0f);
float marketContentH = cardPadLg
+ ovFont->LegacySize + ovGap
+ sub1->LegacySize + 2.0f * dp
+ capFont->LegacySize
+ cardPadLg;
float classicCardH = S.drawElement("tabs.balance.classic", "card-height").size;
float cardH;
if (classicCardH >= 0.0f) {
// TOML override scaled by dp so it grows with DPI + user font scale
cardH = std::max(classicCardH * dp, marketContentH);
} else {
float minH = S.drawElement("tabs.balance.classic", "card-min-height").sizeOr(70.0f) * dp;
cardH = std::max(StatCardHeight(vs, minH), marketContentH);
}
// Helper: draw accent stripe on left edge, clipped to card rounded corners.
// We draw a full-size rounded rect (left corners only) and clip it to the
// stripe width so the shape itself follows the card rounding.
const float accentW = S.drawElement("tabs.balance", "accent-width").sizeOr(4.0f);
auto drawAccent = [&](const ImVec2& cMin, const ImVec2& cMax, ImU32 col) {
dl->PushClipRect(cMin, ImVec2(cMin.x + accentW, cMax.y), true);
dl->AddRectFilled(cMin, cMax, col, cardSpec.rounding,
ImDrawFlags_RoundCornersLeft);
dl->PopClipRect();
};
// Helper: compute card position given card index (0-3)
auto cardPos = [&](int idx) -> ImVec2 {
int col = idx % numCols;
int row = idx / numCols;
return ImVec2(origin.x + col * (cardWidth + cardGap),
origin.y + row * (cardH + cardGap));
};
// ---- Total Balance card ----
{
ImVec2 cMin = cardPos(0);
ImVec2 cMax(cMin.x + cardWidth, cMin.y + cardH);
DrawGlassPanel(dl, cMin, cMax, cardSpec);
drawAccent(cMin, cMax, S.resolveColor("var(--accent-total)", OnSurface()));
float cx = cMin.x + cardPadLg;
float cy = cMin.y + cardPadLg;
// Coin logo (small, top-right corner)
ImTextureID logoTex = app->getCoinLogoTexture();
if (logoTex != 0) {
float logoSz = ovFont->LegacySize + sub1->LegacySize + 4.0f * dp;
float logoX = cMax.x - cardPadLg - logoSz;
float logoY = cMin.y + cardPadLg;
dl->AddImage(logoTex,
ImVec2(logoX, logoY),
ImVec2(logoX + logoSz, logoY + logoSz),
ImVec2(0, 0), ImVec2(1, 1),
IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance.classic", "logo-opacity").sizeOr(180.0f)));
}
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(cx, cy),
OnSurfaceMedium(), "TOTAL BALANCE");
cy += ovFont->LegacySize + ovGap;
snprintf(buf, sizeof(buf), "%.8f", s_dispTotal);
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, cy), OnSurface(), buf);
ImVec2 balSz = sub1->CalcTextSizeA(sub1->LegacySize, 10000, 0, buf);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cx + balSz.x + tickGap,
cy + sub1->LegacySize - capFont->LegacySize),
OnSurfaceMedium(), DRAGONX_TICKER);
cy += sub1->LegacySize + valGap;
// USD value
{
double usd_value = state.getBalanceUSD();
if (usd_value > 0.0)
snprintf(buf, sizeof(buf), "$%.2f USD", usd_value);
else
snprintf(buf, sizeof(buf), "$-.-- USD");
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy),
OnSurfaceDisabled(), buf);
}
cy += capFont->LegacySize + 2 * dp;
// Sync progress or mining indicator (whichever fits)
if (state.sync.syncing && state.sync.headers > 0) {
float pct = static_cast<float>(state.sync.verification_progress) * 100.0f;
snprintf(buf, sizeof(buf), "Syncing %.1f%%", pct);
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy),
Warning(), buf);
// Thin sync bar at card bottom — clipped to card rounded corners
float barH = (syncBar.height >= 0) ? syncBar.height : 3.0f;
float prog = static_cast<float>(state.sync.verification_progress);
if (prog > 1.0f) prog = 1.0f;
float barTop = cMax.y - barH;
// Clip to the bottom strip so the full-card rounded rect
// curves exactly match the card's own rounded corners
dl->PushClipRect(ImVec2(cMin.x, barTop), cMax, true);
// Background track
dl->AddRectFilled(cMin, cMax,
IM_COL32(255, 255, 255, 15), cardSpec.rounding);
// Progress fill — additional horizontal clip
float progRight = cMin.x + (cMax.x - cMin.x) * prog;
dl->PushClipRect(ImVec2(cMin.x, barTop), ImVec2(progRight, cMax.y), true);
dl->AddRectFilled(cMin, cMax,
WithAlpha(Warning(), 200), cardSpec.rounding);
dl->PopClipRect();
dl->PopClipRect();
} else if (state.mining.generate) {
float pulse = schema::UI().drawElement("animations", "pulse-base-normal").size
+ schema::UI().drawElement("animations", "pulse-amp-normal").size
* (float)std::sin((double)ImGui::GetTime()
* schema::UI().drawElement("animations", "pulse-speed-normal").size);
ImU32 mineCol = WithAlpha(Success(), (int)(255.0f * pulse));
dl->AddCircleFilled(ImVec2(cx + 4 * dp, cy + capFont->LegacySize * 0.5f),
S.drawElement("tabs.balance.classic", "mining-dot-radius").sizeOr(3.0f), mineCol);
double hr = state.mining.localHashrate;
snprintf(buf, sizeof(buf), " Mining %s", FormatHashrate(hr).c_str());
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cx + 12 * dp, cy),
WithAlpha(Success(), 200), buf);
} else {
// Refresh-staleness badge (W6-2): connected, not syncing/mining, but the balance
// hasn't refreshed in a while (a busy daemon can fail z_gettotalbalance without
// dropping the whole connection). The node banner only covers full disconnects, so
// this pill is the sole signal that the shown number may be out of date.
StalenessBadge badge = evaluateStalenessBadge(
state.last_balance_update, std::time(nullptr), state.connected);
if (badge.show) {
const bool err = (badge.severity == StalenessSeverity::Error);
ImU32 fg = err ? Error() : Warning();
ImU32 bg = WithAlpha(fg, 38);
ImU32 bd = WithAlpha(fg, 90);
snprintf(buf, sizeof(buf), "%s %s",
TR("data_stale_prefix"), timeAgo(state.last_balance_update).c_str());
ImVec2 pillSz = DrawPill(dl, ImVec2(cx, cy), buf, capFont, fg, bg, bd);
// Hover → explain what stale means and how old the data actually is.
if (material::IsRectHovered(ImVec2(cx, cy), ImVec2(cx + pillSz.x, cy + pillSz.y)))
Tooltip("%s", TR("data_stale_tooltip"));
}
}
// Hover glow
if (material::IsRectHovered(cMin, cMax)) {
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f));
}
}
// ---- Shielded card ----
{
ImVec2 cMin = cardPos(1);
ImVec2 cMax(cMin.x + cardWidth, cMin.y + cardH);
DrawGlassPanel(dl, cMin, cMax, cardSpec);
drawAccent(cMin, cMax, WithAlpha(S.resolveColor("var(--accent-shielded)", Success()), 200));
float cx = cMin.x + cardPadLg;
float cy = cMin.y + cardPadLg;
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(cx, cy),
OnSurfaceMedium(), TR("shielded"));
cy += ovFont->LegacySize + ovGap;
snprintf(buf, sizeof(buf), "%.8f", s_dispShielded);
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, cy), greenCol, buf);
ImVec2 balSz = sub1->CalcTextSizeA(sub1->LegacySize, 10000, 0, buf);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cx + balSz.x + tickGap,
cy + sub1->LegacySize - capFont->LegacySize),
OnSurfaceMedium(), DRAGONX_TICKER);
cy += sub1->LegacySize + valGap;
// Privacy ratio + address count
{
float privPct = (s_dispTotal > 1e-9)
? (float)(s_dispShielded / s_dispTotal * 100.0) : 0.0f;
snprintf(buf, sizeof(buf), "%.0f%% of total · %d Z-addr",
privPct, (int)state.z_addresses.size());
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy),
WithAlpha(Success(), 160), buf);
}
// Unconfirmed badge (top-right corner)
if (state.unconfirmed_balance > 0.0) {
snprintf(buf, sizeof(buf), "+%.4f", state.unconfirmed_balance);
ImVec2 ts = capFont->CalcTextSizeA(
capFont->LegacySize, 10000, 0, buf);
float bp = S.drawElement("tabs.balance.classic", "unconfirmed-badge-padding").sizeOr(4.0f);
float br = S.drawElement("tabs.balance.classic", "unconfirmed-badge-rounding").sizeOr(4.0f);
ImVec2 bMin(cMax.x - ts.x - bp * 3,
cMin.y + cardPadLg);
ImVec2 bMax(cMax.x - bp, bMin.y + ts.y + bp);
dl->AddRectFilled(bMin, bMax,
WithAlpha(Warning(), 40), br);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(bMin.x + bp, bMin.y + bp * 0.5f),
amberCol, buf);
}
// Hover glow + click to Receive
if (material::IsRectHovered(cMin, cMax)) {
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f));
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(0))
app->setCurrentPage(NavPage::Receive);
}
}
// ---- Transparent card ----
{
ImVec2 cMin = cardPos(2);
ImVec2 cMax(cMin.x + cardWidth, cMin.y + cardH);
DrawGlassPanel(dl, cMin, cMax, cardSpec);
drawAccent(cMin, cMax, WithAlpha(S.resolveColor("var(--accent-transparent)", Warning()), 200));
float cx = cMin.x + cardPadLg;
float cy = cMin.y + cardPadLg;
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(cx, cy),
OnSurfaceMedium(), TR("transparent"));
cy += ovFont->LegacySize + ovGap;
snprintf(buf, sizeof(buf), "%.8f", s_dispTransparent);
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, cy), goldCol, buf);
ImVec2 balSz = sub1->CalcTextSizeA(sub1->LegacySize, 10000, 0, buf);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cx + balSz.x + tickGap,
cy + sub1->LegacySize - capFont->LegacySize),
OnSurfaceMedium(), DRAGONX_TICKER);
cy += sub1->LegacySize + valGap;
snprintf(buf, sizeof(buf), "%d T-addresses",
(int)state.t_addresses.size());
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy),
OnSurfaceDisabled(), buf);
// Hover glow + click to Receive
if (material::IsRectHovered(cMin, cMax)) {
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f));
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(0))
app->setCurrentPage(NavPage::Receive);
}
}
// ---- Market card ----
{
ImVec2 cMin = cardPos(3);
ImVec2 cMax(cMin.x + cardWidth, cMin.y + cardH);
DrawGlassPanel(dl, cMin, cMax, cardSpec);
drawAccent(cMin, cMax, S.resolveColor("var(--accent-action)", Primary()));
float cx = cMin.x + cardPadLg;
float cy = cMin.y + cardPadLg;
// Price string (compute early to measure text width)
const auto& market = state.market;
if (market.price_usd > 0) {
if (market.price_usd >= 0.01)
snprintf(buf, sizeof(buf), "$%.4f", market.price_usd);
else if (market.price_usd >= 0.0001)
snprintf(buf, sizeof(buf), "$%.6f", market.price_usd);
else
snprintf(buf, sizeof(buf), "$%.8f", market.price_usd);
} else {
snprintf(buf, sizeof(buf), "$--.--");
}
ImVec2 pSz = sub1->CalcTextSizeA(sub1->LegacySize, 10000, 0, buf);
ImVec2 usdSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, "USD");
// Measure widest text line to determine sparkline left edge
float textW = std::max(pSz.x + tickGap + usdSz.x,
ovFont->CalcTextSizeA(ovFont->LegacySize, 10000, 0, "MARKET").x);
float sparkGap = S.drawElement("tabs.balance.classic", "sparkline-gap").sizeOr(12.0f);
float sparkLeft = cx + textW + sparkGap;
float sparkRight = cMax.x - cardPadLg;
// Left side: label + price + 24h change
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(cx, cy),
OnSurfaceMedium(), "MARKET");
cy += ovFont->LegacySize + ovGap;
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, cy),
OnSurface(), buf);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cx + pSz.x + tickGap,
cy + sub1->LegacySize - capFont->LegacySize),
OnSurfaceMedium(), "USD");
cy += sub1->LegacySize + valGap;
// 24h change
if (market.price_usd > 0) {
bool pos = market.change_24h >= 0;
ImU32 chgCol = pos ? Success()
: Error();
snprintf(buf, sizeof(buf), "%s%.1f%% 24h",
pos ? "+" : "", market.change_24h);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cx, cy), chgCol, buf);
}
// Right side: sparkline fills remaining card space
if (market.price_history.size() >= 2 && sparkLeft < sparkRight) {
float spTop = cMin.y + cardPadLg;
float spBot = cMax.y - cardPadLg;
ImVec2 spMin(sparkLeft, spTop);
ImVec2 spMax(sparkRight, spBot);
ImU32 lineCol = market.change_24h >= 0
? WithAlpha(Success(), 200)
: WithAlpha(Error(), 200);
DrawSparkline(dl, spMin, spMax,
market.price_history, lineCol);
}
// Hover glow + click to Market
if (material::IsRectHovered(cMin, cMax)) {
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f));
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(0))
app->setCurrentPage(NavPage::Market);
}
}
// Advance cursor past the card row(s)
{
int totalCards = 4;
int numRows = (totalCards + numCols - 1) / numCols;
ImGui::Dummy(ImVec2(availWidth, cardH * numRows + cardGap * (numRows - 1)));
}
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
}
// ================================================================
// Address list + Recent transactions — shared renderers (feature-complete:
// set-label / add-to-portfolio context items, custom address icons, drag-to-reorder,
// keyboard nav, copy-flash, i18n). Classic keeps its own address-table-height override
// (see tabs.balance.classic) so the card sizing is unchanged.
// ================================================================
{
float recentReserve = contentAvail.y * S.drawElement("tabs.balance", "recent-tx-reserve-ratio").sizeOr(0.18f);
float classicAddrH = S.drawElement("tabs.balance.classic", "address-table-height").size;
float addrH = (classicAddrH >= 0.0f) ? classicAddrH * dp
: ImGui::GetContentRegionAvail().y - recentReserve
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
RenderSharedAddressList(app, addrH, contentAvail.x, glassRound, hs, vs);
RenderSharedRecentTx(app, recentReserve, contentAvail.x, hs, vs);
}
}
// Shared helpers (UpdateBalanceLerp / RenderCompactHero / RenderSharedAddressList /
// RenderSharedRecentTx / RenderSyncBar) now live in balance_components.{h,cpp}.
// ============================================================================
// Layout 1: Donut Chart
// ============================================================================
static void RenderBalanceDonut(App* app) {
using namespace material;
const auto& S = schema::UISchema::instance();
const auto& state = app->state();
UpdateBalanceLerp(app);
ImVec2 contentAvail = ImGui::GetContentRegionAvail();
float availW = contentAvail.x;
float hs = Layout::hScale(availW);
float vs = Layout::vScale(contentAvail.y);
float glassRound = Layout::glassRounding();
float cGap = Layout::cardGap();
const float dp = Layout::dpiScale();
ImDrawList* dl = ImGui::GetWindowDrawList();
char buf[64];
// --- Hero: total balance ---
float donutTopMargin = S.drawElement("tabs.balance.donut", "top-margin").size;
if (donutTopMargin >= 0.0f)
ImGui::Dummy(ImVec2(0, donutTopMargin));
else
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance.donut", "hero-pad-ratio").sizeOr(8.0f) * vs));
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), "TOTAL BALANCE");
ImGui::Dummy(ImVec2(0, 2 * dp));
snprintf(buf, sizeof(buf), "%.8f", s_dispTotal);
ImFont* heroFont = Type().h2();
ImVec2 pos = ImGui::GetCursorScreenPos();
DrawTextShadow(dl, heroFont, heroFont->LegacySize, pos, OnSurface(), buf);
ImVec2 heroSize = heroFont->CalcTextSizeA(heroFont->LegacySize, 10000.0f, 0.0f, buf);
ImGui::Dummy(heroSize);
ImGui::SameLine();
ImFont* capFont = Type().caption();
float tickerY = pos.y + heroSize.y - capFont->LegacySize;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(ImGui::GetCursorScreenPos().x, tickerY),
OnSurfaceMedium(), DRAGONX_TICKER);
ImGui::NewLine();
// USD value
double usd_value = state.getBalanceUSD();
if (usd_value > 0.0) snprintf(buf, sizeof(buf), "$%.2f USD", usd_value);
else snprintf(buf, sizeof(buf), "$-.-- USD");
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
}
RenderSyncBar(app, dl, vs);
ImGui::Dummy(ImVec2(0, cGap));
// --- Donut + legend panel ---
{
ImFont* donutOv = Type().overline();
ImFont* donutCap = Type().caption();
// Font-content floor: legend needs overline + 3 caption lines + spacing
float donutFontFloor = Layout::spacingLg() * 2
+ donutOv->LegacySize + Layout::spacingMd()
+ donutCap->LegacySize * 3 + Layout::spacingSm() * 3;
float donutCardH = S.drawElement("tabs.balance.donut", "card-height").size;
float panelH;
if (donutCardH >= 0.0f) {
panelH = std::max(donutCardH * dp, donutFontFloor);
} else {
panelH = std::max({donutFontFloor,
S.drawElement("tabs.balance.donut", "panel-min-height").sizeOr(80.0f) * dp,
contentAvail.y * S.drawElement("tabs.balance.donut", "panel-height-ratio").sizeOr(0.20f)});
}
ImVec2 panelMin = ImGui::GetCursorScreenPos();
ImVec2 panelMax(panelMin.x + availW, panelMin.y + panelH);
GlassPanelSpec spec;
spec.rounding = glassRound;
DrawGlassPanel(dl, panelMin, panelMax, spec);
float donutPadOverride = S.drawElement("tabs.balance.donut", "card-padding").size;
float donutPad = (donutPadOverride >= 0.0f) ? donutPadOverride : Layout::spacingLg();
// Donut ring
float cx = panelMin.x + panelH * 0.5f + donutPad;
float cy = panelMin.y + panelH * 0.5f;
float radius = std::min(
panelH * S.drawElement("tabs.balance.donut", "outer-radius-ratio").sizeOr(0.40f),
availW * S.drawElement("tabs.balance.donut", "max-radius-ratio").sizeOr(0.12f));
float innerRadius = radius * S.drawElement("tabs.balance.donut", "inner-radius-ratio").sizeOr(0.6f);
float total = (float)s_dispTotal;
float shielded = (float)s_dispShielded;
float ratio = (total > 1e-9f) ? shielded / total : 0.5f;
// Shielded arc (green)
float startAngle = -IM_PI * 0.5f; // top
float shieldEnd = startAngle + 2.0f * IM_PI * ratio;
if (ratio > 0.01f) {
dl->PathClear();
dl->PathArcTo(ImVec2(cx, cy), radius, startAngle, shieldEnd, 32);
dl->PathArcTo(ImVec2(cx, cy), innerRadius, shieldEnd, startAngle, 32);
dl->PathFillConvex(WithAlpha(Success(), 180));
}
// Transparent arc (gold)
if (ratio < 0.99f) {
dl->PathClear();
dl->PathArcTo(ImVec2(cx, cy), radius, shieldEnd, startAngle + 2.0f * IM_PI, 32);
dl->PathArcTo(ImVec2(cx, cy), innerRadius, startAngle + 2.0f * IM_PI, shieldEnd, 32);
dl->PathFillConvex(WithAlpha(Warning(), 180));
}
// Center text: privacy %
float privPct = ratio * 100.0f;
snprintf(buf, sizeof(buf), "%.0f%%", privPct);
ImFont* sub1 = Type().subtitle1();
ImVec2 pctSz = sub1->CalcTextSizeA(sub1->LegacySize, 1000, 0, buf);
dl->AddText(sub1, sub1->LegacySize,
ImVec2(cx - pctSz.x * 0.5f, cy - pctSz.y * 0.5f),
OnSurface(), buf);
// Legend (right side)
float legendX = panelMin.x + panelH + donutPad * 2;
float legendY = panelMin.y + donutPad;
ImFont* capFont = Type().caption();
ImFont* body2 = Type().body2();
float legendDotR = S.drawElement("tabs.balance.donut", "legend-dot-radius").sizeOr(4.0f);
float legendXOff = S.drawElement("tabs.balance.donut", "legend-x-offset").sizeOr(14.0f);
float legendLineGap = S.drawElement("tabs.balance.donut", "legend-line-gap").sizeOr(6.0f);
float legendSectionGap = S.drawElement("tabs.balance.donut", "legend-section-gap").sizeOr(10.0f);
// Shielded legend
dl->AddCircleFilled(ImVec2(legendX + 5 * dp, legendY + capFont->LegacySize * 0.5f), legendDotR, Success());
snprintf(buf, sizeof(buf), "Shielded %.8f", s_dispShielded);
dl->AddText(capFont, capFont->LegacySize, ImVec2(legendX + legendXOff, legendY), Success(), buf);
legendY += capFont->LegacySize + legendLineGap;
// Transparent legend
dl->AddCircleFilled(ImVec2(legendX + 5 * dp, legendY + capFont->LegacySize * 0.5f), legendDotR, Warning());
snprintf(buf, sizeof(buf), "Transparent %.8f", s_dispTransparent);
dl->AddText(capFont, capFont->LegacySize, ImVec2(legendX + legendXOff, legendY), Warning(), buf);
legendY += capFont->LegacySize + legendSectionGap;
// Market price
const auto& market = state.market;
if (market.price_usd > 0) {
if (market.price_usd >= 0.01)
snprintf(buf, sizeof(buf), "Market: $%.4f", market.price_usd);
else
snprintf(buf, sizeof(buf), "Market: $%.8f", market.price_usd);
dl->AddText(capFont, capFont->LegacySize, ImVec2(legendX + legendXOff, legendY),
OnSurfaceMedium(), buf);
legendY += capFont->LegacySize + 4 * dp;
bool pos = market.change_24h >= 0;
snprintf(buf, sizeof(buf), "%s%.1f%% 24h", pos ? "+" : "", market.change_24h);
dl->AddText(capFont, capFont->LegacySize, ImVec2(legendX + legendXOff, legendY),
pos ? Success() : Error(), buf);
}
ImGui::Dummy(ImVec2(availW, panelH));
}
ImGui::Dummy(ImVec2(0, cGap));
// --- Shared address list + recent tx ---
float recentReserve = contentAvail.y * S.drawElement("tabs.balance", "recent-tx-reserve-ratio").sizeOr(0.18f);
float donutAddrOverride = S.drawElement("tabs.balance.donut", "address-table-height").size;
float addrH = (donutAddrOverride >= 0.0f) ? donutAddrOverride * dp
: ImGui::GetContentRegionAvail().y - recentReserve
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
}
// ============================================================================
// Layout 2: Consolidated Card
// ============================================================================
static void RenderBalanceConsolidated(App* app) {
using namespace material;
const auto& S = schema::UISchema::instance();
const auto& state = app->state();
UpdateBalanceLerp(app);
ImVec2 contentAvail = ImGui::GetContentRegionAvail();
float availW = contentAvail.x;
float hs = Layout::hScale(availW);
float vs = Layout::vScale(contentAvail.y);
float glassRound = Layout::glassRounding();
float cGap = Layout::cardGap();
const float dp = Layout::dpiScale();
ImDrawList* dl = ImGui::GetWindowDrawList();
char buf[64];
// Single consolidated card
float consTopMargin = S.drawElement("tabs.balance.consolidated", "top-margin").size;
if (consTopMargin >= 0.0f)
ImGui::Dummy(ImVec2(0, consTopMargin));
ImFont* heroFont = Type().h2();
ImFont* sub1 = Type().subtitle1();
ImFont* capFont = Type().caption();
ImFont* ovFont = Type().overline();
float consPadOverride = S.drawElement("tabs.balance.consolidated", "card-padding").size;
float pad = (consPadOverride >= 0.0f) ? consPadOverride : Layout::spacingLg();
// Font-content floor: pad + overline + hero + caption + subtitle + pad
float consFontFloor = pad + ovFont->LegacySize + Layout::spacingSm()
+ heroFont->LegacySize + Layout::spacingSm()
+ capFont->LegacySize + Layout::spacingSm()
+ sub1->LegacySize + pad;
float consCardH = S.drawElement("tabs.balance.consolidated", "card-height").size;
float cardH;
if (consCardH >= 0.0f) {
cardH = std::max(consCardH * dp, consFontFloor);
} else {
cardH = std::max({consFontFloor,
S.drawElement("tabs.balance.consolidated", "card-min-height").sizeOr(90.0f) * dp,
contentAvail.y * S.drawElement("tabs.balance.consolidated", "card-height-ratio").sizeOr(0.22f)});
}
ImVec2 cardMin = ImGui::GetCursorScreenPos();
ImVec2 cardMax(cardMin.x + availW, cardMin.y + cardH);
GlassPanelSpec spec;
spec.rounding = glassRound;
DrawGlassPanel(dl, cardMin, cardMax, spec);
float cx = cardMin.x + pad;
float cy = cardMin.y + pad;
// Coin logo
ImTextureID logoTex = app->getCoinLogoTexture();
float logoSz = heroFont->LegacySize + capFont->LegacySize + 4.0f * dp;
if (logoTex != 0) {
dl->AddImage(logoTex,
ImVec2(cx, cy), ImVec2(cx + logoSz, cy + logoSz),
ImVec2(0, 0), ImVec2(1, 1), IM_COL32(255, 255, 255, 255));
cx += logoSz + Layout::spacingMd();
}
// Total balance
snprintf(buf, sizeof(buf), "%.8f", s_dispTotal);
DrawTextShadow(dl, heroFont, heroFont->LegacySize, ImVec2(cx, cy), OnSurface(), buf);
ImVec2 heroSz = heroFont->CalcTextSizeA(heroFont->LegacySize, 10000, 0, buf);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cx + heroSz.x + 4 * dp, cy + heroSz.y - capFont->LegacySize),
OnSurfaceMedium(), DRAGONX_TICKER);
cy += heroSz.y + 2 * dp;
// USD value
double usd_value = state.getBalanceUSD();
if (usd_value > 0.0) snprintf(buf, sizeof(buf), "$%.2f USD", usd_value);
else snprintf(buf, sizeof(buf), "$-.-- USD");
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy), OnSurfaceDisabled(), buf);
// Market badge (top-right)
{
const auto& market = state.market;
if (market.price_usd > 0) {
float badgeX = cardMax.x - pad;
float badgeY = cardMin.y + pad;
if (market.price_usd >= 0.01)
snprintf(buf, sizeof(buf), "$%.4f", market.price_usd);
else
snprintf(buf, sizeof(buf), "$%.8f", market.price_usd);
ImVec2 pSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, buf);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(badgeX - pSz.x, badgeY), OnSurfaceMedium(), buf);
badgeY += capFont->LegacySize + 2 * dp;
bool pos = market.change_24h >= 0;
snprintf(buf, sizeof(buf), "%s%.1f%%", pos ? "+" : "", market.change_24h);
ImVec2 chgSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, buf);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(badgeX - chgSz.x, badgeY),
pos ? Success() : Error(), buf);
}
}
// Divider
float divY = cardMin.y + cardH * S.drawElement("tabs.balance.consolidated", "divider-y-ratio").sizeOr(0.55f);
dl->AddLine(ImVec2(cardMin.x + pad, divY), ImVec2(cardMax.x - pad, divY),
IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance.consolidated", "divider-alpha").sizeOr(20.0f)),
S.drawElement("tabs.balance.consolidated", "divider-thickness").sizeOr(1.0f));
// Bottom half: proportion bars
float barY = divY + Layout::spacingSm();
float barH = std::max(
S.drawElement("tabs.balance.consolidated", "bar-min-height").sizeOr(6.0f),
S.drawElement("tabs.balance.consolidated", "bar-base-height").sizeOr(10.0f) * vs);
float halfW = (availW - pad * 3) * 0.5f;
float total = (float)s_dispTotal;
float shieldRatio = (total > 1e-9f) ? (float)(s_dispShielded / total) : 0.5f;
float transRatio = 1.0f - shieldRatio;
// Shielded bar
float shieldX = cardMin.x + pad;
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(shieldX, barY), Success(), "SHIELDED");
barY += ovFont->LegacySize + 4 * dp;
dl->AddRectFilled(ImVec2(shieldX, barY), ImVec2(shieldX + halfW, barY + barH),
IM_COL32(255, 255, 255, 15), barH * 0.5f);
dl->AddRectFilled(ImVec2(shieldX, barY), ImVec2(shieldX + halfW * shieldRatio, barY + barH),
WithAlpha(Success(), 180), barH * 0.5f);
barY += barH + 2 * dp;
snprintf(buf, sizeof(buf), "%.8f (%.0f%%)", s_dispShielded, shieldRatio * 100.0f);
dl->AddText(capFont, capFont->LegacySize, ImVec2(shieldX, barY), OnSurfaceMedium(), buf);
// Transparent bar
float transX = cardMin.x + pad * 2 + halfW;
barY = divY + Layout::spacingSm();
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(transX, barY), Warning(), "TRANSPARENT");
barY += ovFont->LegacySize + 4 * dp;
dl->AddRectFilled(ImVec2(transX, barY), ImVec2(transX + halfW, barY + barH),
IM_COL32(255, 255, 255, 15), barH * 0.5f);
dl->AddRectFilled(ImVec2(transX, barY), ImVec2(transX + halfW * transRatio, barY + barH),
WithAlpha(Warning(), 180), barH * 0.5f);
barY += barH + 2 * dp;
snprintf(buf, sizeof(buf), "%.8f (%.0f%%)", s_dispTransparent, transRatio * 100.0f);
dl->AddText(capFont, capFont->LegacySize, ImVec2(transX, barY), OnSurfaceMedium(), buf);
// Sync bar at card bottom — clipped to rounded corners
if (state.sync.syncing && state.sync.headers > 0) {
const auto syncBar = S.drawElement("tabs.balance", "sync-bar");
float syncBarH = (syncBar.height >= 0) ? syncBar.height : 3.0f;
float prog = static_cast<float>(state.sync.verification_progress);
if (prog > 1.0f) prog = 1.0f;
float syncBarTop = cardMax.y - syncBarH;
// Clip to the bottom strip so the full-card rounded rect
// curves exactly match the card's own rounded corners
dl->PushClipRect(ImVec2(cardMin.x, syncBarTop), cardMax, true);
// Background track
dl->AddRectFilled(cardMin, cardMax,
IM_COL32(255, 255, 255, 15), glassRound);
// Progress fill — additional horizontal clip
float progRight = cardMin.x + (cardMax.x - cardMin.x) * prog;
dl->PushClipRect(ImVec2(cardMin.x, syncBarTop), ImVec2(progRight, cardMax.y), true);
dl->AddRectFilled(cardMin, cardMax,
WithAlpha(Warning(), 200), glassRound);
dl->PopClipRect();
dl->PopClipRect();
}
ImGui::Dummy(ImVec2(availW, cardH));
ImGui::Dummy(ImVec2(0, cGap));
float recentReserve = contentAvail.y * S.drawElement("tabs.balance", "recent-tx-reserve-ratio").sizeOr(0.18f);
float consAddrOverride = S.drawElement("tabs.balance.consolidated", "address-table-height").size;
float addrH = (consAddrOverride >= 0.0f) ? consAddrOverride * dp
: ImGui::GetContentRegionAvail().y - recentReserve
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
}
// ============================================================================
// Layout 3: Dashboard Tiles
// ============================================================================
static void RenderBalanceDashboard(App* app) {
using namespace material;
const auto& S = schema::UISchema::instance();
const auto& state = app->state();
UpdateBalanceLerp(app);
ImVec2 contentAvail = ImGui::GetContentRegionAvail();
float availW = contentAvail.x;
float hs = Layout::hScale(availW);
float vs = Layout::vScale(contentAvail.y);
auto tier = Layout::currentTier(availW, contentAvail.y);
float glassRound = Layout::glassRounding();
float cGap = Layout::cardGap();
const float dp = Layout::dpiScale();
ImDrawList* dl = ImGui::GetWindowDrawList();
char buf[64];
// Compact hero line
float dashTopMargin = S.drawElement("tabs.balance.dashboard", "top-margin").size;
if (dashTopMargin >= 0.0f)
ImGui::Dummy(ImVec2(0, dashTopMargin));
float dashHeroH = S.drawElement("tabs.balance.dashboard", "hero-height").size;
RenderCompactHero(app, dl, availW, hs, vs, dashHeroH);
RenderSyncBar(app, dl, vs);
ImGui::Dummy(ImVec2(0, cGap));
// 4-tile grid
int numCols = (tier == Layout::LayoutTier::Compact && availW < S.drawElement("tabs.balance.dashboard", "compact-cutoff").sizeOr(500.0f) * Layout::dpiScale())
? (int)S.drawElement("tabs.balance.dashboard", "tile-compact-cols").sizeOr(2.0f)
: (int)S.drawElement("tabs.balance.dashboard", "tile-num-cols").sizeOr(4.0f);
int numRows = (numCols == 2) ? 2 : 1;
float tileW = (availW - (numCols - 1) * cGap) / numCols;
ImFont* ovFont = Type().overline();
ImFont* sub1 = Type().subtitle1();
ImFont* capFont = Type().caption();
// Font-content floor: pad + overline + subtitle1 + caption + pad
float dashFontFloor = Layout::spacingLg()
+ ovFont->LegacySize + Layout::spacingSm()
+ sub1->LegacySize + Layout::spacingSm()
+ capFont->LegacySize
+ Layout::spacingLg();
float dashCardH = S.drawElement("tabs.balance.dashboard", "card-height").size;
float tileH;
if (dashCardH >= 0.0f) {
tileH = std::max(dashCardH * dp, dashFontFloor);
} else {
tileH = std::max({dashFontFloor,
S.drawElement("tabs.balance.dashboard", "tile-min-height").sizeOr(70.0f) * dp,
contentAvail.y * S.drawElement("tabs.balance.dashboard", "tile-height-ratio").sizeOr(0.16f) / numRows});
}
ImVec2 origin = ImGui::GetCursorScreenPos();
GlassPanelSpec tileSpec;
tileSpec.rounding = glassRound;
struct TileInfo {
const char* label;
const char* value;
ImU32 accent;
const char* icon;
NavPage nav;
bool isAction;
};
snprintf(buf, sizeof(buf), "%.8f", s_dispShielded);
static char shBuf[64], trBuf[64];
snprintf(shBuf, sizeof(shBuf), "%.8f", s_dispShielded);
snprintf(trBuf, sizeof(trBuf), "%.8f", s_dispTransparent);
TileInfo tiles[4] = {
{"SHIELDED", shBuf, S.resolveColor("var(--accent-shielded)", Success()), ICON_MD_SHIELD, NavPage::Receive, false},
{"TRANSPARENT", trBuf, S.resolveColor("var(--accent-transparent)", Warning()), ICON_MD_CIRCLE, NavPage::Receive, false},
{"QUICK SEND", "Send", S.resolveColor("var(--accent-action)", Primary()), ICON_MD_CALL_MADE, NavPage::Send, true},
{"QUICK RECEIVE", "Receive", S.resolveColor("var(--accent-action)", Primary()), ICON_MD_CALL_RECEIVED, NavPage::Receive, true},
};
for (int i = 0; i < 4; i++) {
int col = (numCols == 4) ? i : (i % 2);
int row = (numCols == 4) ? 0 : (i / 2);
float xOff = col * (tileW + cGap);
float yOff = row * (tileH + cGap);
ImVec2 tMin(origin.x + xOff, origin.y + yOff);
ImVec2 tMax(tMin.x + tileW, tMin.y + tileH);
DrawGlassPanel(dl, tMin, tMax, tileSpec);
// Accent stripe — clipped to tile rounded corners
{
float aw = S.drawElement("tabs.balance", "accent-width").sizeOr(4.0f);
dl->PushClipRect(tMin, ImVec2(tMin.x + aw, tMax.y), true);
dl->AddRectFilled(tMin, tMax, tiles[i].accent, tileSpec.rounding,
ImDrawFlags_RoundCornersLeft);
dl->PopClipRect();
}
float dashPadOverride = S.drawElement("tabs.balance.dashboard", "card-padding").size;
float tilePad = (dashPadOverride >= 0.0f) ? dashPadOverride : Layout::spacingLg();
float tilePadV = (dashPadOverride >= 0.0f) ? dashPadOverride : Layout::spacingSm();
float px = tMin.x + tilePad;
float py = tMin.y + tilePadV;
// Icon
ImFont* iconFont = Type().iconSmall();
ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, tiles[i].icon);
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(px, py), tiles[i].accent, tiles[i].icon);
px += iSz.x + Layout::spacingSm();
// Label
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(px, py), OnSurfaceMedium(), tiles[i].label);
py += ovFont->LegacySize + 4 * dp;
// Value
if (!tiles[i].isAction) {
dl->AddText(capFont, capFont->LegacySize, ImVec2(tMin.x + tilePad, py),
tiles[i].accent, tiles[i].value);
} else {
dl->AddText(capFont, capFont->LegacySize, ImVec2(tMin.x + tilePad, py),
OnSurfaceMedium(), "Click to open");
}
// Click
if (material::IsRectHovered(tMin, tMax)) {
dl->AddRect(tMin, tMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
tileSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f));
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(0))
app->setCurrentPage(tiles[i].nav);
}
}
float totalTileH = numRows * tileH + (numRows - 1) * cGap;
ImGui::Dummy(ImVec2(availW, totalTileH));
ImGui::Dummy(ImVec2(0, cGap));
float recentReserve = contentAvail.y * S.drawElement("tabs.balance", "recent-tx-reserve-ratio").sizeOr(0.18f);
float dashAddrOverride = S.drawElement("tabs.balance.dashboard", "address-table-height").size;
float addrH = (dashAddrOverride >= 0.0f) ? dashAddrOverride * dp
: ImGui::GetContentRegionAvail().y - recentReserve
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
}
// ============================================================================
// Layout 4: Vertical Stack
// ============================================================================
static void RenderBalanceVerticalStack(App* app) {
using namespace material;
const auto& S = schema::UISchema::instance();
const auto& state = app->state();
UpdateBalanceLerp(app);
ImVec2 contentAvail = ImGui::GetContentRegionAvail();
float availW = contentAvail.x;
float hs = Layout::hScale(availW);
float vs = Layout::vScale(contentAvail.y);
float glassRound = Layout::glassRounding();
float cGap = Layout::cardGap();
const float dp = Layout::dpiScale();
ImDrawList* dl = ImGui::GetWindowDrawList();
char buf[64];
float vstackTopMargin = S.drawElement("tabs.balance.vertical-stack", "top-margin").size;
if (vstackTopMargin >= 0.0f)
ImGui::Dummy(ImVec2(0, vstackTopMargin));
ImFont* capFont = Type().caption();
ImFont* body2 = Type().body2();
ImFont* sub1 = Type().subtitle1();
// Font-content floor per row: icon + label + value must fit
float vstackRowFontFloor = std::max(body2->LegacySize, capFont->LegacySize)
+ Layout::spacingSm() * 2;
float rowGap = S.drawElement("tabs.balance.vertical-stack", "row-gap").sizeOr(2.0f);
float vstackFontFloor = vstackRowFontFloor * 4 + rowGap * 3;
float vstackCardH = S.drawElement("tabs.balance.vertical-stack", "card-height").size;
float stackH;
if (vstackCardH >= 0.0f) {
stackH = std::max(vstackCardH * dp, vstackFontFloor);
} else {
stackH = std::max({vstackFontFloor,
S.drawElement("tabs.balance.vertical-stack", "stack-min-height").sizeOr(80.0f) * dp,
contentAvail.y * S.drawElement("tabs.balance.vertical-stack", "stack-height-ratio").sizeOr(0.16f)});
}
float rowH = (stackH - 3 * rowGap) / 4.0f;
float rowMinH = std::max(
S.drawElement("tabs.balance.vertical-stack", "row-min-height").sizeOr(20.0f) * dp,
vstackRowFontFloor);
if (rowH < rowMinH) rowH = rowMinH;
float total = (float)s_dispTotal;
float shieldRatio = (total > 1e-9f) ? (float)(s_dispShielded / total) : 0.5f;
float transRatio = 1.0f - shieldRatio;
struct RowInfo {
const char* label;
const char* icon;
ImU32 accent;
double amount;
float ratio;
};
RowInfo rowInfos[4] = {
{"Total Balance", ICON_MD_ACCOUNT_BALANCE_WALLET, S.resolveColor("var(--accent-total)", OnSurface()), s_dispTotal, 1.0f},
{"Shielded", ICON_MD_SHIELD, S.resolveColor("var(--accent-shielded)", Success()), s_dispShielded, shieldRatio},
{"Transparent", ICON_MD_CIRCLE, S.resolveColor("var(--accent-transparent)", Warning()), s_dispTransparent, transRatio},
{"Market", ICON_MD_TRENDING_UP, S.resolveColor("var(--accent-action)", Primary()), state.market.price_usd, 0.0f},
};
for (int i = 0; i < 4; i++) {
ImVec2 rowPos = ImGui::GetCursorScreenPos();
ImVec2 rowEnd(rowPos.x + availW, rowPos.y + rowH);
float vstackPadOverride = S.drawElement("tabs.balance.vertical-stack", "card-padding").size;
float rowPad = (vstackPadOverride >= 0.0f) ? vstackPadOverride : Layout::spacingLg();
// Subtle background
float rowBgAlpha = S.drawElement("tabs.balance.vertical-stack", "row-bg-alpha").sizeOr(8.0f);
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, (int)rowBgAlpha), 4.0f * dp);
// Left accent — clipped to row rounding
dl->PushClipRect(rowPos, rowEnd, true);
dl->AddRectFilled(ImVec2(rowPos.x, rowPos.y),
ImVec2(rowPos.x + 3 * dp, rowEnd.y),
rowInfos[i].accent);
dl->PopClipRect();
float px = rowPos.x + rowPad;
float cy = rowPos.y + (rowH - capFont->LegacySize) * 0.5f;
// Icon
ImFont* iconFont = Type().iconSmall();
ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, rowInfos[i].icon);
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(px, rowPos.y + (rowH - iSz.y) * 0.5f),
rowInfos[i].accent, rowInfos[i].icon);
px += iSz.x + Layout::spacingSm();
// Label
dl->AddText(capFont, capFont->LegacySize, ImVec2(px, cy),
OnSurfaceMedium(), rowInfos[i].label);
// Amount (right side)
if (i < 3) {
snprintf(buf, sizeof(buf), "%.8f %s", rowInfos[i].amount, DRAGONX_TICKER);
} else {
if (state.market.price_usd >= 0.01)
snprintf(buf, sizeof(buf), "$%.4f", state.market.price_usd);
else if (state.market.price_usd > 0)
snprintf(buf, sizeof(buf), "$%.8f", state.market.price_usd);
else
snprintf(buf, sizeof(buf), "$--.--");
}
ImVec2 amtSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, buf);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(rowEnd.x - amtSz.x - rowPad, cy),
i == 0 ? OnSurface() : rowInfos[i].accent, buf);
// Proportion bar (for shielded/transparent rows — fills gap between label and amount)
if (i == 1 || i == 2) {
ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, rowInfos[i].label);
float barGap = S.drawElement("tabs.balance.vertical-stack", "sparkline-gap").sizeOr(12.0f);
float barPad = S.drawElement("tabs.balance.vertical-stack", "sparkline-pad").sizeOr(4.0f);
float barH = std::max(
S.drawElement("tabs.balance.vertical-stack", "bar-min-height").sizeOr(3.0f),
rowH * S.drawElement("tabs.balance.vertical-stack", "bar-height-ratio").sizeOr(0.15f));
float barLeft = px + labelSz.x + barGap;
float barRight = rowEnd.x - amtSz.x - rowPad - barGap;
if (barLeft < barRight) {
float barW = barRight - barLeft;
float barY = rowPos.y + (rowH - barH) * 0.5f;
dl->AddRectFilled(ImVec2(barLeft, barY), ImVec2(barRight, barY + barH),
IM_COL32(255, 255, 255, 15), barH * 0.5f);
dl->AddRectFilled(ImVec2(barLeft, barY),
ImVec2(barLeft + barW * rowInfos[i].ratio, barY + barH),
WithAlpha(rowInfos[i].accent, 180), barH * 0.5f);
}
}
// Market: 24h change + sparkline
if (i == 3 && state.market.price_usd > 0) {
bool pos = state.market.change_24h >= 0;
snprintf(buf, sizeof(buf), "%s%.1f%%", pos ? "+" : "", state.market.change_24h);
ImVec2 chgSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, buf);
float chgX = rowEnd.x - amtSz.x - Layout::spacingLg() - chgSz.x - Layout::spacingSm();
dl->AddText(capFont, capFont->LegacySize, ImVec2(chgX, cy),
pos ? Success() : Error(), buf);
// Sparkline in the gap between label and 24h change
if (state.market.price_history.size() >= 2) {
ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, rowInfos[i].label);
float sparkGap = S.drawElement("tabs.balance.vertical-stack", "sparkline-gap").sizeOr(12.0f);
float sparkPad = S.drawElement("tabs.balance.vertical-stack", "sparkline-pad").sizeOr(4.0f);
float sparkLeft = px + labelSz.x + sparkGap;
float sparkRight = chgX - sparkGap;
if (sparkLeft < sparkRight) {
ImVec2 spMin(sparkLeft, rowPos.y + sparkPad);
ImVec2 spMax(sparkRight, rowEnd.y - sparkPad);
ImU32 lineCol = pos
? WithAlpha(Success(), 200)
: WithAlpha(Error(), 200);
DrawSparkline(dl, spMin, spMax,
state.market.price_history, lineCol);
}
}
}
ImGui::Dummy(ImVec2(availW, rowH));
if (i < 3) ImGui::Dummy(ImVec2(0, rowGap));
}
RenderSyncBar(app, dl, vs);
ImGui::Dummy(ImVec2(0, cGap));
float recentReserve = contentAvail.y * S.drawElement("tabs.balance", "recent-tx-reserve-ratio").sizeOr(0.18f);
float vstackAddrOverride = S.drawElement("tabs.balance.vertical-stack", "address-table-height").size;
float addrH = (vstackAddrOverride >= 0.0f) ? vstackAddrOverride * dp
: ImGui::GetContentRegionAvail().y - recentReserve
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
}
// ============================================================================
// Layout 5b: Vertical 2×2 (Total+Market left, Shielded+Transparent right)
// ============================================================================
static void RenderBalanceVertical2x2(App* app) {
using namespace material;
const auto& S = schema::UISchema::instance();
const auto& state = app->state();
UpdateBalanceLerp(app);
ImVec2 contentAvail = ImGui::GetContentRegionAvail();
float availW = contentAvail.x;
float hs = Layout::hScale(availW);
float vs = Layout::vScale(contentAvail.y);
float glassRound = Layout::glassRounding();
float cGap = Layout::cardGap();
const float dp = Layout::dpiScale();
ImDrawList* dl = ImGui::GetWindowDrawList();
char buf[64];
const char* cfgSec = "tabs.balance.vertical-2x2";
float topMargin = S.drawElement(cfgSec, "top-margin").size;
if (topMargin >= 0.0f)
ImGui::Dummy(ImVec2(0, topMargin));
ImFont* capFont = Type().caption();
ImFont* iconFont = Type().iconSmall();
// Font-content floor per row: caption text + vertical padding
float v2x2RowFontFloor = capFont->LegacySize + Layout::spacingSm() * 2;
float rowGap = S.drawElement(cfgSec, "row-gap").sizeOr(2.0f);
float colGap = S.drawElement(cfgSec, "col-gap").sizeOr(8.0f);
float v2x2FontFloor = v2x2RowFontFloor * 2 + rowGap;
float cardHOverride = S.drawElement(cfgSec, "card-height").size;
float stackH;
if (cardHOverride >= 0.0f) {
stackH = std::max(cardHOverride * dp, v2x2FontFloor);
} else {
stackH = std::max({v2x2FontFloor,
S.drawElement(cfgSec, "stack-min-height").sizeOr(60.0f) * dp,
contentAvail.y * S.drawElement(cfgSec, "stack-height-ratio").sizeOr(0.12f)});
}
float rowH = (stackH - rowGap) / 2.0f;
float rowMinH = std::max(
S.drawElement(cfgSec, "row-min-height").sizeOr(24.0f) * dp,
v2x2RowFontFloor);
if (rowH < rowMinH) rowH = rowMinH;
float colW = (availW - colGap) / 2.0f;
float padOverride = S.drawElement(cfgSec, "card-padding").size;
float rowPad = (padOverride >= 0.0f) ? padOverride : Layout::spacingLg();
float rowBgAlpha = S.drawElement(cfgSec, "row-bg-alpha").sizeOr(8.0f);
float total = (float)s_dispTotal;
float shieldRatio = (total > 1e-9f) ? (float)(s_dispShielded / total) : 0.5f;
float transRatio = 1.0f - shieldRatio;
// Grid: [row][col] — row 0 top, row 1 bottom; col 0 left, col 1 right
// Left col: Total Balance (row 0), Market (row 1)
// Right col: Shielded (row 0), Transparent (row 1)
struct CellInfo {
const char* label;
const char* icon;
ImU32 accent;
double amount;
float ratio;
bool isMarket;
bool hasBar;
};
CellInfo cells[2][2] = {
// Row 0: Total Balance (left), Shielded (right)
{
{"Total Balance", ICON_MD_ACCOUNT_BALANCE_WALLET, S.resolveColor("var(--accent-total)", OnSurface()), s_dispTotal, 1.0f, false, false},
{"Shielded", ICON_MD_SHIELD, S.resolveColor("var(--accent-shielded)", Success()), s_dispShielded, shieldRatio, false, true},
},
// Row 1: Market (left), Transparent (right)
{
{"Market", ICON_MD_TRENDING_UP, S.resolveColor("var(--accent-action)", Primary()), state.market.price_usd, 0.0f, true, false},
{"Transparent", ICON_MD_CIRCLE, S.resolveColor("var(--accent-transparent)", Warning()), s_dispTransparent, transRatio, false, true},
},
};
ImVec2 gridOrigin = ImGui::GetCursorScreenPos();
for (int row = 0; row < 2; row++) {
for (int col = 0; col < 2; col++) {
const auto& cell = cells[row][col];
float cellX = gridOrigin.x + col * (colW + colGap);
float cellY = gridOrigin.y + row * (rowH + rowGap);
ImVec2 cellMin(cellX, cellY);
ImVec2 cellMax(cellX + colW, cellY + rowH);
// Background
dl->AddRectFilled(cellMin, cellMax, IM_COL32(255, 255, 255, (int)rowBgAlpha), 4.0f * dp);
// Left accent — clipped to cell rounding
dl->PushClipRect(cellMin, cellMax, true);
dl->AddRectFilled(ImVec2(cellMin.x, cellMin.y),
ImVec2(cellMin.x + 3 * dp, cellMax.y),
cell.accent);
dl->PopClipRect();
float px = cellMin.x + rowPad;
float cy = cellMin.y + (rowH - capFont->LegacySize) * 0.5f;
// Icon
ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, cell.icon);
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(px, cellMin.y + (rowH - iSz.y) * 0.5f),
cell.accent, cell.icon);
px += iSz.x + Layout::spacingSm();
// Label
dl->AddText(capFont, capFont->LegacySize, ImVec2(px, cy),
OnSurfaceMedium(), cell.label);
// Amount (right-aligned)
if (!cell.isMarket) {
snprintf(buf, sizeof(buf), "%.8f %s", cell.amount, DRAGONX_TICKER);
} else {
if (state.market.price_usd >= 0.01)
snprintf(buf, sizeof(buf), "$%.4f", state.market.price_usd);
else if (state.market.price_usd > 0)
snprintf(buf, sizeof(buf), "$%.8f", state.market.price_usd);
else
snprintf(buf, sizeof(buf), "$--.--");
}
ImVec2 amtSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, buf);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cellMax.x - amtSz.x - rowPad, cy),
cell.isMarket ? cell.accent : (row == 0 && col == 0 ? OnSurface() : cell.accent), buf);
// Proportion bar (shielded/transparent)
if (cell.hasBar) {
float barW = colW * S.drawElement(cfgSec, "bar-width-ratio").sizeOr(0.12f);
float barH = std::max(
S.drawElement(cfgSec, "bar-min-height").sizeOr(3.0f),
rowH * S.drawElement(cfgSec, "bar-height-ratio").sizeOr(0.15f));
float barX = cellMax.x - amtSz.x - rowPad - barW - Layout::spacingSm();
float barY = cellMin.y + (rowH - barH) * 0.5f;
dl->AddRectFilled(ImVec2(barX, barY), ImVec2(barX + barW, barY + barH),
IM_COL32(255, 255, 255, 15), barH * 0.5f);
dl->AddRectFilled(ImVec2(barX, barY),
ImVec2(barX + barW * cell.ratio, barY + barH),
WithAlpha(cell.accent, 180), barH * 0.5f);
}
// Market: 24h change + sparkline
if (cell.isMarket && state.market.price_usd > 0) {
bool pos = state.market.change_24h >= 0;
snprintf(buf, sizeof(buf), "%s%.1f%%", pos ? "+" : "", state.market.change_24h);
ImVec2 chgSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, buf);
float chgX = cellMax.x - amtSz.x - Layout::spacingLg() - chgSz.x - Layout::spacingSm();
dl->AddText(capFont, capFont->LegacySize, ImVec2(chgX, cy),
pos ? Success() : Error(), buf);
// Sparkline between label and 24h change
if (state.market.price_history.size() >= 2) {
ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, cell.label);
float sparkGap = S.drawElement(cfgSec, "sparkline-gap").sizeOr(12.0f);
float sparkPad = S.drawElement(cfgSec, "sparkline-pad").sizeOr(4.0f);
float sparkLeft = px + labelSz.x + sparkGap;
float sparkRight = chgX - sparkGap;
if (sparkLeft < sparkRight) {
ImVec2 spMin(sparkLeft, cellMin.y + sparkPad);
ImVec2 spMax(sparkRight, cellMax.y - sparkPad);
ImU32 lineCol = pos
? WithAlpha(Success(), 200)
: WithAlpha(Error(), 200);
DrawSparkline(dl, spMin, spMax,
state.market.price_history, lineCol);
}
}
}
}
}
// Advance cursor past the 2×2 grid
float totalGridH = 2.0f * rowH + rowGap;
ImGui::Dummy(ImVec2(availW, totalGridH));
RenderSyncBar(app, dl, vs);
ImGui::Dummy(ImVec2(0, cGap));
float recentReserve = contentAvail.y * S.drawElement("tabs.balance", "recent-tx-reserve-ratio").sizeOr(0.18f);
float addrOverride = S.drawElement(cfgSec, "address-table-height").size;
float addrH = (addrOverride >= 0.0f) ? addrOverride * dp
: ImGui::GetContentRegionAvail().y - recentReserve
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
}
// ============================================================================
// Layout 5: Privacy Shield Meter
// ============================================================================
static void RenderBalanceShield(App* app) {
using namespace material;
const auto& S = schema::UISchema::instance();
const auto& state = app->state();
UpdateBalanceLerp(app);
ImVec2 contentAvail = ImGui::GetContentRegionAvail();
float availW = contentAvail.x;
float hs = Layout::hScale(availW);
float vs = Layout::vScale(contentAvail.y);
float glassRound = Layout::glassRounding();
float cGap = Layout::cardGap();
const float dp = Layout::dpiScale();
ImDrawList* dl = ImGui::GetWindowDrawList();
char buf[64];
// Hero section
float shieldTopMargin = S.drawElement("tabs.balance.shield", "top-margin").size;
if (shieldTopMargin >= 0.0f)
ImGui::Dummy(ImVec2(0, shieldTopMargin));
else
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance", "compact-hero-pad").sizeOr(8.0f) * vs));
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), "TOTAL BALANCE");
ImGui::Dummy(ImVec2(0, 2 * dp));
snprintf(buf, sizeof(buf), "%.8f", s_dispTotal);
ImFont* heroFont = Type().h2();
ImVec2 pos = ImGui::GetCursorScreenPos();
DrawTextShadow(dl, heroFont, heroFont->LegacySize, pos, OnSurface(), buf);
ImVec2 heroSz = heroFont->CalcTextSizeA(heroFont->LegacySize, 10000.0f, 0.0f, buf);
ImGui::Dummy(heroSz);
ImGui::SameLine();
ImFont* capFont = Type().caption();
dl->AddText(capFont, capFont->LegacySize,
ImVec2(ImGui::GetCursorScreenPos().x, pos.y + heroSz.y - capFont->LegacySize),
OnSurfaceMedium(), DRAGONX_TICKER);
ImGui::NewLine();
double usd_value = state.getBalanceUSD();
if (usd_value > 0.0) snprintf(buf, sizeof(buf), "$%.2f USD", usd_value);
else snprintf(buf, sizeof(buf), "$-.-- USD");
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
}
RenderSyncBar(app, dl, vs);
ImGui::Dummy(ImVec2(0, cGap));
// Shield gauge panel
{
ImFont* shieldCap = Type().caption();
ImFont* shieldSub1 = Type().subtitle1();
// Font-content floor: gauge area + legend text (subtitle + 2 captions)
float shieldFontFloor = Layout::spacingLg() * 2
+ shieldSub1->LegacySize + Layout::spacingSm()
+ shieldCap->LegacySize * 2 + Layout::spacingSm() * 2;
float shieldCardH = S.drawElement("tabs.balance.shield", "card-height").size;
float gaugeH;
if (shieldCardH >= 0.0f) {
gaugeH = std::max(shieldCardH * dp, shieldFontFloor);
} else {
gaugeH = std::max({shieldFontFloor,
S.drawElement("tabs.balance.shield", "gauge-min-height").sizeOr(80.0f) * dp,
contentAvail.y * S.drawElement("tabs.balance.shield", "gauge-height-ratio").sizeOr(0.18f)});
}
ImVec2 panelMin = ImGui::GetCursorScreenPos();
ImVec2 panelMax(panelMin.x + availW, panelMin.y + gaugeH);
GlassPanelSpec spec;
spec.rounding = glassRound;
DrawGlassPanel(dl, panelMin, panelMax, spec);
float total = (float)s_dispTotal;
float privacyRatio = (total > 1e-9f) ? (float)(s_dispShielded / total) : 0.0f;
float privPct = privacyRatio * 100.0f;
// Semicircle gauge
float gaugeCx = panelMin.x + gaugeH;
float gaugeCy = panelMin.y + gaugeH * S.drawElement("tabs.balance.shield", "gauge-center-y-ratio").sizeOr(0.7f);
float gaugeR = std::min(
gaugeH * S.drawElement("tabs.balance.shield", "gauge-radius-ratio").sizeOr(0.55f),
availW * S.drawElement("tabs.balance.shield", "gauge-max-radius-ratio").sizeOr(0.15f));
float gaugeInnerR = gaugeR * S.drawElement("tabs.balance.shield", "gauge-inner-ratio").sizeOr(0.7f);
// Background arc (gray)
dl->PathClear();
dl->PathArcTo(ImVec2(gaugeCx, gaugeCy), gaugeR, IM_PI, 2.0f * IM_PI, 32);
dl->PathArcTo(ImVec2(gaugeCx, gaugeCy), gaugeInnerR, 2.0f * IM_PI, IM_PI, 32);
dl->PathFillConvex(IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance.shield", "gauge-bg-alpha").sizeOr(20.0f)));
// Filled arc (colored by threshold)
ImU32 gaugeCol;
float goodThreshold = S.drawElement("tabs.balance.shield", "good-threshold").sizeOr(80.0f);
float medThreshold = S.drawElement("tabs.balance.shield", "medium-threshold").sizeOr(50.0f);
if (privPct >= goodThreshold) gaugeCol = WithAlpha(Success(), 200);
else if (privPct >= medThreshold) gaugeCol = WithAlpha(Warning(), 200);
else gaugeCol = WithAlpha(Error(), 200);
float fillEnd = IM_PI + IM_PI * privacyRatio;
if (privacyRatio > 0.01f) {
dl->PathClear();
dl->PathArcTo(ImVec2(gaugeCx, gaugeCy), gaugeR, IM_PI, fillEnd, 32);
dl->PathArcTo(ImVec2(gaugeCx, gaugeCy), gaugeInnerR, fillEnd, IM_PI, 32);
dl->PathFillConvex(gaugeCol);
}
// Needle line
float needleAngle = IM_PI + IM_PI * privacyRatio;
float needleLen = gaugeR * 0.85f;
ImVec2 needleTip(gaugeCx + cosf(needleAngle) * needleLen,
gaugeCy + sinf(needleAngle) * needleLen);
dl->AddLine(ImVec2(gaugeCx, gaugeCy), needleTip, gaugeCol,
S.drawElement("tabs.balance.shield", "needle-thickness").sizeOr(2.0f));
// Center text: percentage
ImFont* sub1 = Type().subtitle1();
snprintf(buf, sizeof(buf), "%.0f%%", privPct);
ImVec2 pctSz = sub1->CalcTextSizeA(sub1->LegacySize, 1000, 0, buf);
dl->AddText(sub1, sub1->LegacySize,
ImVec2(gaugeCx - pctSz.x * 0.5f, gaugeCy - pctSz.y - 2 * dp),
gaugeCol, buf);
// Label below gauge
ImFont* capFont = Type().caption();
const char* statusMsg;
if (privPct >= 80.0f) statusMsg = TR("privacy_great");
else if (privPct >= 50.0f) statusMsg = TR("privacy_medium");
else statusMsg = TR("privacy_low");
ImVec2 msgSz = capFont->CalcTextSizeA(capFont->LegacySize, 1000, 0, statusMsg);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(gaugeCx - msgSz.x * 0.5f, gaugeCy + 4 * dp),
OnSurfaceMedium(), statusMsg);
// Right side: balances + market
float shieldPadOverride = S.drawElement("tabs.balance.shield", "card-padding").size;
float shieldPad = (shieldPadOverride >= 0.0f) ? shieldPadOverride : Layout::spacingLg();
float infoX = panelMin.x + gaugeH * 2 + shieldPad;
float infoY = panelMin.y + shieldPad;
ImFont* ovFont = Type().overline();
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(infoX, infoY), Success(), "SHIELDED");
infoY += ovFont->LegacySize + 2 * dp;
snprintf(buf, sizeof(buf), "%.8f", s_dispShielded);
dl->AddText(capFont, capFont->LegacySize, ImVec2(infoX, infoY), Success(), buf);
infoY += capFont->LegacySize + 6 * dp;
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(infoX, infoY), Warning(), "TRANSPARENT");
infoY += ovFont->LegacySize + 2 * dp;
snprintf(buf, sizeof(buf), "%.8f", s_dispTransparent);
dl->AddText(capFont, capFont->LegacySize, ImVec2(infoX, infoY), Warning(), buf);
infoY += capFont->LegacySize + 6 * dp;
const auto& market = state.market;
if (market.price_usd > 0) {
if (market.price_usd >= 0.01)
snprintf(buf, sizeof(buf), "$%.4f", market.price_usd);
else
snprintf(buf, sizeof(buf), "$%.8f", market.price_usd);
dl->AddText(capFont, capFont->LegacySize, ImVec2(infoX, infoY),
OnSurfaceMedium(), buf);
}
ImGui::Dummy(ImVec2(availW, gaugeH));
}
ImGui::Dummy(ImVec2(0, cGap));
float recentReserve = contentAvail.y * S.drawElement("tabs.balance", "recent-tx-reserve-ratio").sizeOr(0.18f);
float shieldAddrOverride = S.drawElement("tabs.balance.shield", "address-table-height").size;
float addrH = (shieldAddrOverride >= 0.0f) ? shieldAddrOverride * dp
: ImGui::GetContentRegionAvail().y - recentReserve
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
}
// ============================================================================
// Layout 6: Balance Timeline (placeholder — requires history tracking)
// ============================================================================
static void RenderBalanceTimeline(App* app) {
using namespace material;
const auto& S = schema::UISchema::instance();
const auto& state = app->state();
UpdateBalanceLerp(app);
ImVec2 contentAvail = ImGui::GetContentRegionAvail();
float availW = contentAvail.x;
float hs = Layout::hScale(availW);
float vs = Layout::vScale(contentAvail.y);
float glassRound = Layout::glassRounding();
float cGap = Layout::cardGap();
const float dp = Layout::dpiScale();
ImDrawList* dl = ImGui::GetWindowDrawList();
char buf[64];
// Hero
float tlTopMargin = S.drawElement("tabs.balance.timeline", "top-margin").size;
if (tlTopMargin >= 0.0f)
ImGui::Dummy(ImVec2(0, tlTopMargin));
else
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance", "compact-hero-pad").sizeOr(8.0f) * vs));
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), "TOTAL BALANCE");
ImGui::Dummy(ImVec2(0, 2 * dp));
snprintf(buf, sizeof(buf), "%.8f", s_dispTotal);
ImFont* heroFont = Type().h2();
ImVec2 pos = ImGui::GetCursorScreenPos();
DrawTextShadow(dl, heroFont, heroFont->LegacySize, pos, OnSurface(), buf);
ImVec2 heroSz = heroFont->CalcTextSizeA(heroFont->LegacySize, 10000.0f, 0.0f, buf);
ImGui::Dummy(heroSz);
ImGui::SameLine();
ImFont* capFont = Type().caption();
dl->AddText(capFont, capFont->LegacySize,
ImVec2(ImGui::GetCursorScreenPos().x, pos.y + heroSz.y - capFont->LegacySize),
OnSurfaceMedium(), DRAGONX_TICKER);
ImGui::NewLine();
double usd_value = state.getBalanceUSD();
if (usd_value > 0.0) snprintf(buf, sizeof(buf), "$%.2f USD", usd_value);
else snprintf(buf, sizeof(buf), "$-.-- USD");
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
}
RenderSyncBar(app, dl, vs);
ImGui::Dummy(ImVec2(0, cGap));
// Chart area placeholder
{
float tlChartH = S.drawElement("tabs.balance.timeline", "chart-height").size;
float chartH;
if (tlChartH >= 0.0f) {
chartH = tlChartH * dp; // scaled by dp for DPI + font scale
} else {
chartH = std::max(
S.drawElement("tabs.balance.timeline", "chart-min-height").sizeOr(80.0f) * dp,
contentAvail.y * S.drawElement("tabs.balance.timeline", "chart-height-ratio").sizeOr(0.20f));
}
ImVec2 chartMin = ImGui::GetCursorScreenPos();
ImVec2 chartMax(chartMin.x + availW, chartMin.y + chartH);
GlassPanelSpec spec;
spec.rounding = glassRound;
DrawGlassPanel(dl, chartMin, chartMax, spec);
ImFont* capFont = Type().caption();
const char* msg = TR("balance_history_collecting");
ImVec2 msgSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, msg);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(chartMin.x + (availW - msgSz.x) * 0.5f,
chartMin.y + (chartH - msgSz.y) * 0.5f),
OnSurfaceDisabled(), msg);
// If we have market sparkline data, use it as a preview
const auto& market = state.market;
if (market.price_history.size() >= 2) {
float sparkPad = Layout::spacingLg();
ImVec2 spMin(chartMin.x + sparkPad, chartMin.y + capFont->LegacySize + sparkPad * 2);
ImVec2 spMax(chartMax.x - sparkPad, chartMax.y - sparkPad);
if (spMax.y > spMin.y && spMax.x > spMin.x) {
ImU32 lineCol = market.change_24h >= 0
? WithAlpha(Success(), (int)S.drawElement("tabs.balance.timeline", "sparkline-alpha").sizeOr(120.0f))
: WithAlpha(Error(), (int)S.drawElement("tabs.balance.timeline", "sparkline-alpha").sizeOr(120.0f));
DrawSparkline(dl, spMin, spMax, market.price_history, lineCol);
}
}
ImGui::Dummy(ImVec2(availW, chartH));
}
// Compact 3 summary cards
ImGui::Dummy(ImVec2(0, cGap));
{
ImFont* ovFont = Type().overline();
ImFont* capFont = Type().caption();
// Font-content floor: pad + overline + gap + caption + pad
float tlPadVal = S.drawElement("tabs.balance.timeline", "card-padding").size;
float tlPad = (tlPadVal >= 0.0f) ? tlPadVal : Layout::spacingXs();
float tlFontFloor = tlPad + ovFont->LegacySize + 2.0f * dp + capFont->LegacySize + tlPad;
float tlSummaryH = S.drawElement("tabs.balance.timeline", "summary-card-height").size;
float cardH;
if (tlSummaryH >= 0.0f) {
cardH = std::max(tlSummaryH * dp, tlFontFloor);
} else {
cardH = std::max({tlFontFloor,
S.drawElement("tabs.balance.timeline", "summary-min-height").sizeOr(44.0f) * dp,
contentAvail.y * S.drawElement("tabs.balance.timeline", "summary-height-ratio").sizeOr(0.08f)});
}
float cardW = (availW - 2 * cGap) / 3.0f;
ImVec2 origin = ImGui::GetCursorScreenPos();
GlassPanelSpec spec;
spec.rounding = glassRound;
struct SumCard { const char* label; ImU32 col; double val; bool isMoney; };
SumCard cards[3] = {
{"SHIELDED", Success(), s_dispShielded, false},
{"TRANSPARENT", Warning(), s_dispTransparent, false},
{"MARKET", Primary(), state.market.price_usd, true},
};
for (int i = 0; i < 3; i++) {
ImVec2 cMin(origin.x + i * (cardW + cGap), origin.y);
ImVec2 cMax(cMin.x + cardW, cMin.y + cardH);
DrawGlassPanel(dl, cMin, cMax, spec);
float tlPadOverride = S.drawElement("tabs.balance.timeline", "card-padding").size;
float cx = cMin.x + ((tlPadOverride >= 0.0f) ? tlPadOverride : Layout::spacingSm());
float cy = cMin.y + ((tlPadOverride >= 0.0f) ? tlPadOverride : Layout::spacingXs());
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(cx, cy), OnSurfaceMedium(), cards[i].label);
cy += ovFont->LegacySize + 2 * dp;
if (cards[i].isMoney) {
if (cards[i].val >= 0.01) snprintf(buf, sizeof(buf), "$%.4f", cards[i].val);
else if (cards[i].val > 0) snprintf(buf, sizeof(buf), "$%.8f", cards[i].val);
else snprintf(buf, sizeof(buf), "$--.--");
} else {
snprintf(buf, sizeof(buf), "%.8f", cards[i].val);
}
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy), cards[i].col, buf);
}
ImGui::Dummy(ImVec2(availW, cardH));
}
ImGui::Dummy(ImVec2(0, cGap));
float recentReserve = contentAvail.y * S.drawElement("tabs.balance", "recent-tx-reserve-ratio").sizeOr(0.18f);
float tlAddrOverride = S.drawElement("tabs.balance.timeline", "address-table-height").size;
float addrH = (tlAddrOverride >= 0.0f) ? tlAddrOverride * dp
: ImGui::GetContentRegionAvail().y - recentReserve
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
}
// ============================================================================
// Layout 7: Two-Row Hero
// ============================================================================
static void RenderBalanceTwoRow(App* app) {
using namespace material;
const auto& S = schema::UISchema::instance();
const auto& state = app->state();
UpdateBalanceLerp(app);
ImVec2 contentAvail = ImGui::GetContentRegionAvail();
float availW = contentAvail.x;
float hs = Layout::hScale(availW);
float vs = Layout::vScale(contentAvail.y);
float glassRound = Layout::glassRounding();
float cGap = Layout::cardGap();
const float dp = Layout::dpiScale();
ImDrawList* dl = ImGui::GetWindowDrawList();
char buf[64];
// Top margin
float twoRowTopMargin = S.drawElement("tabs.balance.two-row", "top-margin").size;
if (twoRowTopMargin >= 0.0f)
ImGui::Dummy(ImVec2(0, twoRowTopMargin));
// Row 1: logo + balance + USD + actions
RenderCompactHero(app, dl, availW, hs, vs);
// Sync + mining on same line
{
const auto& state = app->state();
ImFont* capFont = Type().caption();
if (state.sync.syncing && state.sync.headers > 0) {
float pct = static_cast<float>(state.sync.verification_progress) * 100.0f;
snprintf(buf, sizeof(buf), "Syncing %.1f%%", pct);
Type().textColored(TypeStyle::Caption, Warning(), buf);
ImGui::SameLine();
}
if (state.mining.generate) {
double hr = state.mining.localHashrate;
snprintf(buf, sizeof(buf), "Mining %s", FormatHashrate(hr).c_str());
Type().textColored(TypeStyle::Caption, WithAlpha(Success(), 200), buf);
ImGui::SameLine();
}
// Action buttons right-aligned
float btnW = S.drawElement("tabs.balance.two-row", "action-btn-width").sizeOr(80.0f);
float rightEdge = ImGui::GetWindowWidth() - Layout::spacingLg();
ImGui::SameLine(rightEdge - btnW * 2 - Layout::spacingSm());
if (TactileButton("Send", ImVec2(btnW, 0), S.resolveFont("button"))) {
app->setCurrentPage(NavPage::Send);
}
ImGui::SameLine();
if (TactileButton("Receive", ImVec2(btnW, 0), S.resolveFont("button"))) {
app->setCurrentPage(NavPage::Receive);
}
}
RenderSyncBar(app, dl, vs);
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance.two-row", "sync-gap").sizeOr(2.0f)));
// Row 2: 3 mini-cards inline
{
ImFont* twoRowCap = Type().caption();
// Font-content floor: caption centered + vertical padding
float twoRowFontFloor = twoRowCap->LegacySize + Layout::spacingSm() * 2;
float twoRowCardH = S.drawElement("tabs.balance.two-row", "card-height").size;
float miniH;
if (twoRowCardH >= 0.0f) {
miniH = std::max(twoRowCardH * dp, twoRowFontFloor);
} else {
miniH = std::max({twoRowFontFloor,
S.drawElement("tabs.balance.two-row", "mini-min-height").sizeOr(28.0f) * dp,
S.drawElement("tabs.balance.two-row", "mini-base-height").sizeOr(36.0f) * vs});
}
float miniW = (availW - 2 * cGap) / 3.0f;
ImVec2 origin = ImGui::GetCursorScreenPos();
GlassPanelSpec spec;
spec.rounding = std::max(
S.drawElement("tabs.balance.two-row", "mini-rounding-min").sizeOr(4.0f),
glassRound * S.drawElement("tabs.balance.two-row", "mini-rounding-ratio").sizeOr(0.5f));
ImFont* capFont = Type().caption();
float indicatorR = S.drawElement("tabs.balance.two-row", "indicator-radius").sizeOr(3.0f);
int balDecimals = (int)S.drawElement("tabs.balance.two-row", "balance-decimals").sizeOr(4.0f);
float twoRowPadOverride = S.drawElement("tabs.balance.two-row", "card-padding").size;
float miniPad = (twoRowPadOverride >= 0.0f) ? twoRowPadOverride : Layout::spacingSm();
// Shielded mini-card
{
ImVec2 cMin = origin;
ImVec2 cMax(cMin.x + miniW, cMin.y + miniH);
DrawGlassPanel(dl, cMin, cMax, spec);
float cx = cMin.x + miniPad;
float cy = cMin.y + (miniH - capFont->LegacySize) * 0.5f;
dl->AddCircleFilled(ImVec2(cx + 4 * dp, cy + capFont->LegacySize * 0.5f), indicatorR, Success());
snprintf(buf, sizeof(buf), "%.*f", balDecimals, s_dispShielded);
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx + 12 * dp, cy), Success(), buf);
// Percentage of total (right-aligned)
float shieldPct = (s_dispTotal > 1e-9) ? (float)(s_dispShielded / s_dispTotal * 100.0) : 0.0f;
snprintf(buf, sizeof(buf), "%.0f%%", shieldPct);
ImVec2 pctSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, buf);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cMax.x - pctSz.x - miniPad, cy),
WithAlpha(Success(), 160), buf);
}
// Transparent mini-card
{
ImVec2 cMin(origin.x + miniW + cGap, origin.y);
ImVec2 cMax(cMin.x + miniW, cMin.y + miniH);
DrawGlassPanel(dl, cMin, cMax, spec);
float cx = cMin.x + miniPad;
float cy = cMin.y + (miniH - capFont->LegacySize) * 0.5f;
dl->AddCircleFilled(ImVec2(cx + 4 * dp, cy + capFont->LegacySize * 0.5f), indicatorR, Warning());
snprintf(buf, sizeof(buf), "%.*f", balDecimals, s_dispTransparent);
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx + 12 * dp, cy), Warning(), buf);
// Percentage of total (right-aligned)
float transPct = (s_dispTotal > 1e-9) ? (float)(s_dispTransparent / s_dispTotal * 100.0) : 0.0f;
snprintf(buf, sizeof(buf), "%.0f%%", transPct);
ImVec2 pctSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, buf);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cMax.x - pctSz.x - miniPad, cy),
WithAlpha(Warning(), 160), buf);
}
// Market mini-card
{
ImVec2 cMin(origin.x + 2 * (miniW + cGap), origin.y);
ImVec2 cMax(cMin.x + miniW, cMin.y + miniH);
DrawGlassPanel(dl, cMin, cMax, spec);
float cx = cMin.x + miniPad;
float cy = cMin.y + (miniH - capFont->LegacySize) * 0.5f;
const auto& market = state.market;
if (market.price_usd >= 0.01)
snprintf(buf, sizeof(buf), "$%.4f", market.price_usd);
else if (market.price_usd > 0)
snprintf(buf, sizeof(buf), "$%.8f", market.price_usd);
else
snprintf(buf, sizeof(buf), "$--.--");
ImVec2 priceSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, buf);
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy), OnSurfaceMedium(), buf);
float sparkRight = cMax.x - miniPad;
if (market.price_usd > 0) {
bool pos = market.change_24h >= 0;
snprintf(buf, sizeof(buf), "%s%.1f%%", pos ? "+" : "", market.change_24h);
ImVec2 chgSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, buf);
float chgX = cMax.x - chgSz.x - miniPad;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(chgX, cy),
pos ? Success() : Error(), buf);
sparkRight = chgX;
// Sparkline between price and percentage
if (market.price_history.size() >= 2) {
float sparkGap = S.drawElement("tabs.balance.two-row", "sparkline-gap").sizeOr(6.0f);
float sparkPad = S.drawElement("tabs.balance.two-row", "sparkline-pad").sizeOr(4.0f);
float sparkLeft = cx + priceSz.x + sparkGap;
float sparkRightEdge = sparkRight - sparkGap;
if (sparkLeft < sparkRightEdge) {
ImVec2 spMin(sparkLeft, cMin.y + sparkPad);
ImVec2 spMax(sparkRightEdge, cMax.y - sparkPad);
ImU32 lineCol = pos
? WithAlpha(Success(), 200)
: WithAlpha(Error(), 200);
DrawSparkline(dl, spMin, spMax,
market.price_history, lineCol);
}
}
}
}
ImGui::Dummy(ImVec2(availW, miniH));
}
ImGui::Dummy(ImVec2(0, cGap));
float recentReserve = contentAvail.y * S.drawElement("tabs.balance", "recent-tx-reserve-ratio").sizeOr(0.18f);
float twoRowAddrOverride = S.drawElement("tabs.balance.two-row", "address-table-height").size;
float addrH = (twoRowAddrOverride >= 0.0f) ? twoRowAddrOverride * dp
: ImGui::GetContentRegionAvail().y - recentReserve
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
}
// ============================================================================
// Layout 8: Minimal (card-less typography only)
// ============================================================================
static void RenderBalanceMinimal(App* app) {
using namespace material;
const auto& S = schema::UISchema::instance();
const auto& state = app->state();
UpdateBalanceLerp(app);
ImVec2 contentAvail = ImGui::GetContentRegionAvail();
float availW = contentAvail.x;
float hs = Layout::hScale(availW);
float vs = Layout::vScale(contentAvail.y);
float glassRound = Layout::glassRounding();
const float dp = Layout::dpiScale();
ImDrawList* dl = ImGui::GetWindowDrawList();
char buf[64];
// Top margin
float minTopMargin = S.drawElement("tabs.balance.minimal", "top-margin").size;
if (minTopMargin >= 0.0f)
ImGui::Dummy(ImVec2(0, minTopMargin));
ImFont* heroFont = Type().h2();
ImFont* sub1 = Type().subtitle1();
ImFont* capFont = Type().caption();
// Line 1: Big balance + market price
snprintf(buf, sizeof(buf), "%.8f %s", s_dispTotal, DRAGONX_TICKER);
ImVec2 pos = ImGui::GetCursorScreenPos();
DrawTextShadow(dl, heroFont, heroFont->LegacySize, pos, OnSurface(), buf);
ImVec2 heroSz = heroFont->CalcTextSizeA(heroFont->LegacySize, 10000.0f, 0.0f, buf);
// Market price right-aligned
const auto& market = state.market;
if (market.price_usd > 0) {
if (market.price_usd >= 0.01)
snprintf(buf, sizeof(buf), "$%.4f", market.price_usd);
else
snprintf(buf, sizeof(buf), "$%.8f", market.price_usd);
ImVec2 pSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, buf);
float rightEdge = pos.x + availW;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(rightEdge - pSz.x, pos.y + heroSz.y - capFont->LegacySize),
OnSurfaceMedium(), buf);
}
ImGui::Dummy(heroSz);
// Line 2: Shielded + Transparent
snprintf(buf, sizeof(buf), TR("balance_shielded_fmt"), s_dispShielded);
Type().textColored(TypeStyle::Caption, Success(), buf);
ImGui::SameLine(0, Layout::spacingLg());
snprintf(buf, sizeof(buf), TR("balance_transparent_fmt"), s_dispTransparent);
Type().textColored(TypeStyle::Caption, Warning(), buf);
// USD value
double usd_value = state.getBalanceUSD();
if (usd_value > 0.0) snprintf(buf, sizeof(buf), "$%.2f USD", usd_value);
else snprintf(buf, sizeof(buf), "$-.-- USD");
ImGui::SameLine(0, Layout::spacingLg());
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
RenderSyncBar(app, dl, vs);
// Dashed separator
{
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImVec2 sepPos = ImGui::GetCursorScreenPos();
float dashLen = S.drawElement("tabs.balance.minimal", "dash-length").sizeOr(6.0f);
float gapLen = S.drawElement("tabs.balance.minimal", "dash-gap").sizeOr(4.0f);
float sepAlpha = S.drawElement("tabs.balance.minimal", "separator-alpha").sizeOr(25.0f);
float sepThick = S.drawElement("tabs.balance.minimal", "separator-thickness").sizeOr(1.0f);
float x = sepPos.x;
float endX = sepPos.x + availW;
while (x < endX) {
float x2 = std::min(x + dashLen, endX);
dl->AddLine(ImVec2(x, sepPos.y), ImVec2(x2, sepPos.y),
IM_COL32(255, 255, 255, (int)sepAlpha), sepThick);
x += dashLen + gapLen;
}
ImGui::Dummy(ImVec2(availW, 1));
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
}
float recentReserve = contentAvail.y * S.drawElement("tabs.balance", "recent-tx-reserve-ratio").sizeOr(0.18f);
float minAddrOverride = S.drawElement("tabs.balance.minimal", "address-table-height").size;
float addrH = (minAddrOverride >= 0.0f) ? minAddrOverride * dp
: ImGui::GetContentRegionAvail().y - recentReserve
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
}
} // namespace ui
} // namespace dragonx