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