4 Commits

Author SHA1 Message Date
7d47c6e14e fix(console): balance colour-toggle push/pop so it stops drawing a red error rect
The accent + text-colour toggle buttons pushed/popped ImGuiCol_Text guarded by a flag the TactileButton flips in between, so a click left the colour stack unbalanced for that frame — and ImGui's error recovery drew a red rectangle around the console window (imgui.cpp:11727). Capture the flag into a local before the button and guard both the push and the pop with it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 13:39:42 -05:00
973bc0d338 feat(market): trend sparkline in all portfolio styles + inline chart/style pickers
Render the portfolio trend sparkline in every row style, each with a layout that features it:
- Table: a dedicated header-labelled TREND column (48dp rows), aligned across rows.
- Cards: a left-aligned info stack (icon + label / value+delta chip / DRGX) with a full-height sparkline filling the rest of the width (content-driven, so short groups give the chart more room).
- Spotlight: a big-value-over-a-full-width-chart-band hero tile (92dp).
Default sparkline interval is now MONTH (a real curve from the daily series, not the young minute buffer). Removed the per-row container accent line (redundant with the icon/sparkline colour) and the per-group shielded/transparent bar (private-by-default makes it near-always 100%, and dropped its now-dead SumPortfolioSplit helper). DPI-scale the sparkline stroke. Adds the "Trend" column string across 9 languages.

Relocated the Market controls out of a removed settings modal + gear: the chart line/candle picker now sits top-right of the chart (left of the trade button, candle-capable ranges only), and the Table/Cards/Spotlight picker sits left of Manage. Dropped the market fetch-prices toggle (still in the general Settings page).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 13:39:42 -05:00
e4c9b98ca2 feat(market): trend sparkline in all portfolio styles + fix candlestick availability
Two coupled Market-tab changes (same file):
- Sparkline in every style: render the per-entry trend sparkline in Table (a reserved trend column, aligned under the column header, reserved only when at least one group opts in so the dense grid isn't padded otherwise) and Cards (a filled strip on the right, above the full-width Z/T bar), not just Spotlight — so the per-entry 'show sparkline' setting is honored in all three views. DPI-scale the sparkline stroke (1.2f/1.4f * dp), from the sparkline audit's L1.
- Fix the candlestick toggle intermittently missing: the Market tab never triggered the per-exchange OHLC fetch on its own (only a pair-chip / refresh click did), so candlesticks were unavailable on a fresh tab open. Call refreshExchangeChart() every frame (self-throttled + in-flight-guarded — its own doc comment already says 'safe to call every frame'), which also fixes the new pair's candles never loading if you switch pairs mid-fetch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 00:56:06 -05:00
1c38f68781 fix(market): HiDPI-scale hand-drawn geometry + dedup row/column helpers
Market-tab audit follow-up (all 9 confirmed findings):
- Scale the unscaled corner radii on the Cards/Spotlight row rects, the address-preview glass panel, the chart Y-axis labels, and both chart hover tooltips by dpiScale() — they mismatched the coincident already-scaled rects on HiDPI (the CLAUDE.md hand-drawn-geometry rule).
- Share the portfolio row-height/gap formula (pfRowHeight/pfRowGapFor) and the TABLE column widths (kPfValColW/kPfDrgxColW) between the draw loop and the scroll-region height budget so they can't drift and clip.
- Derive the portfolio total from the shielded/transparent split (one address scan instead of two in Cards).
- Build the address-picker selected-set once (unordered_set) instead of a linear PortfolioEntryContains per sort-compare and per row.
No correctness/security/threading defects were found in the audit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 23:00:02 -05:00
14 changed files with 206 additions and 247 deletions

Binary file not shown.

View File

@@ -731,6 +731,7 @@
"market_cap_short": "Kap.", "market_cap_short": "Kap.",
"market_chart_loading": "Preisverlauf wird geladen", "market_chart_loading": "Preisverlauf wird geladen",
"market_col_name": "Name", "market_col_name": "Name",
"market_col_trend": "Trend",
"market_col_value": "Wert", "market_col_value": "Wert",
"market_iv_1d": "1T", "market_iv_1d": "1T",
"market_iv_1h": "1S", "market_iv_1h": "1S",

View File

@@ -731,6 +731,7 @@
"market_cap_short": "Cap.", "market_cap_short": "Cap.",
"market_chart_loading": "Cargando historial de precios", "market_chart_loading": "Cargando historial de precios",
"market_col_name": "Nombre", "market_col_name": "Nombre",
"market_col_trend": "Tendencia",
"market_col_value": "Valor", "market_col_value": "Valor",
"market_iv_1d": "1D", "market_iv_1d": "1D",
"market_iv_1h": "1H", "market_iv_1h": "1H",

View File

@@ -731,6 +731,7 @@
"market_cap_short": "Cap.", "market_cap_short": "Cap.",
"market_chart_loading": "Chargement de l'historique des prix", "market_chart_loading": "Chargement de l'historique des prix",
"market_col_name": "Nom", "market_col_name": "Nom",
"market_col_trend": "Tendance",
"market_col_value": "Valeur", "market_col_value": "Valeur",
"market_iv_1d": "1J", "market_iv_1d": "1J",
"market_iv_1h": "1H", "market_iv_1h": "1H",

View File

@@ -731,6 +731,7 @@
"market_cap_short": "時価総額", "market_cap_short": "時価総額",
"market_chart_loading": "価格履歴を読み込み中", "market_chart_loading": "価格履歴を読み込み中",
"market_col_name": "名前", "market_col_name": "名前",
"market_col_trend": "トレンド",
"market_col_value": "価値", "market_col_value": "価値",
"market_iv_1d": "1日", "market_iv_1d": "1日",
"market_iv_1h": "1時間", "market_iv_1h": "1時間",

View File

@@ -731,6 +731,7 @@
"market_cap_short": "시총", "market_cap_short": "시총",
"market_chart_loading": "가격 기록 불러오는 중", "market_chart_loading": "가격 기록 불러오는 중",
"market_col_name": "이름", "market_col_name": "이름",
"market_col_trend": "추세",
"market_col_value": "가치", "market_col_value": "가치",
"market_iv_1d": "1일", "market_iv_1d": "1일",
"market_iv_1h": "1시간", "market_iv_1h": "1시간",

View File

@@ -731,6 +731,7 @@
"market_cap_short": "Cap.", "market_cap_short": "Cap.",
"market_chart_loading": "Carregando histórico de preços", "market_chart_loading": "Carregando histórico de preços",
"market_col_name": "Nome", "market_col_name": "Nome",
"market_col_trend": "Tendência",
"market_col_value": "Valor", "market_col_value": "Valor",
"market_iv_1d": "1D", "market_iv_1d": "1D",
"market_iv_1h": "1H", "market_iv_1h": "1H",

View File

@@ -731,6 +731,7 @@
"market_cap_short": "Кап.", "market_cap_short": "Кап.",
"market_chart_loading": "Загрузка истории цен", "market_chart_loading": "Загрузка истории цен",
"market_col_name": "Название", "market_col_name": "Название",
"market_col_trend": "Тренд",
"market_col_value": "Стоимость", "market_col_value": "Стоимость",
"market_iv_1d": "1Д", "market_iv_1d": "1Д",
"market_iv_1h": "1Ч", "market_iv_1h": "1Ч",

View File

@@ -731,6 +731,7 @@
"market_cap_short": "市值", "market_cap_short": "市值",
"market_chart_loading": "正在加载价格历史", "market_chart_loading": "正在加载价格历史",
"market_col_name": "名称", "market_col_name": "名称",
"market_col_trend": "趋势",
"market_col_value": "价值", "market_col_value": "价值",
"market_iv_1d": "1天", "market_iv_1d": "1天",
"market_iv_1h": "1时", "market_iv_1h": "1时",

View File

@@ -91,7 +91,8 @@ public:
bool showValue = true; // show the converted/fiat value on the card bool showValue = true; // show the converted/fiat value on the card
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 = 4; // 0=minute 1=hour 2=day 3=week 4=month (default month: a real curve
// from the daily series, vs the young in-session minute buffer)
// Per-wallet visibility: "" (shown in every wallet — legacy/global) or a wallet-identity // Per-wallet visibility: "" (shown in every wallet — legacy/global) or a wallet-identity
// hash (shown only when that wallet is active). New entries are tagged with the current // 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. // wallet so a portfolio built for wallet A doesn't clutter wallet B.

View File

@@ -31,19 +31,6 @@ inline double SumPortfolioBalance(const std::vector<std::string>& entryAddresses
return sum; return sum;
} }
// Shielded/transparent breakdown of an entry's balance (for the per-group Z/T split bar). Pure.
struct PortfolioSplit { double shielded = 0.0; double transparent = 0.0; };
inline PortfolioSplit SumPortfolioSplit(const std::vector<std::string>& entryAddresses,
const std::vector<AddressInfo>& walletAddresses)
{
PortfolioSplit s;
if (entryAddresses.empty()) return s;
std::unordered_set<std::string> want(entryAddresses.begin(), entryAddresses.end());
for (const auto& a : walletAddresses)
if (want.count(a.address)) { if (a.isShielded()) s.shielded += a.balance; else s.transparent += a.balance; }
return s;
}
// Whether `address` is part of the entry's address group. // Whether `address` is part of the entry's address group.
inline bool PortfolioEntryContains(const std::vector<std::string>& entryAddresses, inline bool PortfolioEntryContains(const std::vector<std::string>& entryAddresses,
const std::string& address) const std::string& address)

View File

@@ -572,12 +572,14 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
ImGui::SameLine(); ImGui::SameLine();
{ {
float btnSz = ImGui::GetFrameHeight(); float btnSz = ImGui::GetFrameHeight();
// Capture the flag BEFORE the button can flip it — the push/pop guards must use the SAME value,
// or a click leaves the colour stack unbalanced (ImGui then draws a red error rect on the window).
const bool dim = !s_line_accents_enabled;
const char* icon = s_line_accents_enabled ? ICON_MD_FORMAT_COLOR_FILL : ICON_MD_FORMAT_COLOR_RESET; const char* icon = s_line_accents_enabled ? ICON_MD_FORMAT_COLOR_FILL : ICON_MD_FORMAT_COLOR_RESET;
if (!s_line_accents_enabled) if (dim) ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()));
if (TactileButton(icon, ImVec2(btnSz, btnSz), Type().iconMed())) if (TactileButton(icon, ImVec2(btnSz, btnSz), Type().iconMed()))
s_line_accents_enabled = !s_line_accents_enabled; s_line_accents_enabled = !s_line_accents_enabled;
if (!s_line_accents_enabled) ImGui::PopStyleColor(); if (dim) ImGui::PopStyleColor();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("console_toggle_accents")); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("console_toggle_accents"));
} }
@@ -585,11 +587,11 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
ImGui::SameLine(); ImGui::SameLine();
{ {
float btnSz = ImGui::GetFrameHeight(); float btnSz = ImGui::GetFrameHeight();
if (!s_line_text_color_enabled) const bool dim = !s_line_text_color_enabled; // capture before the click flips it (balanced push/pop)
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled())); if (dim) ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()));
if (TactileButton(ICON_MD_FORMAT_COLOR_TEXT, ImVec2(btnSz, btnSz), Type().iconMed())) if (TactileButton(ICON_MD_FORMAT_COLOR_TEXT, ImVec2(btnSz, btnSz), Type().iconMed()))
s_line_text_color_enabled = !s_line_text_color_enabled; s_line_text_color_enabled = !s_line_text_color_enabled;
if (!s_line_text_color_enabled) ImGui::PopStyleColor(); if (dim) ImGui::PopStyleColor();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("console_toggle_text_color")); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("console_toggle_text_color"));
} }

View File

@@ -31,6 +31,7 @@
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <ctime> #include <ctime>
#include <unordered_set>
namespace dragonx { namespace dragonx {
namespace ui { namespace ui {
@@ -47,7 +48,6 @@ struct MarketViewState {
int chartStyle = 1; // 0 = line, 1 = candlestick (only applies when per-exchange OHLC exists) int chartStyle = 1; // 0 = line, 1 = candlestick (only applies when per-exchange OHLC exists)
}; };
static MarketViewState s_mkt; static MarketViewState s_mkt;
static bool s_show_market_settings = false; // Market general-options modal (gear button, like chat)
// The effective exchange registry: the live CoinGecko list when available, else the // The effective exchange registry: the live CoinGecko list when available, else the
// compiled-in fallback. // compiled-in fallback.
@@ -133,7 +133,7 @@ struct PfEditState {
bool showValue = true; bool showValue = true;
bool show24h = false; bool show24h = false;
bool showSparkline = false; bool showSparkline = false;
int sparkInterval = 0; // 0=min 1=hour 2=day 3=week 4=month int sparkInterval = 4; // 0=min 1=hour 2=day 3=week 4=month (default month)
}; };
static PfEditState s_pfEdit; static PfEditState s_pfEdit;
@@ -163,16 +163,16 @@ static bool pfProjectSeries(const std::vector<double>& hist, ImVec2 mn, ImVec2 m
} }
static void pfDrawSparkline(ImDrawList* dl, ImVec2 mn, ImVec2 mx, static void pfDrawSparkline(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
const std::vector<double>& hist, ImU32 col) const std::vector<double>& hist, ImU32 col, float dp)
{ {
std::vector<ImVec2> pts; std::vector<ImVec2> pts;
if (!pfProjectSeries(hist, mn, mx, pts)) return; if (!pfProjectSeries(hist, mn, mx, pts)) return;
dl->AddPolyline(pts.data(), (int)pts.size(), col, ImDrawFlags_None, 1.2f); 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. // Sparkline with a soft fill under the line — for the card's dedicated bottom strip.
static void pfDrawSparklineFilled(ImDrawList* dl, ImVec2 mn, ImVec2 mx, static void pfDrawSparklineFilled(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
const std::vector<double>& hist, ImU32 lineCol) const std::vector<double>& hist, ImU32 lineCol, float dp)
{ {
std::vector<ImVec2> pts; std::vector<ImVec2> pts;
if (!pfProjectSeries(hist, mn, mx, pts)) return; if (!pfProjectSeries(hist, mn, mx, pts)) return;
@@ -181,7 +181,7 @@ static void pfDrawSparklineFilled(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
dl->PathLineTo(ImVec2(pts[n - 1].x, mx.y)); dl->PathLineTo(ImVec2(pts[n - 1].x, mx.y));
dl->PathLineTo(ImVec2(pts[0].x, mx.y)); dl->PathLineTo(ImVec2(pts[0].x, mx.y));
dl->PathFillConcave(WithAlpha(lineCol, 28)); dl->PathFillConcave(WithAlpha(lineCol, 28));
dl->AddPolyline(pts.data(), n, WithAlpha(lineCol, 220), ImDrawFlags_None, 1.4f); dl->AddPolyline(pts.data(), n, WithAlpha(lineCol, 220), ImDrawFlags_None, 1.4f * dp);
} }
@@ -284,7 +284,7 @@ static void PortfolioBeginEdit(App* app, int index)
s_pfEdit.showValue = true; s_pfEdit.showValue = true;
s_pfEdit.show24h = false; s_pfEdit.show24h = false;
s_pfEdit.showSparkline = false; s_pfEdit.showSparkline = false;
s_pfEdit.sparkInterval = 0; s_pfEdit.sparkInterval = 4; // default month (a real curve from the daily series)
} }
s_pfEdit.search[0] = '\0'; s_pfEdit.search[0] = '\0';
s_pfEdit.typeFilter = 0; s_pfEdit.typeFilter = 0;
@@ -668,14 +668,18 @@ static void pfDrawAddressSection(App* app)
} }
filtered.push_back(&a); filtered.push_back(&a);
} }
// Build the selected-address set once — the sort comparator and the per-row membership test below
// both consulted it via a linear PortfolioEntryContains scan (O(n) each) every frame.
std::unordered_set<std::string> selSet(s_pfEdit.addrs.begin(), s_pfEdit.addrs.end());
std::sort(filtered.begin(), filtered.end(), [&](const AddressInfo* x, const AddressInfo* y) { std::sort(filtered.begin(), filtered.end(), [&](const AddressInfo* x, const AddressInfo* y) {
bool sx = data::PortfolioEntryContains(s_pfEdit.addrs, x->address); bool sx = selSet.count(x->address) != 0;
bool sy = data::PortfolioEntryContains(s_pfEdit.addrs, y->address); bool sy = selSet.count(y->address) != 0;
if (sx != sy) return sx; if (sx != sy) return sx;
return x->balance > y->balance; return x->balance > y->balance;
}); });
if (selAllClicked) if (selAllClicked)
for (const AddressInfo* a : filtered) data::PortfolioEntryAdd(s_pfEdit.addrs, a->address); for (const AddressInfo* a : filtered)
if (data::PortfolioEntryAdd(s_pfEdit.addrs, a->address)) selSet.insert(a->address);
ImGui::Dummy(ImVec2(0, Layout::spacingXs())); ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImDrawList* ldl = material::BeginFadeScrollChild("##pfAddrList", s_pfEdit.addrFade, ImDrawList* ldl = material::BeginFadeScrollChild("##pfAddrList", s_pfEdit.addrFade,
ImVec2(Layout::spacingMd(), Layout::spacingSm()), dp); ImVec2(Layout::spacingMd(), Layout::spacingSm()), dp);
@@ -685,7 +689,7 @@ static void pfDrawAddressSection(App* app)
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_addr_match")); Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_addr_match"));
for (const AddressInfo* ap : filtered) { for (const AddressInfo* ap : filtered) {
const AddressInfo& a = *ap; const AddressInfo& a = *ap;
bool inSet = data::PortfolioEntryContains(s_pfEdit.addrs, a.address); bool inSet = selSet.count(a.address) != 0;
ImVec2 rmn = ImGui::GetCursorScreenPos(); ImVec2 rmn = ImGui::GetCursorScreenPos();
ImVec2 rmx(rmn.x + lw, rmn.y + rowH); ImVec2 rmx(rmn.x + lw, rmn.y + rowH);
bool rhov = ImGui::IsMouseHoveringRect(rmn, rmx); bool rhov = ImGui::IsMouseHoveringRect(rmn, rmx);
@@ -734,8 +738,8 @@ static void pfDrawAddressSection(App* app)
ImGui::PushID(a.address.c_str()); ImGui::PushID(a.address.c_str());
ImGui::InvisibleButton("##pfrow", ImVec2(lw, rowH)); ImGui::InvisibleButton("##pfrow", ImVec2(lw, rowH));
if (ImGui::IsItemClicked()) { if (ImGui::IsItemClicked()) {
if (inSet) data::PortfolioEntryRemove(s_pfEdit.addrs, a.address); if (inSet) { data::PortfolioEntryRemove(s_pfEdit.addrs, a.address); selSet.erase(a.address); }
else data::PortfolioEntryAdd(s_pfEdit.addrs, a.address); else { data::PortfolioEntryAdd(s_pfEdit.addrs, a.address); selSet.insert(a.address); }
} }
ImGui::PopID(); ImGui::PopID();
} }
@@ -957,7 +961,7 @@ static void RenderPortfolioEditor(App* app)
ImVec2 pMin = ImGui::GetCursorScreenPos(); ImVec2 pMin = ImGui::GetCursorScreenPos();
ImVec2 pMax(pMin.x + pw, pMin.y + ph); ImVec2 pMax(pMin.x + pw, pMin.y + ph);
ImU32 accent = s_pfEdit.color ? (ImU32)s_pfEdit.color : 0; ImU32 accent = s_pfEdit.color ? (ImU32)s_pfEdit.color : 0;
GlassPanelSpec g; g.rounding = 10.0f; g.fillAlpha = 22; g.borderAlpha = 40; GlassPanelSpec g; g.rounding = 10.0f * dp; g.fillAlpha = 22; g.borderAlpha = 40;
DrawGlassPanel(pdl, pMin, pMax, g); DrawGlassPanel(pdl, pMin, pMax, g);
if (accent) { if (accent) {
int oa = (int)(std::max(0, std::min(100, s_pfEdit.outlineOpacity)) * 2.55f + 0.5f); int oa = (int)(std::max(0, std::min(100, s_pfEdit.outlineOpacity)) * 2.55f + 0.5f);
@@ -969,7 +973,7 @@ static void RenderPortfolioEditor(App* app)
ImU32 spCol = WithAlpha(h.back() >= h.front() ? Success() : Error(), 80); ImU32 spCol = WithAlpha(h.back() >= h.front() ? Success() : Error(), 80);
ImVec2 spMin(pMin.x + ppad, pMin.y + ph * 0.46f); ImVec2 spMin(pMin.x + ppad, pMin.y + ph * 0.46f);
ImVec2 spMax(pMax.x - ppad, pMax.y - ppad * 0.6f); ImVec2 spMax(pMax.x - ppad, pMax.y - ppad * 0.6f);
pfDrawSparkline(pdl, spMin, spMax, h, spCol); pfDrawSparkline(pdl, spMin, spMax, h, spCol, dp);
} }
} }
float rowY = pMin.y + ppad, nameX = pMin.x + ppad; float rowY = pMin.y + ppad, nameX = pMin.x + ppad;
@@ -1083,58 +1087,61 @@ bool PortfolioEditorActive() { return s_pfEdit.open; }
// muted em-dash so a row never looks broken. Pure drawing into dl. // 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 // 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. // 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; }; struct PfTableCols { float labelR, drgxR, valR, chgR; };
static PfTableCols pfTableCols(float right, float dp) static PfTableCols pfTableCols(float right, float dp)
{ {
const float colGap = Layout::spacingMd(); const float colGap = Layout::spacingMd();
const float chgW = 62.0f*dp, valW = 108.0f*dp, drgxW = 118.0f*dp;
PfTableCols c; PfTableCols c;
c.chgR = right; c.chgR = right;
c.valR = c.chgR - chgW - colGap; c.valR = c.chgR - kPfChgColW * dp - colGap;
c.drgxR = c.valR - valW - colGap; c.drgxR = c.valR - kPfValColW * dp - colGap;
c.labelR = c.drgxR - drgxW - colGap; c.labelR = c.drgxR - kPfDrgxColW * dp - colGap;
return c; 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, static void pfDrawRow(ImDrawList* dl, ImVec2 rowMin, ImVec2 rowMax,
const config::Settings::PortfolioEntry& e, const WalletState& state, const config::Settings::PortfolioEntry& e, const WalletState& state,
const MarketInfo& market, int style, float dp, const MarketInfo& market, int style, float dp,
ImFont* sub1, ImFont* capFont, bool hov) ImFont* sub1, ImFont* capFont, bool hov, bool tableReserveSpark = false)
{ {
double bal = data::SumPortfolioBalance(e.addresses, state.addresses); const double bal = data::SumPortfolioBalance(e.addresses, state.addresses);
ImU32 accent = e.color ? (ImU32)e.color : 0; ImU32 accent = e.color ? (ImU32)e.color : 0;
const bool zeroBal = (bal <= 0.0); // empty groups render dimmed (content only, not the container) const bool zeroBal = (bal <= 0.0); // empty groups render dimmed (content only, not the container)
// ---- Per-style CONTAINER (the enabler): the three styles differ before any field is read. // ---- Per-style CONTAINER: Table (0) = borderless ledger row (hairline + hover wash); Cards (1) =
// Table (0) = borderless ledger row (hairline + hover wash + a slim left identity tick); // glass card with a faint identity tint; Spotlight (2) = a heavier glass tile. No accent line —
// Cards (1) = glass card with a faint accent tint + a left accent spine; // the group's colour already reads from its icon and the sparkline, so it adds nothing here. ----
// Spotlight (2) = a heavier tile (higher fill + a bold accent spine). ----
const float round = 10.0f * dp; const float round = 10.0f * dp;
const int oa = (int)(std::max(0, std::min(100, e.outlineOpacity)) * 2.55f + 0.5f);
if (style == 0) { if (style == 0) {
if (hov) dl->AddRectFilled(rowMin, rowMax, WithAlpha(OnSurface(), 14), 0.0f); 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), dl->AddLine(ImVec2(rowMin.x, rowMax.y - 0.5f), ImVec2(rowMax.x, rowMax.y - 0.5f),
WithAlpha(OnSurface(), 24), 1.0f); WithAlpha(OnSurface(), 24), 1.0f);
dl->AddRectFilled(ImVec2(rowMin.x, rowMin.y + 5.0f * dp), ImVec2(rowMin.x + 3.0f * dp, rowMax.y - 5.0f * dp),
accent ? accent : WithAlpha(OnSurface(), 55), 1.5f * dp);
} else if (style == 1) { } else if (style == 1) {
GlassPanelSpec g; g.rounding = 10.0f; g.fillAlpha = hov ? 42 : 30; g.borderAlpha = 55; GlassPanelSpec g; g.rounding = round; g.fillAlpha = hov ? 42 : 30; g.borderAlpha = 55;
DrawGlassPanel(dl, rowMin, rowMax, g); DrawGlassPanel(dl, rowMin, rowMax, g);
if (accent) { if (accent) dl->AddRectFilled(rowMin, rowMax, WithAlpha(accent, hov ? 16 : 10), round); // faint identity wash
dl->AddRectFilled(rowMin, rowMax, WithAlpha(accent, hov ? 16 : 10), round); if (hov) dl->AddRect(rowMin, rowMax, WithAlpha(OnSurface(), 80), round, 0, 1.2f);
dl->AddRectFilled(rowMin, ImVec2(rowMin.x + 3.0f * dp, rowMax.y), WithAlpha(accent, 205),
round, ImDrawFlags_RoundCornersLeft);
dl->AddRect(rowMin, rowMax, WithAlpha(accent, hov ? std::min(255, oa + 45) : oa), 10.0f, 0, hov ? 1.8f : 1.2f);
} else if (hov) {
dl->AddRect(rowMin, rowMax, WithAlpha(OnSurface(), 80), 10.0f, 0, 1.2f);
}
} else { } else {
GlassPanelSpec g; g.rounding = 10.0f; g.fillAlpha = hov ? 56 : 46; g.borderAlpha = 60; GlassPanelSpec g; g.rounding = round; g.fillAlpha = hov ? 56 : 46; g.borderAlpha = 60;
DrawGlassPanel(dl, rowMin, rowMax, g); DrawGlassPanel(dl, rowMin, rowMax, g);
dl->AddRectFilled(rowMin, ImVec2(rowMin.x + 4.0f * dp, rowMax.y), if (hov) dl->AddRect(rowMin, rowMax, WithAlpha(OnSurface(), 70), round, 0, 1.2f);
WithAlpha(accent ? accent : Primary(), 220), round, ImDrawFlags_RoundCornersLeft);
if (accent) dl->AddRect(rowMin, rowMax, WithAlpha(accent, hov ? std::min(255, oa + 45) : oa), 10.0f, 0, hov ? 1.8f : 1.2f);
else if (hov) dl->AddRect(rowMin, rowMax, WithAlpha(OnSurface(), 70), 10.0f, 0, 1.2f);
} }
auto tw = [](ImFont* f, const std::string& s){ return f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, s.c_str()).x; }; auto tw = [](ImFont* f, const std::string& s){ return f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, s.c_str()).x; };
@@ -1174,11 +1181,12 @@ static void pfDrawRow(ImDrawList* dl, ImVec2 rowMin, ImVec2 rowMax,
const int zdVtx = zeroBal ? dl->VtxBuffer.Size : -1; const int zdVtx = zeroBal ? dl->VtxBuffer.Size : -1;
if (style == 0) { if (style == 0) {
// ---- TABLE: icon + label (left); tight numeric columns DRGX | value | 24h pill (right). // ---- TABLE: icon + label (left); a dedicated centre TREND column (aligned + header-labelled);
// No sparkline — the trend rides in a small coloured % pill, keeping this a scannable grid. // 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 PfTableCols cols = pfTableCols(right, dp);
const float chgR = cols.chgR, valR = cols.valR, drgxR = cols.drgxR, labelR = cols.labelR; const float chgR = cols.chgR, valR = cols.valR, drgxR = cols.drgxR, labelR = cols.labelR;
const float valW = 108.0f*dp, drgxW = 118.0f*dp; // fit() widths for the value / DRGX columns const float valW = kPfValColW*dp, drgxW = kPfDrgxColW*dp; // fit() widths (shared with pfTableCols)
float iconSz = capFont->LegacySize, x = left; float iconSz = capFont->LegacySize, x = left;
if (!e.icon.empty()) { 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); material::project_icons::drawByName(dl, e.icon.c_str(), ImVec2(x+iconSz*0.5f, midY), accent?accent:OnSurfaceMedium(), Type().iconSmall(), iconSz);
@@ -1194,65 +1202,81 @@ static void pfDrawRow(ImDrawList* dl, ImVec2 rowMin, ImVec2 rowMax,
dl->AddText(capFont, capFont->LegacySize, ImVec2(pmn.x + 6.0f*dp, midY - capFont->LegacySize*0.5f), dl->AddText(capFont, capFont->LegacySize, ImVec2(pmn.x + 6.0f*dp, midY - capFont->LegacySize*0.5f),
hasChange?chgCol:OnSurfaceDisabled(), s.c_str()); hasChange?chgCol:OnSurfaceDisabled(), s.c_str());
} }
const float lblMaxW = std::max(24.0f*dp, labelR - x); // 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()); 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) { } else if (style == 1) {
// ---- CARDS: muted label + a leading (shadowed) value on top; DRGX + 24h beneath; and a // ---- CARDS: a balanced snapshot — a LEFT-aligned info stack (icon + label / value+delta chip /
// per-group shielded/transparent split bar along the bottom edge as the signature. // DRGX) grouped on the left, with a full-height sparkline filling the rest of the width.
const float zbarH = 3.0f*dp; const float zoneTop = rowMin.y + padY;
float iconSz = capFont->LegacySize, x = left; const float zoneBot = rowMax.y - padY;
const float topY = rowMin.y + padY + 1.0f*dp; // Icon centred in the info zone; the label/value/DRGX stack sits to its right.
const float botY = rowMax.y - padY - zbarH - 3.0f*dp - capFont->LegacySize; const float iconSz = capFont->LegacySize;
float textX = left;
if (!e.icon.empty()) { if (!e.icon.empty()) {
material::project_icons::drawByName(dl, e.icon.c_str(), ImVec2(x+iconSz*0.5f, topY+sub1->LegacySize*0.5f), accent?accent:OnSurfaceMedium(), Type().iconSmall(), iconSz); material::project_icons::drawByName(dl, e.icon.c_str(), ImVec2(left + iconSz*0.5f, (zoneTop + zoneBot)*0.5f), accent?accent:OnSurfaceMedium(), Type().iconSmall(), iconSz);
x += iconSz + Layout::spacingSm(); textX = left + iconSz + Layout::spacingSm();
} }
// value (top-right, leads via a soft shadow) // Info zone driven by the ACTUAL content width so a short group hands the extra room to the chart;
std::string vTop = wantValue ? (hasValue?valStr:kDash) : std::string(); // bounded [28%, 52%] so the info never cramps and the chart always keeps a decent band.
float vW = wantValue ? tw(sub1, vTop) : 0.0f; float infoR = right, cSparkL = right;
if (wantValue) DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(right - vW, topY), hasValue?OnSurface():OnSurfaceDisabled(), vTop.c_str()); if (hasSpark) {
// label (top-left, muted so the value leads) const std::string vTxt0 = wantValue ? (hasValue?valStr:kDash) : std::string();
float labelR = wantValue ? (right - vW - colGap) : right; const float chipW = (wantChange && hasChange) ? (Layout::spacingSm() + tw(capFont, chgStr) + 12.0f*dp) : 0.0f;
dl->AddText(capFont, capFont->LegacySize, ImVec2(x, topY + (sub1->LegacySize - capFont->LegacySize)*0.5f), OnSurfaceMedium(), fit(e.label,capFont,std::max(24.0f*dp, labelR - x)).c_str()); const float valLineW = wantValue ? (tw(sub1, vTxt0) + chipW) : 0.0f;
// DRGX (bottom-left) + 24h (bottom-right) const float contentW = std::max(std::max(tw(capFont, e.label), valLineW), e.showDrgx ? tw(capFont, drgxStr) : 0.0f);
if (e.showDrgx) dl->AddText(capFont, capFont->LegacySize, ImVec2(left, botY), OnSurfaceMedium(), drgxStr.c_str()); const float span = right - left;
if (wantChange) { std::string s = hasChange?chgStr:kDash; dl->AddText(capFont, capFont->LegacySize, ImVec2(right - tw(capFont,s), botY), hasChange?chgCol:OnSurfaceDisabled(), s.c_str()); } const float infoW = std::min(std::max((textX - left) + contentW + colGap * 2.0f, span * 0.28f), span * 0.52f);
// shielded/transparent split bar (Cards-only signature; read-only balance data) cSparkL = left + infoW;
{ infoR = cSparkL - colGap;
const data::PortfolioSplit sp = data::SumPortfolioSplit(e.addresses, state.addresses); }
const double tot = sp.shielded + sp.transparent; const float gap = Layout::spacingXs();
const float barY = rowMax.y - padY - zbarH; const float stackH = capFont->LegacySize + gap + sub1->LegacySize + gap + capFont->LegacySize;
const bool barLight = IsLightTheme(); float cy = zoneTop + std::max(0.0f, (zoneBot - zoneTop - stackH) * 0.5f);
dl->AddRectFilled(ImVec2(left, barY), ImVec2(right, barY + zbarH), // label (muted)
barLight ? IM_COL32(0,0,0,20) : IM_COL32(255,255,255,12), zbarH*0.5f); dl->AddText(capFont, capFont->LegacySize, ImVec2(textX, cy), OnSurfaceMedium(), fit(e.label, capFont, std::max(24.0f*dp, infoR - textX)).c_str());
if (tot > 0) { cy += capFont->LegacySize + gap;
float sr = (float)(sp.shielded / tot); sr = sr<0?0.0f:(sr>1?1.0f:sr); // value (leads via a soft shadow) + delta chip trailing it
float sw = (right - left) * sr; if (wantValue) {
const int fa = barLight ? 205 : 175; const std::string vTxt = hasValue ? valStr : kDash;
if (sw > 0.5f) dl->AddRectFilled(ImVec2(left, barY), ImVec2(left+sw, barY+zbarH), WithAlpha(Success(), fa), DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(textX, cy), hasValue?OnSurface():OnSurfaceDisabled(), vTxt.c_str());
(right-left-sw > 0.5f) ? ImDrawFlags_RoundCornersLeft : ImDrawFlags_RoundCornersAll, zbarH*0.5f); if (wantChange && hasChange) {
if (right-left-sw > 0.5f) dl->AddRectFilled(ImVec2(left+sw, barY), ImVec2(right, barY+zbarH), WithAlpha(Warning(), fa), const float pw = tw(capFont, chgStr) + 12.0f*dp, ph = capFont->LegacySize + 5.0f*dp;
sw > 0.5f ? ImDrawFlags_RoundCornersRight : ImDrawFlags_RoundCornersAll, zbarH*0.5f); 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 { } else {
// ---- SPOTLIGHT: muted name + DRGX on top, then a LARGE value coloured by 24h direction with a // ---- SPOTLIGHT: "big number over a chart" hero tile — a top zone (muted name + DRGX, then a
// delta chip, and a filled sparkline on the right. A third font tier no other style uses. // 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 heroSz = sub1->LegacySize * 1.5f; // ImGui 1.92 rebakes at this size (crisp, not upscaled)
const float sparkW = (right - left) * 0.34f; const float topY = rowMin.y + padY;
const float sparkL = right - sparkW;
const float rightZoneL = hasSpark ? (sparkL - colGap) : right;
const float blockH = capFont->LegacySize + Layout::spacingXs() + heroSz;
const float topY = midY - blockH * 0.5f;
float iconSz = capFont->LegacySize, x = left; float iconSz = capFont->LegacySize, x = left;
if (!e.icon.empty()) { 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); 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(); x += iconSz + Layout::spacingSm();
} }
// name (top-left, muted) + DRGX (top-right, muted) // name (top-left, muted) + DRGX (top-right, muted)
const float drgxW2 = e.showDrgx ? tw(capFont, drgxStr) : 0.0f; const float drgxW2 = e.showDrgx ? tw(capFont, drgxStr) : 0.0f;
if (e.showDrgx) rtext(capFont, rightZoneL, topY + capFont->LegacySize*0.5f, OnSurfaceMedium(), drgxStr); if (e.showDrgx) rtext(capFont, right, topY + capFont->LegacySize*0.5f, OnSurfaceMedium(), drgxStr);
const float nameMaxW = std::max(24.0f*dp, (rightZoneL - (e.showDrgx ? drgxW2 + colGap : 0.0f)) - x); 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()); 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) // hero value — coloured by 24h direction (neutral when no live change)
const float valY = topY + capFont->LegacySize + Layout::spacingXs(); const float valY = topY + capFont->LegacySize + Layout::spacingXs();
@@ -1260,17 +1284,22 @@ static void pfDrawRow(ImDrawList* dl, ImVec2 rowMin, ImVec2 rowMax,
const ImU32 heroCol = !hasValue ? OnSurfaceDisabled() : (hasChange ? chgCol : OnSurface()); const ImU32 heroCol = !hasValue ? OnSurfaceDisabled() : (hasChange ? chgCol : OnSurface());
DrawTextShadow(dl, sub1, heroSz, ImVec2(left, valY), heroCol, hero.c_str()); DrawTextShadow(dl, sub1, heroSz, ImVec2(left, valY), heroCol, hero.c_str());
const float heroW = sub1->CalcTextSizeA(heroSz, FLT_MAX, 0, hero.c_str()).x; const float heroW = sub1->CalcTextSizeA(heroSz, FLT_MAX, 0, hero.c_str()).x;
// delta chip trailing the hero value (only if it fits before the sparkline zone) // delta chip trailing the hero value
if (wantChange && hasChange) { if (wantChange && hasChange) {
const float pw = tw(capFont, chgStr) + 12.0f*dp, ph = capFont->LegacySize + 5.0f*dp; const float pw = tw(capFont, chgStr) + 12.0f*dp, ph = capFont->LegacySize + 5.0f*dp;
const float chipX = left + heroW + Layout::spacingSm(); const float chipX = left + heroW + Layout::spacingSm();
const float chipY = valY + (heroSz - ph)*0.5f; const float chipY = valY + (heroSz - ph)*0.5f;
if (chipX + pw <= rightZoneL) { if (chipX + pw <= right) {
dl->AddRectFilled(ImVec2(chipX, chipY), ImVec2(chipX+pw, chipY+ph), WithAlpha(chgCol, 34), ph*0.5f); 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()); dl->AddText(capFont, capFont->LegacySize, ImVec2(chipX+6.0f*dp, chipY + (ph-capFont->LegacySize)*0.5f), chgCol, chgStr.c_str());
} }
} }
if (hasSpark) pfDrawSparklineFilled(dl, ImVec2(sparkL, rowMin.y + padY), ImVec2(right, rowMax.y - padY), spark, sparkCol); // 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 if (zdVtx >= 0) { // fade the just-emitted body vertices for an empty group
@@ -1474,6 +1503,23 @@ static void mktDrawPriceHero(const MktCtx& cx)
float tradeBtnX = cardMax.x - pad - tradeBtnW; float tradeBtnX = cardMax.x - pad - tradeBtnW;
float tradeBtnY = cardMin.y + Layout::spacingSm(); 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); ImVec2 tMin(tradeBtnX, tradeBtnY), tMax(tradeBtnX + tradeBtnW, tradeBtnY + tradeBtnH);
bool tradeHov = material::IsRectHovered(tMin, tMax); bool tradeHov = material::IsRectHovered(tMin, tMax);
@@ -1596,30 +1642,7 @@ static void mktDrawPriceChart(const MktCtx& cx)
bx += bw + Layout::spacingXs(); bx += bw + Layout::spacingXs();
} }
// Line/candle toggle — only when the selected range has per-exchange candles (the aggregate / // (Line/candle chart-style toggle now lives top-right of the price hero, left of the trade button.)
// Live view is line-only, so the toggle is hidden there).
if (cx.chartCandles && cx.chartCandles->size() >= 2) {
bx += Layout::spacingSm();
ImFont* icoF = material::Typography::instance().iconSmall();
const bool isCandle = (s_mkt.chartStyle == 1);
const char* styleIcon = isCandle ? ICON_MD_CANDLESTICK_CHART : ICON_MD_SHOW_CHART;
ImVec2 tmn(bx, rowTop), tmx(bx + pillH, rowTop + pillH);
bool thov = material::IsRectHovered(tmn, tmx);
if (thov) { dl->AddRectFilled(tmn, tmx, IM_COL32(255, 255, 255, 20), 4.0f * mktDp);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); }
ImVec2 tiSz = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, styleIcon);
dl->AddText(icoF, icoF->LegacySize,
ImVec2(tmn.x + (pillH - tiSz.x) * 0.5f, tmn.y + (pillH - tiSz.y) * 0.5f),
thov ? OnSurface() : OnSurfaceMedium(), styleIcon);
ImGui::SetCursorScreenPos(tmn);
if (ImGui::InvisibleButton("##ChartStyle", ImVec2(pillH, pillH))) {
s_mkt.chartStyle = isCandle ? 0 : 1;
if (app->settings()) { app->settings()->setChartStyle(s_mkt.chartStyle); app->settings()->save(); }
}
if (ImGui::IsItemHovered())
material::Tooltip("%s", TR(isCandle ? "market_style_line" : "market_style_candle"));
bx += pillH;
}
// Refresh button (far right). // Refresh button (far right).
float rEdge = chartMax.x - chartPad; float rEdge = chartMax.x - chartPad;
@@ -1703,7 +1726,7 @@ static void mktDrawPriceChart(const MktCtx& cx)
ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf); 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 // 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. // smaller than the label width) so it never spills onto the tab background.
float lblX = std::max(chartMin.x + 3.0f, plotLeft - labelSz.x - 6); float lblX = std::max(chartMin.x + 3.0f * mktDp, plotLeft - labelSz.x - 6.0f * mktDp);
dl->AddText(capFont, capFont->LegacySize, dl->AddText(capFont, capFont->LegacySize,
ImVec2(lblX, gy - labelSz.y * 0.5f), ImVec2(lblX, gy - labelSz.y * 0.5f),
OnSurfaceDisabled(), buf); OnSurfaceDisabled(), buf);
@@ -1766,8 +1789,8 @@ static void mktDrawPriceChart(const MktCtx& cx)
tipX = std::max(plotLeft, tipX); tipX = std::max(plotLeft, tipX);
float tipY = plotTop + 4.0f * mktDp; float tipY = plotTop + 4.0f * mktDp;
ImVec2 tMin(tipX, tipY), tMax(tipX + tw + pad * 2, tipY + th); ImVec2 tMin(tipX, tipY), tMax(tipX + tw + pad * 2, tipY + th);
dl->AddRectFilled(tMin, tMax, IM_COL32(20, 20, 30, 235), 4.0f); 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, 0, 1.0f); dl->AddRect(tMin, tMax, IM_COL32(255, 255, 255, 30), 4.0f * mktDp, 0, 1.0f);
ImU32 cCol = (hc.close >= hc.open) ? Success() : Error(); 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), OnSurface(), when);
dl->AddText(capFont, lh, ImVec2(tipX + pad, tipY + pad + lh + gap), cCol, l2); dl->AddText(capFont, lh, ImVec2(tipX + pad, tipY + pad + lh + gap), cCol, l2);
@@ -1870,16 +1893,16 @@ static void mktDrawPriceChart(const MktCtx& cx)
snprintf(buf, sizeof(buf), "%s", FormatPrice(s_mkt.history[idx]).c_str()); snprintf(buf, sizeof(buf), "%s", FormatPrice(s_mkt.history[idx]).c_str());
ImVec2 tipSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf); ImVec2 tipSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
float tipPad = Layout::spacingSm() + Layout::spacingXs(); float tipPad = Layout::spacingSm() + Layout::spacingXs();
float tipX = px + 10; float tipX = px + 10.0f * mktDp;
float tipY = py - tipSz.y - tipPad * 2 - 4; float tipY = py - tipSz.y - tipPad * 2 - 4.0f * mktDp;
if (tipX + tipSz.x + tipPad * 2 > plotRight) if (tipX + tipSz.x + tipPad * 2 > plotRight)
tipX = px - tipSz.x - tipPad * 2 - 10; tipX = px - tipSz.x - tipPad * 2 - 10.0f * mktDp;
if (tipY < plotTop) tipY = py + 10; if (tipY < plotTop) tipY = py + 10.0f * mktDp;
ImVec2 tipMin(tipX, tipY); ImVec2 tipMin(tipX, tipY);
ImVec2 tipMax(tipX + tipSz.x + tipPad * 2, tipY + tipSz.y + tipPad * 2); ImVec2 tipMax(tipX + tipSz.x + tipPad * 2, tipY + tipSz.y + tipPad * 2);
dl->AddRectFilled(tipMin, tipMax, IM_COL32(20, 20, 30, 230), 4.0f); dl->AddRectFilled(tipMin, tipMax, IM_COL32(20, 20, 30, 230), 4.0f * mktDp);
dl->AddRect(tipMin, tipMax, IM_COL32(255, 255, 255, 30), 4.0f, 0, 1.0f); dl->AddRect(tipMin, tipMax, IM_COL32(255, 255, 255, 30), 4.0f * mktDp, 0, 1.0f);
dl->AddText(capFont, capFont->LegacySize, dl->AddText(capFont, capFont->LegacySize,
ImVec2(tipX + tipPad, tipY + tipPad), dotCol, buf); ImVec2(tipX + tipPad, tipY + tipPad), dotCol, buf);
} }
@@ -1945,23 +1968,29 @@ static void mktDrawPortfolio(const MktCtx& cx)
} }
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("market_portfolio")); Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("market_portfolio"));
// Settings gear + "Manage…" button, right-aligned on the header row (gear left of Manage). The gear // Portfolio-style segmented control (Table / Cards / Spotlight) + "Manage…" button, right-aligned on
// opens the general market-options modal; Manage opens the portfolio editor. // the header row (style picker left of Manage). Manage opens the portfolio editor.
{ {
const char* ml = TR("portfolio_manage"); const char* ml = TR("portfolio_manage");
float mBtnW = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, ml).x + Layout::spacingMd() * 2; float mBtnW = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, ml).x + Layout::spacingMd() * 2;
const float gearW = ImGui::GetFrameHeight(); // square, matching the Manage button's height const char* styleItems[3] = { TR("portfolio_style_compact"), TR("portfolio_style_detailed"),
const float gGap = Layout::spacingXs(); TR("portfolio_style_featured") };
const float segH = ImGui::GetFrameHeight();
const float segW = 210.0f * mktDp;
const float sGap = Layout::spacingSm();
ImGui::SameLine(); ImGui::SameLine();
RightAlignX(mBtnW + gGap + gearW); // reserve the whole group before drawing left-to-right RightAlignX(segW + sGap + mBtnW); // right-align the [style picker | Manage] group
IconButtonStyle gst; const ImVec2 segOrigin = ImGui::GetCursorScreenPos();
gst.tooltip = TR("market_settings_tip"); const int curStyle = app->settings() ? app->settings()->getPortfolioStyle() : 0;
gst.hoverBg = WithAlpha(OnSurface(), 30); const int segClk = SegmentedControl(ImGui::GetWindowDrawList(), segOrigin, segW, segH,
gst.restBg = WithAlpha(OnSurface(), 20); styleItems, 3, curStyle, Type().caption(), "##pfStyleSeg", mktDp);
gst.bgRounding = 7.0f * mktDp; if (segClk >= 0 && segClk != curStyle && app->settings()) {
if (IconButton("##marketSettings", ICON_MD_SETTINGS, Type().iconSmall(), ImVec2(gearW, gearW), gst)) app->settings()->setPortfolioStyle(segClk);
s_show_market_settings = true; app->settings()->save();
ImGui::SameLine(0.0f, gGap); }
ImGui::SetCursorScreenPos(segOrigin);
ImGui::Dummy(ImVec2(segW, segH)); // reserve + restore layout flow
ImGui::SameLine(0.0f, sGap);
if (material::TactileButton(ml, ImVec2(mBtnW, 0))) { if (material::TactileButton(ml, ImVec2(mBtnW, 0))) {
// Open the editor on the first group visible to this wallet (or the empty state if none) — // 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. // never a raw index 0 that might belong to a different wallet.
@@ -2071,9 +2100,16 @@ static void mktDrawPortfolio(const MktCtx& cx)
} }
int style = app->settings() ? app->settings()->getPortfolioStyle() : 0; int style = app->settings() ? app->settings()->getPortfolioStyle() : 0;
float rowH = (style == 0 ? 40.0f : style == 1 ? 68.0f : 80.0f) * mktDp; // 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. // Table rows abut like a ledger (thin gap); Cards/Spotlight breathe.
float rowGap = (style == 0 ? 2.0f * mktDp : Layout::spacingSm()); float rowGap = pfRowGapFor(style, mktDp);
float rowsH = std::max(rowH, portfolioH - pfSummaryH); float rowsH = std::max(rowH, portfolioH - pfSummaryH);
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + pfSummaryH)); ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + pfSummaryH));
@@ -2108,6 +2144,8 @@ static void mktDrawPortfolio(const MktCtx& cx)
rdl->AddText(ovF, ovF->LegacySize, ImVec2(xr - w, ty), hcol, s); rdl->AddText(ovF, ovF->LegacySize, ImVec2(xr - w, ty), hcol, s);
}; };
rdl->AddText(ovF, ovF->LegacySize, ImVec2(hLeft, ty), hcol, TR("market_col_name")); 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.drgxR, DRAGONX_TICKER);
hdr(hc.valR, TR("market_col_value")); hdr(hc.valR, TR("market_col_value"));
hdr(hc.chgR, TR("market_24h")); hdr(hc.chgR, TR("market_24h"));
@@ -2124,7 +2162,7 @@ static void mktDrawPortfolio(const MktCtx& cx)
bool hov = ImGui::IsItemHovered(); bool hov = ImGui::IsItemHovered();
if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::PopID(); ImGui::PopID();
pfDrawRow(rdl, rMin, rMax, allEntries[i], state, market, style, mktDp, sub1, capFont, hov); pfDrawRow(rdl, rMin, rMax, allEntries[i], state, market, style, mktDp, sub1, capFont, hov, anySpark);
if (clicked) { PortfolioBeginEdit(app, i); s_pfEdit.open = true; } if (clicked) { PortfolioBeginEdit(app, i); s_pfEdit.open = true; }
if (vi + 1 < (int)vis.size()) ImGui::Dummy(ImVec2(rowW, rowGap)); if (vi + 1 < (int)vis.size()) ImGui::Dummy(ImVec2(rowW, rowGap));
} }
@@ -2138,87 +2176,6 @@ static void mktDrawPortfolio(const MktCtx& cx)
ImGui::Dummy(ImVec2(0, gap)); ImGui::Dummy(ImVec2(0, gap));
} }
// General market-tab options modal (opened by the PORTFOLIO-header gear). Mirrors the chat/contacts
// settings modals: a BlurFloat overlay of right-aligned segmented controls + a text-sized Done button.
// All controls write EXISTING settings (+ sync the live s_mkt mirror for chart range/style so the chart
// updates the same frame). Rendered outside the scroll child so it overlays the page.
static void RenderMarketSettings(App* app)
{
if (!s_show_market_settings || !app->settings()) return;
auto* st = app->settings();
const float dp = Layout::dpiScale();
OverlayDialogSpec ov;
ov.title = TR("market_settings_title");
ov.p_open = &s_show_market_settings;
ov.style = OverlayStyle::BlurFloat;
ov.cardWidth = 480.0f;
ov.idSuffix = "marketsettings";
if (BeginOverlayDialog(ov)) {
const float ctrlW = 280.0f * dp; // roomy enough for the multi-word segment labels
auto rowStart = [&](const char* label) {
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(label);
ImGui::SameLine();
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, ImGui::GetContentRegionAvail().x - ctrlW));
};
// A right-aligned segmented control on its own row; returns the clicked index (-1 if none).
auto seg = [&](const char* label, const char* const* items, int count, int cur, const char* id) -> int {
rowStart(label);
const float segH = ImGui::GetFrameHeight();
const ImVec2 o = ImGui::GetCursorScreenPos();
int clk = SegmentedControl(ImGui::GetWindowDrawList(), o, ctrlW, segH,
items, count, cur, Type().caption(), id, dp);
ImGui::SetCursorScreenPos(o);
ImGui::Dummy(ImVec2(ctrlW, segH)); // restore + reserve the control's rect for layout flow
return clk;
};
// Portfolio layout — surfaces the row style that was previously only Left/Right-arrow cyclable,
// and (unlike the arrow cycle) persists it.
{
const char* items[] = { TR("portfolio_style_compact"), TR("portfolio_style_detailed"),
TR("portfolio_style_featured") };
const int cur = st->getPortfolioStyle();
const int clk = seg(TR("portfolio_style_label"), items, 3, cur, "##mktPfStyle");
if (clk >= 0 && clk != cur) { st->setPortfolioStyle(clk); st->save(); }
}
// (Chart RANGE is deliberately not duplicated here — the Live/1H/1D/1W/1M toggle is always
// visible on the chart itself, and a 5-segment control can't hold long "Live" translations.)
// Chart style (candlesticks only render where per-exchange OHLC exists; the setting is still stored).
{
ImGui::Dummy(ImVec2(0.0f, 4.0f * dp));
const char* items[] = { TR("market_style_line_label"), TR("market_style_candle_label") };
const int cur = st->getChartStyle();
const int clk = seg(TR("market_opt_chart_style"), items, 2, cur, "##mktChartStyle");
if (clk >= 0 && clk != cur) { s_mkt.chartStyle = clk; st->setChartStyle(clk); st->save(); }
}
// Fetch live prices (master toggle, mirrors the general Settings page).
{
ImGui::Dummy(ImVec2(0.0f, 6.0f * dp));
bool v = st->getFetchPrices();
if (ImGui::Checkbox(TR("fetch_prices"), &v)) { st->setFetchPrices(v); st->save(); }
}
// Done — sized to its text (+ a little shoulder room) and centered, not full width.
ImGui::Dummy(ImVec2(0.0f, 8.0f * dp));
{
const char* doneLbl = TR("chat_settings_done");
ImGui::PushFont(Type().button());
const float bw = ImGui::CalcTextSize(doneLbl).x + ImGui::GetStyle().FramePadding.x * 2.0f + 28.0f * dp;
ImGui::PopFont();
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (ImGui::GetContentRegionAvail().x - bw) * 0.5f));
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(WithAlpha(Primary(), 205)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(Primary()));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(WithAlpha(Primary(), 235)));
if (material::TactileButton(doneLbl, ImVec2(bw, 0)))
s_show_market_settings = false;
ImGui::PopStyleColor(3);
}
EndOverlayDialog();
}
}
void RenderMarketTab(App* app) void RenderMarketTab(App* app)
{ {
auto& S = schema::UI(); auto& S = schema::UI();
@@ -2241,6 +2198,10 @@ void RenderMarketTab(App* app)
// Also kick the historical price-chart fetch (self-throttled) so the chart's hour/day/week/ // 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. // month intervals populate promptly when the user opens the Market tab.
app->refreshMarketChart(); 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); const auto& registry = EffectiveRegistry(market);
// Load persisted exchange/pair on first frame // Load persisted exchange/pair on first frame
@@ -2253,7 +2214,7 @@ void RenderMarketTab(App* app)
// Left/Right arrows: cycle portfolio row styles (compact / detailed / featured), mirroring the // 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 // Overview tab's layout switch. Skip while typing, when Ctrl is held (theme cycle), or while the
// portfolio editor / market-settings modal is open. // portfolio editor / market-settings modal is open.
if (app->settings() && !s_pfEdit.open && !s_show_market_settings && if (app->settings() && !s_pfEdit.open &&
!ImGui::GetIO().WantTextInput && !ImGui::GetIO().KeyCtrl) { !ImGui::GetIO().WantTextInput && !ImGui::GetIO().KeyCtrl) {
bool prev = ImGui::IsKeyPressed(ImGuiKey_LeftArrow); bool prev = ImGui::IsKeyPressed(ImGuiKey_LeftArrow);
bool next = ImGui::IsKeyPressed(ImGuiKey_RightArrow); bool next = ImGui::IsKeyPressed(ImGuiKey_RightArrow);
@@ -2319,8 +2280,8 @@ void RenderMarketTab(App* app)
for (const auto& e : pfEntriesGeo) for (const auto& e : pfEntriesGeo)
if (e.scope.empty() || (!pfActiveHash.empty() && e.scope == pfActiveHash)) pfVisN++; if (e.scope.empty() || (!pfActiveHash.empty() && e.scope == pfActiveHash)) pfVisN++;
int pfStyle = app->settings()->getPortfolioStyle(); int pfStyle = app->settings()->getPortfolioStyle();
float pfRowH = (pfStyle == 0 ? 40.0f : pfStyle == 1 ? 68.0f : 80.0f) * mktDp; float pfRowH = pfRowHeight(pfStyle, mktDp);
float pfRowGap = (pfStyle == 0 ? 2.0f * mktDp : Layout::spacingSm()); // Table rows abut float pfRowGap = pfRowGapFor(pfStyle, mktDp); // Table rows abut
float pfHeaderH = (pfStyle == 0) ? (capFont->LegacySize + 8.0f * mktDp) : 0.0f; // Table column strip float pfHeaderH = (pfStyle == 0) ? (capFont->LegacySize + 8.0f * mktDp) : 0.0f; // Table column strip
const int pfMaxVisibleRows = 4; // rows shown before the list scrolls 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 // Height for up to pfMaxVisibleRows rows. ItemSpacing is zeroed inside the row child, so the
@@ -2389,7 +2350,6 @@ void RenderMarketTab(App* app)
// Portfolio editor modal (rendered outside the scroll child so it overlays the page). It captures // 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. // + blurs the live content drawn above, so it must render after the tab body.
RenderPortfolioEditor(app); RenderPortfolioEditor(app);
RenderMarketSettings(app);
} }
} // namespace ui } // namespace ui

View File

@@ -1568,6 +1568,7 @@ void I18n::loadBuiltinEnglish()
strings_["market_24h"] = "24h"; strings_["market_24h"] = "24h";
strings_["market_col_name"] = "Name"; strings_["market_col_name"] = "Name";
strings_["market_col_value"] = "Value"; strings_["market_col_value"] = "Value";
strings_["market_col_trend"] = "Trend";
strings_["market_24h_volume"] = "24H VOLUME"; strings_["market_24h_volume"] = "24H VOLUME";
strings_["market_6h"] = "6h"; strings_["market_6h"] = "6h";
strings_["market_iv_live"] = "Live"; strings_["market_iv_live"] = "Live";