// 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/market_series.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/overlay_scroll.h" #include "../material/colors.h" #include "../material/typography.h" #include "../material/project_icons.h" #include "../notifications.h" #include "../effects/imgui_acrylic.h" #include "../effects/scroll_fade_shader.h" #include "../effects/low_spec.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 ---- struct MarketViewState { std::vector history; // mirror of WalletState market.price_history int exchangeIdx = 0; // selected exchange / pair int pairIdx = 0; bool stateLoaded = false; int chartInterval = 4; // main chart range: 0=Live 1=1H 2=1D 3=1W 4=1M (default 1M) }; static MarketViewState s_mkt; // 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_mkt.stateLoaded || !settings) return; s_mkt.stateLoaded = 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_mkt.exchangeIdx = ei; for (int pi = 0; pi < (int)registry[ei].pairs.size(); pi++) { if (registry[ei].pairs[pi].displayName == savedPair) { s_mkt.pairIdx = 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) working state ---- struct PfEditState { bool open = false; // modal visible (queried by PortfolioEditorActive()) int sel = -1; // selected group index in the master list; -1 = none int section = 0; // right-pane segmented control: 0=Appearance 1=Price 2=Addresses // (The blur backdrop + capture-once are owned by material::BeginOverlayDialog now.) // Working copy of the group being edited. char label[64] = {0}; std::vector addrs; std::string icon; // selected wallet-icon name ("" = none) unsigned int color = 0; // selected accent (packed IM_COL32; 0 = default) int outlineOpacity = 25; // accent-outline opacity (percent) char search[64] = {0}; // address-list filter text char iconSearch[64] = {0}; // icon-picker filter text int typeFilter = 0; // 0 = all, 1 = shielded, 2 = transparent bool fundedOnly = false; effects::ScrollFadeShader iconFade; // edge-fade shaders for the two internal scroll regions effects::ScrollFadeShader addrFade; float customRgb[3] = {0.31f, 0.62f, 1.0f}; // working RGB for the custom color picker // Price-data config (working copy while editing a group). int priceBasis = 0; double manualPrice = 0.0; char manualCcy[12] = "USD"; bool showDrgx = true; bool showValue = true; bool show24h = false; bool showSparkline = false; int sparkInterval = 0; // 0=min 1=hour 2=day 3=week 4=month }; static PfEditState s_pfEdit; // The pure price-series math lives in data/market_series.h; the selected chart range is // s_mkt.chartInterval (0=Live 1=1H 2=1D 3=1W 4=1M, session-scoped, default 1M). // 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. // Normalize a numeric series into evenly-spaced screen points within [mn, mx], y scaled to the // series min..max. Returns false (leaving `out` untouched) when there are fewer than 2 points. static bool pfProjectSeries(const std::vector& hist, ImVec2 mn, ImVec2 mx, std::vector& out) { int n = (int)hist.size(); if (n < 2) return false; 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; out.resize(n); for (int i = 0; i < n; i++) { float t = (float)i / (float)(n - 1); out[i] = ImVec2(mn.x + t * w, mx.y - (float)((hist[i] - lo) / (hi - lo)) * h); } return true; } static void pfDrawSparkline(ImDrawList* dl, ImVec2 mn, ImVec2 mx, const std::vector& hist, ImU32 col) { std::vector pts; if (!pfProjectSeries(hist, mn, mx, pts)) return; dl->AddPolyline(pts.data(), (int)pts.size(), 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) { std::vector pts; if (!pfProjectSeries(hist, mn, mx, pts)) return; int n = (int)pts.size(); 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); } // 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 IM_COL32(0x6C, 0x7B, 0xFF, 0xFF), // indigo IM_COL32(0x74, 0xC0, 0xFC, 0xFF), // sky IM_COL32(0x3D, 0xD6, 0xF5, 0xFF), // cyan IM_COL32(0x63, 0xE6, 0xBE, 0xFF), // mint IM_COL32(0x2F, 0xB3, 0x80, 0xFF), // emerald IM_COL32(0xA8, 0xE0, 0x5F, 0xFF), // lime IM_COL32(0xB5, 0xB8, 0x4D, 0xFF), // olive IM_COL32(0xF5, 0xC5, 0x42, 0xFF), // gold IM_COL32(0xFF, 0x9F, 0x45, 0xFF), // orange IM_COL32(0xFF, 0x8A, 0x65, 0xFF), // coral IM_COL32(0xE6, 0x49, 0x80, 0xFF), // crimson IM_COL32(0xFF, 0x8B, 0x94, 0xFF), // rose IM_COL32(0xE5, 0x99, 0xF7, 0xFF), // magenta IM_COL32(0x97, 0x75, 0xFA, 0xFF), // violet IM_COL32(0xB1, 0x97, 0xFC, 0xFF), // lavender IM_COL32(0xB0, 0x89, 0x68, 0xFF), // brown IM_COL32(0x8F, 0xA3, 0xB8, 0xFF), // steel }; // 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_pfEdit.sel = index; if (index >= 0 && index < (int)entries.size()) { const auto& src = entries[index]; snprintf(s_pfEdit.label, sizeof(s_pfEdit.label), "%s", src.label.c_str()); s_pfEdit.addrs = src.addresses; s_pfEdit.icon = src.icon; s_pfEdit.color = src.color; s_pfEdit.outlineOpacity = src.outlineOpacity; s_pfEdit.priceBasis = src.priceBasis; s_pfEdit.manualPrice = src.manualPrice; snprintf(s_pfEdit.manualCcy, sizeof(s_pfEdit.manualCcy), "%s", src.manualCurrency.c_str()); s_pfEdit.showDrgx = src.showDrgx; s_pfEdit.showValue = src.showValue; s_pfEdit.show24h = src.show24h; s_pfEdit.showSparkline = src.showSparkline; s_pfEdit.sparkInterval = src.sparklineInterval; } else { s_pfEdit.label[0] = '\0'; s_pfEdit.addrs.clear(); s_pfEdit.icon.clear(); s_pfEdit.color = 0; s_pfEdit.outlineOpacity = 25; s_pfEdit.priceBasis = 0; s_pfEdit.manualPrice = 0.0; snprintf(s_pfEdit.manualCcy, sizeof(s_pfEdit.manualCcy), "USD"); s_pfEdit.showDrgx = true; s_pfEdit.showValue = true; s_pfEdit.show24h = false; s_pfEdit.showSparkline = false; s_pfEdit.sparkInterval = 0; } s_pfEdit.search[0] = '\0'; s_pfEdit.typeFilter = 0; s_pfEdit.fundedOnly = false; } // ---- Portfolio-editor persistence helpers (operate on the s_pfEdit working state) ---- // Build a PortfolioEntry from the working set, preserving off-form fields (grid geometry) from base. static config::Settings::PortfolioEntry pfBuildWorking(const config::Settings::PortfolioEntry& base) { config::Settings::PortfolioEntry e = base; e.label = s_pfEdit.label; e.addresses = s_pfEdit.addrs; e.icon = s_pfEdit.icon; e.color = s_pfEdit.color; e.outlineOpacity = s_pfEdit.outlineOpacity; e.priceBasis = s_pfEdit.priceBasis; e.manualPrice = s_pfEdit.manualPrice; e.manualCurrency = s_pfEdit.manualCcy; e.showDrgx = s_pfEdit.showDrgx; e.showValue = s_pfEdit.showValue; e.show24h = s_pfEdit.show24h; e.showSparkline = s_pfEdit.showSparkline; e.sparklineInterval = s_pfEdit.sparkInterval; return e; } // True when the working set matches a stored entry (i.e. there are no uncommitted edits). static bool pfWorkingMatches(const config::Settings::PortfolioEntry& e) { return e.label == std::string(s_pfEdit.label) && e.addresses == s_pfEdit.addrs && e.icon == s_pfEdit.icon && e.color == s_pfEdit.color && e.outlineOpacity == s_pfEdit.outlineOpacity && e.priceBasis == s_pfEdit.priceBasis && e.manualPrice == s_pfEdit.manualPrice && e.manualCurrency == std::string(s_pfEdit.manualCcy) && e.showDrgx == s_pfEdit.showDrgx && e.showValue == s_pfEdit.showValue && e.show24h == s_pfEdit.show24h && e.showSparkline == s_pfEdit.showSparkline && e.sparklineInterval == s_pfEdit.sparkInterval; } static void pfPersist(config::Settings* settings, const std::vector& entries) { settings->setPortfolioEntries(entries); settings->save(); } // Persist the working group when it is named and actually changed; unnamed drafts are dropped. // New groups default to the active wallet's scope (per-wallet visibility); existing groups keep // whatever scope they already had. static void pfCommitIfNeeded(App* app) { config::Settings* settings = app->settings(); if (s_pfEdit.label[0] == '\0') return; auto entries = settings->getPortfolioEntries(); bool existing = (s_pfEdit.sel >= 0 && s_pfEdit.sel < (int)entries.size()); config::Settings::PortfolioEntry base; if (existing) { base = entries[s_pfEdit.sel]; if (pfWorkingMatches(base)) return; } auto e = pfBuildWorking(base); if (!existing) e.scope = app->activeWalletIdentityHash(); if (existing) entries[s_pfEdit.sel] = e; else { entries.push_back(e); s_pfEdit.sel = (int)entries.size() - 1; } pfPersist(settings, entries); } // ---- Detail-pane section renderers (drawn inside ##pfDetailBody; operate on s_pfEdit) ---- // Appearance: accent-color picker (+ custom), outline-opacity slider, icon grid. static void pfDrawAppearanceSection() { float dp = Layout::dpiScale(); // Color picker: preset swatches in ONE row (overflow hidden) + a custom "+" cell that is // always visible at the right and opens a picker. { float sw = 22.0f * dp, gap = 5.0f * dp; ImGui::TextUnformatted(TR("portfolio_color")); ImDrawList* cdl = ImGui::GetWindowDrawList(); int nColors = (int)(sizeof(kPfPalette) / sizeof(kPfPalette[0])); float availW = ImGui::GetContentRegionAvail().x; ImVec2 rowStart = ImGui::GetCursorScreenPos(); // Reserved "+" slot at the right; inset by a few px so its selection outline // (drawn at radius sw*0.5) stays inside the modal instead of clipping at the edge. float plusX = rowStart.x + std::max(sw, availW - sw - 4.0f * dp); bool isCustom = (s_pfEdit.color != 0u); for (int c = 0; c < nColors; c++) if (s_pfEdit.color == kPfPalette[c]) isCustom = false; bool openCustom = false; // Preset swatches (0 = default/no color, 1..N = palette); stop before the "+" slot. float x = rowStart.x; for (int idx = 0; idx <= nColors; idx++) { if (x + sw > plusX - gap) break; // would reach the "+" -> hide the remaining colors ImVec2 mn(x, rowStart.y); ImVec2 cc(mn.x + sw * 0.5f, mn.y + sw * 0.5f); bool isDefault = (idx == 0); ImU32 col32 = isDefault ? 0u : kPfPalette[idx - 1]; bool sel = isDefault ? (s_pfEdit.color == 0u) : (s_pfEdit.color == col32); 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, col32); 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 + idx); ImGui::SetCursorScreenPos(mn); ImGui::InvisibleButton("##pfcol", ImVec2(sw, sw)); if (ImGui::IsItemClicked()) s_pfEdit.color = isDefault ? 0u : col32; ImGui::PopID(); x += sw + gap; } // Custom "+" cell — always visible at the reserved right slot. { ImVec2 mn(plusX, rowStart.y); 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_pfEdit.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::PushID(2999); ImGui::SetCursorScreenPos(mn); ImGui::InvisibleButton("##pfcustomcol", ImVec2(sw, sw)); if (ImGui::IsItemClicked()) { ImU32 base = s_pfEdit.color ? (ImU32)s_pfEdit.color : IM_COL32(0x4F, 0x9D, 0xFF, 0xFF); s_pfEdit.customRgb[0] = ((base >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f; s_pfEdit.customRgb[1] = ((base >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f; s_pfEdit.customRgb[2] = ((base >> IM_COL32_B_SHIFT) & 0xFF) / 255.0f; openCustom = true; } if (hov) material::Tooltip("%s", TR("portfolio_custom_color")); ImGui::PopID(); } ImGui::SetCursorScreenPos(ImVec2(rowStart.x, rowStart.y + sw)); ImGui::Dummy(ImVec2(availW, 0)); if (openCustom) ImGui::OpenPopup("##pfCustomColor"); if (ImGui::BeginPopup("##pfCustomColor")) { ImGui::ColorPicker3("##pfpick", s_pfEdit.customRgb, ImGuiColorEditFlags_NoSidePreview | ImGuiColorEditFlags_NoAlpha | ImGuiColorEditFlags_NoLabel); s_pfEdit.color = IM_COL32((int)(s_pfEdit.customRgb[0] * 255.0f + 0.5f), (int)(s_pfEdit.customRgb[1] * 255.0f + 0.5f), (int)(s_pfEdit.customRgb[2] * 255.0f + 0.5f), 255); ImGui::EndPopup(); } } // Accent-outline opacity (only meaningful when an accent color is set). ImGui::BeginDisabled(s_pfEdit.color == 0u); ImGui::TextUnformatted(TR("portfolio_outline_opacity")); ImGui::SetNextItemWidth(-1); ImGui::SliderInt("##pfOutlineOpacity", &s_pfEdit.outlineOpacity, 0, 100, "%d%%"); if (s_pfEdit.outlineOpacity < 0) s_pfEdit.outlineOpacity = 0; if (s_pfEdit.outlineOpacity > 100) s_pfEdit.outlineOpacity = 100; ImGui::EndDisabled(); ImGui::Dummy(ImVec2(0, Layout::spacingSm())); // Icon picker: search on the heading line + a 12-per-row grid that scales to width, // smooth-scrolled with the Settings-tab edge fade. { float cellGap = 6.0f * dp; ImFont* gIconFont = Type().iconXL(); ImGui::TextUnformatted(TR("portfolio_icon")); ImGui::SameLine(); { float searchW = std::min(ImGui::GetContentRegionAvail().x, 200.0f * dp); float off = ImGui::GetContentRegionAvail().x - searchW; // right-align the field if (off > 0.0f) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + off); ImGui::SetNextItemWidth(searchW); ImGui::InputTextWithHint("##pfIconSearch", TR("portfolio_search_icons"), s_pfEdit.iconSearch, sizeof(s_pfEdit.iconSearch)); } // Filtered icon list (-1 = "None", shown only when not searching). std::string isearch = s_pfEdit.iconSearch; std::vector iconIdx; if (isearch.empty()) iconIdx.push_back(-1); int nIcons = material::project_icons::walletIconCount(); for (int i = 0; i < nIcons; i++) { if (!isearch.empty() && !util::containsIgnoreCase(material::project_icons::walletIconName(i), isearch)) continue; iconIdx.push_back(i); } ImDrawList* gdl = material::BeginFadeScrollChild("##pfIconGrid", s_pfEdit.iconFade, ImVec2(Layout::spacingSm(), Layout::spacingSm()), dp); const int cols = 12; float availW = ImGui::GetContentRegionAvail().x; float cell = std::max(14.0f * dp, (availW - cellGap * (cols - 1)) / (float)cols); float gIconSz = cell * 0.5f; float gridW = cell * cols + cellGap * (cols - 1); float indent = std::max(0.0f, (availW - gridW) * 0.5f); int gcol = 0; for (size_t n = 0; n < iconIdx.size(); n++) { int idx = iconIdx[n]; if (gcol == 0) { if (indent > 0.0f) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + indent); } else 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 = (idx < 0); const char* nm2 = isNone ? "" : material::project_icons::walletIconName(idx); bool sel = isNone ? s_pfEdit.icon.empty() : (s_pfEdit.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, gIconFont, gIconSz); } ImGui::PushID(idx + 2); ImGui::InvisibleButton("##pfic", ImVec2(cell, cell)); if (ImGui::IsItemClicked()) s_pfEdit.icon = isNone ? std::string() : std::string(nm2); if (hov) material::Tooltip("%s", isNone ? TR("portfolio_no_icon") : nm2); ImGui::PopID(); gcol = (gcol + 1) % cols; } material::EndFadeScrollChild(s_pfEdit.iconFade, gdl); } } // Price: basis radios, optional manual price/currency, shown-field toggles, sparkline interval. static void pfDrawPriceSection() { // Price basis — radio group (all options visible; no dropdown). const char* basisItems[4] = { TR("portfolio_price_usd"), TR("portfolio_price_btc"), TR("portfolio_price_drgx"), TR("portfolio_price_manual") }; for (int b = 0; b < 4; b++) { if (b) ImGui::SameLine(); ImGui::RadioButton(basisItems[b], &s_pfEdit.priceBasis, b); } if (s_pfEdit.priceBasis == 3) { // Manual: price-per-DRGX + currency label float half = (ImGui::GetContentRegionAvail().x - Layout::spacingSm()) * 0.62f; ImGui::SetNextItemWidth(half); ImGui::InputDouble("##pfManPrice", &s_pfEdit.manualPrice, 0.0, 0.0, "%.6f"); if (s_pfEdit.manualPrice < 0.0) s_pfEdit.manualPrice = 0.0; ImGui::SameLine(); ImGui::SetNextItemWidth(-1); ImGui::InputTextWithHint("##pfManCcy", TR("portfolio_currency"), s_pfEdit.manualCcy, sizeof(s_pfEdit.manualCcy)); } ImGui::Dummy(ImVec2(0, Layout::spacingSm())); // Shown fields. ImGui::TextUnformatted(TR("portfolio_show")); ImGui::Checkbox(DRAGONX_TICKER "##pfShowDrgx", &s_pfEdit.showDrgx); ImGui::SameLine(); ImGui::BeginDisabled(s_pfEdit.priceBasis == 2); // DRGX-only -> no value field ImGui::Checkbox(TR("portfolio_show_value"), &s_pfEdit.showValue); ImGui::EndDisabled(); ImGui::SameLine(); // 24h + sparkline toggles share one line (both live-market only). ImGui::BeginDisabled(!(s_pfEdit.priceBasis == 0 || s_pfEdit.priceBasis == 1)); ImGui::Checkbox(TR("portfolio_show_24h"), &s_pfEdit.show24h); ImGui::SameLine(); ImGui::Checkbox(TR("portfolio_show_sparkline"), &s_pfEdit.showSparkline); ImGui::EndDisabled(); ImGui::Dummy(ImVec2(0, Layout::spacingSm())); // Sparkline interval — radio group (enabled only when the sparkline is shown). ImGui::BeginDisabled(!s_pfEdit.showSparkline || !(s_pfEdit.priceBasis == 0 || s_pfEdit.priceBasis == 1)); const char* spItems[5] = { TR("portfolio_spark_min"), TR("portfolio_spark_hour"), TR("portfolio_spark_day"), TR("portfolio_spark_week"), TR("portfolio_spark_month") }; for (int s = 0; s < 5; s++) { if (s) ImGui::SameLine(); ImGui::RadioButton(spItems[s], &s_pfEdit.sparkInterval, s); } ImGui::EndDisabled(); } // Addresses: selection count, search, type-filter/select row, and the pickable address list. static void pfDrawAddressSection(App* app) { float dp = Layout::dpiScale(); const auto& state = app->getWalletState(); ImFont* body2f = Type().body2(); ImFont* capF = Type().caption(); ImFont* iconFont = Type().iconSmall(); float iconFsz = iconFont->LegacySize; { char selc[48]; snprintf(selc, sizeof(selc), TR("portfolio_addresses_sel"), (int)s_pfEdit.addrs.size()); Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), selc); } ImGui::SetNextItemWidth(-1); ImGui::InputTextWithHint("##pfSearch", TR("portfolio_search"), s_pfEdit.search, sizeof(s_pfEdit.search)); ImGui::Dummy(ImVec2(0, Layout::spacingXs())); // Filter + selection on one row: type-filter segmented (left) + Select all / Clear / // Funded (right). Select-all is deferred so it applies to the freshly-filtered set below. bool selAllClicked = false; { const char* flabels[3] = { TR("portfolio_select_all"), TR("shielded"), TR("transparent") }; const char* selLbl = TR("portfolio_select_shown"); const char* clrLbl = TR("portfolio_clear_sel"); ImGuiStyle& st = ImGui::GetStyle(); ImVec2 pillPad(Layout::spacingLg(), Layout::spacingSm()); // roomier text padding float defFrameH = ImGui::GetFrameHeight(); float fundedW = defFrameH + st.ItemInnerSpacing.x + ImGui::CalcTextSize(TR("portfolio_funded")).x; float pillH = ImGui::GetTextLineHeight() + pillPad.y * 2.0f; float selW = ImGui::CalcTextSize(selLbl).x + pillPad.x * 2.0f; float clrW = ImGui::CalcTextSize(clrLbl).x + pillPad.x * 2.0f; float groupW = selW + clrW + fundedW + st.ItemSpacing.x * 2.0f; float segH = 26.0f * dp; float rowW = ImGui::GetContentRegionAvail().x; float segW = std::min(360.0f * dp, std::max(160.0f * dp, rowW - groupW - Layout::spacingMd())); float rowH = std::max(segH, pillH); ImVec2 rowStart = ImGui::GetCursorScreenPos(); // Segmented type filter (left), vertically centered in the row. float segY = rowStart.y + (rowH - segH) * 0.5f; int tclk = material::SegmentedControl(ImGui::GetWindowDrawList(), ImVec2(rowStart.x, segY), segW, segH, flabels, 3, s_pfEdit.typeFilter, capF, "##pftf", dp); if (tclk >= 0) s_pfEdit.typeFilter = tclk; // Select all / Clear (rounded pills) + Funded toggle (right), vertically centered. float grpX = rowStart.x + std::max(0.0f, rowW - groupW); ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 12.0f * dp); ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, pillPad); ImGui::SetCursorScreenPos(ImVec2(grpX, rowStart.y + (rowH - pillH) * 0.5f)); if (material::TactileButton(selLbl)) selAllClicked = true; ImGui::SameLine(); if (material::TactileButton(clrLbl)) s_pfEdit.addrs.clear(); ImGui::PopStyleVar(2); ImGui::SameLine(); ImGui::SetCursorScreenPos(ImVec2(ImGui::GetCursorScreenPos().x, rowStart.y + (rowH - defFrameH) * 0.5f)); // center vs. pills ImGui::Checkbox(TR("portfolio_funded"), &s_pfEdit.fundedOnly); ImGui::SetCursorScreenPos(ImVec2(rowStart.x, rowStart.y + rowH)); ImGui::Dummy(ImVec2(rowW, 0)); } ImGui::Dummy(ImVec2(0, Layout::spacingXs())); std::string search = s_pfEdit.search; std::vector filtered; for (const auto& a : state.addresses) { if (s_pfEdit.typeFilter == 1 && a.type != "shielded") continue; if (s_pfEdit.typeFilter == 2 && a.type != "transparent") continue; if (s_pfEdit.fundedOnly && 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_pfEdit.addrs, x->address); bool sy = data::PortfolioEntryContains(s_pfEdit.addrs, y->address); if (sx != sy) return sx; return x->balance > y->balance; }); if (selAllClicked) for (const AddressInfo* a : filtered) data::PortfolioEntryAdd(s_pfEdit.addrs, a->address); ImGui::Dummy(ImVec2(0, Layout::spacingXs())); ImDrawList* ldl = material::BeginFadeScrollChild("##pfAddrList", s_pfEdit.addrFade, ImVec2(Layout::spacingMd(), Layout::spacingSm()), dp); 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_pfEdit.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(); 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(); bool isZ = (a.type == "shielded"); const char* chip = isZ ? "Z" : "T"; ImU32 chipCol = isZ ? Success() : Warning(); ImVec2 chipPos(rx, rcy - capF->LegacySize * 0.5f - 2.0f); float chipW = material::DrawPill(ldl, chipPos, chip, capF, chipCol, WithAlpha(chipCol, 40), 0, ImVec2(4.0f * dp, 2.0f), 4.0f * dp).x; rx += chipW + Layout::spacingSm(); 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(); } 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); ImGui::PushID(a.address.c_str()); ImGui::InvisibleButton("##pfrow", ImVec2(lw, rowH)); if (ImGui::IsItemClicked()) { if (inSet) data::PortfolioEntryRemove(s_pfEdit.addrs, a.address); else data::PortfolioEntryAdd(s_pfEdit.addrs, a.address); } ImGui::PopID(); } material::EndFadeScrollChild(s_pfEdit.addrFade, ldl); } // 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_pfEdit.open) return; config::Settings* settings = app->settings(); const auto& state = app->getWalletState(); float dp = Layout::dpiScale(); ImFont* body2f = Type().body2(); ImFont* capF = Type().caption(); ImFont* sub1f = Type().subtitle1(); ImFont* iconFont = Type().iconSmall(); // Persistence helpers are file-static (pfBuildWorking / pfWorkingMatches / pfCommitIfNeeded). // ---------------- Full-window blur overlay (shared framework) ---------------- // Backdrop + capture-once, floating card, plain heading, outside-click dismiss and effect // suppression are owned by material::BeginOverlayDialog (BlurFloat). Esc still closes here; // Esc/outside-click discard (only Close commits); LatchBlurOverlayActive (App::render) handles // the acrylic re-capture on close. material::OverlayDialogSpec ov; ov.title = TR("portfolio_manage_title"); ov.p_open = &s_pfEdit.open; ov.style = material::OverlayStyle::BlurFloat; ov.cardWidth = 1120.0f; ov.cardHeight = 760.0f; ov.idSuffix = "pf"; if (!material::BeginOverlayDialog(ov)) return; if (ImGui::IsKeyPressed(ImGuiKey_Escape)) s_pfEdit.open = false; { // clamp/validate the selection against the current entry list const auto& entriesRO = settings->getPortfolioEntries(); if (s_pfEdit.sel >= (int)entriesRO.size()) s_pfEdit.sel = entriesRO.empty() ? -1 : 0; } float footerH = 56.0f * dp; // room for the 40px buttons + separator so they aren't clipped float bodyH = std::max(120.0f, ImGui::GetContentRegionAvail().y - footerH - Layout::spacingSm()); float contentW = ImGui::GetContentRegionAvail().x; float gap = Layout::spacingLg(); float masterW = std::min(std::max(contentW * 0.28f, 240.0f * dp), 320.0f * dp); float detailW = contentW - masterW - gap; // Does the selected group have uncommitted edits? (Only the selected group can differ from the // stored copy — others were committed on deselect.) Drives the master-list "unsaved" dot and the // detail-pane Revert/Save enable state. bool selDirty = false; { const auto& es = settings->getPortfolioEntries(); if (s_pfEdit.sel >= 0 && s_pfEdit.sel < (int)es.size()) selDirty = !pfWorkingMatches(es[s_pfEdit.sel]); } // ================= MASTER: scrollable group list + a pinned Add button below it ================= float addH = 34.0f * dp; ImGui::BeginGroup(); { const auto& entries = settings->getPortfolioEntries(); ImGui::BeginChild("##pfMasterList", ImVec2(masterW, std::max(48.0f, bodyH - addH - Layout::spacingSm())), false); { ImDrawList* mdl = ImGui::GetWindowDrawList(); if (entries.empty()) Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_entries")); float rowH = 46.0f * dp; float delSlot = 36.0f * dp; // reserved trailing space for the delete icon + margin int clickedSel = -999, delRow = -1; // deferred: don't mutate `entries` mid-loop for (int i = 0; i < (int)entries.size(); i++) { ImGui::PushID(i); const auto& en = entries[i]; bool selRow = (s_pfEdit.sel == i); ImVec2 rmn = ImGui::GetCursorScreenPos(); if (ImGui::Selectable("##pfgrp", selRow, ImGuiSelectableFlags_SpanAvailWidth, ImVec2(0, rowH))) { if (i != s_pfEdit.sel) clickedSel = i; } ImVec2 rmx(rmn.x + ImGui::GetItemRectSize().x, rmn.y + rowH); bool rowHov = ImGui::IsItemHovered(); ImU32 accent = en.color ? (ImU32)en.color : WithAlpha(OnSurface(), 60); mdl->AddRectFilled(ImVec2(rmn.x, rmn.y + 4.0f * dp), ImVec2(rmn.x + 3.0f * dp, rmx.y - 4.0f * dp), accent, 1.5f * dp); float ix = rmn.x + Layout::spacingSm() + 4.0f * dp; if (!en.icon.empty()) { material::project_icons::drawByName(mdl, en.icon, ImVec2(ix + sub1f->LegacySize * 0.5f, rmn.y + rowH * 0.5f), en.color ? (ImU32)en.color : OnSurfaceMedium(), iconFont, sub1f->LegacySize); ix += sub1f->LegacySize + Layout::spacingSm(); } // Unsaved-changes dot: shown on the selected row while its edits aren't committed. if (selRow && selDirty) { float dr = 3.5f * dp; mdl->AddCircleFilled(ImVec2(ix + dr, rmn.y + rowH * 0.28f + body2f->LegacySize * 0.5f), dr, Warning()); ix += dr * 2.0f + Layout::spacingXs(); } float txtRight = rmx.x - delSlot; // reserve the trailing delete-icon slot std::string nm = en.label.empty() ? std::string(TR("portfolio_new_entry")) : en.label; while (nm.size() > 1 && body2f->CalcTextSizeA(body2f->LegacySize, FLT_MAX, 0, nm.c_str()).x > (txtRight - ix)) nm.pop_back(); mdl->AddText(body2f, body2f->LegacySize, ImVec2(ix, rmn.y + rowH * 0.28f), en.label.empty() ? OnSurfaceDisabled() : OnSurface(), nm.c_str()); double bal = data::SumPortfolioBalance(en.addresses, state.addresses); char vl[80]; snprintf(vl, sizeof(vl), "%.4f %s \xC2\xB7 %d", bal, DRAGONX_TICKER, (int)en.addresses.size()); mdl->AddText(capF, capF->LegacySize, ImVec2(ix, rmn.y + rowH * 0.56f), OnSurfaceMedium(), vl); // Per-row delete icon (larger, inset from the edge). Hit-tested manually so it is // NOT an overlapping ImGui item over the row Selectable (which asserted on hover). if (rowHov || selRow) { ImFont* delFont = Type().iconMed(); float ds = delFont->LegacySize; ImVec2 dc(rmx.x - Layout::spacingMd() - ds * 0.5f, rmn.y + rowH * 0.5f); ImVec2 dmn(dc.x - ds * 0.65f, dc.y - ds * 0.65f), dmx(dc.x + ds * 0.65f, dc.y + ds * 0.65f); bool dhov = ImGui::IsMouseHoveringRect(dmn, dmx); if (dhov) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) delRow = i; } ImVec2 isz = delFont->CalcTextSizeA(ds, FLT_MAX, 0, ICON_MD_DELETE_OUTLINE); mdl->AddText(delFont, ds, ImVec2(dc.x - isz.x * 0.5f, dc.y - isz.y * 0.5f), dhov ? Error() : OnSurfaceMedium(), ICON_MD_DELETE_OUTLINE); } ImGui::PopID(); } if (delRow >= 0) { // Operates on the stored entries; any uncommitted working edits are discarded. auto es = settings->getPortfolioEntries(); if (delRow < (int)es.size()) { es.erase(es.begin() + delRow); pfPersist(settings, es); if (es.empty()) s_pfEdit.sel = -1; else if (delRow < s_pfEdit.sel) s_pfEdit.sel -= 1; else if (delRow == s_pfEdit.sel) s_pfEdit.sel = std::min(delRow, (int)es.size() - 1); PortfolioBeginEdit(app, s_pfEdit.sel); } } else if (clickedSel != -999) { PortfolioBeginEdit(app, clickedSel); // switching groups discards uncommitted edits } } ImGui::EndChild(); ImGui::Dummy(ImVec2(0, Layout::spacingSm())); // Add immediately creates a persisted "Untitled" group and selects it for editing. if (material::TactileButton(TR("portfolio_add_entry"), ImVec2(masterW, addH))) { // Adding switches to a new group; uncommitted edits to the current one are discarded. auto es = settings->getPortfolioEntries(); config::Settings::PortfolioEntry ne; ne.label = TR("portfolio_untitled"); ne.scope = app->activeWalletIdentityHash(); // new group defaults to this wallet es.push_back(ne); pfPersist(settings, es); s_pfEdit.sel = (int)es.size() - 1; PortfolioBeginEdit(app, s_pfEdit.sel); } } ImGui::EndGroup(); ImGui::SameLine(0, gap); // ================= DETAIL: options for the selected group ================= ImGui::BeginChild("##pfDetail", ImVec2(detailW, bodyH), false); if (s_pfEdit.sel == -1) { const char* msg = TR("portfolio_detail_empty"); ImVec2 av = ImGui::GetContentRegionAvail(); ImVec2 ts = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, msg); ImGui::SetCursorPos(ImVec2(std::max(0.0f, (av.x - ts.x) * 0.5f), av.y * 0.42f)); Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), msg); } else { // Body fills the detail column minus a bottom row reserved for the group-scoped // Revert/Save buttons (kept inside this container, below the modal-level Close in the footer). float pfBtnRowH = addH + Layout::spacingMd(); ImGui::BeginChild("##pfDetailBody", ImVec2(0, std::max(80.0f * dp, ImGui::GetContentRegionAvail().y - pfBtnRowH)), false, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); // ---- Header: live preview + label (always visible above the accordion) ---- { 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_pfEdit.color ? (ImU32)s_pfEdit.color : 0; GlassPanelSpec g; g.rounding = 10.0f; g.fillAlpha = 22; g.borderAlpha = 40; DrawGlassPanel(pdl, pMin, pMax, g); if (accent) { int oa = (int)(std::max(0, std::min(100, s_pfEdit.outlineOpacity)) * 2.55f + 0.5f); pdl->AddRect(pMin, pMax, WithAlpha(accent, oa), 10.0f, 0, 2.0f); } if (s_pfEdit.showSparkline && (s_pfEdit.priceBasis == 0 || s_pfEdit.priceBasis == 1)) { std::vector h = data::sparklineSeries(state.market, s_pfEdit.sparkInterval); if (h.size() >= 2) { ImU32 spCol = WithAlpha(h.back() >= h.front() ? Success() : Error(), 80); ImVec2 spMin(pMin.x + ppad, 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; if (!s_pfEdit.icon.empty()) { material::project_icons::drawByName(pdl, s_pfEdit.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_pfEdit.label[0] ? s_pfEdit.label : TR("portfolio_group_name"); pdl->AddText(sub1f, sub1f->LegacySize, ImVec2(nameX, rowY), s_pfEdit.label[0] ? OnSurface() : OnSurfaceDisabled(), nm); double pbal = data::SumPortfolioBalance(s_pfEdit.addrs, state.addresses); config::Settings::PortfolioEntry tmp; tmp.priceBasis = s_pfEdit.priceBasis; tmp.manualPrice = s_pfEdit.manualPrice; tmp.manualCurrency = s_pfEdit.manualCcy; tmp.showDrgx = s_pfEdit.showDrgx; tmp.showValue = s_pfEdit.showValue; tmp.show24h = s_pfEdit.show24h; 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::spacingSm())); ImGui::SetNextItemWidth(-1); ImGui::InputTextWithHint("##pfLabel", TR("portfolio_group_name"), s_pfEdit.label, sizeof(s_pfEdit.label)); ImGui::Dummy(ImVec2(0, Layout::spacingSm())); // Segmented control switches the detail section (no collapsible dropdowns) — one at a time. { const char* secs[3] = { TR("portfolio_appearance"), TR("portfolio_price"), TR("portfolio_addresses_hdr") }; float segH = 30.0f * dp; float segW = ImGui::GetContentRegionAvail().x; ImVec2 sMin = ImGui::GetCursorScreenPos(); int clk = material::SegmentedControl(ImGui::GetWindowDrawList(), sMin, segW, segH, secs, 3, s_pfEdit.section, body2f, "##pfseg", dp); if (clk >= 0) s_pfEdit.section = clk; ImGui::SetCursorScreenPos(ImVec2(sMin.x, sMin.y + segH)); ImGui::Dummy(ImVec2(segW, 0)); } ImGui::Dummy(ImVec2(0, Layout::spacingMd())); // ---- Detail sections (one shown at a time via the segmented control above) ---- if (s_pfEdit.section == 0) pfDrawAppearanceSection(); else if (s_pfEdit.section == 1) pfDrawPriceSection(); else if (s_pfEdit.section == 2) pfDrawAddressSection(app); ImGui::EndChild(); // ##pfDetailBody // Group-scoped actions (Add-entry height → lower hierarchy than the modal Close in the footer). { float bw = 104.0f * dp, sp = Layout::spacingSm(); float grp = bw * 2 + sp; material::RightAlignX(grp); ImGui::SetCursorPosY(ImGui::GetCursorPosY() + Layout::spacingSm()); ImGui::BeginDisabled(!selDirty); if (material::TactileButton(TR("portfolio_revert"), ImVec2(bw, addH))) PortfolioBeginEdit(app, s_pfEdit.sel); // reload the working set from the stored entry ImGui::SameLine(0, sp); ImGui::BeginDisabled(s_pfEdit.label[0] == '\0'); if (material::TactileButton(TR("portfolio_save"), ImVec2(bw, addH))) pfCommitIfNeeded(app); ImGui::EndDisabled(); ImGui::EndDisabled(); } } ImGui::EndChild(); // ##pfDetail // ================= FOOTER: close (modal-level action, larger → higher hierarchy) ================= { float bh = 40.0f * dp, bw = 120.0f * dp; material::RightAlignX(bw); if (material::TactileButton(TR("portfolio_close"), ImVec2(bw, bh))) { pfCommitIfNeeded(app); s_pfEdit.open = false; } } material::EndOverlayDialog(); } bool PortfolioEditorActive() { return s_pfEdit.open; } // ---- Portfolio ROW layouts (replace the drag/resize grid) -------------------------------------- // Draw one group as a full-width row card in [rowMin,rowMax]; style 0=single-line, 1=two-line, // 2=value-hero. Keeps every field (icon, accent, label, DRGX, value, 24h %, sparkline) — hidden // fields per the entry's config simply don't render. Pure drawing into dl. static void pfDrawRow(ImDrawList* dl, ImVec2 rowMin, ImVec2 rowMax, const config::Settings::PortfolioEntry& e, const WalletState& state, const MarketInfo& market, int style, float dp, ImFont* sub1, ImFont* body2, ImFont* capFont, bool hov) { double bal = data::SumPortfolioBalance(e.addresses, state.addresses); ImU32 accent = e.color ? (ImU32)e.color : 0; float stripW = (style == 2 ? 5.0f : 3.0f) * dp; GlassPanelSpec g; g.rounding = 10.0f; g.fillAlpha = hov ? 34 : 22; g.borderAlpha = 40; DrawGlassPanel(dl, rowMin, rowMax, g); if (accent) { dl->AddRectFilled(rowMin, ImVec2(rowMin.x + stripW, rowMax.y), accent, 10.0f, ImDrawFlags_RoundCornersLeft); int oa = (int)(std::max(0, std::min(100, e.outlineOpacity)) * 2.55f + 0.5f); dl->AddRect(rowMin, rowMax, WithAlpha(accent, oa), 10.0f, 0, hov ? 2.0f : 1.5f); } else if (hov) { dl->AddRect(rowMin, rowMax, WithAlpha(OnSurface(), 70), 10.0f, 0, 1.0f); } auto tw = [](ImFont* f, const std::string& s){ return f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, s.c_str()).x; }; auto fit = [&](std::string s, ImFont* f, float maxW){ bool t=false; while (s.size()>1 && tw(f,s)>maxW){ s.pop_back(); t=true; } if (t) s += "\xE2\x80\xA6"; return s; }; char vb[80]; std::string valStr; bool hasValue=false; if (e.priceBasis==0 && market.price_usd>0){ snprintf(vb,sizeof(vb),"$%.2f",bal*market.price_usd); valStr=vb; hasValue=true; } else if (e.priceBasis==1 && market.price_btc>0){ snprintf(vb,sizeof(vb),"%.8f BTC",bal*market.price_btc); valStr=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()); valStr=vb; hasValue=true; } char db[48]; snprintf(db,sizeof(db),"%.4f %s",bal,DRAGONX_TICKER); std::string drgxStr = e.showDrgx ? std::string(db) : std::string(); std::string valueStr = (e.showValue && hasValue) ? valStr : std::string(); bool showChange = e.show24h && (e.priceBasis==0||e.priceBasis==1) && market.change_24h!=0.0; std::string chgStr; if (showChange){ char cb[32]; snprintf(cb,sizeof(cb),"%+.2f%%",market.change_24h); chgStr=cb; } ImU32 chgCol = market.change_24h>=0 ? Success() : Error(); bool showSpark = e.showSparkline && (e.priceBasis==0||e.priceBasis==1); std::vector spark; if (showSpark){ spark=data::sparklineSeries(market,e.sparklineInterval); if (spark.size()<2) showSpark=false; } ImU32 sparkCol = (!spark.empty() && spark.back()>=spark.front()) ? Success() : Error(); const float padX = Layout::spacingMd(), padY = Layout::spacingSm(), sp = Layout::spacingMd(); float left = rowMin.x + stripW + padX; float right = rowMax.x - padX; float midY = (rowMin.y + rowMax.y) * 0.5f; float iconSz = sub1->LegacySize; if (style == 0) { // Single line — icon + label (left); drgx / value / 24h / sparkline right-aligned. float y = midY - sub1->LegacySize * 0.5f, x = left, rx = right; if (!e.icon.empty()) { material::project_icons::drawByName(dl, e.icon.c_str(), ImVec2(x+iconSz*0.5f, midY), accent?accent:OnSurfaceMedium(), Type().iconSmall(), iconSz); x += iconSz + Layout::spacingSm(); } if (showSpark){ float sw=110*dp; pfDrawSparkline(dl, ImVec2(rx-sw, rowMin.y+padY), ImVec2(rx, rowMax.y-padY), spark, sparkCol); rx -= sw + sp; } if (!chgStr.empty()){ float w=tw(capFont,chgStr); dl->AddText(capFont,capFont->LegacySize,ImVec2(rx-w, midY-capFont->LegacySize*0.5f), chgCol, chgStr.c_str()); rx -= w + sp; } if (!valueStr.empty()){ float w=tw(sub1,valueStr); dl->AddText(sub1,sub1->LegacySize,ImVec2(rx-w,y),OnSurface(),valueStr.c_str()); rx -= w + sp; } if (!drgxStr.empty()){ float w=tw(capFont,drgxStr); dl->AddText(capFont,capFont->LegacySize,ImVec2(rx-w, midY-capFont->LegacySize*0.5f),OnSurfaceMedium(),drgxStr.c_str()); rx -= w + sp; } float lblMaxW = std::max(24.0f*dp, rx - x - Layout::spacingSm()); dl->AddText(sub1,sub1->LegacySize,ImVec2(x,y),OnSurface(),fit(e.label,sub1,lblMaxW).c_str()); } else if (style == 1) { // Two lines — label + (24h, sparkline) on top; DRGX + value below. float topY = rowMin.y + padY, botY = rowMax.y - padY - capFont->LegacySize, x = left, rx = right; if (!e.icon.empty()) { material::project_icons::drawByName(dl, e.icon.c_str(), ImVec2(x+iconSz*0.5f, topY+iconSz*0.5f), accent?accent:OnSurfaceMedium(), Type().iconSmall(), iconSz); x += iconSz + Layout::spacingSm(); } if (showSpark){ float sw=130*dp; pfDrawSparkline(dl, ImVec2(rx-sw, topY), ImVec2(rx, midY), spark, sparkCol); rx -= sw + sp; } if (!chgStr.empty()){ float w=tw(capFont,chgStr); dl->AddText(capFont,capFont->LegacySize,ImVec2(rx-w,topY),chgCol,chgStr.c_str()); rx -= w + sp; } dl->AddText(sub1,sub1->LegacySize,ImVec2(x,topY),OnSurface(),fit(e.label,sub1,std::max(24.0f*dp, rx-x)).c_str()); if (!drgxStr.empty()) dl->AddText(capFont,capFont->LegacySize,ImVec2(x,botY),OnSurfaceMedium(),drgxStr.c_str()); if (!valueStr.empty()){ float w=tw(body2,valueStr); dl->AddText(body2,body2->LegacySize,ImVec2(right-w, botY-(body2->LegacySize-capFont->LegacySize)),OnSurface(),valueStr.c_str()); } } else { // Value-hero — label; big value; DRGX · 24h; sparkline fills the right. float topY = rowMin.y + padY, x = left; if (!e.icon.empty()) { material::project_icons::drawByName(dl, e.icon.c_str(), ImVec2(x+iconSz*0.5f, topY+capFont->LegacySize*0.5f), accent?accent:OnSurfaceMedium(), Type().iconSmall(), capFont->LegacySize); x += capFont->LegacySize + Layout::spacingSm(); } dl->AddText(capFont,capFont->LegacySize,ImVec2(x,topY),OnSurfaceMedium(),fit(e.label,capFont,right-x-140*dp).c_str()); float valY = topY + capFont->LegacySize + Layout::spacingXs(); std::string hero = !valueStr.empty()? valueStr : drgxStr; DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(left, valY), accent?accent:Success(), hero.c_str()); float botY = valY + sub1->LegacySize + Layout::spacingXs(); std::string sub = (!valueStr.empty() && !drgxStr.empty()) ? drgxStr : std::string(); if (!sub.empty()) dl->AddText(capFont,capFont->LegacySize,ImVec2(left,botY),OnSurfaceDisabled(),sub.c_str()); if (!chgStr.empty()){ float sw = sub.empty()?0.0f:(tw(capFont,sub)+tw(capFont," \xC2\xB7 ")); if(!sub.empty()) dl->AddText(capFont,capFont->LegacySize,ImVec2(left+tw(capFont,sub),botY),OnSurfaceDisabled()," \xC2\xB7 "); dl->AddText(capFont,capFont->LegacySize,ImVec2(left+sw,botY),chgCol,chgStr.c_str()); } if (showSpark){ float sw=(right-left)*0.42f; pfDrawSparklineFilled(dl, ImVec2(right-sw, valY-2*dp), ImVec2(right, rowMax.y-padY), spark, sparkCol); } } } // Pair selector: every trading pair across all exchanges as flat wrapping chips; clicking one // switches the active exchange/pair (persisted) and refreshes market data. Followed by attribution. static void mktDrawPairSelector(App* app, const std::vector& registry, float availWidth) { auto& S = schema::UI(); const auto& market = app->getWalletState().market; ImDrawList* dl = ImGui::GetWindowDrawList(); ImFont* capFont = Type().caption(); ImFont* ovFont = Type().overline(); float gap = Layout::cardGap(); char buf[128]; 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_mkt.exchangeIdx && pr == s_mkt.pairIdx); 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_mkt.exchangeIdx || pr != s_mkt.pairIdx) { s_mkt.exchangeIdx = ex; s_mkt.pairIdx = pr; 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), TR("market_updated"), market.last_updated.c_str()); Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf); } ImGui::Dummy(ImVec2(0, gap)); } } // Shared per-frame context for the three coupled RenderMarketTab sections (hero/chart/portfolio): // the precomputed geometry + chart series. Cheap locals (dl, fonts, dp, state, market, schema) are // recomputed inside each helper. struct MktCtx { App* app; const data::ExchangeInfo* currentExchange; float chartH, heroHeaderH, pfSummaryH, portfolioH, ratioBarH; const std::vector* chartTimes; std::time_t nowSec; bool chartUp; double periodChangePct; std::string periodSuffix; }; // Combined hero card: draws the ONE glass panel spanning the price header AND the chart below it, // then the price / ticker / period-change badge / trade button (or a loading/error state). static void mktDrawPriceHero(const MktCtx& cx) { App* app = cx.app; const auto& market = app->getWalletState().market; const auto& currentExchange = *cx.currentExchange; ImDrawList* dl = ImGui::GetWindowDrawList(); float availWidth = ImGui::GetContentRegionAvail().x; float pad = Layout::cardInnerPadding(); ImFont* capFont = Type().caption(); ImFont* sub1 = Type().subtitle1(); ImFont* body2 = Type().body2(); GlassPanelSpec glassSpec; glassSpec.rounding = Layout::glassRounding(); float chartH = cx.chartH, heroHeaderH = cx.heroHeaderH; bool chartUp = cx.chartUp; double periodChangePct = cx.periodChangePct; const std::string& periodSuffix = cx.periodSuffix; char buf[128]; 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 cx0 = 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(cx0, 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(cx0 + priceW + Layout::spacingSm(), cy + (h3->LegacySize - body2->LegacySize)), OnSurfaceMedium(), DRAGONX_TICKER); // Change badge — over the displayed chart period (or 24h for Live) — right of the ticker. float tickerW = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, DRAGONX_TICKER).x; float badgeX = cx0 + priceW + Layout::spacingSm() + tickerW + Layout::spacingMd(); ImU32 chgCol = chartUp ? Success() : Error(); snprintf(buf, sizeof(buf), "%s%.2f%% %s", chartUp ? "+" : "", periodChangePct, periodSuffix.c_str()); 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 = chartUp ? 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); // (Divider is drawn in the chart block, below the interval buttons.) // ---- TRADE BUTTON (top-right of card) ---- if (!currentExchange.pairs.empty()) { const char* pairName = currentExchange.pairs[s_mkt.pairIdx].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_mkt.pairIdx].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(cx0, cy + 10), OnSurfaceDisabled(), status); if (!market.price_loading && !market.price_error.empty()) { std::string errorText = market.price_error; float maxErrorW = cardMax.x - cx0 - 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(cx0, 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 hero's shared glass panel: interval strip + 24h stats + refresh, // then the plotted curve (grid, area fill, hi/lo + time labels, hover crosshair/tooltip) or an // empty state. Reads the precomputed series (s_mkt.history / cx.chartTimes). static void mktDrawPriceChart(const MktCtx& cx) { App* app = cx.app; const auto& market = app->getWalletState().market; auto& S = schema::UI(); ImDrawList* dl = ImGui::GetWindowDrawList(); float availWidth = ImGui::GetContentRegionAvail().x; float pad = Layout::cardInnerPadding(); float hs = Layout::hScale(availWidth); float mktDp = Layout::dpiScale(); ImFont* capFont = Type().caption(); ImFont* sub1 = Type().subtitle1(); float chartH = cx.chartH; const auto& chartTimes = *cx.chartTimes; std::time_t nowSec = cx.nowSec; bool chartUp = cx.chartUp; char buf[128]; // The chart series (s_mkt.history) + timestamps (chartTimes) were computed in the // precompute above. Historical intervals come from CoinGecko market_chart; "Live" is the // in-session buffer. Until two points exist, the empty-state below is shown. // The chart shares the combined hero+chart glass panel drawn in PRICE SUMMARY above. ImVec2 chartMin = ImGui::GetCursorScreenPos(); ImVec2 chartMax(chartMin.x + availWidth, chartMin.y + chartH); float chartPad = pad; float pillH = capFont->LegacySize + Layout::spacingXs() * 2.0f; // Buttons sit near the top of the chart area, with a gap below them before the plot. float stripTop = chartMin.y + Layout::spacingXs(); // ---- Top strip: interval buttons (left) + 24H volume / market cap + refresh (right) ---- { float rowTop = stripTop; float textY = rowTop + (pillH - capFont->LegacySize) * 0.5f; // Interval buttons (left). Live = in-session buffer; the rest are historical. const struct { const char* lbl; int iv; } kIvs[] = { { TR("market_iv_live"), 0 }, { TR("market_iv_1h"), 1 }, { TR("market_iv_1d"), 2 }, { TR("market_iv_1w"), 3 }, { TR("market_iv_1m"), 4 } }; float bx = chartMin.x + chartPad; for (int b = 0; b < 5; b++) { float tw = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, kIvs[b].lbl).x; float bw = tw + Layout::spacingSm() * 2.0f; ImVec2 bmn(bx, rowTop), bmx(bx + bw, rowTop + pillH); bool sel = (s_mkt.chartInterval == kIvs[b].iv); bool bhov = material::IsRectHovered(bmn, bmx); ImU32 bg = sel ? WithAlpha(Primary(), 200) : (bhov ? WithAlpha(OnSurface(), 35) : WithAlpha(OnSurface(), 18)); dl->AddRectFilled(bmn, bmx, bg, 4.0f); dl->AddText(capFont, capFont->LegacySize, ImVec2(bx + Layout::spacingSm(), textY), sel ? IM_COL32(255, 255, 255, 255) : OnSurface(), kIvs[b].lbl); ImGui::SetCursorScreenPos(bmn); ImGui::PushID(9100 + b); if (ImGui::InvisibleButton("##civ", ImVec2(bw, pillH))) { s_mkt.chartInterval = kIvs[b].iv; } if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); ImGui::PopID(); bx += bw + Layout::spacingXs(); } // Refresh button (far right). float rEdge = chartMax.x - chartPad; ImFont* iconSmall = material::Typography::instance().iconSmall(); ImVec2 rbMin(rEdge - pillH, rowTop), rbMax(rEdge, rowTop + pillH); bool refreshHov = material::IsRectHovered(rbMin, rbMax); if (refreshHov) { dl->AddRectFilled(rbMin, rbMax, IM_COL32(255, 255, 255, 20), 4.0f); ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); } ImVec2 icSz = iconSmall->CalcTextSizeA(iconSmall->LegacySize, FLT_MAX, 0, ICON_MD_REFRESH); dl->AddText(iconSmall, iconSmall->LegacySize, ImVec2(rbMin.x + (pillH - icSz.x) * 0.5f, rbMin.y + (pillH - icSz.y) * 0.5f), refreshHov ? OnSurface() : OnSurfaceMedium(), ICON_MD_REFRESH); ImGui::SetCursorScreenPos(rbMin); if (ImGui::InvisibleButton("##RefreshMarket", ImVec2(pillH, pillH))) { app->refreshMarketData(); } if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("market_refresh_price")); // 24H volume + market cap, right-aligned to the left of the refresh button. Skipped // when there's no room (narrow window) so they never overlap the interval buttons. float sxr = rbMin.x - Layout::spacingMd(); auto drawStat = [&](const char* label, const std::string& val) { char sb[64]; snprintf(sb, sizeof(sb), "%s %s", label, val.c_str()); float w = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, sb).x; if (sxr - w < bx + Layout::spacingMd()) return; sxr -= w; dl->AddText(capFont, capFont->LegacySize, ImVec2(sxr, textY), OnSurfaceMedium(), sb); sxr -= Layout::spacingMd(); }; if (market.price_usd > 0) { drawStat(TR("market_cap_short"), FormatCompactUSD(market.market_cap)); drawStat(TR("market_vol_short"), FormatCompactUSD(market.volume_24h)); } } float labelPadLeft = std::max(S.drawElement("tabs.market", "chart-y-axis-min-padding").size, S.drawElement("tabs.market", "chart-y-axis-padding").size * hs); // Extra bottom room so the time labels aren't crowded against the card's bottom edge. float labelPadBottom = Layout::spacingXl() + Layout::spacingSm(); float plotLeft = chartMin.x + labelPadLeft; float plotRight = chartMax.x - chartPad; float plotTop = stripTop + pillH + Layout::spacingMd(); // gap below the buttons before the plot float plotBottom = chartMax.y - labelPadBottom; float plotW = plotRight - plotLeft; float plotH = plotBottom - plotTop; if (s_mkt.history.size() >= 2) { // Compute Y range with padding double yMin = *std::min_element(s_mkt.history.begin(), s_mkt.history.end()); double yMax = *std::max_element(s_mkt.history.begin(), s_mkt.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; // Adaptive precision (via the shared price formatter) — cleaner than a fixed 6 decimals. snprintf(buf, sizeof(buf), "%s", FormatPrice(labelVal).c_str()); ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf); // Keep the axis label inside the card even on narrow windows (min-padding may be // smaller than the label width) so it never spills onto the tab background. float lblX = std::max(chartMin.x + 3.0f, plotLeft - labelSz.x - 6); dl->AddText(capFont, capFont->LegacySize, ImVec2(lblX, gy - labelSz.y * 0.5f), OnSurfaceDisabled(), buf); } // Build points size_t n = s_mkt.history.size(); std::vector points(n); // Colored by the DISPLAYED period's direction (chartUp), not just 24h. ImU32 dirCol = chartUp ? Success() : Error(); ImU32 lineCol = WithAlpha(dirCol, 220); ImU32 dotCol = dirCol; 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_mkt.history[i] - yMin) / (yMax - yMin)) * plotH; points[i] = ImVec2(x, y); } // Flat translucent area fill under the curve (matches the portfolio group sparklines). 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(WithAlpha(dirCol, 28)); // Line (no per-point dots — a clean curve). dl->AddPolyline(points.data(), (int)points.size(), lineCol, ImDrawFlags_None, S.drawElement("tabs.market", "chart-line-thickness").size); // High/low price labels at the displayed range's extremes (no dot markers). if (n >= 3) { size_t hiIdx = (size_t)(std::max_element(s_mkt.history.begin(), s_mkt.history.end()) - s_mkt.history.begin()); size_t loIdx = (size_t)(std::min_element(s_mkt.history.begin(), s_mkt.history.end()) - s_mkt.history.begin()); auto markExtreme = [&](size_t idx, bool high) { ImVec2 p = points[idx]; std::string lbl = FormatPrice(s_mkt.history[idx]); ImVec2 ls = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, lbl.c_str()); float ly = high ? p.y - ls.y - 5.0f * mktDp : p.y + 5.0f * mktDp; float lx = std::min(std::max(plotLeft, p.x - ls.x * 0.5f), plotRight - ls.x); dl->AddText(capFont, capFont->LegacySize, ImVec2(lx, ly), OnSurfaceMedium(), lbl.c_str()); }; markExtreme(hiIdx, true); markExtreme(loIdx, false); } // X-axis time labels. The series is timestamped, so label each tick with the real // "