ObsidianDragon - DragonX ImGui Wallet
Full-node GUI wallet for DragonX cryptocurrency. Built with Dear ImGui, SDL3, and OpenGL3/DX11. Features: - Send/receive shielded and transparent transactions - Autoshield with merged transaction display - Built-in CPU mining (xmrig) - Peer management and network monitoring - Wallet encryption with PIN lock - QR code generation for receive addresses - Transaction history with pagination - Console for direct RPC commands - Cross-platform (Linux, Windows)
This commit is contained in:
819
src/ui/windows/market_tab.cpp
Normal file
819
src/ui/windows/market_tab.cpp
Normal file
@@ -0,0 +1,819 @@
|
||||
// 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/exchange_info.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;
|
||||
static std::vector<double> s_time_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 float s_pair_scroll = 0.0f;
|
||||
static float s_pair_scroll_target = 0.0f;
|
||||
static bool s_pair_dragging = false;
|
||||
static float s_pair_drag_start_x = 0.0f;
|
||||
static float s_pair_drag_start_scroll = 0.0f;
|
||||
static bool s_market_state_loaded = false;
|
||||
|
||||
// Helper: load selected exchange/pair from settings
|
||||
static void LoadMarketState(config::Settings* settings)
|
||||
{
|
||||
if (s_market_state_loaded || !settings) return;
|
||||
s_market_state_loaded = true;
|
||||
|
||||
const auto& registry = data::getExchangeRegistry();
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Load persisted exchange/pair on first frame
|
||||
LoadMarketState(app->settings());
|
||||
|
||||
// Exchange registry
|
||||
const auto& registry = data::getExchangeRegistry();
|
||||
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];
|
||||
|
||||
// ================================================================
|
||||
// Proportional section budget — all content fits without scrolling
|
||||
// ================================================================
|
||||
float mkSHdr = ovFont->LegacySize + Layout::spacingXs()
|
||||
+ ImGui::GetStyle().ItemSpacing.y * 2.0f;
|
||||
float mkGapOver = gap + ImGui::GetStyle().ItemSpacing.y;
|
||||
float mkOverhead = 3.0f * (mkSHdr + mkGapOver) + 2.0f * mkGapOver;
|
||||
float pairBarH = S.drawElement("tabs.market", "pair-bar-height").height;
|
||||
float mkCardBudget = std::max(200.0f, marketAvail.y - mkOverhead);
|
||||
|
||||
Layout::SectionBudget mb(mkCardBudget);
|
||||
float statsMarketBudH = mb.allocate(0.14f, S.drawElement("tabs.market", "stats-card-min-height").size);
|
||||
float portfolioBudgetH = mb.allocate(0.18f, 50.0f);
|
||||
|
||||
// ================================================================
|
||||
// PRICE HERO — Large glass card with price + change badge
|
||||
// ================================================================
|
||||
{
|
||||
ImVec2 cardMin = ImGui::GetCursorScreenPos();
|
||||
float cardH = std::max(S.drawElement("tabs.market", "hero-card-min-height").size, S.drawElement("tabs.market", "hero-card-height").size * vs);
|
||||
ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + cardH);
|
||||
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
|
||||
|
||||
// Accent stripe — clipped to card rounded corners
|
||||
{
|
||||
float sw = S.drawElement("tabs.market", "accent-stripe-width").size;
|
||||
dl->PushClipRect(cardMin, ImVec2(cardMin.x + sw, cardMax.y), true);
|
||||
dl->AddRectFilled(cardMin, cardMax, WithAlpha(S.resolveColor("var(--accent-market)", Success()), 200),
|
||||
glassSpec.rounding, ImDrawFlags_RoundCornersLeft);
|
||||
dl->PopClipRect();
|
||||
}
|
||||
|
||||
float cx = cardMin.x + Layout::spacingLg();
|
||||
float cy = cardMin.y + Layout::spacingLg();
|
||||
|
||||
if (market.price_usd > 0) {
|
||||
// Large price with text shadow
|
||||
std::string priceStr = FormatPrice(market.price_usd);
|
||||
ImU32 priceCol = Success();
|
||||
DrawTextShadow(dl, h4, h4->LegacySize, ImVec2(cx, cy), priceCol, priceStr.c_str());
|
||||
|
||||
// BTC price beside it
|
||||
float priceW = h4->CalcTextSizeA(h4->LegacySize, FLT_MAX, 0, priceStr.c_str()).x;
|
||||
snprintf(buf, sizeof(buf), "%.10f BTC", market.price_btc);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(cx + priceW + Layout::spacingXl(), cy + (h4->LegacySize - capFont->LegacySize)),
|
||||
OnSurfaceMedium(), buf);
|
||||
|
||||
// 24h change badge
|
||||
float badgeY = cy + h4->LegacySize + 8;
|
||||
ImU32 chgCol = market.change_24h >= 0 ? Success() : Error();
|
||||
snprintf(buf, sizeof(buf), "%s%.2f%%", market.change_24h >= 0 ? "+" : "", market.change_24h);
|
||||
|
||||
ImVec2 txtSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
|
||||
float badgePad = Layout::spacingSm() + Layout::spacingXs();
|
||||
ImVec2 bMin(cx, badgeY);
|
||||
ImVec2 bMax(cx + txtSz.x + badgePad * 2, badgeY + txtSz.y + badgePad);
|
||||
ImU32 badgeBg = market.change_24h >= 0 ? WithAlpha(Success(), 30) : WithAlpha(Error(), 30);
|
||||
dl->AddRectFilled(bMin, bMax, badgeBg, 4.0f);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx + badgePad, badgeY + badgePad * 0.5f), chgCol, buf);
|
||||
|
||||
// "24h" label after badge
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(bMax.x + 6, badgeY + badgePad * 0.5f), OnSurfaceDisabled(), "24h");
|
||||
} else {
|
||||
DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(cx, cy + 10), OnSurfaceDisabled(), "Price data unavailable");
|
||||
}
|
||||
|
||||
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y));
|
||||
ImGui::Dummy(ImVec2(availWidth, 0));
|
||||
ImGui::Dummy(ImVec2(0, gap));
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// STATS — Three glass cards (Price | Volume | Market Cap)
|
||||
// ================================================================
|
||||
{
|
||||
float cardW = (availWidth - 2 * gap) / 3.0f;
|
||||
float cardH = std::min(StatCardHeight(vs), statsMarketBudH);
|
||||
ImVec2 origin = ImGui::GetCursorScreenPos();
|
||||
|
||||
struct StatInfo { const char* label; std::string value; ImU32 col; ImU32 accent; };
|
||||
StatInfo cards[3] = {
|
||||
{"PRICE", market.price_usd > 0 ? FormatPrice(market.price_usd) : "N/A",
|
||||
OnSurface(), WithAlpha(Success(), 200)},
|
||||
{"24H VOLUME", FormatCompactUSD(market.volume_24h),
|
||||
OnSurface(), WithAlpha(Secondary(), 200)},
|
||||
{"MARKET CAP", FormatCompactUSD(market.market_cap),
|
||||
OnSurface(), WithAlpha(Warning(), 200)},
|
||||
};
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
float xOff = i * (cardW + gap);
|
||||
ImVec2 cMin(origin.x + xOff, origin.y);
|
||||
ImVec2 cMax(cMin.x + cardW, cMin.y + cardH);
|
||||
|
||||
StatCardSpec sc;
|
||||
sc.overline = cards[i].label;
|
||||
sc.value = cards[i].value.c_str();
|
||||
sc.valueCol = cards[i].col;
|
||||
sc.accentCol = cards[i].accent;
|
||||
sc.centered = true;
|
||||
DrawStatCard(dl, cMin, cMax, sc, glassSpec);
|
||||
}
|
||||
|
||||
ImGui::Dummy(ImVec2(availWidth, cardH));
|
||||
ImGui::Dummy(ImVec2(0, gap));
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// PRICE CHART — Custom drawn inside glass panel (matches app design)
|
||||
// ================================================================
|
||||
{
|
||||
// Initialize history with simulated data if not set
|
||||
if (!s_history_initialized && market.price_usd > 0) {
|
||||
s_price_history.clear();
|
||||
s_time_history.clear();
|
||||
double base = market.price_usd;
|
||||
for (int i = 0; i < 24; i++) {
|
||||
double variance = ((rand() % 1000) - 500) / 10000.0 * base;
|
||||
s_price_history.push_back(base + variance);
|
||||
s_time_history.push_back(static_cast<double>(i));
|
||||
}
|
||||
s_history_initialized = true;
|
||||
}
|
||||
|
||||
// Chart height from schema
|
||||
float chartH = std::max(60.0f, chartElem.height * vs);
|
||||
ImVec2 chartMin = ImGui::GetCursorScreenPos();
|
||||
ImVec2 chartMax(chartMin.x + availWidth, chartMin.y + chartH);
|
||||
DrawGlassPanel(dl, chartMin, chartMax, glassSpec);
|
||||
|
||||
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
|
||||
for (size_t i = 0; i + 1 < n; i++) {
|
||||
ImVec2 quad[4] = {
|
||||
points[i],
|
||||
points[i + 1],
|
||||
ImVec2(points[i + 1].x, plotBottom),
|
||||
ImVec2(points[i].x, plotBottom)
|
||||
};
|
||||
dl->AddConvexPolyFilled(quad, 4, 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 labels
|
||||
const int xlabels[] = {0, 6, 12, 18, 23};
|
||||
const char* xlabelText[] = {"24h", "18h", "12h", "6h", "Now"};
|
||||
for (int xi = 0; xi < 5; xi++) {
|
||||
int idx = xlabels[xi];
|
||||
float t = (float)idx / (float)(n - 1);
|
||||
float x = plotLeft + t * plotW;
|
||||
ImVec2 lblSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, xlabelText[xi]);
|
||||
float lx = x - lblSz.x * 0.5f;
|
||||
if (lx < plotLeft) lx = plotLeft;
|
||||
if (lx + lblSz.x > plotRight) lx = plotRight - lblSz.x;
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(lx, plotBottom + 2), OnSurfaceDisabled(), xlabelText[xi]);
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
snprintf(buf, sizeof(buf), "%dh ago: %s",
|
||||
24 - idx, 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 = "No price history available";
|
||||
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()) ImGui::SetTooltip("Refresh price data");
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// EXCHANGE SELECTOR — Combo dropdown + pair name + trade link
|
||||
// ================================================================
|
||||
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.market", "exchange-top-gap").size));
|
||||
{
|
||||
float comboW = S.drawElement("tabs.market", "exchange-combo-width").size;
|
||||
|
||||
ImGui::PushFont(body2);
|
||||
ImGui::PushItemWidth(comboW);
|
||||
if (ImGui::BeginCombo("##ExchangeCombo", currentExchange.name.c_str())) {
|
||||
for (int i = 0; i < (int)registry.size(); i++) {
|
||||
bool selected = (i == s_exchange_idx);
|
||||
if (ImGui::Selectable(registry[i].name.c_str(), selected)) {
|
||||
if (i != s_exchange_idx) {
|
||||
s_exchange_idx = i;
|
||||
s_pair_idx = 0;
|
||||
s_pair_scroll = 0.0f;
|
||||
s_pair_scroll_target = 0.0f;
|
||||
s_history_initialized = false;
|
||||
app->settings()->setSelectedExchange(registry[i].name);
|
||||
if (!registry[i].pairs.empty())
|
||||
app->settings()->setSelectedPair(registry[i].pairs[0].displayName);
|
||||
app->settings()->save();
|
||||
app->refreshMarketData();
|
||||
}
|
||||
}
|
||||
if (selected) ImGui::SetItemDefaultFocus();
|
||||
}
|
||||
ImGui::EndCombo();
|
||||
}
|
||||
ImGui::PopItemWidth();
|
||||
|
||||
// Show current pair name beside combo
|
||||
if (!currentExchange.pairs.empty()) {
|
||||
ImGui::SameLine(0, Layout::spacingLg());
|
||||
Type().textColored(TypeStyle::Subtitle1, OnSurface(),
|
||||
currentExchange.pairs[s_pair_idx].displayName.c_str());
|
||||
|
||||
// "Open on exchange" button
|
||||
ImGui::SameLine(0, Layout::spacingSm());
|
||||
ImGui::PushFont(material::Typography::instance().iconSmall());
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(4, 4));
|
||||
snprintf(buf, sizeof(buf), ICON_MD_OPEN_IN_NEW "##TradeLink");
|
||||
if (ImGui::Button(buf)) {
|
||||
util::Platform::openUrl(currentExchange.pairs[s_pair_idx].tradeUrl);
|
||||
}
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::PopFont();
|
||||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Open on %s", currentExchange.name.c_str());
|
||||
}
|
||||
|
||||
// Attribution
|
||||
ImGui::SameLine(0, Layout::spacingLg());
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), "Price data from CoinGecko API");
|
||||
|
||||
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::PopFont();
|
||||
ImGui::Dummy(ImVec2(0, gap));
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// PAIR BAR — Horizontally scrolling chip selector (always visible)
|
||||
// ================================================================
|
||||
{
|
||||
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 fadeW = S.drawElement("tabs.market", "pair-bar-fade-width").size;
|
||||
float arrowSz = S.drawElement("tabs.market", "pair-bar-arrow-size").size;
|
||||
|
||||
ImVec2 barOrigin = ImGui::GetCursorScreenPos();
|
||||
float barW = availWidth;
|
||||
float barH = pairBarH;
|
||||
|
||||
// Compute total content width of all chips
|
||||
float totalChipW = 0.0f;
|
||||
std::vector<float> chipWidths;
|
||||
for (const auto& pair : currentExchange.pairs) {
|
||||
float tw = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, pair.displayName.c_str()).x;
|
||||
float cw = tw + Layout::spacingLg() * 2.0f;
|
||||
chipWidths.push_back(cw);
|
||||
totalChipW += cw + chipSpacing;
|
||||
}
|
||||
totalChipW -= chipSpacing; // no trailing spacing
|
||||
|
||||
float scrollableW = barW - arrowSz * 2.0f - Layout::spacingSm() * 2.0f;
|
||||
float maxScroll = std::max(0.0f, totalChipW - scrollableW);
|
||||
|
||||
// Smooth scroll lerp
|
||||
s_pair_scroll += (s_pair_scroll_target - s_pair_scroll) * 0.15f;
|
||||
if (std::abs(s_pair_scroll - s_pair_scroll_target) < 0.5f)
|
||||
s_pair_scroll = s_pair_scroll_target;
|
||||
|
||||
// Clamp
|
||||
s_pair_scroll_target = std::clamp(s_pair_scroll_target, 0.0f, maxScroll);
|
||||
s_pair_scroll = std::clamp(s_pair_scroll, 0.0f, maxScroll);
|
||||
|
||||
// Left arrow button
|
||||
float arrowY = barOrigin.y + (barH - arrowSz) * 0.5f;
|
||||
bool canScrollLeft = s_pair_scroll_target > 0.01f;
|
||||
ImGui::SetCursorScreenPos(ImVec2(barOrigin.x, arrowY));
|
||||
ImGui::BeginDisabled(!canScrollLeft);
|
||||
ImGui::PushFont(material::Typography::instance().iconSmall());
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(2, 2));
|
||||
if (ImGui::Button(ICON_MD_CHEVRON_LEFT "##PairLeft", ImVec2(arrowSz, arrowSz))) {
|
||||
// Scroll left by average chip width
|
||||
float avgChipW = totalChipW / currentExchange.pairs.size();
|
||||
s_pair_scroll_target -= avgChipW + chipSpacing;
|
||||
if (s_pair_scroll_target < 0) s_pair_scroll_target = 0;
|
||||
}
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::PopFont();
|
||||
ImGui::EndDisabled();
|
||||
|
||||
// Chip area with clipping
|
||||
float chipAreaLeft = barOrigin.x + arrowSz + Layout::spacingSm();
|
||||
float chipAreaRight = barOrigin.x + barW - arrowSz - Layout::spacingSm();
|
||||
float chipY = barOrigin.y + (barH - chipH) * 0.5f;
|
||||
|
||||
dl->PushClipRect(ImVec2(chipAreaLeft, barOrigin.y),
|
||||
ImVec2(chipAreaRight, barOrigin.y + barH), true);
|
||||
|
||||
// Render chips
|
||||
float cx = chipAreaLeft - s_pair_scroll;
|
||||
bool anyClicked = false;
|
||||
for (int i = 0; i < (int)currentExchange.pairs.size(); i++) {
|
||||
float cw = chipWidths[i];
|
||||
ImVec2 cMin(cx, chipY);
|
||||
ImVec2 cMax(cx + cw, chipY + chipH);
|
||||
|
||||
bool selected = (i == s_pair_idx);
|
||||
ImU32 chipBg = selected ? WithAlpha(Primary(), 200) : WithAlpha(OnSurface(), 20);
|
||||
ImU32 chipBorder = selected ? Primary() : WithAlpha(OnSurface(), 40);
|
||||
ImU32 chipText = selected ? IM_COL32(255, 255, 255, 255) : OnSurface();
|
||||
|
||||
dl->AddRectFilled(cMin, cMax, chipBg, chipR);
|
||||
dl->AddRect(cMin, cMax, chipBorder, chipR, 0, 1.0f);
|
||||
|
||||
ImVec2 textSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0,
|
||||
currentExchange.pairs[i].displayName.c_str());
|
||||
float textX = cx + (cw - textSz.x) * 0.5f;
|
||||
float textY = chipY + (chipH - textSz.y) * 0.5f;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(textX, textY), chipText,
|
||||
currentExchange.pairs[i].displayName.c_str());
|
||||
|
||||
// Click detection via invisible button
|
||||
ImGui::SetCursorScreenPos(cMin);
|
||||
snprintf(buf, sizeof(buf), "##PairChip%d", i);
|
||||
if (ImGui::InvisibleButton(buf, ImVec2(cw, chipH))) {
|
||||
if (!s_pair_dragging || std::abs(ImGui::GetIO().MousePos.x - s_pair_drag_start_x) < 4.0f) {
|
||||
s_pair_idx = i;
|
||||
anyClicked = true;
|
||||
app->settings()->setSelectedPair(currentExchange.pairs[i].displayName);
|
||||
app->settings()->save();
|
||||
s_history_initialized = false;
|
||||
app->refreshMarketData();
|
||||
}
|
||||
}
|
||||
|
||||
cx += cw + chipSpacing;
|
||||
}
|
||||
|
||||
dl->PopClipRect();
|
||||
|
||||
// Fade overlays on edges
|
||||
ImU32 bgCol = IM_COL32(0, 0, 0, 0);
|
||||
ImU32 surfaceCol = Surface();
|
||||
if (s_pair_scroll > 1.0f) {
|
||||
// Left fade
|
||||
dl->AddRectFilledMultiColor(
|
||||
ImVec2(chipAreaLeft, barOrigin.y), ImVec2(chipAreaLeft + fadeW, barOrigin.y + barH),
|
||||
surfaceCol, bgCol, bgCol, surfaceCol);
|
||||
}
|
||||
if (s_pair_scroll < maxScroll - 1.0f) {
|
||||
// Right fade
|
||||
dl->AddRectFilledMultiColor(
|
||||
ImVec2(chipAreaRight - fadeW, barOrigin.y), ImVec2(chipAreaRight, barOrigin.y + barH),
|
||||
bgCol, surfaceCol, surfaceCol, bgCol);
|
||||
}
|
||||
|
||||
// Right arrow button
|
||||
bool canScrollRight = s_pair_scroll_target < maxScroll - 0.01f;
|
||||
ImGui::SetCursorScreenPos(ImVec2(barOrigin.x + barW - arrowSz, arrowY));
|
||||
ImGui::BeginDisabled(!canScrollRight);
|
||||
ImGui::PushFont(material::Typography::instance().iconSmall());
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(2, 2));
|
||||
if (ImGui::Button(ICON_MD_CHEVRON_RIGHT "##PairRight", ImVec2(arrowSz, arrowSz))) {
|
||||
float avgChipW = totalChipW / currentExchange.pairs.size();
|
||||
s_pair_scroll_target += avgChipW + chipSpacing;
|
||||
if (s_pair_scroll_target > maxScroll) s_pair_scroll_target = maxScroll;
|
||||
}
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::PopFont();
|
||||
ImGui::EndDisabled();
|
||||
|
||||
// Mouse wheel horizontal scroll when hovering pair bar
|
||||
ImVec2 mPos = ImGui::GetIO().MousePos;
|
||||
if (mPos.x >= barOrigin.x && mPos.x <= barOrigin.x + barW &&
|
||||
mPos.y >= barOrigin.y && mPos.y <= barOrigin.y + barH) {
|
||||
float wheel = ImGui::GetIO().MouseWheel;
|
||||
if (wheel != 0.0f) {
|
||||
float avgChipW = totalChipW / currentExchange.pairs.size();
|
||||
s_pair_scroll_target -= wheel * (avgChipW + chipSpacing);
|
||||
s_pair_scroll_target = std::clamp(s_pair_scroll_target, 0.0f, maxScroll);
|
||||
}
|
||||
}
|
||||
|
||||
// Mouse drag scrolling
|
||||
if (ImGui::IsMouseClicked(0) && mPos.x >= chipAreaLeft && mPos.x <= chipAreaRight &&
|
||||
mPos.y >= barOrigin.y && mPos.y <= barOrigin.y + barH) {
|
||||
s_pair_dragging = true;
|
||||
s_pair_drag_start_x = mPos.x;
|
||||
s_pair_drag_start_scroll = s_pair_scroll_target;
|
||||
}
|
||||
if (s_pair_dragging) {
|
||||
if (ImGui::IsMouseDown(0)) {
|
||||
float dx = mPos.x - s_pair_drag_start_x;
|
||||
s_pair_scroll_target = std::clamp(s_pair_drag_start_scroll - dx, 0.0f, maxScroll);
|
||||
} else {
|
||||
s_pair_dragging = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Arrow key navigation
|
||||
if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) {
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_LeftArrow) && s_pair_idx > 0) {
|
||||
s_pair_idx--;
|
||||
app->settings()->setSelectedPair(currentExchange.pairs[s_pair_idx].displayName);
|
||||
app->settings()->save();
|
||||
s_history_initialized = false;
|
||||
app->refreshMarketData();
|
||||
anyClicked = true;
|
||||
}
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_RightArrow) && s_pair_idx < (int)currentExchange.pairs.size() - 1) {
|
||||
s_pair_idx++;
|
||||
app->settings()->setSelectedPair(currentExchange.pairs[s_pair_idx].displayName);
|
||||
app->settings()->save();
|
||||
s_history_initialized = false;
|
||||
app->refreshMarketData();
|
||||
anyClicked = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll to keep selected chip visible
|
||||
if (anyClicked) {
|
||||
float chipLeft = 0;
|
||||
for (int i = 0; i < s_pair_idx; i++)
|
||||
chipLeft += chipWidths[i] + chipSpacing;
|
||||
float chipRight = chipLeft + chipWidths[s_pair_idx];
|
||||
if (chipLeft < s_pair_scroll_target)
|
||||
s_pair_scroll_target = chipLeft - chipSpacing;
|
||||
if (chipRight > s_pair_scroll_target + scrollableW)
|
||||
s_pair_scroll_target = chipRight - scrollableW + chipSpacing;
|
||||
s_pair_scroll_target = std::clamp(s_pair_scroll_target, 0.0f, maxScroll);
|
||||
}
|
||||
|
||||
// Advance cursor past the bar
|
||||
ImGui::SetCursorScreenPos(ImVec2(barOrigin.x, barOrigin.y + barH));
|
||||
ImGui::Dummy(ImVec2(availWidth, 0));
|
||||
ImGui::Dummy(ImVec2(0, gap));
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// PORTFOLIO — Glass card with balance breakdown
|
||||
// ================================================================
|
||||
{
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), "PORTFOLIO");
|
||||
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 = std::max(50.0f, portfolioBudgetH);
|
||||
ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + cardH);
|
||||
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
|
||||
|
||||
// Accent stripe
|
||||
{
|
||||
float sw = S.drawElement("tabs.market", "accent-stripe-width").size;
|
||||
dl->PushClipRect(cardMin, ImVec2(cardMin.x + sw, cardMax.y), true);
|
||||
dl->AddRectFilled(cardMin, cardMax, WithAlpha(S.resolveColor("var(--accent-portfolio)", Secondary()), 200),
|
||||
glassSpec.rounding, ImDrawFlags_RoundCornersLeft);
|
||||
dl->PopClipRect();
|
||||
}
|
||||
|
||||
float cx = cardMin.x + Layout::spacingLg();
|
||||
float cy = cardMin.y + Layout::spacingLg();
|
||||
|
||||
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);
|
||||
|
||||
double portfolio_btc = total_balance * market.price_btc;
|
||||
snprintf(buf, sizeof(buf), "%.10f BTC", portfolio_btc);
|
||||
float valW = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, buf).x;
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(cardMax.x - valW - pad, cy + 2), OnSurfaceMedium(), buf);
|
||||
} else {
|
||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, cy), OnSurfaceDisabled(), "No price data");
|
||||
}
|
||||
|
||||
cy += sub1->LegacySize + 8;
|
||||
|
||||
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 | 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 + 8;
|
||||
|
||||
if (total_balance > 0) {
|
||||
float barW = availWidth - Layout::spacingXxl() * 1.5f;
|
||||
float barH = std::max(S.drawElement("tabs.market", "ratio-bar-min-height").size, S.drawElement("tabs.market", "ratio-bar-height").size * vs);
|
||||
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 + barH),
|
||||
IM_COL32(255, 255, 255, 10), 3.0f);
|
||||
if (shieldedW > 0.5f)
|
||||
dl->AddRectFilled(barStart, ImVec2(barStart.x + shieldedW, barStart.y + barH),
|
||||
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 + barH),
|
||||
WithAlpha(Warning(), 200),
|
||||
shieldedW > 0.5f ? ImDrawFlags_RoundCornersRight : ImDrawFlags_RoundCornersAll, 3.0f);
|
||||
|
||||
int pct = (int)(shieldedRatio * 100.0f + 0.5f);
|
||||
snprintf(buf, sizeof(buf), "%d%% Shielded", pct);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(cx, cy + barH + 2), OnSurfaceDisabled(), buf);
|
||||
}
|
||||
|
||||
float actualCardH = (total_balance > 0) ? std::max(60.0f, portfolioBudgetH) : cardH;
|
||||
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + actualCardH));
|
||||
ImGui::Dummy(ImVec2(availWidth, 0));
|
||||
ImGui::Dummy(ImVec2(0, gap));
|
||||
}
|
||||
|
||||
ImGui::EndChild(); // ##MarketScroll
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
Reference in New Issue
Block a user