fix(market): portfolio modal — hover assert, cut-off footer, plain heading, FX bleed, bigger delete icon

- Fix the ImGui error on hovering a group row: the per-row delete was an
  overlapping InvisibleButton + SetCursorScreenPos over the row Selectable. Replace
  it with a manual IsMouseHoveringRect / IsMouseClicked hit-test (no overlapping
  ImGui item, no cursor manipulation).
- Delete icon is larger (iconMed) and inset from the row edge (spacingMd).
- Footer no longer clipped: reserve 56px for the 40px buttons + separator.
- Manage-portfolio heading is now plain text (h6) with no title-bar background or
  divider — content floats on the backdrop.
- Sidebar theme-effect borders no longer bleed through the overlay: suppress panel
  theme-effects frame-wide (RAII guard in App::render via ui::PortfolioEditorActive)
  while the modal is open, instead of only during the market render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 20:47:33 -05:00
parent 0e8816e220
commit 5afb339ba7
3 changed files with 42 additions and 23 deletions

View File

@@ -1243,6 +1243,17 @@ void App::render()
// Process deferred encryption from wizard (runs in background) // Process deferred encryption from wizard (runs in background)
processDeferredEncryption(); processDeferredEncryption();
// While the full-window portfolio modal is open, suppress panel theme-effects for the whole
// frame so their foreground-draw-list borders (sidebar included) don't bleed over the overlay.
// RAII-restored to the prior state on any return path.
struct FxSuppressGuard {
bool active;
explicit FxSuppressGuard(bool on) : active(on) {
if (active) ui::effects::ThemeEffects::instance().setEnabled(false);
}
~FxSuppressGuard() { if (active) ui::effects::ThemeEffects::instance().setEnabled(true); }
} fxGuard(ui::PortfolioEditorActive() && ui::effects::ThemeEffects::instance().isEnabled());
// Main content area - use full window (no menu bar) // Main content area - use full window (no menu bar)
ImGuiViewport* viewport = ImGui::GetMainViewport(); ImGuiViewport* viewport = ImGui::GetMainViewport();
ImGui::SetNextWindowPos(viewport->WorkPos); ImGui::SetNextWindowPos(viewport->WorkPos);

View File

@@ -16,7 +16,6 @@
#include "../material/colors.h" #include "../material/colors.h"
#include "../material/typography.h" #include "../material/typography.h"
#include "../material/project_icons.h" #include "../material/project_icons.h"
#include "../effects/theme_effects.h"
#include "../effects/imgui_acrylic.h" #include "../effects/imgui_acrylic.h"
#include "../../util/text_format.h" #include "../../util/text_format.h"
#include "../../embedded/IconsMaterialDesign.h" #include "../../embedded/IconsMaterialDesign.h"
@@ -438,7 +437,14 @@ static void RenderPortfolioEditor(App* app)
ImGui::PopStyleVar(); // pop WindowPadding — the 28x20 applies to ##pfCard only, not nested children ImGui::PopStyleVar(); // pop WindowPadding — the 28x20 applies to ##pfCard only, not nested children
ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.5f, 0.5f)); // centered button labels ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.5f, 0.5f)); // centered button labels
material::DrawDialogTitleBar(TR("portfolio_manage_title"), nullptr); // no X — Close is in the footer // Plain heading — no title-bar background or divider (content floats on the backdrop).
{
ImFont* titleFont = Type().h6();
ImGui::PushFont(titleFont);
ImGui::TextUnformatted(TR("portfolio_manage_title"));
ImGui::PopFont();
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
}
if (ImGui::IsKeyPressed(ImGuiKey_Escape)) s_portfolio_editor_open = false; if (ImGui::IsKeyPressed(ImGuiKey_Escape)) s_portfolio_editor_open = false;
{ // clamp/validate the selection against the current entry list { // clamp/validate the selection against the current entry list
@@ -446,7 +452,7 @@ static void RenderPortfolioEditor(App* app)
if (s_pf_sel >= (int)entriesRO.size()) s_pf_sel = entriesRO.empty() ? -1 : 0; if (s_pf_sel >= (int)entriesRO.size()) s_pf_sel = entriesRO.empty() ? -1 : 0;
} }
float footerH = 40.0f * dp; float footerH = 56.0f * dp; // room for the 40px buttons + separator so they aren't clipped
float bodyH = std::max(120.0f, ImGui::GetContentRegionAvail().y - footerH - Layout::spacingSm()); float bodyH = std::max(120.0f, ImGui::GetContentRegionAvail().y - footerH - Layout::spacingSm());
float contentW = ImGui::GetContentRegionAvail().x; float contentW = ImGui::GetContentRegionAvail().x;
float gap = Layout::spacingLg(); float gap = Layout::spacingLg();
@@ -464,18 +470,16 @@ static void RenderPortfolioEditor(App* app)
if (entries.empty()) if (entries.empty())
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_entries")); Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_entries"));
float rowH = 46.0f * dp; float rowH = 46.0f * dp;
float delSlot = 26.0f * dp; float delSlot = 36.0f * dp; // reserved trailing space for the delete icon + margin
int clickedSel = -999, delRow = -1; // deferred: don't mutate `entries` mid-loop int clickedSel = -999, delRow = -1; // deferred: don't mutate `entries` mid-loop
for (int i = 0; i < (int)entries.size(); i++) { for (int i = 0; i < (int)entries.size(); i++) {
ImGui::PushID(i); ImGui::PushID(i);
const auto& en = entries[i]; const auto& en = entries[i];
bool selRow = (s_pf_sel == i); bool selRow = (s_pf_sel == i);
ImVec2 rmn = ImGui::GetCursorScreenPos(); 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 (ImGui::Selectable("##pfgrp", selRow, ImGuiSelectableFlags_SpanAvailWidth, ImVec2(0, rowH))) {
if (i != s_pf_sel) clickedSel = i; if (i != s_pf_sel) clickedSel = i;
} }
ImVec2 rowNext = ImGui::GetCursorScreenPos(); // next-row cursor (the delete button moves it)
ImVec2 rmx(rmn.x + ImGui::GetItemRectSize().x, rmn.y + rowH); ImVec2 rmx(rmn.x + ImGui::GetItemRectSize().x, rmn.y + rowH);
bool rowHov = ImGui::IsItemHovered(); bool rowHov = ImGui::IsItemHovered();
ImU32 accent = en.color ? (ImU32)en.color : WithAlpha(OnSurface(), 60); ImU32 accent = en.color ? (ImU32)en.color : WithAlpha(OnSurface(), 60);
@@ -497,18 +501,21 @@ static void RenderPortfolioEditor(App* app)
double bal = data::SumPortfolioBalance(en.addresses, state.addresses); 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()); 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); mdl->AddText(capF, capF->LegacySize, ImVec2(ix, rmn.y + rowH * 0.56f), OnSurfaceMedium(), vl);
// Per-row delete icon (shown on hover or when selected). // Per-row delete icon (larger, inset from the edge). Hit-tested manually so it is
// NOT an overlapping ImGui item over the row Selectable (which asserted on hover).
if (rowHov || selRow) { if (rowHov || selRow) {
float ds = 22.0f * dp; ImFont* delFont = Type().iconMed();
ImVec2 dmn(rmx.x - ds - Layout::spacingXs(), rmn.y + (rowH - ds) * 0.5f); float ds = delFont->LegacySize;
ImGui::SetCursorScreenPos(dmn); ImVec2 dc(rmx.x - Layout::spacingMd() - ds * 0.5f, rmn.y + rowH * 0.5f);
if (ImGui::InvisibleButton("##pfdel", ImVec2(ds, ds))) delRow = i; ImVec2 dmn(dc.x - ds * 0.65f, dc.y - ds * 0.65f), dmx(dc.x + ds * 0.65f, dc.y + ds * 0.65f);
bool dhov = ImGui::IsItemHovered(); bool dhov = ImGui::IsMouseHoveringRect(dmn, dmx);
if (dhov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); if (dhov) {
ImVec2 isz = iconFont->CalcTextSizeA(iconFsz, FLT_MAX, 0, ICON_MD_DELETE_OUTLINE); ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
mdl->AddText(iconFont, iconFsz, ImVec2(dmn.x + (ds - isz.x) * 0.5f, dmn.y + (ds - isz.y) * 0.5f), if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) delRow = i;
}
ImVec2 isz = delFont->CalcTextSizeA(ds, FLT_MAX, 0, ICON_MD_DELETE_OUTLINE);
mdl->AddText(delFont, ds, ImVec2(dc.x - isz.x * 0.5f, dc.y - isz.y * 0.5f),
dhov ? Error() : OnSurfaceMedium(), ICON_MD_DELETE_OUTLINE); dhov ? Error() : OnSurfaceMedium(), ICON_MD_DELETE_OUTLINE);
ImGui::SetCursorScreenPos(rowNext); // restore flow so the next row isn't offset
} }
ImGui::PopID(); ImGui::PopID();
} }
@@ -951,6 +958,8 @@ static void RenderPortfolioEditor(App* app)
ImGui::PopStyleVar(3); ImGui::PopStyleVar(3);
} }
bool PortfolioEditorActive() { return s_portfolio_editor_open; }
void RenderMarketTab(App* app) void RenderMarketTab(App* app)
{ {
auto& S = schema::UI(); auto& S = schema::UI();
@@ -982,12 +991,8 @@ void RenderMarketTab(App* app)
const auto& currentExchange = registry[s_exchange_idx]; const auto& currentExchange = registry[s_exchange_idx];
if (s_pair_idx >= (int)currentExchange.pairs.size()) s_pair_idx = 0; if (s_pair_idx >= (int)currentExchange.pairs.size()) s_pair_idx = 0;
// While the modal is open the overlay captures + blurs the live tab content behind it. Suppress // (Panel theme-effects are suppressed frame-wide in App::render() while the modal is open, so
// panel theme-effects for this render so their foreground-draw-list borders don't bleed over the // their foreground borders don't bleed over the overlay — see PortfolioEditorActive().)
// overlay (foreground draws above all windows). Restored after the modal is rendered.
bool fxSuppressed = s_portfolio_editor_open && effects::ThemeEffects::instance().isEnabled();
if (fxSuppressed) effects::ThemeEffects::instance().setEnabled(false);
ImVec2 marketAvail = ImGui::GetContentRegionAvail(); ImVec2 marketAvail = ImGui::GetContentRegionAvail();
// Scrollable (the portfolio grid can extend past the window) but with no visible scrollbar — // Scrollable (the portfolio grid can extend past the window) but with no visible scrollbar —
// scrolling still works via the mouse wheel. // scrolling still works via the mouse wheel.
@@ -1923,7 +1928,6 @@ void RenderMarketTab(App* app)
// Portfolio editor modal (rendered outside the scroll child so it overlays the page). It captures // Portfolio editor modal (rendered outside the scroll child so it overlays the page). It captures
// + blurs the live content drawn above, so it must render after the tab body. // + blurs the live content drawn above, so it must render after the tab body.
RenderPortfolioEditor(app); RenderPortfolioEditor(app);
if (fxSuppressed) effects::ThemeEffects::instance().setEnabled(true);
} }
} // namespace ui } // namespace ui

View File

@@ -15,5 +15,9 @@ namespace ui {
*/ */
void RenderMarketTab(App* app); void RenderMarketTab(App* app);
// True while the full-window Manage-portfolio modal is open (used to suppress panel theme-effects
// frame-wide so their foreground borders don't bleed over the overlay).
bool PortfolioEditorActive();
} // namespace ui } // namespace ui
} // namespace dragonx } // namespace dragonx