The Market tab is non-scrolling, but an oversized fixed-height chart ate ~65%
of the height and squeezed the portfolio into a cramped strip, with custom
groups rendered as tiny afterthought rows at the very bottom.
- Precompute section geometry (hero header + portfolio card height) up front and
let the price chart ABSORB the remaining vertical space, capped at its schema
height and floored at 130px*vs so it stays useful. The chart now shrinks to
make room instead of dominating. Replaces the barely-used SectionBudget.
- Portfolio summary refined: fiat value stays the hero with the 24h change beside
it and the BTC value right-aligned; DRGX balance + Z/T breakdown on the next
row; a full-width shielded/transparent ratio bar + % label.
- Custom groups now render as a wrapping grid of mini glass cards (label + DRGX +
fiat each), sized to the card width, instead of a cramped one-line list. A
"GROUPS" subheader separates them from the all-funds summary. The card grows to
fit the grid.
New i18n key portfolio_groups ("GROUPS"). Full-node + Lite build clean; ctest
1/1; hygiene clean. Rendering change — needs a screenshot check of the Market tab.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
914 lines
45 KiB
C++
914 lines
45 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
|
|
#include "market_tab.h"
|
|
#include "../../app.h"
|
|
#include "../../config/version.h"
|
|
#include "../../data/wallet_state.h"
|
|
#include "../../config/settings.h"
|
|
#include "../../data/portfolio.h"
|
|
#include "../../data/exchange_info.h"
|
|
#include "../../util/i18n.h"
|
|
#include "../schema/ui_schema.h"
|
|
#include "../material/type.h"
|
|
#include "../material/draw_helpers.h"
|
|
#include "../material/colors.h"
|
|
#include "../material/typography.h"
|
|
#include "../../embedded/IconsMaterialDesign.h"
|
|
#include "../../util/platform.h"
|
|
#include "../layout.h"
|
|
#include "imgui.h"
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <ctime>
|
|
|
|
namespace dragonx {
|
|
namespace ui {
|
|
|
|
using namespace material;
|
|
|
|
// ---- Market tab persistent state ----
|
|
static std::vector<double> s_price_history; // mirror of WalletState market.price_history
|
|
static bool s_history_initialized = false;
|
|
static double s_last_refresh_time = 0.0;
|
|
|
|
// Exchange / pair selection
|
|
static int s_exchange_idx = 0;
|
|
static int s_pair_idx = 0;
|
|
static bool s_market_state_loaded = false;
|
|
|
|
// The effective exchange registry: the live CoinGecko list when available, else the
|
|
// compiled-in fallback.
|
|
static const std::vector<data::ExchangeInfo>& EffectiveRegistry(const MarketInfo& market)
|
|
{
|
|
return market.exchanges.empty() ? data::getExchangeRegistry() : market.exchanges;
|
|
}
|
|
|
|
// Helper: load selected exchange/pair from settings
|
|
static void LoadMarketState(config::Settings* settings, const std::vector<data::ExchangeInfo>& registry)
|
|
{
|
|
if (s_market_state_loaded || !settings) return;
|
|
s_market_state_loaded = true;
|
|
|
|
std::string savedExchange = settings->getSelectedExchange();
|
|
std::string savedPair = settings->getSelectedPair();
|
|
|
|
for (int ei = 0; ei < (int)registry.size(); ei++) {
|
|
if (registry[ei].name == savedExchange) {
|
|
s_exchange_idx = ei;
|
|
for (int pi = 0; pi < (int)registry[ei].pairs.size(); pi++) {
|
|
if (registry[ei].pairs[pi].displayName == savedPair) {
|
|
s_pair_idx = pi;
|
|
break;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper: format compact currency
|
|
static std::string FormatCompactUSD(double val)
|
|
{
|
|
char buf[64];
|
|
if (val >= 1e9) snprintf(buf, sizeof(buf), "$%.2fB", val / 1e9);
|
|
else if (val >= 1e6) snprintf(buf, sizeof(buf), "$%.2fM", val / 1e6);
|
|
else if (val >= 1e3) snprintf(buf, sizeof(buf), "$%.2fK", val / 1e3);
|
|
else snprintf(buf, sizeof(buf), "$%.2f", val);
|
|
return std::string(buf);
|
|
}
|
|
|
|
// Helper: format price to sensible precision
|
|
static std::string FormatPrice(double price)
|
|
{
|
|
char buf[64];
|
|
if (price >= 0.01) snprintf(buf, sizeof(buf), "$%.4f", price);
|
|
else if (price >= 0.0001) snprintf(buf, sizeof(buf), "$%.6f", price);
|
|
else snprintf(buf, sizeof(buf), "$%.8f", price);
|
|
return std::string(buf);
|
|
}
|
|
|
|
// ---- Portfolio editor (modal) state ----
|
|
static bool s_portfolio_editor_open = false;
|
|
static int s_pf_editing = -1; // -1 = list mode; -2 = adding new; >=0 = editing that index
|
|
static char s_pf_label[64] = {0};
|
|
static std::vector<std::string> s_pf_addrs;
|
|
|
|
// Populate the working buffers for a new (index < 0) or existing entry.
|
|
static void PortfolioBeginEdit(App* app, int index)
|
|
{
|
|
const auto& entries = app->settings()->getPortfolioEntries();
|
|
s_pf_editing = index;
|
|
if (index >= 0 && index < (int)entries.size()) {
|
|
snprintf(s_pf_label, sizeof(s_pf_label), "%s", entries[index].label.c_str());
|
|
s_pf_addrs = entries[index].addresses;
|
|
} else {
|
|
s_pf_label[0] = '\0';
|
|
s_pf_addrs.clear();
|
|
}
|
|
}
|
|
|
|
// The "Manage portfolio" modal: list/add/edit/delete entries, each a label tied to a group
|
|
// of wallet addresses. Persists to Settings on every mutation (like the address book).
|
|
static void RenderPortfolioEditor(App* app)
|
|
{
|
|
if (!s_portfolio_editor_open) return;
|
|
float dialogW = std::min(560.0f, ImGui::GetMainViewport()->Size.x * 0.9f);
|
|
if (!material::BeginOverlayDialog(TR("portfolio_manage_title"), &s_portfolio_editor_open, dialogW)) {
|
|
return;
|
|
}
|
|
|
|
config::Settings* settings = app->settings();
|
|
const auto& state = app->getWalletState();
|
|
|
|
auto persist = [&](const std::vector<config::Settings::PortfolioEntry>& entries) {
|
|
settings->setPortfolioEntries(entries);
|
|
settings->save();
|
|
};
|
|
|
|
if (s_pf_editing == -1) {
|
|
// ---- LIST MODE ----
|
|
const auto& entries = settings->getPortfolioEntries();
|
|
if (entries.empty()) {
|
|
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("portfolio_no_entries"));
|
|
}
|
|
for (int i = 0; i < (int)entries.size(); i++) {
|
|
ImGui::PushID(i);
|
|
double bal = data::SumPortfolioBalance(entries[i].addresses, state.addresses);
|
|
char row[160];
|
|
snprintf(row, sizeof(row), "%s \xE2\x80\x94 %.4f %s (%d)",
|
|
entries[i].label.c_str(), bal, DRAGONX_TICKER, (int)entries[i].addresses.size());
|
|
ImGui::TextUnformatted(row);
|
|
ImGui::SameLine();
|
|
float bw = 72.0f;
|
|
float avail = ImGui::GetContentRegionAvail().x;
|
|
if (avail > bw * 2 + Layout::spacingSm())
|
|
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + avail - bw * 2 - Layout::spacingSm());
|
|
if (ImGui::Button(TR("portfolio_edit"), ImVec2(bw, 0))) PortfolioBeginEdit(app, i);
|
|
ImGui::SameLine();
|
|
if (ImGui::Button(TR("portfolio_delete"), ImVec2(bw, 0))) {
|
|
auto e = entries; e.erase(e.begin() + i); persist(e);
|
|
ImGui::PopID();
|
|
break;
|
|
}
|
|
ImGui::PopID();
|
|
}
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
if (ImGui::Button(TR("portfolio_add_entry"))) PortfolioBeginEdit(app, -2);
|
|
} else {
|
|
// ---- EDIT MODE ----
|
|
ImGui::TextUnformatted(TR("portfolio_label"));
|
|
ImGui::SetNextItemWidth(-1);
|
|
ImGui::InputText("##pfLabel", s_pf_label, sizeof(s_pf_label));
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
|
|
|
// Quick group selectors.
|
|
if (ImGui::Button(TR("portfolio_all_shielded"))) {
|
|
for (const auto& a : state.addresses)
|
|
if (a.type == "shielded") data::PortfolioEntryAdd(s_pf_addrs, a.address);
|
|
}
|
|
ImGui::SameLine();
|
|
if (ImGui::Button(TR("portfolio_all_transparent"))) {
|
|
for (const auto& a : state.addresses)
|
|
if (a.type == "transparent") data::PortfolioEntryAdd(s_pf_addrs, a.address);
|
|
}
|
|
ImGui::SameLine();
|
|
if (ImGui::Button(TR("portfolio_clear_sel"))) s_pf_addrs.clear();
|
|
|
|
char selc[48];
|
|
snprintf(selc, sizeof(selc), TR("portfolio_addresses_sel"), (int)s_pf_addrs.size());
|
|
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), selc);
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
|
|
|
// Address checklist. The overlay dialog auto-resizes to its content
|
|
// (ImGuiChildFlags_AutoResizeY), so a negative "fill-remaining" height collapses to ~0
|
|
// here — size the list to its rows instead, capped at 40% of the viewport so a large
|
|
// wallet scrolls rather than running off-screen.
|
|
float pfRowH = ImGui::GetFrameHeightWithSpacing();
|
|
int pfRows = std::max(1, (int)state.addresses.size());
|
|
float pfListH = std::min(pfRows * pfRowH, ImGui::GetMainViewport()->Size.y * 0.4f);
|
|
ImGui::BeginChild("##pfAddrList", ImVec2(0, pfListH), true);
|
|
for (const auto& a : state.addresses) {
|
|
bool inSet = data::PortfolioEntryContains(s_pf_addrs, a.address);
|
|
char line[192];
|
|
snprintf(line, sizeof(line), "%s (%.4f %s)", a.address.c_str(), a.balance, DRAGONX_TICKER);
|
|
ImGui::PushID(a.address.c_str());
|
|
if (ImGui::Checkbox(line, &inSet)) {
|
|
if (inSet) data::PortfolioEntryAdd(s_pf_addrs, a.address);
|
|
else data::PortfolioEntryRemove(s_pf_addrs, a.address);
|
|
}
|
|
ImGui::PopID();
|
|
}
|
|
ImGui::EndChild();
|
|
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
|
bool canSave = s_pf_label[0] != '\0';
|
|
ImGui::BeginDisabled(!canSave);
|
|
if (ImGui::Button(TR("portfolio_save"))) {
|
|
auto entries = settings->getPortfolioEntries();
|
|
config::Settings::PortfolioEntry e;
|
|
e.label = s_pf_label;
|
|
e.addresses = s_pf_addrs;
|
|
if (s_pf_editing >= 0 && s_pf_editing < (int)entries.size()) entries[s_pf_editing] = e;
|
|
else entries.push_back(e);
|
|
persist(entries);
|
|
s_pf_editing = -1;
|
|
}
|
|
ImGui::EndDisabled();
|
|
ImGui::SameLine();
|
|
if (ImGui::Button(TR("portfolio_cancel"))) s_pf_editing = -1;
|
|
}
|
|
|
|
material::EndOverlayDialog();
|
|
}
|
|
|
|
void RenderMarketTab(App* app)
|
|
{
|
|
auto& S = schema::UI();
|
|
auto summaryPanel = S.table("tabs.market", "summary-panel");
|
|
auto btcPriceLbl = S.label("tabs.market", "btc-price-label");
|
|
auto change24hLbl = S.label("tabs.market", "change-24h-label");
|
|
auto volumeLbl = S.label("tabs.market", "volume-label");
|
|
auto volumeValLbl = S.label("tabs.market", "volume-value-label");
|
|
auto mktCapLbl = S.label("tabs.market", "market-cap-label");
|
|
auto mktCapValLbl = S.label("tabs.market", "market-cap-value-label");
|
|
auto chartElem = S.drawElement("tabs.market", "chart");
|
|
auto portfolioValLbl = S.label("tabs.market", "portfolio-value-label");
|
|
auto portfolioBtcLbl = S.label("tabs.market", "portfolio-btc-label");
|
|
const auto& state = app->getWalletState();
|
|
const auto& market = state.market;
|
|
|
|
// Kick a once-per-session live exchange-list fetch (self-guarded), then source the
|
|
// registry from it (falling back to the compiled-in list until it arrives).
|
|
app->refreshExchanges();
|
|
const auto& registry = EffectiveRegistry(market);
|
|
|
|
// Load persisted exchange/pair on first frame
|
|
LoadMarketState(app->settings(), registry);
|
|
|
|
if (s_exchange_idx >= (int)registry.size()) s_exchange_idx = 0;
|
|
const auto& currentExchange = registry[s_exchange_idx];
|
|
if (s_pair_idx >= (int)currentExchange.pairs.size()) s_pair_idx = 0;
|
|
|
|
// Non-scrolling container — content resizes to fit available height
|
|
ImVec2 marketAvail = ImGui::GetContentRegionAvail();
|
|
ImGui::BeginChild("##MarketScroll", marketAvail, false,
|
|
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
|
|
|
// Responsive: scale factors per frame
|
|
float availWidth = ImGui::GetContentRegionAvail().x;
|
|
float hs = Layout::hScale(availWidth);
|
|
float vs = Layout::vScale(marketAvail.y);
|
|
float pad = Layout::cardInnerPadding();
|
|
float gap = Layout::cardGap();
|
|
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
GlassPanelSpec glassSpec;
|
|
glassSpec.rounding = Layout::glassRounding();
|
|
ImFont* ovFont = Type().overline();
|
|
ImFont* capFont = Type().caption();
|
|
ImFont* sub1 = Type().subtitle1();
|
|
ImFont* h4 = Type().h4();
|
|
ImFont* body2 = Type().body2();
|
|
|
|
char buf[128];
|
|
|
|
// ================================================================
|
|
// Section geometry — this tab does NOT scroll, so everything must fit. Precompute the
|
|
// portfolio card height (refined summary + a grid of group cards) and the hero header,
|
|
// then let the price chart absorb the remaining vertical space (capped at its schema
|
|
// height, floored so it stays useful). Fixes the old layout where an oversized chart
|
|
// squeezed the portfolio into a cramped strip.
|
|
// ================================================================
|
|
float mktDp = Layout::dpiScale();
|
|
|
|
// -- Portfolio card geometry --
|
|
const auto& pfEntriesGeo = app->settings()->getPortfolioEntries();
|
|
float pfInnerW = availWidth - Layout::spacingLg() * 2.0f;
|
|
float ratioBarH = std::max(S.drawElement("tabs.market", "ratio-bar-min-height").size,
|
|
S.drawElement("tabs.market", "ratio-bar-height").size * vs);
|
|
float pfSummaryH = Layout::spacingLg()
|
|
+ sub1->LegacySize + Layout::spacingSm() // fiat hero row
|
|
+ body2->LegacySize + Layout::spacingSm() // DRGX balance row
|
|
+ ratioBarH + Layout::spacingXs() + capFont->LegacySize // ratio bar + % label
|
|
+ Layout::spacingMd(); // bottom pad
|
|
int pfN = (int)pfEntriesGeo.size();
|
|
float pfCardGap = Layout::spacingSm();
|
|
float pfMinCardW = 200.0f * mktDp;
|
|
int pfCardsPerRow = std::max(1, (int)((pfInnerW + pfCardGap) / (pfMinCardW + pfCardGap)));
|
|
if (pfN > 0 && pfCardsPerRow > pfN) pfCardsPerRow = pfN;
|
|
float pfCardW = (pfN > 0) ? (pfInnerW - (pfCardsPerRow - 1) * pfCardGap) / (float)pfCardsPerRow : 0.0f;
|
|
float pfCardH = body2->LegacySize + Layout::spacingXs() + body2->LegacySize
|
|
+ capFont->LegacySize + Layout::spacingSm() * 2.0f;
|
|
int pfRows = (pfN > 0) ? (pfN + pfCardsPerRow - 1) / pfCardsPerRow : 0;
|
|
float pfGroupsH = (pfN > 0)
|
|
? (ovFont->LegacySize + Layout::spacingSm()
|
|
+ pfRows * pfCardH + std::max(0, pfRows - 1) * pfCardGap
|
|
+ Layout::spacingMd())
|
|
: 0.0f;
|
|
float portfolioCardH = pfSummaryH + pfGroupsH;
|
|
|
|
// -- Hero header (price + stats, excluding the chart) --
|
|
float mktStatsRowH = ovFont->LegacySize + Layout::spacingXs() + sub1->LegacySize + pad;
|
|
float heroHeaderH = std::max(
|
|
S.drawElement("tabs.market", "hero-card-min-height").size + mktStatsRowH + pad,
|
|
(S.drawElement("tabs.market", "hero-card-height").size + mktStatsRowH + pad) * vs);
|
|
|
|
// -- Chart absorbs the slack: capped at its schema height, floored so it stays useful --
|
|
float mktChipH = S.drawElement("tabs.market", "pair-chip-height").height;
|
|
float mktPairsReserve = mktChipH * 2.0f + Layout::spacingXl() + capFont->LegacySize;
|
|
float chartH = std::min(chartElem.height * vs,
|
|
marketAvail.y - heroHeaderH - portfolioCardH - mktPairsReserve - gap * 3.0f);
|
|
chartH = std::max(chartH, 130.0f * vs);
|
|
|
|
// ================================================================
|
|
// PRICE SUMMARY — Combined hero card with price, stats, and exchange
|
|
// ================================================================
|
|
{
|
|
float dp = Layout::dpiScale();
|
|
ImVec2 cardMin = ImGui::GetCursorScreenPos();
|
|
float cardH = heroHeaderH;
|
|
ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + cardH);
|
|
// Combined hero + chart: draw ONE glass panel spanning the price/stats header AND the
|
|
// chart below it (chartH precomputed so the chart shrinks to fit the portfolio).
|
|
float mergedChartH = chartH;
|
|
ImVec2 mergedMax(cardMin.x + availWidth, cardMax.y + mergedChartH);
|
|
DrawGlassPanel(dl, cardMin, mergedMax, glassSpec);
|
|
|
|
float cx = cardMin.x + Layout::spacingLg();
|
|
float cy = cardMin.y + Layout::spacingLg();
|
|
|
|
if (market.price_usd > 0) {
|
|
// ---- HERO PRICE (large, prominent) ----
|
|
ImFont* h3 = Type().h3();
|
|
std::string priceStr = FormatPrice(market.price_usd);
|
|
ImU32 priceCol = Success();
|
|
DrawTextShadow(dl, h3, h3->LegacySize, ImVec2(cx, cy), priceCol, priceStr.c_str());
|
|
|
|
// Ticker label after price
|
|
float priceW = h3->CalcTextSizeA(h3->LegacySize, FLT_MAX, 0, priceStr.c_str()).x;
|
|
dl->AddText(body2, body2->LegacySize,
|
|
ImVec2(cx + priceW + Layout::spacingSm(), cy + (h3->LegacySize - body2->LegacySize)),
|
|
OnSurfaceMedium(), DRAGONX_TICKER);
|
|
|
|
// 24h change badge — to the right of ticker
|
|
float tickerW = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, DRAGONX_TICKER).x;
|
|
float badgeX = cx + priceW + Layout::spacingSm() + tickerW + Layout::spacingMd();
|
|
ImU32 chgCol = market.change_24h >= 0 ? Success() : Error();
|
|
snprintf(buf, sizeof(buf), "%s%.2f%% 24h", market.change_24h >= 0 ? "+" : "", market.change_24h);
|
|
ImVec2 chgSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
|
|
float badgePadH = Layout::spacingSm();
|
|
float badgePadV = Layout::spacingXs();
|
|
ImVec2 bMin(badgeX, cy + (h3->LegacySize - chgSz.y - badgePadV * 2) * 0.5f);
|
|
ImVec2 bMax(badgeX + chgSz.x + badgePadH * 2, bMin.y + chgSz.y + badgePadV * 2);
|
|
ImU32 badgeBg = market.change_24h >= 0 ? WithAlpha(Success(), 30) : WithAlpha(Error(), 30);
|
|
dl->AddRectFilled(bMin, bMax, badgeBg, 4.0f * dp);
|
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(bMin.x + badgePadH, bMin.y + badgePadV), chgCol, buf);
|
|
|
|
// ---- SEPARATOR ----
|
|
float sepY = cy + h3->LegacySize + Layout::spacingMd();
|
|
float rnd = glassSpec.rounding;
|
|
dl->AddLine(ImVec2(cardMin.x + rnd * 0.5f, sepY),
|
|
ImVec2(cardMax.x - rnd * 0.5f, sepY),
|
|
WithAlpha(OnSurface(), 15), 1.0f * dp);
|
|
|
|
// ---- STATS ROW (24h Volume | Market Cap) ----
|
|
// (BTC-price column + the "updated Ns ago" staleness line were dropped to
|
|
// simplify; freshness is still shown by the attribution "Updated" line.)
|
|
float statsY = sepY + Layout::spacingSm();
|
|
float colW = (availWidth - Layout::spacingLg() * 2) / 2.0f;
|
|
|
|
struct StatItem { const char* label; std::string value; ImU32 valueCol; };
|
|
StatItem stats[2] = {
|
|
{TR("market_24h_volume"), FormatCompactUSD(market.volume_24h), OnSurface()},
|
|
{TR("market_cap"), FormatCompactUSD(market.market_cap), OnSurface()},
|
|
};
|
|
|
|
for (int i = 0; i < 2; i++) {
|
|
float sx = cardMin.x + Layout::spacingLg() + i * colW;
|
|
float centerX = sx + colW * 0.5f;
|
|
|
|
// Label (overline, centered)
|
|
ImVec2 lblSz = ovFont->CalcTextSizeA(ovFont->LegacySize, 10000, 0, stats[i].label);
|
|
dl->AddText(ovFont, ovFont->LegacySize,
|
|
ImVec2(centerX - lblSz.x * 0.5f, statsY), OnSurfaceMedium(), stats[i].label);
|
|
|
|
// Value (subtitle1, centered)
|
|
float valY = statsY + ovFont->LegacySize + Layout::spacingXs();
|
|
ImVec2 valSz = sub1->CalcTextSizeA(sub1->LegacySize, 10000, 0, stats[i].value.c_str());
|
|
dl->AddText(sub1, sub1->LegacySize,
|
|
ImVec2(centerX - valSz.x * 0.5f, valY), stats[i].valueCol, stats[i].value.c_str());
|
|
}
|
|
|
|
// ---- TRADE BUTTON (top-right of card) ----
|
|
if (!currentExchange.pairs.empty()) {
|
|
const char* pairName = currentExchange.pairs[s_pair_idx].displayName.c_str();
|
|
ImFont* iconFont = Type().iconSmall();
|
|
ImVec2 textSz = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, pairName);
|
|
ImVec2 iconSz = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, ICON_MD_OPEN_IN_NEW);
|
|
float iconGap = Layout::spacingSm();
|
|
float tradePadH = Layout::spacingMd();
|
|
float tradePadV = Layout::spacingSm();
|
|
float tradeBtnW = textSz.x + iconGap + iconSz.x + tradePadH * 2;
|
|
float tradeBtnH = std::max(textSz.y, iconSz.y) + tradePadV * 2;
|
|
float tradeBtnX = cardMax.x - pad - tradeBtnW;
|
|
float tradeBtnY = cardMin.y + Layout::spacingSm();
|
|
|
|
ImVec2 tMin(tradeBtnX, tradeBtnY), tMax(tradeBtnX + tradeBtnW, tradeBtnY + tradeBtnH);
|
|
bool tradeHov = material::IsRectHovered(tMin, tMax);
|
|
|
|
// Glass pill background
|
|
GlassPanelSpec tradeBtnGlass;
|
|
tradeBtnGlass.rounding = tradeBtnH * 0.5f;
|
|
tradeBtnGlass.fillAlpha = tradeHov ? 35 : 20;
|
|
DrawGlassPanel(dl, tMin, tMax, tradeBtnGlass);
|
|
if (tradeHov)
|
|
dl->AddRectFilled(tMin, tMax, WithAlpha(Primary(), 20), tradeBtnH * 0.5f);
|
|
|
|
// Text (pair name with body2, icon with icon font)
|
|
ImU32 tradeCol = tradeHov ? OnSurface() : OnSurfaceMedium();
|
|
float contentY = tradeBtnY + tradePadV;
|
|
float curX = tradeBtnX + tradePadH;
|
|
dl->AddText(body2, body2->LegacySize,
|
|
ImVec2(curX, contentY), tradeCol, pairName);
|
|
curX += textSz.x + iconGap;
|
|
dl->AddText(iconFont, iconFont->LegacySize,
|
|
ImVec2(curX, contentY + (textSz.y - iconSz.y) * 0.5f), tradeCol, ICON_MD_OPEN_IN_NEW);
|
|
|
|
// Click
|
|
ImVec2 savedCur = ImGui::GetCursorScreenPos();
|
|
ImGui::SetCursorScreenPos(tMin);
|
|
ImGui::InvisibleButton("##TradeOnExchange", ImVec2(tradeBtnW, tradeBtnH));
|
|
if (ImGui::IsItemClicked()) {
|
|
util::Platform::openUrl(currentExchange.pairs[s_pair_idx].tradeUrl);
|
|
}
|
|
if (ImGui::IsItemHovered()) {
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
material::Tooltip(TR("market_trade_on"), currentExchange.name.c_str());
|
|
}
|
|
ImGui::SetCursorScreenPos(savedCur);
|
|
}
|
|
} else {
|
|
const char* status = market.price_loading ? TR("market_price_loading") : TR("market_price_unavailable");
|
|
DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(cx, cy + 10), OnSurfaceDisabled(), status);
|
|
if (!market.price_loading && !market.price_error.empty()) {
|
|
std::string errorText = market.price_error;
|
|
float maxErrorW = cardMax.x - cx - Layout::spacingLg();
|
|
while (errorText.size() > 4 &&
|
|
capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, errorText.c_str()).x > maxErrorW) {
|
|
errorText.pop_back();
|
|
}
|
|
if (errorText.size() < market.price_error.size()) errorText += "...";
|
|
dl->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(cx, cy + 10 + sub1->LegacySize + Layout::spacingXs()),
|
|
Warning(), errorText.c_str());
|
|
}
|
|
}
|
|
|
|
// No inter-card gap — the chart is drawn immediately below, inside the same panel.
|
|
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y));
|
|
ImGui::Dummy(ImVec2(availWidth, 0));
|
|
}
|
|
|
|
// ================================================================
|
|
// PRICE CHART — drawn inside the combined hero card's glass panel (above)
|
|
// ================================================================
|
|
{
|
|
// Plot the REAL accumulated price history (one point per refresh, oldest→newest).
|
|
// No synthetic data: until at least two real samples exist, the empty-state below
|
|
// ("not enough history yet") is shown rather than inventing a price curve.
|
|
s_price_history = market.price_history;
|
|
s_history_initialized = true;
|
|
|
|
// Chart height precomputed above (responsive — the chart absorbs the vertical slack
|
|
// left by the hero header + portfolio card). No glass panel here — it shares the
|
|
// combined hero+chart card's panel drawn in PRICE SUMMARY above.
|
|
ImVec2 chartMin = ImGui::GetCursorScreenPos();
|
|
ImVec2 chartMax(chartMin.x + availWidth, chartMin.y + chartH);
|
|
|
|
if (!s_price_history.empty() && s_price_history.size() >= 2) {
|
|
float chartPad = pad;
|
|
float labelPadLeft = std::max(S.drawElement("tabs.market", "chart-y-axis-min-padding").size, S.drawElement("tabs.market", "chart-y-axis-padding").size * hs);
|
|
float labelPadBottom = Layout::spacingXl();
|
|
|
|
float plotLeft = chartMin.x + labelPadLeft;
|
|
float plotRight = chartMax.x - chartPad;
|
|
float plotTop = chartMin.y + chartPad;
|
|
float plotBottom = chartMax.y - labelPadBottom;
|
|
float plotW = plotRight - plotLeft;
|
|
float plotH = plotBottom - plotTop;
|
|
|
|
// Compute Y range with padding
|
|
double yMin = *std::min_element(s_price_history.begin(), s_price_history.end());
|
|
double yMax = *std::max_element(s_price_history.begin(), s_price_history.end());
|
|
if (yMax <= yMin) { yMax = yMin + 1e-8; }
|
|
double yRange = yMax - yMin;
|
|
double yPadding = yRange * 0.12;
|
|
yMin -= yPadding;
|
|
yMax += yPadding;
|
|
|
|
// Horizontal grid lines (4 lines)
|
|
for (int g = 0; g <= 4; g++) {
|
|
float gy = plotTop + plotH * (float)g / 4.0f;
|
|
dl->AddLine(ImVec2(plotLeft, gy), ImVec2(plotRight, gy),
|
|
IM_COL32(255, 255, 255, 12), 1.0f);
|
|
double labelVal = yMax - (yMax - yMin) * (double)g / 4.0;
|
|
snprintf(buf, sizeof(buf), "$%.6f", labelVal);
|
|
ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
|
|
dl->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(plotLeft - labelSz.x - 6, gy - labelSz.y * 0.5f),
|
|
OnSurfaceDisabled(), buf);
|
|
}
|
|
|
|
// Build points
|
|
size_t n = s_price_history.size();
|
|
std::vector<ImVec2> points(n);
|
|
|
|
ImU32 lineCol = market.change_24h >= 0
|
|
? WithAlpha(Success(), 220) : WithAlpha(Error(), 220);
|
|
ImU32 fillCol = market.change_24h >= 0
|
|
? WithAlpha(Success(), 25) : WithAlpha(Error(), 25);
|
|
ImU32 dotCol = market.change_24h >= 0
|
|
? Success() : Error();
|
|
|
|
for (size_t i = 0; i < n; i++) {
|
|
float t = (n > 1) ? (float)i / (float)(n - 1) : 0.0f;
|
|
float x = plotLeft + t * plotW;
|
|
float y = plotBottom - (float)((s_price_history[i] - yMin) / (yMax - yMin)) * plotH;
|
|
points[i] = ImVec2(x, y);
|
|
}
|
|
|
|
// Fill under curve (single concave polygon to avoid AA seam artifacts)
|
|
if (n >= 2) {
|
|
for (size_t i = 0; i < n; i++)
|
|
dl->PathLineTo(points[i]);
|
|
dl->PathLineTo(ImVec2(points[n - 1].x, plotBottom));
|
|
dl->PathLineTo(ImVec2(points[0].x, plotBottom));
|
|
dl->PathFillConcave(fillCol);
|
|
}
|
|
|
|
// Line
|
|
dl->AddPolyline(points.data(), (int)points.size(), lineCol, ImDrawFlags_None, S.drawElement("tabs.market", "chart-line-thickness").size);
|
|
|
|
// Dots
|
|
float dotR = std::max(S.drawElement("tabs.market", "chart-dot-min-radius").size, S.drawElement("tabs.market", "chart-dot-radius").size * hs);
|
|
for (size_t i = 0; i < n; i++) {
|
|
dl->AddCircleFilled(points[i], dotR, dotCol);
|
|
}
|
|
|
|
// X-axis: samples are per-refresh, not hourly-timestamped, so only the most
|
|
// recent point can be labelled truthfully ("Now"). The series simply runs
|
|
// older→newer, left→right. (Avoids the old fake "24h…6h" hourly gradations.)
|
|
{
|
|
const char* nowText = TR("market_now");
|
|
ImVec2 lblSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, nowText);
|
|
dl->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(plotRight - lblSz.x, plotBottom + 2), OnSurfaceDisabled(), nowText);
|
|
}
|
|
|
|
// Hover crosshair + tooltip
|
|
ImVec2 mousePos = ImGui::GetIO().MousePos;
|
|
if (mousePos.x >= plotLeft && mousePos.x <= plotRight &&
|
|
mousePos.y >= plotTop && mousePos.y <= plotBottom + labelPadBottom)
|
|
{
|
|
float mx = mousePos.x - plotLeft;
|
|
float closest_t = mx / plotW;
|
|
int idx = (int)(closest_t * (n - 1) + 0.5f);
|
|
if (idx < 0) idx = 0;
|
|
if (idx >= (int)n) idx = (int)n - 1;
|
|
|
|
float px = points[idx].x;
|
|
float py = points[idx].y;
|
|
|
|
dl->AddLine(ImVec2(px, plotTop), ImVec2(px, plotBottom),
|
|
IM_COL32(255, 255, 255, 40), 1.0f);
|
|
dl->AddLine(ImVec2(plotLeft, py), ImVec2(plotRight, py),
|
|
IM_COL32(255, 255, 255, 40), 1.0f);
|
|
|
|
float hoverDotR = std::max(S.drawElement("tabs.market", "chart-hover-dot-min-radius").size, S.drawElement("tabs.market", "chart-hover-dot-radius").size * hs);
|
|
float hoverRingR = std::max(S.drawElement("tabs.market", "chart-hover-ring-min-radius").size, S.drawElement("tabs.market", "chart-hover-ring-radius").size * hs);
|
|
dl->AddCircleFilled(ImVec2(px, py), hoverDotR, dotCol);
|
|
dl->AddCircle(ImVec2(px, py), hoverRingR, IM_COL32(255, 255, 255, 80), 0, 1.5f);
|
|
|
|
// Samples are per-refresh, not timestamped, so we don't claim a specific
|
|
// "Xh ago" — just show the price at the hovered point.
|
|
snprintf(buf, sizeof(buf), "%s", FormatPrice(s_price_history[idx]).c_str());
|
|
ImVec2 tipSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
|
|
float tipPad = Layout::spacingSm() + Layout::spacingXs();
|
|
float tipX = px + 10;
|
|
float tipY = py - tipSz.y - tipPad * 2 - 4;
|
|
if (tipX + tipSz.x + tipPad * 2 > plotRight)
|
|
tipX = px - tipSz.x - tipPad * 2 - 10;
|
|
if (tipY < plotTop) tipY = py + 10;
|
|
|
|
ImVec2 tipMin(tipX, tipY);
|
|
ImVec2 tipMax(tipX + tipSz.x + tipPad * 2, tipY + tipSz.y + tipPad * 2);
|
|
dl->AddRectFilled(tipMin, tipMax, IM_COL32(20, 20, 30, 230), 4.0f);
|
|
dl->AddRect(tipMin, tipMax, IM_COL32(255, 255, 255, 30), 4.0f, 0, 1.0f);
|
|
dl->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(tipX + tipPad, tipY + tipPad), dotCol, buf);
|
|
}
|
|
} else {
|
|
const char* msg = TR("market_no_history");
|
|
ImVec2 ts = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, msg);
|
|
dl->AddText(sub1, sub1->LegacySize,
|
|
ImVec2(chartMin.x + (availWidth - ts.x) * 0.5f, chartMin.y + chartH * 0.45f),
|
|
OnSurfaceDisabled(), msg);
|
|
}
|
|
|
|
// --- Refresh button + timestamp pinned in chart top-right ---
|
|
{
|
|
float iconBtnSz = capFont->LegacySize + 8.0f;
|
|
float refreshX = chartMax.x - pad;
|
|
float refreshY = chartMin.y + pad * 0.5f;
|
|
|
|
// Draw refresh icon button
|
|
ImVec2 btnMin(refreshX - iconBtnSz, refreshY);
|
|
ImVec2 btnMax(refreshX, refreshY + iconBtnSz);
|
|
bool refreshHov = material::IsRectHovered(btnMin, btnMax);
|
|
if (refreshHov) {
|
|
dl->AddRectFilled(btnMin, btnMax, IM_COL32(255, 255, 255, 20), 4.0f);
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
}
|
|
|
|
ImFont* iconSmall = material::Typography::instance().iconSmall();
|
|
ImVec2 iconSz = iconSmall->CalcTextSizeA(iconSmall->LegacySize, FLT_MAX, 0, ICON_MD_REFRESH);
|
|
dl->AddText(iconSmall, iconSmall->LegacySize,
|
|
ImVec2(btnMin.x + (iconBtnSz - iconSz.x) * 0.5f, btnMin.y + (iconBtnSz - iconSz.y) * 0.5f),
|
|
refreshHov ? OnSurface() : OnSurfaceMedium(), ICON_MD_REFRESH);
|
|
|
|
ImGui::SetCursorScreenPos(btnMin);
|
|
if (ImGui::InvisibleButton("##RefreshMarket", ImVec2(iconBtnSz, iconBtnSz))) {
|
|
app->refreshMarketData();
|
|
s_history_initialized = false;
|
|
s_last_refresh_time = ImGui::GetTime();
|
|
}
|
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("market_refresh_price"));
|
|
|
|
// Timestamp text to the left of refresh button
|
|
if (s_last_refresh_time > 0.0) {
|
|
double elapsed = ImGui::GetTime() - s_last_refresh_time;
|
|
if (elapsed < 60.0)
|
|
snprintf(buf, sizeof(buf), "%.0fs ago", elapsed);
|
|
else if (elapsed < 3600.0)
|
|
snprintf(buf, sizeof(buf), "%.0fm ago", elapsed / 60.0);
|
|
else
|
|
snprintf(buf, sizeof(buf), "%.1fh ago", elapsed / 3600.0);
|
|
ImVec2 tsSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
|
|
dl->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(btnMin.x - tsSz.x - 6, btnMin.y + (iconBtnSz - tsSz.y) * 0.5f),
|
|
OnSurfaceDisabled(), buf);
|
|
}
|
|
}
|
|
|
|
ImGui::SetCursorScreenPos(ImVec2(chartMin.x, chartMin.y + chartH));
|
|
ImGui::Dummy(ImVec2(availWidth, 0));
|
|
}
|
|
|
|
// ================================================================
|
|
// PAIR SELECTOR — every trading pair across all exchanges shown as
|
|
// round buttons (flat; no exchange dropdown). Wraps to multiple rows.
|
|
// ================================================================
|
|
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.market", "exchange-top-gap").size));
|
|
{
|
|
float chipH = S.drawElement("tabs.market", "pair-chip-height").height;
|
|
float chipR = S.drawElement("tabs.market", "pair-chip-radius").radius;
|
|
float chipSpacing = S.drawElement("tabs.market", "pair-chip-spacing").size;
|
|
float innerGap = Layout::spacingSm();
|
|
float sidePad = Layout::spacingMd();
|
|
|
|
ImVec2 origin = ImGui::GetCursorScreenPos();
|
|
float left = origin.x;
|
|
float right = origin.x + availWidth;
|
|
float cx = left;
|
|
float cy = origin.y;
|
|
int btnCounter = 0;
|
|
|
|
for (int ex = 0; ex < (int)registry.size(); ex++) {
|
|
for (int pr = 0; pr < (int)registry[ex].pairs.size(); pr++) {
|
|
const std::string& pairName = registry[ex].pairs[pr].displayName;
|
|
const std::string& exName = registry[ex].name;
|
|
|
|
float pairW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, pairName.c_str()).x;
|
|
float exW = ovFont->CalcTextSizeA(ovFont->LegacySize, FLT_MAX, 0, exName.c_str()).x;
|
|
float cw = sidePad * 2.0f + pairW + innerGap + exW;
|
|
|
|
// Wrap to the next row when the pill would overflow (never on an empty row).
|
|
if (cx + cw > right && cx > left) {
|
|
cx = left;
|
|
cy += chipH + chipSpacing;
|
|
}
|
|
|
|
ImVec2 cMin(cx, cy);
|
|
ImVec2 cMax(cx + cw, cy + chipH);
|
|
bool selected = (ex == s_exchange_idx && pr == s_pair_idx);
|
|
bool hov = material::IsRectHovered(cMin, cMax);
|
|
ImU32 chipBg = selected ? WithAlpha(Primary(), 200)
|
|
: (hov ? WithAlpha(OnSurface(), 35) : WithAlpha(OnSurface(), 20));
|
|
ImU32 chipBorder = selected ? Primary() : WithAlpha(OnSurface(), 40);
|
|
ImU32 pairCol = selected ? IM_COL32(255, 255, 255, 255) : OnSurface();
|
|
ImU32 exCol = selected ? WithAlpha(IM_COL32(255, 255, 255, 255), 190) : OnSurfaceDisabled();
|
|
|
|
dl->AddRectFilled(cMin, cMax, chipBg, chipR);
|
|
dl->AddRect(cMin, cMax, chipBorder, chipR, 0, 1.0f);
|
|
|
|
float tx = cx + sidePad;
|
|
dl->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(tx, cy + (chipH - capFont->LegacySize) * 0.5f), pairCol, pairName.c_str());
|
|
dl->AddText(ovFont, ovFont->LegacySize,
|
|
ImVec2(tx + pairW + innerGap, cy + (chipH - ovFont->LegacySize) * 0.5f),
|
|
exCol, exName.c_str());
|
|
|
|
ImGui::SetCursorScreenPos(cMin);
|
|
snprintf(buf, sizeof(buf), "##PairBtn%d", btnCounter++);
|
|
if (ImGui::InvisibleButton(buf, ImVec2(cw, chipH))) {
|
|
if (ex != s_exchange_idx || pr != s_pair_idx) {
|
|
s_exchange_idx = ex;
|
|
s_pair_idx = pr;
|
|
s_history_initialized = false;
|
|
app->settings()->setSelectedExchange(exName);
|
|
app->settings()->setSelectedPair(pairName);
|
|
app->settings()->save();
|
|
app->refreshMarketData();
|
|
}
|
|
}
|
|
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
|
|
cx += cw + chipSpacing;
|
|
}
|
|
}
|
|
|
|
// Advance the cursor past the wrapped rows.
|
|
ImGui::SetCursorScreenPos(ImVec2(left, cy + chipH));
|
|
ImGui::Dummy(ImVec2(availWidth, 0));
|
|
|
|
// Attribution (moved here from the removed exchange-selector row).
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("market_attribution"));
|
|
if (!market.last_updated.empty()) {
|
|
ImGui::SameLine(0, 12);
|
|
snprintf(buf, sizeof(buf), " \xc2\xb7 Updated %s", market.last_updated.c_str());
|
|
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
|
|
}
|
|
ImGui::Dummy(ImVec2(0, gap));
|
|
}
|
|
|
|
// ================================================================
|
|
// PORTFOLIO — Glass card with balance breakdown
|
|
// ================================================================
|
|
{
|
|
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("market_portfolio"));
|
|
// "Manage…" button, right-aligned on the header row — opens the portfolio editor.
|
|
{
|
|
const char* ml = TR("portfolio_manage");
|
|
float mBtnW = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, ml).x + Layout::spacingMd() * 2;
|
|
ImGui::SameLine();
|
|
float availX = ImGui::GetContentRegionAvail().x;
|
|
if (availX > mBtnW) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + availX - mBtnW);
|
|
if (ImGui::Button(ml, ImVec2(mBtnW, 0))) s_portfolio_editor_open = true;
|
|
}
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
|
|
|
double total_balance = state.totalBalance;
|
|
double private_balance = state.privateBalance;
|
|
double transparent_balance = state.transparentBalance;
|
|
|
|
ImVec2 cardMin = ImGui::GetCursorScreenPos();
|
|
float cardH = portfolioCardH; // precomputed: summary block + group-card grid
|
|
ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + cardH);
|
|
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
|
|
|
|
float cx = cardMin.x + Layout::spacingLg();
|
|
float cy = cardMin.y + Layout::spacingLg();
|
|
|
|
// ---- SUMMARY: fiat value (hero) + 24h change, BTC right-aligned ----
|
|
if (market.price_usd > 0) {
|
|
double portfolio_usd = total_balance * market.price_usd;
|
|
if (portfolio_usd >= 1.0) snprintf(buf, sizeof(buf), "$%.2f USD", portfolio_usd);
|
|
else snprintf(buf, sizeof(buf), "$%.6f USD", portfolio_usd);
|
|
DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(cx, cy), Success(), buf);
|
|
float usdW = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, buf).x;
|
|
|
|
if (market.change_24h != 0.0) {
|
|
char chg[48];
|
|
snprintf(chg, sizeof(chg), "%+.2f%% %s", market.change_24h, TR("market_24h_change"));
|
|
ImU32 chgCol = market.change_24h >= 0 ? Success() : Error();
|
|
dl->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(cx + usdW + Layout::spacingMd(),
|
|
cy + (sub1->LegacySize - capFont->LegacySize)),
|
|
chgCol, chg);
|
|
}
|
|
|
|
double portfolio_btc = total_balance * market.price_btc;
|
|
snprintf(buf, sizeof(buf), "\xE2\x89\x88 %.8f BTC", portfolio_btc); // "≈ <n> BTC"
|
|
float btcW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf).x;
|
|
dl->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(cardMax.x - btcW - pad, cy + (sub1->LegacySize - capFont->LegacySize)),
|
|
OnSurfaceMedium(), buf);
|
|
} else {
|
|
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, cy), OnSurfaceDisabled(), TR("market_no_price"));
|
|
}
|
|
cy += sub1->LegacySize + Layout::spacingSm();
|
|
|
|
// DRGX balance + Z/T breakdown (right-aligned).
|
|
snprintf(buf, sizeof(buf), "%.8f %s", total_balance, DRAGONX_TICKER);
|
|
dl->AddText(body2, body2->LegacySize, ImVec2(cx, cy), OnSurface(), buf);
|
|
snprintf(buf, sizeof(buf), "Z %.4f \xC2\xB7 T %.4f", private_balance, transparent_balance);
|
|
float brkW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf).x;
|
|
dl->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(cardMax.x - brkW - pad, cy + 2), OnSurfaceDisabled(), buf);
|
|
cy += body2->LegacySize + Layout::spacingSm();
|
|
|
|
// Full-width shielded/transparent ratio bar + % label.
|
|
if (total_balance > 0) {
|
|
float barW = pfInnerW;
|
|
float shieldedRatio = (float)(private_balance / total_balance);
|
|
if (shieldedRatio > 1.0f) shieldedRatio = 1.0f;
|
|
if (shieldedRatio < 0.0f) shieldedRatio = 0.0f;
|
|
float shieldedW = barW * shieldedRatio;
|
|
float transpW = barW - shieldedW;
|
|
ImVec2 barStart(cx, cy);
|
|
|
|
dl->AddRectFilled(barStart, ImVec2(barStart.x + barW, barStart.y + ratioBarH),
|
|
IM_COL32(255, 255, 255, 10), 3.0f);
|
|
if (shieldedW > 0.5f)
|
|
dl->AddRectFilled(barStart, ImVec2(barStart.x + shieldedW, barStart.y + ratioBarH),
|
|
WithAlpha(Success(), 200),
|
|
transpW > 0.5f ? ImDrawFlags_RoundCornersLeft : ImDrawFlags_RoundCornersAll, 3.0f);
|
|
if (transpW > 0.5f)
|
|
dl->AddRectFilled(ImVec2(barStart.x + shieldedW, barStart.y),
|
|
ImVec2(barStart.x + barW, barStart.y + ratioBarH),
|
|
WithAlpha(Warning(), 200),
|
|
shieldedW > 0.5f ? ImDrawFlags_RoundCornersRight : ImDrawFlags_RoundCornersAll, 3.0f);
|
|
|
|
// market_pct_shielded is "%.0f%% Shielded" — pass a double (an int to %f is UB).
|
|
snprintf(buf, sizeof(buf), TR("market_pct_shielded"), static_cast<double>(shieldedRatio) * 100.0);
|
|
dl->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(cx, cy + ratioBarH + Layout::spacingXs()), OnSurfaceDisabled(), buf);
|
|
}
|
|
|
|
// ---- CUSTOM GROUPS — one glass card per entry, wrapping into a grid ----
|
|
if (pfN > 0) {
|
|
float gy = cardMin.y + pfSummaryH;
|
|
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(cx, gy), OnSurfaceMedium(), TR("portfolio_groups"));
|
|
gy += ovFont->LegacySize + Layout::spacingSm();
|
|
|
|
for (int i = 0; i < pfN; i++) {
|
|
const auto& e = pfEntriesGeo[i];
|
|
int col = i % pfCardsPerRow;
|
|
int row = i / pfCardsPerRow;
|
|
ImVec2 gcMin(cx + col * (pfCardW + pfCardGap), gy + row * (pfCardH + pfCardGap));
|
|
ImVec2 gcMax(gcMin.x + pfCardW, gcMin.y + pfCardH);
|
|
|
|
GlassPanelSpec gcGlass;
|
|
gcGlass.rounding = 8.0f;
|
|
gcGlass.fillAlpha = 18;
|
|
gcGlass.borderAlpha = 30;
|
|
DrawGlassPanel(dl, gcMin, gcMax, gcGlass);
|
|
|
|
double bal = data::SumPortfolioBalance(e.addresses, state.addresses);
|
|
float tcx = gcMin.x + Layout::spacingSm();
|
|
float tcy = gcMin.y + Layout::spacingSm();
|
|
float maxTextW = pfCardW - Layout::spacingSm() * 2.0f;
|
|
|
|
// Label (truncated with an ellipsis to the card width).
|
|
std::string label = e.label;
|
|
bool truncated = false;
|
|
while (label.size() > 1 &&
|
|
body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, label.c_str()).x > maxTextW) {
|
|
label.pop_back();
|
|
truncated = true;
|
|
}
|
|
if (truncated) label += "\xE2\x80\xA6";
|
|
dl->AddText(body2, body2->LegacySize, ImVec2(tcx, tcy), OnSurfaceMedium(), label.c_str());
|
|
tcy += body2->LegacySize + Layout::spacingXs();
|
|
|
|
// DRGX amount.
|
|
snprintf(buf, sizeof(buf), "%.4f %s", bal, DRAGONX_TICKER);
|
|
dl->AddText(body2, body2->LegacySize, ImVec2(tcx, tcy), OnSurface(), buf);
|
|
tcy += body2->LegacySize;
|
|
|
|
// Fiat value.
|
|
if (market.price_usd > 0) {
|
|
snprintf(buf, sizeof(buf), "$%.2f", bal * market.price_usd);
|
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(tcx, tcy), OnSurfaceDisabled(), buf);
|
|
}
|
|
}
|
|
}
|
|
|
|
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + cardH));
|
|
ImGui::Dummy(ImVec2(availWidth, 0));
|
|
ImGui::Dummy(ImVec2(0, gap));
|
|
}
|
|
|
|
ImGui::EndChild(); // ##MarketScroll
|
|
|
|
// Portfolio editor modal (rendered outside the scroll child so it overlays the page).
|
|
RenderPortfolioEditor(app);
|
|
}
|
|
|
|
} // namespace ui
|
|
} // namespace dragonx
|