// 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/colors.h" #include "../material/typography.h" #include "../material/project_icons.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 ---- static std::vector s_price_history; // mirror of WalletState market.price_history // 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_sel = -1; // selected group index in the master list; -1 = none static int s_pf_section = 0; // right-pane segmented control: 0=Appearance 1=Price 2=Addresses // Backdrop live-capture: capture once on open + on viewport resize (not every frame). The blur is a // frozen snapshot while the modal is open — efficient and flicker-free. static bool s_pf_was_open = false; static int s_pf_capture_frames = 0; // frames left to (re)capture the live backdrop static bool s_pf_capturing_frame = false; // true on any frame a live-backdrop capture is armed static float s_pf_capture_w = 0.0f, s_pf_capture_h = 0.0f; // viewport size at the last capture trigger 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 int s_pf_outline_opacity = 25; // accent-outline opacity (percent) static char s_pf_search[64] = {0}; // address-list filter text static char s_pf_icon_search[64] = {0}; // icon-picker filter text static int s_pf_type_filter = 0; // 0 = all, 1 = shielded, 2 = transparent static bool s_pf_funded_only = false; // Edge-fade shaders for the two internal scroll regions (one persistent instance each). static effects::ScrollFadeShader s_pf_icon_fade; static effects::ScrollFadeShader s_pf_addr_fade; 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 // Wrap a scroll region with smooth scrolling + the Settings-tab edge fade + a thicker, rounded // scrollbar that is inset from the container edge. Uses a bordered/rounded OUTER frame whose // padding holds an INNER scrolling child, so the inner scrollbar floats inside the frame rather // than hugging its border. `fade` must persist across frames (one instance per scroll region). // `pad` is the inner content inset; returns the inner draw list. Pair with pfEndScrollChild(). static ImDrawList* pfBeginScrollChild(const char* id, effects::ScrollFadeShader& fade, const ImVec2& pad, float dp) { // Outer: rounded bordered frame; its padding is the gap that insets the scrollbar from the edge. ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 6.0f * dp); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f * dp, 6.0f * dp)); ImGui::BeginChild(id, ImVec2(0, std::max(120.0f * dp, ImGui::GetContentRegionAvail().y)), true, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); ImGui::PopStyleVar(2); // ChildRounding + outer WindowPadding (both captured by the outer child) // Inner: borderless scrolling child with a thicker rounded scrollbar at its own (inset) edge. ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, 11.0f * dp); ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarRounding, 5.5f * dp); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, pad); std::string innerId = std::string(id) + "_i"; ImGui::BeginChild(innerId.c_str(), ImVec2(0, 0), false, ImGuiWindowFlags_NoScrollWithMouse); ImGui::PopStyleVar(); // inner WindowPadding — captured by this child; don't cascade to tooltips ApplySmoothScroll(); ImDrawList* dl = ImGui::GetWindowDrawList(); // Fade the top/bottom edges; off in low-spec and while the backdrop is being captured (the // acrylic overlay rebinds render state on those frames, which would clash with the fade shader). const float fadeH = 22.0f * dp; if (!effects::isLowSpecMode() && !s_pf_capturing_frame && fade.init()) { ImVec2 cMin = ImGui::GetWindowPos(); ImVec2 cMax(cMin.x + ImGui::GetWindowSize().x, cMin.y + ImGui::GetWindowSize().y); float sY = ImGui::GetScrollY(), sMax = ImGui::GetScrollMaxY(); fade.fadeTopY = cMin.y; fade.fadeBottomY = cMax.y; fade.fadeZoneTop = (sY > 1.0f) ? fadeH : 0.0f; fade.fadeZoneBottom = (sMax > 0.0f && sY < sMax - 1.0f) ? fadeH : 0.0f; fade.addBind(dl); } return dl; } static void pfEndScrollChild(effects::ScrollFadeShader& fade, ImDrawList* dl) { if (!effects::isLowSpecMode() && !s_pf_capturing_frame && fade.ready) effects::ScrollFadeShader::addUnbind(dl); ImGui::EndChild(); // inner ImGui::PopStyleVar(2); // ScrollbarSize + ScrollbarRounding ImGui::EndChild(); // outer } // Main price-chart interval selector: 0=Live (in-session minute buffer) 1=hour 2=day 3=week // 4=month. Session-scoped. The pure series math lives in data/market_series.h. static int s_chart_interval = 4; // default: 1M (last 30 days) // 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; } // Right-align the next item(s) of total width `w` within the remaining content region (cursor form). static void pfRightAlignX(float w) { float avail = ImGui::GetContentRegionAvail().x; if (avail > w) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + avail - w); } // Draw an equal-width segmented control (rounded track + N cells, each with a centered label and an // active pill) at screen-space `origin`, spanning `totalW` x `height`. Every cell is a hit-tested // InvisibleButton (Hand cursor on hover), disambiguated by `idBase`. Does NOT move the layout cursor // afterward — the caller positions the next content. Returns the clicked cell index, or -1. static int pfSegmentedControl(ImDrawList* dl, ImVec2 origin, float totalW, float height, const char* const* labels, int count, int selected, ImFont* font, const char* idBase, float dp) { int clicked = -1; float cellW = totalW / (float)count; dl->AddRectFilled(origin, ImVec2(origin.x + totalW, origin.y + height), WithAlpha(OnSurface(), 20), height * 0.5f); for (int i = 0; i < count; i++) { ImVec2 cMin(origin.x + i * cellW, origin.y), cMax(cMin.x + cellW, origin.y + height); bool active = (selected == i); bool hov = ImGui::IsMouseHoveringRect(cMin, cMax); if (active) dl->AddRectFilled(ImVec2(cMin.x + 2.0f * dp, cMin.y + 2.0f * dp), ImVec2(cMax.x - 2.0f * dp, cMax.y - 2.0f * dp), WithAlpha(Primary(), 210), (height - 4.0f * dp) * 0.5f); ImVec2 ts = font->CalcTextSizeA(font->LegacySize, FLT_MAX, 0, labels[i]); dl->AddText(font, font->LegacySize, ImVec2(cMin.x + (cellW - ts.x) * 0.5f, cMin.y + (height - ts.y) * 0.5f), active ? IM_COL32(255, 255, 255, 255) : (hov ? OnSurface() : OnSurfaceMedium()), labels[i]); ImGui::PushID(i); ImGui::SetCursorScreenPos(cMin); if (ImGui::InvisibleButton(idBase, ImVec2(cellW, height))) clicked = i; if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); ImGui::PopID(); } return clicked; } 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); } // 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 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_pf_sel = 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_outline_opacity = src.outlineOpacity; 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_outline_opacity = 25; 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; 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(); float iconFsz = iconFont->LegacySize; auto persist = [&](const std::vector& entries) { settings->setPortfolioEntries(entries); settings->save(); }; // Build a PortfolioEntry from the working set, preserving off-form fields (grid geometry) from base. auto buildWorking = [&](const config::Settings::PortfolioEntry& base) { config::Settings::PortfolioEntry e = base; e.label = s_pf_label; e.addresses = s_pf_addrs; e.icon = s_pf_icon; e.color = s_pf_color; e.outlineOpacity = s_pf_outline_opacity; 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; return e; }; auto workingMatches = [&](const config::Settings::PortfolioEntry& e) { return e.label == std::string(s_pf_label) && e.addresses == s_pf_addrs && e.icon == s_pf_icon && e.color == s_pf_color && e.outlineOpacity == s_pf_outline_opacity && e.priceBasis == s_pf_price_basis && e.manualPrice == s_pf_manual_price && e.manualCurrency == std::string(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; }; // Persist the working group when it is named and actually changed; unnamed drafts are dropped. auto commitIfNeeded = [&]() { if (s_pf_label[0] == '\0') return; auto entries = settings->getPortfolioEntries(); bool existing = (s_pf_sel >= 0 && s_pf_sel < (int)entries.size()); config::Settings::PortfolioEntry base; if (existing) { base = entries[s_pf_sel]; if (workingMatches(base)) return; } auto e = buildWorking(base); if (existing) entries[s_pf_sel] = e; else { entries.push_back(e); s_pf_sel = (int)entries.size() - 1; } persist(entries); }; // ---------------- Full-window overlay frame ---------------- material::MarkOverlayDialogActive(); ImGuiViewport* vp = ImGui::GetMainViewport(); ImVec2 vpPos = vp->Pos, vpSize = vp->Size; // Trigger a live backdrop (re)capture on OPEN and on viewport RESIZE only. A few frames are used // so the capture lands after the theme-effect suppression (frame after open) settles. bool justOpened = !s_pf_was_open; s_pf_was_open = true; if (justOpened || s_pf_capture_w != vpSize.x || s_pf_capture_h != vpSize.y) s_pf_capture_frames = 3; s_pf_capture_w = vpSize.x; s_pf_capture_h = vpSize.y; // Don't force-focus the scrim while a popup (basis/interval combo, color picker) is open, or it // would close the popup; the same guard gates the outside-click dismiss below. const bool popupOpen = ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel); if (!popupOpen) ImGui::SetNextWindowFocus(); ImGui::SetNextWindowPos(vpPos); ImGui::SetNextWindowSize(vpSize); ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0, 0, 0, 0)); // transparent — we draw the backdrop ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); ImGui::Begin("##PortfolioOverlay", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse | ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoSavedSettings); ImDrawList* dl = ImGui::GetWindowDrawList(); // Only on capture frames (open/resize): capture the live UI drawn below this overlay (then reset // ImGui's render state) so the backdrop blurs the live tab content. Other frames reuse the frozen // blurred snapshot (blurCacheValid_), so there's no per-frame capture/re-blur cost or flicker. // Latch "capturing" for the WHOLE frame before decrementing, so the scroll children (which draw // later this frame) suppress their fade shader on every capture frame — including the last one, // where the counter would otherwise already read 0 and clash with the capture's render-state reset. s_pf_capturing_frame = (s_pf_capture_frames > 0); if (s_pf_capturing_frame) { if (ImDrawCallback liveCap = effects::ImGuiAcrylic::GetLiveCaptureCallback()) { dl->AddCallback(liveCap, nullptr); dl->AddCallback(ImDrawCallback_ResetRenderState, nullptr); } s_pf_capture_frames--; } // Skip the blur on the very first frame (live content isn't captured until this frame's render) — // shows the plain opaque scrim for one frame instead of a blur of stale background pixels. material::DrawFullWindowBlurBackdrop(dl, vpPos, ImVec2(vpPos.x + vpSize.x, vpPos.y + vpSize.y), /*allowBlur=*/!justOpened); ImGui::SetCursorScreenPos(vpPos); ImGui::InvisibleButton("##pfInputBlocker", vpSize, ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonRight | ImGuiButtonFlags_MouseButtonMiddle); // Content region: large, centered, fixed size. No card panel — the content floats directly // on the blurred backdrop. float cardW = std::min(vpSize.x - 64.0f * dp, 1120.0f * dp); float cardH = std::min(vpSize.y - 64.0f * dp, 760.0f * dp); ImVec2 cardMin(vpPos.x + (vpSize.x - cardW) * 0.5f, vpPos.y + (vpSize.y - cardH) * 0.5f); ImVec2 cardMax(cardMin.x + cardW, cardMin.y + cardH); ImGui::SetCursorScreenPos(cardMin); ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 20.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(28.0f, 20.0f)); ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0, 0, 0, 0)); ImGui::BeginChild("##pfCard", ImVec2(cardW, cardH), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar); ImGui::PopStyleVar(); // pop WindowPadding — the 28x20 applies to ##pfCard only, not nested children ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.5f, 0.5f)); // centered button labels // Plain heading — no title-bar background or divider (content floats on the backdrop). { ImFont* titleFont = Type().h6(); ImGui::PushFont(titleFont); ImGui::TextUnformatted(TR("portfolio_manage_title")); ImGui::PopFont(); ImGui::Dummy(ImVec2(0, Layout::spacingXs())); } if (ImGui::IsKeyPressed(ImGuiKey_Escape)) s_portfolio_editor_open = false; { // clamp/validate the selection against the current entry list const auto& entriesRO = settings->getPortfolioEntries(); if (s_pf_sel >= (int)entriesRO.size()) s_pf_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_pf_sel >= 0 && s_pf_sel < (int)es.size()) selDirty = !workingMatches(es[s_pf_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_pf_sel == i); ImVec2 rmn = ImGui::GetCursorScreenPos(); if (ImGui::Selectable("##pfgrp", selRow, ImGuiSelectableFlags_SpanAvailWidth, ImVec2(0, rowH))) { if (i != s_pf_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); persist(es); if (es.empty()) s_pf_sel = -1; else if (delRow < s_pf_sel) s_pf_sel -= 1; else if (delRow == s_pf_sel) s_pf_sel = std::min(delRow, (int)es.size() - 1); PortfolioBeginEdit(app, s_pf_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 (ImGui::Button(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"); es.push_back(ne); persist(es); s_pf_sel = (int)es.size() - 1; PortfolioBeginEdit(app, s_pf_sel); } } ImGui::EndGroup(); ImGui::SameLine(0, gap); // ================= DETAIL: options for the selected group ================= ImGui::BeginChild("##pfDetail", ImVec2(detailW, bodyH), false); if (s_pf_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_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) { int oa = (int)(std::max(0, std::min(100, s_pf_outline_opacity)) * 2.55f + 0.5f); pdl->AddRect(pMin, pMax, WithAlpha(accent, oa), 10.0f, 0, 2.0f); } if (s_pf_show_sparkline && (s_pf_price_basis == 0 || s_pf_price_basis == 1)) { std::vector h = data::sparklineSeries(state.market, s_pf_spark_interval); 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_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::spacingSm())); ImGui::SetNextItemWidth(-1); ImGui::InputTextWithHint("##pfLabel", TR("portfolio_group_name"), s_pf_label, sizeof(s_pf_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 = pfSegmentedControl(ImGui::GetWindowDrawList(), sMin, segW, segH, secs, 3, s_pf_section, body2f, "##pfseg", dp); if (clk >= 0) s_pf_section = clk; ImGui::SetCursorScreenPos(ImVec2(sMin.x, sMin.y + segH)); ImGui::Dummy(ImVec2(segW, 0)); } ImGui::Dummy(ImVec2(0, Layout::spacingMd())); // ---- APPEARANCE (color + outline opacity + icon) ---- if (s_pf_section == 0) { // 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_pf_color != 0u); for (int c = 0; c < nColors; c++) if (s_pf_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_pf_color == 0u) : (s_pf_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_pf_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_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::PushID(2999); ImGui::SetCursorScreenPos(mn); 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; 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_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(); } } // Accent-outline opacity (only meaningful when an accent color is set). ImGui::BeginDisabled(s_pf_color == 0u); ImGui::TextUnformatted(TR("portfolio_outline_opacity")); ImGui::SetNextItemWidth(-1); ImGui::SliderInt("##pfOutlineOpacity", &s_pf_outline_opacity, 0, 100, "%d%%"); if (s_pf_outline_opacity < 0) s_pf_outline_opacity = 0; if (s_pf_outline_opacity > 100) s_pf_outline_opacity = 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_pf_icon_search, sizeof(s_pf_icon_search)); } // Filtered icon list (-1 = "None", shown only when not searching). std::string isearch = s_pf_icon_search; 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 = pfBeginScrollChild("##pfIconGrid", s_pf_icon_fade, 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_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, gIconFont, gIconSz); } ImGui::PushID(idx + 2); 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; } pfEndScrollChild(s_pf_icon_fade, gdl); } } // ---- PRICE (basis, manual price, shown fields, sparkline) ---- if (s_pf_section == 1) { // 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_pf_price_basis, b); } 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)); } ImGui::Dummy(ImVec2(0, Layout::spacingSm())); // Shown fields. ImGui::TextUnformatted(TR("portfolio_show")); 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(); // 24h + sparkline toggles share one line (both live-market only). ImGui::BeginDisabled(!(s_pf_price_basis == 0 || s_pf_price_basis == 1)); ImGui::Checkbox(TR("portfolio_show_24h"), &s_pf_show_24h); ImGui::SameLine(); ImGui::Checkbox(TR("portfolio_show_sparkline"), &s_pf_show_sparkline); ImGui::EndDisabled(); ImGui::Dummy(ImVec2(0, Layout::spacingSm())); // Sparkline interval — radio group (enabled only when the sparkline is shown). ImGui::BeginDisabled(!s_pf_show_sparkline || !(s_pf_price_basis == 0 || s_pf_price_basis == 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_pf_spark_interval, s); } ImGui::EndDisabled(); } // ---- ADDRESSES (search, filter, select) ---- if (s_pf_section == 2) { { char selc[48]; snprintf(selc, sizeof(selc), TR("portfolio_addresses_sel"), (int)s_pf_addrs.size()); Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), selc); } ImGui::SetNextItemWidth(-1); ImGui::InputTextWithHint("##pfSearch", TR("portfolio_search"), s_pf_search, sizeof(s_pf_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 = pfSegmentedControl(ImGui::GetWindowDrawList(), ImVec2(rowStart.x, segY), segW, segH, flabels, 3, s_pf_type_filter, capF, "##pftf", dp); if (tclk >= 0) s_pf_type_filter = 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 (ImGui::Button(selLbl)) selAllClicked = true; ImGui::SameLine(); if (ImGui::Button(clrLbl)) s_pf_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_pf_funded_only); ImGui::SetCursorScreenPos(ImVec2(rowStart.x, rowStart.y + rowH)); ImGui::Dummy(ImVec2(rowW, 0)); } ImGui::Dummy(ImVec2(0, Layout::spacingXs())); 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; }); if (selAllClicked) for (const AddressInfo* a : filtered) data::PortfolioEntryAdd(s_pf_addrs, a->address); ImGui::Dummy(ImVec2(0, Layout::spacingXs())); ImDrawList* ldl = pfBeginScrollChild("##pfAddrList", s_pf_addr_fade, 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_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(); 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(); 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(); 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_pf_addrs, a.address); else data::PortfolioEntryAdd(s_pf_addrs, a.address); } ImGui::PopID(); } pfEndScrollChild(s_pf_addr_fade, ldl); } 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; pfRightAlignX(grp); ImGui::SetCursorPosY(ImGui::GetCursorPosY() + Layout::spacingSm()); ImGui::BeginDisabled(!selDirty); if (ImGui::Button(TR("portfolio_revert"), ImVec2(bw, addH))) PortfolioBeginEdit(app, s_pf_sel); // reload the working set from the stored entry ImGui::SameLine(0, sp); ImGui::BeginDisabled(s_pf_label[0] == '\0'); if (ImGui::Button(TR("portfolio_save"), ImVec2(bw, addH))) commitIfNeeded(); ImGui::EndDisabled(); ImGui::EndDisabled(); } } ImGui::EndChild(); // ##pfDetail // ================= FOOTER: close (modal-level action, larger → higher hierarchy) ================= { float bh = 40.0f * dp, bw = 120.0f * dp; pfRightAlignX(bw); if (ImGui::Button(TR("portfolio_close"), ImVec2(bw, bh))) { commitIfNeeded(); s_portfolio_editor_open = false; } } ImGui::EndChild(); // ##pfCard ImGui::PopStyleColor(); ImGui::PopStyleVar(2); // ChildRounding + ButtonTextAlign (WindowPadding was popped after BeginChild) // Outside-click dismiss (guarded against the appearing frame and open popups). if (!popupOpen && !ImGui::IsWindowAppearing() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !ImGui::IsMouseHoveringRect(cardMin, cardMax)) s_portfolio_editor_open = false; // Esc / outside-click discard uncommitted edits (only the Close button above commits explicitly). // Invalidate the acrylic capture so the background (not the last live capture) is re-captured for // the other glass panels once the overlay is gone, and arm a fresh capture for the next open. if (!s_portfolio_editor_open) { effects::ImGuiAcrylic::InvalidateCapture(); s_pf_was_open = false; } ImGui::End(); ImGui::PopStyleColor(); ImGui::PopStyleVar(3); } bool PortfolioEditorActive() { return s_portfolio_editor_open; } 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(); // Also kick the historical price-chart fetch (self-throttled) so the chart's hour/day/week/ // month intervals populate promptly when the user opens the Market tab. app->refreshMarketChart(); 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; // (Panel theme-effects are suppressed frame-wide in App::render() while the modal is open, so // their foreground borders don't bleed over the overlay — see PortfolioEditorActive().) ImVec2 marketAvail = ImGui::GetContentRegionAvail(); // Scrollable (the portfolio grid can extend past the window) but with no visible scrollbar — // scrolling still works via the mouse wheel. ImGui::BeginChild("##MarketScroll", marketAvail, false, ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar); // 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* 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(); // Header holds only the price row; 24H volume + market cap and the divider moved onto the // chart's top strip (with the interval buttons) so they no longer create an empty band. float heroHeaderH = Layout::spacingLg() // top pad + mktH3->LegacySize // price row + Layout::spacingXs(); // small gap before the chart strip // -- 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 = 8, pfMinH = 2; // minimum card size in cells (~256x64) 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; // Chart series for the selected interval (timestamped), shared by the hero change badge and // the chart block below. Computed once here so the badge can report the displayed period's change. std::time_t nowSec = std::time(nullptr); std::vector chartTimes; s_price_history.clear(); for (const auto& pr : data::chartSeries(market, s_chart_interval, nowSec)) { s_price_history.push_back(pr.second); chartTimes.push_back(pr.first); } // Change over the displayed period (Live falls back to the market's 24h figure). double periodChangePct = market.change_24h; if (s_chart_interval != 0 && s_price_history.size() >= 2 && s_price_history.front() > 0.0) periodChangePct = (s_price_history.back() - s_price_history.front()) / s_price_history.front() * 100.0; bool chartUp = periodChangePct >= 0.0; // Suffix = the SELECTED range (not the raw data span), so 1D reads "24h", 1W reads "7d", etc. std::string periodSuffix = "24h"; switch (s_chart_interval) { case 1: periodSuffix = "1h"; break; // 1H case 2: periodSuffix = "24h"; break; // 1D case 3: periodSuffix = "7d"; break; // 1W case 4: periodSuffix = "30d"; break; // 1M default: periodSuffix = "24h"; break; // Live (uses the market's 24h change) } // ================================================================ // 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); // 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 = cx + 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_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) // ================================================================ { // The chart series (s_price_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 }, { "1H", 1 }, { "1D", 2 }, { "1W", 3 }, { "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_chart_interval == 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_chart_interval = 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_price_history.size() >= 2) { // 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; // 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_price_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_price_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_price_history.begin(), s_price_history.end()) - s_price_history.begin()); size_t loIdx = (size_t)(std::min_element(s_price_history.begin(), s_price_history.end()) - s_price_history.begin()); auto markExtreme = [&](size_t idx, bool high) { ImVec2 p = points[idx]; std::string lbl = FormatPrice(s_price_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 // "