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) <noreply@anthropic.com>
This commit is contained in:
2026-07-04 02:33:15 -05:00
parent d91be3b34e
commit ebf01bd858
3 changed files with 40 additions and 195 deletions

View File

@@ -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<std::string> 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<double>& 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<double>& 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);