// 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 "../material/project_icons.h" #include "../../util/text_format.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; // 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& 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& 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 s_pf_addrs; static std::string s_pf_icon; // selected wallet-icon name ("" = none) static unsigned int s_pf_color = 0; // selected accent (packed IM_COL32; 0 = default) static char s_pf_search[64] = {0}; // address-list filter text static int s_pf_type_filter = 0; // 0 = all, 1 = shielded, 2 = transparent static bool s_pf_funded_only = false; static float s_pf_custom_rgb[3] = {0.31f, 0.62f, 1.0f}; // working RGB for the custom color picker // Price-data config (working copy while editing a group). static int s_pf_price_basis = 0; static double s_pf_manual_price = 0.0; static char s_pf_manual_ccy[12] = "USD"; static bool s_pf_show_drgx = true; static bool s_pf_show_value = true; static bool s_pf_show_24h = false; static bool s_pf_show_sparkline = false; static int s_pf_spark_interval = 0; // 0=min 1=hour 2=day 3=week 4=month // price_history is ~1 sample/minute; resample it to the group's chosen interval by averaging // each block of K minute-samples into one point. static std::vector pfResampleHistory(const std::vector& hist, int interval) { static const int kMins[5] = {1, 60, 1440, 10080, 43200}; int k = kMins[(interval >= 0 && interval < 5) ? interval : 0]; if (k <= 1) return hist; std::vector out; for (size_t i = 0; i < hist.size(); i += (size_t)k) { double sum = 0.0; size_t cnt = 0; for (size_t j = i; j < hist.size() && j < i + (size_t)k; ++j) { sum += hist[j]; ++cnt; } if (cnt) out.push_back(sum / (double)cnt); } return out; } // A group's value trend tracks the DRGX price, so a group sparkline plots the price history // (normalized) across a rect. Colored by overall direction. Only meaningful for market bases. static void pfDrawSparkline(ImDrawList* dl, ImVec2 mn, ImVec2 mx, const std::vector& hist, ImU32 col) { int n = (int)hist.size(); if (n < 2) return; double lo = *std::min_element(hist.begin(), hist.end()); double hi = *std::max_element(hist.begin(), hist.end()); if (hi <= lo) hi = lo + 1e-9; float w = mx.x - mn.x, h = mx.y - mn.y; std::vector pts(n); for (int i = 0; i < n; i++) { float t = (n > 1) ? (float)i / (float)(n - 1) : 0.0f; pts[i] = ImVec2(mn.x + t * w, mx.y - (float)((hist[i] - lo) / (hi - lo)) * h); } dl->AddPolyline(pts.data(), n, col, ImDrawFlags_None, 1.2f); } // Sparkline with a soft fill under the line — for the card's dedicated bottom strip. static void pfDrawSparklineFilled(ImDrawList* dl, ImVec2 mn, ImVec2 mx, const std::vector& hist, ImU32 lineCol) { int n = (int)hist.size(); if (n < 2) return; double lo = *std::min_element(hist.begin(), hist.end()); double hi = *std::max_element(hist.begin(), hist.end()); if (hi <= lo) hi = lo + 1e-9; float w = mx.x - mn.x, h = mx.y - mn.y; std::vector pts(n); for (int i = 0; i < n; i++) { float t = (n > 1) ? (float)i / (float)(n - 1) : 0.0f; pts[i] = ImVec2(mn.x + t * w, mx.y - (float)((hist[i] - lo) / (hi - lo)) * h); } for (int i = 0; i < n; i++) dl->PathLineTo(pts[i]); dl->PathLineTo(ImVec2(pts[n - 1].x, mx.y)); dl->PathLineTo(ImVec2(pts[0].x, mx.y)); dl->PathFillConcave(WithAlpha(lineCol, 28)); dl->AddPolyline(pts.data(), n, WithAlpha(lineCol, 220), ImDrawFlags_None, 1.4f); } // Dashboard-grid state for the portfolio cards (drag to move, corner-drag to resize). struct PfCell { int col, row, w, h; }; static int s_pf_drag = -1; // card index being dragged (-1 = none) static int s_pf_resize = -1; // card index being resized static bool s_pf_moved = false; // drag passed the click threshold this gesture static ImVec2 s_pf_press; // mouse pos at grab static ImVec2 s_pf_grab; // mouse offset within the card at grab static PfCell s_pf_old = {0,0,1,1};// dragged card's cell at grab (for swap-on-drop) static PfCell s_pf_target = {0,0,1,1}; // live drop target cell // Build the right-hand amount strings for a group card / preview from its price-data config. struct PfDisplay { std::string primary; // name-row, top-right (DRGX amount or the value) std::string secondary; // below primary (the value, when DRGX is also shown) std::string change; // "+2.34%" (live-market bases only) or empty ImU32 changeCol = 0; }; static PfDisplay pfBuildDisplay(const config::Settings::PortfolioEntry& e, double bal, const MarketInfo& market) { PfDisplay d; char b[80]; std::string valueStr; bool hasValue = false; if (e.priceBasis == 0 && market.price_usd > 0) { snprintf(b, sizeof(b), "$%.2f", bal * market.price_usd); valueStr = b; hasValue = true; } else if (e.priceBasis == 1 && market.price_btc > 0) { snprintf(b, sizeof(b), "%.8f BTC", bal * market.price_btc); valueStr = b; hasValue = true; } else if (e.priceBasis == 3 && e.manualPrice > 0.0) { snprintf(b, sizeof(b), "%.2f %s", bal * e.manualPrice, e.manualCurrency.c_str()); valueStr = b; hasValue = true; } std::string drgxStr; if (e.showDrgx) { snprintf(b, sizeof(b), "%.4f %s", bal, DRAGONX_TICKER); drgxStr = b; } if (e.showDrgx) { d.primary = drgxStr; if (e.showValue && hasValue) d.secondary = valueStr; } else if (e.showValue && hasValue) { d.primary = valueStr; } if (e.show24h && (e.priceBasis == 0 || e.priceBasis == 1) && market.change_24h != 0.0) { snprintf(b, sizeof(b), "%+.2f%%", market.change_24h); d.change = b; d.changeCol = market.change_24h >= 0 ? material::Success() : material::Error(); } return d; } // Preset accent palette for portfolio groups (0 slot = "default / no color"). static const ImU32 kPfPalette[] = { IM_COL32(0x4F, 0x9D, 0xFF, 0xFF), // blue IM_COL32(0x3D, 0xDC, 0x84, 0xFF), // green IM_COL32(0xFF, 0xB0, 0x3D, 0xFF), // amber IM_COL32(0xFF, 0x6B, 0x6B, 0xFF), // red IM_COL32(0xB0, 0x7B, 0xFF, 0xFF), // purple IM_COL32(0x4F, 0xD6, 0xD6, 0xFF), // teal IM_COL32(0xFF, 0x8F, 0xC7, 0xFF), // pink IM_COL32(0xC7, 0xCE, 0xD6, 0xFF), // slate }; // 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()) { const auto& src = entries[index]; snprintf(s_pf_label, sizeof(s_pf_label), "%s", src.label.c_str()); s_pf_addrs = src.addresses; s_pf_icon = src.icon; s_pf_color = src.color; s_pf_price_basis = src.priceBasis; s_pf_manual_price = src.manualPrice; snprintf(s_pf_manual_ccy, sizeof(s_pf_manual_ccy), "%s", src.manualCurrency.c_str()); s_pf_show_drgx = src.showDrgx; s_pf_show_value = src.showValue; s_pf_show_24h = src.show24h; s_pf_show_sparkline = src.showSparkline; s_pf_spark_interval = src.sparklineInterval; } else { s_pf_label[0] = '\0'; s_pf_addrs.clear(); s_pf_icon.clear(); s_pf_color = 0; s_pf_price_basis = 0; s_pf_manual_price = 0.0; snprintf(s_pf_manual_ccy, sizeof(s_pf_manual_ccy), "USD"); s_pf_show_drgx = true; s_pf_show_value = true; s_pf_show_24h = false; s_pf_show_sparkline = false; s_pf_spark_interval = 0; } s_pf_search[0] = '\0'; s_pf_type_filter = 0; s_pf_funded_only = false; } // 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; // Edit mode is a wider two-column layout; list mode stays compact. float dialogW = std::min((s_pf_editing == -1 ? 560.0f : 780.0f), ImGui::GetMainViewport()->Size.x * 0.92f); 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& 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: two columns (Appearance | Addresses) ---- float dp = Layout::dpiScale(); ImFont* body2f = Type().body2(); ImFont* capF = Type().caption(); ImFont* sub1f = Type().subtitle1(); ImFont* iconFont = Type().iconSmall(); float iconFsz = iconFont->LegacySize; float contentW = ImGui::GetContentRegionAvail().x; float colGap = Layout::spacingLg(); float leftW = std::max(220.0f, (contentW - colGap) * 0.40f); float rightW = contentW - colGap - leftW; float colH = std::min(ImGui::GetMainViewport()->Size.y * 0.52f, 460.0f * dp); // ===================== LEFT: APPEARANCE ===================== ImGui::BeginChild("##pfLeft", ImVec2(leftW, colH), false); { Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("portfolio_appearance")); ImGui::Dummy(ImVec2(0, Layout::spacingSm())); // Live preview of the group card (icon + color + name + summed balance). { ImDrawList* pdl = ImGui::GetWindowDrawList(); float ppad = Layout::spacingMd(); float pw = ImGui::GetContentRegionAvail().x; float ph = ppad * 2.0f + sub1f->LegacySize + Layout::spacingXs() + capF->LegacySize; ImVec2 pMin = ImGui::GetCursorScreenPos(); ImVec2 pMax(pMin.x + pw, pMin.y + ph); ImU32 accent = s_pf_color ? (ImU32)s_pf_color : 0; GlassPanelSpec g; g.rounding = 10.0f; g.fillAlpha = 22; g.borderAlpha = 40; DrawGlassPanel(pdl, pMin, pMax, g); if (accent) { float bx = pMin.x + 2.0f; pdl->AddRectFilled(ImVec2(bx, pMin.y + ppad * 0.6f), ImVec2(bx + 3.0f, pMax.y - ppad * 0.6f), accent, 2.0f); } if (s_pf_show_sparkline && (s_pf_price_basis == 0 || s_pf_price_basis == 1)) { std::vector h = pfResampleHistory(state.market.price_history, s_pf_spark_interval); if (h.size() >= 2) { ImU32 spCol = WithAlpha(h.back() >= h.front() ? Success() : Error(), 80); ImVec2 spMin(pMin.x + ppad + (accent ? 6.0f : 0.0f), pMin.y + ph * 0.46f); ImVec2 spMax(pMax.x - ppad, pMax.y - ppad * 0.6f); pfDrawSparkline(pdl, spMin, spMax, h, spCol); } } float rowY = pMin.y + ppad, nameX = pMin.x + ppad + (accent ? 6.0f : 0.0f); if (!s_pf_icon.empty()) { material::project_icons::drawByName(pdl, s_pf_icon, ImVec2(nameX + sub1f->LegacySize * 0.5f, rowY + sub1f->LegacySize * 0.5f), accent ? accent : OnSurface(), iconFont, sub1f->LegacySize); nameX += sub1f->LegacySize + Layout::spacingSm(); } const char* nm = s_pf_label[0] ? s_pf_label : TR("portfolio_group_name"); pdl->AddText(sub1f, sub1f->LegacySize, ImVec2(nameX, rowY), s_pf_label[0] ? OnSurface() : OnSurfaceDisabled(), nm); double pbal = data::SumPortfolioBalance(s_pf_addrs, state.addresses); config::Settings::PortfolioEntry tmp; tmp.priceBasis = s_pf_price_basis; tmp.manualPrice = s_pf_manual_price; tmp.manualCurrency = s_pf_manual_ccy; tmp.showDrgx = s_pf_show_drgx; tmp.showValue = s_pf_show_value; tmp.show24h = s_pf_show_24h; PfDisplay pd = pfBuildDisplay(tmp, pbal, state.market); float primW = pd.primary.empty() ? 0.0f : body2f->CalcTextSizeA(body2f->LegacySize, FLT_MAX, 0, pd.primary.c_str()).x; if (!pd.primary.empty()) pdl->AddText(body2f, body2f->LegacySize, ImVec2(pMax.x - ppad - primW, rowY + (sub1f->LegacySize - body2f->LegacySize) * 0.5f), OnSurface(), pd.primary.c_str()); { float belowY = rowY + sub1f->LegacySize + Layout::spacingXs(); float cursorR = pMax.x - ppad; if (!pd.change.empty()) { float cw = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, pd.change.c_str()).x; pdl->AddText(capF, capF->LegacySize, ImVec2(cursorR - cw, belowY), pd.changeCol, pd.change.c_str()); cursorR -= cw + Layout::spacingSm(); } if (!pd.secondary.empty()) { float sw2 = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, pd.secondary.c_str()).x; pdl->AddText(capF, capF->LegacySize, ImVec2(cursorR - sw2, belowY), OnSurfaceDisabled(), pd.secondary.c_str()); } } ImGui::Dummy(ImVec2(pw, ph)); } ImGui::Dummy(ImVec2(0, Layout::spacingMd())); // Label ImGui::TextUnformatted(TR("portfolio_label")); ImGui::SetNextItemWidth(-1); ImGui::InputText("##pfLabel", s_pf_label, sizeof(s_pf_label)); ImGui::Dummy(ImVec2(0, Layout::spacingSm())); // Color picker: preset swatches + a custom-color circle that opens a picker popup. { float sw = 22.0f * dp; ImGui::TextUnformatted(TR("portfolio_color")); ImDrawList* cdl = ImGui::GetWindowDrawList(); int nColors = (int)(sizeof(kPfPalette) / sizeof(kPfPalette[0])); for (int c = 0; c <= nColors; c++) { // slot 0 = default / no color if (c != 0) ImGui::SameLine(0, 5.0f * dp); ImVec2 mn = ImGui::GetCursorScreenPos(); ImVec2 cc(mn.x + sw * 0.5f, mn.y + sw * 0.5f); bool isDefault = (c == 0); bool sel = isDefault ? (s_pf_color == 0u) : (s_pf_color == kPfPalette[c - 1]); bool hov = ImGui::IsMouseHoveringRect(mn, ImVec2(mn.x + sw, mn.y + sw)); if (isDefault) cdl->AddCircle(cc, sw * 0.40f, OnSurfaceMedium(), 0, 1.5f * dp); else cdl->AddCircleFilled(cc, sw * 0.40f, kPfPalette[c - 1]); if (sel) cdl->AddCircle(cc, sw * 0.50f, OnSurface(), 0, 2.0f * dp); else if (hov) cdl->AddCircle(cc, sw * 0.50f, WithAlpha(OnSurface(), 120), 0, 1.5f * dp); ImGui::PushID(2000 + c); ImGui::InvisibleButton("##pfcol", ImVec2(sw, sw)); if (ImGui::IsItemClicked()) s_pf_color = isDefault ? 0u : kPfPalette[c - 1]; ImGui::PopID(); } // Custom-color circle: shows the current color when it isn't a preset, else a "+"; // click opens a color-picker popup. bool isCustom = (s_pf_color != 0u); for (int c = 0; c < nColors; c++) if (s_pf_color == kPfPalette[c]) isCustom = false; ImGui::SameLine(0, 5.0f * dp); ImVec2 mn = ImGui::GetCursorScreenPos(); ImVec2 cc(mn.x + sw * 0.5f, mn.y + sw * 0.5f); bool hov = ImGui::IsMouseHoveringRect(mn, ImVec2(mn.x + sw, mn.y + sw)); if (isCustom) { cdl->AddCircleFilled(cc, sw * 0.40f, (ImU32)s_pf_color); } else { cdl->AddCircle(cc, sw * 0.40f, OnSurfaceMedium(), 0, 1.5f * dp); float pr = sw * 0.18f; cdl->AddLine(ImVec2(cc.x - pr, cc.y), ImVec2(cc.x + pr, cc.y), OnSurfaceMedium(), 1.5f * dp); cdl->AddLine(ImVec2(cc.x, cc.y - pr), ImVec2(cc.x, cc.y + pr), OnSurfaceMedium(), 1.5f * dp); } if (isCustom) cdl->AddCircle(cc, sw * 0.50f, OnSurface(), 0, 2.0f * dp); else if (hov) cdl->AddCircle(cc, sw * 0.50f, WithAlpha(OnSurface(), 120), 0, 1.5f * dp); ImGui::InvisibleButton("##pfcustomcol", ImVec2(sw, sw)); if (ImGui::IsItemClicked()) { ImU32 base = s_pf_color ? (ImU32)s_pf_color : IM_COL32(0x4F, 0x9D, 0xFF, 0xFF); s_pf_custom_rgb[0] = ((base >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f; s_pf_custom_rgb[1] = ((base >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f; s_pf_custom_rgb[2] = ((base >> IM_COL32_B_SHIFT) & 0xFF) / 255.0f; ImGui::OpenPopup("##pfCustomColor"); } if (hov) material::Tooltip("%s", TR("portfolio_custom_color")); if (ImGui::BeginPopup("##pfCustomColor")) { ImGui::ColorPicker3("##pfpick", s_pf_custom_rgb, ImGuiColorEditFlags_NoSidePreview | ImGuiColorEditFlags_NoAlpha | ImGuiColorEditFlags_NoLabel); s_pf_color = IM_COL32((int)(s_pf_custom_rgb[0] * 255.0f + 0.5f), (int)(s_pf_custom_rgb[1] * 255.0f + 0.5f), (int)(s_pf_custom_rgb[2] * 255.0f + 0.5f), 255); ImGui::EndPopup(); } } ImGui::Dummy(ImVec2(0, Layout::spacingSm())); // Price data: how this group's value is priced + which fields the card shows. { ImGui::TextUnformatted(TR("portfolio_price")); const char* basisItems[4] = { TR("portfolio_price_usd"), TR("portfolio_price_btc"), TR("portfolio_price_drgx"), TR("portfolio_price_manual") }; ImGui::SetNextItemWidth(-1); ImGui::Combo("##pfBasis", &s_pf_price_basis, basisItems, 4); if (s_pf_price_basis == 3) { // Manual: price-per-DRGX + currency label float half = (ImGui::GetContentRegionAvail().x - Layout::spacingSm()) * 0.62f; ImGui::SetNextItemWidth(half); ImGui::InputDouble("##pfManPrice", &s_pf_manual_price, 0.0, 0.0, "%.6f"); if (s_pf_manual_price < 0.0) s_pf_manual_price = 0.0; ImGui::SameLine(); ImGui::SetNextItemWidth(-1); ImGui::InputTextWithHint("##pfManCcy", TR("portfolio_currency"), s_pf_manual_ccy, sizeof(s_pf_manual_ccy)); } // Field toggles (which price fields appear on the card). ImGui::Checkbox(DRAGONX_TICKER "##pfShowDrgx", &s_pf_show_drgx); ImGui::SameLine(); ImGui::BeginDisabled(s_pf_price_basis == 2); // DRGX-only -> no value field ImGui::Checkbox(TR("portfolio_show_value"), &s_pf_show_value); ImGui::EndDisabled(); ImGui::SameLine(); ImGui::BeginDisabled(!(s_pf_price_basis == 0 || s_pf_price_basis == 1)); // 24h: live-market only ImGui::Checkbox(TR("portfolio_show_24h"), &s_pf_show_24h); ImGui::EndDisabled(); // Sparkline on its own line (also live-market only) + its interval. ImGui::BeginDisabled(!(s_pf_price_basis == 0 || s_pf_price_basis == 1)); ImGui::Checkbox(TR("portfolio_show_sparkline"), &s_pf_show_sparkline); ImGui::SameLine(); ImGui::BeginDisabled(!s_pf_show_sparkline); const char* spItems[5] = { TR("portfolio_spark_min"), TR("portfolio_spark_hour"), TR("portfolio_spark_day"), TR("portfolio_spark_week"), TR("portfolio_spark_month") }; ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); ImGui::Combo("##pfSparkInterval", &s_pf_spark_interval, spItems, 5); ImGui::EndDisabled(); ImGui::EndDisabled(); } ImGui::Dummy(ImVec2(0, Layout::spacingSm())); // Icon picker — fills the remaining height of the left column. { float cell = 28.0f * dp, cellGap = 4.0f * dp; ImGui::TextUnformatted(TR("portfolio_icon")); float iconGridH = std::max(cell * 2.0f + cellGap, ImGui::GetContentRegionAvail().y); ImGui::BeginChild("##pfIconGrid", ImVec2(0, iconGridH), true); ImDrawList* gdl = ImGui::GetWindowDrawList(); float availW = ImGui::GetContentRegionAvail().x; int cols = std::max(1, (int)((availW + cellGap) / (cell + cellGap))); int total = material::project_icons::walletIconCount() + 1; // slot 0 = "None" int gcol = 0; for (int k = 0; k < total; k++) { if (gcol != 0) ImGui::SameLine(0, cellGap); ImVec2 mn = ImGui::GetCursorScreenPos(); ImVec2 mx(mn.x + cell, mn.y + cell); ImVec2 cc(mn.x + cell * 0.5f, mn.y + cell * 0.5f); bool hov = ImGui::IsMouseHoveringRect(mn, mx); bool isNone = (k == 0); const char* nm2 = isNone ? "" : material::project_icons::walletIconName(k - 1); bool sel = isNone ? s_pf_icon.empty() : (s_pf_icon == nm2); if (sel) { gdl->AddRectFilled(mn, mx, WithAlpha(Primary(), 40), 6.0f * dp); gdl->AddRect(mn, mx, WithAlpha(Primary(), 120), 6.0f * dp, 0, 1.5f * dp); } else if (hov) { gdl->AddRectFilled(mn, mx, IM_COL32(255, 255, 255, 20), 6.0f * dp); } ImU32 icol = sel ? Primary() : (hov ? OnSurface() : OnSurfaceMedium()); if (isNone) { float r = cell * 0.26f; gdl->AddCircle(cc, r, icol, 0, 1.5f * dp); gdl->AddLine(ImVec2(cc.x - r * 0.7f, cc.y + r * 0.7f), ImVec2(cc.x + r * 0.7f, cc.y - r * 0.7f), icol, 1.5f * dp); } else { material::project_icons::drawByName(gdl, nm2, cc, icol, iconFont, iconFsz); } ImGui::PushID(k); ImGui::InvisibleButton("##pfic", ImVec2(cell, cell)); if (ImGui::IsItemClicked()) s_pf_icon = isNone ? std::string() : std::string(nm2); if (hov) material::Tooltip("%s", isNone ? TR("portfolio_no_icon") : nm2); ImGui::PopID(); gcol = (gcol + 1) % cols; } ImGui::EndChild(); } } ImGui::EndChild(); ImGui::SameLine(0, colGap); // ===================== RIGHT: ADDRESSES ===================== ImGui::BeginChild("##pfRight", ImVec2(rightW, colH), false); { Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("portfolio_addresses_hdr")); ImGui::SameLine(); { char selc[48]; snprintf(selc, sizeof(selc), TR("portfolio_addresses_sel"), (int)s_pf_addrs.size()); float w = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, selc).x; float ax = ImGui::GetContentRegionAvail().x; if (ax > w) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + ax - w); Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), selc); } ImGui::Dummy(ImVec2(0, Layout::spacingXs())); // Search ImGui::SetNextItemWidth(-1); ImGui::InputTextWithHint("##pfSearch", TR("portfolio_search"), s_pf_search, sizeof(s_pf_search)); ImGui::Dummy(ImVec2(0, Layout::spacingXs())); // Type filter chips + funded toggle { const char* flabels[3] = { TR("portfolio_select_all"), TR("shielded"), TR("transparent") }; for (int f = 0; f < 3; f++) { if (f) ImGui::SameLine(0, 4.0f * dp); bool active = (s_pf_type_filter == f); if (active) ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(WithAlpha(Primary(), 200))); if (ImGui::SmallButton((std::string(flabels[f]) + "##ptf" + std::to_string(f)).c_str())) s_pf_type_filter = f; if (active) ImGui::PopStyleColor(); } ImGui::SameLine(0, Layout::spacingMd()); ImGui::Checkbox(TR("portfolio_funded"), &s_pf_funded_only); } ImGui::Dummy(ImVec2(0, Layout::spacingXs())); // Build the filtered address list (selected first, then by balance desc). std::string search = s_pf_search; std::vector filtered; for (const auto& a : state.addresses) { if (s_pf_type_filter == 1 && a.type != "shielded") continue; if (s_pf_type_filter == 2 && a.type != "transparent") continue; if (s_pf_funded_only && a.balance <= 1e-9) continue; if (!search.empty()) { std::string hay = a.address + " " + app->getAddressLabel(a.address); if (!util::containsIgnoreCase(hay, search)) continue; } filtered.push_back(&a); } std::sort(filtered.begin(), filtered.end(), [&](const AddressInfo* x, const AddressInfo* y) { bool sx = data::PortfolioEntryContains(s_pf_addrs, x->address); bool sy = data::PortfolioEntryContains(s_pf_addrs, y->address); if (sx != sy) return sx; return x->balance > y->balance; }); // Select-shown / clear if (ImGui::SmallButton(TR("portfolio_select_shown"))) for (const AddressInfo* a : filtered) data::PortfolioEntryAdd(s_pf_addrs, a->address); ImGui::SameLine(); if (ImGui::SmallButton(TR("portfolio_clear_sel"))) s_pf_addrs.clear(); ImGui::Dummy(ImVec2(0, Layout::spacingXs())); // Address list fills the remaining height in the right column. float listH = std::max(60.0f, ImGui::GetContentRegionAvail().y); ImGui::BeginChild("##pfAddrList", ImVec2(0, listH), true); ImDrawList* ldl = ImGui::GetWindowDrawList(); float rowH = std::max(26.0f * dp, body2f->LegacySize + Layout::spacingSm()); float lw = ImGui::GetContentRegionAvail().x; if (filtered.empty()) Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_addr_match")); for (const AddressInfo* ap : filtered) { const AddressInfo& a = *ap; bool inSet = data::PortfolioEntryContains(s_pf_addrs, a.address); ImVec2 rmn = ImGui::GetCursorScreenPos(); ImVec2 rmx(rmn.x + lw, rmn.y + rowH); bool rhov = ImGui::IsMouseHoveringRect(rmn, rmx); if (inSet) ldl->AddRectFilled(rmn, rmx, WithAlpha(Primary(), 22), 4.0f); else if (rhov) ldl->AddRectFilled(rmn, rmx, IM_COL32(255, 255, 255, 14), 4.0f); float rcy = rmn.y + rowH * 0.5f; float rx = rmn.x + Layout::spacingSm(); // Checkbox indicator float cb = 15.0f * dp; ImVec2 cbMin(rx, rcy - cb * 0.5f), cbMax(rx + cb, rcy + cb * 0.5f); if (inSet) { ldl->AddRectFilled(cbMin, cbMax, Primary(), 3.0f * dp); ldl->AddLine(ImVec2(cbMin.x + cb * 0.22f, rcy), ImVec2(cbMin.x + cb * 0.42f, cbMax.y - cb * 0.25f), IM_COL32(255, 255, 255, 255), 1.6f * dp); ldl->AddLine(ImVec2(cbMin.x + cb * 0.42f, cbMax.y - cb * 0.25f), ImVec2(cbMin.x + cb * 0.80f, cbMin.y + cb * 0.22f), IM_COL32(255, 255, 255, 255), 1.6f * dp); } else { ldl->AddRect(cbMin, cbMax, WithAlpha(OnSurface(), 120), 3.0f * dp, 0, 1.2f * dp); } rx += cb + Layout::spacingSm(); // Type chip (Z / T) bool isZ = (a.type == "shielded"); const char* chip = isZ ? "Z" : "T"; ImU32 chipCol = isZ ? Success() : Warning(); float chipW = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, chip).x + 8.0f * dp; ldl->AddRectFilled(ImVec2(rx, rcy - capF->LegacySize * 0.5f - 2.0f), ImVec2(rx + chipW, rcy + capF->LegacySize * 0.5f + 2.0f), WithAlpha(chipCol, 40), 4.0f * dp); ldl->AddText(capF, capF->LegacySize, ImVec2(rx + 4.0f * dp, rcy - capF->LegacySize * 0.5f), chipCol, chip); rx += chipW + Layout::spacingSm(); // Address's own icon (if set) std::string aicon = app->getAddressIcon(a.address); if (!aicon.empty()) { material::project_icons::drawByName(ldl, aicon, ImVec2(rx + iconFsz * 0.5f, rcy), OnSurfaceMedium(), iconFont, iconFsz); rx += iconFsz + Layout::spacingSm(); } // Balance (right) then primary text (label or truncated address). char balbuf[48]; snprintf(balbuf, sizeof(balbuf), "%.4f", a.balance); float balW = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, balbuf).x; float textRight = rmx.x - Layout::spacingSm() - balW - Layout::spacingSm(); std::string alabel = app->getAddressLabel(a.address); std::string primary = alabel.empty() ? util::truncateMiddle(a.address, 22) : alabel; float availTextW = textRight - rx; while (primary.size() > 1 && body2f->CalcTextSizeA(body2f->LegacySize, FLT_MAX, 0, primary.c_str()).x > availTextW) primary.pop_back(); ldl->AddText(body2f, body2f->LegacySize, ImVec2(rx, rcy - body2f->LegacySize * 0.5f), inSet ? OnSurface() : OnSurfaceMedium(), primary.c_str()); ldl->AddText(capF, capF->LegacySize, ImVec2(rmx.x - Layout::spacingSm() - balW, rcy - capF->LegacySize * 0.5f), a.balance > 1e-9 ? OnSurface() : OnSurfaceDisabled(), balbuf); // Whole row toggles selection. ImGui::PushID(a.address.c_str()); ImGui::InvisibleButton("##pfrow", ImVec2(lw, rowH)); if (ImGui::IsItemClicked()) { if (inSet) data::PortfolioEntryRemove(s_pf_addrs, a.address); else data::PortfolioEntryAdd(s_pf_addrs, a.address); } ImGui::PopID(); } ImGui::EndChild(); } ImGui::EndChild(); // ===================== ACTIONS ===================== ImGui::Dummy(ImVec2(0, Layout::spacingSm())); bool canSave = s_pf_label[0] != '\0'; ImGui::BeginDisabled(!canSave); if (ImGui::Button(TR("portfolio_save"))) { auto entries = settings->getPortfolioEntries(); // Start from the existing entry when editing so its grid placement/size (and any // other fields not on this form) are preserved — editing must not reset the card. config::Settings::PortfolioEntry e; if (s_pf_editing >= 0 && s_pf_editing < (int)entries.size()) e = entries[s_pf_editing]; e.label = s_pf_label; e.addresses = s_pf_addrs; e.icon = s_pf_icon; e.color = s_pf_color; e.priceBasis = s_pf_price_basis; e.manualPrice = s_pf_manual_price; e.manualCurrency = s_pf_manual_ccy; e.showDrgx = s_pf_show_drgx; e.showValue = s_pf_show_value; e.show24h = s_pf_show_24h; e.showSparkline = s_pf_show_sparkline; e.sparklineInterval = s_pf_spark_interval; 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(); // Scrollable: the portfolio grid can extend past the window and scroll into view. ImGui::BeginChild("##MarketScroll", marketAvail, false, ImGuiWindowFlags_NoBackground); // 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. // ================================================================ float mktDp = Layout::dpiScale(); // -- Compact price chart: a modest responsive height, no longer stretched to fill the tab. -- float chartH = std::max(110.0f * vs, std::min(chartElem.height * vs, marketAvail.y * 0.22f)); // -- Hero header: size to the ACTUAL content (price row + separator gap + stats row) so the // chart starts right after "24H VOLUME"/"Market Cap" instead of below a reserved empty band. -- ImFont* mktH3 = Type().h3(); float heroHeaderH = Layout::spacingLg() // top pad + mktH3->LegacySize + Layout::spacingMd() // price row -> separator + Layout::spacingSm() // separator -> stats + ovFont->LegacySize + Layout::spacingXs() + sub1->LegacySize // stats label + value + Layout::spacingLg(); // bottom pad before the chart // -- Portfolio geometry. No parent card background: the summary floats under the header // and the custom-group cards float below it (each with its own glass background). -- const auto& pfEntriesGeo = app->settings()->getPortfolioEntries(); float ratioBarH = std::max(S.drawElement("tabs.market", "ratio-bar-min-height").size, S.drawElement("tabs.market", "ratio-bar-height").size * vs); float pfSummaryH = sub1->LegacySize + Layout::spacingSm() // fiat hero row + body2->LegacySize + Layout::spacingSm() // DRGX balance row + ratioBarH + Layout::spacingXs() + capFont->LegacySize // ratio bar + % label + Layout::spacingMd(); // gap before the group cards int pfN = (int)pfEntriesGeo.size(); float pfCardPad = Layout::spacingMd(); // inner padding of each group card // Fine SQUARE grid: cells are ~32px minimum and scale up to evenly fill the width. Cards snap to // this grid and span many cells; they're drawn with an inner inset so adjacent cards don't touch. float pfCardGap = 0.0f; // gapless cell grid float pfInset = 3.0f * mktDp; // per-card inset for visual separation const int pfMinW = 4, pfMinH = 3; // minimum card size in cells (~128x96) int pfCols = std::max(pfMinW, (int)(availWidth / (32.0f * mktDp))); float pfCellW = availWidth / (float)pfCols; // square cell edge (>= ~32px) float pfCellH = pfCellW; // Resolve each group's grid cell: honor a stored placement when it fits, else auto-place // row-major into the first free cells. Deterministic each frame. std::vector pfLayout((size_t)std::max(0, pfN)); { std::vector> occ; auto ensure = [&](int rEnd) { while ((int)occ.size() < rEnd) occ.emplace_back(pfCols, 0); }; auto fits = [&](int c, int r, int w, int h) -> bool { if (c < 0 || r < 0 || c + w > pfCols) return false; ensure(r + h); for (int rr = r; rr < r + h; rr++) for (int cc = c; cc < c + w; cc++) if (occ[rr][cc]) return false; return true; }; auto mark = [&](int c, int r, int w, int h) { ensure(r + h); for (int rr = r; rr < r + h; rr++) for (int cc = c; cc < c + w; cc++) occ[rr][cc] = 1; }; std::vector placed((size_t)pfN, 0); for (int i = 0; i < pfN; i++) { const auto& e = pfEntriesGeo[i]; int w = std::max(pfMinW, std::min(e.gridW, pfCols)), h = std::max(pfMinH, e.gridH); if (e.gridCol >= 0 && e.gridRow >= 0 && fits(e.gridCol, e.gridRow, w, h)) { pfLayout[i] = {e.gridCol, e.gridRow, w, h}; mark(e.gridCol, e.gridRow, w, h); placed[i] = 1; } } for (int i = 0; i < pfN; i++) { if (placed[i]) continue; const auto& e = pfEntriesGeo[i]; int w = std::max(pfMinW, std::min(e.gridW, pfCols)), h = std::max(pfMinH, e.gridH); int fc = 0, fr = 0; bool found = false; for (int r = 0; !found && r < 4096; r++) for (int c = 0; c + w <= pfCols; c++) if (fits(c, r, w, h)) { fc = c; fr = r; found = true; break; } pfLayout[i] = {fc, fr, w, h}; mark(fc, fr, w, h); } } int pfMaxRow = 0; for (const auto& L : pfLayout) pfMaxRow = std::max(pfMaxRow, L.row + L.h); // Content height = the grid plus a couple of empty rows so cards can be dragged below the // current bottom (the tab scrolls when the total exceeds the window). float pfGroupsH = (pfN > 0) ? ((pfMaxRow + 2) * pfCellH) : 0.0f; float portfolioH = pfSummaryH + pfGroupsH; // ================================================================ // 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 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 time labels. The price series is one sample per market refresh (~60s // cadence, see RefreshScheduler::kPrice), so label the axis in approximate // minutes-ago (rightmost = "now"). Approximate — a refresh can be delayed — hence "~". { const float kSecPerPoint = 60.0f; // RefreshScheduler::kPrice int nPts = (int)n; int ticks = std::min(4, nPts); for (int tk = 0; tk < ticks; tk++) { float t = (ticks > 1) ? (float)tk / (float)(ticks - 1) : 1.0f; float xpos = plotLeft + t * plotW; int idx = (int)(t * (nPts - 1) + 0.5f); int minsAgo = (int)(((float)(nPts - 1 - idx) * kSecPerPoint) / 60.0f + 0.5f); char tlbl[32]; if (minsAgo <= 0) snprintf(tlbl, sizeof(tlbl), "%s", TR("market_now")); else if (minsAgo < 90) snprintf(tlbl, sizeof(tlbl), "~%dm", minsAgo); else snprintf(tlbl, sizeof(tlbl), "~%.1fh", minsAgo / 60.0f); ImVec2 lblSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, tlbl); float lx = (tk == 0) ? plotLeft : (tk == ticks - 1) ? plotRight - lblSz.x : xpos - lblSz.x * 0.5f; dl->AddText(capFont, capFont->LegacySize, ImVec2(lx, plotBottom + 2), OnSurfaceDisabled(), tlbl); } } // 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 — floating summary (no card background) + custom-group cards // ================================================================ { 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; // No parent card background — the summary floats directly under the "MY DRGX" header. ImVec2 cardMin = ImGui::GetCursorScreenPos(); float rightEdge = cardMin.x + availWidth; float cx = cardMin.x; float cy = cardMin.y; // ---- 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); // "≈ BTC" float btcW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf).x; dl->AddText(capFont, capFont->LegacySize, ImVec2(rightEdge - btcW, 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(rightEdge - brkW, cy + 2), OnSurfaceDisabled(), buf); cy += body2->LegacySize + Layout::spacingSm(); // Full-width shielded/transparent ratio bar + % label. if (total_balance > 0) { float barW = availWidth; 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(shieldedRatio) * 100.0); dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy + ratioBarH + Layout::spacingXs()), OnSurfaceDisabled(), buf); } // ---- CUSTOM GROUPS — dashboard grid: drag a card to move it, drag its corner to resize. // A subtle dot grid shows while rearranging. Click (no drag) opens the editor. ---- if (pfN > 0) { float gy = cardMin.y + pfSummaryH; ImGuiIO& io = ImGui::GetIO(); bool interacting = (s_pf_drag >= 0 || s_pf_resize >= 0); float rh = 14.0f * mktDp; // corner resize-grip size auto cellMin = [&](const PfCell& L) { return ImVec2(cx + L.col * (pfCellW + pfCardGap), gy + L.row * (pfCellH + pfCardGap)); }; auto cellSize = [&](int w, int h) { return ImVec2(w * pfCellW + (w - 1) * pfCardGap, h * pfCellH + (h - 1) * pfCardGap); }; // Draw one card's content into [gcMin, gcMax]. // Stat tile: header (icon + name, 24h badge) / hero value (large, centered band) / // footer (DRGX amount) + a dedicated sparkline strip along the bottom. auto drawCard = [&](ImVec2 gcMin, ImVec2 gcMax, const config::Settings::PortfolioEntry& e, bool hov) { // The grid is gapless, so inset the card within its cell block for separation. gcMin.x += pfInset; gcMin.y += pfInset; gcMax.x -= pfInset; gcMax.y -= pfInset; double bal = data::SumPortfolioBalance(e.addresses, state.addresses); ImU32 accent = e.color ? (ImU32)e.color : 0; float pad = pfCardPad; GlassPanelSpec gcGlass; gcGlass.rounding = 10.0f; gcGlass.fillAlpha = hov ? 34 : 22; gcGlass.borderAlpha = 40; DrawGlassPanel(dl, gcMin, gcMax, gcGlass); if (hov) dl->AddRect(gcMin, gcMax, WithAlpha(OnSurface(), 70), 10.0f, 0, 1.0f); if (accent) dl->AddRectFilled(ImVec2(gcMin.x + 2.0f, gcMin.y + pad * 0.6f), ImVec2(gcMin.x + 5.0f, gcMax.y - pad * 0.6f), accent, 2.0f); float left = gcMin.x + pad + (accent ? 6.0f : 0.0f); float right = gcMax.x - pad; float availW = right - left; auto fit = [&](std::string s, ImFont* f, float maxW) { bool t = false; while (s.size() > 1 && f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, s.c_str()).x > maxW) { s.pop_back(); t = true; } if (t) s += "\xE2\x80\xA6"; return s; }; // Value + DRGX strings per basis. char vb[80]; std::string valueStr; bool hasValue = false; if (e.priceBasis == 0 && market.price_usd > 0) { snprintf(vb, sizeof(vb), "$%.2f", bal * market.price_usd); valueStr = vb; hasValue = true; } else if (e.priceBasis == 1 && market.price_btc > 0) { snprintf(vb, sizeof(vb), "%.6f BTC", bal * market.price_btc); valueStr = vb; hasValue = true; } else if (e.priceBasis == 3 && e.manualPrice > 0.0) { snprintf(vb, sizeof(vb), "%.2f %s", bal * e.manualPrice, e.manualCurrency.c_str()); valueStr = vb; hasValue = true; } char db[48]; snprintf(db, sizeof(db), "%.4f %s", bal, DRAGONX_TICKER); bool heroIsValue = e.showValue && hasValue; std::string heroStr = heroIsValue ? valueStr : (e.showDrgx ? std::string(db) : (hasValue ? valueStr : std::string(db))); std::string footStr = (heroIsValue && e.showDrgx) ? std::string(db) : std::string(); // Header: icon + name (left), 24h badge (right). float hy = gcMin.y + pad, nameX = left, nameMaxW = availW; if (!e.icon.empty()) { material::project_icons::drawByName(dl, e.icon.c_str(), ImVec2(nameX + body2->LegacySize * 0.5f, hy + body2->LegacySize * 0.5f), accent ? accent : OnSurfaceMedium(), Type().iconSmall(), body2->LegacySize); nameX += body2->LegacySize + Layout::spacingSm(); nameMaxW = right - nameX; } if (e.show24h && (e.priceBasis == 0 || e.priceBasis == 1) && market.change_24h != 0.0) { char cb[32]; snprintf(cb, sizeof(cb), "%+.2f%%", market.change_24h); float cw = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, cb).x; dl->AddText(capFont, capFont->LegacySize, ImVec2(right - cw, hy + (body2->LegacySize - capFont->LegacySize) * 0.5f), market.change_24h >= 0 ? Success() : Error(), cb); nameMaxW -= cw + Layout::spacingSm(); } dl->AddText(body2, body2->LegacySize, ImVec2(nameX, hy), OnSurfaceMedium(), fit(e.label, body2, nameMaxW).c_str()); // Sparkline strip along the bottom. float sparkH = 0.0f; if (e.showSparkline && (e.priceBasis == 0 || e.priceBasis == 1)) { std::vector hh = pfResampleHistory(market.price_history, e.sparklineInterval); if (hh.size() >= 2) { sparkH = std::max(18.0f * mktDp, (gcMax.y - gcMin.y) * 0.24f); pfDrawSparklineFilled(dl, ImVec2(left, gcMax.y - pad - sparkH), ImVec2(right, gcMax.y - pad), hh, hh.back() >= hh.front() ? Success() : Error()); } } // Footer: DRGX amount just above the sparkline. float footBottom = gcMax.y - pad - (sparkH > 0 ? sparkH + Layout::spacingXs() : 0.0f); float footTop = footBottom; if (!footStr.empty()) { footTop = footBottom - capFont->LegacySize; dl->AddText(capFont, capFont->LegacySize, ImVec2(left, footTop), OnSurfaceDisabled(), fit(footStr, capFont, availW).c_str()); } // Hero value: large, centered in the band between the header and footer. if (!heroStr.empty()) { ImFont* heroFont = ((gcMax.y - gcMin.y) > 120.0f * mktDp) ? h4 : sub1; float bandTop = hy + body2->LegacySize + Layout::spacingSm(); float bandBot = (footStr.empty() ? footBottom : footTop) - Layout::spacingXs(); float heroY = std::max(bandTop, (bandTop + bandBot) * 0.5f - heroFont->LegacySize * 0.5f); dl->AddText(heroFont, heroFont->LegacySize, ImVec2(left, heroY), OnSurface(), fit(heroStr, heroFont, availW).c_str()); } }; // Subtle dot grid while rearranging (fine 32px cells; extends into the buffer rows). if (interacting) { ImU32 dotc = WithAlpha(OnSurface(), 38); for (int r = 0; r <= pfMaxRow + 2; r++) for (int c = 0; c <= pfCols; c++) dl->AddCircleFilled(ImVec2(cx + c * pfCellW, gy + r * pfCellH), 1.2f * mktDp, dotc); } // Draw + hit-test each card. Skip the one being dragged/resized (drawn specially below). for (int i = 0; i < pfN; i++) { ImVec2 gcMin = cellMin(pfLayout[i]); ImVec2 sz = cellSize(pfLayout[i].w, pfLayout[i].h); ImVec2 gcMax(gcMin.x + sz.x, gcMin.y + sz.y); if (i == s_pf_drag && s_pf_moved) { dl->AddRect(gcMin, gcMax, WithAlpha(OnSurface(), 45), 10.0f, 0, 1.0f); // source ghost continue; } if (i == s_pf_resize) continue; bool hov = !interacting && material::IsRectHovered(gcMin, gcMax); drawCard(gcMin, gcMax, pfEntriesGeo[i], hov); // Resize grip (bottom-right). bool gripHov = material::IsRectHovered(ImVec2(gcMax.x - rh, gcMax.y - rh), gcMax); ImU32 gcc = WithAlpha(OnSurface(), gripHov ? 170 : 80); for (int k = 1; k <= 2; k++) { float o = k * 4.0f * mktDp; dl->AddLine(ImVec2(gcMax.x - o, gcMax.y - 3.0f), ImVec2(gcMax.x - 3.0f, gcMax.y - o), gcc, 1.3f * mktDp); } // Start a gesture only when idle. One body button; the press location (corner vs body) // decides resize vs move — avoids overlapping-item hit-test issues. if (s_pf_drag < 0 && s_pf_resize < 0) { ImGui::PushID(3000 + i); ImGui::SetCursorScreenPos(gcMin); ImGui::InvisibleButton("##pfBody", sz); bool bodyAct = ImGui::IsItemActivated(); bool bodyHov = ImGui::IsItemHovered(); ImGui::PopID(); if (bodyHov) ImGui::SetMouseCursor(gripHov ? ImGuiMouseCursor_ResizeNWSE : ImGuiMouseCursor_Hand); if (bodyAct) { if (gripHov) { s_pf_resize = i; s_pf_old = pfLayout[i]; } else { s_pf_drag = i; s_pf_moved = false; s_pf_old = pfLayout[i]; s_pf_press = io.MousePos; s_pf_grab = ImVec2(io.MousePos.x - gcMin.x, io.MousePos.y - gcMin.y); } } } } auto pfPersist = [&](const std::vector& v) { app->settings()->setPortfolioEntries(v); app->settings()->save(); }; // ----- Resize in progress (corner drag) ----- if (s_pf_resize >= 0 && s_pf_resize < pfN) { int i = s_pf_resize; ImVec2 mn = cellMin(pfLayout[i]); int nw = std::max(pfMinW, std::min(pfCols - pfLayout[i].col, (int)((io.MousePos.x - mn.x + pfCardGap) / (pfCellW + pfCardGap) + 0.5f))); int nh = std::max(pfMinH, (int)((io.MousePos.y - mn.y + pfCardGap) / (pfCellH + pfCardGap) + 0.5f)); ImVec2 sz = cellSize(nw, nh), mx(mn.x + sz.x, mn.y + sz.y); drawCard(mn, mx, pfEntriesGeo[i], true); dl->AddRect(mn, mx, WithAlpha(Primary(), 200), 10.0f, 0, 2.0f * mktDp); ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNWSE); if (!io.MouseDown[0]) { auto entries = app->settings()->getPortfolioEntries(); if (i < (int)entries.size()) { entries[i].gridCol = pfLayout[i].col; entries[i].gridRow = pfLayout[i].row; entries[i].gridW = nw; entries[i].gridH = nh; pfPersist(entries); } s_pf_resize = -1; } } // ----- Drag in progress ----- if (s_pf_drag >= 0 && s_pf_drag < pfN) { int i = s_pf_drag; if (io.MouseDown[0]) { if (std::abs(io.MousePos.x - s_pf_press.x) > 4.0f || std::abs(io.MousePos.y - s_pf_press.y) > 4.0f) s_pf_moved = true; if (s_pf_moved) { int w = pfLayout[i].w, h = pfLayout[i].h; float tx = io.MousePos.x - s_pf_grab.x, ty = io.MousePos.y - s_pf_grab.y; int tc = std::max(0, std::min(pfCols - w, (int)((tx - cx) / (pfCellW + pfCardGap) + 0.5f))); int tr = std::max(0, (int)((ty - gy) / (pfCellH + pfCardGap) + 0.5f)); s_pf_target = {tc, tr, w, h}; ImVec2 tmn(cx + tc * (pfCellW + pfCardGap), gy + tr * (pfCellH + pfCardGap)); ImVec2 tsz = cellSize(w, h), tmx(tmn.x + tsz.x, tmn.y + tsz.y); dl->AddRectFilled(tmn, tmx, WithAlpha(Primary(), 25), 10.0f); dl->AddRect(tmn, tmx, WithAlpha(Primary(), 200), 10.0f, 0, 2.0f * mktDp); ImVec2 fmn(tx, ty), fmx(fmn.x + tsz.x, fmn.y + tsz.y); drawCard(fmn, fmx, pfEntriesGeo[i], true); // dragged card follows the mouse ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeAll); } } else { if (!s_pf_moved) { PortfolioBeginEdit(app, i); s_portfolio_editor_open = true; } else { auto entries = app->settings()->getPortfolioEntries(); if (i < (int)entries.size()) { int occ = -1; for (int j = 0; j < pfN; j++) if (j != i && pfLayout[j].col == s_pf_target.col && pfLayout[j].row == s_pf_target.row) { occ = j; break; } entries[i].gridCol = s_pf_target.col; entries[i].gridRow = s_pf_target.row; if (occ >= 0 && occ < (int)entries.size()) { entries[occ].gridCol = s_pf_old.col; entries[occ].gridRow = s_pf_old.row; } pfPersist(entries); } } s_pf_drag = -1; s_pf_moved = false; } } } ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + portfolioH)); 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