feat(market): rework tab layout — responsive chart + refined portfolio + group cards

The Market tab is non-scrolling, but an oversized fixed-height chart ate ~65%
of the height and squeezed the portfolio into a cramped strip, with custom
groups rendered as tiny afterthought rows at the very bottom.

- Precompute section geometry (hero header + portfolio card height) up front and
  let the price chart ABSORB the remaining vertical space, capped at its schema
  height and floored at 130px*vs so it stays useful. The chart now shrinks to
  make room instead of dominating. Replaces the barely-used SectionBudget.
- Portfolio summary refined: fiat value stays the hero with the 24h change beside
  it and the BTC value right-aligned; DRGX balance + Z/T breakdown on the next
  row; a full-width shielded/transparent ratio bar + % label.
- Custom groups now render as a wrapping grid of mini glass cards (label + DRGX +
  fiat each), sized to the card width, instead of a cramped one-line list. A
  "GROUPS" subheader separates them from the all-funds summary. The card grows to
  fit the grid.

New i18n key portfolio_groups ("GROUPS"). Full-node + Lite build clean; ctest
1/1; hygiene clean. Rendering change — needs a screenshot check of the Market tab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 22:12:11 -05:00
parent 3e1cdd15f3
commit 67feaea767
2 changed files with 120 additions and 70 deletions

View File

@@ -276,16 +276,52 @@ void RenderMarketTab(App* app)
char buf[128];
// ================================================================
// Proportional section budget — all content fits without scrolling
// Section geometry — this tab does NOT scroll, so everything must fit. Precompute the
// portfolio card height (refined summary + a grid of group cards) and the hero header,
// then let the price chart absorb the remaining vertical space (capped at its schema
// height, floored so it stays useful). Fixes the old layout where an oversized chart
// squeezed the portfolio into a cramped strip.
// ================================================================
float mkSHdr = ovFont->LegacySize + Layout::spacingXs()
+ ImGui::GetStyle().ItemSpacing.y * 2.0f;
float mkGapOver = gap + ImGui::GetStyle().ItemSpacing.y;
float mkOverhead = 3.0f * (mkSHdr + mkGapOver) + 2.0f * mkGapOver;
float mkCardBudget = std::max(200.0f, marketAvail.y - mkOverhead);
float mktDp = Layout::dpiScale();
Layout::SectionBudget mb(mkCardBudget);
float portfolioBudgetH = mb.allocate(0.18f, 50.0f);
// -- Portfolio card geometry --
const auto& pfEntriesGeo = app->settings()->getPortfolioEntries();
float pfInnerW = availWidth - Layout::spacingLg() * 2.0f;
float ratioBarH = std::max(S.drawElement("tabs.market", "ratio-bar-min-height").size,
S.drawElement("tabs.market", "ratio-bar-height").size * vs);
float pfSummaryH = Layout::spacingLg()
+ sub1->LegacySize + Layout::spacingSm() // fiat hero row
+ body2->LegacySize + Layout::spacingSm() // DRGX balance row
+ ratioBarH + Layout::spacingXs() + capFont->LegacySize // ratio bar + % label
+ Layout::spacingMd(); // bottom pad
int pfN = (int)pfEntriesGeo.size();
float pfCardGap = Layout::spacingSm();
float pfMinCardW = 200.0f * mktDp;
int pfCardsPerRow = std::max(1, (int)((pfInnerW + pfCardGap) / (pfMinCardW + pfCardGap)));
if (pfN > 0 && pfCardsPerRow > pfN) pfCardsPerRow = pfN;
float pfCardW = (pfN > 0) ? (pfInnerW - (pfCardsPerRow - 1) * pfCardGap) / (float)pfCardsPerRow : 0.0f;
float pfCardH = body2->LegacySize + Layout::spacingXs() + body2->LegacySize
+ capFont->LegacySize + Layout::spacingSm() * 2.0f;
int pfRows = (pfN > 0) ? (pfN + pfCardsPerRow - 1) / pfCardsPerRow : 0;
float pfGroupsH = (pfN > 0)
? (ovFont->LegacySize + Layout::spacingSm()
+ pfRows * pfCardH + std::max(0, pfRows - 1) * pfCardGap
+ Layout::spacingMd())
: 0.0f;
float portfolioCardH = pfSummaryH + pfGroupsH;
// -- Hero header (price + stats, excluding the chart) --
float mktStatsRowH = ovFont->LegacySize + Layout::spacingXs() + sub1->LegacySize + pad;
float heroHeaderH = std::max(
S.drawElement("tabs.market", "hero-card-min-height").size + mktStatsRowH + pad,
(S.drawElement("tabs.market", "hero-card-height").size + mktStatsRowH + pad) * vs);
// -- Chart absorbs the slack: capped at its schema height, floored so it stays useful --
float mktChipH = S.drawElement("tabs.market", "pair-chip-height").height;
float mktPairsReserve = mktChipH * 2.0f + Layout::spacingXl() + capFont->LegacySize;
float chartH = std::min(chartElem.height * vs,
marketAvail.y - heroHeaderH - portfolioCardH - mktPairsReserve - gap * 3.0f);
chartH = std::max(chartH, 130.0f * vs);
// ================================================================
// PRICE SUMMARY — Combined hero card with price, stats, and exchange
@@ -293,13 +329,11 @@ void RenderMarketTab(App* app)
{
float dp = Layout::dpiScale();
ImVec2 cardMin = ImGui::GetCursorScreenPos();
float heroMinH = S.drawElement("tabs.market", "hero-card-min-height").size;
float statsRowH = ovFont->LegacySize + Layout::spacingXs() + sub1->LegacySize + pad;
float cardH = std::max(heroMinH + statsRowH + pad, (S.drawElement("tabs.market", "hero-card-height").size + statsRowH + pad) * vs);
float cardH = heroHeaderH;
ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + cardH);
// Combined hero + chart: draw ONE glass panel spanning the price/stats header AND the
// chart below it. The chart block no longer draws its own panel or an inter-card gap.
float mergedChartH = std::max(60.0f, chartElem.height * vs);
// chart below it (chartH precomputed so the chart shrinks to fit the portfolio).
float mergedChartH = chartH;
ImVec2 mergedMax(cardMin.x + availWidth, cardMax.y + mergedChartH);
DrawGlassPanel(dl, cardMin, mergedMax, glassSpec);
@@ -448,9 +482,9 @@ void RenderMarketTab(App* app)
s_price_history = market.price_history;
s_history_initialized = true;
// Chart height from schema. No glass panel here — it shares the combined hero+chart
// card's panel drawn in PRICE SUMMARY above (mergedChartH uses this same height).
float chartH = std::max(60.0f, chartElem.height * vs);
// Chart height precomputed above (responsive — the chart absorbs the vertical slack
// left by the hero header + portfolio card). No glass panel here — it shares the
// combined hero+chart card's panel drawn in PRICE SUMMARY above.
ImVec2 chartMin = ImGui::GetCursorScreenPos();
ImVec2 chartMax(chartMin.x + availWidth, chartMin.y + chartH);
@@ -741,111 +775,126 @@ void RenderMarketTab(App* app)
double private_balance = state.privateBalance;
double transparent_balance = state.transparentBalance;
// Custom portfolio entries render below the "all funds" summary; grow the card to fit.
const auto& pfEntries = app->settings()->getPortfolioEntries();
float entryRowH = body2->LegacySize + Layout::spacingSm();
float entriesBlockH = pfEntries.empty()
? 0.0f
: (Layout::spacingSm() * 2.0f + (float)pfEntries.size() * entryRowH);
ImVec2 cardMin = ImGui::GetCursorScreenPos();
float baseH = std::max(60.0f, portfolioBudgetH);
float cardH = baseH + entriesBlockH;
float cardH = portfolioCardH; // precomputed: summary block + group-card grid
ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + cardH);
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
float cx = cardMin.x + Layout::spacingLg();
float cy = cardMin.y + Layout::spacingLg();
// ---- SUMMARY: fiat value (hero) + 24h change, BTC right-aligned ----
if (market.price_usd > 0) {
double portfolio_usd = total_balance * market.price_usd;
if (portfolio_usd >= 1.0)
snprintf(buf, sizeof(buf), "$%.2f USD", portfolio_usd);
else
snprintf(buf, sizeof(buf), "$%.6f USD", portfolio_usd);
if (portfolio_usd >= 1.0) snprintf(buf, sizeof(buf), "$%.2f USD", portfolio_usd);
else snprintf(buf, sizeof(buf), "$%.6f USD", portfolio_usd);
DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(cx, cy), Success(), buf);
float usdW = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, buf).x;
// 24h change on the holdings (the coin's 24h % applies to the value), shown
// beside the USD figure and colored by direction.
if (market.change_24h != 0.0) {
float usdW = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, buf).x;
char chg[48];
snprintf(chg, sizeof(chg), "%+.2f%% %s", market.change_24h, TR("market_24h_change"));
ImU32 chgCol = market.change_24h >= 0 ? Success() : Error();
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cx + usdW + Layout::spacingMd(), cy + 3), chgCol, chg);
ImVec2(cx + usdW + Layout::spacingMd(),
cy + (sub1->LegacySize - capFont->LegacySize)),
chgCol, chg);
}
double portfolio_btc = total_balance * market.price_btc;
snprintf(buf, sizeof(buf), "%.10f BTC", portfolio_btc);
float valW = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, buf).x;
snprintf(buf, sizeof(buf), "\xE2\x89\x88 %.8f BTC", portfolio_btc); // "≈ <n> BTC"
float btcW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf).x;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cardMax.x - valW - pad, cy + 2), OnSurfaceMedium(), buf);
ImVec2(cardMax.x - btcW - pad, cy + (sub1->LegacySize - capFont->LegacySize)),
OnSurfaceMedium(), buf);
} else {
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, cy), OnSurfaceDisabled(), TR("market_no_price"));
}
cy += sub1->LegacySize + Layout::spacingSm();
cy += sub1->LegacySize + 8;
// DRGX balance + Z/T breakdown (right-aligned).
snprintf(buf, sizeof(buf), "%.8f %s", total_balance, DRAGONX_TICKER);
dl->AddText(body2, body2->LegacySize, ImVec2(cx, cy), OnSurface(), buf);
snprintf(buf, sizeof(buf), "Z: %.4f | T: %.4f", private_balance, transparent_balance);
snprintf(buf, sizeof(buf), "Z %.4f \xC2\xB7 T %.4f", private_balance, transparent_balance);
float brkW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf).x;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cardMax.x - brkW - pad, cy + 2), OnSurfaceDisabled(), buf);
cy += body2->LegacySize + Layout::spacingSm();
cy += body2->LegacySize + 8;
// Full-width shielded/transparent ratio bar + % label.
if (total_balance > 0) {
float barW = availWidth - Layout::spacingXxl() * 1.5f;
float barH = std::max(S.drawElement("tabs.market", "ratio-bar-min-height").size, S.drawElement("tabs.market", "ratio-bar-height").size * vs);
float barW = pfInnerW;
float shieldedRatio = (float)(private_balance / total_balance);
if (shieldedRatio > 1.0f) shieldedRatio = 1.0f;
if (shieldedRatio < 0.0f) shieldedRatio = 0.0f;
float shieldedW = barW * shieldedRatio;
float transpW = barW - shieldedW;
ImVec2 barStart(cx, cy);
dl->AddRectFilled(barStart, ImVec2(barStart.x + barW, barStart.y + barH),
dl->AddRectFilled(barStart, ImVec2(barStart.x + barW, barStart.y + ratioBarH),
IM_COL32(255, 255, 255, 10), 3.0f);
if (shieldedW > 0.5f)
dl->AddRectFilled(barStart, ImVec2(barStart.x + shieldedW, barStart.y + barH),
dl->AddRectFilled(barStart, ImVec2(barStart.x + shieldedW, barStart.y + ratioBarH),
WithAlpha(Success(), 200),
transpW > 0.5f ? ImDrawFlags_RoundCornersLeft : ImDrawFlags_RoundCornersAll, 3.0f);
if (transpW > 0.5f)
dl->AddRectFilled(ImVec2(barStart.x + shieldedW, barStart.y),
ImVec2(barStart.x + barW, barStart.y + barH),
ImVec2(barStart.x + barW, barStart.y + ratioBarH),
WithAlpha(Warning(), 200),
shieldedW > 0.5f ? ImDrawFlags_RoundCornersRight : ImDrawFlags_RoundCornersAll, 3.0f);
// market_pct_shielded is "%.0f%% Shielded" — pass a double, not an int (passing
// an int to a %f conversion is UB and renders garbage on x86-64).
snprintf(buf, sizeof(buf), TR("market_pct_shielded"),
static_cast<double>(shieldedRatio) * 100.0);
// market_pct_shielded is "%.0f%% Shielded" — pass a double (an int to %f is UB).
snprintf(buf, sizeof(buf), TR("market_pct_shielded"), static_cast<double>(shieldedRatio) * 100.0);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cx, cy + barH + 2), OnSurfaceDisabled(), buf);
ImVec2(cx, cy + ratioBarH + Layout::spacingXs()), OnSurfaceDisabled(), buf);
}
// ---- CUSTOM PORTFOLIO ENTRIES (below the all-funds summary) ----
if (!pfEntries.empty()) {
float ey = cardMin.y + baseH - Layout::spacingSm();
dl->AddLine(ImVec2(cardMin.x + glassSpec.rounding * 0.5f, ey),
ImVec2(cardMax.x - glassSpec.rounding * 0.5f, ey), WithAlpha(OnSurface(), 20), 1.0f);
ey += Layout::spacingSm();
for (const auto& e : pfEntries) {
// ---- CUSTOM GROUPS — one glass card per entry, wrapping into a grid ----
if (pfN > 0) {
float gy = cardMin.y + pfSummaryH;
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(cx, gy), OnSurfaceMedium(), TR("portfolio_groups"));
gy += ovFont->LegacySize + Layout::spacingSm();
for (int i = 0; i < pfN; i++) {
const auto& e = pfEntriesGeo[i];
int col = i % pfCardsPerRow;
int row = i / pfCardsPerRow;
ImVec2 gcMin(cx + col * (pfCardW + pfCardGap), gy + row * (pfCardH + pfCardGap));
ImVec2 gcMax(gcMin.x + pfCardW, gcMin.y + pfCardH);
GlassPanelSpec gcGlass;
gcGlass.rounding = 8.0f;
gcGlass.fillAlpha = 18;
gcGlass.borderAlpha = 30;
DrawGlassPanel(dl, gcMin, gcMax, gcGlass);
double bal = data::SumPortfolioBalance(e.addresses, state.addresses);
dl->AddText(body2, body2->LegacySize, ImVec2(cx, ey), OnSurface(), e.label.c_str());
char amt[96];
if (market.price_usd > 0)
snprintf(amt, sizeof(amt), "%.4f %s ($%.2f)", bal, DRAGONX_TICKER, bal * market.price_usd);
else
snprintf(amt, sizeof(amt), "%.4f %s", bal, DRAGONX_TICKER);
float amtW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, amt).x;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cardMax.x - amtW - pad, ey + 1), OnSurfaceMedium(), amt);
ey += entryRowH;
float tcx = gcMin.x + Layout::spacingSm();
float tcy = gcMin.y + Layout::spacingSm();
float maxTextW = pfCardW - Layout::spacingSm() * 2.0f;
// Label (truncated with an ellipsis to the card width).
std::string label = e.label;
bool truncated = false;
while (label.size() > 1 &&
body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, label.c_str()).x > maxTextW) {
label.pop_back();
truncated = true;
}
if (truncated) label += "\xE2\x80\xA6";
dl->AddText(body2, body2->LegacySize, ImVec2(tcx, tcy), OnSurfaceMedium(), label.c_str());
tcy += body2->LegacySize + Layout::spacingXs();
// DRGX amount.
snprintf(buf, sizeof(buf), "%.4f %s", bal, DRAGONX_TICKER);
dl->AddText(body2, body2->LegacySize, ImVec2(tcx, tcy), OnSurface(), buf);
tcy += body2->LegacySize;
// Fiat value.
if (market.price_usd > 0) {
snprintf(buf, sizeof(buf), "$%.2f", bal * market.price_usd);
dl->AddText(capFont, capFont->LegacySize, ImVec2(tcx, tcy), OnSurfaceDisabled(), buf);
}
}
}

View File

@@ -1119,6 +1119,7 @@ void I18n::loadBuiltinEnglish()
strings_["market_pct_shielded"] = "%.0f%% Shielded";
strings_["market_portfolio"] = "MY DRGX";
strings_["portfolio_all_funds"] = "All funds";
strings_["portfolio_groups"] = "GROUPS";
strings_["portfolio_manage"] = "Manage\xE2\x80\xA6";
strings_["portfolio_manage_title"] = "Manage portfolio";
strings_["portfolio_add_entry"] = "Add entry";