Files
ObsidianDragon/src/ui/windows/balance_tab.cpp
DanS 121a79e768 refactor(audit): batch 9 — de-duplicate balance/peers/transactions UI cards
Three UI de-duplications (rendering changes; the test suite skips the GUI, so
these need a manual screenshot check of the Balance/Peers/Transactions tabs).

1. Classic balance layout — the DEFAULT layout — hand-rolled its own ~550-line
   address-list + recent-tx renderers that had DIVERGED from the shared
   RenderSharedAddressList/RenderSharedRecentTx used by the other 9 layouts.
   Deleted the inline copies and called the shared renderers (as the minimal
   layout already does), so the default layout now gains set-label +
   add-to-portfolio context items, custom address icons, drag-to-reorder,
   keyboard nav, copy-flash, and fully TR()'d strings. Hero row and Classic
   sizing (tabs.balance.classic address-table-height=340) preserved; addrH is
   computed before the call exactly as before. This is a deliberate
   feature-parity behavior change for the default layout.

2. peers_tab info cards — extracted drawStatCell() (label + value/em-dash with
   the shared offset math) and one drawCardDivider() replacing two duplicate
   divider lambdas; plain cells drive off a per-card loop. Bespoke cells
   (Blocks "(X left)", copy-on-click Best Block, TLS check-icon) kept inline.

3. transactions_tab — one drawSummaryCard() replaces the three near-identical
   Received/Sent/Mined blocks (same glass panel, icon, hover outline,
   click-to-filter). Byte-equivalent.

Full-node + Lite build clean (net -651 lines, no new warnings); ctest 1/1;
hygiene clean. NEEDS SCREENSHOT VERIFICATION.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:18:02 -05:00

2119 lines
98 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 "../../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);
}
// 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