fix(modals): hold dialog hidden until its height settles (no reveal-time slide)

An ImGui auto-resize child reports a too-small height on its first render and
the true height a frame later, so centering on last frame's height made the card
visibly slide for ~2 frames as it converged. Hiding only the very first frame
wasn't enough — the next frame or two still moved.

Track per-dialog height stability: keep the card hidden (laid out + measured, but
clipped) until its measured height holds steady across two frames, then reveal it
already centered. A dialog re-arms on (re)open so each open reveals stable; once
shown it won't re-hide on a mid-dialog content change (just repositions); a
frame cap prevents an ever-changing height from hiding it forever. Fixed-height
dialogs are unaffected. Verified migrate / about / seed-backup / import-key
reveal centered at the settled frame.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 15:29:13 -05:00
parent 5e5f167fb0
commit e6bea8eb04

View File

@@ -1283,10 +1283,19 @@ inline int SegmentedControl(ImDrawList* dl, ImVec2 origin, float totalW, float h
// Creates a fullscreen semi-transparent overlay with a centered card dialog.
// Similar to the shutdown screen pattern but for interactive dialogs.
// Per-dialog content height (keyed by the child's id) measured at the end of each frame, so the next
// frame can size the glass card to its content instead of a fixed viewport band. g_overlayCurrentKey
// carries the active dialog's key from BeginOverlayDialog to EndOverlayDialog (overlays don't nest).
inline std::unordered_map<std::string, float> g_overlayCardHeights;
// Per-dialog content state (keyed by the child's id), measured at the end of each frame so the next
// frame can position/size the (auto-height) card to its content. An auto-resize child reports a
// too-small height on its first render and the true height a frame later, so we keep the card hidden
// until the height holds steady (stableCount) before revealing it centered — otherwise it visibly
// slides for a couple frames as the height converges. g_overlayCurrentKey carries the active dialog's
// key from BeginOverlayDialog to EndOverlayDialog (overlays don't nest).
struct OverlayCardState {
float height = 0.0f; // last measured content-child height
int stableCount = 0; // consecutive frames the height held steady (within 1px)
int appearFrames = 0; // frames since (re)appearing while still hidden — a safety cap
bool shown = false; // revealed (centered) at least once this open; don't re-hide after
};
inline std::unordered_map<std::string, OverlayCardState> g_overlayCardHeights;
inline std::string g_overlayCurrentKey;
// Set when BeginOverlayDialog clips the card on an auto-height dialog's measuring frame (so it never
// flashes off-center); EndOverlayDialog pops the clip. Overlays don't nest, so a single flag is fine.
@@ -1365,6 +1374,10 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
return false;
}
// True only on the frame the dialog (re)opens — used to re-arm the auto-height settle so each
// fresh open reveals already-centered (rather than flashing last-open's stale position).
const bool scrimAppearing = ImGui::IsWindowAppearing();
ImDrawList* dl = ImGui::GetWindowDrawList();
// Live-blur backdrop with capture-once: (re)capture the content drawn BELOW this overlay for a
@@ -1414,18 +1427,26 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
g_overlayCurrentKey.clear();
} else {
g_overlayCurrentKey = childId;
auto it = g_overlayCardHeights.find(childId);
const float measuredH = (it != g_overlayCardHeights.end()) ? it->second : 0.0f;
if (measuredH > 0.0f) {
OverlayCardState& cs = g_overlayCardHeights[childId];
if (scrimAppearing) { cs.shown = false; cs.stableCount = 0; cs.appearFrames = 0; }
if (!cs.shown) cs.appearFrames++;
const float measuredH = cs.height;
// Reveal once the measured height has settled (auto-resize converges in ~2 frames) or it's
// already been shown this open (don't re-hide on a mid-dialog content change); a frame cap
// guarantees a pathological ever-changing height can't hide the dialog forever.
const bool ready = measuredH > 0.0f &&
(cs.shown || cs.stableCount >= 1 || cs.appearFrames >= 8);
if (ready) {
cs.shown = true;
// Center the measured content; if it's taller than the window, anchor at the top margin.
cardY = (measuredH < vp_size.y - 32.0f)
? vp_pos.y + (vp_size.y - measuredH) * 0.5f
: vp_pos.y + 16.0f;
cardBottomY = cardY + measuredH;
} else {
// First frame: content height not yet known. Lay it out (so the auto-height child gets
// measured) but keep the card hidden (hideForMeasure below) so it never flashes off-center
// — it appears, already centered, on the next frame once the height is known.
// Still settling: lay the content out (so the auto-height child gets measured) but keep
// the card hidden (hideForMeasure below) so it never flashes off-center — it appears,
// already centered and stable, once the height stops changing.
hideForMeasure = true;
cardY = vp_pos.y + vp_size.y * 0.15f;
cardBottomY = vp_pos.y + vp_size.y * spec.cardBottomViewportRatio;
@@ -1509,10 +1530,16 @@ inline void EndOverlayDialog()
{
if (g_overlayHideFrameClip) { ImGui::PopClipRect(); g_overlayHideFrameClip = false; }
ImGui::EndChild();
// Remember the rendered card height (the child is the last item) so the next frame's
// BeginOverlayDialog can size the glass to the content — kills the empty band under short dialogs.
// Remember the rendered card height (the child is the last item) + track whether it has stopped
// changing, so next frame's BeginOverlayDialog can reveal the card only once its height (and thus
// its centered position) is stable.
if (!g_overlayCurrentKey.empty()) {
g_overlayCardHeights[g_overlayCurrentKey] = ImGui::GetItemRectSize().y;
OverlayCardState& cs = g_overlayCardHeights[g_overlayCurrentKey];
const float h = ImGui::GetItemRectSize().y;
const float d = (h >= cs.height) ? (h - cs.height) : (cs.height - h);
if (h > 0.0f && d <= 1.0f) cs.stableCount++;
else cs.stableCount = 0;
cs.height = h;
}
ImGui::PopStyleColor(); // ChildBg
ImGui::PopStyleVar(2); // ChildRounding, WindowPadding (for child)