From ebf01bd8584a11cc9b25c86ba5b9c4261b413e94 Mon Sep 17 00:00:00 2001 From: DanS Date: Sat, 4 Jul 2026 02:33:15 -0500 Subject: [PATCH] feat(market): migrate portfolio modal onto the shared blur-overlay framework (Phase 1) Dogfood the Phase 0 framework: RenderPortfolioEditor now opens via material::BeginOverlayDialog(BlurFloat) + EndOverlayDialog instead of its own inline overlay scaffold, and uses the promoted material:: SegmentedControl / RightAlignX / BeginFadeScrollChild helpers (overlay_scroll.h). Delete the portfolio's inline backdrop/capture-once state machine + its PfEditState capture statics + the four local helper copies. App::render's ModalRenderGuard is now driven by the modal-agnostic AnyBlurOverlayActiveLastFrame() (timing-equivalent), and LatchBlurOverlayActive() at frame end handles the acrylic re-capture on close. Framework tweak: floating cards pop the card WindowPadding after BeginChild (so nested children don't inherit it) and center button labels, keeping the net style-var count at 2 so EndOverlayDialog is unchanged and GlassCard stays byte-identical. RenderPortfolioEditor: 357 -> 282 lines. Needs GUI verification that the portfolio modal is visually/behaviourally unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.cpp | 11 +- src/ui/material/draw_helpers.h | 7 ++ src/ui/windows/market_tab.cpp | 217 ++++----------------------------- 3 files changed, 40 insertions(+), 195 deletions(-) diff --git a/src/app.cpp b/src/app.cpp index 60a54b1..650cbff 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -1243,11 +1243,12 @@ void App::render() // Process deferred encryption from wizard (runs in background) processDeferredEncryption(); - // While the full-window portfolio modal is open: (1) suppress panel theme-effects for the whole + // While any full-window BLUR overlay is open: (1) suppress panel theme-effects for the whole // frame so their foreground-draw-list borders (sidebar included) don't bleed over the overlay; // (2) mark a full-window blur overlay active so glass panels use their opaque fallback (they're // covered by the overlay backdrop, and this leaves the backdrop as the sole acrylic blur caller). - // RAII-restored to the prior state on any return path. + // Driven by the shared framework's last-frame latch (modal-agnostic; timing-equivalent to the old + // PortfolioEditorActive() signal). RAII-restored to the prior state on any return path. struct ModalRenderGuard { bool fxOn, wasEffects; explicit ModalRenderGuard(bool modalOpen) { @@ -1260,7 +1261,7 @@ void App::render() if (fxOn) ui::effects::ThemeEffects::instance().setEnabled(true); ui::material::SetFullWindowBlurOverlayActive(false); } - } modalGuard(ui::PortfolioEditorActive()); + } modalGuard(ui::material::AnyBlurOverlayActiveLastFrame()); // Main content area - use full window (no menu bar) ImGuiViewport* viewport = ImGui::GetMainViewport(); @@ -1913,6 +1914,10 @@ void App::render() // Render notifications (toast messages) ui::Notifications::instance().render(); + + // Latch whether any blur overlay was drawn this frame (for next frame's ModalRenderGuard) and, on + // the overlay's close transition, invalidate the acrylic capture so glass panels re-blur cleanly. + ui::material::LatchBlurOverlayActive(); } void App::renderStatusBar() diff --git a/src/ui/material/draw_helpers.h b/src/ui/material/draw_helpers.h index 5ba9550..bd442cd 100644 --- a/src/ui/material/draw_helpers.h +++ b/src/ui/material/draw_helpers.h @@ -1269,6 +1269,13 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec) bool childVisible = ImGui::BeginChild(childId.c_str(), ImVec2(cardWidth, fixedHeight ? (cardBottomY - cardY) : 0.0f), cflags, ImGuiWindowFlags_NoScrollbar); + // Floating (portfolio-style) cards: the padding applies to this content child only, so pop it + // now (nested children mustn't inherit it), and center button labels. Net style-var count stays + // at 2 (ChildRounding + ButtonTextAlign) so EndOverlayDialog's PopStyleVar(2) is unchanged. + if (floating) { + ImGui::PopStyleVar(); // WindowPadding + ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.5f, 0.5f)); + } if (childVisible && spec.title) { if (plainHd) { diff --git a/src/ui/windows/market_tab.cpp b/src/ui/windows/market_tab.cpp index 21eebac..e6de22c 100644 --- a/src/ui/windows/market_tab.cpp +++ b/src/ui/windows/market_tab.cpp @@ -14,6 +14,7 @@ #include "../schema/ui_schema.h" #include "../material/type.h" #include "../material/draw_helpers.h" +#include "../material/overlay_scroll.h" #include "../material/colors.h" #include "../material/typography.h" #include "../material/project_icons.h" @@ -101,11 +102,7 @@ struct PfEditState { bool open = false; // modal visible (queried by PortfolioEditorActive()) int sel = -1; // selected group index in the master list; -1 = none int section = 0; // right-pane segmented control: 0=Appearance 1=Price 2=Addresses - // Backdrop live-capture: capture once on open + on viewport resize (frozen snapshot while open). - bool wasOpen = false; - int captureFrames = 0; // frames left to (re)capture the live backdrop - bool capturingFrame = false; // true on any frame a live-backdrop capture is armed - float captureW = 0.0f, captureH = 0.0f; // viewport size at the last capture trigger + // (The blur backdrop + capture-once are owned by material::BeginOverlayDialog now.) // Working copy of the group being edited. char label[64] = {0}; std::vector addrs; @@ -131,54 +128,6 @@ struct PfEditState { }; static PfEditState s_pfEdit; -// Wrap a scroll region with smooth scrolling + the Settings-tab edge fade + a thicker, rounded -// scrollbar that is inset from the container edge. Uses a bordered/rounded OUTER frame whose -// padding holds an INNER scrolling child, so the inner scrollbar floats inside the frame rather -// than hugging its border. `fade` must persist across frames (one instance per scroll region). -// `pad` is the inner content inset; returns the inner draw list. Pair with pfEndScrollChild(). -static ImDrawList* pfBeginScrollChild(const char* id, effects::ScrollFadeShader& fade, - const ImVec2& pad, float dp) -{ - // Outer: rounded bordered frame; its padding is the gap that insets the scrollbar from the edge. - ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 6.0f * dp); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f * dp, 6.0f * dp)); - ImGui::BeginChild(id, ImVec2(0, std::max(120.0f * dp, ImGui::GetContentRegionAvail().y)), true, - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); - ImGui::PopStyleVar(2); // ChildRounding + outer WindowPadding (both captured by the outer child) - // Inner: borderless scrolling child with a thicker rounded scrollbar at its own (inset) edge. - ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, 11.0f * dp); - ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarRounding, 5.5f * dp); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, pad); - std::string innerId = std::string(id) + "_i"; - ImGui::BeginChild(innerId.c_str(), ImVec2(0, 0), false, ImGuiWindowFlags_NoScrollWithMouse); - ImGui::PopStyleVar(); // inner WindowPadding — captured by this child; don't cascade to tooltips - ApplySmoothScroll(); - ImDrawList* dl = ImGui::GetWindowDrawList(); - // Fade the top/bottom edges; off in low-spec and while the backdrop is being captured (the - // acrylic overlay rebinds render state on those frames, which would clash with the fade shader). - const float fadeH = 22.0f * dp; - if (!effects::isLowSpecMode() && !s_pfEdit.capturingFrame && fade.init()) { - ImVec2 cMin = ImGui::GetWindowPos(); - ImVec2 cMax(cMin.x + ImGui::GetWindowSize().x, cMin.y + ImGui::GetWindowSize().y); - float sY = ImGui::GetScrollY(), sMax = ImGui::GetScrollMaxY(); - fade.fadeTopY = cMin.y; - fade.fadeBottomY = cMax.y; - fade.fadeZoneTop = (sY > 1.0f) ? fadeH : 0.0f; - fade.fadeZoneBottom = (sMax > 0.0f && sY < sMax - 1.0f) ? fadeH : 0.0f; - fade.addBind(dl); - } - return dl; -} - -static void pfEndScrollChild(effects::ScrollFadeShader& fade, ImDrawList* dl) -{ - if (!effects::isLowSpecMode() && !s_pfEdit.capturingFrame && fade.ready) - effects::ScrollFadeShader::addUnbind(dl); - ImGui::EndChild(); // inner - ImGui::PopStyleVar(2); // ScrollbarSize + ScrollbarRounding - ImGui::EndChild(); // outer -} - // The pure price-series math lives in data/market_series.h; the selected chart range is // s_mkt.chartInterval (0=Live 1=1H 2=1D 3=1W 4=1M, session-scoped, default 1M). @@ -204,47 +153,6 @@ static bool pfProjectSeries(const std::vector& hist, ImVec2 mn, ImVec2 m return true; } -// Right-align the next item(s) of total width `w` within the remaining content region (cursor form). -static void pfRightAlignX(float w) -{ - float avail = ImGui::GetContentRegionAvail().x; - if (avail > w) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + avail - w); -} - -// Draw an equal-width segmented control (rounded track + N cells, each with a centered label and an -// active pill) at screen-space `origin`, spanning `totalW` x `height`. Every cell is a hit-tested -// InvisibleButton (Hand cursor on hover), disambiguated by `idBase`. Does NOT move the layout cursor -// afterward — the caller positions the next content. Returns the clicked cell index, or -1. -static int pfSegmentedControl(ImDrawList* dl, ImVec2 origin, float totalW, float height, - const char* const* labels, int count, int selected, - ImFont* font, const char* idBase, float dp) -{ - int clicked = -1; - float cellW = totalW / (float)count; - dl->AddRectFilled(origin, ImVec2(origin.x + totalW, origin.y + height), - WithAlpha(OnSurface(), 20), height * 0.5f); - for (int i = 0; i < count; i++) { - ImVec2 cMin(origin.x + i * cellW, origin.y), cMax(cMin.x + cellW, origin.y + height); - bool active = (selected == i); - bool hov = ImGui::IsMouseHoveringRect(cMin, cMax); - if (active) - dl->AddRectFilled(ImVec2(cMin.x + 2.0f * dp, cMin.y + 2.0f * dp), - ImVec2(cMax.x - 2.0f * dp, cMax.y - 2.0f * dp), - WithAlpha(Primary(), 210), (height - 4.0f * dp) * 0.5f); - ImVec2 ts = font->CalcTextSizeA(font->LegacySize, FLT_MAX, 0, labels[i]); - dl->AddText(font, font->LegacySize, - ImVec2(cMin.x + (cellW - ts.x) * 0.5f, cMin.y + (height - ts.y) * 0.5f), - active ? IM_COL32(255, 255, 255, 255) : (hov ? OnSurface() : OnSurfaceMedium()), - labels[i]); - ImGui::PushID(i); - ImGui::SetCursorScreenPos(cMin); - if (ImGui::InvisibleButton(idBase, ImVec2(cellW, height))) clicked = i; - if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); - ImGui::PopID(); - } - return clicked; -} - static void pfDrawSparkline(ImDrawList* dl, ImVec2 mn, ImVec2 mx, const std::vector& hist, ImU32 col) { @@ -548,7 +456,7 @@ static void pfDrawAppearanceSection() continue; iconIdx.push_back(i); } - ImDrawList* gdl = pfBeginScrollChild("##pfIconGrid", s_pfEdit.iconFade, + ImDrawList* gdl = material::BeginFadeScrollChild("##pfIconGrid", s_pfEdit.iconFade, ImVec2(Layout::spacingSm(), Layout::spacingSm()), dp); const int cols = 12; float availW = ImGui::GetContentRegionAvail().x; @@ -590,7 +498,7 @@ static void pfDrawAppearanceSection() ImGui::PopID(); gcol = (gcol + 1) % cols; } - pfEndScrollChild(s_pfEdit.iconFade, gdl); + material::EndFadeScrollChild(s_pfEdit.iconFade, gdl); } } @@ -674,7 +582,7 @@ static void pfDrawAddressSection(App* app) ImVec2 rowStart = ImGui::GetCursorScreenPos(); // Segmented type filter (left), vertically centered in the row. float segY = rowStart.y + (rowH - segH) * 0.5f; - int tclk = pfSegmentedControl(ImGui::GetWindowDrawList(), ImVec2(rowStart.x, segY), + int tclk = material::SegmentedControl(ImGui::GetWindowDrawList(), ImVec2(rowStart.x, segY), segW, segH, flabels, 3, s_pfEdit.typeFilter, capF, "##pftf", dp); if (tclk >= 0) s_pfEdit.typeFilter = tclk; // Select all / Clear (rounded pills) + Funded toggle (right), vertically centered. @@ -715,7 +623,7 @@ static void pfDrawAddressSection(App* app) if (selAllClicked) for (const AddressInfo* a : filtered) data::PortfolioEntryAdd(s_pfEdit.addrs, a->address); ImGui::Dummy(ImVec2(0, Layout::spacingXs())); - ImDrawList* ldl = pfBeginScrollChild("##pfAddrList", s_pfEdit.addrFade, + ImDrawList* ldl = material::BeginFadeScrollChild("##pfAddrList", s_pfEdit.addrFade, ImVec2(Layout::spacingMd(), Layout::spacingSm()), dp); float rowH = std::max(26.0f * dp, body2f->LegacySize + Layout::spacingSm()); float lw = ImGui::GetContentRegionAvail().x; @@ -778,7 +686,7 @@ static void pfDrawAddressSection(App* app) } ImGui::PopID(); } - pfEndScrollChild(s_pfEdit.addrFade, ldl); + material::EndFadeScrollChild(s_pfEdit.addrFade, ldl); } // The "Manage portfolio" modal: list/add/edit/delete entries, each a label tied to a group @@ -797,76 +705,19 @@ static void RenderPortfolioEditor(App* app) // Persistence helpers are file-static (pfBuildWorking / pfWorkingMatches / pfCommitIfNeeded). - // ---------------- Full-window overlay frame ---------------- - material::MarkOverlayDialogActive(); - ImGuiViewport* vp = ImGui::GetMainViewport(); - ImVec2 vpPos = vp->Pos, vpSize = vp->Size; - // Trigger a live backdrop (re)capture on OPEN and on viewport RESIZE only. A few frames are used - // so the capture lands after the theme-effect suppression (frame after open) settles. - bool justOpened = !s_pfEdit.wasOpen; - s_pfEdit.wasOpen = true; - if (justOpened || s_pfEdit.captureW != vpSize.x || s_pfEdit.captureH != vpSize.y) s_pfEdit.captureFrames = 3; - s_pfEdit.captureW = vpSize.x; s_pfEdit.captureH = vpSize.y; - // Don't force-focus the scrim while a popup (basis/interval combo, color picker) is open, or it - // would close the popup; the same guard gates the outside-click dismiss below. - const bool popupOpen = ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel); - if (!popupOpen) ImGui::SetNextWindowFocus(); - ImGui::SetNextWindowPos(vpPos); - ImGui::SetNextWindowSize(vpSize); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0, 0, 0, 0)); // transparent — we draw the backdrop - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); - ImGui::Begin("##PortfolioOverlay", nullptr, - ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse | - ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoSavedSettings); - ImDrawList* dl = ImGui::GetWindowDrawList(); - // Only on capture frames (open/resize): capture the live UI drawn below this overlay (then reset - // ImGui's render state) so the backdrop blurs the live tab content. Other frames reuse the frozen - // blurred snapshot (blurCacheValid_), so there's no per-frame capture/re-blur cost or flicker. - // Latch "capturing" for the WHOLE frame before decrementing, so the scroll children (which draw - // later this frame) suppress their fade shader on every capture frame — including the last one, - // where the counter would otherwise already read 0 and clash with the capture's render-state reset. - s_pfEdit.capturingFrame = (s_pfEdit.captureFrames > 0); - if (s_pfEdit.capturingFrame) { - if (ImDrawCallback liveCap = effects::ImGuiAcrylic::GetLiveCaptureCallback()) { - dl->AddCallback(liveCap, nullptr); - dl->AddCallback(ImDrawCallback_ResetRenderState, nullptr); - } - s_pfEdit.captureFrames--; - } - // Skip the blur on the very first frame (live content isn't captured until this frame's render) — - // shows the plain opaque scrim for one frame instead of a blur of stale background pixels. - material::DrawFullWindowBlurBackdrop(dl, vpPos, ImVec2(vpPos.x + vpSize.x, vpPos.y + vpSize.y), - /*allowBlur=*/!justOpened); - ImGui::SetCursorScreenPos(vpPos); - ImGui::InvisibleButton("##pfInputBlocker", vpSize, - ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonRight | ImGuiButtonFlags_MouseButtonMiddle); - - // Content region: large, centered, fixed size. No card panel — the content floats directly - // on the blurred backdrop. - float cardW = std::min(vpSize.x - 64.0f * dp, 1120.0f * dp); - float cardH = std::min(vpSize.y - 64.0f * dp, 760.0f * dp); - ImVec2 cardMin(vpPos.x + (vpSize.x - cardW) * 0.5f, vpPos.y + (vpSize.y - cardH) * 0.5f); - ImVec2 cardMax(cardMin.x + cardW, cardMin.y + cardH); - - ImGui::SetCursorScreenPos(cardMin); - ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 20.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(28.0f, 20.0f)); - ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0, 0, 0, 0)); - ImGui::BeginChild("##pfCard", ImVec2(cardW, cardH), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar); - 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 - - // 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())); - } + // ---------------- Full-window blur overlay (shared framework) ---------------- + // Backdrop + capture-once, floating card, plain heading, outside-click dismiss and effect + // suppression are owned by material::BeginOverlayDialog (BlurFloat). Esc still closes here; + // Esc/outside-click discard (only Close commits); LatchBlurOverlayActive (App::render) handles + // the acrylic re-capture on close. + material::OverlayDialogSpec ov; + ov.title = TR("portfolio_manage_title"); + ov.p_open = &s_pfEdit.open; + ov.style = material::OverlayStyle::BlurFloat; + ov.cardWidth = 1120.0f; + ov.cardHeight = 760.0f; + ov.idSuffix = "pf"; + if (!material::BeginOverlayDialog(ov)) return; if (ImGui::IsKeyPressed(ImGuiKey_Escape)) s_pfEdit.open = false; { // clamp/validate the selection against the current entry list @@ -1081,7 +932,7 @@ static void RenderPortfolioEditor(App* app) float segH = 30.0f * dp; float segW = ImGui::GetContentRegionAvail().x; ImVec2 sMin = ImGui::GetCursorScreenPos(); - int clk = pfSegmentedControl(ImGui::GetWindowDrawList(), sMin, segW, segH, + int clk = material::SegmentedControl(ImGui::GetWindowDrawList(), sMin, segW, segH, secs, 3, s_pfEdit.section, body2f, "##pfseg", dp); if (clk >= 0) s_pfEdit.section = clk; ImGui::SetCursorScreenPos(ImVec2(sMin.x, sMin.y + segH)); @@ -1099,7 +950,7 @@ static void RenderPortfolioEditor(App* app) { float bw = 104.0f * dp, sp = Layout::spacingSm(); float grp = bw * 2 + sp; - pfRightAlignX(grp); + material::RightAlignX(grp); ImGui::SetCursorPosY(ImGui::GetCursorPosY() + Layout::spacingSm()); ImGui::BeginDisabled(!selDirty); if (ImGui::Button(TR("portfolio_revert"), ImVec2(bw, addH))) @@ -1116,29 +967,11 @@ static void RenderPortfolioEditor(App* app) // ================= FOOTER: close (modal-level action, larger → higher hierarchy) ================= { float bh = 40.0f * dp, bw = 120.0f * dp; - pfRightAlignX(bw); + material::RightAlignX(bw); if (ImGui::Button(TR("portfolio_close"), ImVec2(bw, bh))) { pfCommitIfNeeded(settings); s_pfEdit.open = false; } } - ImGui::EndChild(); // ##pfCard - ImGui::PopStyleColor(); - ImGui::PopStyleVar(2); // ChildRounding + ButtonTextAlign (WindowPadding was popped after BeginChild) - - // Outside-click dismiss (guarded against the appearing frame and open popups). - if (!popupOpen && !ImGui::IsWindowAppearing() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) - && !ImGui::IsMouseHoveringRect(cardMin, cardMax)) - s_pfEdit.open = false; - // Esc / outside-click discard uncommitted edits (only the Close button above commits explicitly). - // Invalidate the acrylic capture so the background (not the last live capture) is re-captured for - // the other glass panels once the overlay is gone, and arm a fresh capture for the next open. - if (!s_pfEdit.open) { - effects::ImGuiAcrylic::InvalidateCapture(); - s_pfEdit.wasOpen = false; - } - - ImGui::End(); - ImGui::PopStyleColor(); - ImGui::PopStyleVar(3); + material::EndOverlayDialog(); } bool PortfolioEditorActive() { return s_pfEdit.open; } @@ -1839,7 +1672,7 @@ static void mktDrawPortfolio(const MktCtx& cx) const char* ml = TR("portfolio_manage"); float mBtnW = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, ml).x + Layout::spacingMd() * 2; ImGui::SameLine(); - pfRightAlignX(mBtnW); + material::RightAlignX(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);