// 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 "../../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 #include #include namespace dragonx { namespace ui { using namespace material; // ---- Market tab persistent state ---- static std::vector s_price_history; static std::vector 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 portfolioBudgetH = mb.allocate(0.18f, 50.0f); // ================================================================ // PRICE SUMMARY — Combined hero card with price, stats, and exchange // ================================================================ { float dp = Layout::dpiScale(); ImVec2 cardMin = ImGui::GetCursorScreenPos(); float heroMinH = S.drawElement("tabs.market", "hero-card-min-height").size; float statsRowH = ovFont->LegacySize + Layout::spacingXs() + sub1->LegacySize + pad; float cardH = std::max(heroMinH + statsRowH + pad, (S.drawElement("tabs.market", "hero-card-height").size + statsRowH + pad) * 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) { // ---- 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 (BTC Price | Volume | Market Cap) ---- float statsY = sepY + Layout::spacingSm(); float colW = (availWidth - Layout::spacingLg() * 2) / 3.0f; struct StatItem { const char* label; std::string value; ImU32 valueCol; }; StatItem stats[3] = { {TR("market_btc_price"), "", OnSurface()}, {TR("market_24h_volume"), FormatCompactUSD(market.volume_24h), OnSurface()}, {TR("market_cap"), FormatCompactUSD(market.market_cap), OnSurface()}, }; snprintf(buf, sizeof(buf), "%.10f", market.price_btc); stats[0].value = buf; for (int i = 0; i < 3; 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()); } // ---- STALENESS INDICATOR ---- { auto fetchTime = market.last_fetch_time; if (fetchTime.time_since_epoch().count() > 0) { auto elapsed = std::chrono::steady_clock::now() - fetchTime; int ageSecs = (int)std::chrono::duration_cast(elapsed).count(); bool stale = ageSecs > 300; // 5 minutes if (ageSecs < 60) snprintf(buf, sizeof(buf), "%s %ds %s", stale ? ICON_MD_WARNING : "", ageSecs, TR("ago")); else snprintf(buf, sizeof(buf), "%s %dm %s", stale ? ICON_MD_WARNING : "", ageSecs / 60, TR("ago")); ImFont* staleFont = capFont; ImU32 staleCol = stale ? Warning() : WithAlpha(OnSurface(), 100); float staleY = statsY + ovFont->LegacySize + Layout::spacingXs() + sub1->LegacySize + Layout::spacingSm(); ImVec2 staleSz = staleFont->CalcTextSizeA(staleFont->LegacySize, FLT_MAX, 0, buf); dl->AddText(staleFont, staleFont->LegacySize, ImVec2(cardMin.x + Layout::spacingLg(), staleY), staleCol, buf); } } // ---- 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); ImGui::SetTooltip(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()); } } ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y)); ImGui::Dummy(ImVec2(availWidth, 0)); 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(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 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 labels const int xlabels[] = {0, 6, 12, 18, 23}; const char* xlabelText[] = {TR("market_24h"), TR("market_18h"), TR("market_12h"), TR("market_6h"), TR("market_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 = 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()) ImGui::SetTooltip("%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)); } // ================================================================ // EXCHANGE SELECTOR — Combo dropdown + attribution // ================================================================ 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(); // Attribution ImGui::SameLine(0, Layout::spacingLg()); 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::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 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(), TR("market_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(), TR("market_no_price")); } 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), TR("market_pct_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