feat(market): fine 32px square portfolio grid that scales to width and scrolls

Replace the fill-to-height portfolio dashboard with a fine gapless grid of
~32px square cells whose column count scales to fill the tab width. The grid
extends past the window and the Market tab scrolls when cards are placed lower.

- Cells are a fixed square edge (>= ~32px), gapless; cards inset by 3dp so
  neighbours don't touch. Column count = availWidth / 32dp (min pfMinW cols).
- Card minimum span is pfMinW x pfMinH (~128x96); layout/resize clamps to it.
  New groups default to 6x4 cells.
- Content height reserves two extra rows below the lowest card so a card can be
  dragged past the current bottom; the scroll child grows to match.
- Dot grid (shown while rearranging) softened and extended into the buffer rows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 14:30:04 -05:00
parent 67f6156030
commit db212a81c6
2 changed files with 27 additions and 31 deletions

View File

@@ -771,9 +771,8 @@ void RenderMarketTab(App* app)
// Non-scrolling container — content resizes to fit available height
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)
// Scrollable: the portfolio grid can extend past the window and scroll into view.
ImGui::BeginChild("##MarketScroll", marketAvail, false, ImGuiWindowFlags_NoBackground);
// Responsive: scale factors per frame
float availWidth = ImGui::GetContentRegionAvail().x;
@@ -820,13 +819,15 @@ void RenderMarketTab(App* app)
+ ratioBarH + Layout::spacingXs() + capFont->LegacySize // ratio bar + % label
+ Layout::spacingMd(); // gap before the group cards
int pfN = (int)pfEntriesGeo.size();
float pfCardGap = Layout::spacingSm();
float pfCardPad = Layout::spacingMd(); // inner padding of each group card
// Dashboard grid of SQUARE cells. Column count is responsive; min 2 columns so a card can meet
// its 2x1 minimum size. Cards drag to a new cell and corner-resize to span multiple cells.
int pfCols = std::max(2, std::min(8, (int)(availWidth / (110.0f * mktDp) + 0.5f)));
float pfCellW = (availWidth - (pfCols - 1) * pfCardGap) / (float)pfCols;
float pfCellH = pfCellW; // square cells; the smallest card is 2x1
// Fine SQUARE grid: cells are ~32px minimum and scale up to evenly fill the width. Cards snap to
// this grid and span many cells; they're drawn with an inner inset so adjacent cards don't touch.
float pfCardGap = 0.0f; // gapless cell grid
float pfInset = 3.0f * mktDp; // per-card inset for visual separation
const int pfMinW = 4, pfMinH = 3; // minimum card size in cells (~128x96)
int pfCols = std::max(pfMinW, (int)(availWidth / (32.0f * mktDp)));
float pfCellW = availWidth / (float)pfCols; // square cell edge (>= ~32px)
float pfCellH = pfCellW;
// 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));
@@ -844,7 +845,7 @@ void RenderMarketTab(App* app)
std::vector<char> placed((size_t)pfN, 0);
for (int i = 0; i < pfN; i++) {
const auto& e = pfEntriesGeo[i];
int w = std::max(2, std::min(e.gridW, pfCols)), h = std::max(1, e.gridH);
int w = std::max(pfMinW, std::min(e.gridW, pfCols)), h = std::max(pfMinH, 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;
}
@@ -852,7 +853,7 @@ void RenderMarketTab(App* app)
for (int i = 0; i < pfN; i++) {
if (placed[i]) continue;
const auto& e = pfEntriesGeo[i];
int w = std::max(2, std::min(e.gridW, pfCols)), h = std::max(1, e.gridH);
int w = std::max(pfMinW, std::min(e.gridW, pfCols)), h = std::max(pfMinH, 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++)
@@ -861,7 +862,9 @@ void RenderMarketTab(App* app)
}
}
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;
// Content height = the grid plus a couple of empty rows so cards can be dragged below the
// current bottom (the tab scrolls when the total exceeds the window).
float pfGroupsH = (pfN > 0) ? ((pfMaxRow + 2) * pfCellH) : 0.0f;
float portfolioH = pfSummaryH + pfGroupsH;
// ================================================================
@@ -1407,13 +1410,6 @@ 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
@@ -1429,6 +1425,8 @@ void RenderMarketTab(App* app)
// 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) {
// The grid is gapless, so inset the card within its cell block for separation.
gcMin.x += pfInset; gcMin.y += pfInset; gcMax.x -= pfInset; gcMax.y -= pfInset;
double bal = data::SumPortfolioBalance(e.addresses, state.addresses);
ImU32 accent = e.color ? (ImU32)e.color : 0;
float pad = pfCardPad;
@@ -1511,14 +1509,12 @@ void RenderMarketTab(App* app)
}
};
// Subtle dot grid while rearranging.
// Subtle dot grid while rearranging (fine 32px cells; extends into the buffer rows).
if (interacting) {
ImU32 dotc = WithAlpha(OnSurface(), 55);
for (int r = 0; r <= pfMaxRow; r++)
ImU32 dotc = WithAlpha(OnSurface(), 38);
for (int r = 0; r <= pfMaxRow + 2; 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);
dl->AddCircleFilled(ImVec2(cx + c * pfCellW, gy + r * pfCellH), 1.2f * mktDp, dotc);
}
// Draw + hit-test each card. Skip the one being dragged/resized (drawn specially below).
@@ -1573,9 +1569,9 @@ void RenderMarketTab(App* app)
if (s_pf_resize >= 0 && s_pf_resize < pfN) {
int i = s_pf_resize;
ImVec2 mn = cellMin(pfLayout[i]);
int nw = std::max(2, std::min(pfCols - pfLayout[i].col,
int nw = std::max(pfMinW, 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));
int nh = std::max(pfMinH, (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);