Replace the drag/resize dashboard grid on the Market tab's portfolio with
three full-width row styles — compact (single-line), detailed (two-line),
and value-hero — cycled with Left/Right arrows like the Overview layouts.
Rows live in an internal scroll region bounded to the portfolio's height
budget, so the section stays put as groups are added.
- pfDrawRow renders each group in the selected style, reusing the icon /
value / DRGX / 24h / sparkline pieces; per-entry field toggles honored.
- portfolio_style setting (0/1/2) persisted; arrow-key switcher + toast.
- Portfolio groups gain a per-wallet scope: new groups default to the
active wallet's identity hash; legacy groups (empty scope) stay visible
in every wallet. Scope filter drives both the height budget and the rows.
- Remove the now-dead grid machinery (mktDrawCard, pfComputeGridLayout,
PfCell, PfGridDrag, MktCardCtx + MktCtx grid fields) — net code drop.
- Full UI sweep: seed demo portfolio groups + wavy price history and add
market-rows-{compact,detailed,featured} surfaces so the styles are
captured per skin (snapshot/restore the real portfolio + market data).
- i18n: portfolio_style_{label,compact,detailed,featured} + 8 langs; CJK
subset rebuilt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1903 lines
101 KiB
C++
1903 lines
101 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/market_series.h"
|
|
#include "../../data/exchange_info.h"
|
|
#include "../../util/i18n.h"
|
|
#include "../schema/ui_schema.h"
|
|
#include "../material/type.h"
|
|
#include "../material/draw_helpers.h"
|
|
#include "../material/overlay_scroll.h"
|
|
#include "../material/colors.h"
|
|
#include "../material/typography.h"
|
|
#include "../material/project_icons.h"
|
|
#include "../notifications.h"
|
|
#include "../effects/imgui_acrylic.h"
|
|
#include "../effects/scroll_fade_shader.h"
|
|
#include "../effects/low_spec.h"
|
|
#include "../../util/text_format.h"
|
|
#include "../../embedded/IconsMaterialDesign.h"
|
|
#include "../../util/platform.h"
|
|
#include "../layout.h"
|
|
#include "imgui.h"
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <ctime>
|
|
|
|
namespace dragonx {
|
|
namespace ui {
|
|
|
|
using namespace material;
|
|
|
|
// ---- Market tab persistent state ----
|
|
struct MarketViewState {
|
|
std::vector<double> history; // mirror of WalletState market.price_history
|
|
int exchangeIdx = 0; // selected exchange / pair
|
|
int pairIdx = 0;
|
|
bool stateLoaded = false;
|
|
int chartInterval = 4; // main chart range: 0=Live 1=1H 2=1D 3=1W 4=1M (default 1M)
|
|
};
|
|
static MarketViewState s_mkt;
|
|
|
|
// The effective exchange registry: the live CoinGecko list when available, else the
|
|
// compiled-in fallback.
|
|
static const std::vector<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_mkt.stateLoaded || !settings) return;
|
|
s_mkt.stateLoaded = true;
|
|
|
|
std::string savedExchange = settings->getSelectedExchange();
|
|
std::string savedPair = settings->getSelectedPair();
|
|
|
|
for (int ei = 0; ei < (int)registry.size(); ei++) {
|
|
if (registry[ei].name == savedExchange) {
|
|
s_mkt.exchangeIdx = ei;
|
|
for (int pi = 0; pi < (int)registry[ei].pairs.size(); pi++) {
|
|
if (registry[ei].pairs[pi].displayName == savedPair) {
|
|
s_mkt.pairIdx = pi;
|
|
break;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper: format compact currency
|
|
static std::string FormatCompactUSD(double val)
|
|
{
|
|
char buf[64];
|
|
if (val >= 1e9) snprintf(buf, sizeof(buf), "$%.2fB", val / 1e9);
|
|
else if (val >= 1e6) snprintf(buf, sizeof(buf), "$%.2fM", val / 1e6);
|
|
else if (val >= 1e3) snprintf(buf, sizeof(buf), "$%.2fK", val / 1e3);
|
|
else snprintf(buf, sizeof(buf), "$%.2f", val);
|
|
return std::string(buf);
|
|
}
|
|
|
|
// Helper: format price to sensible precision
|
|
static std::string FormatPrice(double price)
|
|
{
|
|
char buf[64];
|
|
if (price >= 0.01) snprintf(buf, sizeof(buf), "$%.4f", price);
|
|
else if (price >= 0.0001) snprintf(buf, sizeof(buf), "$%.6f", price);
|
|
else snprintf(buf, sizeof(buf), "$%.8f", price);
|
|
return std::string(buf);
|
|
}
|
|
|
|
// ---- Portfolio editor (modal) working state ----
|
|
struct PfEditState {
|
|
bool open = false; // modal visible (queried by PortfolioEditorActive())
|
|
int sel = -1; // selected group index in the master list; -1 = none
|
|
int section = 0; // right-pane segmented control: 0=Appearance 1=Price 2=Addresses
|
|
// (The blur backdrop + capture-once are owned by material::BeginOverlayDialog now.)
|
|
// Working copy of the group being edited.
|
|
char label[64] = {0};
|
|
std::vector<std::string> addrs;
|
|
std::string icon; // selected wallet-icon name ("" = none)
|
|
unsigned int color = 0; // selected accent (packed IM_COL32; 0 = default)
|
|
int outlineOpacity = 25; // accent-outline opacity (percent)
|
|
char search[64] = {0}; // address-list filter text
|
|
char iconSearch[64] = {0}; // icon-picker filter text
|
|
int typeFilter = 0; // 0 = all, 1 = shielded, 2 = transparent
|
|
bool fundedOnly = false;
|
|
effects::ScrollFadeShader iconFade; // edge-fade shaders for the two internal scroll regions
|
|
effects::ScrollFadeShader addrFade;
|
|
float customRgb[3] = {0.31f, 0.62f, 1.0f}; // working RGB for the custom color picker
|
|
// Price-data config (working copy while editing a group).
|
|
int priceBasis = 0;
|
|
double manualPrice = 0.0;
|
|
char manualCcy[12] = "USD";
|
|
bool showDrgx = true;
|
|
bool showValue = true;
|
|
bool show24h = false;
|
|
bool showSparkline = false;
|
|
int sparkInterval = 0; // 0=min 1=hour 2=day 3=week 4=month
|
|
};
|
|
static PfEditState s_pfEdit;
|
|
|
|
// The pure price-series math lives in data/market_series.h; the selected chart range is
|
|
// s_mkt.chartInterval (0=Live 1=1H 2=1D 3=1W 4=1M, session-scoped, default 1M).
|
|
|
|
|
|
// A group's value trend tracks the DRGX price, so a group sparkline plots the price history
|
|
// (normalized) across a rect. Colored by overall direction. Only meaningful for market bases.
|
|
// Normalize a numeric series into evenly-spaced screen points within [mn, mx], y scaled to the
|
|
// series min..max. Returns false (leaving `out` untouched) when there are fewer than 2 points.
|
|
static bool pfProjectSeries(const std::vector<double>& hist, ImVec2 mn, ImVec2 mx,
|
|
std::vector<ImVec2>& out)
|
|
{
|
|
int n = (int)hist.size();
|
|
if (n < 2) return false;
|
|
double lo = *std::min_element(hist.begin(), hist.end());
|
|
double hi = *std::max_element(hist.begin(), hist.end());
|
|
if (hi <= lo) hi = lo + 1e-9;
|
|
float w = mx.x - mn.x, h = mx.y - mn.y;
|
|
out.resize(n);
|
|
for (int i = 0; i < n; i++) {
|
|
float t = (float)i / (float)(n - 1);
|
|
out[i] = ImVec2(mn.x + t * w, mx.y - (float)((hist[i] - lo) / (hi - lo)) * h);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static void pfDrawSparkline(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
|
|
const std::vector<double>& hist, ImU32 col)
|
|
{
|
|
std::vector<ImVec2> 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<double>& hist, ImU32 lineCol)
|
|
{
|
|
std::vector<ImVec2> pts;
|
|
if (!pfProjectSeries(hist, mn, mx, pts)) return;
|
|
int n = (int)pts.size();
|
|
for (int i = 0; i < n; i++) dl->PathLineTo(pts[i]);
|
|
dl->PathLineTo(ImVec2(pts[n - 1].x, mx.y));
|
|
dl->PathLineTo(ImVec2(pts[0].x, mx.y));
|
|
dl->PathFillConcave(WithAlpha(lineCol, 28));
|
|
dl->AddPolyline(pts.data(), n, WithAlpha(lineCol, 220), ImDrawFlags_None, 1.4f);
|
|
}
|
|
|
|
|
|
// Build the right-hand amount strings for a group card / preview from its price-data config.
|
|
struct PfDisplay {
|
|
std::string primary; // name-row, top-right (DRGX amount or the value)
|
|
std::string secondary; // below primary (the value, when DRGX is also shown)
|
|
std::string change; // "+2.34%" (live-market bases only) or empty
|
|
ImU32 changeCol = 0;
|
|
};
|
|
static PfDisplay pfBuildDisplay(const config::Settings::PortfolioEntry& e, double bal,
|
|
const MarketInfo& market)
|
|
{
|
|
PfDisplay d;
|
|
char b[80];
|
|
std::string valueStr; bool hasValue = false;
|
|
if (e.priceBasis == 0 && market.price_usd > 0) {
|
|
snprintf(b, sizeof(b), "$%.2f", bal * market.price_usd); valueStr = b; hasValue = true;
|
|
} else if (e.priceBasis == 1 && market.price_btc > 0) {
|
|
snprintf(b, sizeof(b), "%.8f BTC", bal * market.price_btc); valueStr = b; hasValue = true;
|
|
} else if (e.priceBasis == 3 && e.manualPrice > 0.0) {
|
|
snprintf(b, sizeof(b), "%.2f %s", bal * e.manualPrice, e.manualCurrency.c_str());
|
|
valueStr = b; hasValue = true;
|
|
}
|
|
std::string drgxStr;
|
|
if (e.showDrgx) { snprintf(b, sizeof(b), "%.4f %s", bal, DRAGONX_TICKER); drgxStr = b; }
|
|
if (e.showDrgx) {
|
|
d.primary = drgxStr;
|
|
if (e.showValue && hasValue) d.secondary = valueStr;
|
|
} else if (e.showValue && hasValue) {
|
|
d.primary = valueStr;
|
|
}
|
|
if (e.show24h && (e.priceBasis == 0 || e.priceBasis == 1) && market.change_24h != 0.0) {
|
|
snprintf(b, sizeof(b), "%+.2f%%", market.change_24h);
|
|
d.change = b;
|
|
d.changeCol = market.change_24h >= 0 ? material::Success() : material::Error();
|
|
}
|
|
return d;
|
|
}
|
|
|
|
// Preset accent palette for portfolio groups (0 slot = "default / no color").
|
|
static const ImU32 kPfPalette[] = {
|
|
IM_COL32(0x4F, 0x9D, 0xFF, 0xFF), // blue
|
|
IM_COL32(0x3D, 0xDC, 0x84, 0xFF), // green
|
|
IM_COL32(0xFF, 0xB0, 0x3D, 0xFF), // amber
|
|
IM_COL32(0xFF, 0x6B, 0x6B, 0xFF), // red
|
|
IM_COL32(0xB0, 0x7B, 0xFF, 0xFF), // purple
|
|
IM_COL32(0x4F, 0xD6, 0xD6, 0xFF), // teal
|
|
IM_COL32(0xFF, 0x8F, 0xC7, 0xFF), // pink
|
|
IM_COL32(0xC7, 0xCE, 0xD6, 0xFF), // slate
|
|
IM_COL32(0x6C, 0x7B, 0xFF, 0xFF), // indigo
|
|
IM_COL32(0x74, 0xC0, 0xFC, 0xFF), // sky
|
|
IM_COL32(0x3D, 0xD6, 0xF5, 0xFF), // cyan
|
|
IM_COL32(0x63, 0xE6, 0xBE, 0xFF), // mint
|
|
IM_COL32(0x2F, 0xB3, 0x80, 0xFF), // emerald
|
|
IM_COL32(0xA8, 0xE0, 0x5F, 0xFF), // lime
|
|
IM_COL32(0xB5, 0xB8, 0x4D, 0xFF), // olive
|
|
IM_COL32(0xF5, 0xC5, 0x42, 0xFF), // gold
|
|
IM_COL32(0xFF, 0x9F, 0x45, 0xFF), // orange
|
|
IM_COL32(0xFF, 0x8A, 0x65, 0xFF), // coral
|
|
IM_COL32(0xE6, 0x49, 0x80, 0xFF), // crimson
|
|
IM_COL32(0xFF, 0x8B, 0x94, 0xFF), // rose
|
|
IM_COL32(0xE5, 0x99, 0xF7, 0xFF), // magenta
|
|
IM_COL32(0x97, 0x75, 0xFA, 0xFF), // violet
|
|
IM_COL32(0xB1, 0x97, 0xFC, 0xFF), // lavender
|
|
IM_COL32(0xB0, 0x89, 0x68, 0xFF), // brown
|
|
IM_COL32(0x8F, 0xA3, 0xB8, 0xFF), // steel
|
|
};
|
|
|
|
// Populate the working buffers for a new (index < 0) or existing entry.
|
|
static void PortfolioBeginEdit(App* app, int index)
|
|
{
|
|
const auto& entries = app->settings()->getPortfolioEntries();
|
|
s_pfEdit.sel = index;
|
|
if (index >= 0 && index < (int)entries.size()) {
|
|
const auto& src = entries[index];
|
|
snprintf(s_pfEdit.label, sizeof(s_pfEdit.label), "%s", src.label.c_str());
|
|
s_pfEdit.addrs = src.addresses;
|
|
s_pfEdit.icon = src.icon;
|
|
s_pfEdit.color = src.color;
|
|
s_pfEdit.outlineOpacity = src.outlineOpacity;
|
|
s_pfEdit.priceBasis = src.priceBasis;
|
|
s_pfEdit.manualPrice = src.manualPrice;
|
|
snprintf(s_pfEdit.manualCcy, sizeof(s_pfEdit.manualCcy), "%s", src.manualCurrency.c_str());
|
|
s_pfEdit.showDrgx = src.showDrgx;
|
|
s_pfEdit.showValue = src.showValue;
|
|
s_pfEdit.show24h = src.show24h;
|
|
s_pfEdit.showSparkline = src.showSparkline;
|
|
s_pfEdit.sparkInterval = src.sparklineInterval;
|
|
} else {
|
|
s_pfEdit.label[0] = '\0';
|
|
s_pfEdit.addrs.clear();
|
|
s_pfEdit.icon.clear();
|
|
s_pfEdit.color = 0;
|
|
s_pfEdit.outlineOpacity = 25;
|
|
s_pfEdit.priceBasis = 0;
|
|
s_pfEdit.manualPrice = 0.0;
|
|
snprintf(s_pfEdit.manualCcy, sizeof(s_pfEdit.manualCcy), "USD");
|
|
s_pfEdit.showDrgx = true;
|
|
s_pfEdit.showValue = true;
|
|
s_pfEdit.show24h = false;
|
|
s_pfEdit.showSparkline = false;
|
|
s_pfEdit.sparkInterval = 0;
|
|
}
|
|
s_pfEdit.search[0] = '\0';
|
|
s_pfEdit.typeFilter = 0;
|
|
s_pfEdit.fundedOnly = false;
|
|
}
|
|
|
|
// ---- Portfolio-editor persistence helpers (operate on the s_pfEdit working state) ----
|
|
|
|
// Build a PortfolioEntry from the working set, preserving off-form fields (grid geometry) from base.
|
|
static config::Settings::PortfolioEntry pfBuildWorking(const config::Settings::PortfolioEntry& base)
|
|
{
|
|
config::Settings::PortfolioEntry e = base;
|
|
e.label = s_pfEdit.label; e.addresses = s_pfEdit.addrs; e.icon = s_pfEdit.icon;
|
|
e.color = s_pfEdit.color; e.outlineOpacity = s_pfEdit.outlineOpacity;
|
|
e.priceBasis = s_pfEdit.priceBasis; e.manualPrice = s_pfEdit.manualPrice; e.manualCurrency = s_pfEdit.manualCcy;
|
|
e.showDrgx = s_pfEdit.showDrgx; e.showValue = s_pfEdit.showValue; e.show24h = s_pfEdit.show24h;
|
|
e.showSparkline = s_pfEdit.showSparkline; e.sparklineInterval = s_pfEdit.sparkInterval;
|
|
return e;
|
|
}
|
|
|
|
// True when the working set matches a stored entry (i.e. there are no uncommitted edits).
|
|
static bool pfWorkingMatches(const config::Settings::PortfolioEntry& e)
|
|
{
|
|
return e.label == std::string(s_pfEdit.label) && e.addresses == s_pfEdit.addrs && e.icon == s_pfEdit.icon
|
|
&& e.color == s_pfEdit.color && e.outlineOpacity == s_pfEdit.outlineOpacity
|
|
&& e.priceBasis == s_pfEdit.priceBasis && e.manualPrice == s_pfEdit.manualPrice
|
|
&& e.manualCurrency == std::string(s_pfEdit.manualCcy)
|
|
&& e.showDrgx == s_pfEdit.showDrgx && e.showValue == s_pfEdit.showValue && e.show24h == s_pfEdit.show24h
|
|
&& e.showSparkline == s_pfEdit.showSparkline && e.sparklineInterval == s_pfEdit.sparkInterval;
|
|
}
|
|
|
|
static void pfPersist(config::Settings* settings, const std::vector<config::Settings::PortfolioEntry>& entries)
|
|
{
|
|
settings->setPortfolioEntries(entries);
|
|
settings->save();
|
|
}
|
|
|
|
// Persist the working group when it is named and actually changed; unnamed drafts are dropped.
|
|
// New groups default to the active wallet's scope (per-wallet visibility); existing groups keep
|
|
// whatever scope they already had.
|
|
static void pfCommitIfNeeded(App* app)
|
|
{
|
|
config::Settings* settings = app->settings();
|
|
if (s_pfEdit.label[0] == '\0') return;
|
|
auto entries = settings->getPortfolioEntries();
|
|
bool existing = (s_pfEdit.sel >= 0 && s_pfEdit.sel < (int)entries.size());
|
|
config::Settings::PortfolioEntry base;
|
|
if (existing) { base = entries[s_pfEdit.sel]; if (pfWorkingMatches(base)) return; }
|
|
auto e = pfBuildWorking(base);
|
|
if (!existing) e.scope = app->activeWalletIdentityHash();
|
|
if (existing) entries[s_pfEdit.sel] = e;
|
|
else { entries.push_back(e); s_pfEdit.sel = (int)entries.size() - 1; }
|
|
pfPersist(settings, entries);
|
|
}
|
|
|
|
// ---- Detail-pane section renderers (drawn inside ##pfDetailBody; operate on s_pfEdit) ----
|
|
|
|
// Appearance: accent-color picker (+ custom), outline-opacity slider, icon grid.
|
|
static void pfDrawAppearanceSection()
|
|
{
|
|
float dp = Layout::dpiScale();
|
|
// Color picker: preset swatches in ONE row (overflow hidden) + a custom "+" cell that is
|
|
// always visible at the right and opens a picker.
|
|
{
|
|
float sw = 22.0f * dp, gap = 5.0f * dp;
|
|
ImGui::TextUnformatted(TR("portfolio_color"));
|
|
ImDrawList* cdl = ImGui::GetWindowDrawList();
|
|
int nColors = (int)(sizeof(kPfPalette) / sizeof(kPfPalette[0]));
|
|
float availW = ImGui::GetContentRegionAvail().x;
|
|
ImVec2 rowStart = ImGui::GetCursorScreenPos();
|
|
// Reserved "+" slot at the right; inset by a few px so its selection outline
|
|
// (drawn at radius sw*0.5) stays inside the modal instead of clipping at the edge.
|
|
float plusX = rowStart.x + std::max(sw, availW - sw - 4.0f * dp);
|
|
bool isCustom = (s_pfEdit.color != 0u);
|
|
for (int c = 0; c < nColors; c++) if (s_pfEdit.color == kPfPalette[c]) isCustom = false;
|
|
bool openCustom = false;
|
|
// Preset swatches (0 = default/no color, 1..N = palette); stop before the "+" slot.
|
|
float x = rowStart.x;
|
|
for (int idx = 0; idx <= nColors; idx++) {
|
|
if (x + sw > plusX - gap) break; // would reach the "+" -> hide the remaining colors
|
|
ImVec2 mn(x, rowStart.y);
|
|
ImVec2 cc(mn.x + sw * 0.5f, mn.y + sw * 0.5f);
|
|
bool isDefault = (idx == 0);
|
|
ImU32 col32 = isDefault ? 0u : kPfPalette[idx - 1];
|
|
bool sel = isDefault ? (s_pfEdit.color == 0u) : (s_pfEdit.color == col32);
|
|
bool hov = ImGui::IsMouseHoveringRect(mn, ImVec2(mn.x + sw, mn.y + sw));
|
|
if (isDefault) cdl->AddCircle(cc, sw * 0.40f, OnSurfaceMedium(), 0, 1.5f * dp);
|
|
else cdl->AddCircleFilled(cc, sw * 0.40f, col32);
|
|
if (sel) cdl->AddCircle(cc, sw * 0.50f, OnSurface(), 0, 2.0f * dp);
|
|
else if (hov) cdl->AddCircle(cc, sw * 0.50f, WithAlpha(OnSurface(), 120), 0, 1.5f * dp);
|
|
ImGui::PushID(2000 + idx);
|
|
ImGui::SetCursorScreenPos(mn);
|
|
ImGui::InvisibleButton("##pfcol", ImVec2(sw, sw));
|
|
if (ImGui::IsItemClicked()) s_pfEdit.color = isDefault ? 0u : col32;
|
|
ImGui::PopID();
|
|
x += sw + gap;
|
|
}
|
|
// Custom "+" cell — always visible at the reserved right slot.
|
|
{
|
|
ImVec2 mn(plusX, rowStart.y);
|
|
ImVec2 cc(mn.x + sw * 0.5f, mn.y + sw * 0.5f);
|
|
bool hov = ImGui::IsMouseHoveringRect(mn, ImVec2(mn.x + sw, mn.y + sw));
|
|
if (isCustom) {
|
|
cdl->AddCircleFilled(cc, sw * 0.40f, (ImU32)s_pfEdit.color);
|
|
} else {
|
|
cdl->AddCircle(cc, sw * 0.40f, OnSurfaceMedium(), 0, 1.5f * dp);
|
|
float pr = sw * 0.18f;
|
|
cdl->AddLine(ImVec2(cc.x - pr, cc.y), ImVec2(cc.x + pr, cc.y), OnSurfaceMedium(), 1.5f * dp);
|
|
cdl->AddLine(ImVec2(cc.x, cc.y - pr), ImVec2(cc.x, cc.y + pr), OnSurfaceMedium(), 1.5f * dp);
|
|
}
|
|
if (isCustom) cdl->AddCircle(cc, sw * 0.50f, OnSurface(), 0, 2.0f * dp);
|
|
else if (hov) cdl->AddCircle(cc, sw * 0.50f, WithAlpha(OnSurface(), 120), 0, 1.5f * dp);
|
|
ImGui::PushID(2999);
|
|
ImGui::SetCursorScreenPos(mn);
|
|
ImGui::InvisibleButton("##pfcustomcol", ImVec2(sw, sw));
|
|
if (ImGui::IsItemClicked()) {
|
|
ImU32 base = s_pfEdit.color ? (ImU32)s_pfEdit.color : IM_COL32(0x4F, 0x9D, 0xFF, 0xFF);
|
|
s_pfEdit.customRgb[0] = ((base >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f;
|
|
s_pfEdit.customRgb[1] = ((base >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f;
|
|
s_pfEdit.customRgb[2] = ((base >> IM_COL32_B_SHIFT) & 0xFF) / 255.0f;
|
|
openCustom = true;
|
|
}
|
|
if (hov) material::Tooltip("%s", TR("portfolio_custom_color"));
|
|
ImGui::PopID();
|
|
}
|
|
ImGui::SetCursorScreenPos(ImVec2(rowStart.x, rowStart.y + sw));
|
|
ImGui::Dummy(ImVec2(availW, 0));
|
|
if (openCustom) ImGui::OpenPopup("##pfCustomColor");
|
|
if (ImGui::BeginPopup("##pfCustomColor")) {
|
|
ImGui::ColorPicker3("##pfpick", s_pfEdit.customRgb,
|
|
ImGuiColorEditFlags_NoSidePreview | ImGuiColorEditFlags_NoAlpha | ImGuiColorEditFlags_NoLabel);
|
|
s_pfEdit.color = IM_COL32((int)(s_pfEdit.customRgb[0] * 255.0f + 0.5f),
|
|
(int)(s_pfEdit.customRgb[1] * 255.0f + 0.5f),
|
|
(int)(s_pfEdit.customRgb[2] * 255.0f + 0.5f), 255);
|
|
ImGui::EndPopup();
|
|
}
|
|
}
|
|
// Accent-outline opacity (only meaningful when an accent color is set).
|
|
ImGui::BeginDisabled(s_pfEdit.color == 0u);
|
|
ImGui::TextUnformatted(TR("portfolio_outline_opacity"));
|
|
ImGui::SetNextItemWidth(-1);
|
|
ImGui::SliderInt("##pfOutlineOpacity", &s_pfEdit.outlineOpacity, 0, 100, "%d%%");
|
|
if (s_pfEdit.outlineOpacity < 0) s_pfEdit.outlineOpacity = 0;
|
|
if (s_pfEdit.outlineOpacity > 100) s_pfEdit.outlineOpacity = 100;
|
|
ImGui::EndDisabled();
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
// Icon picker: search on the heading line + a 12-per-row grid that scales to width,
|
|
// smooth-scrolled with the Settings-tab edge fade.
|
|
{
|
|
float cellGap = 6.0f * dp;
|
|
ImFont* gIconFont = Type().iconXL();
|
|
ImGui::TextUnformatted(TR("portfolio_icon"));
|
|
ImGui::SameLine();
|
|
{
|
|
float searchW = std::min(ImGui::GetContentRegionAvail().x, 200.0f * dp);
|
|
float off = ImGui::GetContentRegionAvail().x - searchW; // right-align the field
|
|
if (off > 0.0f) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + off);
|
|
ImGui::SetNextItemWidth(searchW);
|
|
ImGui::InputTextWithHint("##pfIconSearch", TR("portfolio_search_icons"),
|
|
s_pfEdit.iconSearch, sizeof(s_pfEdit.iconSearch));
|
|
}
|
|
// Filtered icon list (-1 = "None", shown only when not searching).
|
|
std::string isearch = s_pfEdit.iconSearch;
|
|
std::vector<int> iconIdx;
|
|
if (isearch.empty()) iconIdx.push_back(-1);
|
|
int nIcons = material::project_icons::walletIconCount();
|
|
for (int i = 0; i < nIcons; i++) {
|
|
if (!isearch.empty() &&
|
|
!util::containsIgnoreCase(material::project_icons::walletIconName(i), isearch))
|
|
continue;
|
|
iconIdx.push_back(i);
|
|
}
|
|
ImDrawList* gdl = material::BeginFadeScrollChild("##pfIconGrid", s_pfEdit.iconFade,
|
|
ImVec2(Layout::spacingSm(), Layout::spacingSm()), dp);
|
|
const int cols = 12;
|
|
float availW = ImGui::GetContentRegionAvail().x;
|
|
float cell = std::max(14.0f * dp, (availW - cellGap * (cols - 1)) / (float)cols);
|
|
float gIconSz = cell * 0.5f;
|
|
float gridW = cell * cols + cellGap * (cols - 1);
|
|
float indent = std::max(0.0f, (availW - gridW) * 0.5f);
|
|
int gcol = 0;
|
|
for (size_t n = 0; n < iconIdx.size(); n++) {
|
|
int idx = iconIdx[n];
|
|
if (gcol == 0) { if (indent > 0.0f) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + indent); }
|
|
else ImGui::SameLine(0, cellGap);
|
|
ImVec2 mn = ImGui::GetCursorScreenPos();
|
|
ImVec2 mx(mn.x + cell, mn.y + cell);
|
|
ImVec2 cc(mn.x + cell * 0.5f, mn.y + cell * 0.5f);
|
|
bool hov = ImGui::IsMouseHoveringRect(mn, mx);
|
|
bool isNone = (idx < 0);
|
|
const char* nm2 = isNone ? "" : material::project_icons::walletIconName(idx);
|
|
bool sel = isNone ? s_pfEdit.icon.empty() : (s_pfEdit.icon == nm2);
|
|
if (sel) {
|
|
gdl->AddRectFilled(mn, mx, WithAlpha(Primary(), 40), 6.0f * dp);
|
|
gdl->AddRect(mn, mx, WithAlpha(Primary(), 120), 6.0f * dp, 0, 1.5f * dp);
|
|
} else if (hov) {
|
|
gdl->AddRectFilled(mn, mx, IM_COL32(255, 255, 255, 20), 6.0f * dp);
|
|
}
|
|
ImU32 icol = sel ? Primary() : (hov ? OnSurface() : OnSurfaceMedium());
|
|
if (isNone) {
|
|
float r = cell * 0.26f;
|
|
gdl->AddCircle(cc, r, icol, 0, 1.5f * dp);
|
|
gdl->AddLine(ImVec2(cc.x - r * 0.7f, cc.y + r * 0.7f),
|
|
ImVec2(cc.x + r * 0.7f, cc.y - r * 0.7f), icol, 1.5f * dp);
|
|
} else {
|
|
material::project_icons::drawByName(gdl, nm2, cc, icol, gIconFont, gIconSz);
|
|
}
|
|
ImGui::PushID(idx + 2);
|
|
ImGui::InvisibleButton("##pfic", ImVec2(cell, cell));
|
|
if (ImGui::IsItemClicked()) s_pfEdit.icon = isNone ? std::string() : std::string(nm2);
|
|
if (hov) material::Tooltip("%s", isNone ? TR("portfolio_no_icon") : nm2);
|
|
ImGui::PopID();
|
|
gcol = (gcol + 1) % cols;
|
|
}
|
|
material::EndFadeScrollChild(s_pfEdit.iconFade, gdl);
|
|
}
|
|
}
|
|
|
|
// Price: basis radios, optional manual price/currency, shown-field toggles, sparkline interval.
|
|
static void pfDrawPriceSection()
|
|
{
|
|
// Price basis — radio group (all options visible; no dropdown).
|
|
const char* basisItems[4] = { TR("portfolio_price_usd"), TR("portfolio_price_btc"),
|
|
TR("portfolio_price_drgx"), TR("portfolio_price_manual") };
|
|
for (int b = 0; b < 4; b++) { if (b) ImGui::SameLine(); ImGui::RadioButton(basisItems[b], &s_pfEdit.priceBasis, b); }
|
|
if (s_pfEdit.priceBasis == 3) { // Manual: price-per-DRGX + currency label
|
|
float half = (ImGui::GetContentRegionAvail().x - Layout::spacingSm()) * 0.62f;
|
|
ImGui::SetNextItemWidth(half);
|
|
ImGui::InputDouble("##pfManPrice", &s_pfEdit.manualPrice, 0.0, 0.0, "%.6f");
|
|
if (s_pfEdit.manualPrice < 0.0) s_pfEdit.manualPrice = 0.0;
|
|
ImGui::SameLine();
|
|
ImGui::SetNextItemWidth(-1);
|
|
ImGui::InputTextWithHint("##pfManCcy", TR("portfolio_currency"), s_pfEdit.manualCcy, sizeof(s_pfEdit.manualCcy));
|
|
}
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
// Shown fields.
|
|
ImGui::TextUnformatted(TR("portfolio_show"));
|
|
ImGui::Checkbox(DRAGONX_TICKER "##pfShowDrgx", &s_pfEdit.showDrgx);
|
|
ImGui::SameLine();
|
|
ImGui::BeginDisabled(s_pfEdit.priceBasis == 2); // DRGX-only -> no value field
|
|
ImGui::Checkbox(TR("portfolio_show_value"), &s_pfEdit.showValue);
|
|
ImGui::EndDisabled();
|
|
ImGui::SameLine();
|
|
// 24h + sparkline toggles share one line (both live-market only).
|
|
ImGui::BeginDisabled(!(s_pfEdit.priceBasis == 0 || s_pfEdit.priceBasis == 1));
|
|
ImGui::Checkbox(TR("portfolio_show_24h"), &s_pfEdit.show24h);
|
|
ImGui::SameLine();
|
|
ImGui::Checkbox(TR("portfolio_show_sparkline"), &s_pfEdit.showSparkline);
|
|
ImGui::EndDisabled();
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
// Sparkline interval — radio group (enabled only when the sparkline is shown).
|
|
ImGui::BeginDisabled(!s_pfEdit.showSparkline || !(s_pfEdit.priceBasis == 0 || s_pfEdit.priceBasis == 1));
|
|
const char* spItems[5] = { TR("portfolio_spark_min"), TR("portfolio_spark_hour"),
|
|
TR("portfolio_spark_day"), TR("portfolio_spark_week"),
|
|
TR("portfolio_spark_month") };
|
|
for (int s = 0; s < 5; s++) { if (s) ImGui::SameLine(); ImGui::RadioButton(spItems[s], &s_pfEdit.sparkInterval, s); }
|
|
ImGui::EndDisabled();
|
|
}
|
|
|
|
// Addresses: selection count, search, type-filter/select row, and the pickable address list.
|
|
static void pfDrawAddressSection(App* app)
|
|
{
|
|
float dp = Layout::dpiScale();
|
|
const auto& state = app->getWalletState();
|
|
ImFont* body2f = Type().body2();
|
|
ImFont* capF = Type().caption();
|
|
ImFont* iconFont = Type().iconSmall();
|
|
float iconFsz = iconFont->LegacySize;
|
|
{
|
|
char selc[48];
|
|
snprintf(selc, sizeof(selc), TR("portfolio_addresses_sel"), (int)s_pfEdit.addrs.size());
|
|
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), selc);
|
|
}
|
|
ImGui::SetNextItemWidth(-1);
|
|
ImGui::InputTextWithHint("##pfSearch", TR("portfolio_search"), s_pfEdit.search, sizeof(s_pfEdit.search));
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
|
// Filter + selection on one row: type-filter segmented (left) + Select all / Clear /
|
|
// Funded (right). Select-all is deferred so it applies to the freshly-filtered set below.
|
|
bool selAllClicked = false;
|
|
{
|
|
const char* flabels[3] = { TR("portfolio_select_all"), TR("shielded"), TR("transparent") };
|
|
const char* selLbl = TR("portfolio_select_shown");
|
|
const char* clrLbl = TR("portfolio_clear_sel");
|
|
ImGuiStyle& st = ImGui::GetStyle();
|
|
ImVec2 pillPad(Layout::spacingLg(), Layout::spacingSm()); // roomier text padding
|
|
float defFrameH = ImGui::GetFrameHeight();
|
|
float fundedW = defFrameH + st.ItemInnerSpacing.x + ImGui::CalcTextSize(TR("portfolio_funded")).x;
|
|
float pillH = ImGui::GetTextLineHeight() + pillPad.y * 2.0f;
|
|
float selW = ImGui::CalcTextSize(selLbl).x + pillPad.x * 2.0f;
|
|
float clrW = ImGui::CalcTextSize(clrLbl).x + pillPad.x * 2.0f;
|
|
float groupW = selW + clrW + fundedW + st.ItemSpacing.x * 2.0f;
|
|
float segH = 26.0f * dp;
|
|
float rowW = ImGui::GetContentRegionAvail().x;
|
|
float segW = std::min(360.0f * dp, std::max(160.0f * dp, rowW - groupW - Layout::spacingMd()));
|
|
float rowH = std::max(segH, pillH);
|
|
ImVec2 rowStart = ImGui::GetCursorScreenPos();
|
|
// Segmented type filter (left), vertically centered in the row.
|
|
float segY = rowStart.y + (rowH - segH) * 0.5f;
|
|
int tclk = material::SegmentedControl(ImGui::GetWindowDrawList(), ImVec2(rowStart.x, segY),
|
|
segW, segH, flabels, 3, s_pfEdit.typeFilter, capF, "##pftf", dp);
|
|
if (tclk >= 0) s_pfEdit.typeFilter = tclk;
|
|
// Select all / Clear (rounded pills) + Funded toggle (right), vertically centered.
|
|
float grpX = rowStart.x + std::max(0.0f, rowW - groupW);
|
|
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 12.0f * dp);
|
|
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, pillPad);
|
|
ImGui::SetCursorScreenPos(ImVec2(grpX, rowStart.y + (rowH - pillH) * 0.5f));
|
|
if (material::TactileButton(selLbl)) selAllClicked = true;
|
|
ImGui::SameLine();
|
|
if (material::TactileButton(clrLbl)) s_pfEdit.addrs.clear();
|
|
ImGui::PopStyleVar(2);
|
|
ImGui::SameLine();
|
|
ImGui::SetCursorScreenPos(ImVec2(ImGui::GetCursorScreenPos().x,
|
|
rowStart.y + (rowH - defFrameH) * 0.5f)); // center vs. pills
|
|
ImGui::Checkbox(TR("portfolio_funded"), &s_pfEdit.fundedOnly);
|
|
ImGui::SetCursorScreenPos(ImVec2(rowStart.x, rowStart.y + rowH));
|
|
ImGui::Dummy(ImVec2(rowW, 0));
|
|
}
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
|
std::string search = s_pfEdit.search;
|
|
std::vector<const AddressInfo*> filtered;
|
|
for (const auto& a : state.addresses) {
|
|
if (s_pfEdit.typeFilter == 1 && a.type != "shielded") continue;
|
|
if (s_pfEdit.typeFilter == 2 && a.type != "transparent") continue;
|
|
if (s_pfEdit.fundedOnly && a.balance <= 1e-9) continue;
|
|
if (!search.empty()) {
|
|
std::string hay = a.address + " " + app->getAddressLabel(a.address);
|
|
if (!util::containsIgnoreCase(hay, search)) continue;
|
|
}
|
|
filtered.push_back(&a);
|
|
}
|
|
std::sort(filtered.begin(), filtered.end(), [&](const AddressInfo* x, const AddressInfo* y) {
|
|
bool sx = data::PortfolioEntryContains(s_pfEdit.addrs, x->address);
|
|
bool sy = data::PortfolioEntryContains(s_pfEdit.addrs, y->address);
|
|
if (sx != sy) return sx;
|
|
return x->balance > y->balance;
|
|
});
|
|
if (selAllClicked)
|
|
for (const AddressInfo* a : filtered) data::PortfolioEntryAdd(s_pfEdit.addrs, a->address);
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
|
ImDrawList* ldl = material::BeginFadeScrollChild("##pfAddrList", s_pfEdit.addrFade,
|
|
ImVec2(Layout::spacingMd(), Layout::spacingSm()), dp);
|
|
float rowH = std::max(26.0f * dp, body2f->LegacySize + Layout::spacingSm());
|
|
float lw = ImGui::GetContentRegionAvail().x;
|
|
if (filtered.empty())
|
|
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_addr_match"));
|
|
for (const AddressInfo* ap : filtered) {
|
|
const AddressInfo& a = *ap;
|
|
bool inSet = data::PortfolioEntryContains(s_pfEdit.addrs, a.address);
|
|
ImVec2 rmn = ImGui::GetCursorScreenPos();
|
|
ImVec2 rmx(rmn.x + lw, rmn.y + rowH);
|
|
bool rhov = ImGui::IsMouseHoveringRect(rmn, rmx);
|
|
if (inSet) ldl->AddRectFilled(rmn, rmx, WithAlpha(Primary(), 22), 4.0f);
|
|
else if (rhov) ldl->AddRectFilled(rmn, rmx, IM_COL32(255, 255, 255, 14), 4.0f);
|
|
float rcy = rmn.y + rowH * 0.5f;
|
|
float rx = rmn.x + Layout::spacingSm();
|
|
float cb = 15.0f * dp;
|
|
ImVec2 cbMin(rx, rcy - cb * 0.5f), cbMax(rx + cb, rcy + cb * 0.5f);
|
|
if (inSet) {
|
|
ldl->AddRectFilled(cbMin, cbMax, Primary(), 3.0f * dp);
|
|
ldl->AddLine(ImVec2(cbMin.x + cb * 0.22f, rcy),
|
|
ImVec2(cbMin.x + cb * 0.42f, cbMax.y - cb * 0.25f), IM_COL32(255, 255, 255, 255), 1.6f * dp);
|
|
ldl->AddLine(ImVec2(cbMin.x + cb * 0.42f, cbMax.y - cb * 0.25f),
|
|
ImVec2(cbMin.x + cb * 0.80f, cbMin.y + cb * 0.22f), IM_COL32(255, 255, 255, 255), 1.6f * dp);
|
|
} else {
|
|
ldl->AddRect(cbMin, cbMax, WithAlpha(OnSurface(), 120), 3.0f * dp, 0, 1.2f * dp);
|
|
}
|
|
rx += cb + Layout::spacingSm();
|
|
bool isZ = (a.type == "shielded");
|
|
const char* chip = isZ ? "Z" : "T";
|
|
ImU32 chipCol = isZ ? Success() : Warning();
|
|
ImVec2 chipPos(rx, rcy - capF->LegacySize * 0.5f - 2.0f);
|
|
float chipW = material::DrawPill(ldl, chipPos, chip, capF, chipCol,
|
|
WithAlpha(chipCol, 40), 0, ImVec2(4.0f * dp, 2.0f), 4.0f * dp).x;
|
|
rx += chipW + Layout::spacingSm();
|
|
std::string aicon = app->getAddressIcon(a.address);
|
|
if (!aicon.empty()) {
|
|
material::project_icons::drawByName(ldl, aicon, ImVec2(rx + iconFsz * 0.5f, rcy), OnSurfaceMedium(), iconFont, iconFsz);
|
|
rx += iconFsz + Layout::spacingSm();
|
|
}
|
|
char balbuf[48];
|
|
snprintf(balbuf, sizeof(balbuf), "%.4f", a.balance);
|
|
float balW = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, balbuf).x;
|
|
float textRight = rmx.x - Layout::spacingSm() - balW - Layout::spacingSm();
|
|
std::string alabel = app->getAddressLabel(a.address);
|
|
std::string primary = alabel.empty() ? util::truncateMiddle(a.address, 22) : alabel;
|
|
float availTextW = textRight - rx;
|
|
while (primary.size() > 1 &&
|
|
body2f->CalcTextSizeA(body2f->LegacySize, FLT_MAX, 0, primary.c_str()).x > availTextW)
|
|
primary.pop_back();
|
|
ldl->AddText(body2f, body2f->LegacySize, ImVec2(rx, rcy - body2f->LegacySize * 0.5f),
|
|
inSet ? OnSurface() : OnSurfaceMedium(), primary.c_str());
|
|
ldl->AddText(capF, capF->LegacySize, ImVec2(rmx.x - Layout::spacingSm() - balW, rcy - capF->LegacySize * 0.5f),
|
|
a.balance > 1e-9 ? OnSurface() : OnSurfaceDisabled(), balbuf);
|
|
ImGui::PushID(a.address.c_str());
|
|
ImGui::InvisibleButton("##pfrow", ImVec2(lw, rowH));
|
|
if (ImGui::IsItemClicked()) {
|
|
if (inSet) data::PortfolioEntryRemove(s_pfEdit.addrs, a.address);
|
|
else data::PortfolioEntryAdd(s_pfEdit.addrs, a.address);
|
|
}
|
|
ImGui::PopID();
|
|
}
|
|
material::EndFadeScrollChild(s_pfEdit.addrFade, ldl);
|
|
}
|
|
|
|
// The "Manage portfolio" modal: list/add/edit/delete entries, each a label tied to a group
|
|
// of wallet addresses. Persists to Settings on every mutation (like the address book).
|
|
static void RenderPortfolioEditor(App* app)
|
|
{
|
|
if (!s_pfEdit.open) return;
|
|
|
|
config::Settings* settings = app->settings();
|
|
const auto& state = app->getWalletState();
|
|
float dp = Layout::dpiScale();
|
|
ImFont* body2f = Type().body2();
|
|
ImFont* capF = Type().caption();
|
|
ImFont* sub1f = Type().subtitle1();
|
|
ImFont* iconFont = Type().iconSmall();
|
|
|
|
// Persistence helpers are file-static (pfBuildWorking / pfWorkingMatches / pfCommitIfNeeded).
|
|
|
|
// ---------------- Full-window blur overlay (shared framework) ----------------
|
|
// Backdrop + capture-once, floating card, plain heading, outside-click dismiss and effect
|
|
// suppression are owned by material::BeginOverlayDialog (BlurFloat). Esc still closes here;
|
|
// Esc/outside-click discard (only Close commits); LatchBlurOverlayActive (App::render) handles
|
|
// the acrylic re-capture on close.
|
|
material::OverlayDialogSpec ov;
|
|
ov.title = TR("portfolio_manage_title");
|
|
ov.p_open = &s_pfEdit.open;
|
|
ov.style = material::OverlayStyle::BlurFloat;
|
|
ov.cardWidth = 1120.0f;
|
|
ov.cardHeight = 760.0f;
|
|
ov.idSuffix = "pf";
|
|
if (!material::BeginOverlayDialog(ov)) return;
|
|
if (ImGui::IsKeyPressed(ImGuiKey_Escape)) s_pfEdit.open = false;
|
|
|
|
{ // clamp/validate the selection against the current entry list
|
|
const auto& entriesRO = settings->getPortfolioEntries();
|
|
if (s_pfEdit.sel >= (int)entriesRO.size()) s_pfEdit.sel = entriesRO.empty() ? -1 : 0;
|
|
}
|
|
|
|
float footerH = 56.0f * dp; // room for the 40px buttons + separator so they aren't clipped
|
|
float bodyH = std::max(120.0f, ImGui::GetContentRegionAvail().y - footerH - Layout::spacingSm());
|
|
float contentW = ImGui::GetContentRegionAvail().x;
|
|
float gap = Layout::spacingLg();
|
|
float masterW = std::min(std::max(contentW * 0.28f, 240.0f * dp), 320.0f * dp);
|
|
float detailW = contentW - masterW - gap;
|
|
|
|
// Does the selected group have uncommitted edits? (Only the selected group can differ from the
|
|
// stored copy — others were committed on deselect.) Drives the master-list "unsaved" dot and the
|
|
// detail-pane Revert/Save enable state.
|
|
bool selDirty = false;
|
|
{
|
|
const auto& es = settings->getPortfolioEntries();
|
|
if (s_pfEdit.sel >= 0 && s_pfEdit.sel < (int)es.size()) selDirty = !pfWorkingMatches(es[s_pfEdit.sel]);
|
|
}
|
|
|
|
// ================= MASTER: scrollable group list + a pinned Add button below it =================
|
|
float addH = 34.0f * dp;
|
|
ImGui::BeginGroup();
|
|
{
|
|
const auto& entries = settings->getPortfolioEntries();
|
|
ImGui::BeginChild("##pfMasterList", ImVec2(masterW, std::max(48.0f, bodyH - addH - Layout::spacingSm())), false);
|
|
{
|
|
ImDrawList* mdl = ImGui::GetWindowDrawList();
|
|
if (entries.empty())
|
|
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_entries"));
|
|
float rowH = 46.0f * dp;
|
|
float delSlot = 36.0f * dp; // reserved trailing space for the delete icon + margin
|
|
int clickedSel = -999, delRow = -1; // deferred: don't mutate `entries` mid-loop
|
|
for (int i = 0; i < (int)entries.size(); i++) {
|
|
ImGui::PushID(i);
|
|
const auto& en = entries[i];
|
|
bool selRow = (s_pfEdit.sel == i);
|
|
ImVec2 rmn = ImGui::GetCursorScreenPos();
|
|
if (ImGui::Selectable("##pfgrp", selRow, ImGuiSelectableFlags_SpanAvailWidth, ImVec2(0, rowH))) {
|
|
if (i != s_pfEdit.sel) clickedSel = i;
|
|
}
|
|
ImVec2 rmx(rmn.x + ImGui::GetItemRectSize().x, rmn.y + rowH);
|
|
bool rowHov = ImGui::IsItemHovered();
|
|
ImU32 accent = en.color ? (ImU32)en.color : WithAlpha(OnSurface(), 60);
|
|
mdl->AddRectFilled(ImVec2(rmn.x, rmn.y + 4.0f * dp),
|
|
ImVec2(rmn.x + 3.0f * dp, rmx.y - 4.0f * dp), accent, 1.5f * dp);
|
|
float ix = rmn.x + Layout::spacingSm() + 4.0f * dp;
|
|
if (!en.icon.empty()) {
|
|
material::project_icons::drawByName(mdl, en.icon,
|
|
ImVec2(ix + sub1f->LegacySize * 0.5f, rmn.y + rowH * 0.5f),
|
|
en.color ? (ImU32)en.color : OnSurfaceMedium(), iconFont, sub1f->LegacySize);
|
|
ix += sub1f->LegacySize + Layout::spacingSm();
|
|
}
|
|
// Unsaved-changes dot: shown on the selected row while its edits aren't committed.
|
|
if (selRow && selDirty) {
|
|
float dr = 3.5f * dp;
|
|
mdl->AddCircleFilled(ImVec2(ix + dr, rmn.y + rowH * 0.28f + body2f->LegacySize * 0.5f),
|
|
dr, Warning());
|
|
ix += dr * 2.0f + Layout::spacingXs();
|
|
}
|
|
float txtRight = rmx.x - delSlot; // reserve the trailing delete-icon slot
|
|
std::string nm = en.label.empty() ? std::string(TR("portfolio_new_entry")) : en.label;
|
|
while (nm.size() > 1 && body2f->CalcTextSizeA(body2f->LegacySize, FLT_MAX, 0, nm.c_str()).x > (txtRight - ix))
|
|
nm.pop_back();
|
|
mdl->AddText(body2f, body2f->LegacySize, ImVec2(ix, rmn.y + rowH * 0.28f),
|
|
en.label.empty() ? OnSurfaceDisabled() : OnSurface(), nm.c_str());
|
|
double bal = data::SumPortfolioBalance(en.addresses, state.addresses);
|
|
char vl[80]; snprintf(vl, sizeof(vl), "%.4f %s \xC2\xB7 %d", bal, DRAGONX_TICKER, (int)en.addresses.size());
|
|
mdl->AddText(capF, capF->LegacySize, ImVec2(ix, rmn.y + rowH * 0.56f), OnSurfaceMedium(), vl);
|
|
// Per-row delete icon (larger, inset from the edge). Hit-tested manually so it is
|
|
// NOT an overlapping ImGui item over the row Selectable (which asserted on hover).
|
|
if (rowHov || selRow) {
|
|
ImFont* delFont = Type().iconMed();
|
|
float ds = delFont->LegacySize;
|
|
ImVec2 dc(rmx.x - Layout::spacingMd() - ds * 0.5f, rmn.y + rowH * 0.5f);
|
|
ImVec2 dmn(dc.x - ds * 0.65f, dc.y - ds * 0.65f), dmx(dc.x + ds * 0.65f, dc.y + ds * 0.65f);
|
|
bool dhov = ImGui::IsMouseHoveringRect(dmn, dmx);
|
|
if (dhov) {
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) delRow = i;
|
|
}
|
|
ImVec2 isz = delFont->CalcTextSizeA(ds, FLT_MAX, 0, ICON_MD_DELETE_OUTLINE);
|
|
mdl->AddText(delFont, ds, ImVec2(dc.x - isz.x * 0.5f, dc.y - isz.y * 0.5f),
|
|
dhov ? Error() : OnSurfaceMedium(), ICON_MD_DELETE_OUTLINE);
|
|
}
|
|
ImGui::PopID();
|
|
}
|
|
if (delRow >= 0) {
|
|
// Operates on the stored entries; any uncommitted working edits are discarded.
|
|
auto es = settings->getPortfolioEntries();
|
|
if (delRow < (int)es.size()) {
|
|
es.erase(es.begin() + delRow);
|
|
pfPersist(settings, es);
|
|
if (es.empty()) s_pfEdit.sel = -1;
|
|
else if (delRow < s_pfEdit.sel) s_pfEdit.sel -= 1;
|
|
else if (delRow == s_pfEdit.sel) s_pfEdit.sel = std::min(delRow, (int)es.size() - 1);
|
|
PortfolioBeginEdit(app, s_pfEdit.sel);
|
|
}
|
|
} else if (clickedSel != -999) {
|
|
PortfolioBeginEdit(app, clickedSel); // switching groups discards uncommitted edits
|
|
}
|
|
}
|
|
ImGui::EndChild();
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
// Add immediately creates a persisted "Untitled" group and selects it for editing.
|
|
if (material::TactileButton(TR("portfolio_add_entry"), ImVec2(masterW, addH))) {
|
|
// Adding switches to a new group; uncommitted edits to the current one are discarded.
|
|
auto es = settings->getPortfolioEntries();
|
|
config::Settings::PortfolioEntry ne;
|
|
ne.label = TR("portfolio_untitled");
|
|
ne.scope = app->activeWalletIdentityHash(); // new group defaults to this wallet
|
|
es.push_back(ne);
|
|
pfPersist(settings, es);
|
|
s_pfEdit.sel = (int)es.size() - 1;
|
|
PortfolioBeginEdit(app, s_pfEdit.sel);
|
|
}
|
|
}
|
|
ImGui::EndGroup();
|
|
|
|
ImGui::SameLine(0, gap);
|
|
|
|
// ================= DETAIL: options for the selected group =================
|
|
ImGui::BeginChild("##pfDetail", ImVec2(detailW, bodyH), false);
|
|
if (s_pfEdit.sel == -1) {
|
|
const char* msg = TR("portfolio_detail_empty");
|
|
ImVec2 av = ImGui::GetContentRegionAvail();
|
|
ImVec2 ts = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, msg);
|
|
ImGui::SetCursorPos(ImVec2(std::max(0.0f, (av.x - ts.x) * 0.5f), av.y * 0.42f));
|
|
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), msg);
|
|
} else {
|
|
// Body fills the detail column minus a bottom row reserved for the group-scoped
|
|
// Revert/Save buttons (kept inside this container, below the modal-level Close in the footer).
|
|
float pfBtnRowH = addH + Layout::spacingMd();
|
|
ImGui::BeginChild("##pfDetailBody",
|
|
ImVec2(0, std::max(80.0f * dp, ImGui::GetContentRegionAvail().y - pfBtnRowH)),
|
|
false, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
|
// ---- Header: live preview + label (always visible above the accordion) ----
|
|
{
|
|
ImDrawList* pdl = ImGui::GetWindowDrawList();
|
|
float ppad = Layout::spacingMd();
|
|
float pw = ImGui::GetContentRegionAvail().x;
|
|
float ph = ppad * 2.0f + sub1f->LegacySize + Layout::spacingXs() + capF->LegacySize;
|
|
ImVec2 pMin = ImGui::GetCursorScreenPos();
|
|
ImVec2 pMax(pMin.x + pw, pMin.y + ph);
|
|
ImU32 accent = s_pfEdit.color ? (ImU32)s_pfEdit.color : 0;
|
|
GlassPanelSpec g; g.rounding = 10.0f; g.fillAlpha = 22; g.borderAlpha = 40;
|
|
DrawGlassPanel(pdl, pMin, pMax, g);
|
|
if (accent) {
|
|
int oa = (int)(std::max(0, std::min(100, s_pfEdit.outlineOpacity)) * 2.55f + 0.5f);
|
|
pdl->AddRect(pMin, pMax, WithAlpha(accent, oa), 10.0f, 0, 2.0f);
|
|
}
|
|
if (s_pfEdit.showSparkline && (s_pfEdit.priceBasis == 0 || s_pfEdit.priceBasis == 1)) {
|
|
std::vector<double> h = data::sparklineSeries(state.market, s_pfEdit.sparkInterval);
|
|
if (h.size() >= 2) {
|
|
ImU32 spCol = WithAlpha(h.back() >= h.front() ? Success() : Error(), 80);
|
|
ImVec2 spMin(pMin.x + ppad, pMin.y + ph * 0.46f);
|
|
ImVec2 spMax(pMax.x - ppad, pMax.y - ppad * 0.6f);
|
|
pfDrawSparkline(pdl, spMin, spMax, h, spCol);
|
|
}
|
|
}
|
|
float rowY = pMin.y + ppad, nameX = pMin.x + ppad;
|
|
if (!s_pfEdit.icon.empty()) {
|
|
material::project_icons::drawByName(pdl, s_pfEdit.icon,
|
|
ImVec2(nameX + sub1f->LegacySize * 0.5f, rowY + sub1f->LegacySize * 0.5f),
|
|
accent ? accent : OnSurface(), iconFont, sub1f->LegacySize);
|
|
nameX += sub1f->LegacySize + Layout::spacingSm();
|
|
}
|
|
const char* nm = s_pfEdit.label[0] ? s_pfEdit.label : TR("portfolio_group_name");
|
|
pdl->AddText(sub1f, sub1f->LegacySize, ImVec2(nameX, rowY),
|
|
s_pfEdit.label[0] ? OnSurface() : OnSurfaceDisabled(), nm);
|
|
double pbal = data::SumPortfolioBalance(s_pfEdit.addrs, state.addresses);
|
|
config::Settings::PortfolioEntry tmp;
|
|
tmp.priceBasis = s_pfEdit.priceBasis;
|
|
tmp.manualPrice = s_pfEdit.manualPrice;
|
|
tmp.manualCurrency = s_pfEdit.manualCcy;
|
|
tmp.showDrgx = s_pfEdit.showDrgx;
|
|
tmp.showValue = s_pfEdit.showValue;
|
|
tmp.show24h = s_pfEdit.show24h;
|
|
PfDisplay pd = pfBuildDisplay(tmp, pbal, state.market);
|
|
float primW = pd.primary.empty() ? 0.0f
|
|
: body2f->CalcTextSizeA(body2f->LegacySize, FLT_MAX, 0, pd.primary.c_str()).x;
|
|
if (!pd.primary.empty())
|
|
pdl->AddText(body2f, body2f->LegacySize,
|
|
ImVec2(pMax.x - ppad - primW, rowY + (sub1f->LegacySize - body2f->LegacySize) * 0.5f),
|
|
OnSurface(), pd.primary.c_str());
|
|
{
|
|
float belowY = rowY + sub1f->LegacySize + Layout::spacingXs();
|
|
float cursorR = pMax.x - ppad;
|
|
if (!pd.change.empty()) {
|
|
float cw = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, pd.change.c_str()).x;
|
|
pdl->AddText(capF, capF->LegacySize, ImVec2(cursorR - cw, belowY), pd.changeCol, pd.change.c_str());
|
|
cursorR -= cw + Layout::spacingSm();
|
|
}
|
|
if (!pd.secondary.empty()) {
|
|
float sw2 = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, pd.secondary.c_str()).x;
|
|
pdl->AddText(capF, capF->LegacySize, ImVec2(cursorR - sw2, belowY), OnSurfaceDisabled(), pd.secondary.c_str());
|
|
}
|
|
}
|
|
ImGui::Dummy(ImVec2(pw, ph));
|
|
}
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
ImGui::SetNextItemWidth(-1);
|
|
ImGui::InputTextWithHint("##pfLabel", TR("portfolio_group_name"), s_pfEdit.label, sizeof(s_pfEdit.label));
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
|
|
// Segmented control switches the detail section (no collapsible dropdowns) — one at a time.
|
|
{
|
|
const char* secs[3] = { TR("portfolio_appearance"), TR("portfolio_price"),
|
|
TR("portfolio_addresses_hdr") };
|
|
float segH = 30.0f * dp;
|
|
float segW = ImGui::GetContentRegionAvail().x;
|
|
ImVec2 sMin = ImGui::GetCursorScreenPos();
|
|
int clk = material::SegmentedControl(ImGui::GetWindowDrawList(), sMin, segW, segH,
|
|
secs, 3, s_pfEdit.section, body2f, "##pfseg", dp);
|
|
if (clk >= 0) s_pfEdit.section = clk;
|
|
ImGui::SetCursorScreenPos(ImVec2(sMin.x, sMin.y + segH));
|
|
ImGui::Dummy(ImVec2(segW, 0));
|
|
}
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
|
|
|
|
// ---- Detail sections (one shown at a time via the segmented control above) ----
|
|
if (s_pfEdit.section == 0) pfDrawAppearanceSection();
|
|
else if (s_pfEdit.section == 1) pfDrawPriceSection();
|
|
else if (s_pfEdit.section == 2) pfDrawAddressSection(app);
|
|
|
|
ImGui::EndChild(); // ##pfDetailBody
|
|
// Group-scoped actions (Add-entry height → lower hierarchy than the modal Close in the footer).
|
|
{
|
|
float bw = 104.0f * dp, sp = Layout::spacingSm();
|
|
float grp = bw * 2 + sp;
|
|
material::RightAlignX(grp);
|
|
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + Layout::spacingSm());
|
|
ImGui::BeginDisabled(!selDirty);
|
|
if (material::TactileButton(TR("portfolio_revert"), ImVec2(bw, addH)))
|
|
PortfolioBeginEdit(app, s_pfEdit.sel); // reload the working set from the stored entry
|
|
ImGui::SameLine(0, sp);
|
|
ImGui::BeginDisabled(s_pfEdit.label[0] == '\0');
|
|
if (material::TactileButton(TR("portfolio_save"), ImVec2(bw, addH))) pfCommitIfNeeded(app);
|
|
ImGui::EndDisabled();
|
|
ImGui::EndDisabled();
|
|
}
|
|
}
|
|
ImGui::EndChild(); // ##pfDetail
|
|
|
|
// ================= FOOTER: close (modal-level action, larger → higher hierarchy) =================
|
|
{
|
|
float bh = 40.0f * dp, bw = 120.0f * dp;
|
|
material::RightAlignX(bw);
|
|
if (material::TactileButton(TR("portfolio_close"), ImVec2(bw, bh))) { pfCommitIfNeeded(app); s_pfEdit.open = false; }
|
|
}
|
|
|
|
material::EndOverlayDialog();
|
|
}
|
|
|
|
bool PortfolioEditorActive() { return s_pfEdit.open; }
|
|
|
|
|
|
// ---- Portfolio ROW layouts (replace the drag/resize grid) --------------------------------------
|
|
// Draw one group as a full-width row card in [rowMin,rowMax]; style 0=single-line, 1=two-line,
|
|
// 2=value-hero. Keeps every field (icon, accent, label, DRGX, value, 24h %, sparkline) — hidden
|
|
// fields per the entry's config simply don't render. Pure drawing into dl.
|
|
static void pfDrawRow(ImDrawList* dl, ImVec2 rowMin, ImVec2 rowMax,
|
|
const config::Settings::PortfolioEntry& e, const WalletState& state,
|
|
const MarketInfo& market, int style, float dp,
|
|
ImFont* sub1, ImFont* body2, ImFont* capFont, bool hov)
|
|
{
|
|
double bal = data::SumPortfolioBalance(e.addresses, state.addresses);
|
|
ImU32 accent = e.color ? (ImU32)e.color : 0;
|
|
float stripW = (style == 2 ? 5.0f : 3.0f) * dp;
|
|
|
|
GlassPanelSpec g; g.rounding = 10.0f; g.fillAlpha = hov ? 34 : 22; g.borderAlpha = 40;
|
|
DrawGlassPanel(dl, rowMin, rowMax, g);
|
|
if (accent) {
|
|
dl->AddRectFilled(rowMin, ImVec2(rowMin.x + stripW, rowMax.y), accent, 10.0f, ImDrawFlags_RoundCornersLeft);
|
|
int oa = (int)(std::max(0, std::min(100, e.outlineOpacity)) * 2.55f + 0.5f);
|
|
dl->AddRect(rowMin, rowMax, WithAlpha(accent, oa), 10.0f, 0, hov ? 2.0f : 1.5f);
|
|
} else if (hov) {
|
|
dl->AddRect(rowMin, rowMax, WithAlpha(OnSurface(), 70), 10.0f, 0, 1.0f);
|
|
}
|
|
|
|
auto tw = [](ImFont* f, const std::string& s){ return f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, s.c_str()).x; };
|
|
auto fit = [&](std::string s, ImFont* f, float maxW){ bool t=false; while (s.size()>1 && tw(f,s)>maxW){ s.pop_back(); t=true; } if (t) s += "\xE2\x80\xA6"; return s; };
|
|
|
|
char vb[80]; std::string valStr; bool hasValue=false;
|
|
if (e.priceBasis==0 && market.price_usd>0){ snprintf(vb,sizeof(vb),"$%.2f",bal*market.price_usd); valStr=vb; hasValue=true; }
|
|
else if (e.priceBasis==1 && market.price_btc>0){ snprintf(vb,sizeof(vb),"%.8f BTC",bal*market.price_btc); valStr=vb; hasValue=true; }
|
|
else if (e.priceBasis==3 && e.manualPrice>0.0){ snprintf(vb,sizeof(vb),"%.2f %s",bal*e.manualPrice,e.manualCurrency.c_str()); valStr=vb; hasValue=true; }
|
|
char db[48]; snprintf(db,sizeof(db),"%.4f %s",bal,DRAGONX_TICKER);
|
|
std::string drgxStr = e.showDrgx ? std::string(db) : std::string();
|
|
std::string valueStr = (e.showValue && hasValue) ? valStr : std::string();
|
|
bool showChange = e.show24h && (e.priceBasis==0||e.priceBasis==1) && market.change_24h!=0.0;
|
|
std::string chgStr; if (showChange){ char cb[32]; snprintf(cb,sizeof(cb),"%+.2f%%",market.change_24h); chgStr=cb; }
|
|
ImU32 chgCol = market.change_24h>=0 ? Success() : Error();
|
|
bool showSpark = e.showSparkline && (e.priceBasis==0||e.priceBasis==1);
|
|
std::vector<double> spark;
|
|
if (showSpark){ spark=data::sparklineSeries(market,e.sparklineInterval); if (spark.size()<2) showSpark=false; }
|
|
ImU32 sparkCol = (!spark.empty() && spark.back()>=spark.front()) ? Success() : Error();
|
|
|
|
const float padX = Layout::spacingMd(), padY = Layout::spacingSm(), sp = Layout::spacingMd();
|
|
float left = rowMin.x + stripW + padX;
|
|
float right = rowMax.x - padX;
|
|
float midY = (rowMin.y + rowMax.y) * 0.5f;
|
|
float iconSz = sub1->LegacySize;
|
|
|
|
if (style == 0) {
|
|
// Single line — icon + label (left); drgx / value / 24h / sparkline right-aligned.
|
|
float y = midY - sub1->LegacySize * 0.5f, x = left, rx = right;
|
|
if (!e.icon.empty()) {
|
|
material::project_icons::drawByName(dl, e.icon.c_str(), ImVec2(x+iconSz*0.5f, midY), accent?accent:OnSurfaceMedium(), Type().iconSmall(), iconSz);
|
|
x += iconSz + Layout::spacingSm();
|
|
}
|
|
if (showSpark){ float sw=110*dp; pfDrawSparkline(dl, ImVec2(rx-sw, rowMin.y+padY), ImVec2(rx, rowMax.y-padY), spark, sparkCol); rx -= sw + sp; }
|
|
if (!chgStr.empty()){ float w=tw(capFont,chgStr); dl->AddText(capFont,capFont->LegacySize,ImVec2(rx-w, midY-capFont->LegacySize*0.5f), chgCol, chgStr.c_str()); rx -= w + sp; }
|
|
if (!valueStr.empty()){ float w=tw(sub1,valueStr); dl->AddText(sub1,sub1->LegacySize,ImVec2(rx-w,y),OnSurface(),valueStr.c_str()); rx -= w + sp; }
|
|
if (!drgxStr.empty()){ float w=tw(capFont,drgxStr); dl->AddText(capFont,capFont->LegacySize,ImVec2(rx-w, midY-capFont->LegacySize*0.5f),OnSurfaceMedium(),drgxStr.c_str()); rx -= w + sp; }
|
|
float lblMaxW = std::max(24.0f*dp, rx - x - Layout::spacingSm());
|
|
dl->AddText(sub1,sub1->LegacySize,ImVec2(x,y),OnSurface(),fit(e.label,sub1,lblMaxW).c_str());
|
|
} else if (style == 1) {
|
|
// Two lines — label + (24h, sparkline) on top; DRGX + value below.
|
|
float topY = rowMin.y + padY, botY = rowMax.y - padY - capFont->LegacySize, x = left, rx = right;
|
|
if (!e.icon.empty()) {
|
|
material::project_icons::drawByName(dl, e.icon.c_str(), ImVec2(x+iconSz*0.5f, topY+iconSz*0.5f), accent?accent:OnSurfaceMedium(), Type().iconSmall(), iconSz);
|
|
x += iconSz + Layout::spacingSm();
|
|
}
|
|
if (showSpark){ float sw=130*dp; pfDrawSparkline(dl, ImVec2(rx-sw, topY), ImVec2(rx, midY), spark, sparkCol); rx -= sw + sp; }
|
|
if (!chgStr.empty()){ float w=tw(capFont,chgStr); dl->AddText(capFont,capFont->LegacySize,ImVec2(rx-w,topY),chgCol,chgStr.c_str()); rx -= w + sp; }
|
|
dl->AddText(sub1,sub1->LegacySize,ImVec2(x,topY),OnSurface(),fit(e.label,sub1,std::max(24.0f*dp, rx-x)).c_str());
|
|
if (!drgxStr.empty()) dl->AddText(capFont,capFont->LegacySize,ImVec2(x,botY),OnSurfaceMedium(),drgxStr.c_str());
|
|
if (!valueStr.empty()){ float w=tw(body2,valueStr); dl->AddText(body2,body2->LegacySize,ImVec2(right-w, botY-(body2->LegacySize-capFont->LegacySize)),OnSurface(),valueStr.c_str()); }
|
|
} else {
|
|
// Value-hero — label; big value; DRGX · 24h; sparkline fills the right.
|
|
float topY = rowMin.y + padY, x = left;
|
|
if (!e.icon.empty()) {
|
|
material::project_icons::drawByName(dl, e.icon.c_str(), ImVec2(x+iconSz*0.5f, topY+capFont->LegacySize*0.5f), accent?accent:OnSurfaceMedium(), Type().iconSmall(), capFont->LegacySize);
|
|
x += capFont->LegacySize + Layout::spacingSm();
|
|
}
|
|
dl->AddText(capFont,capFont->LegacySize,ImVec2(x,topY),OnSurfaceMedium(),fit(e.label,capFont,right-x-140*dp).c_str());
|
|
float valY = topY + capFont->LegacySize + Layout::spacingXs();
|
|
std::string hero = !valueStr.empty()? valueStr : drgxStr;
|
|
DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(left, valY), accent?accent:Success(), hero.c_str());
|
|
float botY = valY + sub1->LegacySize + Layout::spacingXs();
|
|
std::string sub = (!valueStr.empty() && !drgxStr.empty()) ? drgxStr : std::string();
|
|
if (!sub.empty()) dl->AddText(capFont,capFont->LegacySize,ImVec2(left,botY),OnSurfaceDisabled(),sub.c_str());
|
|
if (!chgStr.empty()){ float sw = sub.empty()?0.0f:(tw(capFont,sub)+tw(capFont," \xC2\xB7 ")); if(!sub.empty()) dl->AddText(capFont,capFont->LegacySize,ImVec2(left+tw(capFont,sub),botY),OnSurfaceDisabled()," \xC2\xB7 "); dl->AddText(capFont,capFont->LegacySize,ImVec2(left+sw,botY),chgCol,chgStr.c_str()); }
|
|
if (showSpark){ float sw=(right-left)*0.42f; pfDrawSparklineFilled(dl, ImVec2(right-sw, valY-2*dp), ImVec2(right, rowMax.y-padY), spark, sparkCol); }
|
|
}
|
|
}
|
|
|
|
// Pair selector: every trading pair across all exchanges as flat wrapping chips; clicking one
|
|
// switches the active exchange/pair (persisted) and refreshes market data. Followed by attribution.
|
|
static void mktDrawPairSelector(App* app, const std::vector<data::ExchangeInfo>& registry, float availWidth)
|
|
{
|
|
auto& S = schema::UI();
|
|
const auto& market = app->getWalletState().market;
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
ImFont* capFont = Type().caption();
|
|
ImFont* ovFont = Type().overline();
|
|
float gap = Layout::cardGap();
|
|
char buf[128];
|
|
|
|
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.market", "exchange-top-gap").size));
|
|
{
|
|
float chipH = S.drawElement("tabs.market", "pair-chip-height").height;
|
|
float chipR = S.drawElement("tabs.market", "pair-chip-radius").radius;
|
|
float chipSpacing = S.drawElement("tabs.market", "pair-chip-spacing").size;
|
|
float innerGap = Layout::spacingSm();
|
|
float sidePad = Layout::spacingMd();
|
|
|
|
ImVec2 origin = ImGui::GetCursorScreenPos();
|
|
float left = origin.x;
|
|
float right = origin.x + availWidth;
|
|
float cx = left;
|
|
float cy = origin.y;
|
|
int btnCounter = 0;
|
|
|
|
for (int ex = 0; ex < (int)registry.size(); ex++) {
|
|
for (int pr = 0; pr < (int)registry[ex].pairs.size(); pr++) {
|
|
const std::string& pairName = registry[ex].pairs[pr].displayName;
|
|
const std::string& exName = registry[ex].name;
|
|
|
|
float pairW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, pairName.c_str()).x;
|
|
float exW = ovFont->CalcTextSizeA(ovFont->LegacySize, FLT_MAX, 0, exName.c_str()).x;
|
|
float cw = sidePad * 2.0f + pairW + innerGap + exW;
|
|
|
|
// Wrap to the next row when the pill would overflow (never on an empty row).
|
|
if (cx + cw > right && cx > left) {
|
|
cx = left;
|
|
cy += chipH + chipSpacing;
|
|
}
|
|
|
|
ImVec2 cMin(cx, cy);
|
|
ImVec2 cMax(cx + cw, cy + chipH);
|
|
bool selected = (ex == s_mkt.exchangeIdx && pr == s_mkt.pairIdx);
|
|
bool hov = material::IsRectHovered(cMin, cMax);
|
|
ImU32 chipBg = selected ? WithAlpha(Primary(), 200)
|
|
: (hov ? WithAlpha(OnSurface(), 35) : WithAlpha(OnSurface(), 20));
|
|
ImU32 chipBorder = selected ? Primary() : WithAlpha(OnSurface(), 40);
|
|
ImU32 pairCol = selected ? IM_COL32(255, 255, 255, 255) : OnSurface();
|
|
ImU32 exCol = selected ? WithAlpha(IM_COL32(255, 255, 255, 255), 190) : OnSurfaceDisabled();
|
|
|
|
dl->AddRectFilled(cMin, cMax, chipBg, chipR);
|
|
dl->AddRect(cMin, cMax, chipBorder, chipR, 0, 1.0f);
|
|
|
|
float tx = cx + sidePad;
|
|
dl->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(tx, cy + (chipH - capFont->LegacySize) * 0.5f), pairCol, pairName.c_str());
|
|
dl->AddText(ovFont, ovFont->LegacySize,
|
|
ImVec2(tx + pairW + innerGap, cy + (chipH - ovFont->LegacySize) * 0.5f),
|
|
exCol, exName.c_str());
|
|
|
|
ImGui::SetCursorScreenPos(cMin);
|
|
snprintf(buf, sizeof(buf), "##PairBtn%d", btnCounter++);
|
|
if (ImGui::InvisibleButton(buf, ImVec2(cw, chipH))) {
|
|
if (ex != s_mkt.exchangeIdx || pr != s_mkt.pairIdx) {
|
|
s_mkt.exchangeIdx = ex;
|
|
s_mkt.pairIdx = pr;
|
|
app->settings()->setSelectedExchange(exName);
|
|
app->settings()->setSelectedPair(pairName);
|
|
app->settings()->save();
|
|
app->refreshMarketData();
|
|
}
|
|
}
|
|
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
|
|
cx += cw + chipSpacing;
|
|
}
|
|
}
|
|
|
|
// Advance the cursor past the wrapped rows.
|
|
ImGui::SetCursorScreenPos(ImVec2(left, cy + chipH));
|
|
ImGui::Dummy(ImVec2(availWidth, 0));
|
|
|
|
// Attribution (moved here from the removed exchange-selector row).
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("market_attribution"));
|
|
if (!market.last_updated.empty()) {
|
|
ImGui::SameLine(0, 12);
|
|
snprintf(buf, sizeof(buf), TR("market_updated"), market.last_updated.c_str());
|
|
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
|
|
}
|
|
ImGui::Dummy(ImVec2(0, gap));
|
|
}
|
|
}
|
|
|
|
// Shared per-frame context for the three coupled RenderMarketTab sections (hero/chart/portfolio):
|
|
// the precomputed geometry + chart series. Cheap locals (dl, fonts, dp, state, market, schema) are
|
|
// recomputed inside each helper.
|
|
struct MktCtx {
|
|
App* app;
|
|
const data::ExchangeInfo* currentExchange;
|
|
float chartH, heroHeaderH, pfSummaryH, portfolioH, ratioBarH;
|
|
const std::vector<std::time_t>* chartTimes;
|
|
std::time_t nowSec;
|
|
bool chartUp;
|
|
double periodChangePct;
|
|
std::string periodSuffix;
|
|
};
|
|
|
|
// Combined hero card: draws the ONE glass panel spanning the price header AND the chart below it,
|
|
// then the price / ticker / period-change badge / trade button (or a loading/error state).
|
|
static void mktDrawPriceHero(const MktCtx& cx)
|
|
{
|
|
App* app = cx.app;
|
|
const auto& market = app->getWalletState().market;
|
|
const auto& currentExchange = *cx.currentExchange;
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
float availWidth = ImGui::GetContentRegionAvail().x;
|
|
float pad = Layout::cardInnerPadding();
|
|
ImFont* capFont = Type().caption();
|
|
ImFont* sub1 = Type().subtitle1();
|
|
ImFont* body2 = Type().body2();
|
|
GlassPanelSpec glassSpec; glassSpec.rounding = Layout::glassRounding();
|
|
float chartH = cx.chartH, heroHeaderH = cx.heroHeaderH;
|
|
bool chartUp = cx.chartUp; double periodChangePct = cx.periodChangePct;
|
|
const std::string& periodSuffix = cx.periodSuffix;
|
|
char buf[128];
|
|
|
|
float dp = Layout::dpiScale();
|
|
ImVec2 cardMin = ImGui::GetCursorScreenPos();
|
|
float cardH = heroHeaderH;
|
|
ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + cardH);
|
|
// Combined hero + chart: draw ONE glass panel spanning the price/stats header AND the
|
|
// chart below it (chartH precomputed so the chart shrinks to fit the portfolio).
|
|
float mergedChartH = chartH;
|
|
ImVec2 mergedMax(cardMin.x + availWidth, cardMax.y + mergedChartH);
|
|
DrawGlassPanel(dl, cardMin, mergedMax, glassSpec);
|
|
|
|
float cx0 = cardMin.x + Layout::spacingLg();
|
|
float cy = cardMin.y + Layout::spacingLg();
|
|
|
|
if (market.price_usd > 0) {
|
|
// ---- HERO PRICE (large, prominent) ----
|
|
ImFont* h3 = Type().h3();
|
|
std::string priceStr = FormatPrice(market.price_usd);
|
|
ImU32 priceCol = Success();
|
|
DrawTextShadow(dl, h3, h3->LegacySize, ImVec2(cx0, cy), priceCol, priceStr.c_str());
|
|
|
|
// Ticker label after price
|
|
float priceW = h3->CalcTextSizeA(h3->LegacySize, FLT_MAX, 0, priceStr.c_str()).x;
|
|
dl->AddText(body2, body2->LegacySize,
|
|
ImVec2(cx0 + priceW + Layout::spacingSm(), cy + (h3->LegacySize - body2->LegacySize)),
|
|
OnSurfaceMedium(), DRAGONX_TICKER);
|
|
|
|
// Change badge — over the displayed chart period (or 24h for Live) — right of the ticker.
|
|
float tickerW = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, DRAGONX_TICKER).x;
|
|
float badgeX = cx0 + priceW + Layout::spacingSm() + tickerW + Layout::spacingMd();
|
|
ImU32 chgCol = chartUp ? Success() : Error();
|
|
snprintf(buf, sizeof(buf), "%s%.2f%% %s", chartUp ? "+" : "", periodChangePct, periodSuffix.c_str());
|
|
ImVec2 chgSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
|
|
float badgePadH = Layout::spacingSm();
|
|
float badgePadV = Layout::spacingXs();
|
|
ImVec2 bMin(badgeX, cy + (h3->LegacySize - chgSz.y - badgePadV * 2) * 0.5f);
|
|
ImVec2 bMax(badgeX + chgSz.x + badgePadH * 2, bMin.y + chgSz.y + badgePadV * 2);
|
|
ImU32 badgeBg = chartUp ? WithAlpha(Success(), 30) : WithAlpha(Error(), 30);
|
|
dl->AddRectFilled(bMin, bMax, badgeBg, 4.0f * dp);
|
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(bMin.x + badgePadH, bMin.y + badgePadV), chgCol, buf);
|
|
|
|
// (Divider is drawn in the chart block, below the interval buttons.)
|
|
|
|
// ---- TRADE BUTTON (top-right of card) ----
|
|
if (!currentExchange.pairs.empty()) {
|
|
const char* pairName = currentExchange.pairs[s_mkt.pairIdx].displayName.c_str();
|
|
ImFont* iconFont = Type().iconSmall();
|
|
ImVec2 textSz = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, pairName);
|
|
ImVec2 iconSz = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, ICON_MD_OPEN_IN_NEW);
|
|
float iconGap = Layout::spacingSm();
|
|
float tradePadH = Layout::spacingMd();
|
|
float tradePadV = Layout::spacingSm();
|
|
float tradeBtnW = textSz.x + iconGap + iconSz.x + tradePadH * 2;
|
|
float tradeBtnH = std::max(textSz.y, iconSz.y) + tradePadV * 2;
|
|
float tradeBtnX = cardMax.x - pad - tradeBtnW;
|
|
float tradeBtnY = cardMin.y + Layout::spacingSm();
|
|
|
|
ImVec2 tMin(tradeBtnX, tradeBtnY), tMax(tradeBtnX + tradeBtnW, tradeBtnY + tradeBtnH);
|
|
bool tradeHov = material::IsRectHovered(tMin, tMax);
|
|
|
|
// Glass pill background
|
|
GlassPanelSpec tradeBtnGlass;
|
|
tradeBtnGlass.rounding = tradeBtnH * 0.5f;
|
|
tradeBtnGlass.fillAlpha = tradeHov ? 35 : 20;
|
|
DrawGlassPanel(dl, tMin, tMax, tradeBtnGlass);
|
|
if (tradeHov)
|
|
dl->AddRectFilled(tMin, tMax, WithAlpha(Primary(), 20), tradeBtnH * 0.5f);
|
|
|
|
// Text (pair name with body2, icon with icon font)
|
|
ImU32 tradeCol = tradeHov ? OnSurface() : OnSurfaceMedium();
|
|
float contentY = tradeBtnY + tradePadV;
|
|
float curX = tradeBtnX + tradePadH;
|
|
dl->AddText(body2, body2->LegacySize,
|
|
ImVec2(curX, contentY), tradeCol, pairName);
|
|
curX += textSz.x + iconGap;
|
|
dl->AddText(iconFont, iconFont->LegacySize,
|
|
ImVec2(curX, contentY + (textSz.y - iconSz.y) * 0.5f), tradeCol, ICON_MD_OPEN_IN_NEW);
|
|
|
|
// Click
|
|
ImVec2 savedCur = ImGui::GetCursorScreenPos();
|
|
ImGui::SetCursorScreenPos(tMin);
|
|
ImGui::InvisibleButton("##TradeOnExchange", ImVec2(tradeBtnW, tradeBtnH));
|
|
if (ImGui::IsItemClicked()) {
|
|
util::Platform::openUrl(currentExchange.pairs[s_mkt.pairIdx].tradeUrl);
|
|
}
|
|
if (ImGui::IsItemHovered()) {
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
material::Tooltip(TR("market_trade_on"), currentExchange.name.c_str());
|
|
}
|
|
ImGui::SetCursorScreenPos(savedCur);
|
|
}
|
|
} else {
|
|
const char* status = market.price_loading ? TR("market_price_loading") : TR("market_price_unavailable");
|
|
DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(cx0, cy + 10), OnSurfaceDisabled(), status);
|
|
if (!market.price_loading && !market.price_error.empty()) {
|
|
std::string errorText = market.price_error;
|
|
float maxErrorW = cardMax.x - cx0 - Layout::spacingLg();
|
|
while (errorText.size() > 4 &&
|
|
capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, errorText.c_str()).x > maxErrorW) {
|
|
errorText.pop_back();
|
|
}
|
|
if (errorText.size() < market.price_error.size()) errorText += "...";
|
|
dl->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(cx0, cy + 10 + sub1->LegacySize + Layout::spacingXs()),
|
|
Warning(), errorText.c_str());
|
|
}
|
|
}
|
|
|
|
// No inter-card gap — the chart is drawn immediately below, inside the same panel.
|
|
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y));
|
|
ImGui::Dummy(ImVec2(availWidth, 0));
|
|
}
|
|
|
|
// Price chart, drawn inside the hero's shared glass panel: interval strip + 24h stats + refresh,
|
|
// then the plotted curve (grid, area fill, hi/lo + time labels, hover crosshair/tooltip) or an
|
|
// empty state. Reads the precomputed series (s_mkt.history / cx.chartTimes).
|
|
static void mktDrawPriceChart(const MktCtx& cx)
|
|
{
|
|
App* app = cx.app;
|
|
const auto& market = app->getWalletState().market;
|
|
auto& S = schema::UI();
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
float availWidth = ImGui::GetContentRegionAvail().x;
|
|
float pad = Layout::cardInnerPadding();
|
|
float hs = Layout::hScale(availWidth);
|
|
float mktDp = Layout::dpiScale();
|
|
ImFont* capFont = Type().caption();
|
|
ImFont* sub1 = Type().subtitle1();
|
|
float chartH = cx.chartH;
|
|
const auto& chartTimes = *cx.chartTimes;
|
|
std::time_t nowSec = cx.nowSec;
|
|
bool chartUp = cx.chartUp;
|
|
char buf[128];
|
|
|
|
// The chart series (s_mkt.history) + timestamps (chartTimes) were computed in the
|
|
// precompute above. Historical intervals come from CoinGecko market_chart; "Live" is the
|
|
// in-session buffer. Until two points exist, the empty-state below is shown.
|
|
// The chart shares the combined hero+chart glass panel drawn in PRICE SUMMARY above.
|
|
ImVec2 chartMin = ImGui::GetCursorScreenPos();
|
|
ImVec2 chartMax(chartMin.x + availWidth, chartMin.y + chartH);
|
|
|
|
float chartPad = pad;
|
|
float pillH = capFont->LegacySize + Layout::spacingXs() * 2.0f;
|
|
// Buttons sit near the top of the chart area, with a gap below them before the plot.
|
|
float stripTop = chartMin.y + Layout::spacingXs();
|
|
|
|
// ---- Top strip: interval buttons (left) + 24H volume / market cap + refresh (right) ----
|
|
{
|
|
float rowTop = stripTop;
|
|
float textY = rowTop + (pillH - capFont->LegacySize) * 0.5f;
|
|
|
|
// Interval buttons (left). Live = in-session buffer; the rest are historical.
|
|
const struct { const char* lbl; int iv; } kIvs[] = {
|
|
{ TR("market_iv_live"), 0 }, { TR("market_iv_1h"), 1 }, { TR("market_iv_1d"), 2 },
|
|
{ TR("market_iv_1w"), 3 }, { TR("market_iv_1m"), 4 }
|
|
};
|
|
float bx = chartMin.x + chartPad;
|
|
for (int b = 0; b < 5; b++) {
|
|
float tw = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, kIvs[b].lbl).x;
|
|
float bw = tw + Layout::spacingSm() * 2.0f;
|
|
ImVec2 bmn(bx, rowTop), bmx(bx + bw, rowTop + pillH);
|
|
bool sel = (s_mkt.chartInterval == kIvs[b].iv);
|
|
bool bhov = material::IsRectHovered(bmn, bmx);
|
|
ImU32 bg = sel ? WithAlpha(Primary(), 200)
|
|
: (bhov ? WithAlpha(OnSurface(), 35) : WithAlpha(OnSurface(), 18));
|
|
dl->AddRectFilled(bmn, bmx, bg, 4.0f);
|
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(bx + Layout::spacingSm(), textY),
|
|
sel ? IM_COL32(255, 255, 255, 255) : OnSurface(), kIvs[b].lbl);
|
|
ImGui::SetCursorScreenPos(bmn);
|
|
ImGui::PushID(9100 + b);
|
|
if (ImGui::InvisibleButton("##civ", ImVec2(bw, pillH))) {
|
|
s_mkt.chartInterval = kIvs[b].iv;
|
|
}
|
|
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
ImGui::PopID();
|
|
bx += bw + Layout::spacingXs();
|
|
}
|
|
|
|
// Refresh button (far right).
|
|
float rEdge = chartMax.x - chartPad;
|
|
ImFont* iconSmall = material::Typography::instance().iconSmall();
|
|
ImVec2 rbMin(rEdge - pillH, rowTop), rbMax(rEdge, rowTop + pillH);
|
|
bool refreshHov = material::IsRectHovered(rbMin, rbMax);
|
|
if (refreshHov) { dl->AddRectFilled(rbMin, rbMax, IM_COL32(255, 255, 255, 20), 4.0f);
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); }
|
|
ImVec2 icSz = iconSmall->CalcTextSizeA(iconSmall->LegacySize, FLT_MAX, 0, ICON_MD_REFRESH);
|
|
dl->AddText(iconSmall, iconSmall->LegacySize,
|
|
ImVec2(rbMin.x + (pillH - icSz.x) * 0.5f, rbMin.y + (pillH - icSz.y) * 0.5f),
|
|
refreshHov ? OnSurface() : OnSurfaceMedium(), ICON_MD_REFRESH);
|
|
ImGui::SetCursorScreenPos(rbMin);
|
|
if (ImGui::InvisibleButton("##RefreshMarket", ImVec2(pillH, pillH))) {
|
|
app->refreshMarketData();
|
|
}
|
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("market_refresh_price"));
|
|
|
|
// 24H volume + market cap, right-aligned to the left of the refresh button. Skipped
|
|
// when there's no room (narrow window) so they never overlap the interval buttons.
|
|
float sxr = rbMin.x - Layout::spacingMd();
|
|
auto drawStat = [&](const char* label, const std::string& val) {
|
|
char sb[64]; snprintf(sb, sizeof(sb), "%s %s", label, val.c_str());
|
|
float w = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, sb).x;
|
|
if (sxr - w < bx + Layout::spacingMd()) return;
|
|
sxr -= w;
|
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(sxr, textY), OnSurfaceMedium(), sb);
|
|
sxr -= Layout::spacingMd();
|
|
};
|
|
if (market.price_usd > 0) {
|
|
drawStat(TR("market_cap_short"), FormatCompactUSD(market.market_cap));
|
|
drawStat(TR("market_vol_short"), FormatCompactUSD(market.volume_24h));
|
|
}
|
|
}
|
|
|
|
float labelPadLeft = std::max(S.drawElement("tabs.market", "chart-y-axis-min-padding").size,
|
|
S.drawElement("tabs.market", "chart-y-axis-padding").size * hs);
|
|
// Extra bottom room so the time labels aren't crowded against the card's bottom edge.
|
|
float labelPadBottom = Layout::spacingXl() + Layout::spacingSm();
|
|
float plotLeft = chartMin.x + labelPadLeft;
|
|
float plotRight = chartMax.x - chartPad;
|
|
float plotTop = stripTop + pillH + Layout::spacingMd(); // gap below the buttons before the plot
|
|
float plotBottom = chartMax.y - labelPadBottom;
|
|
float plotW = plotRight - plotLeft;
|
|
float plotH = plotBottom - plotTop;
|
|
|
|
if (s_mkt.history.size() >= 2) {
|
|
// Compute Y range with padding
|
|
double yMin = *std::min_element(s_mkt.history.begin(), s_mkt.history.end());
|
|
double yMax = *std::max_element(s_mkt.history.begin(), s_mkt.history.end());
|
|
if (yMax <= yMin) { yMax = yMin + 1e-8; }
|
|
double yRange = yMax - yMin;
|
|
double yPadding = yRange * 0.12;
|
|
yMin -= yPadding;
|
|
yMax += yPadding;
|
|
|
|
// Horizontal grid lines (4 lines)
|
|
for (int g = 0; g <= 4; g++) {
|
|
float gy = plotTop + plotH * (float)g / 4.0f;
|
|
dl->AddLine(ImVec2(plotLeft, gy), ImVec2(plotRight, gy),
|
|
IM_COL32(255, 255, 255, 12), 1.0f);
|
|
double labelVal = yMax - (yMax - yMin) * (double)g / 4.0;
|
|
// Adaptive precision (via the shared price formatter) — cleaner than a fixed 6 decimals.
|
|
snprintf(buf, sizeof(buf), "%s", FormatPrice(labelVal).c_str());
|
|
ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
|
|
// Keep the axis label inside the card even on narrow windows (min-padding may be
|
|
// smaller than the label width) so it never spills onto the tab background.
|
|
float lblX = std::max(chartMin.x + 3.0f, plotLeft - labelSz.x - 6);
|
|
dl->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(lblX, gy - labelSz.y * 0.5f),
|
|
OnSurfaceDisabled(), buf);
|
|
}
|
|
|
|
// Build points
|
|
size_t n = s_mkt.history.size();
|
|
std::vector<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_mkt.history[i] - yMin) / (yMax - yMin)) * plotH;
|
|
points[i] = ImVec2(x, y);
|
|
}
|
|
|
|
// Flat translucent area fill under the curve (matches the portfolio group sparklines).
|
|
for (size_t i = 0; i < n; i++) dl->PathLineTo(points[i]);
|
|
dl->PathLineTo(ImVec2(points[n - 1].x, plotBottom));
|
|
dl->PathLineTo(ImVec2(points[0].x, plotBottom));
|
|
dl->PathFillConcave(WithAlpha(dirCol, 28));
|
|
|
|
// Line (no per-point dots — a clean curve).
|
|
dl->AddPolyline(points.data(), (int)points.size(), lineCol, ImDrawFlags_None, S.drawElement("tabs.market", "chart-line-thickness").size);
|
|
|
|
// High/low price labels at the displayed range's extremes (no dot markers).
|
|
if (n >= 3) {
|
|
size_t hiIdx = (size_t)(std::max_element(s_mkt.history.begin(), s_mkt.history.end()) - s_mkt.history.begin());
|
|
size_t loIdx = (size_t)(std::min_element(s_mkt.history.begin(), s_mkt.history.end()) - s_mkt.history.begin());
|
|
auto markExtreme = [&](size_t idx, bool high) {
|
|
ImVec2 p = points[idx];
|
|
std::string lbl = FormatPrice(s_mkt.history[idx]);
|
|
ImVec2 ls = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, lbl.c_str());
|
|
float ly = high ? p.y - ls.y - 5.0f * mktDp : p.y + 5.0f * mktDp;
|
|
float lx = std::min(std::max(plotLeft, p.x - ls.x * 0.5f), plotRight - ls.x);
|
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(lx, ly), OnSurfaceMedium(), lbl.c_str());
|
|
};
|
|
markExtreme(hiIdx, true);
|
|
markExtreme(loIdx, false);
|
|
}
|
|
|
|
// X-axis time labels. The series is timestamped, so label each tick with the real
|
|
// "<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_mkt.chartInterval <= 1 ? "%b %d %H:%M" : "%b %d, %Y", tmv);
|
|
}
|
|
if (whenBuf[0])
|
|
snprintf(buf, sizeof(buf), "%s \xC2\xB7 %s", whenBuf, FormatPrice(s_mkt.history[idx]).c_str());
|
|
else
|
|
snprintf(buf, sizeof(buf), "%s", FormatPrice(s_mkt.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));
|
|
}
|
|
|
|
// Portfolio: floating balance summary (fiat/DRGX/BTC + shielded ratio bar) and the draggable /
|
|
// resizable custom-group card grid (click a card to edit; drag to move; corner-drag to resize).
|
|
static void mktDrawPortfolio(const MktCtx& cx)
|
|
{
|
|
App* app = cx.app;
|
|
const auto& state = app->getWalletState();
|
|
const auto& market = state.market;
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
float availWidth = ImGui::GetContentRegionAvail().x;
|
|
float gap = Layout::cardGap();
|
|
float mktDp = Layout::dpiScale();
|
|
ImFont* capFont = Type().caption();
|
|
ImFont* sub1 = Type().subtitle1();
|
|
ImFont* body2 = Type().body2();
|
|
float ratioBarH = cx.ratioBarH, pfSummaryH = cx.pfSummaryH, portfolioH = cx.portfolioH;
|
|
char buf[128];
|
|
|
|
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();
|
|
material::RightAlignX(mBtnW);
|
|
if (material::TactileButton(ml, ImVec2(mBtnW, 0))) {
|
|
// Open the combined editor selecting the first group (or the empty state if none).
|
|
PortfolioBeginEdit(app, app->settings()->getPortfolioEntries().empty() ? -1 : 0);
|
|
s_pfEdit.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 cx0 = 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(cx0, 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(cx0 + 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(cx0, 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(cx0, 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(cx0, cy);
|
|
|
|
// Theme-aware: a dark track (not an invisible white one) on light skins, and slightly muted
|
|
// fills on dark skins where full-saturation Success/Warning read as a neon bar.
|
|
const bool barLight = IsLightTheme();
|
|
const int fillA = barLight ? 205 : 165;
|
|
dl->AddRectFilled(barStart, ImVec2(barStart.x + barW, barStart.y + ratioBarH),
|
|
barLight ? IM_COL32(0, 0, 0, 20) : IM_COL32(255, 255, 255, 10), 3.0f);
|
|
if (shieldedW > 0.5f)
|
|
dl->AddRectFilled(barStart, ImVec2(barStart.x + shieldedW, barStart.y + ratioBarH),
|
|
WithAlpha(Success(), fillA),
|
|
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(), fillA),
|
|
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(cx0, cy + ratioBarH + Layout::spacingXs()), OnSurfaceDisabled(), buf);
|
|
}
|
|
|
|
// ---- CUSTOM GROUPS — full-width rows in the selected style (single-line / two-line / value-hero),
|
|
// inside an internal scroll region so the portfolio stays within its height budget. Left/Right
|
|
// arrows switch styles (handled in RenderMarketTab). Click a row to edit it. ----
|
|
{
|
|
const auto& allEntries = app->settings()->getPortfolioEntries();
|
|
const std::string activeHash = app->activeWalletIdentityHash();
|
|
// Visible = this wallet's groups + legacy (empty scope). Keep storage indices for edit targets.
|
|
std::vector<int> vis;
|
|
for (int i = 0; i < (int)allEntries.size(); i++) {
|
|
const auto& e = allEntries[i];
|
|
if (e.scope.empty() || activeHash.empty() || e.scope == activeHash) vis.push_back(i);
|
|
}
|
|
|
|
int style = app->settings() ? app->settings()->getPortfolioStyle() : 0;
|
|
float rowH = (style == 0 ? 46.0f : style == 1 ? 64.0f : 84.0f) * mktDp;
|
|
float rowGap = Layout::spacingSm();
|
|
float rowsH = std::max(rowH, portfolioH - pfSummaryH);
|
|
|
|
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + pfSummaryH));
|
|
// Flush-left child (no window padding) so rows align with the summary; a scrollbar appears
|
|
// only when the visible groups overflow the bounded height.
|
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
|
|
ImGui::BeginChild("##pfRows", ImVec2(availWidth, rowsH), false);
|
|
ImGui::PopStyleVar();
|
|
ImDrawList* rdl = ImGui::GetWindowDrawList();
|
|
float rowW = ImGui::GetContentRegionAvail().x; // narrows automatically if a scrollbar shows
|
|
if (vis.empty()) {
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_entries"));
|
|
} else {
|
|
for (int vi = 0; vi < (int)vis.size(); vi++) {
|
|
int i = vis[vi];
|
|
ImVec2 rMin = ImGui::GetCursorScreenPos();
|
|
ImVec2 rMax(rMin.x + rowW, rMin.y + rowH);
|
|
ImGui::PushID(4000 + i);
|
|
bool clicked = ImGui::InvisibleButton("##pfRowBtn", ImVec2(rowW, rowH));
|
|
bool hov = ImGui::IsItemHovered();
|
|
if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
ImGui::PopID();
|
|
pfDrawRow(rdl, rMin, rMax, allEntries[i], state, market, style, mktDp, sub1, body2, capFont, hov);
|
|
if (clicked) { PortfolioBeginEdit(app, i); s_pfEdit.open = true; }
|
|
if (vi + 1 < (int)vis.size()) ImGui::Dummy(ImVec2(rowW, rowGap));
|
|
}
|
|
}
|
|
ImGui::EndChild();
|
|
}
|
|
|
|
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + portfolioH));
|
|
ImGui::Dummy(ImVec2(availWidth, 0));
|
|
ImGui::Dummy(ImVec2(0, gap));
|
|
}
|
|
|
|
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_mkt.exchangeIdx >= (int)registry.size()) s_mkt.exchangeIdx = 0;
|
|
const auto& currentExchange = registry[s_mkt.exchangeIdx];
|
|
if (s_mkt.pairIdx >= (int)currentExchange.pairs.size()) s_mkt.pairIdx = 0;
|
|
|
|
// Left/Right arrows: cycle portfolio row styles (compact / detailed / featured), mirroring the
|
|
// Overview tab's layout switch. Skip while typing, when Ctrl is held (theme cycle), or while the
|
|
// portfolio editor modal is open.
|
|
if (app->settings() && !s_pfEdit.open && !ImGui::GetIO().WantTextInput && !ImGui::GetIO().KeyCtrl) {
|
|
bool prev = ImGui::IsKeyPressed(ImGuiKey_LeftArrow);
|
|
bool next = ImGui::IsKeyPressed(ImGuiKey_RightArrow);
|
|
if (prev || next) {
|
|
int st = app->settings()->getPortfolioStyle();
|
|
st = next ? (st + 1) % 3 : (st + 2) % 3;
|
|
app->settings()->setPortfolioStyle(st);
|
|
const char* names[3] = { TR("portfolio_style_compact"), TR("portfolio_style_detailed"),
|
|
TR("portfolio_style_featured") };
|
|
Notifications::instance().info(std::string(TR("portfolio_style_label")) + ": " + names[st]);
|
|
}
|
|
}
|
|
|
|
// (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 (the section helpers recompute their own draw list /
|
|
// fonts / paddings; only the values the precompute below needs are kept here).
|
|
float availWidth = ImGui::GetContentRegionAvail().x;
|
|
float vs = Layout::vScale(marketAvail.y);
|
|
ImFont* capFont = Type().caption();
|
|
ImFont* sub1 = Type().subtitle1();
|
|
ImFont* body2 = Type().body2();
|
|
|
|
// ================================================================
|
|
// 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
|
|
// Content height = full-width rows in the selected style, confined to a bounded area (up to
|
|
// pfMaxVisibleRows) with an internal scroll when the visible groups overflow. Only groups
|
|
// visible in the active wallet (own scope or legacy/global) count toward the height.
|
|
const std::string pfActiveHash = app->activeWalletIdentityHash();
|
|
int pfVisN = 0;
|
|
for (const auto& e : pfEntriesGeo)
|
|
if (e.scope.empty() || pfActiveHash.empty() || e.scope == pfActiveHash) pfVisN++;
|
|
int pfStyle = app->settings()->getPortfolioStyle();
|
|
float pfRowH = (pfStyle == 0 ? 46.0f : pfStyle == 1 ? 64.0f : 84.0f) * mktDp;
|
|
float pfRowGap = Layout::spacingSm();
|
|
const int pfMaxVisibleRows = 4; // rows shown before the list scrolls
|
|
float pfWantH = (pfVisN > 0) ? (pfVisN * pfRowH + std::max(0, pfVisN - 1) * pfRowGap) : 0.0f;
|
|
float pfCapH = pfMaxVisibleRows * pfRowH + (pfMaxVisibleRows - 1) * pfRowGap;
|
|
float pfGroupsH = std::min(pfWantH, pfCapH);
|
|
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_mkt.history.clear();
|
|
for (const auto& pr : data::chartSeries(market, s_mkt.chartInterval, nowSec)) {
|
|
s_mkt.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_mkt.chartInterval != 0 && s_mkt.history.size() >= 2 && s_mkt.history.front() > 0.0)
|
|
periodChangePct = (s_mkt.history.back() - s_mkt.history.front()) / s_mkt.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_mkt.chartInterval) {
|
|
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
|
|
// ================================================================
|
|
MktCtx mktc{
|
|
app, ¤tExchange,
|
|
chartH, heroHeaderH, pfSummaryH, portfolioH, ratioBarH,
|
|
&chartTimes, nowSec, chartUp, periodChangePct, periodSuffix
|
|
};
|
|
mktDrawPriceHero(mktc);
|
|
|
|
// ================================================================
|
|
// PRICE CHART — drawn inside the combined hero card's glass panel (above)
|
|
// ================================================================
|
|
mktDrawPriceChart(mktc);
|
|
|
|
// ================================================================
|
|
// PAIR SELECTOR — every trading pair across all exchanges shown as
|
|
// round buttons (flat; no exchange dropdown). Wraps to multiple rows.
|
|
// ================================================================
|
|
mktDrawPairSelector(app, registry, availWidth);
|
|
|
|
// ================================================================
|
|
// PORTFOLIO — floating summary (no card background) + custom-group cards
|
|
// ================================================================
|
|
mktDrawPortfolio(mktc);
|
|
|
|
ImGui::EndChild(); // ##MarketScroll
|
|
|
|
// Portfolio editor modal (rendered outside the scroll child so it overlays the page). It captures
|
|
// + blurs the live content drawn above, so it must render after the tab body.
|
|
RenderPortfolioEditor(app);
|
|
}
|
|
|
|
} // namespace ui
|
|
} // namespace dragonx
|