feat(market): portfolio row-style redesign + per-wallet scope

Replace the drag/resize dashboard grid on the Market tab's portfolio with
three full-width row styles — compact (single-line), detailed (two-line),
and value-hero — cycled with Left/Right arrows like the Overview layouts.
Rows live in an internal scroll region bounded to the portfolio's height
budget, so the section stays put as groups are added.

- pfDrawRow renders each group in the selected style, reusing the icon /
  value / DRGX / 24h / sparkline pieces; per-entry field toggles honored.
- portfolio_style setting (0/1/2) persisted; arrow-key switcher + toast.
- Portfolio groups gain a per-wallet scope: new groups default to the
  active wallet's identity hash; legacy groups (empty scope) stay visible
  in every wallet. Scope filter drives both the height budget and the rows.
- Remove the now-dead grid machinery (mktDrawCard, pfComputeGridLayout,
  PfCell, PfGridDrag, MktCardCtx + MktCtx grid fields) — net code drop.
- Full UI sweep: seed demo portfolio groups + wavy price history and add
  market-rows-{compact,detailed,featured} surfaces so the styles are
  captured per skin (snapshot/restore the real portfolio + market data).
- i18n: portfolio_style_{label,compact,detailed,featured} + 8 langs; CJK
  subset rebuilt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 19:03:07 -05:00
parent a09adb14fb
commit 5db7941fbc
16 changed files with 294 additions and 331 deletions

Binary file not shown.

View File

@@ -839,6 +839,10 @@
"portfolio_spark_min": "Minute", "portfolio_spark_min": "Minute",
"portfolio_spark_month": "Monat", "portfolio_spark_month": "Monat",
"portfolio_spark_week": "Woche", "portfolio_spark_week": "Woche",
"portfolio_style_compact": "Kompakte Zeilen",
"portfolio_style_detailed": "Detaillierte Zeilen",
"portfolio_style_featured": "Hervorgehobene Zeilen",
"portfolio_style_label": "Portfolio-Stil",
"portfolio_untitled": "Ohne Titel", "portfolio_untitled": "Ohne Titel",
"price_chart": "Preisdiagramm", "price_chart": "Preisdiagramm",
"privacy_great": "Großartige Privatsphäre!", "privacy_great": "Großartige Privatsphäre!",
@@ -1341,6 +1345,7 @@
"wallets_new_hint": "Name (z. B. Ersparnisse)", "wallets_new_hint": "Name (z. B. Ersparnisse)",
"wallets_new_label": "Eine neue Wallet erstellen:", "wallets_new_label": "Eine neue Wallet erstellen:",
"wallets_open": "Öffnen", "wallets_open": "Öffnen",
"wallets_reveal": "Ordner anzeigen",
"wallets_title": "Wallets", "wallets_title": "Wallets",
"warning": "Warnung", "warning": "Warnung",
"warning_upper": "WARNUNG!", "warning_upper": "WARNUNG!",

View File

@@ -839,6 +839,10 @@
"portfolio_spark_min": "Minuto", "portfolio_spark_min": "Minuto",
"portfolio_spark_month": "Mes", "portfolio_spark_month": "Mes",
"portfolio_spark_week": "Semana", "portfolio_spark_week": "Semana",
"portfolio_style_compact": "Filas compactas",
"portfolio_style_detailed": "Filas detalladas",
"portfolio_style_featured": "Filas destacadas",
"portfolio_style_label": "Estilo de cartera",
"portfolio_untitled": "Sin título", "portfolio_untitled": "Sin título",
"price_chart": "Gráfico de Precios", "price_chart": "Gráfico de Precios",
"privacy_great": "¡Excelente privacidad!", "privacy_great": "¡Excelente privacidad!",
@@ -1341,6 +1345,7 @@
"wallets_new_hint": "Nombre (p. ej. ahorros)", "wallets_new_hint": "Nombre (p. ej. ahorros)",
"wallets_new_label": "Crear una nueva cartera:", "wallets_new_label": "Crear una nueva cartera:",
"wallets_open": "Abrir", "wallets_open": "Abrir",
"wallets_reveal": "Mostrar carpeta",
"wallets_title": "Carteras", "wallets_title": "Carteras",
"warning": "Advertencia", "warning": "Advertencia",
"warning_upper": "¡ADVERTENCIA!", "warning_upper": "¡ADVERTENCIA!",

View File

@@ -839,6 +839,10 @@
"portfolio_spark_min": "Minute", "portfolio_spark_min": "Minute",
"portfolio_spark_month": "Mois", "portfolio_spark_month": "Mois",
"portfolio_spark_week": "Semaine", "portfolio_spark_week": "Semaine",
"portfolio_style_compact": "Lignes compactes",
"portfolio_style_detailed": "Lignes détaillées",
"portfolio_style_featured": "Lignes en vedette",
"portfolio_style_label": "Style du portefeuille",
"portfolio_untitled": "Sans titre", "portfolio_untitled": "Sans titre",
"price_chart": "Graphique des prix", "price_chart": "Graphique des prix",
"privacy_great": "Excellente confidentialité !", "privacy_great": "Excellente confidentialité !",
@@ -1341,6 +1345,7 @@
"wallets_new_hint": "Nom (par ex. épargne)", "wallets_new_hint": "Nom (par ex. épargne)",
"wallets_new_label": "Créer un nouveau portefeuille :", "wallets_new_label": "Créer un nouveau portefeuille :",
"wallets_open": "Ouvrir", "wallets_open": "Ouvrir",
"wallets_reveal": "Afficher le dossier",
"wallets_title": "Portefeuilles", "wallets_title": "Portefeuilles",
"warning": "Attention", "warning": "Attention",
"warning_upper": "ATTENTION !", "warning_upper": "ATTENTION !",

View File

@@ -839,6 +839,10 @@
"portfolio_spark_min": "分", "portfolio_spark_min": "分",
"portfolio_spark_month": "月", "portfolio_spark_month": "月",
"portfolio_spark_week": "週", "portfolio_spark_week": "週",
"portfolio_style_compact": "コンパクト行",
"portfolio_style_detailed": "詳細行",
"portfolio_style_featured": "注目行",
"portfolio_style_label": "ポートフォリオスタイル",
"portfolio_untitled": "無題", "portfolio_untitled": "無題",
"price_chart": "価格チャート", "price_chart": "価格チャート",
"privacy_great": "優れたプライバシーです!", "privacy_great": "優れたプライバシーです!",
@@ -1341,6 +1345,7 @@
"wallets_new_hint": "名前savings", "wallets_new_hint": "名前savings",
"wallets_new_label": "新しいウォレットを作成:", "wallets_new_label": "新しいウォレットを作成:",
"wallets_open": "開く", "wallets_open": "開く",
"wallets_reveal": "フォルダを開く",
"wallets_title": "ウォレット", "wallets_title": "ウォレット",
"warning": "警告", "warning": "警告",
"warning_upper": "警告!", "warning_upper": "警告!",

View File

@@ -839,6 +839,10 @@
"portfolio_spark_min": "분", "portfolio_spark_min": "분",
"portfolio_spark_month": "월", "portfolio_spark_month": "월",
"portfolio_spark_week": "주", "portfolio_spark_week": "주",
"portfolio_style_compact": "간결한 행",
"portfolio_style_detailed": "상세 행",
"portfolio_style_featured": "강조 행",
"portfolio_style_label": "포트폴리오 스타일",
"portfolio_untitled": "제목 없음", "portfolio_untitled": "제목 없음",
"price_chart": "가격 차트", "price_chart": "가격 차트",
"privacy_great": "프라이버시가 우수합니다!", "privacy_great": "프라이버시가 우수합니다!",
@@ -1341,6 +1345,7 @@
"wallets_new_hint": "이름(예: savings)", "wallets_new_hint": "이름(예: savings)",
"wallets_new_label": "새 지갑 만들기:", "wallets_new_label": "새 지갑 만들기:",
"wallets_open": "열기", "wallets_open": "열기",
"wallets_reveal": "폴더 열기",
"wallets_title": "지갑", "wallets_title": "지갑",
"warning": "경고", "warning": "경고",
"warning_upper": "경고!", "warning_upper": "경고!",

View File

@@ -839,6 +839,10 @@
"portfolio_spark_min": "Minuto", "portfolio_spark_min": "Minuto",
"portfolio_spark_month": "Mês", "portfolio_spark_month": "Mês",
"portfolio_spark_week": "Semana", "portfolio_spark_week": "Semana",
"portfolio_style_compact": "Linhas compactas",
"portfolio_style_detailed": "Linhas detalhadas",
"portfolio_style_featured": "Linhas em destaque",
"portfolio_style_label": "Estilo do portfólio",
"portfolio_untitled": "Sem título", "portfolio_untitled": "Sem título",
"price_chart": "Gráfico de Preços", "price_chart": "Gráfico de Preços",
"privacy_great": "Ótima privacidade!", "privacy_great": "Ótima privacidade!",
@@ -1341,6 +1345,7 @@
"wallets_new_hint": "Nome (ex.: poupança)", "wallets_new_hint": "Nome (ex.: poupança)",
"wallets_new_label": "Criar uma nova carteira:", "wallets_new_label": "Criar uma nova carteira:",
"wallets_open": "Abrir", "wallets_open": "Abrir",
"wallets_reveal": "Mostrar pasta",
"wallets_title": "Carteiras", "wallets_title": "Carteiras",
"warning": "Aviso", "warning": "Aviso",
"warning_upper": "AVISO!", "warning_upper": "AVISO!",

View File

@@ -839,6 +839,10 @@
"portfolio_spark_min": "Минута", "portfolio_spark_min": "Минута",
"portfolio_spark_month": "Месяц", "portfolio_spark_month": "Месяц",
"portfolio_spark_week": "Неделя", "portfolio_spark_week": "Неделя",
"portfolio_style_compact": "Компактные строки",
"portfolio_style_detailed": "Подробные строки",
"portfolio_style_featured": "Избранные строки",
"portfolio_style_label": "Стиль портфеля",
"portfolio_untitled": "Без названия", "portfolio_untitled": "Без названия",
"price_chart": "График цен", "price_chart": "График цен",
"privacy_great": "Отличная конфиденциальность!", "privacy_great": "Отличная конфиденциальность!",
@@ -1341,6 +1345,7 @@
"wallets_new_hint": "Имя (например, savings)", "wallets_new_hint": "Имя (например, savings)",
"wallets_new_label": "Создать новый кошелёк:", "wallets_new_label": "Создать новый кошелёк:",
"wallets_open": "Открыть", "wallets_open": "Открыть",
"wallets_reveal": "Открыть папку",
"wallets_title": "Кошельки", "wallets_title": "Кошельки",
"warning": "Предупреждение", "warning": "Предупреждение",
"warning_upper": "ПРЕДУПРЕЖДЕНИЕ!", "warning_upper": "ПРЕДУПРЕЖДЕНИЕ!",

View File

@@ -839,6 +839,10 @@
"portfolio_spark_min": "分钟", "portfolio_spark_min": "分钟",
"portfolio_spark_month": "月", "portfolio_spark_month": "月",
"portfolio_spark_week": "周", "portfolio_spark_week": "周",
"portfolio_style_compact": "紧凑行",
"portfolio_style_detailed": "详细行",
"portfolio_style_featured": "特色行",
"portfolio_style_label": "投资组合样式",
"portfolio_untitled": "未命名", "portfolio_untitled": "未命名",
"price_chart": "价格图表", "price_chart": "价格图表",
"privacy_great": "隐私性极佳!", "privacy_great": "隐私性极佳!",
@@ -1341,6 +1345,7 @@
"wallets_new_hint": "名称(例如 savings", "wallets_new_hint": "名称(例如 savings",
"wallets_new_label": "创建新钱包:", "wallets_new_label": "创建新钱包:",
"wallets_open": "打开", "wallets_open": "打开",
"wallets_reveal": "打开文件夹",
"wallets_title": "钱包", "wallets_title": "钱包",
"warning": "警告", "warning": "警告",
"warning_upper": "警告!", "warning_upper": "警告!",

View File

@@ -833,6 +833,7 @@ private:
std::vector<AddressInfo> addresses, z_addresses, t_addresses; std::vector<AddressInfo> addresses, z_addresses, t_addresses;
std::vector<TransactionInfo> transactions; std::vector<TransactionInfo> transactions;
double market_price_usd=0; double market_price_usd=0;
double market_change_24h=0;
} sweep_state_snapshot_; } sweep_state_snapshot_;
bool sidebar_user_toggled_ = false; // user manually toggled — suppress auto-collapse bool sidebar_user_toggled_ = false; // user manually toggled — suppress auto-collapse
float sidebar_width_anim_ = 0.0f; // animated width (0 = uninitialized) float sidebar_width_anim_ = 0.0f; // animated width (0 = uninitialized)

View File

@@ -13,6 +13,7 @@
#include "app.h" #include "app.h"
#include "config/settings.h"
#include "ui/schema/skin_manager.h" #include "ui/schema/skin_manager.h"
#include "ui/notifications.h" #include "ui/notifications.h"
#include "ui/sidebar.h" #include "ui/sidebar.h"
@@ -23,6 +24,7 @@
#include <sodium.h> #include <sodium.h>
#include <cmath>
#include <filesystem> #include <filesystem>
#include <fstream> #include <fstream>
@@ -31,6 +33,13 @@ namespace dragonx {
namespace { namespace {
namespace fs = std::filesystem; namespace fs = std::filesystem;
// The real portfolio, snapshotted while demo groups are shown during a full sweep. Settings are
// forward-declared in app.h, so this can't live in the app.h SweepStateSnapshot struct.
std::vector<config::Settings::PortfolioEntry> g_pfSnapshot;
int g_pfStyleSnapshot = 0;
bool g_pfSnapshotValid = false;
std::vector<double> g_marketHistorySnapshot; // real price history, restored at sweep end
// Filesystem-safe one-word tab name. // Filesystem-safe one-word tab name.
const char* sweepPageName(ui::NavPage page) const char* sweepPageName(ui::NavPage page)
{ {
@@ -93,6 +102,7 @@ void App::installDemoWalletData()
s.addresses = state_.addresses; s.z_addresses = state_.z_addresses; s.t_addresses = state_.t_addresses; s.addresses = state_.addresses; s.z_addresses = state_.z_addresses; s.t_addresses = state_.t_addresses;
s.transactions = state_.transactions; s.transactions = state_.transactions;
s.market_price_usd = state_.market.price_usd; s.market_price_usd = state_.market.price_usd;
s.market_change_24h = state_.market.change_24h;
s.valid = true; s.valid = true;
applyHealthyDemoState(); applyHealthyDemoState();
@@ -102,6 +112,18 @@ void App::installDemoWalletData()
state_.privateBalance = 12.50000000; state_.transparentBalance = 3.25000000; state_.privateBalance = 12.50000000; state_.transparentBalance = 3.25000000;
state_.totalBalance = 15.75000000; state_.unconfirmedBalance = 0.50000000; state_.totalBalance = 15.75000000; state_.unconfirmedBalance = 0.50000000;
state_.market.price_usd = 0.01336200; state_.market.price_usd = 0.01336200;
state_.market.change_24h = 5.24000000;
// Wavy, gently-rising price history so the row sparklines render (restored at sweep end).
g_marketHistorySnapshot = state_.market.price_history;
{
std::vector<double> hist;
for (int i = 0; i < 48; i++) {
double t = static_cast<double>(i);
hist.push_back(0.01250 + 0.0000180 * t
+ 0.00060 * std::sin(t * 0.55) + 0.00025 * std::sin(t * 1.7));
}
state_.market.price_history = hist;
}
auto zaddr = [](const char* a, double bal, const char* label) { auto zaddr = [](const char* a, double bal, const char* label) {
AddressInfo i; i.address = a; i.balance = bal; i.type = "shielded"; i.label = label; return i; AddressInfo i; i.address = a; i.balance = bal; i.type = "shielded"; i.label = label; return i;
@@ -135,6 +157,32 @@ void App::installDemoWalletData()
1751290000, 0, "zs1demosecondaryshieldedaddressforuisweep0000000000000000000000000", "pending"), 1751290000, 0, "zs1demosecondaryshieldedaddressforuisweep0000000000000000000000000", "pending"),
}; };
// Demo portfolio groups so the Market tab's row styles render with real-looking data. Snapshot
// the user's real portfolio first; setPortfolio* only mutate memory (save() is never called
// here), and clearDemoWalletData() restores them at sweep end.
if (settings_) {
g_pfSnapshot = settings_->getPortfolioEntries();
g_pfStyleSnapshot = settings_->getPortfolioStyle();
g_pfSnapshotValid = true;
auto grp = [](const char* label, const char* icon, uint32_t color, const char* addr,
bool drgx, bool value, bool ch, bool spark) {
config::Settings::PortfolioEntry e;
e.label = label; e.icon = icon; e.color = color; e.outlineOpacity = 30;
e.addresses = { addr }; e.priceBasis = 0;
e.showDrgx = drgx; e.showValue = value; e.show24h = ch; e.showSparkline = spark;
return e;
};
settings_->setPortfolioEntries({
grp("Savings", "savings", 0xFFFF9D4Fu,
"zs1demoprimaryshieldedaddressforuisweep000000000000000000000000000", true, true, true, true),
grp("Mining rewards", "pickaxe", 0xFF3DB0FFu,
"t1DemoTransparentAddressForUiSweep00000", true, true, true, true),
grp("Cold storage", "diamond", 0xFFFF7BB0u,
"zs1demosecondaryshieldedaddressforuisweep0000000000000000000000000", true, true, false, false),
});
settings_->setPortfolioStyle(0);
}
seedChatDemoData(); seedChatDemoData();
} }
@@ -168,7 +216,18 @@ void App::clearDemoWalletData()
state_.addresses = s.addresses; state_.z_addresses = s.z_addresses; state_.t_addresses = s.t_addresses; state_.addresses = s.addresses; state_.z_addresses = s.z_addresses; state_.t_addresses = s.t_addresses;
state_.transactions = s.transactions; state_.transactions = s.transactions;
state_.market.price_usd = s.market_price_usd; state_.market.price_usd = s.market_price_usd;
state_.market.change_24h = s.market_change_24h;
state_.market.price_history = g_marketHistorySnapshot;
g_marketHistorySnapshot.clear();
s = SweepStateSnapshot{}; // invalidate s = SweepStateSnapshot{}; // invalidate
// Restore the user's real portfolio (demo groups were in-memory only).
if (g_pfSnapshotValid && settings_) {
settings_->setPortfolioEntries(g_pfSnapshot);
settings_->setPortfolioStyle(g_pfStyleSnapshot);
}
g_pfSnapshot.clear();
g_pfSnapshotValid = false;
} }
// ── Catalog ────────────────────────────────────────────────────────────────────────────────── // ── Catalog ──────────────────────────────────────────────────────────────────────────────────
@@ -326,6 +385,17 @@ void App::buildSweepCatalog()
add("popup-send-confirm", ui::NavPage::Send, add("popup-send-confirm", ui::NavPage::Send,
[](App& a) { ui::SweepShowSendConfirm(&a, true); }, [](App& a) { ui::SweepShowSendConfirm(&a, true); },
[](App& a) { ui::SweepShowSendConfirm(&a, false); }); [](App& a) { ui::SweepShowSendConfirm(&a, false); });
// Market portfolio row styles — capture all three so the redesign is reviewable per skin.
add("market-rows-compact", ui::NavPage::Market,
[](App& a) { if (a.settings_) a.settings_->setPortfolioStyle(0); },
[](App& a) { if (a.settings_) a.settings_->setPortfolioStyle(0); });
add("market-rows-detailed", ui::NavPage::Market,
[](App& a) { if (a.settings_) a.settings_->setPortfolioStyle(1); },
[](App& a) { if (a.settings_) a.settings_->setPortfolioStyle(0); });
add("market-rows-featured", ui::NavPage::Market,
[](App& a) { if (a.settings_) a.settings_->setPortfolioStyle(2); },
[](App& a) { if (a.settings_) a.settings_->setPortfolioStyle(0); });
} }
// ── State machine ─────────────────────────────────────────────────────────────────────────── // ── State machine ───────────────────────────────────────────────────────────────────────────

View File

@@ -172,6 +172,8 @@ bool Settings::load(const std::string& path)
if (idx >= 0 && idx < 9) balance_layout_ = legacyIds[idx]; if (idx >= 0 && idx < 9) balance_layout_ = legacyIds[idx];
} }
} }
loadScalar(j, "portfolio_style", portfolio_style_);
if (portfolio_style_ < 0 || portfolio_style_ > 2) portfolio_style_ = 0;
loadScalar(j, "scanline_enabled", scanline_enabled_); loadScalar(j, "scanline_enabled", scanline_enabled_);
if (j.contains("hidden_addresses") && j["hidden_addresses"].is_array()) { if (j.contains("hidden_addresses") && j["hidden_addresses"].is_array()) {
hidden_addresses_.clear(); hidden_addresses_.clear();
@@ -344,6 +346,7 @@ bool Settings::load(const std::string& path)
entry.showSparkline = e["show_sparkline"].get<bool>(); entry.showSparkline = e["show_sparkline"].get<bool>();
if (e.contains("sparkline_interval") && e["sparkline_interval"].is_number_integer()) if (e.contains("sparkline_interval") && e["sparkline_interval"].is_number_integer())
entry.sparklineInterval = e["sparkline_interval"].get<int>(); entry.sparklineInterval = e["sparkline_interval"].get<int>();
entry.scope = e.value("scope", "");
if (e.contains("grid_col") && e["grid_col"].is_number_integer()) if (e.contains("grid_col") && e["grid_col"].is_number_integer())
entry.gridCol = e["grid_col"].get<int>(); entry.gridCol = e["grid_col"].get<int>();
if (e.contains("grid_row") && e["grid_row"].is_number_integer()) if (e.contains("grid_row") && e["grid_row"].is_number_integer())
@@ -418,6 +421,7 @@ bool Settings::save(const std::string& path)
j["ui_opacity"] = ui_opacity_; j["ui_opacity"] = ui_opacity_;
j["window_opacity"] = window_opacity_; j["window_opacity"] = window_opacity_;
j["balance_layout"] = balance_layout_; // saved as string ID j["balance_layout"] = balance_layout_; // saved as string ID
j["portfolio_style"] = portfolio_style_;
j["scanline_enabled"] = scanline_enabled_; j["scanline_enabled"] = scanline_enabled_;
j["hidden_addresses"] = json::array(); j["hidden_addresses"] = json::array();
for (const auto& addr : hidden_addresses_) for (const auto& addr : hidden_addresses_)
@@ -520,6 +524,7 @@ bool Settings::save(const std::string& path)
entry["show_24h"] = e.show24h; entry["show_24h"] = e.show24h;
entry["show_sparkline"] = e.showSparkline; entry["show_sparkline"] = e.showSparkline;
entry["sparkline_interval"] = e.sparklineInterval; entry["sparkline_interval"] = e.sparklineInterval;
entry["scope"] = e.scope;
entry["grid_col"] = e.gridCol; entry["grid_col"] = e.gridCol;
entry["grid_row"] = e.gridRow; entry["grid_row"] = e.gridRow;
entry["grid_w"] = e.gridW; entry["grid_w"] = e.gridW;

View File

@@ -92,12 +92,16 @@ public:
bool show24h = false; // show the 24h % change (live-market bases only) bool show24h = false; // show the 24h % change (live-market bases only)
bool showSparkline = false; // show a price-trend sparkline (live-market bases only) bool showSparkline = false; // show a price-trend sparkline (live-market bases only)
int sparklineInterval = 0; // 0=minute 1=hour 2=day 3=week 4=month (resample of price history) int sparklineInterval = 0; // 0=minute 1=hour 2=day 3=week 4=month (resample of price history)
// Dashboard grid placement on the Market portfolio. col/row in fine ~32px square cells // Per-wallet visibility: "" (shown in every wallet — legacy/global) or a wallet-identity
// (-1 = auto-place), w/h in cells (span). Set when the user drags/resizes a card. // hash (shown only when that wallet is active). New entries are tagged with the current
// wallet so a portfolio built for wallet A doesn't clutter wallet B.
std::string scope;
// Legacy dashboard-grid placement (deprecated by the row layout; kept for back-compat so an
// older build's saved positions aren't dropped, but no longer used by the renderer).
int gridCol = -1; int gridCol = -1;
int gridRow = -1; int gridRow = -1;
int gridW = 8; // default group width (cells); minimum is 8 int gridW = 8;
int gridH = 3; // default group height (cells); minimum is 2, card design adapts int gridH = 3;
}; };
// Theme // Theme
@@ -176,6 +180,11 @@ public:
std::string getBalanceLayout() const { return balance_layout_; } std::string getBalanceLayout() const { return balance_layout_; }
void setBalanceLayout(const std::string& v) { balance_layout_ = v; } void setBalanceLayout(const std::string& v) { balance_layout_ = v; }
// Market-tab portfolio row style: 0 = single-line, 1 = two-line, 2 = value-hero. Cycled with
// Left/Right arrows on the Market tab (like the Overview layouts).
int getPortfolioStyle() const { return portfolio_style_; }
void setPortfolioStyle(int v) { portfolio_style_ = (v < 0 || v > 2) ? 0 : v; }
// Console scanline effect // Console scanline effect
bool getScanlineEnabled() const { return scanline_enabled_; } bool getScanlineEnabled() const { return scanline_enabled_; }
void setScanlineEnabled(bool v) { scanline_enabled_ = v; } void setScanlineEnabled(bool v) { scanline_enabled_ = v; }
@@ -467,6 +476,7 @@ private:
float window_opacity_ = 1.0f; // Mac/Linux: default fully opaque float window_opacity_ = 1.0f; // Mac/Linux: default fully opaque
#endif #endif
std::string balance_layout_ = "classic"; std::string balance_layout_ = "classic";
int portfolio_style_ = 0; // Market portfolio row style (0 single / 1 two-line / 2 hero)
bool scanline_enabled_ = true; bool scanline_enabled_ = true;
std::set<std::string> hidden_addresses_; std::set<std::string> hidden_addresses_;
std::set<std::string> favorite_addresses_; std::set<std::string> favorite_addresses_;

View File

@@ -18,6 +18,7 @@
#include "../material/colors.h" #include "../material/colors.h"
#include "../material/typography.h" #include "../material/typography.h"
#include "../material/project_icons.h" #include "../material/project_icons.h"
#include "../notifications.h"
#include "../effects/imgui_acrylic.h" #include "../effects/imgui_acrylic.h"
#include "../effects/scroll_fade_shader.h" #include "../effects/scroll_fade_shader.h"
#include "../effects/low_spec.h" #include "../effects/low_spec.h"
@@ -175,18 +176,6 @@ static void pfDrawSparklineFilled(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
dl->AddPolyline(pts.data(), n, WithAlpha(lineCol, 220), ImDrawFlags_None, 1.4f); dl->AddPolyline(pts.data(), n, WithAlpha(lineCol, 220), ImDrawFlags_None, 1.4f);
} }
// Dashboard-grid state for the portfolio cards (drag to move, corner-drag to resize).
struct PfCell { int col, row, w, h; };
struct PfGridDrag {
int drag = -1; // card index being dragged (-1 = none)
int resize = -1; // card index being resized
bool moved = false; // drag passed the click threshold this gesture
ImVec2 press; // mouse pos at grab
ImVec2 grab; // mouse offset within the card at grab
PfCell old = {0,0,1,1}; // dragged card's cell at grab (for swap-on-drop)
PfCell target = {0,0,1,1};// live drop target cell
};
static PfGridDrag s_grid;
// Build the right-hand amount strings for a group card / preview from its price-data config. // Build the right-hand amount strings for a group card / preview from its price-data config.
struct PfDisplay { struct PfDisplay {
@@ -326,14 +315,18 @@ static void pfPersist(config::Settings* settings, const std::vector<config::Sett
} }
// Persist the working group when it is named and actually changed; unnamed drafts are dropped. // Persist the working group when it is named and actually changed; unnamed drafts are dropped.
static void pfCommitIfNeeded(config::Settings* settings) // New groups default to the active wallet's scope (per-wallet visibility); existing groups keep
// whatever scope they already had.
static void pfCommitIfNeeded(App* app)
{ {
config::Settings* settings = app->settings();
if (s_pfEdit.label[0] == '\0') return; if (s_pfEdit.label[0] == '\0') return;
auto entries = settings->getPortfolioEntries(); auto entries = settings->getPortfolioEntries();
bool existing = (s_pfEdit.sel >= 0 && s_pfEdit.sel < (int)entries.size()); bool existing = (s_pfEdit.sel >= 0 && s_pfEdit.sel < (int)entries.size());
config::Settings::PortfolioEntry base; config::Settings::PortfolioEntry base;
if (existing) { base = entries[s_pfEdit.sel]; if (pfWorkingMatches(base)) return; } if (existing) { base = entries[s_pfEdit.sel]; if (pfWorkingMatches(base)) return; }
auto e = pfBuildWorking(base); auto e = pfBuildWorking(base);
if (!existing) e.scope = app->activeWalletIdentityHash();
if (existing) entries[s_pfEdit.sel] = e; if (existing) entries[s_pfEdit.sel] = e;
else { entries.push_back(e); s_pfEdit.sel = (int)entries.size() - 1; } else { entries.push_back(e); s_pfEdit.sel = (int)entries.size() - 1; }
pfPersist(settings, entries); pfPersist(settings, entries);
@@ -830,6 +823,7 @@ static void RenderPortfolioEditor(App* app)
auto es = settings->getPortfolioEntries(); auto es = settings->getPortfolioEntries();
config::Settings::PortfolioEntry ne; config::Settings::PortfolioEntry ne;
ne.label = TR("portfolio_untitled"); ne.label = TR("portfolio_untitled");
ne.scope = app->activeWalletIdentityHash(); // new group defaults to this wallet
es.push_back(ne); es.push_back(ne);
pfPersist(settings, es); pfPersist(settings, es);
s_pfEdit.sel = (int)es.size() - 1; s_pfEdit.sel = (int)es.size() - 1;
@@ -956,7 +950,7 @@ static void RenderPortfolioEditor(App* app)
PortfolioBeginEdit(app, s_pfEdit.sel); // reload the working set from the stored entry PortfolioBeginEdit(app, s_pfEdit.sel); // reload the working set from the stored entry
ImGui::SameLine(0, sp); ImGui::SameLine(0, sp);
ImGui::BeginDisabled(s_pfEdit.label[0] == '\0'); ImGui::BeginDisabled(s_pfEdit.label[0] == '\0');
if (material::TactileButton(TR("portfolio_save"), ImVec2(bw, addH))) pfCommitIfNeeded(settings); if (material::TactileButton(TR("portfolio_save"), ImVec2(bw, addH))) pfCommitIfNeeded(app);
ImGui::EndDisabled(); ImGui::EndDisabled();
ImGui::EndDisabled(); ImGui::EndDisabled();
} }
@@ -967,7 +961,7 @@ static void RenderPortfolioEditor(App* app)
{ {
float bh = 40.0f * dp, bw = 120.0f * dp; float bh = 40.0f * dp, bw = 120.0f * dp;
material::RightAlignX(bw); material::RightAlignX(bw);
if (material::TactileButton(TR("portfolio_close"), ImVec2(bw, bh))) { pfCommitIfNeeded(settings); s_pfEdit.open = false; } if (material::TactileButton(TR("portfolio_close"), ImVec2(bw, bh))) { pfCommitIfNeeded(app); s_pfEdit.open = false; }
} }
material::EndOverlayDialog(); material::EndOverlayDialog();
@@ -975,157 +969,95 @@ static void RenderPortfolioEditor(App* app)
bool PortfolioEditorActive() { return s_pfEdit.open; } bool PortfolioEditorActive() { return s_pfEdit.open; }
// Resolve each portfolio group's grid cell: honor a stored placement when it fits, else auto-place
// row-major into the first free cells. Pure + deterministic (no ImGui). One PfCell per entry.
static std::vector<PfCell> pfComputeGridLayout(
const std::vector<config::Settings::PortfolioEntry>& entries, int cols, int minW, int minH)
{
int n = (int)entries.size();
std::vector<PfCell> layout((size_t)std::max(0, n));
std::vector<std::vector<char>> occ;
auto ensure = [&](int rEnd) { while ((int)occ.size() < rEnd) occ.emplace_back(cols, 0); };
auto fits = [&](int c, int r, int w, int h) -> bool {
if (c < 0 || r < 0 || c + w > cols) return false;
ensure(r + h);
for (int rr = r; rr < r + h; rr++) for (int cc = c; cc < c + w; cc++) if (occ[rr][cc]) return false;
return true;
};
auto mark = [&](int c, int r, int w, int h) { ensure(r + h);
for (int rr = r; rr < r + h; rr++) for (int cc = c; cc < c + w; cc++) occ[rr][cc] = 1; };
std::vector<char> placed((size_t)std::max(0, n), 0);
for (int i = 0; i < n; i++) {
const auto& e = entries[i];
int w = std::max(minW, std::min(e.gridW, cols)), h = std::max(minH, e.gridH);
if (e.gridCol >= 0 && e.gridRow >= 0 && fits(e.gridCol, e.gridRow, w, h)) {
layout[i] = {e.gridCol, e.gridRow, w, h}; mark(e.gridCol, e.gridRow, w, h); placed[i] = 1;
}
}
for (int i = 0; i < n; i++) {
if (placed[i]) continue;
const auto& e = entries[i];
int w = std::max(minW, std::min(e.gridW, cols)), h = std::max(minH, e.gridH);
int fc = 0, fr = 0; bool found = false;
for (int r = 0; !found && r < 4096; r++)
for (int c = 0; c + w <= cols; c++)
if (fits(c, r, w, h)) { fc = c; fr = r; found = true; break; }
layout[i] = {fc, fr, w, h}; mark(fc, fr, w, h);
}
return layout;
}
// Shared drawing context for a portfolio group card (bound once per frame in RenderMarketTab). // ---- Portfolio ROW layouts (replace the drag/resize grid) --------------------------------------
struct MktCardCtx { // Draw one group as a full-width row card in [rowMin,rowMax]; style 0=single-line, 1=two-line,
ImDrawList* dl; // 2=value-hero. Keeps every field (icon, accent, label, DRGX, value, 24h %, sparkline) — hidden
const WalletState* state; // fields per the entry's config simply don't render. Pure drawing into dl.
const MarketInfo* market; static void pfDrawRow(ImDrawList* dl, ImVec2 rowMin, ImVec2 rowMax,
float mktDp; const config::Settings::PortfolioEntry& e, const WalletState& state,
float pfInset; const MarketInfo& market, int style, float dp,
float pfCardPad; ImFont* sub1, ImFont* body2, ImFont* capFont, bool hov)
ImFont* sub1;
ImFont* capFont;
};
// Draw one portfolio group card (name/icon, value + %chg, secondary DRGX, sparkline body) within
// [gcMin,gcMax] (inset for gaps). hov = hover/active highlight. Pure drawing into cx.dl.
static void mktDrawCard(const MktCardCtx& cx, ImVec2 gcMin, ImVec2 gcMax,
const config::Settings::PortfolioEntry& e, bool hov)
{ {
ImDrawList* dl = cx.dl;
const auto& state = *cx.state;
const auto& market = *cx.market;
float mktDp = cx.mktDp, pfInset = cx.pfInset;
ImFont* sub1 = cx.sub1, *capFont = cx.capFont;
// The grid is gapless, so inset the card within its cell block for separation.
gcMin.x += pfInset; gcMin.y += pfInset; gcMax.x -= pfInset; gcMax.y -= pfInset;
double bal = data::SumPortfolioBalance(e.addresses, state.addresses); double bal = data::SumPortfolioBalance(e.addresses, state.addresses);
ImU32 accent = e.color ? (ImU32)e.color : 0; ImU32 accent = e.color ? (ImU32)e.color : 0;
float pad = cx.pfCardPad; float stripW = (style == 2 ? 5.0f : 3.0f) * dp;
GlassPanelSpec gcGlass; gcGlass.rounding = 10.0f;
gcGlass.fillAlpha = hov ? 34 : 22; gcGlass.borderAlpha = 40; GlassPanelSpec g; g.rounding = 10.0f; g.fillAlpha = hov ? 34 : 22; g.borderAlpha = 40;
DrawGlassPanel(dl, gcMin, gcMax, gcGlass); DrawGlassPanel(dl, rowMin, rowMax, g);
// Accented groups get a colored outline around the whole card (configurable opacity);
// others get a subtle hover ring.
if (accent) { if (accent) {
dl->AddRectFilled(rowMin, ImVec2(rowMin.x + stripW, rowMax.y), accent, 10.0f, ImDrawFlags_RoundCornersLeft);
int oa = (int)(std::max(0, std::min(100, e.outlineOpacity)) * 2.55f + 0.5f); int oa = (int)(std::max(0, std::min(100, e.outlineOpacity)) * 2.55f + 0.5f);
dl->AddRect(gcMin, gcMax, WithAlpha(accent, oa), 10.0f, 0, hov ? 2.5f : 2.0f); dl->AddRect(rowMin, rowMax, WithAlpha(accent, oa), 10.0f, 0, hov ? 2.0f : 1.5f);
} else if (hov) dl->AddRect(gcMin, gcMax, WithAlpha(OnSurface(), 70), 10.0f, 0, 1.0f); } else if (hov) {
dl->AddRect(rowMin, rowMax, WithAlpha(OnSurface(), 70), 10.0f, 0, 1.0f);
}
auto fit = [&](std::string s, ImFont* f, float maxW) { auto tw = [](ImFont* f, const std::string& s){ return f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, s.c_str()).x; };
bool t = false; 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; };
while (s.size() > 1 && f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, s.c_str()).x > maxW) { s.pop_back(); t = true; }
if (t) s += "\xE2\x80\xA6";
return s;
};
// Value + DRGX strings per basis. char vb[80]; std::string valStr; bool hasValue=false;
char vb[80]; std::string valueStr; bool hasValue = false; if (e.priceBasis==0 && market.price_usd>0){ snprintf(vb,sizeof(vb),"$%.2f",bal*market.price_usd); valStr=vb; hasValue=true; }
if (e.priceBasis == 0 && market.price_usd > 0) { snprintf(vb, sizeof(vb), "$%.2f", bal * market.price_usd); valueStr = vb; hasValue = true; } else if (e.priceBasis==1 && market.price_btc>0){ snprintf(vb,sizeof(vb),"%.8f BTC",bal*market.price_btc); valStr=vb; hasValue=true; }
else if (e.priceBasis == 1 && market.price_btc > 0) { snprintf(vb, sizeof(vb), "%.8f BTC", bal * market.price_btc); valueStr = vb; hasValue = true; } else if (e.priceBasis==3 && e.manualPrice>0.0){ snprintf(vb,sizeof(vb),"%.2f %s",bal*e.manualPrice,e.manualCurrency.c_str()); 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()); valueStr = vb; hasValue = true; }
char db[48]; snprintf(db,sizeof(db),"%.4f %s",bal,DRAGONX_TICKER); char db[48]; snprintf(db,sizeof(db),"%.4f %s",bal,DRAGONX_TICKER);
bool heroIsValue = e.showValue && hasValue; std::string drgxStr = e.showDrgx ? std::string(db) : std::string();
std::string heroStr = heroIsValue ? valueStr : (e.showDrgx ? std::string(db) : (hasValue ? valueStr : std::string(db))); std::string valueStr = (e.showValue && hasValue) ? valStr : std::string();
std::string footStr = (heroIsValue && e.showDrgx) ? std::string(db) : std::string();
// Compact stat card: name (+ secondary DRGX) on the left; value with % change stacked
// below it in the top-right corner; the sparkline fills the body — so it renders even
// on a 2-cell card. Tighter padding on short cards.
float cardHeight = gcMax.y - gcMin.y;
bool cardShort = cardHeight < 90.0f * mktDp;
float hpad = cardShort ? Layout::spacingSm() : pad;
float hleft = gcMin.x + hpad, hright = gcMax.x - hpad;
float hAvailW = hright - hleft;
float topY = gcMin.y + hpad;
// Right column: value (top-right), % change just below it.
float rightBottom = topY;
float valLeft = hright;
bool showChange = e.show24h && (e.priceBasis==0||e.priceBasis==1) && market.change_24h!=0.0; bool showChange = e.show24h && (e.priceBasis==0||e.priceBasis==1) && market.change_24h!=0.0;
if (!heroStr.empty()) { std::string chgStr; if (showChange){ char cb[32]; snprintf(cb,sizeof(cb),"%+.2f%%",market.change_24h); chgStr=cb; }
ImFont* vf = sub1; ImU32 chgCol = market.change_24h>=0 ? Success() : Error();
std::string vs = fit(heroStr, vf, hAvailW * 0.66f); bool showSpark = e.showSparkline && (e.priceBasis==0||e.priceBasis==1);
float vw = vf->CalcTextSizeA(vf->LegacySize, FLT_MAX, 0, vs.c_str()).x; std::vector<double> spark;
valLeft = hright - vw; if (showSpark){ spark=data::sparklineSeries(market,e.sparklineInterval); if (spark.size()<2) showSpark=false; }
dl->AddText(vf, vf->LegacySize, ImVec2(valLeft, topY), OnSurface(), vs.c_str()); ImU32 sparkCol = (!spark.empty() && spark.back()>=spark.front()) ? Success() : Error();
rightBottom = topY + vf->LegacySize;
if (showChange) {
char cb[32]; snprintf(cb, sizeof(cb), "%+.2f%%", market.change_24h);
float cw = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, cb).x;
dl->AddText(capFont, capFont->LegacySize, ImVec2(hright - cw, rightBottom + 1.0f * mktDp),
market.change_24h >= 0 ? Success() : Error(), cb);
valLeft = std::min(valLeft, hright - cw);
rightBottom += capFont->LegacySize + 1.0f * mktDp;
}
}
// Left column: icon + name (larger, top), secondary DRGX amount below (always shown). const float padX = Layout::spacingMd(), padY = Layout::spacingSm(), sp = Layout::spacingMd();
float nameX = hleft; float left = rowMin.x + stripW + padX;
float right = rowMax.x - padX;
float midY = (rowMin.y + rowMax.y) * 0.5f;
float iconSz = sub1->LegacySize;
if (style == 0) {
// Single line — icon + label (left); drgx / value / 24h / sparkline right-aligned.
float y = midY - sub1->LegacySize * 0.5f, x = left, rx = right;
if (!e.icon.empty()) { if (!e.icon.empty()) {
material::project_icons::drawByName(dl, e.icon.c_str(), material::project_icons::drawByName(dl, e.icon.c_str(), ImVec2(x+iconSz*0.5f, midY), accent?accent:OnSurfaceMedium(), Type().iconSmall(), iconSz);
ImVec2(nameX + sub1->LegacySize * 0.5f, topY + sub1->LegacySize * 0.5f), x += iconSz + Layout::spacingSm();
accent ? accent : OnSurfaceMedium(), Type().iconSmall(), sub1->LegacySize);
nameX += sub1->LegacySize + Layout::spacingSm();
} }
float nameMaxW = std::max(24.0f * mktDp, valLeft - Layout::spacingSm() - nameX); if (showSpark){ float sw=110*dp; pfDrawSparkline(dl, ImVec2(rx-sw, rowMin.y+padY), ImVec2(rx, rowMax.y-padY), spark, sparkCol); rx -= sw + sp; }
dl->AddText(sub1, sub1->LegacySize, ImVec2(nameX, topY), OnSurfaceMedium(), if (!chgStr.empty()){ float w=tw(capFont,chgStr); dl->AddText(capFont,capFont->LegacySize,ImVec2(rx-w, midY-capFont->LegacySize*0.5f), chgCol, chgStr.c_str()); rx -= w + sp; }
fit(e.label, sub1, nameMaxW).c_str()); if (!valueStr.empty()){ float w=tw(sub1,valueStr); dl->AddText(sub1,sub1->LegacySize,ImVec2(rx-w,y),OnSurface(),valueStr.c_str()); rx -= w + sp; }
float leftBottom = topY + sub1->LegacySize; if (!drgxStr.empty()){ float w=tw(capFont,drgxStr); dl->AddText(capFont,capFont->LegacySize,ImVec2(rx-w, midY-capFont->LegacySize*0.5f),OnSurfaceMedium(),drgxStr.c_str()); rx -= w + sp; }
if (!footStr.empty()) { float lblMaxW = std::max(24.0f*dp, rx - x - Layout::spacingSm());
dl->AddText(capFont, capFont->LegacySize, ImVec2(nameX, leftBottom + 1.0f * mktDp), dl->AddText(sub1,sub1->LegacySize,ImVec2(x,y),OnSurface(),fit(e.label,sub1,lblMaxW).c_str());
OnSurfaceDisabled(), fit(footStr, capFont, nameMaxW).c_str()); } else if (style == 1) {
leftBottom += capFont->LegacySize + 1.0f * mktDp; // Two lines — label + (24h, sparkline) on top; DRGX + value below.
float topY = rowMin.y + padY, botY = rowMax.y - padY - capFont->LegacySize, x = left, rx = right;
if (!e.icon.empty()) {
material::project_icons::drawByName(dl, e.icon.c_str(), ImVec2(x+iconSz*0.5f, topY+iconSz*0.5f), accent?accent:OnSurfaceMedium(), Type().iconSmall(), iconSz);
x += iconSz + Layout::spacingSm();
} }
if (showSpark){ float sw=130*dp; pfDrawSparkline(dl, ImVec2(rx-sw, topY), ImVec2(rx, midY), spark, sparkCol); rx -= sw + sp; }
// Body: sparkline fills from below the header to the card bottom. if (!chgStr.empty()){ float w=tw(capFont,chgStr); dl->AddText(capFont,capFont->LegacySize,ImVec2(rx-w,topY),chgCol,chgStr.c_str()); rx -= w + sp; }
if (e.showSparkline && (e.priceBasis == 0 || e.priceBasis == 1)) { dl->AddText(sub1,sub1->LegacySize,ImVec2(x,topY),OnSurface(),fit(e.label,sub1,std::max(24.0f*dp, rx-x)).c_str());
std::vector<double> hh = data::sparklineSeries(market, e.sparklineInterval); if (!drgxStr.empty()) dl->AddText(capFont,capFont->LegacySize,ImVec2(x,botY),OnSurfaceMedium(),drgxStr.c_str());
if (hh.size() >= 2) { if (!valueStr.empty()){ float w=tw(body2,valueStr); dl->AddText(body2,body2->LegacySize,ImVec2(right-w, botY-(body2->LegacySize-capFont->LegacySize)),OnSurface(),valueStr.c_str()); }
float sparkTop = std::max(leftBottom, rightBottom) + Layout::spacingXs(); } else {
float sparkBot = gcMax.y - hpad; // Value-hero — label; big value; DRGX · 24h; sparkline fills the right.
if (sparkBot - sparkTop >= 8.0f * mktDp) float topY = rowMin.y + padY, x = left;
pfDrawSparklineFilled(dl, ImVec2(hleft, sparkTop), ImVec2(hright, sparkBot), if (!e.icon.empty()) {
hh, hh.back() >= hh.front() ? Success() : Error()); material::project_icons::drawByName(dl, e.icon.c_str(), ImVec2(x+iconSz*0.5f, topY+capFont->LegacySize*0.5f), accent?accent:OnSurfaceMedium(), Type().iconSmall(), capFont->LegacySize);
x += capFont->LegacySize + Layout::spacingSm();
} }
dl->AddText(capFont,capFont->LegacySize,ImVec2(x,topY),OnSurfaceMedium(),fit(e.label,capFont,right-x-140*dp).c_str());
float valY = topY + capFont->LegacySize + Layout::spacingXs();
std::string hero = !valueStr.empty()? valueStr : drgxStr;
DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(left, valY), accent?accent:Success(), hero.c_str());
float botY = valY + sub1->LegacySize + Layout::spacingXs();
std::string sub = (!valueStr.empty() && !drgxStr.empty()) ? drgxStr : std::string();
if (!sub.empty()) dl->AddText(capFont,capFont->LegacySize,ImVec2(left,botY),OnSurfaceDisabled(),sub.c_str());
if (!chgStr.empty()){ float sw = sub.empty()?0.0f:(tw(capFont,sub)+tw(capFont," \xC2\xB7 ")); if(!sub.empty()) dl->AddText(capFont,capFont->LegacySize,ImVec2(left+tw(capFont,sub),botY),OnSurfaceDisabled()," \xC2\xB7 "); dl->AddText(capFont,capFont->LegacySize,ImVec2(left+sw,botY),chgCol,chgStr.c_str()); }
if (showSpark){ float sw=(right-left)*0.42f; pfDrawSparklineFilled(dl, ImVec2(right-sw, valY-2*dp), ImVec2(right, rowMax.y-padY), spark, sparkCol); }
} }
} }
@@ -1237,10 +1169,6 @@ struct MktCtx {
bool chartUp; bool chartUp;
double periodChangePct; double periodChangePct;
std::string periodSuffix; std::string periodSuffix;
const std::vector<config::Settings::PortfolioEntry>* pfEntriesGeo;
const std::vector<PfCell>* pfLayout;
int pfN, pfCols, pfMinW, pfMinH, pfMaxRow;
float pfCellW, pfCellH, pfInset, pfCardPad, pfCardGap;
}; };
// Combined hero card: draws the ONE glass panel spanning the price header AND the chart below it, // Combined hero card: draws the ONE glass panel spanning the price header AND the chart below it,
@@ -1659,10 +1587,6 @@ static void mktDrawPortfolio(const MktCtx& cx)
ImFont* sub1 = Type().subtitle1(); ImFont* sub1 = Type().subtitle1();
ImFont* body2 = Type().body2(); ImFont* body2 = Type().body2();
float ratioBarH = cx.ratioBarH, pfSummaryH = cx.pfSummaryH, portfolioH = cx.portfolioH; float ratioBarH = cx.ratioBarH, pfSummaryH = cx.pfSummaryH, portfolioH = cx.portfolioH;
const auto& pfEntriesGeo = *cx.pfEntriesGeo;
const auto& pfLayout = *cx.pfLayout;
int pfN = cx.pfN, pfCols = cx.pfCols, pfMinW = cx.pfMinW, pfMinH = cx.pfMinH, pfMaxRow = cx.pfMaxRow;
float pfCellW = cx.pfCellW, pfCellH = cx.pfCellH, pfInset = cx.pfInset, pfCardPad = cx.pfCardPad, pfCardGap = cx.pfCardGap;
char buf[128]; char buf[128];
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("market_portfolio")); Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("market_portfolio"));
@@ -1760,157 +1684,51 @@ static void mktDrawPortfolio(const MktCtx& cx)
ImVec2(cx0, cy + ratioBarH + Layout::spacingXs()), OnSurfaceDisabled(), buf); ImVec2(cx0, cy + ratioBarH + Layout::spacingXs()), OnSurfaceDisabled(), buf);
} }
// ---- CUSTOM GROUPS — dashboard grid: drag a card to move it, drag its corner to resize. // ---- CUSTOM GROUPS — full-width rows in the selected style (single-line / two-line / value-hero),
// A subtle dot grid shows while rearranging. Click (no drag) opens the editor. ---- // inside an internal scroll region so the portfolio stays within its height budget. Left/Right
if (pfN > 0) { // arrows switch styles (handled in RenderMarketTab). Click a row to edit it. ----
float gy = cardMin.y + pfSummaryH;
ImGuiIO& io = ImGui::GetIO();
bool interacting = (s_grid.drag >= 0 || s_grid.resize >= 0);
float rh = 20.0f * mktDp; // corner resize-grip size (generous so a short resize is easy to grab)
auto cellMin = [&](const PfCell& L) {
return ImVec2(cx0 + L.col * (pfCellW + pfCardGap), gy + L.row * (pfCellH + pfCardGap));
};
auto cellSize = [&](int w, int h) {
return ImVec2(w * pfCellW + (w - 1) * pfCardGap, h * pfCellH + (h - 1) * pfCardGap);
};
// Draw one card's content into [gcMin, gcMax].
// Stat tile: header (icon + name, 24h badge) / hero value (large, centered band) /
// footer (DRGX amount) + a dedicated sparkline strip along the bottom.
MktCardCtx cardCx{ dl, &state, &market, mktDp, pfInset, pfCardPad, sub1, capFont };
// Subtle dot grid while rearranging (fine 32px cells). Fill the whole visible grid area
// — down to the bottom of the scroll viewport, not just the occupied rows — so a card can
// be dropped anywhere, including the empty space below the current cards.
if (interacting) {
ImU32 dotc = WithAlpha(OnSurface(), 38);
float visibleBottom = dl->GetClipRectMax().y; // bottom edge of the scroll child
int rowsToBottom = (int)((visibleBottom - gy) / pfCellH) + 1;
int lastRow = std::max(pfMaxRow + 2, rowsToBottom);
for (int r = 0; r <= lastRow; r++)
for (int c = 0; c <= pfCols; c++)
dl->AddCircleFilled(ImVec2(cx0 + c * pfCellW, gy + r * pfCellH), 1.2f * mktDp, dotc);
}
// Draw + hit-test each card. Skip the one being dragged/resized (drawn specially below).
for (int i = 0; i < pfN; i++) {
ImVec2 gcMin = cellMin(pfLayout[i]);
ImVec2 sz = cellSize(pfLayout[i].w, pfLayout[i].h);
ImVec2 gcMax(gcMin.x + sz.x, gcMin.y + sz.y);
if (i == s_grid.drag && s_grid.moved) {
dl->AddRect(gcMin, gcMax, WithAlpha(OnSurface(), 45), 10.0f, 0, 1.0f); // source ghost
continue;
}
if (i == s_grid.resize) continue;
bool hov = !interacting && material::IsRectHovered(gcMin, gcMax);
mktDrawCard(cardCx, gcMin, gcMax, pfEntriesGeo[i], hov);
// Resize grip (bottom-right).
bool gripHov = material::IsRectHovered(ImVec2(gcMax.x - rh, gcMax.y - rh), gcMax);
ImU32 gcc = WithAlpha(OnSurface(), gripHov ? 170 : 80);
for (int k = 1; k <= 2; k++) {
float o = k * 4.0f * mktDp;
dl->AddLine(ImVec2(gcMax.x - o, gcMax.y - 3.0f), ImVec2(gcMax.x - 3.0f, gcMax.y - o), gcc, 1.3f * mktDp);
}
// Start a gesture only when idle. One body button; the press location (corner vs body)
// decides resize vs move — avoids overlapping-item hit-test issues.
if (s_grid.drag < 0 && s_grid.resize < 0) {
ImGui::PushID(3000 + i);
ImGui::SetCursorScreenPos(gcMin);
ImGui::InvisibleButton("##pfBody", sz);
bool bodyAct = ImGui::IsItemActivated();
bool bodyHov = ImGui::IsItemHovered();
ImGui::PopID();
if (bodyHov) ImGui::SetMouseCursor(gripHov ? ImGuiMouseCursor_ResizeNWSE : ImGuiMouseCursor_Hand);
if (bodyAct) {
if (gripHov) { s_grid.resize = i; s_grid.old = pfLayout[i]; }
else {
s_grid.drag = i; s_grid.moved = false; s_grid.old = pfLayout[i];
s_grid.press = io.MousePos;
s_grid.grab = ImVec2(io.MousePos.x - gcMin.x, io.MousePos.y - gcMin.y);
}
}
}
}
auto pfPersist = [&](const std::vector<config::Settings::PortfolioEntry>& v) {
app->settings()->setPortfolioEntries(v); app->settings()->save();
};
// ----- Resize in progress (corner drag) -----
if (s_grid.resize >= 0 && s_grid.resize < pfN) {
int i = s_grid.resize;
ImVec2 mn = cellMin(pfLayout[i]);
int nw = std::max(pfMinW, std::min(pfCols - pfLayout[i].col,
(int)((io.MousePos.x - mn.x + pfCardGap) / (pfCellW + pfCardGap) + 0.5f)));
int nh = std::max(pfMinH, (int)((io.MousePos.y - mn.y + pfCardGap) / (pfCellH + pfCardGap) + 0.5f));
ImVec2 sz = cellSize(nw, nh), mx(mn.x + sz.x, mn.y + sz.y);
mktDrawCard(cardCx, mn, mx, pfEntriesGeo[i], true);
dl->AddRect(mn, mx, WithAlpha(Primary(), 200), 10.0f, 0, 2.0f * mktDp);
// Live size readout (e.g. "8\xC3\x972") so the reached grid span is visible — the
// minimum is pfMinW x pfMinH (8x2).
{ {
char szb[32]; snprintf(szb, sizeof(szb), "%d\xC3\x97%d", nw, nh); const auto& allEntries = app->settings()->getPortfolioEntries();
ImVec2 tsz = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, szb); const std::string activeHash = app->activeWalletIdentityHash();
ImVec2 tp((mn.x + mx.x) * 0.5f - tsz.x * 0.5f, (mn.y + mx.y) * 0.5f - tsz.y * 0.5f); // Visible = this wallet's groups + legacy (empty scope). Keep storage indices for edit targets.
dl->AddRectFilled(ImVec2(tp.x - 6, tp.y - 3), ImVec2(tp.x + tsz.x + 6, tp.y + tsz.y + 3), std::vector<int> vis;
IM_COL32(20, 20, 30, 205), 4.0f); for (int i = 0; i < (int)allEntries.size(); i++) {
dl->AddText(sub1, sub1->LegacySize, tp, OnSurface(), szb); const auto& e = allEntries[i];
} if (e.scope.empty() || activeHash.empty() || e.scope == activeHash) vis.push_back(i);
ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNWSE);
if (!io.MouseDown[0]) {
auto entries = app->settings()->getPortfolioEntries();
if (i < (int)entries.size()) {
entries[i].gridCol = pfLayout[i].col; entries[i].gridRow = pfLayout[i].row;
entries[i].gridW = nw; entries[i].gridH = nh;
pfPersist(entries);
}
s_grid.resize = -1;
}
} }
// ----- Drag in progress ----- int style = app->settings() ? app->settings()->getPortfolioStyle() : 0;
if (s_grid.drag >= 0 && s_grid.drag < pfN) { float rowH = (style == 0 ? 46.0f : style == 1 ? 64.0f : 84.0f) * mktDp;
int i = s_grid.drag; float rowGap = Layout::spacingSm();
if (io.MouseDown[0]) { float rowsH = std::max(rowH, portfolioH - pfSummaryH);
if (std::abs(io.MousePos.x - s_grid.press.x) > 4.0f || std::abs(io.MousePos.y - s_grid.press.y) > 4.0f)
s_grid.moved = true; ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + pfSummaryH));
if (s_grid.moved) { // Flush-left child (no window padding) so rows align with the summary; a scrollbar appears
int w = pfLayout[i].w, h = pfLayout[i].h; // only when the visible groups overflow the bounded height.
float tx = io.MousePos.x - s_grid.grab.x, ty = io.MousePos.y - s_grid.grab.y; ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
int tc = std::max(0, std::min(pfCols - w, (int)((tx - cx0) / (pfCellW + pfCardGap) + 0.5f))); ImGui::BeginChild("##pfRows", ImVec2(availWidth, rowsH), false);
int tr = std::max(0, (int)((ty - gy) / (pfCellH + pfCardGap) + 0.5f)); ImGui::PopStyleVar();
s_grid.target = {tc, tr, w, h}; ImDrawList* rdl = ImGui::GetWindowDrawList();
ImVec2 tmn(cx0 + tc * (pfCellW + pfCardGap), gy + tr * (pfCellH + pfCardGap)); float rowW = ImGui::GetContentRegionAvail().x; // narrows automatically if a scrollbar shows
ImVec2 tsz = cellSize(w, h), tmx(tmn.x + tsz.x, tmn.y + tsz.y); if (vis.empty()) {
dl->AddRectFilled(tmn, tmx, WithAlpha(Primary(), 25), 10.0f); ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
dl->AddRect(tmn, tmx, WithAlpha(Primary(), 200), 10.0f, 0, 2.0f * mktDp); Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_entries"));
ImVec2 fmn(tx, ty), fmx(fmn.x + tsz.x, fmn.y + tsz.y);
mktDrawCard(cardCx, fmn, fmx, pfEntriesGeo[i], true); // dragged card follows the mouse
ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeAll);
}
} else { } else {
if (!s_grid.moved) { PortfolioBeginEdit(app, i); s_pfEdit.open = true; } for (int vi = 0; vi < (int)vis.size(); vi++) {
else { int i = vis[vi];
auto entries = app->settings()->getPortfolioEntries(); ImVec2 rMin = ImGui::GetCursorScreenPos();
if (i < (int)entries.size()) { ImVec2 rMax(rMin.x + rowW, rMin.y + rowH);
int occ = -1; ImGui::PushID(4000 + i);
for (int j = 0; j < pfN; j++) bool clicked = ImGui::InvisibleButton("##pfRowBtn", ImVec2(rowW, rowH));
if (j != i && pfLayout[j].col == s_grid.target.col && pfLayout[j].row == s_grid.target.row) { occ = j; break; } bool hov = ImGui::IsItemHovered();
entries[i].gridCol = s_grid.target.col; entries[i].gridRow = s_grid.target.row; if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (occ >= 0 && occ < (int)entries.size()) { ImGui::PopID();
entries[occ].gridCol = s_grid.old.col; entries[occ].gridRow = s_grid.old.row; pfDrawRow(rdl, rMin, rMax, allEntries[i], state, market, style, mktDp, sub1, body2, capFont, hov);
} if (clicked) { PortfolioBeginEdit(app, i); s_pfEdit.open = true; }
pfPersist(entries); if (vi + 1 < (int)vis.size()) ImGui::Dummy(ImVec2(rowW, rowGap));
}
}
s_grid.drag = -1; s_grid.moved = false;
} }
} }
ImGui::EndChild();
} }
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + portfolioH)); ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + portfolioH));
@@ -1949,6 +1767,22 @@ void RenderMarketTab(App* app)
const auto& currentExchange = registry[s_mkt.exchangeIdx]; const auto& currentExchange = registry[s_mkt.exchangeIdx];
if (s_mkt.pairIdx >= (int)currentExchange.pairs.size()) s_mkt.pairIdx = 0; if (s_mkt.pairIdx >= (int)currentExchange.pairs.size()) s_mkt.pairIdx = 0;
// Left/Right arrows: cycle portfolio row styles (compact / detailed / featured), mirroring the
// Overview tab's layout switch. Skip while typing, when Ctrl is held (theme cycle), or while the
// portfolio editor modal is open.
if (app->settings() && !s_pfEdit.open && !ImGui::GetIO().WantTextInput && !ImGui::GetIO().KeyCtrl) {
bool prev = ImGui::IsKeyPressed(ImGuiKey_LeftArrow);
bool next = ImGui::IsKeyPressed(ImGuiKey_RightArrow);
if (prev || next) {
int st = app->settings()->getPortfolioStyle();
st = next ? (st + 1) % 3 : (st + 2) % 3;
app->settings()->setPortfolioStyle(st);
const char* names[3] = { TR("portfolio_style_compact"), TR("portfolio_style_detailed"),
TR("portfolio_style_featured") };
Notifications::instance().info(std::string(TR("portfolio_style_label")) + ": " + names[st]);
}
}
// (Panel theme-effects are suppressed frame-wide in App::render() while the modal is open, so // (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().) // their foreground borders don't bleed over the overlay — see PortfolioEditorActive().)
ImVec2 marketAvail = ImGui::GetContentRegionAvail(); ImVec2 marketAvail = ImGui::GetContentRegionAvail();
@@ -1991,23 +1825,20 @@ void RenderMarketTab(App* app)
+ body2->LegacySize + Layout::spacingSm() // DRGX balance row + body2->LegacySize + Layout::spacingSm() // DRGX balance row
+ ratioBarH + Layout::spacingXs() + capFont->LegacySize // ratio bar + % label + ratioBarH + Layout::spacingXs() + capFont->LegacySize // ratio bar + % label
+ Layout::spacingMd(); // gap before the group cards + Layout::spacingMd(); // gap before the group cards
int pfN = (int)pfEntriesGeo.size(); // Content height = full-width rows in the selected style, confined to a bounded area (up to
float pfCardPad = Layout::spacingMd(); // inner padding of each group card // pfMaxVisibleRows) with an internal scroll when the visible groups overflow. Only groups
// Fine SQUARE grid: cells are ~32px minimum and scale up to evenly fill the width. Cards snap to // visible in the active wallet (own scope or legacy/global) count toward the height.
// this grid and span many cells; they're drawn with an inner inset so adjacent cards don't touch. const std::string pfActiveHash = app->activeWalletIdentityHash();
float pfCardGap = 0.0f; // gapless cell grid int pfVisN = 0;
float pfInset = 3.0f * mktDp; // per-card inset for visual separation for (const auto& e : pfEntriesGeo)
const int pfMinW = 8, pfMinH = 2; // minimum card size in cells (~256x64) if (e.scope.empty() || pfActiveHash.empty() || e.scope == pfActiveHash) pfVisN++;
int pfCols = std::max(pfMinW, (int)(availWidth / (32.0f * mktDp))); int pfStyle = app->settings()->getPortfolioStyle();
float pfCellW = availWidth / (float)pfCols; // square cell edge (>= ~32px) float pfRowH = (pfStyle == 0 ? 46.0f : pfStyle == 1 ? 64.0f : 84.0f) * mktDp;
float pfCellH = pfCellW; float pfRowGap = Layout::spacingSm();
// Resolve each group's grid cell: honor a stored placement when it fits, else auto-place const int pfMaxVisibleRows = 4; // rows shown before the list scrolls
// row-major into the first free cells. Deterministic each frame. float pfWantH = (pfVisN > 0) ? (pfVisN * pfRowH + std::max(0, pfVisN - 1) * pfRowGap) : 0.0f;
std::vector<PfCell> pfLayout = pfComputeGridLayout(pfEntriesGeo, pfCols, pfMinW, pfMinH); float pfCapH = pfMaxVisibleRows * pfRowH + (pfMaxVisibleRows - 1) * pfRowGap;
int pfMaxRow = 0; for (const auto& L : pfLayout) pfMaxRow = std::max(pfMaxRow, L.row + L.h); float pfGroupsH = std::min(pfWantH, pfCapH);
// Content height = the grid plus a couple of empty rows so cards can be dragged below the
// current bottom (the tab scrolls when the total exceeds the window).
float pfGroupsH = (pfN > 0) ? ((pfMaxRow + 2) * pfCellH) : 0.0f;
float portfolioH = pfSummaryH + pfGroupsH; float portfolioH = pfSummaryH + pfGroupsH;
// Chart series for the selected interval (timestamped), shared by the hero change badge and // Chart series for the selected interval (timestamped), shared by the hero change badge and
@@ -2040,9 +1871,7 @@ void RenderMarketTab(App* app)
MktCtx mktc{ MktCtx mktc{
app, &currentExchange, app, &currentExchange,
chartH, heroHeaderH, pfSummaryH, portfolioH, ratioBarH, chartH, heroHeaderH, pfSummaryH, portfolioH, ratioBarH,
&chartTimes, nowSec, chartUp, periodChangePct, periodSuffix, &chartTimes, nowSec, chartUp, periodChangePct, periodSuffix
&pfEntriesGeo, &pfLayout, pfN, pfCols, pfMinW, pfMinH, pfMaxRow,
pfCellW, pfCellH, pfInset, pfCardPad, pfCardGap
}; };
mktDrawPriceHero(mktc); mktDrawPriceHero(mktc);

View File

@@ -177,6 +177,9 @@ public:
ImGui::Separator(); ImGui::Separator();
if (StyledButton(TR("refresh"), ImVec2(110.0f * dp, 0))) s_needScan = true; if (StyledButton(TR("refresh"), ImVec2(110.0f * dp, 0))) s_needScan = true;
ImGui::SameLine(); ImGui::SameLine();
if (StyledButton(TR("wallets_reveal"), ImVec2(150.0f * dp, 0)))
util::Platform::openFolder(util::Platform::getDragonXDataDir());
ImGui::SameLine();
if (StyledButton(TR("close"), ImVec2(110.0f * dp, 0))) s_open = false; if (StyledButton(TR("close"), ImVec2(110.0f * dp, 0))) s_open = false;
EndOverlayDialog(); EndOverlayDialog();

View File

@@ -279,6 +279,7 @@ void I18n::loadBuiltinEnglish()
strings_["wallets_imported"] = "Wallet imported — switching…"; strings_["wallets_imported"] = "Wallet imported — switching…";
strings_["wallets_import_failed"] = "Could not import that wallet file."; strings_["wallets_import_failed"] = "Could not import that wallet file.";
strings_["wallets_creating"] = "Creating wallet — the node will restart…"; strings_["wallets_creating"] = "Creating wallet — the node will restart…";
strings_["wallets_reveal"] = "Reveal folder";
strings_["tt_seed_migrate"] = "Create a new seed-phrase wallet and move your funds into it"; strings_["tt_seed_migrate"] = "Create a new seed-phrase wallet and move your funds into it";
// Migrate-to-seed modal // Migrate-to-seed modal
strings_["mig_title"] = "Migrate to a seed wallet"; strings_["mig_title"] = "Migrate to a seed wallet";
@@ -1300,6 +1301,10 @@ void I18n::loadBuiltinEnglish()
strings_["portfolio_all_transparent"] = "All transparent"; strings_["portfolio_all_transparent"] = "All transparent";
strings_["portfolio_clear_sel"] = "Clear"; strings_["portfolio_clear_sel"] = "Clear";
strings_["portfolio_no_entries"] = "No custom entries yet. Add one to track a group of addresses."; strings_["portfolio_no_entries"] = "No custom entries yet. Add one to track a group of addresses.";
strings_["portfolio_style_label"] = "Portfolio style";
strings_["portfolio_style_compact"] = "Compact rows";
strings_["portfolio_style_detailed"] = "Detailed rows";
strings_["portfolio_style_featured"] = "Featured rows";
strings_["portfolio_edit"] = "Edit"; strings_["portfolio_edit"] = "Edit";
strings_["portfolio_delete"] = "Delete"; strings_["portfolio_delete"] = "Delete";
strings_["portfolio_save"] = "Save"; strings_["portfolio_save"] = "Save";