refactor(market): extract portfolio section into mktDrawPortfolio

Move the balance summary (fiat/DRGX/BTC + shielded ratio bar) and the
draggable/resizable custom-group card grid (drag/resize gestures, dot grid,
click-to-edit) out of RenderMarketTab into mktDrawPortfolio(MktCtx); the local
x-coord `cx` renamed `cx0` (ctx param is `cx`). Drop the now-unused
gap/dl/glassSpec/buf locals. RenderMarketTab is now ~150 lines (from 962) — a
thin orchestrator: precompute -> build MktCtx -> hero/chart/pair/portfolio.
Verbatim bodies. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-04 01:02:11 -05:00
parent 71fb81670f
commit 4543cdc495

View File

@@ -1811,6 +1811,276 @@ static void mktDrawPriceChart(const MktCtx& cx)
ImGui::Dummy(ImVec2(availWidth, 0));
}
// Portfolio: floating balance summary (fiat/DRGX/BTC + shielded ratio bar) and the draggable /
// resizable custom-group card grid (click a card to edit; drag to move; corner-drag to resize).
static void mktDrawPortfolio(const MktCtx& cx)
{
App* app = cx.app;
const auto& state = app->getWalletState();
const auto& market = state.market;
ImDrawList* dl = ImGui::GetWindowDrawList();
float availWidth = ImGui::GetContentRegionAvail().x;
float gap = Layout::cardGap();
float mktDp = Layout::dpiScale();
ImFont* capFont = Type().caption();
ImFont* sub1 = Type().subtitle1();
ImFont* body2 = Type().body2();
float ratioBarH = cx.ratioBarH, pfSummaryH = cx.pfSummaryH, portfolioH = cx.portfolioH;
const auto& pfEntriesGeo = *cx.pfEntriesGeo;
const auto& pfLayout = *cx.pfLayout;
int pfN = cx.pfN, pfCols = cx.pfCols, pfMinW = cx.pfMinW, pfMinH = cx.pfMinH, pfMaxRow = cx.pfMaxRow;
float pfCellW = cx.pfCellW, pfCellH = cx.pfCellH, pfInset = cx.pfInset, pfCardPad = cx.pfCardPad, pfCardGap = cx.pfCardGap;
char buf[128];
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("market_portfolio"));
// "Manage…" button, right-aligned on the header row — opens the portfolio editor.
{
const char* ml = TR("portfolio_manage");
float mBtnW = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, ml).x + Layout::spacingMd() * 2;
ImGui::SameLine();
pfRightAlignX(mBtnW);
if (ImGui::Button(ml, ImVec2(mBtnW, 0))) {
// Open the combined editor selecting the first group (or the empty state if none).
PortfolioBeginEdit(app, app->settings()->getPortfolioEntries().empty() ? -1 : 0);
s_pfEdit.open = true;
}
}
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
double total_balance = state.totalBalance;
double private_balance = state.privateBalance;
double transparent_balance = state.transparentBalance;
// No parent card background — the summary floats directly under the "MY DRGX" header.
ImVec2 cardMin = ImGui::GetCursorScreenPos();
float rightEdge = cardMin.x + availWidth;
float cx0 = cardMin.x;
float cy = cardMin.y;
// ---- 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);
DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(cx0, cy), Success(), buf);
float usdW = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, buf).x;
if (market.change_24h != 0.0) {
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(cx0 + usdW + Layout::spacingMd(),
cy + (sub1->LegacySize - capFont->LegacySize)),
chgCol, chg);
}
double portfolio_btc = total_balance * market.price_btc;
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(rightEdge - btcW, cy + (sub1->LegacySize - capFont->LegacySize)),
OnSurfaceMedium(), buf);
} else {
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx0, cy), OnSurfaceDisabled(), TR("market_no_price"));
}
cy += sub1->LegacySize + Layout::spacingSm();
// DRGX balance + Z/T breakdown (right-aligned).
snprintf(buf, sizeof(buf), "%.8f %s", total_balance, DRAGONX_TICKER);
dl->AddText(body2, body2->LegacySize, ImVec2(cx0, cy), OnSurface(), buf);
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(rightEdge - brkW, cy + 2), OnSurfaceDisabled(), buf);
cy += body2->LegacySize + Layout::spacingSm();
// Full-width shielded/transparent ratio bar + % label.
if (total_balance > 0) {
float barW = availWidth;
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(cx0, cy);
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 + 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 + ratioBarH),
WithAlpha(Warning(), 200),
shieldedW > 0.5f ? ImDrawFlags_RoundCornersRight : ImDrawFlags_RoundCornersAll, 3.0f);
// 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(cx0, cy + ratioBarH + Layout::spacingXs()), OnSurfaceDisabled(), buf);
}
// ---- 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;
ImGuiIO& io = ImGui::GetIO();
bool interacting = (s_grid.drag >= 0 || s_grid.resize >= 0);
float rh = 20.0f * mktDp; // corner resize-grip size (generous so a short resize is easy to grab)
auto cellMin = [&](const PfCell& L) {
return ImVec2(cx0 + 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].
// Stat tile: header (icon + name, 24h badge) / hero value (large, centered band) /
// footer (DRGX amount) + a dedicated sparkline strip along the bottom.
MktCardCtx cardCx{ dl, &state, &market, mktDp, pfInset, pfCardPad, sub1, capFont };
// Subtle dot grid while rearranging (fine 32px cells). Fill the whole visible grid area
// — down to the bottom of the scroll viewport, not just the occupied rows — so a card can
// be dropped anywhere, including the empty space below the current cards.
if (interacting) {
ImU32 dotc = WithAlpha(OnSurface(), 38);
float visibleBottom = dl->GetClipRectMax().y; // bottom edge of the scroll child
int rowsToBottom = (int)((visibleBottom - gy) / pfCellH) + 1;
int lastRow = std::max(pfMaxRow + 2, rowsToBottom);
for (int r = 0; r <= lastRow; r++)
for (int c = 0; c <= pfCols; c++)
dl->AddCircleFilled(ImVec2(cx0 + c * pfCellW, gy + r * pfCellH), 1.2f * 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_grid.drag && s_grid.moved) {
dl->AddRect(gcMin, gcMax, WithAlpha(OnSurface(), 45), 10.0f, 0, 1.0f); // source ghost
continue;
}
if (i == s_grid.resize) continue;
bool hov = !interacting && material::IsRectHovered(gcMin, gcMax);
mktDrawCard(cardCx, 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_grid.drag < 0 && s_grid.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_grid.resize = i; s_grid.old = pfLayout[i]; }
else {
s_grid.drag = i; s_grid.moved = false; s_grid.old = pfLayout[i];
s_grid.press = io.MousePos;
s_grid.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_grid.resize >= 0 && s_grid.resize < pfN) {
int i = s_grid.resize;
ImVec2 mn = cellMin(pfLayout[i]);
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(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);
mktDrawCard(cardCx, mn, mx, pfEntriesGeo[i], true);
dl->AddRect(mn, mx, WithAlpha(Primary(), 200), 10.0f, 0, 2.0f * mktDp);
// Live size readout (e.g. "8\xC3\x972") so the reached grid span is visible — the
// minimum is pfMinW x pfMinH (8x2).
{
char szb[32]; snprintf(szb, sizeof(szb), "%d\xC3\x97%d", nw, nh);
ImVec2 tsz = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, szb);
ImVec2 tp((mn.x + mx.x) * 0.5f - tsz.x * 0.5f, (mn.y + mx.y) * 0.5f - tsz.y * 0.5f);
dl->AddRectFilled(ImVec2(tp.x - 6, tp.y - 3), ImVec2(tp.x + tsz.x + 6, tp.y + tsz.y + 3),
IM_COL32(20, 20, 30, 205), 4.0f);
dl->AddText(sub1, sub1->LegacySize, tp, OnSurface(), szb);
}
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_grid.resize = -1;
}
}
// ----- Drag in progress -----
if (s_grid.drag >= 0 && s_grid.drag < pfN) {
int i = s_grid.drag;
if (io.MouseDown[0]) {
if (std::abs(io.MousePos.x - s_grid.press.x) > 4.0f || std::abs(io.MousePos.y - s_grid.press.y) > 4.0f)
s_grid.moved = true;
if (s_grid.moved) {
int w = pfLayout[i].w, h = pfLayout[i].h;
float tx = io.MousePos.x - s_grid.grab.x, ty = io.MousePos.y - s_grid.grab.y;
int tc = std::max(0, std::min(pfCols - w, (int)((tx - cx0) / (pfCellW + pfCardGap) + 0.5f)));
int tr = std::max(0, (int)((ty - gy) / (pfCellH + pfCardGap) + 0.5f));
s_grid.target = {tc, tr, w, h};
ImVec2 tmn(cx0 + 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);
mktDrawCard(cardCx, fmn, fmx, pfEntriesGeo[i], true); // dragged card follows the mouse
ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeAll);
}
} else {
if (!s_grid.moved) { PortfolioBeginEdit(app, i); s_pfEdit.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_grid.target.col && pfLayout[j].row == s_grid.target.row) { occ = j; break; }
entries[i].gridCol = s_grid.target.col; entries[i].gridRow = s_grid.target.row;
if (occ >= 0 && occ < (int)entries.size()) {
entries[occ].gridCol = s_grid.old.col; entries[occ].gridRow = s_grid.old.row;
}
pfPersist(entries);
}
}
s_grid.drag = -1; s_grid.moved = false;
}
}
}
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + portfolioH));
ImGui::Dummy(ImVec2(availWidth, 0));
ImGui::Dummy(ImVec2(0, gap));
}
void RenderMarketTab(App* app)
{
auto& S = schema::UI();
@@ -1850,20 +2120,14 @@ void RenderMarketTab(App* app)
ImGui::BeginChild("##MarketScroll", marketAvail, false,
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar);
// Responsive: scale factors per frame
// Responsive: scale factors per frame (the section helpers recompute their own draw list /
// fonts / paddings; only the values the precompute below needs are kept here).
float availWidth = ImGui::GetContentRegionAvail().x;
float vs = Layout::vScale(marketAvail.y);
float gap = Layout::cardGap();
ImDrawList* dl = ImGui::GetWindowDrawList();
GlassPanelSpec glassSpec;
glassSpec.rounding = Layout::glassRounding();
ImFont* capFont = Type().caption();
ImFont* sub1 = Type().subtitle1();
ImFont* body2 = Type().body2();
char buf[128];
// ================================================================
// Section geometry.
// ================================================================
@@ -1959,255 +2223,7 @@ void RenderMarketTab(App* app)
// ================================================================
// PORTFOLIO — floating summary (no card background) + custom-group cards
// ================================================================
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("market_portfolio"));
// "Manage…" button, right-aligned on the header row — opens the portfolio editor.
{
const char* ml = TR("portfolio_manage");
float mBtnW = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, ml).x + Layout::spacingMd() * 2;
ImGui::SameLine();
pfRightAlignX(mBtnW);
if (ImGui::Button(ml, ImVec2(mBtnW, 0))) {
// Open the combined editor selecting the first group (or the empty state if none).
PortfolioBeginEdit(app, app->settings()->getPortfolioEntries().empty() ? -1 : 0);
s_pfEdit.open = true;
}
}
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
double total_balance = state.totalBalance;
double private_balance = state.privateBalance;
double transparent_balance = state.transparentBalance;
// No parent card background — the summary floats directly under the "MY DRGX" header.
ImVec2 cardMin = ImGui::GetCursorScreenPos();
float rightEdge = cardMin.x + availWidth;
float cx = cardMin.x;
float cy = cardMin.y;
// ---- 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);
DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(cx, cy), Success(), buf);
float usdW = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, buf).x;
if (market.change_24h != 0.0) {
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 + (sub1->LegacySize - capFont->LegacySize)),
chgCol, chg);
}
double portfolio_btc = total_balance * market.price_btc;
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(rightEdge - btcW, 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();
// 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 \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(rightEdge - brkW, cy + 2), OnSurfaceDisabled(), buf);
cy += body2->LegacySize + Layout::spacingSm();
// Full-width shielded/transparent ratio bar + % label.
if (total_balance > 0) {
float barW = availWidth;
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 + ratioBarH),
IM_COL32(255, 255, 255, 10), 3.0f);
if (shieldedW > 0.5f)
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 + ratioBarH),
WithAlpha(Warning(), 200),
shieldedW > 0.5f ? ImDrawFlags_RoundCornersRight : ImDrawFlags_RoundCornersAll, 3.0f);
// 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 + ratioBarH + Layout::spacingXs()), OnSurfaceDisabled(), buf);
}
// ---- 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;
ImGuiIO& io = ImGui::GetIO();
bool interacting = (s_grid.drag >= 0 || s_grid.resize >= 0);
float rh = 20.0f * mktDp; // corner resize-grip size (generous so a short resize is easy to grab)
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].
// Stat tile: header (icon + name, 24h badge) / hero value (large, centered band) /
// footer (DRGX amount) + a dedicated sparkline strip along the bottom.
MktCardCtx cardCx{ dl, &state, &market, mktDp, pfInset, pfCardPad, sub1, capFont };
// Subtle dot grid while rearranging (fine 32px cells). Fill the whole visible grid area
// — down to the bottom of the scroll viewport, not just the occupied rows — so a card can
// be dropped anywhere, including the empty space below the current cards.
if (interacting) {
ImU32 dotc = WithAlpha(OnSurface(), 38);
float visibleBottom = dl->GetClipRectMax().y; // bottom edge of the scroll child
int rowsToBottom = (int)((visibleBottom - gy) / pfCellH) + 1;
int lastRow = std::max(pfMaxRow + 2, rowsToBottom);
for (int r = 0; r <= lastRow; r++)
for (int c = 0; c <= pfCols; c++)
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).
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_grid.drag && s_grid.moved) {
dl->AddRect(gcMin, gcMax, WithAlpha(OnSurface(), 45), 10.0f, 0, 1.0f); // source ghost
continue;
}
if (i == s_grid.resize) continue;
bool hov = !interacting && material::IsRectHovered(gcMin, gcMax);
mktDrawCard(cardCx, 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_grid.drag < 0 && s_grid.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_grid.resize = i; s_grid.old = pfLayout[i]; }
else {
s_grid.drag = i; s_grid.moved = false; s_grid.old = pfLayout[i];
s_grid.press = io.MousePos;
s_grid.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_grid.resize >= 0 && s_grid.resize < pfN) {
int i = s_grid.resize;
ImVec2 mn = cellMin(pfLayout[i]);
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(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);
mktDrawCard(cardCx, mn, mx, pfEntriesGeo[i], true);
dl->AddRect(mn, mx, WithAlpha(Primary(), 200), 10.0f, 0, 2.0f * mktDp);
// Live size readout (e.g. "8\xC3\x972") so the reached grid span is visible — the
// minimum is pfMinW x pfMinH (8x2).
{
char szb[32]; snprintf(szb, sizeof(szb), "%d\xC3\x97%d", nw, nh);
ImVec2 tsz = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, szb);
ImVec2 tp((mn.x + mx.x) * 0.5f - tsz.x * 0.5f, (mn.y + mx.y) * 0.5f - tsz.y * 0.5f);
dl->AddRectFilled(ImVec2(tp.x - 6, tp.y - 3), ImVec2(tp.x + tsz.x + 6, tp.y + tsz.y + 3),
IM_COL32(20, 20, 30, 205), 4.0f);
dl->AddText(sub1, sub1->LegacySize, tp, OnSurface(), szb);
}
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_grid.resize = -1;
}
}
// ----- Drag in progress -----
if (s_grid.drag >= 0 && s_grid.drag < pfN) {
int i = s_grid.drag;
if (io.MouseDown[0]) {
if (std::abs(io.MousePos.x - s_grid.press.x) > 4.0f || std::abs(io.MousePos.y - s_grid.press.y) > 4.0f)
s_grid.moved = true;
if (s_grid.moved) {
int w = pfLayout[i].w, h = pfLayout[i].h;
float tx = io.MousePos.x - s_grid.grab.x, ty = io.MousePos.y - s_grid.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_grid.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);
mktDrawCard(cardCx, fmn, fmx, pfEntriesGeo[i], true); // dragged card follows the mouse
ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeAll);
}
} else {
if (!s_grid.moved) { PortfolioBeginEdit(app, i); s_pfEdit.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_grid.target.col && pfLayout[j].row == s_grid.target.row) { occ = j; break; }
entries[i].gridCol = s_grid.target.col; entries[i].gridRow = s_grid.target.row;
if (occ >= 0 && occ < (int)entries.size()) {
entries[occ].gridCol = s_grid.old.col; entries[occ].gridRow = s_grid.old.row;
}
pfPersist(entries);
}
}
s_grid.drag = -1; s_grid.moved = false;
}
}
}
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + portfolioH));
ImGui::Dummy(ImVec2(availWidth, 0));
ImGui::Dummy(ImVec2(0, gap));
}
mktDrawPortfolio(mktc);
ImGui::EndChild(); // ##MarketScroll