feat(market): portfolio groups as a draggable/resizable dashboard grid

Turn the portfolio group area into a dashboard grid:
- Responsive column count (1-4 by width); each 1x1 cell is one card.
- Drag a card to move it to another cell; drag its bottom-right corner to
  resize (span multiple cells). Both snap to the grid.
- A subtle dot grid appears while rearranging (dragging or resizing), with a
  highlighted drop-target / resize footprint.
- Click (no drag past a 4px threshold) still opens the group editor.
- Grid placement (col/row) and span (w/h) persist per group in settings.json;
  positions are resolved each frame — stored placement when it fits, else
  auto-placed row-major into free cells. Dropping onto an occupied cell swaps.

PortfolioEntry gains grid_col/grid_row/grid_w/grid_h. The card render was
refactored into a drawCard() lambda reused for the in-grid, dragged, and
resizing states.

Full-node + Lite build clean; ctest 1/1; hygiene clean. This is an interactive
feature I can't verify without the GUI — needs drag/resize testing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 01:54:20 -05:00
parent b26aff392b
commit 77219c5ce7
3 changed files with 219 additions and 63 deletions

View File

@@ -332,6 +332,14 @@ bool Settings::load(const std::string& path)
entry.show24h = e["show_24h"].get<bool>();
if (e.contains("show_sparkline") && e["show_sparkline"].is_boolean())
entry.showSparkline = e["show_sparkline"].get<bool>();
if (e.contains("grid_col") && e["grid_col"].is_number_integer())
entry.gridCol = e["grid_col"].get<int>();
if (e.contains("grid_row") && e["grid_row"].is_number_integer())
entry.gridRow = e["grid_row"].get<int>();
if (e.contains("grid_w") && e["grid_w"].is_number_integer())
entry.gridW = e["grid_w"].get<int>();
if (e.contains("grid_h") && e["grid_h"].is_number_integer())
entry.gridH = e["grid_h"].get<int>();
if (!entry.label.empty()) portfolio_entries_.push_back(std::move(entry));
}
}
@@ -490,6 +498,10 @@ bool Settings::save(const std::string& path)
entry["show_value"] = e.showValue;
entry["show_24h"] = e.show24h;
entry["show_sparkline"] = e.showSparkline;
entry["grid_col"] = e.gridCol;
entry["grid_row"] = e.gridRow;
entry["grid_w"] = e.gridW;
entry["grid_h"] = e.gridH;
j["portfolio_entries"].push_back(std::move(entry));
}
j["font_scale"] = font_scale_;

View File

@@ -90,6 +90,12 @@ public:
bool showValue = true; // show the converted/fiat value on the card
bool show24h = false; // show the 24h % change (live-market bases only)
bool showSparkline = false; // show a price-trend sparkline (live-market bases only)
// Dashboard grid placement on the Market portfolio. col/row in grid cells (-1 = auto-place),
// w/h in cells (span). Set when the user drags/resizes a card.
int gridCol = -1;
int gridRow = -1;
int gridW = 1;
int gridH = 1;
};
// Theme

View File

@@ -131,6 +131,16 @@ static void pfDrawSparkline(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
dl->AddPolyline(pts.data(), n, col, ImDrawFlags_None, 1.2f);
}
// 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)
static int s_pf_resize = -1; // card index being resized
static bool s_pf_moved = false; // drag passed the click threshold this gesture
static ImVec2 s_pf_press; // mouse pos at grab
static ImVec2 s_pf_grab; // mouse offset within the card at grab
static PfCell s_pf_old = {0,0,1,1};// dragged card's cell at grab (for swap-on-drop)
static PfCell s_pf_target = {0,0,1,1}; // live drop target cell
// Build the right-hand amount strings for a group card / preview from its price-data config.
struct PfDisplay {
std::string primary; // name-row, top-right (DRGX amount or the value)
@@ -757,15 +767,47 @@ void RenderMarketTab(App* app)
int pfN = (int)pfEntriesGeo.size();
float pfCardGap = Layout::spacingSm();
float pfCardPad = Layout::spacingMd(); // inner padding of each group card
float pfCardW = std::min(280.0f * mktDp, availWidth); // max card width (shrinks on a narrow tab)
int pfCardsPerRow = std::max(1, (int)((availWidth + pfCardGap) / (pfCardW + pfCardGap)));
if (pfN > 0 && pfCardsPerRow > pfN) pfCardsPerRow = pfN;
// Card: a name row (sub1) + a fiat row (caption); the DRGX/fiat amounts sit top-right.
float pfCardH = pfCardPad * 2.0f + sub1->LegacySize + Layout::spacingXs() + capFont->LegacySize;
int pfRows = (pfN > 0) ? (pfN + pfCardsPerRow - 1) / pfCardsPerRow : 0;
float pfGroupsH = (pfN > 0)
? (pfRows * pfCardH + std::max(0, pfRows - 1) * pfCardGap)
: 0.0f;
// Dashboard grid: responsive column count; a 1x1 cell is one card. Cards can be dragged to a
// new cell and corner-resized to span multiple cells.
int pfCols = std::max(1, std::min(4, (int)(availWidth / (240.0f * mktDp) + 0.5f)));
float pfCellW = (availWidth - (pfCols - 1) * pfCardGap) / (float)pfCols;
// A 1x1 cell: name row (sub1) + a fiat row (caption) + padding; the amounts sit top-right.
float pfCellH = pfCardPad * 2.0f + sub1->LegacySize + Layout::spacingXs() + capFont->LegacySize;
// Resolve each group's grid cell: honor a stored placement when it fits, else auto-place
// row-major into the first free cells. Deterministic each frame.
std::vector<PfCell> pfLayout((size_t)std::max(0, pfN));
{
std::vector<std::vector<char>> occ;
auto ensure = [&](int rEnd) { while ((int)occ.size() < rEnd) occ.emplace_back(pfCols, 0); };
auto fits = [&](int c, int r, int w, int h) -> bool {
if (c < 0 || r < 0 || c + w > pfCols) 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)pfN, 0);
for (int i = 0; i < pfN; i++) {
const auto& e = pfEntriesGeo[i];
int w = std::max(1, std::min(e.gridW, pfCols)), h = std::max(1, e.gridH);
if (e.gridCol >= 0 && e.gridRow >= 0 && fits(e.gridCol, e.gridRow, w, h)) {
pfLayout[i] = {e.gridCol, e.gridRow, w, h}; mark(e.gridCol, e.gridRow, w, h); placed[i] = 1;
}
}
for (int i = 0; i < pfN; i++) {
if (placed[i]) continue;
const auto& e = pfEntriesGeo[i];
int w = std::max(1, std::min(e.gridW, pfCols)), h = std::max(1, e.gridH);
int fc = 0, fr = 0; bool found = false;
for (int r = 0; !found && r < 4096; r++)
for (int c = 0; c + w <= pfCols; c++)
if (fits(c, r, w, h)) { fc = c; fr = r; found = true; break; }
pfLayout[i] = {fc, fr, w, h}; mark(fc, fr, w, h);
}
}
int pfMaxRow = 0; for (const auto& L : pfLayout) pfMaxRow = std::max(pfMaxRow, L.row + L.h);
float pfGroupsH = (pfN > 0) ? (pfMaxRow * (pfCellH + pfCardGap) - pfCardGap) : 0.0f;
float portfolioH = pfSummaryH + pfGroupsH;
// ================================================================
@@ -1307,48 +1349,43 @@ void RenderMarketTab(App* app)
ImVec2(cx, cy + ratioBarH + Layout::spacingXs()), OnSurfaceDisabled(), buf);
}
// ---- CUSTOM GROUPS — floating glass cards below the summary (no heading) ----
// ---- CUSTOM GROUPS — dashboard grid: drag a card to move it, drag its corner to resize.
// A subtle dot grid shows while rearranging. Click (no drag) opens the editor. ----
if (pfN > 0) {
float gy = cardMin.y + pfSummaryH;
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);
ImU32 accent = e.color ? (ImU32)e.color : 0;
bool hov = material::IsRectHovered(gcMin, gcMax);
ImGuiIO& io = ImGui::GetIO();
bool interacting = (s_pf_drag >= 0 || s_pf_resize >= 0);
float rh = 14.0f * mktDp; // corner resize-grip size
GlassPanelSpec gcGlass;
gcGlass.rounding = 10.0f;
gcGlass.fillAlpha = hov ? 34 : 22;
gcGlass.borderAlpha = 40;
auto cellMin = [&](const PfCell& L) {
return ImVec2(cx + 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].
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;
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);
// Left accent bar in the group's color (if one is set).
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);
}
// Price-trend sparkline (faint, in the lower band, behind the text) for
// live-market groups. The group's value tracks the DRGX price, so plot its history.
if (e.showSparkline && (e.priceBasis == 0 || e.priceBasis == 1) &&
market.price_history.size() >= 2) {
const auto& h = market.price_history;
ImU32 spCol = WithAlpha(h.back() >= h.front() ? Success() : Error(), 80);
ImVec2 spMin(gcMin.x + pfCardPad + (accent ? 6.0f : 0.0f), gcMin.y + pfCardH * 0.46f);
ImVec2 spMax(gcMax.x - pfCardPad, gcMax.y - pfCardPad * 0.6f);
pfDrawSparkline(dl, spMin, spMax, h, spCol);
const auto& hh = market.price_history;
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);
}
double bal = data::SumPortfolioBalance(e.addresses, state.addresses);
float rowY = gcMin.y + pfCardPad;
float gcRight = gcMax.x - pfCardPad;
// Amounts, top-right corner, per the group's price-data config.
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;
@@ -1357,7 +1394,6 @@ void RenderMarketTab(App* app)
ImVec2(gcRight - primaryW, rowY + (sub1->LegacySize - body2->LegacySize) * 0.5f),
OnSurface(), disp.primary.c_str());
{
// Second line: the value (when DRGX is primary) and/or the 24h change.
float belowY = rowY + sub1->LegacySize + Layout::spacingXs();
float cursorR = gcRight;
if (!disp.change.empty()) {
@@ -1370,37 +1406,139 @@ void RenderMarketTab(App* app)
dl->AddText(capFont, capFont->LegacySize, ImVec2(cursorR - sw2, belowY), OnSurfaceDisabled(), disp.secondary.c_str());
}
}
// Icon (left) then the group name.
float nameX = gcMin.x + pfCardPad + (accent ? 6.0f : 0.0f);
if (!e.icon.empty()) {
ImFont* gIcon = Type().iconSmall();
float gIconSz = sub1->LegacySize;
material::project_icons::drawByName(
dl, e.icon.c_str(), ImVec2(nameX + gIconSz * 0.5f, rowY + sub1->LegacySize * 0.5f),
accent ? accent : OnSurface(), gIcon, gIconSz);
nameX += gIconSz + Layout::spacingSm();
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();
}
// Group name, larger — truncated so it never overlaps the amount.
float nameMaxW = (gcRight - primaryW - Layout::spacingMd()) - nameX;
std::string label = e.label;
bool truncated = false;
while (label.size() > 1 &&
sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, label.c_str()).x > nameMaxW) {
label.pop_back();
truncated = true;
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 (truncated) label += "\xE2\x80\xA6";
if (tr2) label += "\xE2\x80\xA6";
dl->AddText(sub1, sub1->LegacySize, ImVec2(nameX, rowY), OnSurface(), label.c_str());
};
// Whole card is clickable → open the editor for this group.
ImGui::PushID(3000 + i);
ImGui::SetCursorScreenPos(gcMin);
ImGui::InvisibleButton("##pfCard", ImVec2(pfCardW, pfCardH));
if (ImGui::IsItemClicked()) { PortfolioBeginEdit(app, i); s_portfolio_editor_open = true; }
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::PopID();
// Subtle dot grid while rearranging.
if (interacting) {
ImU32 dotc = WithAlpha(OnSurface(), 55);
for (int r = 0; r <= pfMaxRow + 1; 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),
1.5f * 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_pf_drag && s_pf_moved) {
dl->AddRect(gcMin, gcMax, WithAlpha(OnSurface(), 45), 10.0f, 0, 1.0f); // source ghost
continue;
}
if (i == s_pf_resize) continue;
bool hov = !interacting && material::IsRectHovered(gcMin, gcMax);
drawCard(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_pf_drag < 0 && s_pf_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_pf_resize = i; s_pf_old = pfLayout[i]; }
else {
s_pf_drag = i; s_pf_moved = false; s_pf_old = pfLayout[i];
s_pf_press = io.MousePos;
s_pf_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_pf_resize >= 0 && s_pf_resize < pfN) {
int i = s_pf_resize;
ImVec2 mn = cellMin(pfLayout[i]);
int nw = std::max(1, std::min(pfCols - pfLayout[i].col,
(int)((io.MousePos.x - mn.x + pfCardGap) / (pfCellW + pfCardGap) + 0.5f)));
int nh = std::max(1, (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);
drawCard(mn, mx, pfEntriesGeo[i], true);
dl->AddRect(mn, mx, WithAlpha(Primary(), 200), 10.0f, 0, 2.0f * mktDp);
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_pf_resize = -1;
}
}
// ----- Drag in progress -----
if (s_pf_drag >= 0 && s_pf_drag < pfN) {
int i = s_pf_drag;
if (io.MouseDown[0]) {
if (std::abs(io.MousePos.x - s_pf_press.x) > 4.0f || std::abs(io.MousePos.y - s_pf_press.y) > 4.0f)
s_pf_moved = true;
if (s_pf_moved) {
int w = pfLayout[i].w, h = pfLayout[i].h;
float tx = io.MousePos.x - s_pf_grab.x, ty = io.MousePos.y - s_pf_grab.y;
int tc = std::max(0, std::min(pfCols - w, (int)((tx - cx) / (pfCellW + pfCardGap) + 0.5f)));
int tr = std::max(0, (int)((ty - gy) / (pfCellH + pfCardGap) + 0.5f));
s_pf_target = {tc, tr, w, h};
ImVec2 tmn(cx + tc * (pfCellW + pfCardGap), gy + tr * (pfCellH + pfCardGap));
ImVec2 tsz = cellSize(w, h), tmx(tmn.x + tsz.x, tmn.y + tsz.y);
dl->AddRectFilled(tmn, tmx, WithAlpha(Primary(), 25), 10.0f);
dl->AddRect(tmn, tmx, WithAlpha(Primary(), 200), 10.0f, 0, 2.0f * mktDp);
ImVec2 fmn(tx, ty), fmx(fmn.x + tsz.x, fmn.y + tsz.y);
drawCard(fmn, fmx, pfEntriesGeo[i], true); // dragged card follows the mouse
ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeAll);
}
} else {
if (!s_pf_moved) { PortfolioBeginEdit(app, i); s_portfolio_editor_open = true; }
else {
auto entries = app->settings()->getPortfolioEntries();
if (i < (int)entries.size()) {
int occ = -1;
for (int j = 0; j < pfN; j++)
if (j != i && pfLayout[j].col == s_pf_target.col && pfLayout[j].row == s_pf_target.row) { occ = j; break; }
entries[i].gridCol = s_pf_target.col; entries[i].gridRow = s_pf_target.row;
if (occ >= 0 && occ < (int)entries.size()) {
entries[occ].gridCol = s_pf_old.col; entries[occ].gridRow = s_pf_old.row;
}
pfPersist(entries);
}
}
s_pf_drag = -1; s_pf_moved = false;
}
}
}