From 77219c5ce7c90bfc7b977963d80e320808ca7627 Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 3 Jul 2026 01:54:20 -0500 Subject: [PATCH] feat(market): portfolio groups as a draggable/resizable dashboard grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/config/settings.cpp | 12 ++ src/config/settings.h | 6 + src/ui/windows/market_tab.cpp | 264 ++++++++++++++++++++++++++-------- 3 files changed, 219 insertions(+), 63 deletions(-) diff --git a/src/config/settings.cpp b/src/config/settings.cpp index 6753ec1..4fb7268 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -332,6 +332,14 @@ bool Settings::load(const std::string& path) entry.show24h = e["show_24h"].get(); if (e.contains("show_sparkline") && e["show_sparkline"].is_boolean()) entry.showSparkline = e["show_sparkline"].get(); + if (e.contains("grid_col") && e["grid_col"].is_number_integer()) + entry.gridCol = e["grid_col"].get(); + if (e.contains("grid_row") && e["grid_row"].is_number_integer()) + entry.gridRow = e["grid_row"].get(); + if (e.contains("grid_w") && e["grid_w"].is_number_integer()) + entry.gridW = e["grid_w"].get(); + if (e.contains("grid_h") && e["grid_h"].is_number_integer()) + entry.gridH = e["grid_h"].get(); 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_; diff --git a/src/config/settings.h b/src/config/settings.h index bd30bd8..be21f99 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -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 diff --git a/src/ui/windows/market_tab.cpp b/src/ui/windows/market_tab.cpp index b6a3c88..a9934ed 100644 --- a/src/ui/windows/market_tab.cpp +++ b/src/ui/windows/market_tab.cpp @@ -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 pfLayout((size_t)std::max(0, pfN)); + { + std::vector> 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 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& 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; + } } }