From d91be3b34e2873af73f89accc7ed65b4ea3fe304 Mon Sep 17 00:00:00 2001 From: DanS Date: Sat, 4 Jul 2026 02:13:47 -0500 Subject: [PATCH] feat(material): spec-driven BeginOverlayDialog (Phase 0b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor BeginOverlayDialog to run through an OverlayDialogSpec + OverlayStyle (GlassCard vs BlurFloat) with orthogonal flags (blurBackdrop / floatingContent / plainHeading, fixed vs auto card height). The classic look is now GlassCard with all flags off; the existing positional overload forwards to it → all ~43 current dialogs are byte-identical (verified line-by-line). The BlurFloat path — live-blur backdrop with per-overlay capture-once (frame-gap arming), floating content, plain h6 heading — is implemented but unused until Phase 1 dogfoods it on the portfolio. Also replace the unusable open-refcount with a per-frame drawn flag + last-frame latch that invalidates the acrylic capture on the overlay's close transition. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ui/material/draw_helpers.h | 228 +++++++++++++++++++++------------ 1 file changed, 146 insertions(+), 82 deletions(-) diff --git a/src/ui/material/draw_helpers.h b/src/ui/material/draw_helpers.h index c9ac779..5ba9550 100644 --- a/src/ui/material/draw_helpers.h +++ b/src/ui/material/draw_helpers.h @@ -1036,23 +1036,35 @@ inline bool DrawDialogTitleBar(const char* title, bool* p_open, ImU32 accent_col // content, segmented section nav, edge-fade scroll). Promoted verbatim from the Market tab so every // modal can adopt the same look. The overlay begin/end (below) drives these. -// Refcount of open blur-backdrop overlays (nesting-safe) + a 1-frame latch. App::render builds its -// effect-suppression guard at the TOP of the frame, before dialogs dispatch, so it must read a -// stable "was any blur overlay up last frame" signal rather than this frame's in-progress count. -inline int& BlurOverlayOpenCountRef() { static int n = 0; return n; } +// "A blur overlay was drawn this frame" flag + a 1-frame latch. App::render builds its +// effect-suppression guard at the TOP of the frame, before dialogs dispatch, so it reads the +// LAST-frame latch (a stable signal) rather than this frame's in-progress state. +inline bool& BlurOverlayDrawnThisFrameRef() { static bool v = false; return v; } inline bool& BlurOverlayActiveLastFrameRef() { static bool v = false; return v; } -inline void PushBlurOverlayOpen() { ++BlurOverlayOpenCountRef(); } -inline void PopBlurOverlayOpen() { if (BlurOverlayOpenCountRef() > 0) --BlurOverlayOpenCountRef(); } -inline bool AnyBlurOverlayActive() { return BlurOverlayOpenCountRef() > 0; } +inline void MarkBlurOverlayDrawn() { BlurOverlayDrawnThisFrameRef() = true; } inline bool AnyBlurOverlayActiveLastFrame() { return BlurOverlayActiveLastFrameRef(); } -// Latch this frame's state for the next frame's guard. Call once at the end of App::render. -inline void LatchBlurOverlayActive() { BlurOverlayActiveLastFrameRef() = AnyBlurOverlayActive(); } +// Latch this frame's state for the next frame's guard; on the open->closed transition, invalidate +// the acrylic capture so the other glass panels re-capture the (overlay-free) background. Call once +// at the end of App::render. +inline void LatchBlurOverlayActive() { + bool now = BlurOverlayDrawnThisFrameRef(); + if (BlurOverlayActiveLastFrameRef() && !now) effects::ImGuiAcrylic::InvalidateCapture(); + BlurOverlayActiveLastFrameRef() = now; + BlurOverlayDrawnThisFrameRef() = false; +} // True during the frames a blur overlay is (re)capturing the live backdrop — the scroll-fade shader // is suppressed then, since the acrylic capture rebinds render state on those frames. inline bool& CapturingBlurBackdropRef() { static bool v = false; return v; } inline bool IsCapturingBlurBackdrop() { return CapturingBlurBackdropRef(); } +// Per-overlay capture-arming state (keyed by the scrim window id) so each blur overlay arms its +// capture-once independently on open/resize, detected via frame-count gaps (no close sweep needed). +struct BlurCaptureState { int lastSeenFrame = -2; int captureFrames = 0; float w = 0.0f, h = 0.0f; }; +inline BlurCaptureState& BlurCaptureStateFor(const std::string& key) { + static std::unordered_map m; return m[key]; +} + // Right-align the next item(s) of total width `w` within the remaining content region (cursor form). inline void RightAlignX(float w) { @@ -1110,44 +1122,60 @@ inline int SegmentedControl(ImDrawList* dl, ImVec2 origin, float totalW, float h inline std::unordered_map g_overlayCardHeights; inline std::string g_overlayCurrentKey; -inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth = 460.0f, float scrimOpacity = 0.92f, - float cardBottomViewportRatio = 0.85f, const char* idSuffix = nullptr) +// Style of an overlay dialog. GlassCard = the classic scrim + glass card + boxed title bar. +// BlurFloat = the "Manage Portfolio" look: live-blur backdrop + floating content + plain heading. +enum class OverlayStyle { GlassCard, BlurFloat }; + +// Spec-driven overlay. The flags below are orthogonal modifiers on one scaffold; OverlayStyle is a +// convenience preset (GlassCard = all off; BlurFloat = blurBackdrop+floatingContent+plainHeading). +struct OverlayDialogSpec { + const char* title = nullptr; + bool* p_open = nullptr; + OverlayStyle style = OverlayStyle::GlassCard; + bool blurBackdrop = false; // live-blur backdrop instead of the opaque scrim + bool floatingContent = false; // transparent card (no glass panel — content floats on the blur) + bool plainHeading = false; // Type().h6() heading instead of the boxed DrawDialogTitleBar + float cardWidth = 460.0f; // scaled by dpiScale; clamped to viewport + float cardHeight = 0.0f; // 0 => auto-height (content-sized); else fixed height (large modals) + float scrimOpacity = 0.92f; // GlassCard scrim only + float cardBottomViewportRatio = 0.85f; // auto-height cap + const char* idSuffix = nullptr; // distinct window/child ids for nesting + capture keying +}; + +inline bool BeginOverlayDialog(const OverlayDialogSpec& spec) { + const bool blur = spec.blurBackdrop || spec.style == OverlayStyle::BlurFloat; + const bool floating = spec.floatingContent || spec.style == OverlayStyle::BlurFloat; + const bool plainHd = spec.plainHeading || spec.style == OverlayStyle::BlurFloat; + const float dp = Layout::dpiScale(); + MarkOverlayDialogActive(); - ImGuiViewport* vp = ImGui::GetMainViewport(); - ImVec2 vp_pos = vp->Pos; - ImVec2 vp_size = vp->Size; + ImVec2 vp_pos = vp->Pos, vp_size = vp->Size; - // Dialog widths are authored as raw pixels, but the fonts/spacing inside scale with - // Layout::dpiScale() (which includes the user's font-size setting). Scale the card by the same - // factor so the content doesn't outgrow a fixed card and overflow/misalign at non-default - // scales. No-op at the default scale (dpiScale() == 1). Clamped to the viewport so a large scale - // can't push the card off-screen. - cardWidth *= Layout::dpiScale(); + // Cards are authored in raw px but scale with dpiScale (font-size setting); clamp to viewport. + float cardWidth = spec.cardWidth * dp; cardWidth = std::min(cardWidth, vp_size.x - 32.0f); - // Fullscreen scrim overlay ImGui::SetNextWindowPos(vp_pos); ImGui::SetNextWindowSize(vp_size); - // Focus the scrim so the dialog owns input — but NOT while a popup (a Combo dropdown, - // color picker, etc.) opened from the dialog is showing, or forcing focus back to the - // scrim every frame would immediately close that popup. + // Focus the scrim so the dialog owns input — but not while a popup (combo/color picker) opened + // from the dialog is showing, or forcing focus every frame would immediately close that popup. const bool overlayPopupOpen = ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel); if (!overlayPopupOpen) ImGui::SetNextWindowFocus(); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.04f, 0.04f, 0.06f, scrimOpacity)); + // Blur overlays draw their own backdrop, so the window itself is transparent. + ImGui::PushStyleColor(ImGuiCol_WindowBg, + blur ? ImVec4(0, 0, 0, 0) : ImVec4(0.04f, 0.04f, 0.06f, spec.scrimOpacity)); ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); - + std::string scrimId = "##OverlayScrim"; std::string childId = "##OverlayDialogContent"; - if (idSuffix && idSuffix[0] != '\0') { - scrimId += "_"; - scrimId += idSuffix; - childId += "_"; - childId += idSuffix; + if (spec.idSuffix && spec.idSuffix[0] != '\0') { + scrimId += "_"; scrimId += spec.idSuffix; + childId += "_"; childId += spec.idSuffix; } bool opened = ImGui::Begin(scrimId.c_str(), nullptr, @@ -1155,82 +1183,118 @@ inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse | ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoSavedSettings); - + if (!opened) { ImGui::End(); ImGui::PopStyleVar(3); ImGui::PopStyleColor(); return false; } - + ImDrawList* dl = ImGui::GetWindowDrawList(); - // Consume pointer input on the scrim so the overlay owns clicks and wheel - // events even when the click lands outside the card content. + // Live-blur backdrop with capture-once: (re)capture the content drawn BELOW this overlay for a + // few frames on open/resize, then reuse the frozen blurred snapshot. Low-spec / acrylic-off falls + // back to an opaque base inside DrawFullWindowBlurBackdrop (no blur cost). + if (blur) { + MarkBlurOverlayDrawn(); + BlurCaptureState& st = BlurCaptureStateFor(scrimId); + int frame = ImGui::GetFrameCount(); + bool justOpened = (st.lastSeenFrame < frame - 1); + st.lastSeenFrame = frame; + if (justOpened || st.w != vp_size.x || st.h != vp_size.y) st.captureFrames = 3; + st.w = vp_size.x; st.h = vp_size.y; + CapturingBlurBackdropRef() = (st.captureFrames > 0); + if (st.captureFrames > 0) { + if (ImDrawCallback liveCap = effects::ImGuiAcrylic::GetLiveCaptureCallback()) { + dl->AddCallback(liveCap, nullptr); + dl->AddCallback(ImDrawCallback_ResetRenderState, nullptr); + } + st.captureFrames--; + } + DrawFullWindowBlurBackdrop(dl, vp_pos, ImVec2(vp_pos.x + vp_size.x, vp_pos.y + vp_size.y), + /*allowBlur=*/!justOpened); // opaque the first frame (nothing captured yet) + } else { + CapturingBlurBackdropRef() = false; + } + + // Consume pointer input on the scrim so the overlay owns clicks/wheel even outside the card. ImGui::SetCursorScreenPos(vp_pos); ImGui::InvisibleButton("##OverlayInputBlocker", vp_size, ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonRight | ImGuiButtonFlags_MouseButtonMiddle); - - // Calculate card position (centered) + + // Card geometry: fixed-height (large modals) is centered; auto-height reuses last frame's + // measured content height (short dialogs get short cards), capped at the viewport ratio. float cardX = vp_pos.x + (vp_size.x - cardWidth) * 0.5f; - float cardY = vp_pos.y + vp_size.y * 0.15f; + float cardY, cardBottomY; + const bool fixedHeight = (spec.cardHeight > 0.0f); + if (fixedHeight) { + float cardH = std::min(spec.cardHeight * dp, vp_size.y - 32.0f); + cardY = vp_pos.y + (vp_size.y - cardH) * 0.5f; + cardBottomY = cardY + cardH; + g_overlayCurrentKey.clear(); + } else { + cardY = vp_pos.y + vp_size.y * 0.15f; + g_overlayCurrentKey = childId; + float ratioMaxY = vp_pos.y + vp_size.y * spec.cardBottomViewportRatio; + auto it = g_overlayCardHeights.find(childId); + cardBottomY = (it != g_overlayCardHeights.end() && it->second > 0.0f) + ? std::min(cardY + it->second, ratioMaxY) : ratioMaxY; + } + ImVec2 cardMin(cardX, cardY), cardMax(cardX + cardWidth, cardBottomY); - // Size the card height to its content. The content child below is AutoResizeY, so a glass card - // drawn to a fixed viewport ratio left a tall band of empty glass under short dialogs. Reuse the - // height the child reported LAST frame (content is stable frame-to-frame, so no visible lag) and - // fall back to the ratio on the first frame. Still capped at the ratio so a very tall dialog can't - // run off-screen (its content spills/scrolls as before). - g_overlayCurrentKey = childId; - float ratioMaxY = vp_pos.y + vp_size.y * cardBottomViewportRatio; - auto prevHeightIt = g_overlayCardHeights.find(childId); - float cardBottomY = (prevHeightIt != g_overlayCardHeights.end() && prevHeightIt->second > 0.0f) - ? std::min(cardY + prevHeightIt->second, ratioMaxY) - : ratioMaxY; + // Card background — a glass panel unless the content floats directly on the backdrop. + if (!floating) { + GlassPanelSpec cardGlass; + cardGlass.rounding = 16.0f; cardGlass.fillAlpha = 35; cardGlass.borderAlpha = 50; cardGlass.borderWidth = 1.0f; + DrawGlassPanel(dl, cardMin, cardMax, cardGlass); + } - // Draw glass card background - ImVec2 cardMin(cardX, cardY); - ImVec2 cardMax(cardX + cardWidth, cardBottomY); - - // Card background with glass effect - GlassPanelSpec cardGlass; - cardGlass.rounding = 16.0f; - cardGlass.fillAlpha = 35; - cardGlass.borderAlpha = 50; - cardGlass.borderWidth = 1.0f; - DrawGlassPanel(dl, cardMin, cardMax, cardGlass); - - // Click outside the card dismisses the dialog — but NOT on the frame it first appears, otherwise - // the very click that opened it (a button fired the same frame) is read as an outside-click and - // the dialog flashes open then instantly closes. Also skip while a popup opened from the dialog - // is showing: a click inside a Combo dropdown / color picker that overhangs the card would - // otherwise be read as an outside-click and close the whole dialog. - if (p_open && !overlayPopupOpen && !ImGui::IsWindowAppearing() && + // Click outside the card dismisses — but not on the appearing frame (the opening click) or while + // a popup opened from the dialog is showing (a click in an overhanging combo would look outside). + if (spec.p_open && !overlayPopupOpen && !ImGui::IsWindowAppearing() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !ImGui::IsMouseHoveringRect(cardMin, cardMax, false)) { - *p_open = false; + *spec.p_open = false; } - - // Set up child region for card content + + // Content child. ImGui::SetCursorScreenPos(ImVec2(cardX, cardY)); - ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 16.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(28, 24)); - ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0, 0, 0, 0)); // Transparent - glass already drawn - + ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, floating ? 20.0f : 16.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, floating ? ImVec2(28, 20) : ImVec2(28, 24)); + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0, 0, 0, 0)); // transparent (glass/blur behind) + ImGuiChildFlags cflags = ImGuiChildFlags_AlwaysUseWindowPadding | (fixedHeight ? 0 : ImGuiChildFlags_AutoResizeY); bool childVisible = ImGui::BeginChild(childId.c_str(), - ImVec2(cardWidth, 0), // 0 height = auto-size - ImGuiChildFlags_AutoResizeY | ImGuiChildFlags_AlwaysUseWindowPadding, - ImGuiWindowFlags_NoScrollbar); - - // Draw title bar with close button - if (childVisible && title) { - DrawDialogTitleBar(title, p_open); + ImVec2(cardWidth, fixedHeight ? (cardBottomY - cardY) : 0.0f), + cflags, ImGuiWindowFlags_NoScrollbar); + + if (childVisible && spec.title) { + if (plainHd) { + ImGui::PushFont(Type().h6()); + ImGui::TextUnformatted(spec.title); + ImGui::PopFont(); + ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + } else { + DrawDialogTitleBar(spec.title, spec.p_open); + } } - + return childVisible; } +// Legacy positional overload — forwards to the spec impl with GlassCard defaults (byte-identical). +inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth = 460.0f, + float scrimOpacity = 0.92f, float cardBottomViewportRatio = 0.85f, + const char* idSuffix = nullptr) +{ + OverlayDialogSpec s; + s.title = title; s.p_open = p_open; s.cardWidth = cardWidth; + s.scrimOpacity = scrimOpacity; s.cardBottomViewportRatio = cardBottomViewportRatio; s.idSuffix = idSuffix; + return BeginOverlayDialog(s); +} + inline void EndOverlayDialog() { ImGui::EndChild();