feat(market): stat-tile group cards + grid fills available width and height
Recommendation #1 + fill-to-fit: - Group cards are now stat tiles instead of top-heavy rows: a header (icon + name, with a 24h badge right), the value as a large hero centered in the middle band, a footer DRGX amount, and a dedicated sparkline strip along the bottom (soft fill under the line). Uses the taller square cells properly. - The grid now fills the available height too: cell height is computed at render time so the rows reach the bottom of the tab (no dead space), and the dot grid extends to the bottom edge while rearranging. Cell height = availableHeight / rows, so adding a row shrinks cells to keep filling. The hero font drops from h4 to subtitle1 on short cells so it always fits. Full-node + Lite build clean; ctest 1/1; hygiene clean. Interactive/visual — needs a screenshot + drag/resize check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -148,6 +148,28 @@ static void pfDrawSparkline(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
|
||||
dl->AddPolyline(pts.data(), n, col, ImDrawFlags_None, 1.2f);
|
||||
}
|
||||
|
||||
// Sparkline with a soft fill under the line — for the card's dedicated bottom strip.
|
||||
static void pfDrawSparklineFilled(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
|
||||
const std::vector<double>& hist, ImU32 lineCol)
|
||||
{
|
||||
int n = (int)hist.size();
|
||||
if (n < 2) return;
|
||||
double lo = *std::min_element(hist.begin(), hist.end());
|
||||
double hi = *std::max_element(hist.begin(), hist.end());
|
||||
if (hi <= lo) hi = lo + 1e-9;
|
||||
float w = mx.x - mn.x, h = mx.y - mn.y;
|
||||
std::vector<ImVec2> pts(n);
|
||||
for (int i = 0; i < n; i++) {
|
||||
float t = (n > 1) ? (float)i / (float)(n - 1) : 0.0f;
|
||||
pts[i] = ImVec2(mn.x + t * w, mx.y - (float)((hist[i] - lo) / (hi - lo)) * h);
|
||||
}
|
||||
for (int i = 0; i < n; i++) dl->PathLineTo(pts[i]);
|
||||
dl->PathLineTo(ImVec2(pts[n - 1].x, mx.y));
|
||||
dl->PathLineTo(ImVec2(pts[0].x, mx.y));
|
||||
dl->PathFillConcave(WithAlpha(lineCol, 28));
|
||||
dl->AddPolyline(pts.data(), n, WithAlpha(lineCol, 220), ImDrawFlags_None, 1.4f);
|
||||
}
|
||||
|
||||
// Dashboard-grid state for the portfolio cards (drag to move, corner-drag to resize).
|
||||
struct PfCell { int col, row, w, h; };
|
||||
static int s_pf_drag = -1; // card index being dragged (-1 = none)
|
||||
@@ -751,6 +773,7 @@ void RenderMarketTab(App* app)
|
||||
ImVec2 marketAvail = ImGui::GetContentRegionAvail();
|
||||
ImGui::BeginChild("##MarketScroll", marketAvail, false,
|
||||
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||||
ImVec2 mktOrigin = ImGui::GetCursorScreenPos(); // scroll-child content top (for filling the group grid)
|
||||
|
||||
// Responsive: scale factors per frame
|
||||
float availWidth = ImGui::GetContentRegionAvail().x;
|
||||
@@ -1384,6 +1407,13 @@ void RenderMarketTab(App* app)
|
||||
// A subtle dot grid shows while rearranging. Click (no drag) opens the editor. ----
|
||||
if (pfN > 0) {
|
||||
float gy = cardMin.y + pfSummaryH;
|
||||
// Grid fills the available width (cols span it) AND height: size the rows to reach the
|
||||
// bottom of the tab so there's no dead space and the dot grid extends to the edges.
|
||||
float groupBottom = mktOrigin.y + marketAvail.y;
|
||||
float availGroupH = std::max(pfMaxRow * (76.0f * mktDp), groupBottom - gy);
|
||||
if (pfMaxRow > 0)
|
||||
pfCellH = (availGroupH - (pfMaxRow - 1) * pfCardGap) / (float)pfMaxRow;
|
||||
portfolioH = pfSummaryH + availGroupH;
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
bool interacting = (s_pf_drag >= 0 || s_pf_resize >= 0);
|
||||
float rh = 14.0f * mktDp; // corner resize-grip size
|
||||
@@ -1396,68 +1426,95 @@ void RenderMarketTab(App* app)
|
||||
};
|
||||
|
||||
// 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.
|
||||
auto drawCard = [&](ImVec2 gcMin, ImVec2 gcMax, const config::Settings::PortfolioEntry& e, bool hov) {
|
||||
double bal = data::SumPortfolioBalance(e.addresses, state.addresses);
|
||||
ImU32 accent = e.color ? (ImU32)e.color : 0;
|
||||
float ch = gcMax.y - gcMin.y;
|
||||
float pad = pfCardPad;
|
||||
GlassPanelSpec gcGlass; gcGlass.rounding = 10.0f;
|
||||
gcGlass.fillAlpha = hov ? 34 : 22; gcGlass.borderAlpha = 40;
|
||||
DrawGlassPanel(dl, gcMin, gcMax, gcGlass);
|
||||
if (hov) dl->AddRect(gcMin, gcMax, WithAlpha(OnSurface(), 70), 10.0f, 0, 1.0f);
|
||||
if (accent) {
|
||||
float bx = gcMin.x + 2.0f;
|
||||
dl->AddRectFilled(ImVec2(bx, gcMin.y + pfCardPad * 0.6f),
|
||||
ImVec2(bx + 3.0f, gcMax.y - pfCardPad * 0.6f), accent, 2.0f);
|
||||
if (accent)
|
||||
dl->AddRectFilled(ImVec2(gcMin.x + 2.0f, gcMin.y + pad * 0.6f),
|
||||
ImVec2(gcMin.x + 5.0f, gcMax.y - pad * 0.6f), accent, 2.0f);
|
||||
|
||||
float left = gcMin.x + pad + (accent ? 6.0f : 0.0f);
|
||||
float right = gcMax.x - pad;
|
||||
float availW = right - left;
|
||||
auto fit = [&](std::string s, ImFont* f, float maxW) {
|
||||
bool t = false;
|
||||
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 valueStr; bool hasValue = false;
|
||||
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), "%.6f 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()); valueStr = vb; hasValue = true; }
|
||||
char db[48]; snprintf(db, sizeof(db), "%.4f %s", bal, DRAGONX_TICKER);
|
||||
bool heroIsValue = e.showValue && hasValue;
|
||||
std::string heroStr = heroIsValue ? valueStr : (e.showDrgx ? std::string(db) : (hasValue ? valueStr : std::string(db)));
|
||||
std::string footStr = (heroIsValue && e.showDrgx) ? std::string(db) : std::string();
|
||||
|
||||
// Header: icon + name (left), 24h badge (right).
|
||||
float hy = gcMin.y + pad, nameX = left, nameMaxW = availW;
|
||||
if (!e.icon.empty()) {
|
||||
material::project_icons::drawByName(dl, e.icon.c_str(),
|
||||
ImVec2(nameX + body2->LegacySize * 0.5f, hy + body2->LegacySize * 0.5f),
|
||||
accent ? accent : OnSurfaceMedium(), Type().iconSmall(), body2->LegacySize);
|
||||
nameX += body2->LegacySize + Layout::spacingSm();
|
||||
nameMaxW = right - nameX;
|
||||
}
|
||||
if (e.show24h && (e.priceBasis == 0 || e.priceBasis == 1) && market.change_24h != 0.0) {
|
||||
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(right - cw, hy + (body2->LegacySize - capFont->LegacySize) * 0.5f),
|
||||
market.change_24h >= 0 ? Success() : Error(), cb);
|
||||
nameMaxW -= cw + Layout::spacingSm();
|
||||
}
|
||||
dl->AddText(body2, body2->LegacySize, ImVec2(nameX, hy), OnSurfaceMedium(),
|
||||
fit(e.label, body2, nameMaxW).c_str());
|
||||
|
||||
// Sparkline strip along the bottom.
|
||||
float sparkH = 0.0f;
|
||||
if (e.showSparkline && (e.priceBasis == 0 || e.priceBasis == 1)) {
|
||||
std::vector<double> hh = pfResampleHistory(market.price_history, e.sparklineInterval);
|
||||
if (hh.size() >= 2) {
|
||||
ImU32 spCol = WithAlpha(hh.back() >= hh.front() ? Success() : Error(), 80);
|
||||
pfDrawSparkline(dl, ImVec2(gcMin.x + pfCardPad + (accent ? 6.0f : 0.0f), gcMin.y + ch * 0.46f),
|
||||
ImVec2(gcMax.x - pfCardPad, gcMax.y - pfCardPad * 0.6f), hh, spCol);
|
||||
sparkH = std::max(18.0f * mktDp, (gcMax.y - gcMin.y) * 0.24f);
|
||||
pfDrawSparklineFilled(dl, ImVec2(left, gcMax.y - pad - sparkH), ImVec2(right, gcMax.y - pad),
|
||||
hh, hh.back() >= hh.front() ? Success() : Error());
|
||||
}
|
||||
}
|
||||
float rowY = gcMin.y + pfCardPad, gcRight = gcMax.x - pfCardPad;
|
||||
PfDisplay disp = pfBuildDisplay(e, bal, market);
|
||||
float primaryW = disp.primary.empty() ? 0.0f
|
||||
: body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, disp.primary.c_str()).x;
|
||||
if (!disp.primary.empty())
|
||||
dl->AddText(body2, body2->LegacySize,
|
||||
ImVec2(gcRight - primaryW, rowY + (sub1->LegacySize - body2->LegacySize) * 0.5f),
|
||||
OnSurface(), disp.primary.c_str());
|
||||
{
|
||||
float belowY = rowY + sub1->LegacySize + Layout::spacingXs();
|
||||
float cursorR = gcRight;
|
||||
if (!disp.change.empty()) {
|
||||
float cw = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, disp.change.c_str()).x;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(gcRight - cw, belowY), disp.changeCol, disp.change.c_str());
|
||||
cursorR = gcRight - cw - Layout::spacingSm();
|
||||
}
|
||||
if (!disp.secondary.empty()) {
|
||||
float sw2 = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, disp.secondary.c_str()).x;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cursorR - sw2, belowY), OnSurfaceDisabled(), disp.secondary.c_str());
|
||||
}
|
||||
|
||||
// Footer: DRGX amount just above the sparkline.
|
||||
float footBottom = gcMax.y - pad - (sparkH > 0 ? sparkH + Layout::spacingXs() : 0.0f);
|
||||
float footTop = footBottom;
|
||||
if (!footStr.empty()) {
|
||||
footTop = footBottom - capFont->LegacySize;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(left, footTop),
|
||||
OnSurfaceDisabled(), fit(footStr, capFont, availW).c_str());
|
||||
}
|
||||
float nameX = gcMin.x + pfCardPad + (accent ? 6.0f : 0.0f);
|
||||
if (!e.icon.empty()) {
|
||||
material::project_icons::drawByName(dl, e.icon.c_str(),
|
||||
ImVec2(nameX + sub1->LegacySize * 0.5f, rowY + sub1->LegacySize * 0.5f),
|
||||
accent ? accent : OnSurface(), Type().iconSmall(), sub1->LegacySize);
|
||||
nameX += sub1->LegacySize + Layout::spacingSm();
|
||||
|
||||
// Hero value: large, centered in the band between the header and footer.
|
||||
if (!heroStr.empty()) {
|
||||
ImFont* heroFont = ((gcMax.y - gcMin.y) > 120.0f * mktDp) ? h4 : sub1;
|
||||
float bandTop = hy + body2->LegacySize + Layout::spacingSm();
|
||||
float bandBot = (footStr.empty() ? footBottom : footTop) - Layout::spacingXs();
|
||||
float heroY = std::max(bandTop, (bandTop + bandBot) * 0.5f - heroFont->LegacySize * 0.5f);
|
||||
dl->AddText(heroFont, heroFont->LegacySize, ImVec2(left, heroY),
|
||||
OnSurface(), fit(heroStr, heroFont, availW).c_str());
|
||||
}
|
||||
float nameMaxW = (gcRight - primaryW - Layout::spacingMd()) - nameX;
|
||||
std::string label = e.label; bool tr2 = false;
|
||||
while (label.size() > 1 && sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, label.c_str()).x > nameMaxW) {
|
||||
label.pop_back(); tr2 = true;
|
||||
}
|
||||
if (tr2) label += "\xE2\x80\xA6";
|
||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(nameX, rowY), OnSurface(), label.c_str());
|
||||
};
|
||||
|
||||
// Subtle dot grid while rearranging.
|
||||
if (interacting) {
|
||||
ImU32 dotc = WithAlpha(OnSurface(), 55);
|
||||
for (int r = 0; r <= pfMaxRow + 1; r++)
|
||||
for (int r = 0; r <= pfMaxRow; r++)
|
||||
for (int c = 0; c <= pfCols; c++)
|
||||
dl->AddCircleFilled(ImVec2(cx + c * (pfCellW + pfCardGap) - pfCardGap * 0.5f,
|
||||
gy + r * (pfCellH + pfCardGap) - pfCardGap * 0.5f),
|
||||
|
||||
Reference in New Issue
Block a user