feat(material): spec-driven BeginOverlayDialog (Phase 0b)

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-04 02:13:47 -05:00
parent 4b46d4a20c
commit d91be3b34e

View File

@@ -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 // 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. // 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 // "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 must read a // effect-suppression guard at the TOP of the frame, before dialogs dispatch, so it reads the
// stable "was any blur overlay up last frame" signal rather than this frame's in-progress count. // LAST-frame latch (a stable signal) rather than this frame's in-progress state.
inline int& BlurOverlayOpenCountRef() { static int n = 0; return n; } inline bool& BlurOverlayDrawnThisFrameRef() { static bool v = false; return v; }
inline bool& BlurOverlayActiveLastFrameRef() { static bool v = false; return v; } inline bool& BlurOverlayActiveLastFrameRef() { static bool v = false; return v; }
inline void PushBlurOverlayOpen() { ++BlurOverlayOpenCountRef(); } inline void MarkBlurOverlayDrawn() { BlurOverlayDrawnThisFrameRef() = true; }
inline void PopBlurOverlayOpen() { if (BlurOverlayOpenCountRef() > 0) --BlurOverlayOpenCountRef(); }
inline bool AnyBlurOverlayActive() { return BlurOverlayOpenCountRef() > 0; }
inline bool AnyBlurOverlayActiveLastFrame() { return BlurOverlayActiveLastFrameRef(); } inline bool AnyBlurOverlayActiveLastFrame() { return BlurOverlayActiveLastFrameRef(); }
// Latch this frame's state for the next frame's guard. Call once at the end of App::render. // Latch this frame's state for the next frame's guard; on the open->closed transition, invalidate
inline void LatchBlurOverlayActive() { BlurOverlayActiveLastFrameRef() = AnyBlurOverlayActive(); } // 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 // 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. // is suppressed then, since the acrylic capture rebinds render state on those frames.
inline bool& CapturingBlurBackdropRef() { static bool v = false; return v; } inline bool& CapturingBlurBackdropRef() { static bool v = false; return v; }
inline bool IsCapturingBlurBackdrop() { return CapturingBlurBackdropRef(); } 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<std::string, BlurCaptureState> m; return m[key];
}
// Right-align the next item(s) of total width `w` within the remaining content region (cursor form). // Right-align the next item(s) of total width `w` within the remaining content region (cursor form).
inline void RightAlignX(float w) inline void RightAlignX(float w)
{ {
@@ -1110,44 +1122,60 @@ inline int SegmentedControl(ImDrawList* dl, ImVec2 origin, float totalW, float h
inline std::unordered_map<std::string, float> g_overlayCardHeights; inline std::unordered_map<std::string, float> g_overlayCardHeights;
inline std::string g_overlayCurrentKey; inline std::string g_overlayCurrentKey;
inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth = 460.0f, float scrimOpacity = 0.92f, // Style of an overlay dialog. GlassCard = the classic scrim + glass card + boxed title bar.
float cardBottomViewportRatio = 0.85f, const char* idSuffix = nullptr) // 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(); MarkOverlayDialogActive();
ImGuiViewport* vp = ImGui::GetMainViewport(); ImGuiViewport* vp = ImGui::GetMainViewport();
ImVec2 vp_pos = vp->Pos; ImVec2 vp_pos = vp->Pos, vp_size = vp->Size;
ImVec2 vp_size = vp->Size;
// Dialog widths are authored as raw pixels, but the fonts/spacing inside scale with // Cards are authored in raw px but scale with dpiScale (font-size setting); clamp to viewport.
// Layout::dpiScale() (which includes the user's font-size setting). Scale the card by the same float cardWidth = spec.cardWidth * dp;
// 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();
cardWidth = std::min(cardWidth, vp_size.x - 32.0f); cardWidth = std::min(cardWidth, vp_size.x - 32.0f);
// Fullscreen scrim overlay
ImGui::SetNextWindowPos(vp_pos); ImGui::SetNextWindowPos(vp_pos);
ImGui::SetNextWindowSize(vp_size); ImGui::SetNextWindowSize(vp_size);
// Focus the scrim so the dialog owns input — but NOT while a popup (a Combo dropdown, // Focus the scrim so the dialog owns input — but not while a popup (combo/color picker) opened
// color picker, etc.) opened from the dialog is showing, or forcing focus back to the // from the dialog is showing, or forcing focus every frame would immediately close that popup.
// scrim every frame would immediately close that popup.
const bool overlayPopupOpen = const bool overlayPopupOpen =
ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel); ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel);
if (!overlayPopupOpen) ImGui::SetNextWindowFocus(); 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_WindowRounding, 0.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
std::string scrimId = "##OverlayScrim"; std::string scrimId = "##OverlayScrim";
std::string childId = "##OverlayDialogContent"; std::string childId = "##OverlayDialogContent";
if (idSuffix && idSuffix[0] != '\0') { if (spec.idSuffix && spec.idSuffix[0] != '\0') {
scrimId += "_"; scrimId += "_"; scrimId += spec.idSuffix;
scrimId += idSuffix; childId += "_"; childId += spec.idSuffix;
childId += "_";
childId += idSuffix;
} }
bool opened = ImGui::Begin(scrimId.c_str(), nullptr, bool opened = ImGui::Begin(scrimId.c_str(), nullptr,
@@ -1165,72 +1193,108 @@ inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth
ImDrawList* dl = ImGui::GetWindowDrawList(); ImDrawList* dl = ImGui::GetWindowDrawList();
// Consume pointer input on the scrim so the overlay owns clicks and wheel // Live-blur backdrop with capture-once: (re)capture the content drawn BELOW this overlay for a
// events even when the click lands outside the card content. // 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::SetCursorScreenPos(vp_pos);
ImGui::InvisibleButton("##OverlayInputBlocker", vp_size, ImGui::InvisibleButton("##OverlayInputBlocker", vp_size,
ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonLeft |
ImGuiButtonFlags_MouseButtonRight | ImGuiButtonFlags_MouseButtonRight |
ImGuiButtonFlags_MouseButtonMiddle); 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 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 // Card background — a glass panel unless the content floats directly on the backdrop.
// drawn to a fixed viewport ratio left a tall band of empty glass under short dialogs. Reuse the if (!floating) {
// height the child reported LAST frame (content is stable frame-to-frame, so no visible lag) and GlassPanelSpec cardGlass;
// fall back to the ratio on the first frame. Still capped at the ratio so a very tall dialog can't cardGlass.rounding = 16.0f; cardGlass.fillAlpha = 35; cardGlass.borderAlpha = 50; cardGlass.borderWidth = 1.0f;
// run off-screen (its content spills/scrolls as before). DrawGlassPanel(dl, cardMin, cardMax, cardGlass);
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;
// 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() &&
ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
!ImGui::IsMouseHoveringRect(cardMin, cardMax, false)) {
*p_open = false;
} }
// Set up child region for card content // 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)) {
*spec.p_open = false;
}
// Content child.
ImGui::SetCursorScreenPos(ImVec2(cardX, cardY)); ImGui::SetCursorScreenPos(ImVec2(cardX, cardY));
ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 16.0f); ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, floating ? 20.0f : 16.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(28, 24)); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, floating ? ImVec2(28, 20) : ImVec2(28, 24));
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0, 0, 0, 0)); // Transparent - glass already drawn 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(), bool childVisible = ImGui::BeginChild(childId.c_str(),
ImVec2(cardWidth, 0), // 0 height = auto-size ImVec2(cardWidth, fixedHeight ? (cardBottomY - cardY) : 0.0f),
ImGuiChildFlags_AutoResizeY | ImGuiChildFlags_AlwaysUseWindowPadding, cflags, ImGuiWindowFlags_NoScrollbar);
ImGuiWindowFlags_NoScrollbar);
// Draw title bar with close button if (childVisible && spec.title) {
if (childVisible && title) { if (plainHd) {
DrawDialogTitleBar(title, p_open); 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; 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() inline void EndOverlayDialog()
{ {
ImGui::EndChild(); ImGui::EndChild();