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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user