Files
ObsidianDragon/src/ui/windows/market_tab.cpp
DanS 1fdb6656aa feat(market): fix portfolio Add button clipping; add accordion summaries
- Add button can no longer be cut off: it's now a normal widget below the group
  list (in a group with the scroll child) instead of nested inside the list child.
- Stop the modal's 28x20 WindowPadding from cascading into the nested master/detail/
  list children (pop it right after the card child begins) — that double-padding
  was squeezing the master column and the Add button.
- Right pane navigability: collapsed accordion sections now show a compact summary
  on the right (Appearance -> icon, Price -> basis, Addresses -> N selected), so a
  group's state is readable without expanding. Section titles are consistent
  title-case ("Appearance"/"Price"/"Addresses").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 19:13:35 -05:00

1885 lines
104 KiB
C++

// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "market_tab.h"
#include "../../app.h"
#include "../../config/version.h"
#include "../../data/wallet_state.h"
#include "../../config/settings.h"
#include "../../data/portfolio.h"
#include "../../data/exchange_info.h"
#include "../../util/i18n.h"
#include "../schema/ui_schema.h"
#include "../material/type.h"
#include "../material/draw_helpers.h"
#include "../material/colors.h"
#include "../material/typography.h"
#include "../material/project_icons.h"
#include "../../util/text_format.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "../../util/platform.h"
#include "../layout.h"
#include "imgui.h"
#include <algorithm>
#include <cmath>
#include <ctime>
namespace dragonx {
namespace ui {
using namespace material;
// ---- Market tab persistent state ----
static std::vector<double> s_price_history; // mirror of WalletState market.price_history
static bool s_history_initialized = false;
static double s_last_refresh_time = 0.0;
// Exchange / pair selection
static int s_exchange_idx = 0;
static int s_pair_idx = 0;
static bool s_market_state_loaded = false;
// The effective exchange registry: the live CoinGecko list when available, else the
// compiled-in fallback.
static const std::vector<data::ExchangeInfo>& 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<data::ExchangeInfo>& 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, -2 = unsaved draft
static bool s_pf_sec_appearance = true; // right-pane accordion: which section is expanded
static bool s_pf_sec_price = false;
static bool s_pf_sec_addresses = false;
static char s_pf_label[64] = {0};
static std::vector<std::string> 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 int s_pf_type_filter = 0; // 0 = all, 1 = shielded, 2 = transparent
static bool s_pf_funded_only = false;
static float s_pf_custom_rgb[3] = {0.31f, 0.62f, 1.0f}; // working RGB for the custom color picker
// Price-data config (working copy while editing a group).
static int s_pf_price_basis = 0;
static double s_pf_manual_price = 0.0;
static char s_pf_manual_ccy[12] = "USD";
static bool s_pf_show_drgx = true;
static bool s_pf_show_value = true;
static bool s_pf_show_24h = false;
static bool s_pf_show_sparkline = false;
static int s_pf_spark_interval = 0; // 0=min 1=hour 2=day 3=week 4=month
// price_history is ~1 sample/minute; resample it to the group's chosen interval by averaging
// each block of K minute-samples into one point.
static std::vector<double> pfResampleHistory(const std::vector<double>& hist, int interval)
{
static const int kMins[5] = {1, 60, 1440, 10080, 43200};
int k = kMins[(interval >= 0 && interval < 5) ? interval : 0];
if (k <= 1) return hist;
std::vector<double> out;
for (size_t i = 0; i < hist.size(); i += (size_t)k) {
double sum = 0.0; size_t cnt = 0;
for (size_t j = i; j < hist.size() && j < i + (size_t)k; ++j) { sum += hist[j]; ++cnt; }
if (cnt) out.push_back(sum / (double)cnt);
}
return out;
}
// Bucket a timestamped (unix-seconds, price) series into fixed-width time windows, averaging the
// samples in each window into one point. Input is oldest->newest; output preserves that order.
static std::vector<double> pfBucketBySeconds(
const std::vector<std::pair<std::time_t, double>>& series, long windowSec)
{
std::vector<double> out;
if (series.empty() || windowSec <= 0) return out;
long curBucket = 0; double sum = 0.0; int cnt = 0;
for (const auto& sample : series) {
long b = (long)(sample.first / windowSec);
if (cnt > 0 && b != curBucket) { out.push_back(sum / cnt); sum = 0.0; cnt = 0; }
curBucket = b;
sum += sample.second; ++cnt;
}
if (cnt > 0) out.push_back(sum / cnt);
return out;
}
// Resolve the price series backing a group's sparkline for the chosen interval:
// 0=minute -> the live in-session buffer; 1=hour -> intraday (5-min) data bucketed to hours;
// 2=day / 3=week / 4=month -> the ~1yr daily series bucketed accordingly.
// Falls back to the in-session buffer when the historical fetch hasn't populated yet.
static std::vector<double> pfSparklineSeries(const MarketInfo& m, int interval)
{
const long kDay = 86400;
switch (interval) {
case 1: { auto v = pfBucketBySeconds(m.price_chart_intraday, 3600); if (v.size() >= 2) return v; break; }
case 2: { auto v = pfBucketBySeconds(m.price_chart_daily, kDay); if (v.size() >= 2) return v; break; }
case 3: { auto v = pfBucketBySeconds(m.price_chart_daily, 7 * kDay); if (v.size() >= 2) return v; break; }
case 4: { auto v = pfBucketBySeconds(m.price_chart_daily, 30 * kDay); if (v.size() >= 2) return v; break; }
default: break; // minute interval, or no historical data yet -> live buffer below
}
return pfResampleHistory(m.price_history, interval);
}
// Main price-chart interval selector (same semantics as pfSparklineSeries):
// 0=Live (in-session minute buffer) 1=hour 2=day 3=week 4=month. Session-scoped.
static int s_chart_interval = 4; // default: 1M (last 30 days)
// Timestamped price series backing the main chart for the selected RANGE (Live/1H/1D/1W/1M).
// The interval buttons select a time window ending at `now`: 1H = last hour, 1D = last 24h,
// 1W = last 7 days, 1M = last 30 days. 1H/1D come from the 5-minute intraday series; 1W/1M from
// the daily series. Live (or an un-fetched/empty range) uses the in-session minute buffer.
static std::vector<std::pair<std::time_t, double>> pfChartSeries(const MarketInfo& m, int interval,
std::time_t now)
{
const long kDay = 86400;
auto lastWindow = [now](const std::vector<std::pair<std::time_t, double>>& src, long rangeSec) {
std::vector<std::pair<std::time_t, double>> out;
std::time_t cutoff = now - (std::time_t)rangeSec;
for (const auto& s : src) if (s.first >= cutoff) out.push_back(s);
return out;
};
switch (interval) {
case 1: { auto v = lastWindow(m.price_chart_intraday, 3600); if (v.size() >= 2) return v; break; } // 1H
case 2: { auto v = lastWindow(m.price_chart_intraday, kDay); if (v.size() >= 2) return v; break; } // 1D
case 3: { auto v = lastWindow(m.price_chart_daily, 7 * kDay); if (v.size() >= 2) return v; break; } // 1W
case 4: { auto v = lastWindow(m.price_chart_daily, 30 * kDay); if (v.size() >= 2) return v; break; } // 1M
default: break;
}
std::vector<std::pair<std::time_t, double>> out;
const auto& h = m.price_history;
for (size_t i = 0; i < h.size(); i++)
out.push_back({ now - (std::time_t)((h.size() - 1 - i) * 60), h[i] });
return out;
}
// A group's value trend tracks the DRGX price, so a group sparkline plots the price history
// (normalized) across a rect. Colored by overall direction. Only meaningful for market bases.
static void pfDrawSparkline(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
const std::vector<double>& hist, ImU32 col)
{
int n = (int)hist.size();
if (n < 2) return;
double lo = *std::min_element(hist.begin(), hist.end());
double hi = *std::max_element(hist.begin(), hist.end());
if (hi <= lo) hi = lo + 1e-9;
float w = mx.x - mn.x, h = mx.y - mn.y;
std::vector<ImVec2> pts(n);
for (int i = 0; i < n; i++) {
float t = (n > 1) ? (float)i / (float)(n - 1) : 0.0f;
pts[i] = ImVec2(mn.x + t * w, mx.y - (float)((hist[i] - lo) / (hi - lo)) * h);
}
dl->AddPolyline(pts.data(), n, col, ImDrawFlags_None, 1.2f);
}
// Sparkline with a soft fill under the line — for the card's dedicated bottom strip.
static void pfDrawSparklineFilled(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
const std::vector<double>& hist, ImU32 lineCol)
{
int n = (int)hist.size();
if (n < 2) return;
double lo = *std::min_element(hist.begin(), hist.end());
double hi = *std::max_element(hist.begin(), hist.end());
if (hi <= lo) hi = lo + 1e-9;
float w = mx.x - mn.x, h = mx.y - mn.y;
std::vector<ImVec2> pts(n);
for (int i = 0; i < n; i++) {
float t = (n > 1) ? (float)i / (float)(n - 1) : 0.0f;
pts[i] = ImVec2(mn.x + t * w, mx.y - (float)((hist[i] - lo) / (hi - lo)) * h);
}
for (int i = 0; i < n; i++) dl->PathLineTo(pts[i]);
dl->PathLineTo(ImVec2(pts[n - 1].x, mx.y));
dl->PathLineTo(ImVec2(pts[0].x, mx.y));
dl->PathFillConcave(WithAlpha(lineCol, 28));
dl->AddPolyline(pts.data(), n, WithAlpha(lineCol, 220), ImDrawFlags_None, 1.4f);
}
// Dashboard-grid state for the portfolio cards (drag to move, corner-drag to resize).
struct PfCell { int col, row, w, h; };
static int s_pf_drag = -1; // card index being dragged (-1 = none)
static int s_pf_resize = -1; // card index being resized
static bool s_pf_moved = false; // drag passed the click threshold this gesture
static ImVec2 s_pf_press; // mouse pos at grab
static ImVec2 s_pf_grab; // mouse offset within the card at grab
static PfCell s_pf_old = {0,0,1,1};// dragged card's cell at grab (for swap-on-drop)
static PfCell s_pf_target = {0,0,1,1}; // live drop target cell
// Build the right-hand amount strings for a group card / preview from its price-data config.
struct PfDisplay {
std::string primary; // name-row, top-right (DRGX amount or the value)
std::string secondary; // below primary (the value, when DRGX is also shown)
std::string change; // "+2.34%" (live-market bases only) or empty
ImU32 changeCol = 0;
};
static PfDisplay pfBuildDisplay(const config::Settings::PortfolioEntry& e, double bal,
const MarketInfo& market)
{
PfDisplay d;
char b[80];
std::string valueStr; bool hasValue = false;
if (e.priceBasis == 0 && market.price_usd > 0) {
snprintf(b, sizeof(b), "$%.2f", bal * market.price_usd); valueStr = b; hasValue = true;
} else if (e.priceBasis == 1 && market.price_btc > 0) {
snprintf(b, sizeof(b), "%.8f BTC", bal * market.price_btc); valueStr = b; hasValue = true;
} else if (e.priceBasis == 3 && e.manualPrice > 0.0) {
snprintf(b, sizeof(b), "%.2f %s", bal * e.manualPrice, e.manualCurrency.c_str());
valueStr = b; hasValue = true;
}
std::string drgxStr;
if (e.showDrgx) { snprintf(b, sizeof(b), "%.4f %s", bal, DRAGONX_TICKER); drgxStr = b; }
if (e.showDrgx) {
d.primary = drgxStr;
if (e.showValue && hasValue) d.secondary = valueStr;
} else if (e.showValue && hasValue) {
d.primary = valueStr;
}
if (e.show24h && (e.priceBasis == 0 || e.priceBasis == 1) && market.change_24h != 0.0) {
snprintf(b, sizeof(b), "%+.2f%%", market.change_24h);
d.change = b;
d.changeCol = market.change_24h >= 0 ? material::Success() : material::Error();
}
return d;
}
// Preset accent palette for portfolio groups (0 slot = "default / no color").
static const ImU32 kPfPalette[] = {
IM_COL32(0x4F, 0x9D, 0xFF, 0xFF), // blue
IM_COL32(0x3D, 0xDC, 0x84, 0xFF), // green
IM_COL32(0xFF, 0xB0, 0x3D, 0xFF), // amber
IM_COL32(0xFF, 0x6B, 0x6B, 0xFF), // red
IM_COL32(0xB0, 0x7B, 0xFF, 0xFF), // purple
IM_COL32(0x4F, 0xD6, 0xD6, 0xFF), // teal
IM_COL32(0xFF, 0x8F, 0xC7, 0xFF), // pink
IM_COL32(0xC7, 0xCE, 0xD6, 0xFF), // slate
};
// Populate the working buffers for a new (index < 0) or existing entry.
static void PortfolioBeginEdit(App* app, int index)
{
const auto& entries = app->settings()->getPortfolioEntries();
s_pf_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<config::Settings::PortfolioEntry>& 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;
// 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();
material::DrawFullWindowBlurBackdrop(dl, vpPos, ImVec2(vpPos.x + vpSize.x, vpPos.y + vpSize.y));
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
material::DrawDialogTitleBar(TR("portfolio_manage_title"), &s_portfolio_editor_open);
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 = 40.0f * dp;
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;
// ================= 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;
int clickedSel = -999; // 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);
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();
}
float txtRight = rmx.x - Layout::spacingSm();
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);
ImGui::PopID();
}
if (clickedSel != -999) { commitIfNeeded(); PortfolioBeginEdit(app, clickedSel); }
}
ImGui::EndChild();
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Add button lives outside the scroll child (a normal widget) so it can never be clipped.
if (ImGui::Button(TR("portfolio_add_entry"), ImVec2(masterW, addH))) { commitIfNeeded(); PortfolioBeginEdit(app, -2); }
}
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 {
// ---- 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<double> h = pfSparklineSeries(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()));
// Accordion: exactly one section expanded at a time. Collapsed sections show a compact
// summary on the right so a group's state is readable at a glance without expanding.
auto section = [&](const char* label, bool& openFlag, const std::string& summary) -> bool {
ImGui::SetNextItemOpen(openFlag, ImGuiCond_Always);
bool now = ImGui::CollapsingHeader(label);
if (!now && !summary.empty()) {
ImVec2 hmn = ImGui::GetItemRectMin(), hmx = ImGui::GetItemRectMax();
float sw2 = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, summary.c_str()).x;
ImGui::GetWindowDrawList()->AddText(capF, capF->LegacySize,
ImVec2(hmx.x - sw2 - Layout::spacingMd(), (hmn.y + hmx.y) * 0.5f - capF->LegacySize * 0.5f),
OnSurfaceMedium(), summary.c_str());
}
if (now != openFlag) { s_pf_sec_appearance = s_pf_sec_price = s_pf_sec_addresses = false; openFlag = now; }
return openFlag;
};
// Collapsed-section summaries.
const char* basisNames[4] = { TR("portfolio_price_usd"), TR("portfolio_price_btc"),
TR("portfolio_price_drgx"), TR("portfolio_price_manual") };
std::string apSummary = s_pf_icon.empty() ? std::string(TR("portfolio_no_icon")) : s_pf_icon;
std::string prSummary = basisNames[std::max(0, std::min(3, s_pf_price_basis))];
char adSummary[48]; snprintf(adSummary, sizeof(adSummary), TR("portfolio_addresses_sel"), (int)s_pf_addrs.size());
// ---- APPEARANCE (color + outline opacity + icon) ----
if (section(TR("portfolio_appearance"), s_pf_sec_appearance, apSummary)) {
ImGui::Indent(Layout::spacingSm());
// Color picker: preset swatches + a custom-color circle that opens a picker popup.
{
float sw = 22.0f * dp;
ImGui::TextUnformatted(TR("portfolio_color"));
ImDrawList* cdl = ImGui::GetWindowDrawList();
int nColors = (int)(sizeof(kPfPalette) / sizeof(kPfPalette[0]));
for (int c = 0; c <= nColors; c++) { // slot 0 = default / no color
if (c != 0) ImGui::SameLine(0, 5.0f * dp);
ImVec2 mn = ImGui::GetCursorScreenPos();
ImVec2 cc(mn.x + sw * 0.5f, mn.y + sw * 0.5f);
bool isDefault = (c == 0);
bool sel = isDefault ? (s_pf_color == 0u) : (s_pf_color == kPfPalette[c - 1]);
bool hov = ImGui::IsMouseHoveringRect(mn, ImVec2(mn.x + sw, mn.y + sw));
if (isDefault) cdl->AddCircle(cc, sw * 0.40f, OnSurfaceMedium(), 0, 1.5f * dp);
else cdl->AddCircleFilled(cc, sw * 0.40f, kPfPalette[c - 1]);
if (sel) cdl->AddCircle(cc, sw * 0.50f, OnSurface(), 0, 2.0f * dp);
else if (hov) cdl->AddCircle(cc, sw * 0.50f, WithAlpha(OnSurface(), 120), 0, 1.5f * dp);
ImGui::PushID(2000 + c);
ImGui::InvisibleButton("##pfcol", ImVec2(sw, sw));
if (ImGui::IsItemClicked()) s_pf_color = isDefault ? 0u : kPfPalette[c - 1];
ImGui::PopID();
}
bool isCustom = (s_pf_color != 0u);
for (int c = 0; c < nColors; c++)
if (s_pf_color == kPfPalette[c]) isCustom = false;
ImGui::SameLine(0, 5.0f * dp);
ImVec2 mn = ImGui::GetCursorScreenPos();
ImVec2 cc(mn.x + sw * 0.5f, mn.y + sw * 0.5f);
bool hov = ImGui::IsMouseHoveringRect(mn, ImVec2(mn.x + sw, mn.y + sw));
if (isCustom) {
cdl->AddCircleFilled(cc, sw * 0.40f, (ImU32)s_pf_color);
} else {
cdl->AddCircle(cc, sw * 0.40f, OnSurfaceMedium(), 0, 1.5f * dp);
float pr = sw * 0.18f;
cdl->AddLine(ImVec2(cc.x - pr, cc.y), ImVec2(cc.x + pr, cc.y), OnSurfaceMedium(), 1.5f * dp);
cdl->AddLine(ImVec2(cc.x, cc.y - pr), ImVec2(cc.x, cc.y + pr), OnSurfaceMedium(), 1.5f * dp);
}
if (isCustom) cdl->AddCircle(cc, sw * 0.50f, OnSurface(), 0, 2.0f * dp);
else if (hov) cdl->AddCircle(cc, sw * 0.50f, WithAlpha(OnSurface(), 120), 0, 1.5f * dp);
ImGui::InvisibleButton("##pfcustomcol", ImVec2(sw, sw));
if (ImGui::IsItemClicked()) {
ImU32 base = s_pf_color ? (ImU32)s_pf_color : IM_COL32(0x4F, 0x9D, 0xFF, 0xFF);
s_pf_custom_rgb[0] = ((base >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f;
s_pf_custom_rgb[1] = ((base >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f;
s_pf_custom_rgb[2] = ((base >> IM_COL32_B_SHIFT) & 0xFF) / 255.0f;
ImGui::OpenPopup("##pfCustomColor");
}
if (hov) material::Tooltip("%s", TR("portfolio_custom_color"));
if (ImGui::BeginPopup("##pfCustomColor")) {
ImGui::ColorPicker3("##pfpick", s_pf_custom_rgb,
ImGuiColorEditFlags_NoSidePreview | ImGuiColorEditFlags_NoAlpha | ImGuiColorEditFlags_NoLabel);
s_pf_color = IM_COL32((int)(s_pf_custom_rgb[0] * 255.0f + 0.5f),
(int)(s_pf_custom_rgb[1] * 255.0f + 0.5f),
(int)(s_pf_custom_rgb[2] * 255.0f + 0.5f), 255);
ImGui::EndPopup();
}
}
// 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 grid (fixed height, scrolls internally).
{
float cell = 28.0f * dp, cellGap = 4.0f * dp;
ImGui::TextUnformatted(TR("portfolio_icon"));
ImGui::BeginChild("##pfIconGrid", ImVec2(0, 132.0f * dp), true);
ImDrawList* gdl = ImGui::GetWindowDrawList();
float availW = ImGui::GetContentRegionAvail().x;
int cols = std::max(1, (int)((availW + cellGap) / (cell + cellGap)));
int total = material::project_icons::walletIconCount() + 1; // slot 0 = "None"
int gcol = 0;
for (int k = 0; k < total; k++) {
if (gcol != 0) ImGui::SameLine(0, cellGap);
ImVec2 mn = ImGui::GetCursorScreenPos();
ImVec2 mx(mn.x + cell, mn.y + cell);
ImVec2 cc(mn.x + cell * 0.5f, mn.y + cell * 0.5f);
bool hov = ImGui::IsMouseHoveringRect(mn, mx);
bool isNone = (k == 0);
const char* nm2 = isNone ? "" : material::project_icons::walletIconName(k - 1);
bool sel = isNone ? s_pf_icon.empty() : (s_pf_icon == nm2);
if (sel) {
gdl->AddRectFilled(mn, mx, WithAlpha(Primary(), 40), 6.0f * dp);
gdl->AddRect(mn, mx, WithAlpha(Primary(), 120), 6.0f * dp, 0, 1.5f * dp);
} else if (hov) {
gdl->AddRectFilled(mn, mx, IM_COL32(255, 255, 255, 20), 6.0f * dp);
}
ImU32 icol = sel ? Primary() : (hov ? OnSurface() : OnSurfaceMedium());
if (isNone) {
float r = cell * 0.26f;
gdl->AddCircle(cc, r, icol, 0, 1.5f * dp);
gdl->AddLine(ImVec2(cc.x - r * 0.7f, cc.y + r * 0.7f),
ImVec2(cc.x + r * 0.7f, cc.y - r * 0.7f), icol, 1.5f * dp);
} else {
material::project_icons::drawByName(gdl, nm2, cc, icol, iconFont, iconFsz);
}
ImGui::PushID(k);
ImGui::InvisibleButton("##pfic", ImVec2(cell, cell));
if (ImGui::IsItemClicked()) s_pf_icon = isNone ? std::string() : std::string(nm2);
if (hov) material::Tooltip("%s", isNone ? TR("portfolio_no_icon") : nm2);
ImGui::PopID();
gcol = (gcol + 1) % cols;
}
ImGui::EndChild();
}
ImGui::Unindent(Layout::spacingSm());
}
// ---- PRICE (basis, manual price, shown fields, sparkline) ----
if (section(TR("portfolio_price"), s_pf_sec_price, prSummary)) {
ImGui::Indent(Layout::spacingSm());
const char* basisItems[4] = { TR("portfolio_price_usd"), TR("portfolio_price_btc"),
TR("portfolio_price_drgx"), TR("portfolio_price_manual") };
ImGui::SetNextItemWidth(-1);
ImGui::Combo("##pfBasis", &s_pf_price_basis, basisItems, 4);
if (s_pf_price_basis == 3) { // Manual: price-per-DRGX + currency label
float half = (ImGui::GetContentRegionAvail().x - Layout::spacingSm()) * 0.62f;
ImGui::SetNextItemWidth(half);
ImGui::InputDouble("##pfManPrice", &s_pf_manual_price, 0.0, 0.0, "%.6f");
if (s_pf_manual_price < 0.0) s_pf_manual_price = 0.0;
ImGui::SameLine();
ImGui::SetNextItemWidth(-1);
ImGui::InputTextWithHint("##pfManCcy", TR("portfolio_currency"), s_pf_manual_ccy, sizeof(s_pf_manual_ccy));
}
ImGui::Checkbox(DRAGONX_TICKER "##pfShowDrgx", &s_pf_show_drgx);
ImGui::SameLine();
ImGui::BeginDisabled(s_pf_price_basis == 2); // DRGX-only -> no value field
ImGui::Checkbox(TR("portfolio_show_value"), &s_pf_show_value);
ImGui::EndDisabled();
ImGui::SameLine();
ImGui::BeginDisabled(!(s_pf_price_basis == 0 || s_pf_price_basis == 1)); // 24h: live-market only
ImGui::Checkbox(TR("portfolio_show_24h"), &s_pf_show_24h);
ImGui::EndDisabled();
ImGui::BeginDisabled(!(s_pf_price_basis == 0 || s_pf_price_basis == 1));
ImGui::Checkbox(TR("portfolio_show_sparkline"), &s_pf_show_sparkline);
ImGui::SameLine();
ImGui::BeginDisabled(!s_pf_show_sparkline);
const char* spItems[5] = { TR("portfolio_spark_min"), TR("portfolio_spark_hour"),
TR("portfolio_spark_day"), TR("portfolio_spark_week"),
TR("portfolio_spark_month") };
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x);
ImGui::Combo("##pfSparkInterval", &s_pf_spark_interval, spItems, 5);
ImGui::EndDisabled();
ImGui::EndDisabled();
ImGui::Unindent(Layout::spacingSm());
}
// ---- ADDRESSES (search, filter, select) ----
if (section(TR("portfolio_addresses_hdr"), s_pf_sec_addresses, adSummary)) {
ImGui::Indent(Layout::spacingSm());
{
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()));
{
const char* flabels[3] = { TR("portfolio_select_all"), TR("shielded"), TR("transparent") };
for (int f = 0; f < 3; f++) {
if (f) ImGui::SameLine(0, 4.0f * dp);
bool active = (s_pf_type_filter == f);
if (active) ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(WithAlpha(Primary(), 200)));
if (ImGui::SmallButton((std::string(flabels[f]) + "##ptf" + std::to_string(f)).c_str()))
s_pf_type_filter = f;
if (active) ImGui::PopStyleColor();
}
ImGui::SameLine(0, Layout::spacingMd());
ImGui::Checkbox(TR("portfolio_funded"), &s_pf_funded_only);
}
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
std::string search = s_pf_search;
std::vector<const AddressInfo*> 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 (ImGui::SmallButton(TR("portfolio_select_shown")))
for (const AddressInfo* a : filtered) data::PortfolioEntryAdd(s_pf_addrs, a->address);
ImGui::SameLine();
if (ImGui::SmallButton(TR("portfolio_clear_sel"))) s_pf_addrs.clear();
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImGui::BeginChild("##pfAddrList", ImVec2(0, 300.0f * dp), true);
ImDrawList* ldl = ImGui::GetWindowDrawList();
float rowH = std::max(26.0f * dp, body2f->LegacySize + Layout::spacingSm());
float lw = ImGui::GetContentRegionAvail().x;
if (filtered.empty())
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_addr_match"));
for (const AddressInfo* ap : filtered) {
const AddressInfo& a = *ap;
bool inSet = data::PortfolioEntryContains(s_pf_addrs, a.address);
ImVec2 rmn = ImGui::GetCursorScreenPos();
ImVec2 rmx(rmn.x + lw, rmn.y + rowH);
bool rhov = ImGui::IsMouseHoveringRect(rmn, rmx);
if (inSet) ldl->AddRectFilled(rmn, rmx, WithAlpha(Primary(), 22), 4.0f);
else if (rhov) ldl->AddRectFilled(rmn, rmx, IM_COL32(255, 255, 255, 14), 4.0f);
float rcy = rmn.y + rowH * 0.5f;
float rx = rmn.x + Layout::spacingSm();
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();
}
ImGui::EndChild();
ImGui::Unindent(Layout::spacingSm());
}
}
ImGui::EndChild(); // ##pfDetail
// ================= FOOTER: delete / revert / save =================
ImGui::Separator();
{
ImGui::BeginDisabled(s_pf_sel < 0);
if (ImGui::Button(TR("portfolio_delete"))) {
auto entries = settings->getPortfolioEntries();
if (s_pf_sel >= 0 && s_pf_sel < (int)entries.size()) {
entries.erase(entries.begin() + s_pf_sel);
persist(entries);
if (entries.empty()) s_pf_sel = -1;
else { s_pf_sel = std::min(s_pf_sel, (int)entries.size() - 1); PortfolioBeginEdit(app, s_pf_sel); }
}
}
ImGui::EndDisabled();
float bw = 92.0f * dp, aw = bw * 2 + Layout::spacingSm();
ImGui::SameLine();
float availF = ImGui::GetContentRegionAvail().x;
if (availF > aw) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + availF - aw);
ImGui::BeginDisabled(s_pf_sel == -1);
if (ImGui::Button(TR("portfolio_revert"), ImVec2(bw, 0)))
PortfolioBeginEdit(app, s_pf_sel); // reload from the stored entry (or defaults for a draft)
ImGui::EndDisabled();
ImGui::SameLine();
ImGui::BeginDisabled(s_pf_label[0] == '\0');
if (ImGui::Button(TR("portfolio_save"), ImVec2(bw, 0))) commitIfNeeded();
ImGui::EndDisabled();
}
ImGui::EndChild(); // ##pfCard
ImGui::PopStyleColor();
ImGui::PopStyleVar(); // ChildRounding (WindowPadding was popped right 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;
// Commit named edits on any close path (X / Esc / outside-click); unnamed drafts are dropped.
if (!s_portfolio_editor_open) commitIfNeeded();
ImGui::End();
ImGui::PopStyleColor();
ImGui::PopStyleVar(3);
}
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;
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<PfCell> pfLayout((size_t)std::max(0, pfN));
{
std::vector<std::vector<char>> 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<char> 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<std::time_t> chartTimes;
s_price_history.clear();
for (const auto& pr : pfChartSeries(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.
s_history_initialized = true;
// 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;
s_history_initialized = false;
}
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();
s_history_initialized = false;
s_last_refresh_time = ImGui::GetTime();
}
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<ImVec2> 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
// "<time> ago" (rightmost = "now") derived from the sample's timestamp.
{
int nPts = (int)n;
int ticks = std::min(4, nPts);
for (int tk = 0; tk < ticks; tk++) {
float t = (ticks > 1) ? (float)tk / (float)(ticks - 1) : 1.0f;
float xpos = plotLeft + t * plotW;
int idx = (int)(t * (nPts - 1) + 0.5f);
float secAgo = (idx < (int)chartTimes.size()) ? (float)(nowSec - chartTimes[idx])
: (float)(nPts - 1 - idx) * 60.0f;
char tlbl[32];
if (secAgo < 45.0f) snprintf(tlbl, sizeof(tlbl), "%s", TR("market_now"));
else if (secAgo < 5400.0f) snprintf(tlbl, sizeof(tlbl), "~%.0fm", secAgo / 60.0f);
else if (secAgo < 172800.0f) snprintf(tlbl, sizeof(tlbl), "~%.0fh", secAgo / 3600.0f);
else if (secAgo < 1209600.0f) snprintf(tlbl, sizeof(tlbl), "~%.0fd", secAgo / 86400.0f);
else if (secAgo < 7776000.0f) snprintf(tlbl, sizeof(tlbl), "~%.0fw", secAgo / 604800.0f);
else snprintf(tlbl, sizeof(tlbl), "~%.0fmo", secAgo / 2592000.0f);
ImVec2 lblSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, tlbl);
float lx = (tk == 0) ? plotLeft
: (tk == ticks - 1) ? plotRight - lblSz.x
: xpos - lblSz.x * 0.5f;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(lx, plotBottom + 4), OnSurfaceDisabled(), tlbl);
}
}
// Hover crosshair + tooltip
ImVec2 mousePos = ImGui::GetIO().MousePos;
if (mousePos.x >= plotLeft && mousePos.x <= plotRight &&
mousePos.y >= plotTop && mousePos.y <= plotBottom + labelPadBottom)
{
float mx = mousePos.x - plotLeft;
float closest_t = mx / plotW;
int idx = (int)(closest_t * (n - 1) + 0.5f);
if (idx < 0) idx = 0;
if (idx >= (int)n) idx = (int)n - 1;
float px = points[idx].x;
float py = points[idx].y;
dl->AddLine(ImVec2(px, plotTop), ImVec2(px, plotBottom),
IM_COL32(255, 255, 255, 40), 1.0f);
dl->AddLine(ImVec2(plotLeft, py), ImVec2(plotRight, py),
IM_COL32(255, 255, 255, 40), 1.0f);
float hoverDotR = std::max(S.drawElement("tabs.market", "chart-hover-dot-min-radius").size, S.drawElement("tabs.market", "chart-hover-dot-radius").size * hs);
float hoverRingR = std::max(S.drawElement("tabs.market", "chart-hover-ring-min-radius").size, S.drawElement("tabs.market", "chart-hover-ring-radius").size * hs);
dl->AddCircleFilled(ImVec2(px, py), hoverDotR, dotCol);
dl->AddCircle(ImVec2(px, py), hoverRingR, IM_COL32(255, 255, 255, 80), 0, 1.5f);
// Tooltip: real date/time (from the series timestamps) + price at the hovered point.
// Sub-day intervals (Live/1H) show the time; day+ intervals show the date.
char whenBuf[32] = "";
if (idx < (int)chartTimes.size()) {
std::time_t tt = chartTimes[idx];
std::tm* tmv = std::localtime(&tt);
if (tmv) std::strftime(whenBuf, sizeof(whenBuf),
s_chart_interval <= 1 ? "%b %d %H:%M" : "%b %d, %Y", tmv);
}
if (whenBuf[0])
snprintf(buf, sizeof(buf), "%s \xC2\xB7 %s", whenBuf, FormatPrice(s_price_history[idx]).c_str());
else
snprintf(buf, sizeof(buf), "%s", FormatPrice(s_price_history[idx]).c_str());
ImVec2 tipSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
float tipPad = Layout::spacingSm() + Layout::spacingXs();
float tipX = px + 10;
float tipY = py - tipSz.y - tipPad * 2 - 4;
if (tipX + tipSz.x + tipPad * 2 > plotRight)
tipX = px - tipSz.x - tipPad * 2 - 10;
if (tipY < plotTop) tipY = py + 10;
ImVec2 tipMin(tipX, tipY);
ImVec2 tipMax(tipX + tipSz.x + tipPad * 2, tipY + tipSz.y + tipPad * 2);
dl->AddRectFilled(tipMin, tipMax, IM_COL32(20, 20, 30, 230), 4.0f);
dl->AddRect(tipMin, tipMax, IM_COL32(255, 255, 255, 30), 4.0f, 0, 1.0f);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(tipX + tipPad, tipY + tipPad), dotCol, buf);
}
} else {
// Empty state, centered in the plot area (the interval strip + refresh are already drawn).
const char* msg = TR("market_no_history");
ImVec2 ts = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, msg);
dl->AddText(sub1, sub1->LegacySize,
ImVec2(chartMin.x + (availWidth - ts.x) * 0.5f, (plotTop + plotBottom) * 0.5f - ts.y * 0.5f),
OnSurfaceDisabled(), msg);
}
ImGui::SetCursorScreenPos(ImVec2(chartMin.x, chartMin.y + chartH));
ImGui::Dummy(ImVec2(availWidth, 0));
}
// ================================================================
// PAIR SELECTOR — every trading pair across all exchanges shown as
// round buttons (flat; no exchange dropdown). Wraps to multiple rows.
// ================================================================
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.market", "exchange-top-gap").size));
{
float chipH = S.drawElement("tabs.market", "pair-chip-height").height;
float chipR = S.drawElement("tabs.market", "pair-chip-radius").radius;
float chipSpacing = S.drawElement("tabs.market", "pair-chip-spacing").size;
float innerGap = Layout::spacingSm();
float sidePad = Layout::spacingMd();
ImVec2 origin = ImGui::GetCursorScreenPos();
float left = origin.x;
float right = origin.x + availWidth;
float cx = left;
float cy = origin.y;
int btnCounter = 0;
for (int ex = 0; ex < (int)registry.size(); ex++) {
for (int pr = 0; pr < (int)registry[ex].pairs.size(); pr++) {
const std::string& pairName = registry[ex].pairs[pr].displayName;
const std::string& exName = registry[ex].name;
float pairW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, pairName.c_str()).x;
float exW = ovFont->CalcTextSizeA(ovFont->LegacySize, FLT_MAX, 0, exName.c_str()).x;
float cw = sidePad * 2.0f + pairW + innerGap + exW;
// Wrap to the next row when the pill would overflow (never on an empty row).
if (cx + cw > right && cx > left) {
cx = left;
cy += chipH + chipSpacing;
}
ImVec2 cMin(cx, cy);
ImVec2 cMax(cx + cw, cy + chipH);
bool selected = (ex == s_exchange_idx && pr == s_pair_idx);
bool hov = material::IsRectHovered(cMin, cMax);
ImU32 chipBg = selected ? WithAlpha(Primary(), 200)
: (hov ? WithAlpha(OnSurface(), 35) : WithAlpha(OnSurface(), 20));
ImU32 chipBorder = selected ? Primary() : WithAlpha(OnSurface(), 40);
ImU32 pairCol = selected ? IM_COL32(255, 255, 255, 255) : OnSurface();
ImU32 exCol = selected ? WithAlpha(IM_COL32(255, 255, 255, 255), 190) : OnSurfaceDisabled();
dl->AddRectFilled(cMin, cMax, chipBg, chipR);
dl->AddRect(cMin, cMax, chipBorder, chipR, 0, 1.0f);
float tx = cx + sidePad;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(tx, cy + (chipH - capFont->LegacySize) * 0.5f), pairCol, pairName.c_str());
dl->AddText(ovFont, ovFont->LegacySize,
ImVec2(tx + pairW + innerGap, cy + (chipH - ovFont->LegacySize) * 0.5f),
exCol, exName.c_str());
ImGui::SetCursorScreenPos(cMin);
snprintf(buf, sizeof(buf), "##PairBtn%d", btnCounter++);
if (ImGui::InvisibleButton(buf, ImVec2(cw, chipH))) {
if (ex != s_exchange_idx || pr != s_pair_idx) {
s_exchange_idx = ex;
s_pair_idx = pr;
s_history_initialized = false;
app->settings()->setSelectedExchange(exName);
app->settings()->setSelectedPair(pairName);
app->settings()->save();
app->refreshMarketData();
}
}
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
cx += cw + chipSpacing;
}
}
// Advance the cursor past the wrapped rows.
ImGui::SetCursorScreenPos(ImVec2(left, cy + chipH));
ImGui::Dummy(ImVec2(availWidth, 0));
// Attribution (moved here from the removed exchange-selector row).
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("market_attribution"));
if (!market.last_updated.empty()) {
ImGui::SameLine(0, 12);
snprintf(buf, sizeof(buf), " \xc2\xb7 Updated %s", market.last_updated.c_str());
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
}
ImGui::Dummy(ImVec2(0, gap));
}
// ================================================================
// PORTFOLIO — floating summary (no card background) + custom-group cards
// ================================================================
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("market_portfolio"));
// "Manage…" button, right-aligned on the header row — opens the portfolio editor.
{
const char* ml = TR("portfolio_manage");
float mBtnW = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, ml).x + Layout::spacingMd() * 2;
ImGui::SameLine();
float availX = ImGui::GetContentRegionAvail().x;
if (availX > mBtnW) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + availX - mBtnW);
if (ImGui::Button(ml, ImVec2(mBtnW, 0))) {
// Open the combined editor with a valid selection: first group, or a blank draft.
PortfolioBeginEdit(app, app->settings()->getPortfolioEntries().empty() ? -2 : 0);
s_portfolio_editor_open = true;
}
}
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
double total_balance = state.totalBalance;
double private_balance = state.privateBalance;
double transparent_balance = state.transparentBalance;
// No parent card background — the summary floats directly under the "MY DRGX" header.
ImVec2 cardMin = ImGui::GetCursorScreenPos();
float rightEdge = cardMin.x + availWidth;
float cx = cardMin.x;
float cy = cardMin.y;
// ---- SUMMARY: fiat value (hero) + 24h change, BTC right-aligned ----
if (market.price_usd > 0) {
double portfolio_usd = total_balance * market.price_usd;
if (portfolio_usd >= 1.0) snprintf(buf, sizeof(buf), "$%.2f USD", portfolio_usd);
else snprintf(buf, sizeof(buf), "$%.6f USD", portfolio_usd);
DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(cx, cy), Success(), buf);
float usdW = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, buf).x;
if (market.change_24h != 0.0) {
char chg[48];
snprintf(chg, sizeof(chg), "%+.2f%% %s", market.change_24h, TR("market_24h_change"));
ImU32 chgCol = market.change_24h >= 0 ? Success() : Error();
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cx + usdW + Layout::spacingMd(),
cy + (sub1->LegacySize - capFont->LegacySize)),
chgCol, chg);
}
double portfolio_btc = total_balance * market.price_btc;
snprintf(buf, sizeof(buf), "\xE2\x89\x88 %.8f BTC", portfolio_btc); // "≈ <n> BTC"
float btcW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf).x;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(rightEdge - btcW, cy + (sub1->LegacySize - capFont->LegacySize)),
OnSurfaceMedium(), buf);
} else {
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, cy), OnSurfaceDisabled(), TR("market_no_price"));
}
cy += sub1->LegacySize + Layout::spacingSm();
// DRGX balance + Z/T breakdown (right-aligned).
snprintf(buf, sizeof(buf), "%.8f %s", total_balance, DRAGONX_TICKER);
dl->AddText(body2, body2->LegacySize, ImVec2(cx, cy), OnSurface(), buf);
snprintf(buf, sizeof(buf), "Z %.4f \xC2\xB7 T %.4f", private_balance, transparent_balance);
float brkW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf).x;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(rightEdge - brkW, cy + 2), OnSurfaceDisabled(), buf);
cy += body2->LegacySize + Layout::spacingSm();
// Full-width shielded/transparent ratio bar + % label.
if (total_balance > 0) {
float barW = availWidth;
float shieldedRatio = (float)(private_balance / total_balance);
if (shieldedRatio > 1.0f) shieldedRatio = 1.0f;
if (shieldedRatio < 0.0f) shieldedRatio = 0.0f;
float shieldedW = barW * shieldedRatio;
float transpW = barW - shieldedW;
ImVec2 barStart(cx, cy);
dl->AddRectFilled(barStart, ImVec2(barStart.x + barW, barStart.y + ratioBarH),
IM_COL32(255, 255, 255, 10), 3.0f);
if (shieldedW > 0.5f)
dl->AddRectFilled(barStart, ImVec2(barStart.x + shieldedW, barStart.y + ratioBarH),
WithAlpha(Success(), 200),
transpW > 0.5f ? ImDrawFlags_RoundCornersLeft : ImDrawFlags_RoundCornersAll, 3.0f);
if (transpW > 0.5f)
dl->AddRectFilled(ImVec2(barStart.x + shieldedW, barStart.y),
ImVec2(barStart.x + barW, barStart.y + ratioBarH),
WithAlpha(Warning(), 200),
shieldedW > 0.5f ? ImDrawFlags_RoundCornersRight : ImDrawFlags_RoundCornersAll, 3.0f);
// market_pct_shielded is "%.0f%% Shielded" — pass a double (an int to %f is UB).
snprintf(buf, sizeof(buf), TR("market_pct_shielded"), static_cast<double>(shieldedRatio) * 100.0);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cx, cy + ratioBarH + Layout::spacingXs()), OnSurfaceDisabled(), buf);
}
// ---- CUSTOM GROUPS — dashboard grid: drag a card to move it, drag its corner to resize.
// A subtle dot grid shows while rearranging. Click (no drag) opens the editor. ----
if (pfN > 0) {
float gy = cardMin.y + pfSummaryH;
ImGuiIO& io = ImGui::GetIO();
bool interacting = (s_pf_drag >= 0 || s_pf_resize >= 0);
float rh = 20.0f * mktDp; // corner resize-grip size (generous so a short resize is easy to grab)
auto cellMin = [&](const PfCell& L) {
return ImVec2(cx + L.col * (pfCellW + pfCardGap), gy + L.row * (pfCellH + pfCardGap));
};
auto cellSize = [&](int w, int h) {
return ImVec2(w * pfCellW + (w - 1) * pfCardGap, h * pfCellH + (h - 1) * pfCardGap);
};
// Draw one card's content into [gcMin, gcMax].
// Stat tile: header (icon + name, 24h badge) / hero value (large, centered band) /
// footer (DRGX amount) + a dedicated sparkline strip along the bottom.
auto drawCard = [&](ImVec2 gcMin, ImVec2 gcMax, const config::Settings::PortfolioEntry& e, bool hov) {
// The grid is gapless, so inset the card within its cell block for separation.
gcMin.x += pfInset; gcMin.y += pfInset; gcMax.x -= pfInset; gcMax.y -= pfInset;
double bal = data::SumPortfolioBalance(e.addresses, state.addresses);
ImU32 accent = e.color ? (ImU32)e.color : 0;
float pad = pfCardPad;
GlassPanelSpec gcGlass; gcGlass.rounding = 10.0f;
gcGlass.fillAlpha = hov ? 34 : 22; gcGlass.borderAlpha = 40;
DrawGlassPanel(dl, gcMin, gcMax, gcGlass);
// Accented groups get a colored outline around the whole card (configurable opacity);
// others get a subtle hover ring.
if (accent) {
int oa = (int)(std::max(0, std::min(100, e.outlineOpacity)) * 2.55f + 0.5f);
dl->AddRect(gcMin, gcMax, WithAlpha(accent, oa), 10.0f, 0, hov ? 2.5f : 2.0f);
} else if (hov) dl->AddRect(gcMin, gcMax, WithAlpha(OnSurface(), 70), 10.0f, 0, 1.0f);
auto fit = [&](std::string s, ImFont* f, float maxW) {
bool t = false;
while (s.size() > 1 && f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, s.c_str()).x > maxW) { s.pop_back(); t = true; }
if (t) s += "\xE2\x80\xA6";
return s;
};
// Value + DRGX strings per basis.
char vb[80]; std::string valueStr; bool hasValue = false;
if (e.priceBasis == 0 && market.price_usd > 0) { snprintf(vb, sizeof(vb), "$%.2f", bal * market.price_usd); valueStr = vb; hasValue = true; }
else if (e.priceBasis == 1 && market.price_btc > 0) { snprintf(vb, sizeof(vb), "%.6f BTC", bal * market.price_btc); valueStr = vb; hasValue = true; }
else if (e.priceBasis == 3 && e.manualPrice > 0.0) { snprintf(vb, sizeof(vb), "%.2f %s", bal * e.manualPrice, e.manualCurrency.c_str()); valueStr = vb; hasValue = true; }
char db[48]; snprintf(db, sizeof(db), "%.4f %s", bal, DRAGONX_TICKER);
bool heroIsValue = e.showValue && hasValue;
std::string heroStr = heroIsValue ? valueStr : (e.showDrgx ? std::string(db) : (hasValue ? valueStr : std::string(db)));
std::string footStr = (heroIsValue && e.showDrgx) ? std::string(db) : std::string();
// Compact stat card: name (+ secondary DRGX) on the left; value with % change stacked
// below it in the top-right corner; the sparkline fills the body — so it renders even
// on a 2-cell card. Tighter padding on short cards.
float cardHeight = gcMax.y - gcMin.y;
bool cardShort = cardHeight < 90.0f * mktDp;
float hpad = cardShort ? Layout::spacingSm() : pad;
float hleft = gcMin.x + hpad, hright = gcMax.x - hpad;
float hAvailW = hright - hleft;
float topY = gcMin.y + hpad;
// Right column: value (top-right), % change just below it.
float rightBottom = topY;
float valLeft = hright;
bool showChange = e.show24h && (e.priceBasis == 0 || e.priceBasis == 1) && market.change_24h != 0.0;
if (!heroStr.empty()) {
ImFont* vf = sub1;
std::string vs = fit(heroStr, vf, hAvailW * 0.66f);
float vw = vf->CalcTextSizeA(vf->LegacySize, FLT_MAX, 0, vs.c_str()).x;
valLeft = hright - vw;
dl->AddText(vf, vf->LegacySize, ImVec2(valLeft, topY), OnSurface(), vs.c_str());
rightBottom = topY + vf->LegacySize;
if (showChange) {
char cb[32]; snprintf(cb, sizeof(cb), "%+.2f%%", market.change_24h);
float cw = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, cb).x;
dl->AddText(capFont, capFont->LegacySize, ImVec2(hright - cw, rightBottom + 1.0f * mktDp),
market.change_24h >= 0 ? Success() : Error(), cb);
valLeft = std::min(valLeft, hright - cw);
rightBottom += capFont->LegacySize + 1.0f * mktDp;
}
}
// Left column: icon + name (larger, top), secondary DRGX amount below (always shown).
float nameX = hleft;
if (!e.icon.empty()) {
material::project_icons::drawByName(dl, e.icon.c_str(),
ImVec2(nameX + sub1->LegacySize * 0.5f, topY + sub1->LegacySize * 0.5f),
accent ? accent : OnSurfaceMedium(), Type().iconSmall(), sub1->LegacySize);
nameX += sub1->LegacySize + Layout::spacingSm();
}
float nameMaxW = std::max(24.0f * mktDp, valLeft - Layout::spacingSm() - nameX);
dl->AddText(sub1, sub1->LegacySize, ImVec2(nameX, topY), OnSurfaceMedium(),
fit(e.label, sub1, nameMaxW).c_str());
float leftBottom = topY + sub1->LegacySize;
if (!footStr.empty()) {
dl->AddText(capFont, capFont->LegacySize, ImVec2(nameX, leftBottom + 1.0f * mktDp),
OnSurfaceDisabled(), fit(footStr, capFont, nameMaxW).c_str());
leftBottom += capFont->LegacySize + 1.0f * mktDp;
}
// Body: sparkline fills from below the header to the card bottom.
if (e.showSparkline && (e.priceBasis == 0 || e.priceBasis == 1)) {
std::vector<double> hh = pfSparklineSeries(market, e.sparklineInterval);
if (hh.size() >= 2) {
float sparkTop = std::max(leftBottom, rightBottom) + Layout::spacingXs();
float sparkBot = gcMax.y - hpad;
if (sparkBot - sparkTop >= 8.0f * mktDp)
pfDrawSparklineFilled(dl, ImVec2(hleft, sparkTop), ImVec2(hright, sparkBot),
hh, hh.back() >= hh.front() ? Success() : Error());
}
}
};
// Subtle dot grid while rearranging (fine 32px cells). Fill the whole visible grid area
// — down to the bottom of the scroll viewport, not just the occupied rows — so a card can
// be dropped anywhere, including the empty space below the current cards.
if (interacting) {
ImU32 dotc = WithAlpha(OnSurface(), 38);
float visibleBottom = dl->GetClipRectMax().y; // bottom edge of the scroll child
int rowsToBottom = (int)((visibleBottom - gy) / pfCellH) + 1;
int lastRow = std::max(pfMaxRow + 2, rowsToBottom);
for (int r = 0; r <= lastRow; r++)
for (int c = 0; c <= pfCols; c++)
dl->AddCircleFilled(ImVec2(cx + c * pfCellW, gy + r * pfCellH), 1.2f * mktDp, dotc);
}
// Draw + hit-test each card. Skip the one being dragged/resized (drawn specially below).
for (int i = 0; i < pfN; i++) {
ImVec2 gcMin = cellMin(pfLayout[i]);
ImVec2 sz = cellSize(pfLayout[i].w, pfLayout[i].h);
ImVec2 gcMax(gcMin.x + sz.x, gcMin.y + sz.y);
if (i == s_pf_drag && s_pf_moved) {
dl->AddRect(gcMin, gcMax, WithAlpha(OnSurface(), 45), 10.0f, 0, 1.0f); // source ghost
continue;
}
if (i == s_pf_resize) continue;
bool hov = !interacting && material::IsRectHovered(gcMin, gcMax);
drawCard(gcMin, gcMax, pfEntriesGeo[i], hov);
// Resize grip (bottom-right).
bool gripHov = material::IsRectHovered(ImVec2(gcMax.x - rh, gcMax.y - rh), gcMax);
ImU32 gcc = WithAlpha(OnSurface(), gripHov ? 170 : 80);
for (int k = 1; k <= 2; k++) {
float o = k * 4.0f * mktDp;
dl->AddLine(ImVec2(gcMax.x - o, gcMax.y - 3.0f), ImVec2(gcMax.x - 3.0f, gcMax.y - o), gcc, 1.3f * mktDp);
}
// Start a gesture only when idle. One body button; the press location (corner vs body)
// decides resize vs move — avoids overlapping-item hit-test issues.
if (s_pf_drag < 0 && s_pf_resize < 0) {
ImGui::PushID(3000 + i);
ImGui::SetCursorScreenPos(gcMin);
ImGui::InvisibleButton("##pfBody", sz);
bool bodyAct = ImGui::IsItemActivated();
bool bodyHov = ImGui::IsItemHovered();
ImGui::PopID();
if (bodyHov) ImGui::SetMouseCursor(gripHov ? ImGuiMouseCursor_ResizeNWSE : ImGuiMouseCursor_Hand);
if (bodyAct) {
if (gripHov) { s_pf_resize = i; s_pf_old = pfLayout[i]; }
else {
s_pf_drag = i; s_pf_moved = false; s_pf_old = pfLayout[i];
s_pf_press = io.MousePos;
s_pf_grab = ImVec2(io.MousePos.x - gcMin.x, io.MousePos.y - gcMin.y);
}
}
}
}
auto pfPersist = [&](const std::vector<config::Settings::PortfolioEntry>& v) {
app->settings()->setPortfolioEntries(v); app->settings()->save();
};
// ----- Resize in progress (corner drag) -----
if (s_pf_resize >= 0 && s_pf_resize < pfN) {
int i = s_pf_resize;
ImVec2 mn = cellMin(pfLayout[i]);
int nw = std::max(pfMinW, std::min(pfCols - pfLayout[i].col,
(int)((io.MousePos.x - mn.x + pfCardGap) / (pfCellW + pfCardGap) + 0.5f)));
int nh = std::max(pfMinH, (int)((io.MousePos.y - mn.y + pfCardGap) / (pfCellH + pfCardGap) + 0.5f));
ImVec2 sz = cellSize(nw, nh), mx(mn.x + sz.x, mn.y + sz.y);
drawCard(mn, mx, pfEntriesGeo[i], true);
dl->AddRect(mn, mx, WithAlpha(Primary(), 200), 10.0f, 0, 2.0f * mktDp);
// Live size readout (e.g. "8\xC3\x972") so the reached grid span is visible — the
// minimum is pfMinW x pfMinH (8x2).
{
char szb[16]; snprintf(szb, sizeof(szb), "%d\xC3\x97%d", nw, nh);
ImVec2 tsz = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, szb);
ImVec2 tp((mn.x + mx.x) * 0.5f - tsz.x * 0.5f, (mn.y + mx.y) * 0.5f - tsz.y * 0.5f);
dl->AddRectFilled(ImVec2(tp.x - 6, tp.y - 3), ImVec2(tp.x + tsz.x + 6, tp.y + tsz.y + 3),
IM_COL32(20, 20, 30, 205), 4.0f);
dl->AddText(sub1, sub1->LegacySize, tp, OnSurface(), szb);
}
ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNWSE);
if (!io.MouseDown[0]) {
auto entries = app->settings()->getPortfolioEntries();
if (i < (int)entries.size()) {
entries[i].gridCol = pfLayout[i].col; entries[i].gridRow = pfLayout[i].row;
entries[i].gridW = nw; entries[i].gridH = nh;
pfPersist(entries);
}
s_pf_resize = -1;
}
}
// ----- Drag in progress -----
if (s_pf_drag >= 0 && s_pf_drag < pfN) {
int i = s_pf_drag;
if (io.MouseDown[0]) {
if (std::abs(io.MousePos.x - s_pf_press.x) > 4.0f || std::abs(io.MousePos.y - s_pf_press.y) > 4.0f)
s_pf_moved = true;
if (s_pf_moved) {
int w = pfLayout[i].w, h = pfLayout[i].h;
float tx = io.MousePos.x - s_pf_grab.x, ty = io.MousePos.y - s_pf_grab.y;
int tc = std::max(0, std::min(pfCols - w, (int)((tx - cx) / (pfCellW + pfCardGap) + 0.5f)));
int tr = std::max(0, (int)((ty - gy) / (pfCellH + pfCardGap) + 0.5f));
s_pf_target = {tc, tr, w, h};
ImVec2 tmn(cx + tc * (pfCellW + pfCardGap), gy + tr * (pfCellH + pfCardGap));
ImVec2 tsz = cellSize(w, h), tmx(tmn.x + tsz.x, tmn.y + tsz.y);
dl->AddRectFilled(tmn, tmx, WithAlpha(Primary(), 25), 10.0f);
dl->AddRect(tmn, tmx, WithAlpha(Primary(), 200), 10.0f, 0, 2.0f * mktDp);
ImVec2 fmn(tx, ty), fmx(fmn.x + tsz.x, fmn.y + tsz.y);
drawCard(fmn, fmx, pfEntriesGeo[i], true); // dragged card follows the mouse
ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeAll);
}
} else {
if (!s_pf_moved) { PortfolioBeginEdit(app, i); s_portfolio_editor_open = true; }
else {
auto entries = app->settings()->getPortfolioEntries();
if (i < (int)entries.size()) {
int occ = -1;
for (int j = 0; j < pfN; j++)
if (j != i && pfLayout[j].col == s_pf_target.col && pfLayout[j].row == s_pf_target.row) { occ = j; break; }
entries[i].gridCol = s_pf_target.col; entries[i].gridRow = s_pf_target.row;
if (occ >= 0 && occ < (int)entries.size()) {
entries[occ].gridCol = s_pf_old.col; entries[occ].gridRow = s_pf_old.row;
}
pfPersist(entries);
}
}
s_pf_drag = -1; s_pf_moved = false;
}
}
}
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + portfolioH));
ImGui::Dummy(ImVec2(availWidth, 0));
ImGui::Dummy(ImVec2(0, gap));
}
ImGui::EndChild(); // ##MarketScroll
// Portfolio editor modal (rendered outside the scroll child so it overlays the page).
RenderPortfolioEditor(app);
}
} // namespace ui
} // namespace dragonx