feat(market): portfolio modal — per-row delete, footer Close, instant Add, no bleed-through

- Delete moved from the footer to a per-row trash icon on each group (shown on
  hover/selected), with deferred deletion + selection fix-up.
- Removed the title-bar X; the footer is now right-aligned Revert / Save / Close
  (larger buttons), Close commits + dismisses. Esc and outside-click still close.
- Add entry now immediately creates a persisted "Untitled" group and selects it
  (no more invisible draft state); the -2 draft path is gone.
- Backdrop bleed-through fixed: when the modal is open the Market tab body is not
  rendered, so its content and foreground theme-effect borders no longer show over
  the overlay — the modal floats on the app's blurred/opaque backdrop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 20:02:25 -05:00
parent 1fdb6656aa
commit d007f3de39
2 changed files with 60 additions and 30 deletions

View File

@@ -429,7 +429,7 @@ static void RenderPortfolioEditor(App* app)
ImGui::BeginChild("##pfCard", ImVec2(cardW, cardH), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar);
ImGui::PopStyleVar(); // pop WindowPadding — the 28x20 applies to ##pfCard only, not nested children
material::DrawDialogTitleBar(TR("portfolio_manage_title"), &s_portfolio_editor_open);
material::DrawDialogTitleBar(TR("portfolio_manage_title"), nullptr); // no X — Close is in the footer
if (ImGui::IsKeyPressed(ImGuiKey_Escape)) s_portfolio_editor_open = false;
{ // clamp/validate the selection against the current entry list
@@ -455,16 +455,19 @@ static void RenderPortfolioEditor(App* app)
if (entries.empty())
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_entries"));
float rowH = 46.0f * dp;
int clickedSel = -999; // deferred: don't mutate `entries` mid-loop
float delSlot = 26.0f * dp;
int clickedSel = -999, delRow = -1; // deferred: don't mutate `entries` mid-loop
for (int i = 0; i < (int)entries.size(); i++) {
ImGui::PushID(i);
const auto& en = entries[i];
bool selRow = (s_pf_sel == i);
ImVec2 rmn = ImGui::GetCursorScreenPos();
ImGui::SetNextItemAllowOverlap(); // let the per-row delete icon sit on top
if (ImGui::Selectable("##pfgrp", selRow, ImGuiSelectableFlags_SpanAvailWidth, ImVec2(0, rowH))) {
if (i != s_pf_sel) clickedSel = i;
}
ImVec2 rmx(rmn.x + ImGui::GetItemRectSize().x, rmn.y + rowH);
bool rowHov = ImGui::IsItemHovered();
ImU32 accent = en.color ? (ImU32)en.color : WithAlpha(OnSurface(), 60);
mdl->AddRectFilled(ImVec2(rmn.x, rmn.y + 4.0f * dp),
ImVec2(rmn.x + 3.0f * dp, rmx.y - 4.0f * dp), accent, 1.5f * dp);
@@ -475,7 +478,7 @@ static void RenderPortfolioEditor(App* app)
en.color ? (ImU32)en.color : OnSurfaceMedium(), iconFont, sub1f->LegacySize);
ix += sub1f->LegacySize + Layout::spacingSm();
}
float txtRight = rmx.x - Layout::spacingSm();
float txtRight = rmx.x - delSlot; // reserve the trailing delete-icon slot
std::string nm = en.label.empty() ? std::string(TR("portfolio_new_entry")) : en.label;
while (nm.size() > 1 && body2f->CalcTextSizeA(body2f->LegacySize, FLT_MAX, 0, nm.c_str()).x > (txtRight - ix))
nm.pop_back();
@@ -484,14 +487,46 @@ static void RenderPortfolioEditor(App* app)
double bal = data::SumPortfolioBalance(en.addresses, state.addresses);
char vl[80]; snprintf(vl, sizeof(vl), "%.4f %s \xC2\xB7 %d", bal, DRAGONX_TICKER, (int)en.addresses.size());
mdl->AddText(capF, capF->LegacySize, ImVec2(ix, rmn.y + rowH * 0.56f), OnSurfaceMedium(), vl);
// Per-row delete icon (shown on hover or when selected).
if (rowHov || selRow) {
float ds = 22.0f * dp;
ImVec2 dmn(rmx.x - ds - Layout::spacingXs(), rmn.y + (rowH - ds) * 0.5f);
ImGui::SetCursorScreenPos(dmn);
if (ImGui::InvisibleButton("##pfdel", ImVec2(ds, ds))) delRow = i;
bool dhov = ImGui::IsItemHovered();
if (dhov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImVec2 isz = iconFont->CalcTextSizeA(iconFsz, FLT_MAX, 0, ICON_MD_DELETE_OUTLINE);
mdl->AddText(iconFont, iconFsz, ImVec2(dmn.x + (ds - isz.x) * 0.5f, dmn.y + (ds - isz.y) * 0.5f),
dhov ? Error() : OnSurfaceMedium(), ICON_MD_DELETE_OUTLINE);
}
ImGui::PopID();
}
if (clickedSel != -999) { commitIfNeeded(); PortfolioBeginEdit(app, clickedSel); }
if (delRow >= 0) {
commitIfNeeded(); // persist edits to the current group first
auto es = settings->getPortfolioEntries();
if (delRow < (int)es.size()) {
es.erase(es.begin() + delRow);
persist(es);
if (es.empty()) s_pf_sel = -1;
else if (delRow < s_pf_sel) s_pf_sel -= 1;
else if (delRow == s_pf_sel) s_pf_sel = std::min(delRow, (int)es.size() - 1);
PortfolioBeginEdit(app, s_pf_sel);
}
} else if (clickedSel != -999) { commitIfNeeded(); PortfolioBeginEdit(app, clickedSel); }
}
ImGui::EndChild();
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Add button lives outside the scroll child (a normal widget) so it can never be clipped.
if (ImGui::Button(TR("portfolio_add_entry"), ImVec2(masterW, addH))) { commitIfNeeded(); PortfolioBeginEdit(app, -2); }
// Add immediately creates a persisted "Untitled" group and selects it for editing.
if (ImGui::Button(TR("portfolio_add_entry"), ImVec2(masterW, addH))) {
commitIfNeeded();
auto es = settings->getPortfolioEntries();
config::Settings::PortfolioEntry ne;
ne.label = TR("portfolio_untitled");
es.push_back(ne);
persist(es);
s_pf_sel = (int)es.size() - 1;
PortfolioBeginEdit(app, s_pf_sel);
}
}
ImGui::EndGroup();
@@ -868,33 +903,23 @@ static void RenderPortfolioEditor(App* app)
}
ImGui::EndChild(); // ##pfDetail
// ================= FOOTER: delete / revert / save =================
// ================= FOOTER: revert / save / close (right-aligned, large) =================
ImGui::Separator();
{
ImGui::BeginDisabled(s_pf_sel < 0);
if (ImGui::Button(TR("portfolio_delete"))) {
auto entries = settings->getPortfolioEntries();
if (s_pf_sel >= 0 && s_pf_sel < (int)entries.size()) {
entries.erase(entries.begin() + s_pf_sel);
persist(entries);
if (entries.empty()) s_pf_sel = -1;
else { s_pf_sel = std::min(s_pf_sel, (int)entries.size() - 1); PortfolioBeginEdit(app, s_pf_sel); }
}
}
ImGui::EndDisabled();
float bw = 92.0f * dp, aw = bw * 2 + Layout::spacingSm();
ImGui::SameLine();
float bh = 40.0f * dp, bw = 112.0f * dp, sp = Layout::spacingSm();
float aw = bw * 3 + sp * 2;
float availF = ImGui::GetContentRegionAvail().x;
if (availF > aw) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + availF - aw);
ImGui::BeginDisabled(s_pf_sel == -1);
if (ImGui::Button(TR("portfolio_revert"), ImVec2(bw, 0)))
PortfolioBeginEdit(app, s_pf_sel); // reload from the stored entry (or defaults for a draft)
if (ImGui::Button(TR("portfolio_revert"), ImVec2(bw, bh)))
PortfolioBeginEdit(app, s_pf_sel); // reload the working set from the stored entry
ImGui::EndDisabled();
ImGui::SameLine();
ImGui::SameLine(0, sp);
ImGui::BeginDisabled(s_pf_label[0] == '\0');
if (ImGui::Button(TR("portfolio_save"), ImVec2(bw, 0))) commitIfNeeded();
if (ImGui::Button(TR("portfolio_save"), ImVec2(bw, bh))) commitIfNeeded();
ImGui::EndDisabled();
ImGui::SameLine(0, sp);
if (ImGui::Button(TR("portfolio_close"), ImVec2(bw, bh))) { commitIfNeeded(); s_portfolio_editor_open = false; }
}
ImGui::EndChild(); // ##pfCard
@@ -944,6 +969,11 @@ void RenderMarketTab(App* app)
const auto& currentExchange = registry[s_exchange_idx];
if (s_pair_idx >= (int)currentExchange.pairs.size()) s_pair_idx = 0;
// When the Manage-portfolio modal is open it fully covers the tab, so skip rendering the tab
// body (and its foreground theme effects, which would otherwise bleed over the overlay) — the
// modal floats on the app's blurred backdrop instead of the sharp tab content.
if (s_portfolio_editor_open) { RenderPortfolioEditor(app); return; }
ImVec2 marketAvail = ImGui::GetContentRegionAvail();
// Scrollable (the portfolio grid can extend past the window) but with no visible scrollbar —
// scrolling still works via the mouse wheel.
@@ -1540,8 +1570,8 @@ void RenderMarketTab(App* app)
float availX = ImGui::GetContentRegionAvail().x;
if (availX > mBtnW) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + availX - mBtnW);
if (ImGui::Button(ml, ImVec2(mBtnW, 0))) {
// Open the combined editor with a valid selection: first group, or a blank draft.
PortfolioBeginEdit(app, app->settings()->getPortfolioEntries().empty() ? -2 : 0);
// Open the combined editor selecting the first group (or the empty state if none).
PortfolioBeginEdit(app, app->settings()->getPortfolioEntries().empty() ? -1 : 0);
s_portfolio_editor_open = true;
}
}
@@ -1875,9 +1905,7 @@ void RenderMarketTab(App* app)
}
ImGui::EndChild(); // ##MarketScroll
// Portfolio editor modal (rendered outside the scroll child so it overlays the page).
RenderPortfolioEditor(app);
// (When the editor is open the tab body is skipped above and the modal is rendered there.)
}
} // namespace ui

View File

@@ -1166,6 +1166,8 @@ void I18n::loadBuiltinEnglish()
strings_["portfolio_save"] = "Save";
strings_["portfolio_cancel"] = "Cancel";
strings_["portfolio_revert"] = "Revert";
strings_["portfolio_close"] = "Close";
strings_["portfolio_untitled"] = "Untitled";
strings_["portfolio_detail_empty"] = "Select a group on the left, or add one, to edit it.";
strings_["portfolio_add_to"] = "Add to portfolio";
strings_["portfolio_remove_from"] = "Remove from portfolio";