The portfolio-editor address list drew every row every frame inside the fade-scroll child. It now runs through an ImGuiListClipper; the fixed step (the InvisibleButton height plus item spacing) keeps each row's cursor exactly where the unclipped loop put it, so the scroll extent for the fade shader, the check marks, the selection and the "selected rows float to the top" ordering are all unchanged. Verified on DX11 (Windows) at normal and large window sizes and at font scale 1.5, including skin switch and resize with the dialog open. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2405 lines
135 KiB
C++
2405 lines
135 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>
|
|
#include <unordered_set>
|
|
|
|
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)
|
|
int chartStyle = 1; // 0 = line, 1 = candlestick (only applies when per-exchange OHLC exists)
|
|
};
|
|
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;
|
|
}
|
|
|
|
// One-shot restore of the persisted chart range + style (idempotent: both are re-persisted on click).
|
|
static void LoadMarketState(config::Settings* settings)
|
|
{
|
|
if (s_mkt.stateLoaded || !settings) return;
|
|
s_mkt.stateLoaded = true;
|
|
|
|
// Restore the chart range + style (validated).
|
|
int iv = settings->getChartInterval();
|
|
if (iv >= 0 && iv <= 4) s_mkt.chartInterval = iv;
|
|
int st = settings->getChartStyle();
|
|
if (st == 0 || st == 1) s_mkt.chartStyle = st;
|
|
}
|
|
|
|
// Derive the selected exchange/pair INDICES from the persisted NAMES against the CURRENT registry, every frame.
|
|
// The registry changes identity when the live CoinGecko list replaces the 2-entry fallback; names are the
|
|
// source of truth (the chip click and refreshExchangeChart both use them), indices are just a view.
|
|
static void ResolveMarketSelection(config::Settings* settings, const std::vector<data::ExchangeInfo>& registry)
|
|
{
|
|
if (!settings || registry.empty()) return;
|
|
const std::string selEx = settings->getSelectedExchange();
|
|
const std::string selPair = settings->getSelectedPair();
|
|
// Steady state: the current indices already name the saved selection -> no scan.
|
|
if (s_mkt.exchangeIdx >= 0 && s_mkt.exchangeIdx < (int)registry.size()) {
|
|
const auto& ex = registry[s_mkt.exchangeIdx];
|
|
if (ex.name == selEx && s_mkt.pairIdx >= 0 && s_mkt.pairIdx < (int)ex.pairs.size() &&
|
|
ex.pairs[s_mkt.pairIdx].displayName == selPair) return;
|
|
}
|
|
const auto sel = data::resolveExchangeSelection(registry, selEx, selPair);
|
|
s_mkt.exchangeIdx = sel.exchangeIdx;
|
|
s_mkt.pairIdx = sel.pairIdx;
|
|
}
|
|
|
|
// 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 = 4; // 0=min 1=hour 2=day 3=week 4=month (default month)
|
|
};
|
|
static PfEditState s_pfEdit;
|
|
|
|
// Two-stage delete arm/confirm for the master-list per-row delete icon (mirrors the
|
|
// contacts tab's s_confirm_delete_idx): first click on a row's trash arms it (icon turns
|
|
// red); a second click on the SAME row within the loop confirms the erase. Hovering/clicking
|
|
// a different row, or clicking off the icons, disarms. -1 = nothing armed.
|
|
static int s_pfConfirmDelIdx = -1;
|
|
|
|
// 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, float dp)
|
|
{
|
|
std::vector<ImVec2> pts;
|
|
if (!pfProjectSeries(hist, mn, mx, pts)) return;
|
|
dl->AddPolyline(pts.data(), (int)pts.size(), col, ImDrawFlags_None, 1.2f * dp);
|
|
}
|
|
|
|
// 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, float dp)
|
|
{
|
|
std::vector<ImVec2> pts;
|
|
if (!pfProjectSeries(hist, mn, mx, pts)) return;
|
|
int n = (int)pts.size();
|
|
FillUnderPolyline(dl, pts.data(), n, mx.y, WithAlpha(lineCol, 28));
|
|
dl->AddPolyline(pts.data(), n, WithAlpha(lineCol, 220), ImDrawFlags_None, 1.4f * dp);
|
|
}
|
|
|
|
|
|
// 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 = 4; // default month (a real curve from the daily series)
|
|
}
|
|
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.
|
|
// Normalized views of the two free-text buffers, applied identically by pfBuildWorking (what we store)
|
|
// and pfWorkingMatches (the dirty check) so the two can never disagree: a trailing space in the label
|
|
// or an emptied currency field must not leave the group perpetually "dirty" after a save.
|
|
static std::string pfEditLabel() // label with leading/trailing whitespace trimmed
|
|
{
|
|
std::string s(s_pfEdit.label);
|
|
size_t i = 0, j = s.size();
|
|
while (i < j && (unsigned char)s[i] <= ' ') i++;
|
|
while (j > i && (unsigned char)s[j - 1] <= ' ') j--;
|
|
return s.substr(i, j - i);
|
|
}
|
|
static std::string pfEditCurrency() // currency, defaulting an empty buffer to USD
|
|
{
|
|
return s_pfEdit.manualCcy[0] != '\0' ? std::string(s_pfEdit.manualCcy) : std::string("USD");
|
|
}
|
|
|
|
static config::Settings::PortfolioEntry pfBuildWorking(const config::Settings::PortfolioEntry& base)
|
|
{
|
|
config::Settings::PortfolioEntry e = base;
|
|
e.label = pfEditLabel(); 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 = pfEditCurrency();
|
|
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 == pfEditLabel() && 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 == pfEditCurrency()
|
|
&& 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;
|
|
}
|
|
|
|
// A group is only worth persisting when it has a name, at least one address (else it's always $0), and
|
|
// — on the Manual price basis — a positive price (0 would render as "unavailable"). Gates both the Save
|
|
// button and the commit path (Close/switch commit too, so validating only the button wouldn't be enough).
|
|
static bool pfWorkingValid()
|
|
{
|
|
return !pfEditLabel().empty() // a whitespace-only label is treated as empty (invisible group)
|
|
&& !s_pfEdit.addrs.empty()
|
|
&& !(s_pfEdit.priceBasis == 3 && s_pfEdit.manualPrice <= 0.0);
|
|
}
|
|
|
|
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 adopt the active wallet's scope (per-wallet visibility). An existing group with no scope
|
|
// — a placeholder added before the wallet identity resolved, or a pre-scoping legacy group — is also
|
|
// claimed for the active wallet on edit, so it can't linger in the global (empty-scope) bucket and
|
|
// leak into every wallet's list. No-op when the identity is unavailable (stays unscoped until edited
|
|
// with a live identity).
|
|
static void pfCommitIfNeeded(App* app)
|
|
{
|
|
config::Settings* settings = app->settings();
|
|
if (!pfWorkingValid()) return; // drop invalid drafts (unnamed / no addresses / manual price <= 0)
|
|
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 (e.scope.empty()) {
|
|
const std::string sid = app->portfolioScopeId();
|
|
if (!sid.empty()) e.scope = sid;
|
|
}
|
|
if (existing) entries[s_pfEdit.sel] = e;
|
|
else { entries.push_back(e); s_pfEdit.sel = (int)entries.size() - 1; }
|
|
pfPersist(settings, entries);
|
|
}
|
|
|
|
// A group is visible to the active wallet when it carries the wallet's stable scope id, is unscoped (global,
|
|
// shown everywhere), or still carries a legacy address-hash scope (fail-open until migrated). Nothing but
|
|
// global groups is visible while the wallet id is unresolved. The editor mirrors the Market summary's filter
|
|
// so it only lists/edits the current wallet's groups.
|
|
static bool pfIndexVisible(App* app, int i)
|
|
{
|
|
const auto& es = app->settings()->getPortfolioEntries();
|
|
if (i < 0 || i >= (int)es.size()) return false;
|
|
return es[i].visibleInWallet(app->portfolioScopeId());
|
|
}
|
|
static int pfFirstVisibleIndex(App* app)
|
|
{
|
|
const auto& es = app->settings()->getPortfolioEntries();
|
|
const std::string sid = app->portfolioScopeId();
|
|
for (int i = 0; i < (int)es.size(); i++)
|
|
if (es[i].visibleInWallet(sid)) return i;
|
|
return -1;
|
|
}
|
|
|
|
// ---- 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");
|
|
// Reject negatives and non-finite input (InputDouble parses "inf"/"nan"; neither is < 0).
|
|
if (!std::isfinite(s_pfEdit.manualPrice) || 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("addr_type_mining") };
|
|
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;
|
|
// Selected-address set, built once (membership test for the sort flag and the per-row check below).
|
|
std::unordered_set<std::string> selSet(s_pfEdit.addrs.begin(), s_pfEdit.addrs.end());
|
|
// Each candidate carries its "selected" flag, looked up ONCE (the old comparator did two hash lookups per
|
|
// comparison). Same keys + same comparison outcomes => std::sort yields the same permutation as before.
|
|
struct PfPick { const AddressInfo* a; bool sel; };
|
|
std::vector<PfPick> filtered;
|
|
filtered.reserve(state.addresses.size());
|
|
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, selSet.count(a.address) != 0});
|
|
}
|
|
std::sort(filtered.begin(), filtered.end(), [](const PfPick& x, const PfPick& y) {
|
|
if (x.sel != y.sel) return x.sel;
|
|
return x.a->balance > y.a->balance;
|
|
});
|
|
if (selAllClicked)
|
|
for (const PfPick& p : filtered)
|
|
if (data::PortfolioEntryAdd(s_pfEdit.addrs, p.a->address)) selSet.insert(p.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"));
|
|
// C181: clip the portfolio-editor rows. Fixed step = InvisibleButton height (rowH) + ItemSpacing.y,
|
|
// so each row's cursor (and the Layout density tokens that read the content region) is exactly where
|
|
// the unclipped loop put it; the scroll extent stays exact for the fade shader.
|
|
ImGuiListClipper rowClipper;
|
|
rowClipper.Begin(static_cast<int>(filtered.size()), rowH + ImGui::GetStyle().ItemSpacing.y);
|
|
while (rowClipper.Step()) {
|
|
for (int ri = rowClipper.DisplayStart; ri < rowClipper.DisplayEnd; ++ri) {
|
|
const AddressInfo& a = *filtered[static_cast<size_t>(ri)].a;
|
|
bool inSet = selSet.count(a.address) != 0;
|
|
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 * dp);
|
|
else if (rhov) ldl->AddRectFilled(rmn, rmx, IM_COL32(255, 255, 255, 14), 4.0f * dp);
|
|
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 * dp);
|
|
float chipW = material::DrawPill(ldl, chipPos, chip, capF, chipCol,
|
|
WithAlpha(chipCol, 40), 0, ImVec2(4.0f * dp, 2.0f * dp), 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); selSet.erase(a.address); }
|
|
else { data::PortfolioEntryAdd(s_pfEdit.addrs, a.address); selSet.insert(a.address); }
|
|
}
|
|
ImGui::PopID();
|
|
}
|
|
} // while (rowClipper.Step())
|
|
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). Every dismiss gesture
|
|
// (Close button, Esc, outside-click, switching/adding/deleting groups) auto-saves the current
|
|
// group via pfCommitIfNeeded; LatchBlurOverlayActive (App::render) does the acrylic re-capture.
|
|
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;
|
|
// Outside-click dismiss is handled inside BeginOverlayDialog, which clears s_pfEdit.open mid-frame
|
|
// (the card still draws one final frame). Auto-save now, before that frame: the placeholder GC in
|
|
// mktDrawPortfolio already ran this frame while open was still true, so entry indices haven't
|
|
// shifted under us and pfCommitIfNeeded targets the right slot.
|
|
if (!s_pfEdit.open) pfCommitIfNeeded(app);
|
|
// A first Escape should dismiss only an open popup (e.g. the custom-color picker); don't also tear
|
|
// down the whole editor while a popup is capturing the key. Otherwise Escape auto-saves and closes.
|
|
if (ImGui::IsKeyPressed(ImGuiKey_Escape) &&
|
|
!ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel)) {
|
|
pfCommitIfNeeded(app);
|
|
s_pfEdit.open = false;
|
|
}
|
|
|
|
{ // Clamp/validate the selection. The in-editor delete already reloads the working state, but if the
|
|
// list shrank or the selection landed on a group outside this wallet's scope via any other path,
|
|
// reset to the first visible group and reload so a later commit can't overlay stale fields.
|
|
const auto& entriesRO = settings->getPortfolioEntries();
|
|
if (s_pfEdit.sel >= (int)entriesRO.size() ||
|
|
(s_pfEdit.sel >= 0 && !pfIndexVisible(app, s_pfEdit.sel))) {
|
|
s_pfEdit.sel = pfFirstVisibleIndex(app);
|
|
PortfolioBeginEdit(app, s_pfEdit.sel);
|
|
}
|
|
}
|
|
|
|
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 — the working buffer only ever holds the selected group; switching reloads it and
|
|
// discards any uncommitted edits.) Drives the master-list "unsaved" dot and Revert/Save 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();
|
|
// Only list groups scoped to the active wallet (or legacy/unscoped) — same filter the
|
|
// Market summary uses. `i` stays the storage index (selection/delete operate on storage).
|
|
const std::string scopeId = app->portfolioScopeId();
|
|
std::vector<int> vis;
|
|
for (int i = 0; i < (int)entries.size(); i++)
|
|
// Global groups always; this wallet's (and not-yet-migrated legacy) groups only once the wallet
|
|
// is resolved - never another wallet's scoped groups during the switch/first-load window.
|
|
if (entries[i].visibleInWallet(scopeId))
|
|
vis.push_back(i);
|
|
if (vis.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
|
|
bool armedThisFrame = false; // an arm-click also fires the row Selectable; don't let it disarm
|
|
for (int vi = 0; vi < (int)vis.size(); vi++) {
|
|
int i = vis[vi];
|
|
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) {
|
|
bool armed = (s_pfConfirmDelIdx == i); // this row is arm-confirmed
|
|
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);
|
|
// Hovering a *different* row's trash disarms the previously armed one.
|
|
if (!armed && s_pfConfirmDelIdx >= 0) s_pfConfirmDelIdx = -1;
|
|
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
|
if (armed) delRow = i; // 2nd click on the armed row erases
|
|
else { s_pfConfirmDelIdx = i; // 1st click arms this row...
|
|
armedThisFrame = true; } // ...and must survive the row-select this same click triggers
|
|
}
|
|
}
|
|
// A filled trash glyph while armed (vs outline) reinforces the recolor.
|
|
const char* delGlyph = armed ? ICON_MD_DELETE : ICON_MD_DELETE_OUTLINE;
|
|
ImU32 delCol = armed ? ReadableError() : (dhov ? Error() : OnSurfaceMedium());
|
|
ImVec2 isz = delFont->CalcTextSizeA(ds, FLT_MAX, 0, delGlyph);
|
|
mdl->AddText(delFont, ds, ImVec2(dc.x - isz.x * 0.5f, dc.y - isz.y * 0.5f),
|
|
delCol, delGlyph);
|
|
}
|
|
ImGui::PopID();
|
|
}
|
|
if (delRow >= 0) {
|
|
// Auto-save edits to a *different* selected group before mutating the list; only the
|
|
// deleted row's own working state is dropped. (Re-read entries after the commit.)
|
|
if (delRow != s_pfEdit.sel) pfCommitIfNeeded(app);
|
|
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);
|
|
}
|
|
s_pfConfirmDelIdx = -1; // erase done — storage indices shifted, drop the arm
|
|
} else if (clickedSel != -999 && !armedThisFrame) {
|
|
pfCommitIfNeeded(app); // auto-save the current group before switching
|
|
PortfolioBeginEdit(app, clickedSel);
|
|
s_pfConfirmDelIdx = -1; // switching rows disarms any pending delete
|
|
}
|
|
// A left-click that hit no row/action (empty list space) disarms a pending delete.
|
|
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
|
|
!ImGui::IsAnyItemHovered())
|
|
s_pfConfirmDelIdx = -1;
|
|
}
|
|
ImGui::EndChild();
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
// Add immediately creates a persisted "Untitled" group scoped to this wallet - so it's disabled
|
|
// while the wallet is unresolved (switch / first-load window), otherwise a new group would
|
|
// get an empty scope and show under every wallet.
|
|
const bool pfIdentityKnown = !app->portfolioScopeId().empty();
|
|
ImGui::BeginDisabled(!pfIdentityKnown);
|
|
if (material::TactileButton(TR("portfolio_add_entry"), ImVec2(masterW, addH))) {
|
|
// Auto-save the current group, then start a fresh one selected for editing.
|
|
pfCommitIfNeeded(app);
|
|
auto es = settings->getPortfolioEntries();
|
|
config::Settings::PortfolioEntry ne;
|
|
ne.label = TR("portfolio_untitled");
|
|
ne.scope = app->portfolioScopeId(); // new group defaults to this wallet (stable id)
|
|
es.push_back(ne);
|
|
pfPersist(settings, es);
|
|
s_pfEdit.sel = (int)es.size() - 1;
|
|
PortfolioBeginEdit(app, s_pfEdit.sel);
|
|
}
|
|
ImGui::EndDisabled();
|
|
if (!pfIdentityKnown && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
|
|
material::Tooltip("%s", TR("portfolio_wallet_loading"));
|
|
}
|
|
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 * dp; 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 * dp, 0, 2.0f * dp);
|
|
}
|
|
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, dp);
|
|
}
|
|
}
|
|
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(!pfWorkingValid());
|
|
if (material::TactileButton(TR("portfolio_save"), ImVec2(bw, addH))) pfCommitIfNeeded(app);
|
|
ImGui::EndDisabled();
|
|
ImGui::EndDisabled();
|
|
// Explain why Save is disabled — but only for a validation gap, not the "nothing changed"
|
|
// (!selDirty) case, which is self-evident. Priority mirrors pfWorkingValid's checks.
|
|
if (!pfWorkingValid() && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
|
|
if (pfEditLabel().empty()) material::Tooltip("%s", TR("portfolio_save_need_name"));
|
|
else if (s_pfEdit.addrs.empty()) material::Tooltip("%s", TR("portfolio_save_need_address"));
|
|
else material::Tooltip("%s", TR("portfolio_save_need_price"));
|
|
}
|
|
}
|
|
}
|
|
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=compact, 1=detailed,
|
|
// 2=value-hero. No left accent strip — identity comes from the tinted icon + a subtle accent
|
|
// border. Fixed right-aligned columns keep numbers aligned across rows; on-but-absent fields show a
|
|
// muted em-dash so a row never looks broken. Pure drawing into dl.
|
|
// TABLE-style (portfolio_style 0) right-edge column anchors — shared by pfDrawRow's row body and the
|
|
// column-header strip so the header labels sit exactly over the values they name.
|
|
// TABLE-style (style 0) column widths (logical px) — shared by pfTableCols (which turns them into
|
|
// right-edge anchors) and pfDrawRow (which feeds valW/drgxW to fit() truncation), so truncated text
|
|
// always lines up with the column edges. Change here and both stay in sync.
|
|
static constexpr float kPfChgColW = 62.0f;
|
|
static constexpr float kPfValColW = 108.0f;
|
|
static constexpr float kPfDrgxColW = 118.0f;
|
|
|
|
struct PfTableCols { float labelR, drgxR, valR, chgR; };
|
|
static PfTableCols pfTableCols(float right, float dp)
|
|
{
|
|
const float colGap = Layout::spacingMd();
|
|
PfTableCols c;
|
|
c.chgR = right;
|
|
c.valR = c.chgR - kPfChgColW * dp - colGap;
|
|
c.drgxR = c.valR - kPfValColW * dp - colGap;
|
|
c.labelR = c.drgxR - kPfDrgxColW * dp - colGap;
|
|
return c;
|
|
}
|
|
|
|
// Portfolio row height + inter-row gap by style — one source shared by the row drawing
|
|
// (mktDrawPortfolio) and the scroll-region height budget (RenderMarketTab) so they can't drift and clip.
|
|
// Row heights: Table 48dp (a dedicated trend column needs vertical room), Cards 68dp (two-zone),
|
|
// Spotlight 92dp (a dominant bottom chart band under the hero value). Uniform per style.
|
|
static float pfRowHeight(int style, float dp) { return (style == 0 ? 48.0f : style == 1 ? 68.0f : 92.0f) * dp; }
|
|
// Fraction of the Table label+trend zone given to the label; the rest is the centre TREND column.
|
|
// Shared by the row body and the header strip so the sparkline and its "TREND" label line up.
|
|
static constexpr float kPfTableTrendFrac = 0.38f;
|
|
static float pfRowGapFor(int style, float dp) { return style == 0 ? 2.0f * dp : Layout::spacingSm(); }
|
|
|
|
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* capFont, bool hov, bool tableReserveSpark = false)
|
|
{
|
|
const double bal = data::SumPortfolioBalance(e.addresses, state.addresses);
|
|
ImU32 accent = e.color ? (ImU32)e.color : 0;
|
|
const bool zeroBal = (bal <= 0.0); // empty groups render dimmed (content only, not the container)
|
|
|
|
// ---- Per-style CONTAINER: Table (0) = borderless ledger row (hairline + hover wash); Cards (1) =
|
|
// glass card with a faint identity tint; Spotlight (2) = a heavier glass tile. No accent line —
|
|
// the group's colour already reads from its icon and the sparkline, so it adds nothing here. ----
|
|
const float round = 10.0f * dp;
|
|
if (style == 0) {
|
|
if (hov) dl->AddRectFilled(rowMin, rowMax, WithAlpha(OnSurface(), 14), 0.0f);
|
|
dl->AddLine(ImVec2(rowMin.x, rowMax.y - 0.5f), ImVec2(rowMax.x, rowMax.y - 0.5f),
|
|
WithAlpha(OnSurface(), 24), 1.0f);
|
|
} else if (style == 1) {
|
|
GlassPanelSpec g; g.rounding = round; g.fillAlpha = hov ? 42 : 30; g.borderAlpha = 55;
|
|
DrawGlassPanel(dl, rowMin, rowMax, g);
|
|
if (accent) dl->AddRectFilled(rowMin, rowMax, WithAlpha(accent, hov ? 16 : 10), round); // faint identity wash
|
|
if (hov) dl->AddRect(rowMin, rowMax, WithAlpha(OnSurface(), 80), round, 0, 1.2f);
|
|
} else {
|
|
GlassPanelSpec g; g.rounding = round; g.fillAlpha = hov ? 56 : 46; g.borderAlpha = 60;
|
|
DrawGlassPanel(dl, rowMin, rowMax, g);
|
|
if (hov) dl->AddRect(rowMin, rowMax, WithAlpha(OnSurface(), 70), round, 0, 1.2f);
|
|
}
|
|
|
|
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; };
|
|
// Right-aligned text: right edge at xr, vertically centred on yc.
|
|
auto rtext = [&](ImFont* f, float xr, float yc, ImU32 col, const std::string& s){
|
|
dl->AddText(f, f->LegacySize, ImVec2(xr - tw(f, s), yc - f->LegacySize * 0.5f), col, s.c_str());
|
|
};
|
|
const std::string kDash = "\xE2\x80\x94"; // em-dash for on-but-absent fields
|
|
|
|
// ---- Field values + presence ----
|
|
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 = std::string(db);
|
|
bool wantValue = e.showValue;
|
|
bool wantChange = e.show24h && (e.priceBasis==0 || e.priceBasis==1);
|
|
bool hasChange = wantChange && market.change_24h != 0.0;
|
|
std::string chgStr; if (hasChange){ char cb[32]; snprintf(cb,sizeof(cb),"%+.2f%%",market.change_24h); chgStr=cb; }
|
|
ImU32 chgCol = market.change_24h>=0 ? Success() : Error();
|
|
bool wantSpark = e.showSparkline && (e.priceBasis==0 || e.priceBasis==1);
|
|
std::vector<double> spark;
|
|
if (wantSpark) spark = data::sparklineSeries(market, e.sparklineInterval);
|
|
bool hasSpark = wantSpark && spark.size() >= 2;
|
|
ImU32 sparkCol = (!spark.empty() && spark.back()>=spark.front()) ? Success() : Error();
|
|
|
|
const float padX = Layout::spacingMd(), padY = Layout::spacingSm(), colGap = Layout::spacingMd();
|
|
float left = rowMin.x + padX;
|
|
float right = rowMax.x - padX;
|
|
float midY = (rowMin.y + rowMax.y) * 0.5f;
|
|
|
|
// Empty groups: dim the BODY content (text/pill/bar/sparkline) to ~half alpha so a $0 group reads as
|
|
// muted, while the container (card/tick) stays full-strength. Done by scaling the alpha of the
|
|
// vertices this body emits — one place instead of tinting every draw call.
|
|
const int zdVtx = zeroBal ? dl->VtxBuffer.Size : -1;
|
|
|
|
if (style == 0) {
|
|
// ---- TABLE: icon + label (left); a dedicated centre TREND column (aligned + header-labelled);
|
|
// right-aligned numeric columns DRGX | value | 24h pill. The trend column is present for the
|
|
// whole list when any group opts in (tableReserveSpark); each row fills it when it has one.
|
|
const PfTableCols cols = pfTableCols(right, dp);
|
|
const float chgR = cols.chgR, valR = cols.valR, drgxR = cols.drgxR, labelR = cols.labelR;
|
|
const float valW = kPfValColW*dp, drgxW = kPfDrgxColW*dp; // fit() widths (shared with pfTableCols)
|
|
float iconSz = capFont->LegacySize, x = left;
|
|
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 (e.showDrgx) rtext(capFont, drgxR, midY, OnSurfaceMedium(), fit(drgxStr, capFont, drgxW));
|
|
if (wantValue) rtext(sub1, valR, midY, hasValue?OnSurface():OnSurfaceDisabled(), hasValue?fit(valStr,sub1,valW):kDash);
|
|
if (wantChange) {
|
|
const std::string s = hasChange ? chgStr : kDash;
|
|
const float pw = tw(capFont, s) + 12.0f*dp, ph = capFont->LegacySize + 5.0f*dp;
|
|
const ImVec2 pmn(chgR - pw, midY - ph*0.5f);
|
|
if (hasChange) dl->AddRectFilled(pmn, ImVec2(chgR, midY + ph*0.5f), WithAlpha(chgCol, 32), ph*0.5f);
|
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(pmn.x + 6.0f*dp, midY - capFont->LegacySize*0.5f),
|
|
hasChange?chgCol:OnSurfaceDisabled(), s.c_str());
|
|
}
|
|
// Dedicated centre TREND column: when the list has any sparkline (tableReserveSpark), cap the
|
|
// label to a fixed boundary so the column + its header label align across every row; each row
|
|
// fills it only when that group opts in. Rows without any list-wide trend keep the full label.
|
|
// Trend-column origin from the fixed LEFT (icon-independent) so it lines up with the "TREND"
|
|
// header and across rows whether or not a group has an icon (x may be shifted past the icon).
|
|
const float zoneW = std::max(24.0f*dp, labelR - left);
|
|
const float trendX0 = left + zoneW * kPfTableTrendFrac;
|
|
const float lblMaxW = tableReserveSpark ? std::max(24.0f*dp, trendX0 - colGap - x) : std::max(24.0f*dp, labelR - x);
|
|
dl->AddText(sub1, sub1->LegacySize, ImVec2(x, midY - sub1->LegacySize*0.5f), OnSurface(), fit(e.label,sub1,lblMaxW).c_str());
|
|
if (hasSpark && tableReserveSpark && labelR - trendX0 > 40.0f * dp)
|
|
pfDrawSparklineFilled(dl, ImVec2(trendX0, rowMin.y + padY), ImVec2(labelR, rowMax.y - padY), spark, sparkCol, dp);
|
|
} else if (style == 1) {
|
|
// ---- CARDS: a balanced snapshot — a LEFT-aligned info stack (icon + label / value+delta chip /
|
|
// DRGX) grouped on the left, with a full-height sparkline filling the rest of the width.
|
|
const float zoneTop = rowMin.y + padY;
|
|
const float zoneBot = rowMax.y - padY;
|
|
// Icon centred in the info zone; the label/value/DRGX stack sits to its right.
|
|
const float iconSz = capFont->LegacySize;
|
|
float textX = left;
|
|
if (!e.icon.empty()) {
|
|
material::project_icons::drawByName(dl, e.icon.c_str(), ImVec2(left + iconSz*0.5f, (zoneTop + zoneBot)*0.5f), accent?accent:OnSurfaceMedium(), Type().iconSmall(), iconSz);
|
|
textX = left + iconSz + Layout::spacingSm();
|
|
}
|
|
// Info zone driven by the ACTUAL content width so a short group hands the extra room to the chart;
|
|
// bounded [28%, 52%] so the info never cramps and the chart always keeps a decent band.
|
|
float infoR = right, cSparkL = right;
|
|
if (hasSpark) {
|
|
const std::string vTxt0 = wantValue ? (hasValue?valStr:kDash) : std::string();
|
|
const float chipW = (wantChange && hasChange) ? (Layout::spacingSm() + tw(capFont, chgStr) + 12.0f*dp) : 0.0f;
|
|
const float valLineW = wantValue ? (tw(sub1, vTxt0) + chipW) : 0.0f;
|
|
const float contentW = std::max(std::max(tw(capFont, e.label), valLineW), e.showDrgx ? tw(capFont, drgxStr) : 0.0f);
|
|
const float span = right - left;
|
|
const float infoW = std::min(std::max((textX - left) + contentW + colGap * 2.0f, span * 0.28f), span * 0.52f);
|
|
cSparkL = left + infoW;
|
|
infoR = cSparkL - colGap;
|
|
}
|
|
const float gap = Layout::spacingXs();
|
|
const float stackH = capFont->LegacySize + gap + sub1->LegacySize + gap + capFont->LegacySize;
|
|
float cy = zoneTop + std::max(0.0f, (zoneBot - zoneTop - stackH) * 0.5f);
|
|
// label (muted)
|
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(textX, cy), OnSurfaceMedium(), fit(e.label, capFont, std::max(24.0f*dp, infoR - textX)).c_str());
|
|
cy += capFont->LegacySize + gap;
|
|
// value (leads via a soft shadow) + delta chip trailing it
|
|
if (wantValue) {
|
|
const std::string vTxt = hasValue ? valStr : kDash;
|
|
DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(textX, cy), hasValue?OnSurface():OnSurfaceDisabled(), vTxt.c_str());
|
|
if (wantChange && hasChange) {
|
|
const float pw = tw(capFont, chgStr) + 12.0f*dp, ph = capFont->LegacySize + 5.0f*dp;
|
|
const float chipX = textX + tw(sub1, vTxt) + Layout::spacingSm();
|
|
const float chipY = cy + (sub1->LegacySize - ph)*0.5f;
|
|
if (chipX + pw <= infoR) {
|
|
dl->AddRectFilled(ImVec2(chipX, chipY), ImVec2(chipX+pw, chipY+ph), WithAlpha(chgCol, 34), ph*0.5f);
|
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(chipX+6.0f*dp, chipY + (ph-capFont->LegacySize)*0.5f), chgCol, chgStr.c_str());
|
|
}
|
|
}
|
|
}
|
|
cy += sub1->LegacySize + gap;
|
|
// DRGX (muted)
|
|
if (e.showDrgx) dl->AddText(capFont, capFont->LegacySize, ImVec2(textX, cy), OnSurfaceMedium(), fit(drgxStr, capFont, std::max(24.0f*dp, infoR - textX)).c_str());
|
|
if (hasSpark) pfDrawSparklineFilled(dl, ImVec2(cSparkL, zoneTop), ImVec2(right, zoneBot), spark, sparkCol, dp);
|
|
} else {
|
|
// ---- SPOTLIGHT: "big number over a chart" hero tile — a top zone (muted name + DRGX, then a
|
|
// LARGE value coloured by 24h with a delta chip) over a dominant full-width sparkline band
|
|
// filling the bottom. The value spans the full width now (no competing right strip).
|
|
const float heroSz = sub1->LegacySize * 1.5f; // ImGui 1.92 rebakes at this size (crisp, not upscaled)
|
|
const float topY = rowMin.y + padY;
|
|
float iconSz = capFont->LegacySize, 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(), iconSz);
|
|
x += iconSz + Layout::spacingSm();
|
|
}
|
|
// name (top-left, muted) + DRGX (top-right, muted)
|
|
const float drgxW2 = e.showDrgx ? tw(capFont, drgxStr) : 0.0f;
|
|
if (e.showDrgx) rtext(capFont, right, topY + capFont->LegacySize*0.5f, OnSurfaceMedium(), drgxStr);
|
|
const float nameMaxW = std::max(24.0f*dp, (right - (e.showDrgx ? drgxW2 + colGap : 0.0f)) - x);
|
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(x, topY), OnSurfaceMedium(), fit(e.label,capFont,nameMaxW).c_str());
|
|
// hero value — coloured by 24h direction (neutral when no live change)
|
|
const float valY = topY + capFont->LegacySize + Layout::spacingXs();
|
|
const std::string hero = hasValue ? valStr : (e.showDrgx ? drgxStr : kDash);
|
|
const ImU32 heroCol = !hasValue ? OnSurfaceDisabled() : (hasChange ? chgCol : OnSurface());
|
|
DrawTextShadow(dl, sub1, heroSz, ImVec2(left, valY), heroCol, hero.c_str());
|
|
const float heroW = sub1->CalcTextSizeA(heroSz, FLT_MAX, 0, hero.c_str()).x;
|
|
// delta chip trailing the hero value
|
|
if (wantChange && hasChange) {
|
|
const float pw = tw(capFont, chgStr) + 12.0f*dp, ph = capFont->LegacySize + 5.0f*dp;
|
|
const float chipX = left + heroW + Layout::spacingSm();
|
|
const float chipY = valY + (heroSz - ph)*0.5f;
|
|
if (chipX + pw <= right) {
|
|
dl->AddRectFilled(ImVec2(chipX, chipY), ImVec2(chipX+pw, chipY+ph), WithAlpha(chgCol, 34), ph*0.5f);
|
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(chipX+6.0f*dp, chipY + (ph-capFont->LegacySize)*0.5f), chgCol, chgStr.c_str());
|
|
}
|
|
}
|
|
// full-width sparkline band filling the bottom of the tile, below the hero value
|
|
if (hasSpark) {
|
|
const float bandTop = valY + heroSz + Layout::spacingXs();
|
|
if (rowMax.y - padY - bandTop > 12.0f * dp)
|
|
pfDrawSparklineFilled(dl, ImVec2(left, bandTop), ImVec2(right, rowMax.y - padY), spark, sparkCol, dp);
|
|
}
|
|
}
|
|
|
|
if (zdVtx >= 0) { // fade the just-emitted body vertices for an empty group
|
|
for (int vi = zdVtx; vi < dl->VtxBuffer.Size; ++vi) {
|
|
ImDrawVert& v = dl->VtxBuffer[vi];
|
|
v.col = material::ScaleAlpha(v.col, 0.5f);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 dp = Layout::dpiScale();
|
|
float chipH = S.drawElement("tabs.market", "pair-chip-height").height * dp;
|
|
float chipR = S.drawElement("tabs.market", "pair-chip-radius").radius * dp;
|
|
float chipSpacing = S.drawElement("tabs.market", "pair-chip-spacing").size * dp;
|
|
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;
|
|
const std::vector<data::Candle>* chartCandles; // per-exchange OHLC (empty -> line chart)
|
|
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();
|
|
|
|
// Prefer the SELECTED exchange's own current price (it differs per venue — Ourbit vs NonKYC can be
|
|
// ~7% apart); fall back to the CoinGecko cross-exchange aggregate when the venue price is unknown.
|
|
double heroPrice = market.price_usd;
|
|
if (!currentExchange.pairs.empty() && s_mkt.pairIdx < (int)currentExchange.pairs.size()
|
|
&& currentExchange.pairs[s_mkt.pairIdx].lastUsd > 0.0)
|
|
heroPrice = currentExchange.pairs[s_mkt.pairIdx].lastUsd;
|
|
|
|
if (heroPrice > 0) {
|
|
// ---- HERO PRICE (large, prominent) ----
|
|
ImFont* h3 = Type().h3();
|
|
std::string priceStr = FormatPrice(heroPrice);
|
|
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();
|
|
|
|
// Chart line/candle segmented control, immediately left of the trade button — only when the
|
|
// selected range has per-exchange candles (Live / aggregate is line-only, nothing to toggle).
|
|
if (cx.chartCandles && cx.chartCandles->size() >= 2) {
|
|
const char* styleIcons[2] = { ICON_MD_SHOW_CHART, ICON_MD_CANDLESTICK_CHART };
|
|
const float csW = 2.0f * tradeBtnH;
|
|
const ImVec2 csOrigin(tradeBtnX - csW - Layout::spacingSm(), tradeBtnY);
|
|
const ImVec2 csSaved = ImGui::GetCursorScreenPos();
|
|
const int csCur = s_mkt.chartStyle;
|
|
const int csClk = SegmentedControl(dl, csOrigin, csW, tradeBtnH, styleIcons, 2, csCur,
|
|
Type().iconSmall(), "##chartStyleSeg", dp);
|
|
if (csClk >= 0 && csClk != csCur) {
|
|
s_mkt.chartStyle = csClk;
|
|
if (app->settings()) { app->settings()->setChartStyle(csClk); app->settings()->save(); }
|
|
}
|
|
ImGui::SetCursorScreenPos(csSaved);
|
|
}
|
|
|
|
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 * dp), 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 * dp + 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 * mktDp);
|
|
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 (app->settings()) { app->settings()->setChartInterval(kIvs[b].iv); app->settings()->save(); }
|
|
}
|
|
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
ImGui::PopID();
|
|
bx += bw + Layout::spacingXs();
|
|
}
|
|
|
|
// (Line/candle chart-style toggle now lives top-right of the price hero, left of the trade button.)
|
|
|
|
// 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 * mktDp);
|
|
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->retryMarketDataNow(); // also lifts a failure backoff, so a manual retry runs now
|
|
}
|
|
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) {
|
|
// Market cap is coin-wide (stays aggregate); 24h volume is per-venue — show the SELECTED
|
|
// exchange's own volume when we have it, else the CoinGecko aggregate.
|
|
drawStat(TR("market_cap_short"), FormatCompactUSD(market.market_cap));
|
|
double vol = market.volume_24h;
|
|
const auto& exch = *cx.currentExchange;
|
|
if (!exch.pairs.empty() && s_mkt.pairIdx < (int)exch.pairs.size()
|
|
&& exch.pairs[s_mkt.pairIdx].volumeUsd > 0.0)
|
|
vol = exch.pairs[s_mkt.pairIdx].volumeUsd;
|
|
drawStat(TR("market_vol_short"), FormatCompactUSD(vol));
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
const auto& candles = *cx.chartCandles;
|
|
// Candlesticks when per-exchange OHLC exists AND the user hasn't switched to the line view.
|
|
const bool hasCandles = candles.size() >= 2 && s_mkt.chartStyle == 1;
|
|
if (hasCandles || s_mkt.history.size() >= 2) {
|
|
// Compute Y range with padding — candles span low..high, the line spans close..close.
|
|
double yMin, yMax;
|
|
if (hasCandles) {
|
|
yMin = candles[0].low; yMax = candles[0].high;
|
|
for (const auto& c : candles) { if (c.low < yMin) yMin = c.low; if (c.high > yMax) yMax = c.high; }
|
|
} else {
|
|
yMin = *std::min_element(s_mkt.history.begin(), s_mkt.history.end());
|
|
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 * mktDp, plotLeft - labelSz.x - 6.0f * mktDp);
|
|
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;
|
|
|
|
auto yOf = [&](double v){ return plotBottom - (float)((v - yMin) / (yMax - yMin)) * plotH; };
|
|
if (hasCandles) {
|
|
// Candlesticks: a thin wick (low..high) + a body (open..close), green up / red down.
|
|
const int cn = (int)candles.size();
|
|
const float slotW = plotW / (float)cn;
|
|
const float bodyW = std::max(1.0f, slotW * 0.62f);
|
|
for (int i = 0; i < cn; i++) {
|
|
const auto& c = candles[i];
|
|
const float xc = plotLeft + ((float)i + 0.5f) * slotW;
|
|
const ImU32 col = (c.close >= c.open) ? Success() : Error();
|
|
dl->AddLine(ImVec2(xc, yOf(c.high)), ImVec2(xc, yOf(c.low)), WithAlpha(col, 210),
|
|
std::max(1.0f, mktDp));
|
|
float bT = yOf(std::max(c.open, c.close)); // higher price -> smaller y -> body top
|
|
float bB = yOf(std::min(c.open, c.close));
|
|
if (bB - bT < 1.5f * mktDp) bB = bT + 1.5f * mktDp; // doji -> keep a visible sliver
|
|
dl->AddRectFilled(ImVec2(xc - bodyW * 0.5f, bT), ImVec2(xc + bodyW * 0.5f, bB),
|
|
WithAlpha(col, 230), 1.0f);
|
|
}
|
|
// Hover: highlight the candle under the cursor + an OHLC readout (date + open/high/low/close).
|
|
ImVec2 mp = ImGui::GetIO().MousePos;
|
|
if (mp.x >= plotLeft && mp.x <= plotRight && mp.y >= plotTop && mp.y <= plotBottom) {
|
|
int hi = (int)((mp.x - plotLeft) / slotW);
|
|
if (hi < 0) hi = 0;
|
|
if (hi >= cn) hi = cn - 1;
|
|
const auto& hc = candles[hi];
|
|
const float hx = plotLeft + ((float)hi + 0.5f) * slotW;
|
|
dl->AddRectFilled(ImVec2(hx - slotW * 0.5f, plotTop), ImVec2(hx + slotW * 0.5f, plotBottom),
|
|
IM_COL32(255, 255, 255, 12));
|
|
dl->AddLine(ImVec2(hx, plotTop), ImVec2(hx, plotBottom), IM_COL32(255, 255, 255, 45), 1.0f);
|
|
char when[40] = "";
|
|
std::time_t tt = hc.time;
|
|
if (std::tm* tmv = std::localtime(&tt))
|
|
std::strftime(when, sizeof(when), s_mkt.chartInterval <= 2 ? "%b %d %H:%M" : "%b %d, %Y", tmv);
|
|
char l2[80], l3[80];
|
|
snprintf(l2, sizeof(l2), "O %s H %s", FormatPrice(hc.open).c_str(), FormatPrice(hc.high).c_str());
|
|
snprintf(l3, sizeof(l3), "L %s C %s", FormatPrice(hc.low).c_str(), FormatPrice(hc.close).c_str());
|
|
const float lh = capFont->LegacySize;
|
|
const float pad = Layout::spacingSm();
|
|
const float gap = 3.0f * mktDp;
|
|
float tw = std::max(capFont->CalcTextSizeA(lh, FLT_MAX, 0, when).x,
|
|
std::max(capFont->CalcTextSizeA(lh, FLT_MAX, 0, l2).x,
|
|
capFont->CalcTextSizeA(lh, FLT_MAX, 0, l3).x));
|
|
float th = lh * 3 + gap * 2 + pad * 2;
|
|
float tipX = hx + 10.0f * mktDp;
|
|
if (tipX + tw + pad * 2 > plotRight) tipX = hx - tw - pad * 2 - 10.0f * mktDp;
|
|
tipX = std::max(plotLeft, tipX);
|
|
float tipY = plotTop + 4.0f * mktDp;
|
|
ImVec2 tMin(tipX, tipY), tMax(tipX + tw + pad * 2, tipY + th);
|
|
dl->AddRectFilled(tMin, tMax, IM_COL32(20, 20, 30, 235), 4.0f * mktDp);
|
|
dl->AddRect(tMin, tMax, IM_COL32(255, 255, 255, 30), 4.0f * mktDp, 0, 1.0f);
|
|
ImU32 cCol = (hc.close >= hc.open) ? Success() : Error();
|
|
dl->AddText(capFont, lh, ImVec2(tipX + pad, tipY + pad), OnSurface(), when);
|
|
dl->AddText(capFont, lh, ImVec2(tipX + pad, tipY + pad + lh + gap), cCol, l2);
|
|
dl->AddText(capFont, lh, ImVec2(tipX + pad, tipY + pad + 2 * lh + 2 * gap), cCol, l3);
|
|
}
|
|
} else {
|
|
for (size_t i = 0; i < n; i++) {
|
|
float t = (n > 1) ? (float)i / (float)(n - 1) : 0.0f;
|
|
points[i] = ImVec2(plotLeft + t * plotW, yOf(s_mkt.history[i]));
|
|
}
|
|
// Flat translucent area fill under the curve (matches the portfolio group sparklines).
|
|
FillUnderPolyline(dl, points.data(), (int)n, plotBottom, 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). Line only — the
|
|
// candlestick wicks already show each period's high/low.
|
|
if (!hasCandles && 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 * mktDp), OnSurfaceDisabled(), tlbl);
|
|
}
|
|
}
|
|
|
|
// Hover crosshair + tooltip (line only — it indexes the close-series `points`).
|
|
ImVec2 mousePos = ImGui::GetIO().MousePos;
|
|
if (!hasCandles && 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.0f * mktDp;
|
|
float tipY = py - tipSz.y - tipPad * 2 - 4.0f * mktDp;
|
|
if (tipX + tipSz.x + tipPad * 2 > plotRight)
|
|
tipX = px - tipSz.x - tipPad * 2 - 10.0f * mktDp;
|
|
if (tipY < plotTop) tipY = py + 10.0f * mktDp;
|
|
|
|
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 * mktDp);
|
|
dl->AddRect(tipMin, tipMax, IM_COL32(255, 255, 255, 30), 4.0f * mktDp, 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 float dp = Layout::dpiScale();
|
|
const float ecx = chartMin.x + availWidth * 0.5f;
|
|
const float ecy = (plotTop + plotBottom) * 0.5f;
|
|
if (app->isMarketChartLoading()) {
|
|
// A fetch is in flight — e.g. just switched exchange pairs. Show a spinner + animated label
|
|
// instead of "no history", since the data is on its way.
|
|
const float r = 10.0f * dp;
|
|
const float t = (float)ImGui::GetTime() * 4.5f; // rotate ~0.7 rev/s
|
|
dl->PathArcTo(ImVec2(ecx, ecy - 8.0f * dp), r, t, t + IM_PI * 1.5f, 24);
|
|
dl->PathStroke(OnSurfaceMedium(), false, 2.5f * dp);
|
|
char lb[80];
|
|
snprintf(lb, sizeof(lb), "%s%s", TR("market_chart_loading"), LoadingDots());
|
|
ImVec2 ts = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, lb);
|
|
dl->AddText(capFont, capFont->LegacySize,
|
|
ImVec2(ecx - ts.x * 0.5f, ecy + 10.0f * dp), OnSurfaceMedium(), lb);
|
|
} else {
|
|
const char* msg = TR("market_no_history");
|
|
ImVec2 ts = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, msg);
|
|
dl->AddText(sub1, sub1->LegacySize,
|
|
ImVec2(chartMin.x + (availWidth - ts.x) * 0.5f, ecy - 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];
|
|
|
|
// Garbage-collect abandoned placeholders. "Add entry" persists an empty group up front so it shows
|
|
// in the editor list; if the user closes the editor without ever adding an address, that phantom
|
|
// "$0 · 0" group would linger. A group with no addresses can't be Saved (the editor forbids it), so
|
|
// it is always junk — prune it, but only while the editor is closed so we never delete one the user
|
|
// is actively filling in.
|
|
if (!s_pfEdit.open) {
|
|
auto es = app->settings()->getPortfolioEntries();
|
|
size_t before = es.size();
|
|
es.erase(std::remove_if(es.begin(), es.end(),
|
|
[](const config::Settings::PortfolioEntry& e) { return e.addresses.empty(); }),
|
|
es.end());
|
|
if (es.size() != before) pfPersist(app->settings(), es);
|
|
}
|
|
|
|
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("market_portfolio"));
|
|
// Portfolio-style segmented control (Table / Cards / Spotlight) + "Manage…" button, right-aligned on
|
|
// the header row (style picker left of Manage). Manage opens the portfolio editor.
|
|
{
|
|
const char* ml = TR("portfolio_manage");
|
|
float mBtnW = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, ml).x + Layout::spacingMd() * 2;
|
|
const char* styleItems[3] = { TR("portfolio_style_compact"), TR("portfolio_style_detailed"),
|
|
TR("portfolio_style_featured") };
|
|
const float segH = ImGui::GetFrameHeight();
|
|
const float segW = 210.0f * mktDp;
|
|
const float sGap = Layout::spacingSm();
|
|
ImGui::SameLine();
|
|
RightAlignX(segW + sGap + mBtnW); // right-align the [style picker | Manage] group
|
|
const ImVec2 segOrigin = ImGui::GetCursorScreenPos();
|
|
const int curStyle = app->settings() ? app->settings()->getPortfolioStyle() : 0;
|
|
const int segClk = SegmentedControl(ImGui::GetWindowDrawList(), segOrigin, segW, segH,
|
|
styleItems, 3, curStyle, Type().caption(), "##pfStyleSeg", mktDp);
|
|
if (segClk >= 0 && segClk != curStyle && app->settings()) {
|
|
app->settings()->setPortfolioStyle(segClk);
|
|
app->settings()->save();
|
|
}
|
|
ImGui::SetCursorScreenPos(segOrigin);
|
|
ImGui::Dummy(ImVec2(segW, segH)); // reserve + restore layout flow
|
|
ImGui::SameLine(0.0f, sGap);
|
|
if (material::TactileButton(ml, ImVec2(mBtnW, 0))) {
|
|
// Open the editor on the first group visible to this wallet (or the empty state if none) —
|
|
// never a raw index 0 that might belong to a different wallet.
|
|
PortfolioBeginEdit(app, pfFirstVisibleIndex(app));
|
|
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);
|
|
}
|
|
|
|
// Only show the BTC equivalent when a BTC price is actually available — otherwise it would read a
|
|
// misleading "≈ 0.00000000 BTC" (price_usd can be present while price_btc is still 0).
|
|
if (market.price_btc > 0) {
|
|
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.0f * mktDp), 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 * mktDp);
|
|
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 * mktDp);
|
|
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 * mktDp);
|
|
|
|
// 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 scopeId = app->portfolioScopeId();
|
|
// Visible = this wallet's groups + global (empty scope) + not-yet-migrated legacy. Keep storage indices
|
|
// for edit targets.
|
|
std::vector<int> vis;
|
|
for (int i = 0; i < (int)allEntries.size(); i++) {
|
|
const auto& e = allEntries[i];
|
|
// Global groups always; this wallet's only once the wallet is resolved (never another wallet's
|
|
// scoped groups during the switch/first-load window).
|
|
if (e.visibleInWallet(scopeId)) vis.push_back(i);
|
|
}
|
|
|
|
int style = app->settings() ? app->settings()->getPortfolioStyle() : 0;
|
|
// The Table's dedicated TREND column (+ its header label) is present for the whole list when any
|
|
// visible group opts into a sparkline, so the column aligns and doesn't flicker per row.
|
|
bool anySpark = false;
|
|
for (int i : vis) {
|
|
const auto& e = allEntries[i];
|
|
if (e.showSparkline && (e.priceBasis == 0 || e.priceBasis == 1)) { anySpark = true; break; }
|
|
}
|
|
float rowH = pfRowHeight(style, mktDp);
|
|
// Table rows abut like a ledger (thin gap); Cards/Spotlight breathe.
|
|
float rowGap = pfRowGapFor(style, mktDp);
|
|
float rowsH = std::max(rowH, portfolioH - pfSummaryH);
|
|
|
|
// Anchor the bottom of the portfolio: the group list otherwise ends at its content height,
|
|
// leaving un-anchored dead space between the last row and the content-area bottom. Grow the
|
|
// rows region to fill the remaining vertical space (down to the scroll area's bottom, less a
|
|
// small margin) and wrap it in a contained glass panel — so it reads as one framed table with
|
|
// a real bottom edge (like Explorer's block-list card) instead of trailing off into nothing.
|
|
// The empty state then centres in this full-height panel rather than in a one-row band.
|
|
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + pfSummaryH));
|
|
float floorH = ImGui::GetContentRegionAvail().y - gap; // to the scroll bottom, keep a margin
|
|
float panelH = std::max(rowsH, floorH);
|
|
{
|
|
ImVec2 pMin(cardMin.x, cardMin.y + pfSummaryH);
|
|
ImVec2 pMax(rightEdge, pMin.y + panelH);
|
|
GlassPanelSpec pg;
|
|
pg.rounding = Layout::glassRounding();
|
|
pg.fillAlpha = 12; pg.borderAlpha = 24; // faint container: frames without competing with the per-row cards
|
|
DrawGlassPanel(dl, pMin, pMax, pg);
|
|
}
|
|
// Flush-left child (no window padding) so rows align with the summary; a scrollbar appears
|
|
// only when the visible groups overflow the bounded height. NoBackground so the contained
|
|
// glass panel drawn above is the sole surface — otherwise the opaque ChildBg (non-acrylic
|
|
// mode) would paint over the panel fill + border.
|
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
|
|
ImGui::BeginChild("##pfRows", ImVec2(availWidth, panelH), false, ImGuiWindowFlags_NoBackground);
|
|
ImGui::PopStyleVar();
|
|
// Zero item-spacing INSIDE the child: each row emits an InvisibleButton + a gap Dummy, and
|
|
// ImGui's default ItemSpacing.y between those items would inflate the content past rowsH and
|
|
// clip the last row (and widen the gaps). The inter-row gap is controlled by the Dummy below.
|
|
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0));
|
|
ImDrawList* rdl = ImGui::GetWindowDrawList();
|
|
float rowW = ImGui::GetContentRegionAvail().x; // narrows automatically if a scrollbar shows
|
|
if (vis.empty()) {
|
|
material::DrawEmptyState(ICON_MD_PIE_CHART, TR("portfolio_no_entries"));
|
|
} else {
|
|
// TABLE column-header strip (style 0): a thin static header labelling the numeric columns —
|
|
// a signature the card styles don't have. Uses the shared pfTableCols so it aligns with rows.
|
|
if (style == 0) {
|
|
const float headerH = capFont->LegacySize + 8.0f * mktDp;
|
|
const ImVec2 hp = ImGui::GetCursorScreenPos();
|
|
const float hLeft = hp.x + Layout::spacingMd();
|
|
const float hRight = hp.x + rowW - Layout::spacingMd();
|
|
const PfTableCols hc = pfTableCols(hRight, mktDp);
|
|
ImFont* ovF = Type().overline();
|
|
const float ty = hp.y + (headerH - ovF->LegacySize) * 0.5f;
|
|
const ImU32 hcol = OnSurfaceDisabled();
|
|
auto hdr = [&](float xr, const char* s){
|
|
const float w = ovF->CalcTextSizeA(ovF->LegacySize, FLT_MAX, 0, s).x;
|
|
rdl->AddText(ovF, ovF->LegacySize, ImVec2(xr - w, ty), hcol, s);
|
|
};
|
|
rdl->AddText(ovF, ovF->LegacySize, ImVec2(hLeft, ty), hcol, TR("market_col_name"));
|
|
if (anySpark) // TREND column header, left-aligned at the same x the row sparklines start
|
|
rdl->AddText(ovF, ovF->LegacySize, ImVec2(hLeft + (hc.labelR - hLeft) * kPfTableTrendFrac, ty), hcol, TR("market_col_trend"));
|
|
hdr(hc.drgxR, DRAGONX_TICKER);
|
|
hdr(hc.valR, TR("market_col_value"));
|
|
hdr(hc.chgR, TR("market_24h"));
|
|
rdl->AddLine(ImVec2(hLeft, hp.y + headerH - 1.0f), ImVec2(hRight, hp.y + headerH - 1.0f),
|
|
WithAlpha(OnSurface(), 20), 1.0f);
|
|
ImGui::Dummy(ImVec2(rowW, headerH));
|
|
}
|
|
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, capFont, hov, anySpark);
|
|
if (clicked) { PortfolioBeginEdit(app, i); s_pfEdit.open = true; }
|
|
if (vi + 1 < (int)vis.size()) ImGui::Dummy(ImVec2(rowW, rowGap));
|
|
}
|
|
}
|
|
ImGui::PopStyleVar(); // ItemSpacing
|
|
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();
|
|
// ...and the SELECTED pair's per-exchange OHLC candles (self-throttled, in-flight-guarded). Without
|
|
// this, the candle series only loaded on a pair-chip / refresh click, so the candlestick toggle was
|
|
// missing (and "switch to candlesticks" silently did nothing) when the tab was just opened.
|
|
app->refreshExchangeChart();
|
|
const auto& registry = EffectiveRegistry(market);
|
|
|
|
// Load persisted exchange/pair on first frame
|
|
LoadMarketState(app->settings());
|
|
ResolveMarketSelection(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 / market-settings modal is open.
|
|
if (app->settings() && !s_pfEdit.open &&
|
|
!ImGui::GetIO().WantTextInput && !ImGui::GetIO().KeyCtrl) {
|
|
bool prev = ImGui::IsKeyPressed(ImGuiKey_LeftArrow, false);
|
|
bool next = ImGui::IsKeyPressed(ImGuiKey_RightArrow, false);
|
|
if (prev || next) {
|
|
int st = app->settings()->getPortfolioStyle();
|
|
st = next ? (st + 1) % 3 : (st + 2) % 3;
|
|
app->settings()->setPortfolioStyle(st);
|
|
app->settings()->save(); // persist the choice (matches the gear-modal control)
|
|
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. --
|
|
// ~50% taller than before (floor / desired / viewport-cap all scaled) — a roomier price chart.
|
|
float chartH = std::max(165.0f * vs, std::min(chartElem.height * 1.5f * vs, marketAvail.y * 0.33f));
|
|
|
|
// -- 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, global or not-yet-migrated legacy) count toward the height.
|
|
const std::string pfScopeId = app->portfolioScopeId();
|
|
int pfVisN = 0;
|
|
for (const auto& e : pfEntriesGeo)
|
|
if (e.visibleInWallet(pfScopeId)) pfVisN++;
|
|
int pfStyle = app->settings()->getPortfolioStyle();
|
|
float pfRowH = pfRowHeight(pfStyle, mktDp);
|
|
float pfRowGap = pfRowGapFor(pfStyle, mktDp); // Table rows abut
|
|
float pfHeaderH = (pfStyle == 0) ? (capFont->LegacySize + 8.0f * mktDp) : 0.0f; // Table column strip
|
|
const int pfMaxVisibleRows = 4; // rows shown before the list scrolls
|
|
// Height for up to pfMaxVisibleRows rows. ItemSpacing is zeroed inside the row child, so the
|
|
// content is exactly rows + inter-row gaps; one extra gap of bottom breathing room keeps the
|
|
// last visible row off the clip edge. More groups than this scroll internally.
|
|
int pfVisRows = std::min(pfVisN, pfMaxVisibleRows);
|
|
float pfGroupsH = (pfVisRows > 0)
|
|
? (pfHeaderH + pfVisRows * pfRowH + (pfVisRows - 1) * pfRowGap + pfRowGap)
|
|
: 0.0f;
|
|
float portfolioH = pfSummaryH + pfGroupsH;
|
|
|
|
// Chart series for the selected interval (timestamped), shared by the hero change badge and
|
|
// the chart block below. Computed once here so the badge can report the displayed period's change.
|
|
std::time_t nowSec = std::time(nullptr);
|
|
std::vector<std::time_t> chartTimes;
|
|
s_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);
|
|
}
|
|
// OHLC candles for the selected range when a per-exchange series is active (empty -> line chart).
|
|
std::vector<data::Candle> ohlcCandles = data::chartCandles(market, s_mkt.chartInterval, nowSec);
|
|
// 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, &ohlcCandles, 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
|