Rework DrawCardDropShadow (Light/Marble): a Gaussian-sampled ring stack matching a `0 0 4px rgb(50 53 58 / 34a)` box-shadow — offset-free, darker, and tighter than the old faint 14px black halo. Clip to the shadow's own bounding box (card ± reach) instead of the child window bounds so top/bottom shadows are no longer chopped where a card sits flush with its container, while staying on the card's own draw list (correct z-order in modals/popups). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1684 lines
79 KiB
C++
1684 lines
79 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
|
|
#pragma once
|
|
|
|
#include "colors.h"
|
|
#include "type.h"
|
|
#include "tooltip_style.h"
|
|
#include "../layout.h"
|
|
#include "../schema/element_styles.h"
|
|
#include "../schema/color_var_resolver.h"
|
|
#include "../schema/ui_schema.h"
|
|
#include "../effects/theme_effects.h"
|
|
#include "../effects/low_spec.h"
|
|
#include "../effects/imgui_acrylic.h"
|
|
#include "../theme.h"
|
|
#include "../../util/noise_texture.h"
|
|
#include "../../embedded/IconsMaterialDesign.h"
|
|
#include "imgui.h"
|
|
#include "imgui_internal.h"
|
|
#include <algorithm>
|
|
#include <unordered_map>
|
|
#include <cmath>
|
|
#include <string>
|
|
|
|
namespace dragonx {
|
|
namespace ui {
|
|
namespace material {
|
|
|
|
// Scale the alpha channel of an ImU32 color by a float factor.
|
|
inline ImU32 ScaleAlpha(ImU32 col, float scale) {
|
|
int a = static_cast<int>(((col >> IM_COL32_A_SHIFT) & 0xFF) * scale);
|
|
if (a > 255) a = 255;
|
|
return (col & ~IM_COL32_A_MASK) | (static_cast<ImU32>(a) << IM_COL32_A_SHIFT);
|
|
}
|
|
|
|
// True when the active theme is light (app background luminance is high).
|
|
inline bool IsLightTheme() {
|
|
ImU32 bg = Background();
|
|
float r = ((bg >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f;
|
|
float g = ((bg >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f;
|
|
float b = ((bg >> IM_COL32_B_SHIFT) & 0xFF) / 255.0f;
|
|
return (0.299f * r + 0.587f * g + 0.114f * b) > 0.5f;
|
|
}
|
|
|
|
// Animated "loading" ellipsis: "", ".", "..", "..." cycling on a ~3Hz phase.
|
|
inline const char* LoadingDots() {
|
|
int n = ((int)(ImGui::GetTime() * 3.0f)) % 4;
|
|
static const char* kDots[4] = { "", ".", "..", "..." };
|
|
return kDots[n];
|
|
}
|
|
|
|
// ============================================================================
|
|
// Text Drop Shadow
|
|
// ============================================================================
|
|
// Draw text with a subtle dark shadow behind it for readability on
|
|
// translucent / glassy surfaces. The shadow is a 1px offset copy of
|
|
// the text in a near-black colour. When the OS backdrop (DWM Acrylic)
|
|
// is inactive, the shadow is skipped since opaque backgrounds already
|
|
// provide enough contrast.
|
|
|
|
inline void DrawTextShadow(ImDrawList* dl, ImFont* font, float fontSize,
|
|
const ImVec2& pos, ImU32 col, const char* text,
|
|
float offsetX = 1.0f, float offsetY = 1.0f,
|
|
ImU32 shadowCol = 0)
|
|
{
|
|
if (IsBackdropActive()) {
|
|
if (!shadowCol) {
|
|
static uint32_t s_gen = 0;
|
|
static ImU32 s_shadowCol = 0;
|
|
uint32_t g = schema::UI().generation();
|
|
if (g != s_gen) { s_gen = g; s_shadowCol = schema::UI().resolveColor("var(--text-shadow)", IM_COL32(0, 0, 0, 120)); }
|
|
shadowCol = s_shadowCol;
|
|
}
|
|
dl->AddText(font, fontSize,
|
|
ImVec2(pos.x + offsetX, pos.y + offsetY),
|
|
shadowCol, text);
|
|
}
|
|
dl->AddText(font, fontSize, pos, col, text);
|
|
}
|
|
|
|
// Convenience overload that uses the current default font.
|
|
inline void DrawTextShadow(ImDrawList* dl, const ImVec2& pos, ImU32 col,
|
|
const char* text,
|
|
float offsetX = 1.0f, float offsetY = 1.0f,
|
|
ImU32 shadowCol = 0)
|
|
{
|
|
if (IsBackdropActive()) {
|
|
if (!shadowCol) {
|
|
static uint32_t s_gen = 0;
|
|
static ImU32 s_shadowCol = 0;
|
|
uint32_t g = schema::UI().generation();
|
|
if (g != s_gen) { s_gen = g; s_shadowCol = schema::UI().resolveColor("var(--text-shadow)", IM_COL32(0, 0, 0, 120)); }
|
|
shadowCol = s_shadowCol;
|
|
}
|
|
dl->AddText(ImVec2(pos.x + offsetX, pos.y + offsetY),
|
|
shadowCol, text);
|
|
}
|
|
dl->AddText(pos, col, text);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Modal-Aware Hover Check
|
|
// ============================================================================
|
|
// Drop-in replacement for ImGui::IsMouseHoveringRect that also respects
|
|
// modal popup blocking. The raw ImGui helper is a pure geometric test
|
|
// and will return true even when a modal popup covers the rect, which
|
|
// causes background elements to show hover highlights through dialogs.
|
|
|
|
inline int& OverlayDialogActiveFrame()
|
|
{
|
|
static int s_frame = -1;
|
|
return s_frame;
|
|
}
|
|
|
|
inline void MarkOverlayDialogActive()
|
|
{
|
|
OverlayDialogActiveFrame() = ImGui::GetFrameCount();
|
|
}
|
|
|
|
inline bool IsCurrentWindowOverlayDialog()
|
|
{
|
|
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
|
for (ImGuiWindow* node = window; node; node = node->ParentWindow) {
|
|
// Prefix match so both "##OverlayScrim" and suffixed "##OverlayScrim_<id>" blur overlays
|
|
// (used by nested dialogs and the unified blur-overlay framework) are recognized.
|
|
if (node->Name && strncmp(node->Name, "##OverlayScrim", 14) == 0)
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
inline bool IsOverlayDialogBlockingInput()
|
|
{
|
|
int activeFrame = OverlayDialogActiveFrame();
|
|
int currentFrame = ImGui::GetFrameCount();
|
|
return activeFrame == currentFrame || activeFrame == (currentFrame - 1);
|
|
}
|
|
|
|
inline bool IsRectHovered(const ImVec2& r_min, const ImVec2& r_max, bool clip = true)
|
|
{
|
|
if (!ImGui::IsMouseHoveringRect(r_min, r_max, clip))
|
|
return false;
|
|
if (IsOverlayDialogBlockingInput() && !IsCurrentWindowOverlayDialog())
|
|
return false;
|
|
// If a modal popup is open and it is not the current window, treat
|
|
// the content as non-hoverable (same logic ImGui uses internally
|
|
// inside IsWindowContentHoverable for modal blocking).
|
|
//
|
|
// We cannot rely solely on GetTopMostAndVisiblePopupModal() because
|
|
// it checks Active, which is only set when BeginPopupModal() is
|
|
// called in the current frame. Content tabs render BEFORE their
|
|
// associated dialogs, so the modal's Active flag is still false at
|
|
// this point. Checking WasActive (set from the previous frame)
|
|
// covers this render-order gap.
|
|
ImGuiContext& g = *ImGui::GetCurrentContext();
|
|
for (int n = g.OpenPopupStack.Size - 1; n >= 0; n--) {
|
|
ImGuiWindow* popup = g.OpenPopupStack.Data[n].Window;
|
|
if (popup && (popup->Flags & ImGuiWindowFlags_Modal)) {
|
|
if ((popup->Active || popup->WasActive) && !popup->Hidden) {
|
|
if (popup != ImGui::GetCurrentWindow())
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Tactile Button Overlay
|
|
// ============================================================================
|
|
// Adds a subtle top-edge highlight and bottom-edge shadow to a button rect,
|
|
// creating the illusion of a raised physical surface. On active (pressed),
|
|
// the highlight/shadow swap to create an inset "pushed" feel.
|
|
// Call this AFTER ImGui::Button() using GetItemRectMin()/GetItemRectMax().
|
|
|
|
inline void DrawTactileOverlay(ImDrawList* dl, const ImVec2& bMin,
|
|
const ImVec2& bMax, float rounding,
|
|
bool active = false)
|
|
{
|
|
float h = bMax.y - bMin.y;
|
|
float edgeH = std::min(h * 0.38f, 6.0f); // highlight/shadow strip height
|
|
|
|
// AddRectFilledMultiColor does not support rounding, so clip to the
|
|
// button's rounded rect to prevent sharp corners from poking out.
|
|
if (rounding > 0.0f) {
|
|
float inset = rounding * 0.29f; // enough to hide corners
|
|
dl->PushClipRect(
|
|
ImVec2(bMin.x + inset, bMin.y + inset),
|
|
ImVec2(bMax.x - inset, bMax.y - inset), true);
|
|
}
|
|
|
|
ImU32 tHi = schema::UI().resolveColor("var(--tactile-top)", IM_COL32(255, 255, 255, 18));
|
|
ImU32 tHiT = tHi & ~IM_COL32_A_MASK; // transparent version
|
|
|
|
if (!active) {
|
|
// Raised: bright top edge, dark bottom edge
|
|
// Top highlight
|
|
dl->AddRectFilledMultiColor(
|
|
bMin, ImVec2(bMax.x, bMin.y + edgeH),
|
|
tHi, tHi, tHiT, tHiT);
|
|
// Bottom shadow
|
|
dl->AddRectFilledMultiColor(
|
|
ImVec2(bMin.x, bMax.y - edgeH), bMax,
|
|
IM_COL32(0, 0, 0, 0), // top-left
|
|
IM_COL32(0, 0, 0, 0), // top-right
|
|
IM_COL32(0, 0, 0, 22), // bottom-right
|
|
IM_COL32(0, 0, 0, 22)); // bottom-left
|
|
} else {
|
|
// Pressed: dark top edge, bright bottom edge (inset)
|
|
// Top shadow
|
|
dl->AddRectFilledMultiColor(
|
|
bMin, ImVec2(bMax.x, bMin.y + edgeH),
|
|
IM_COL32(0, 0, 0, 20),
|
|
IM_COL32(0, 0, 0, 20),
|
|
IM_COL32(0, 0, 0, 0),
|
|
IM_COL32(0, 0, 0, 0));
|
|
// Bottom highlight (dimmer when pressed)
|
|
ImU32 pHi = ScaleAlpha(tHi, 0.55f);
|
|
ImU32 pHiT = pHi & ~IM_COL32_A_MASK;
|
|
dl->AddRectFilledMultiColor(
|
|
ImVec2(bMin.x, bMax.y - edgeH), bMax,
|
|
pHiT, pHiT, pHi, pHi);
|
|
}
|
|
|
|
if (rounding > 0.0f)
|
|
dl->PopClipRect();
|
|
}
|
|
|
|
// Glass-card button surface: frosted-glass hover/idle fill (skipped while
|
|
// pressed) + rim light + tactile depth overlay. Shared by the tactile
|
|
// button wrappers. Call after computing the button rect / rounding /
|
|
// active / hovered state.
|
|
inline void DrawButtonGlassOverlay(ImDrawList* dl, const ImVec2& bMin,
|
|
const ImVec2& bMax, float rounding,
|
|
bool active, bool hovered)
|
|
{
|
|
// Light themes: flat buttons (like the Manage-portfolio modal's plain buttons). Skip the glass
|
|
// sheen + tactile 3D — ImGui::Button already draws the fill/hover/active — and add just a subtle
|
|
// defining edge, which reads cleaner on light surfaces than the raised glass look.
|
|
if (IsLightTheme()) {
|
|
ImU32 rim = schema::UI().resolveColor("var(--rim-light)", IM_COL32(0, 0, 0, 50));
|
|
dl->AddRect(bMin, bMax, active ? ScaleAlpha(rim, 0.6f) : rim, rounding, 0, 1.0f);
|
|
return;
|
|
}
|
|
// Frosted glass highlight — subtle fill on top
|
|
if (!active) {
|
|
ImU32 col = hovered
|
|
? schema::UI().resolveColor("var(--hover-overlay)", IM_COL32(255, 255, 255, 12))
|
|
: schema::UI().resolveColor("var(--glass-fill)", IM_COL32(255, 255, 255, 6));
|
|
dl->AddRectFilled(bMin, bMax, col, rounding);
|
|
}
|
|
// Rim light
|
|
ImU32 rim = schema::UI().resolveColor("var(--rim-light)", IM_COL32(255, 255, 255, 25));
|
|
dl->AddRect(bMin, bMax,
|
|
active ? ScaleAlpha(rim, 0.6f) : rim,
|
|
rounding, 0, 1.0f);
|
|
// Tactile depth
|
|
DrawTactileOverlay(dl, bMin, bMax, rounding, active);
|
|
}
|
|
|
|
// ── Button font tier helper ─────────────────────────────────────────────
|
|
// Resolves an int tier (0=sm, 1=md/default, 2=lg) to the matching ImFont*.
|
|
// Passing -1 or any out-of-range value returns the default button font.
|
|
|
|
inline ImFont* resolveButtonFont(int tier)
|
|
{
|
|
switch (tier) {
|
|
case 0: return Type().buttonSm();
|
|
case 2: return Type().buttonLg();
|
|
default: return Type().button(); // 1 or any other value
|
|
}
|
|
}
|
|
|
|
// Resolve per-button font: if perButton >= 0 use it, else fall back to sectionDefault
|
|
inline ImFont* resolveButtonFont(int perButton, int sectionDefault)
|
|
{
|
|
return resolveButtonFont(perButton >= 0 ? perButton : sectionDefault);
|
|
}
|
|
|
|
// ── Progress / fill bar ─────────────────────────────────────────────────
|
|
// A rounded track with a fraction (0..1) fill, drawn at the current cursor and advancing the layout
|
|
// cursor past it (matches the download-dialog usage). For a placement you control, use the pMin/pMax
|
|
// overload. Defaults: theme Primary fill, a subtle white track, 4dp corners.
|
|
inline void DrawProgressBar(ImDrawList* dl, const ImVec2& pMin, const ImVec2& pMax, float frac01,
|
|
ImU32 fill = 0, ImU32 track = IM_COL32(255, 255, 255, 30), float rounding = -1.0f)
|
|
{
|
|
if (fill == 0) fill = Primary();
|
|
if (rounding < 0.0f) rounding = 4.0f * Layout::dpiScale();
|
|
frac01 = frac01 < 0.0f ? 0.0f : (frac01 > 1.0f ? 1.0f : frac01);
|
|
dl->AddRectFilled(pMin, pMax, track, rounding);
|
|
if (frac01 > 0.0f)
|
|
dl->AddRectFilled(pMin, ImVec2(pMin.x + (pMax.x - pMin.x) * frac01, pMax.y), fill, rounding);
|
|
}
|
|
|
|
inline void ProgressBar(float width, float height, float frac01,
|
|
ImU32 fill = 0, ImU32 track = IM_COL32(255, 255, 255, 30))
|
|
{
|
|
ImVec2 pMin = ImGui::GetCursorScreenPos();
|
|
DrawProgressBar(ImGui::GetWindowDrawList(), pMin, ImVec2(pMin.x + width, pMin.y + height), frac01, fill, track);
|
|
ImGui::Dummy(ImVec2(0, height));
|
|
}
|
|
|
|
// ── Click-to-copy affordance ────────────────────────────────────────────
|
|
// Turns an already-drawn text run into a click-to-copy control. The caller draws the visible text
|
|
// (font/color/position of its choosing); this places an invisible hit button (textSize + hitPad) at
|
|
// `pos`, shows a hand cursor + tooltip + a hover underline, and copies `copyValue` on click. Returns
|
|
// true the frame it was clicked (already on the clipboard) so the caller can show its own toast.
|
|
// Caller manages any cursor save/restore around the call.
|
|
inline bool CopyableTextOverlay(const char* id, const ImVec2& pos, const ImVec2& textSize, float fontSize,
|
|
const char* copyValue, const char* tooltip, const ImVec2& hitPad)
|
|
{
|
|
const float dp = Layout::dpiScale();
|
|
ImGui::SetCursorScreenPos(pos);
|
|
ImGui::InvisibleButton(id, ImVec2(textSize.x + hitPad.x, fontSize + hitPad.y));
|
|
if (ImGui::IsItemHovered()) {
|
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
if (tooltip && tooltip[0]) Tooltip("%s", tooltip);
|
|
const float uy = pos.y + fontSize + 1.0f * dp;
|
|
ImGui::GetWindowDrawList()->AddLine(ImVec2(pos.x, uy), ImVec2(pos.x + textSize.x, uy),
|
|
WithAlpha(OnSurface(), 60), 1.0f * dp);
|
|
}
|
|
bool copied = false;
|
|
if (ImGui::IsItemClicked()) {
|
|
ImGui::SetClipboardText(copyValue);
|
|
copied = true;
|
|
}
|
|
return copied;
|
|
}
|
|
|
|
// ── Pill / badge ─────────────────────────────────────────────────────────
|
|
// A rounded chip: a filled background sized to `text` + `pad`, an optional 1px border, and the text
|
|
// inset by `pad`. Use PillSize first when you need to right/center-anchor (compute the width, place
|
|
// the pill, then draw).
|
|
inline ImVec2 PillSize(const char* text, ImFont* font, const ImVec2& pad)
|
|
{
|
|
ImVec2 t = font->CalcTextSizeA(font->LegacySize, FLT_MAX, 0.0f, text);
|
|
return ImVec2(t.x + pad.x * 2.0f, font->LegacySize + pad.y * 2.0f);
|
|
}
|
|
|
|
// Draw a pill/badge anchored at top-left `pos`. `border` == 0 draws no outline. rounding < 0 => fully
|
|
// rounded (height/2). Returns the pill's total size.
|
|
inline ImVec2 DrawPill(ImDrawList* dl, const ImVec2& pos, const char* text, ImFont* font,
|
|
ImU32 fg, ImU32 bg, ImU32 border = 0,
|
|
const ImVec2& pad = ImVec2(4.0f, 2.0f), float rounding = -1.0f)
|
|
{
|
|
ImVec2 sz = PillSize(text, font, pad);
|
|
if (rounding < 0.0f) rounding = sz.y * 0.5f;
|
|
ImVec2 pMax(pos.x + sz.x, pos.y + sz.y);
|
|
dl->AddRectFilled(pos, pMax, bg, rounding);
|
|
if (border != 0) dl->AddRect(pos, pMax, border, rounding, 0, 1.0f * Layout::dpiScale());
|
|
dl->AddText(font, font->LegacySize, ImVec2(pos.x + pad.x, pos.y + pad.y), fg, text);
|
|
return sz;
|
|
}
|
|
|
|
// ── Labeled form input ───────────────────────────────────────────────────
|
|
// The app's common form-field idiom: a plain label above a fixed-width single-line input
|
|
// (ImGui::Text label + SetNextItemWidth + InputText / InputTextWithHint). Mirrors the existing
|
|
// pattern exactly (no visual change) so input styling can later evolve in ONE place. `width` < 0
|
|
// means full width (-1). Pass a non-null `hint` to use InputTextWithHint. Returns the input's
|
|
// edited bool. The label is drawn via "%s" so a stray '%' in the text is safe.
|
|
inline bool LabeledInput(const char* label, const char* id, char* buf, size_t bufSize,
|
|
float width = -1.0f, const char* hint = nullptr,
|
|
ImGuiInputTextFlags flags = 0)
|
|
{
|
|
ImGui::Text("%s", label);
|
|
ImGui::SetNextItemWidth(width);
|
|
if (hint && hint[0]) return ImGui::InputTextWithHint(id, hint, buf, bufSize, flags);
|
|
return ImGui::InputText(id, buf, bufSize, flags);
|
|
}
|
|
|
|
// Horizontal variant: label on the left, input to its right on the same line (settings-style rows).
|
|
// `labelPos` is the SameLine offset the input starts at (0 = default item spacing after the label).
|
|
inline bool LabeledInputRow(const char* label, float labelPos, const char* id, char* buf, size_t bufSize,
|
|
float width = -1.0f, const char* hint = nullptr, ImGuiInputTextFlags flags = 0)
|
|
{
|
|
ImGui::Text("%s", label);
|
|
ImGui::SameLine(labelPos);
|
|
ImGui::SetNextItemWidth(width);
|
|
if (hint && hint[0]) return ImGui::InputTextWithHint(id, hint, buf, bufSize, flags);
|
|
return ImGui::InputText(id, buf, bufSize, flags);
|
|
}
|
|
|
|
// Multiline variant: label above a multiline input of the given `size` (InputTextMultiline governs its
|
|
// own width via size, so no SetNextItemWidth). Returns the input's edited bool.
|
|
inline bool LabeledInputMultiline(const char* label, const char* id, char* buf, size_t bufSize,
|
|
const ImVec2& size, ImGuiInputTextFlags flags = 0)
|
|
{
|
|
ImGui::Text("%s", label);
|
|
return ImGui::InputTextMultiline(id, buf, bufSize, size, flags);
|
|
}
|
|
|
|
// ── Frameless icon button ────────────────────────────────────────────────
|
|
// Style for IconButton. All colors default to 0 (= unset): color falls back to OnSurfaceMedium();
|
|
// hoverColor==0 means "no hover recolor"; hoverBg/restBg==0 mean "no background".
|
|
struct IconButtonStyle {
|
|
ImU32 color = 0; // resting glyph color (0 => OnSurfaceMedium())
|
|
ImU32 hoverColor = 0; // glyph color while hovered (0 => same as color)
|
|
ImU32 hoverBg = 0; // fill drawn behind the glyph on hover only (0 => none)
|
|
ImU32 restBg = 0; // fill drawn every frame, e.g. an "active" pill (0 => none)
|
|
float bgRounding = -1.0f; // <0 => size.y*0.5 (pill/circle for a square); else that radius
|
|
bool handCursor = true;
|
|
const char* tooltip = nullptr;
|
|
};
|
|
|
|
// A frameless / pill icon button: an InvisibleButton(id, size) at the current cursor with a centered
|
|
// `glyph` (in `font`), optional resting + hover backgrounds, hover recolor, hand cursor + tooltip.
|
|
// Returns true the frame it is clicked. The caller resolves any state-dependent glyph/colors/tooltip
|
|
// before the call and positions the cursor (SetCursorScreenPos / SameLine) as needed.
|
|
inline bool IconButton(const char* id, const char* glyph, ImFont* font, const ImVec2& size,
|
|
const IconButtonStyle& st)
|
|
{
|
|
const ImVec2 pos = ImGui::GetCursorScreenPos();
|
|
ImGui::InvisibleButton(id, size);
|
|
const bool hov = ImGui::IsItemHovered();
|
|
const bool clk = ImGui::IsItemClicked();
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
const ImVec2 pMax(pos.x + size.x, pos.y + size.y);
|
|
const float round = st.bgRounding < 0.0f ? size.y * 0.5f : st.bgRounding;
|
|
if (st.restBg != 0) dl->AddRectFilled(pos, pMax, st.restBg, round);
|
|
if (hov && st.hoverBg != 0) dl->AddRectFilled(pos, pMax, st.hoverBg, round);
|
|
if (hov) {
|
|
if (st.handCursor) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
|
if (st.tooltip && st.tooltip[0]) Tooltip("%s", st.tooltip);
|
|
}
|
|
const ImU32 gcol = (hov && st.hoverColor != 0) ? st.hoverColor
|
|
: (st.color != 0 ? st.color : OnSurfaceMedium());
|
|
const ImVec2 gsz = font->CalcTextSizeA(font->LegacySize, FLT_MAX, 0, glyph);
|
|
dl->AddText(font, font->LegacySize,
|
|
ImVec2(pos.x + (size.x - gsz.x) * 0.5f, pos.y + (size.y - gsz.y) * 0.5f), gcol, glyph);
|
|
return clk;
|
|
}
|
|
|
|
// ── Tactile wrappers ────────────────────────────────────────────────────
|
|
// Drop-in replacements for ImGui::Button / SmallButton that automatically
|
|
// add the tactile highlight overlay after rendering.
|
|
|
|
inline bool TactileButton(const char* label, const ImVec2& size = ImVec2(0, 0), ImFont* font = nullptr)
|
|
{
|
|
// Draw button with glass-card styling: translucent fill + border + tactile overlay
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
ImFont* useFont = font ? font : Type().button();
|
|
|
|
// For icon fonts, use InvisibleButton + manual centered text rendering
|
|
// to ensure perfect centering (ImGui::Button alignment can be off for icons)
|
|
bool isIconFont = font && (font == Type().iconSmall() || font == Type().iconMed() ||
|
|
font == Type().iconLarge() || font == Type().iconXL());
|
|
|
|
bool pressed;
|
|
if (isIconFont && size.x > 0 && size.y > 0) {
|
|
pressed = ImGui::InvisibleButton(label, size);
|
|
} else {
|
|
ImGui::PushFont(useFont);
|
|
pressed = ImGui::Button(label, size);
|
|
ImGui::PopFont();
|
|
}
|
|
// Glass overlay on the button rect
|
|
ImVec2 bMin = ImGui::GetItemRectMin();
|
|
ImVec2 bMax = ImGui::GetItemRectMax();
|
|
|
|
// For icon fonts, manually draw centered icon after getting button rect
|
|
if (isIconFont && size.x > 0 && size.y > 0) {
|
|
ImVec2 textSz = useFont->CalcTextSizeA(useFont->LegacySize, FLT_MAX, 0, label);
|
|
ImVec2 textPos(bMin.x + (size.x - textSz.x) * 0.5f, bMin.y + (size.y - textSz.y) * 0.5f);
|
|
dl->AddText(useFont, useFont->LegacySize, textPos, ImGui::GetColorU32(ImGuiCol_Text), label);
|
|
}
|
|
|
|
float rounding = ImGui::GetStyle().FrameRounding;
|
|
bool active = ImGui::IsItemActive();
|
|
bool hovered = ImGui::IsItemHovered();
|
|
DrawButtonGlassOverlay(dl, bMin, bMax, rounding, active, hovered);
|
|
return pressed;
|
|
}
|
|
|
|
inline bool TactileSmallButton(const char* label, ImFont* font = nullptr)
|
|
{
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
ImGui::PushFont(font ? font : Type().button());
|
|
bool pressed = ImGui::SmallButton(label);
|
|
ImGui::PopFont();
|
|
ImVec2 bMin = ImGui::GetItemRectMin();
|
|
ImVec2 bMax = ImGui::GetItemRectMax();
|
|
float rounding = ImGui::GetStyle().FrameRounding;
|
|
bool active = ImGui::IsItemActive();
|
|
bool hovered = ImGui::IsItemHovered();
|
|
DrawButtonGlassOverlay(dl, bMin, bMax, rounding, active, hovered);
|
|
return pressed;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Collapsible section header
|
|
// ============================================================================
|
|
// Renders a full-width transparent clickable header row: `label` drawn on the
|
|
// left and an expand/collapse chevron on the right. Toggles `expanded` when
|
|
// clicked and returns the (possibly toggled) state. The label font governs
|
|
// the row height (frame height) and text metrics; the chevron uses the small
|
|
// icon font. `arrowInset` shifts the chevron left of the right edge.
|
|
inline bool CollapsibleHeader(ImDrawList* dl, const char* id, const char* label,
|
|
bool& expanded, float width, ImFont* labelFont,
|
|
ImU32 labelCol, float arrowInset = 0.0f)
|
|
{
|
|
const char* arrow = expanded ? ICON_MD_EXPAND_LESS : ICON_MD_EXPAND_MORE;
|
|
ImGui::PushFont(labelFont);
|
|
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0,0,0,0));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(1,1,1,0.05f));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(1,1,1,0.08f));
|
|
{
|
|
ImVec2 hdrPos = ImGui::GetCursorScreenPos();
|
|
if (ImGui::Button(id, ImVec2(width, ImGui::GetFrameHeight()))) {
|
|
expanded = !expanded;
|
|
}
|
|
float textY = hdrPos.y + (ImGui::GetFrameHeight() - labelFont->LegacySize) * 0.5f;
|
|
dl->AddText(labelFont, labelFont->LegacySize, ImVec2(hdrPos.x, textY), labelCol, label);
|
|
ImFont* iconFont = Type().iconSmall();
|
|
if (!iconFont) iconFont = labelFont;
|
|
float arrowW = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, arrow).x;
|
|
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(hdrPos.x + width - arrowW - arrowInset, textY), labelCol, arrow);
|
|
}
|
|
ImGui::PopStyleColor(3);
|
|
ImGui::PopFont();
|
|
return expanded;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Glass Panel (glassmorphism card)
|
|
// ============================================================================
|
|
// Draws a frosted-glass style panel: a semi-transparent fill with a
|
|
// subtle light border. Used for card/panel containers that sit above
|
|
// the blurred background. When the backdrop is inactive the fill is
|
|
// simply the normal surface colour (fully opaque).
|
|
|
|
struct GlassPanelSpec {
|
|
float rounding = 6.0f;
|
|
int fillAlpha = 18; // fill brightness (white, 0-255)
|
|
int borderAlpha = 30; // border brightness (white, 0-255)
|
|
float borderWidth = 1.0f;
|
|
};
|
|
|
|
// When a full-window blur overlay (e.g. the portfolio modal) is active, glass panels use their
|
|
// opaque fallback instead of the acrylic blur. They're covered by the overlay's backdrop anyway, and
|
|
// this leaves the backdrop as the SOLE applyBlur caller — so it can use its own (stronger) blur
|
|
// radius without thrashing the shared, radius-keyed blur cache. Toggled from App::render().
|
|
inline bool& FullWindowBlurOverlayActiveRef() { static bool v = false; return v; }
|
|
inline void SetFullWindowBlurOverlayActive(bool v) { FullWindowBlurOverlayActiveRef() = v; }
|
|
inline bool IsFullWindowBlurOverlayActive() { return FullWindowBlurOverlayActiveRef(); }
|
|
|
|
// Light-surface themes (Light / Marble) want cards to lift off the pale background with a subtle
|
|
// drop shadow. Cached per theme-generation so the string compare runs once per theme load, not per
|
|
// panel. (Data-driven later if more themes want it.)
|
|
inline bool CardDropShadowWanted()
|
|
{
|
|
static uint32_t s_gen = ~0u;
|
|
static bool s_want = false;
|
|
uint32_t g = schema::UI().generation();
|
|
if (g != s_gen) {
|
|
s_gen = g;
|
|
const std::string& tn = schema::UI().themeName();
|
|
s_want = (tn == "Light" || tn == "Marble");
|
|
}
|
|
return s_want;
|
|
}
|
|
|
|
// Soft, UNIFORM shadow around a rounded rect — no directional offset, equal on all sides. Drawn as
|
|
// fading rounded-rect STROKES (not fills), so it never paints across the card body — only the outer
|
|
// blur remains once the card fill covers the interior. The "render then mask out the UI area" result
|
|
// without an FBO.
|
|
inline void DrawCardDropShadow(ImDrawList* dl, const ImVec2& pMin, const ImVec2& pMax, float rounding)
|
|
{
|
|
const float dp = Layout::dpiScale();
|
|
// Matches the design mockup's `box-shadow: 0 0 4px rgb(50 53 58 / 26%)`: a tight, darker, colored
|
|
// uniform shadow. Sampled as a stack of rounded-rect strokes stepping OUTWARD from the card edge
|
|
// with a Gaussian alpha falloff (darkest at the edge, ~0 by the tail). ~1px-spaced, near-1px-thick
|
|
// rings keep overlap (and thus alpha accumulation) low, so the peak reads close to the target.
|
|
const float blur = 4.0f * dp; // CSS-like blur radius
|
|
const float reach = blur * 1.5f; // how far the shadow extends beyond the card edge (all sides)
|
|
const float sigma = blur * 0.6f;
|
|
const float peak = 34.0f; // edge alpha for rgb(50,53,58) (~13% after the card fill masks the inner half)
|
|
const int steps = std::max(6, (int)(reach / dp + 0.5f));
|
|
|
|
// The shadow rides on the card's OWN draw list (so it keeps the right z-order in EVERY context —
|
|
// main content, modals, foreground popups, notifications; a global background draw list would drop
|
|
// a modal/popup card's shadow behind its scrim). But cards often live inside clipping child windows
|
|
// whose clip rect chops the shadow wherever a card sits flush with the child's top/bottom/side edge.
|
|
// Clip instead to the shadow's own bounding box (card ± reach), so all four sides show in full —
|
|
// bounded on each side to at most `reach` beyond the child clip (and the viewport) so a card scrolled
|
|
// partly out of a scroll area still can't smear its shadow more than the halo width into neighbours.
|
|
ImGuiViewport* vp = ImGui::GetMainViewport();
|
|
const ImVec2 cur0 = dl->GetClipRectMin();
|
|
const ImVec2 cur1 = dl->GetClipRectMax();
|
|
const float x0 = std::max(vp->Pos.x, std::max(pMin.x - reach, cur0.x - reach));
|
|
const float y0 = std::max(vp->Pos.y, std::max(pMin.y - reach, cur0.y - reach));
|
|
const float x1 = std::min(vp->Pos.x + vp->Size.x, std::min(pMax.x + reach, cur1.x + reach));
|
|
const float y1 = std::min(vp->Pos.y + vp->Size.y, std::min(pMax.y + reach, cur1.y + reach));
|
|
dl->PushClipRect(ImVec2(x0, y0), ImVec2(x1, y1), false);
|
|
for (int i = 0; i < steps; ++i) {
|
|
float d = reach * (float)i / (float)(steps - 1); // 0 (card edge) .. reach (outer tail)
|
|
float g = std::exp(-(d * d) / (2.0f * sigma * sigma));
|
|
int a = (int)(peak * g + 0.5f);
|
|
if (a < 1) continue;
|
|
ImVec2 smn(pMin.x - d, pMin.y - d);
|
|
ImVec2 smx(pMax.x + d, pMax.y + d);
|
|
dl->AddRect(smn, smx, IM_COL32(50, 53, 58, a), rounding + d, 0, 1.3f * dp);
|
|
}
|
|
dl->PopClipRect();
|
|
}
|
|
|
|
inline void DrawGlassPanel(ImDrawList* dl, const ImVec2& pMin,
|
|
const ImVec2& pMax,
|
|
const GlassPanelSpec& spec = GlassPanelSpec())
|
|
{
|
|
// Subtle drop shadow behind the card (light themes only). Drawn first so the card fill below
|
|
// covers the interior, leaving only the outer soft blur — the card body is never contaminated.
|
|
if (CardDropShadowWanted())
|
|
DrawCardDropShadow(dl, pMin, pMax, spec.rounding);
|
|
|
|
if (IsBackdropActive() && !dragonx::ui::effects::isLowSpecMode() && !IsFullWindowBlurOverlayActive()) {
|
|
// --- Cached color lookups (invalidated on theme change) ---
|
|
// These 3 resolveColor() calls do string parsing + map lookup
|
|
// each time. Cache them per-frame using the schema generation
|
|
// counter so they resolve at most once per theme load.
|
|
static uint32_t s_gen = 0;
|
|
static ImU32 s_glassFill = 0;
|
|
static ImU32 s_glassNoiseTint = 0;
|
|
static ImU32 s_glassBorder = 0;
|
|
uint32_t curGen = schema::UI().generation();
|
|
if (curGen != s_gen) {
|
|
s_gen = curGen;
|
|
s_glassFill = schema::UI().resolveColor("var(--glass-fill)", IM_COL32(255, 255, 255, 18));
|
|
s_glassNoiseTint = schema::UI().resolveColor("var(--glass-noise-tint)", IM_COL32(255, 255, 255, 10));
|
|
s_glassBorder = schema::UI().resolveColor("var(--glass-border)", IM_COL32(255, 255, 255, 30));
|
|
}
|
|
|
|
float uiOp = effects::ImGuiAcrylic::GetUIOpacity();
|
|
bool useAcrylic = effects::ImGuiAcrylic::IsEnabled()
|
|
&& effects::ImGuiAcrylic::IsAvailable();
|
|
|
|
// Glass / acrylic layer — only rendered when not fully opaque
|
|
// (skip the blur pass at 100% for performance)
|
|
if (uiOp < 0.99f) {
|
|
if (useAcrylic) {
|
|
const auto& acrylicTheme = GetCurrentAcrylicTheme();
|
|
effects::ImGuiAcrylic::DrawAcrylicRect(dl, pMin, pMax,
|
|
acrylicTheme.card, spec.rounding);
|
|
} else {
|
|
// Lightweight fake-glass: translucent fill
|
|
ImU32 fill = (spec.fillAlpha == 18) ? s_glassFill : ScaleAlpha(s_glassFill, spec.fillAlpha / 18.0f);
|
|
dl->AddRectFilled(pMin, pMax, fill, spec.rounding);
|
|
}
|
|
}
|
|
|
|
// Surface overlay — provides smooth transition from glass to opaque.
|
|
// At uiOp=1.0 this fully covers the panel (opaque card).
|
|
// As uiOp decreases, glass/blur progressively shows through.
|
|
dl->AddRectFilled(pMin, pMax,
|
|
WithAlphaF(GetElevatedSurface(GetCurrentColorTheme(), 1), uiOp),
|
|
spec.rounding);
|
|
|
|
// Noise grain overlay — drawn OVER the surface overlay so card
|
|
// opacity doesn't hide it. Gives cards a tactile paper feel.
|
|
// Noise tint is cached per-generation, opacity checked each panel.
|
|
{
|
|
float noiseMul = dragonx::ui::effects::ImGuiAcrylic::GetNoiseOpacity();
|
|
if (noiseMul > 0.0f) {
|
|
// Tint base color changes only on theme reload; opacity slider may change per-frame
|
|
static uint32_t s_noiseGen = 0;
|
|
static uint8_t s_baseAlpha = 0;
|
|
if (curGen != s_noiseGen) {
|
|
s_noiseGen = curGen;
|
|
s_baseAlpha = (s_glassNoiseTint >> IM_COL32_A_SHIFT) & 0xFF;
|
|
}
|
|
uint8_t scaledAlpha = static_cast<uint8_t>(std::min(255.0f, s_baseAlpha * noiseMul));
|
|
ImU32 noiseTint = (s_glassNoiseTint & ~(0xFFu << IM_COL32_A_SHIFT)) | (scaledAlpha << IM_COL32_A_SHIFT);
|
|
float inset = spec.rounding * 0.3f;
|
|
ImVec2 noiseMin(pMin.x + inset, pMin.y + inset);
|
|
ImVec2 noiseMax(pMax.x - inset, pMax.y - inset);
|
|
// Image rect matches clip bounds exactly — no PushClipRect needed
|
|
dragonx::util::DrawTiledNoiseRect(dl, noiseMin, noiseMax, noiseTint);
|
|
}
|
|
}
|
|
|
|
// Border — fades with UI opacity
|
|
ImU32 border = (spec.borderAlpha == 30) ? s_glassBorder : ScaleAlpha(s_glassBorder, spec.borderAlpha / 30.0f);
|
|
if (uiOp < 0.99f) border = ScaleAlpha(border, uiOp);
|
|
dl->AddRect(pMin, pMax, border,
|
|
spec.rounding, 0, spec.borderWidth);
|
|
|
|
// Theme visual effects drawn on ForegroundDrawList so they
|
|
// render above card content (text, values, etc.), not below.
|
|
auto& fx = effects::ThemeEffects::instance();
|
|
if (fx.hasAnyPanelEffect()) {
|
|
ImDrawList* fxDl = ImGui::GetForegroundDrawList();
|
|
if (fx.hasRainbowBorder()) {
|
|
fx.drawRainbowBorder(fxDl, pMin, pMax, spec.rounding, spec.borderWidth);
|
|
}
|
|
if (fx.hasShimmer()) {
|
|
fx.drawShimmer(fxDl, pMin, pMax, spec.rounding);
|
|
}
|
|
if (fx.hasSpecularGlare()) {
|
|
fx.drawSpecularGlare(fxDl, pMin, pMax, spec.rounding);
|
|
}
|
|
// Per-panel theme effects: edge trace + ember rise
|
|
fx.drawPanelEffects(fxDl, pMin, pMax, spec.rounding);
|
|
}
|
|
} else {
|
|
// Low-spec opaque fallback
|
|
dl->AddRectFilled(pMin, pMax,
|
|
GetElevatedSurface(GetCurrentColorTheme(), 1), spec.rounding);
|
|
}
|
|
}
|
|
|
|
// Full-window backdrop for a modal overlay. Lays an OPAQUE base first (so the sheet reads as
|
|
// opaque regardless of the UI-opacity slider — the acrylic path multiplies its alpha by
|
|
// GetUIOpacity(), so it can't guarantee opacity on its own), then — only when the acrylic backdrop
|
|
// is active and we are NOT in low-performance mode — draws a strong blur of the (static) window
|
|
// backdrop on top plus a dim. In low-perf mode only the opaque base is drawn (no blur cost),
|
|
// mirroring DrawGlassPanel's gating.
|
|
// `allowBlur=false` draws only the opaque base (used on the overlay's first frame, before the live
|
|
// content has been captured, to avoid a one-frame blur of stale/background pixels).
|
|
inline void DrawFullWindowBlurBackdrop(ImDrawList* dl, const ImVec2& pMin, const ImVec2& pMax,
|
|
bool allowBlur = true)
|
|
{
|
|
// Theme-aware tones: a light theme gets a light frosted backdrop, a dark theme a dark one — the
|
|
// backdrop tints toward the app background rather than a hardcoded dark.
|
|
dl->AddRectFilled(pMin, pMax, WithAlpha(Background(), 255)); // opaque base (fallback / faint-blur show-through)
|
|
if (allowBlur && IsBackdropActive() && !dragonx::ui::effects::isLowSpecMode()
|
|
&& effects::ImGuiAcrylic::IsEnabled() && effects::ImGuiAcrylic::IsAvailable()) {
|
|
// Safe to use a strong custom radius here: while a full-window overlay is active every glass
|
|
// panel skips acrylic (IsFullWindowBlurOverlayActive), so the backdrop is the SOLE applyBlur
|
|
// caller — no other radius contends for the shared, radius-keyed blur cache.
|
|
auto params = GetCurrentAcrylicTheme().card;
|
|
params.blurRadius = 64.0f; // strong full-window blur
|
|
params.fallbackColor.w = 1.0f; // full-strength blur so the live content reads
|
|
effects::ImGuiAcrylic::DrawAcrylicRect(dl, pMin, pMax, params, 0.0f);
|
|
dl->AddRectFilled(pMin, pMax, WithAlpha(Background(), 70)); // subtle theme-tinted frost so the card reads as foreground
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Glass Card Scope (RAII) — channel-split glass-card scaffold
|
|
// ============================================================================
|
|
// Captures the current cursor as the card's top-left, splits the draw list
|
|
// into two channels (content on 1, glass on 0), insets the content by `pad`,
|
|
// and — on scope exit — appends the bottom pad, sizes the card to its content,
|
|
// draws the glass panel behind it on channel 0, merges, and advances the
|
|
// cursor past the card. `width` is the full card width, `bottomPad` the
|
|
// trailing pad. Reproduces the hand-written prologue/epilogue exactly.
|
|
//
|
|
// {
|
|
// material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec);
|
|
// ... card content ...
|
|
// }
|
|
struct GlassCardScope {
|
|
ImDrawList* dl;
|
|
ImVec2 cardMin;
|
|
float width;
|
|
float pad;
|
|
float bottomPad;
|
|
GlassPanelSpec spec;
|
|
|
|
GlassCardScope(ImDrawList* dl_, float width_, float pad_, float bottomPad_,
|
|
const GlassPanelSpec& spec_)
|
|
: dl(dl_), width(width_), pad(pad_), bottomPad(bottomPad_), spec(spec_)
|
|
{
|
|
cardMin = ImGui::GetCursorScreenPos();
|
|
dl->ChannelsSplit(2);
|
|
dl->ChannelsSetCurrent(1);
|
|
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + pad));
|
|
ImGui::Indent(pad);
|
|
}
|
|
|
|
~GlassCardScope()
|
|
{
|
|
ImGui::Dummy(ImVec2(0, bottomPad));
|
|
ImGui::Unindent(pad);
|
|
|
|
ImVec2 cardMax(cardMin.x + width, ImGui::GetCursorScreenPos().y);
|
|
dl->ChannelsSetCurrent(0);
|
|
DrawGlassPanel(dl, cardMin, cardMax, spec);
|
|
dl->ChannelsMerge();
|
|
|
|
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y));
|
|
ImGui::Dummy(ImVec2(width, 0));
|
|
}
|
|
|
|
GlassCardScope(const GlassCardScope&) = delete;
|
|
GlassCardScope& operator=(const GlassCardScope&) = delete;
|
|
};
|
|
|
|
// ============================================================================
|
|
// Stat Card — reusable card with overline / value / subtitle + accent stripe
|
|
// ============================================================================
|
|
|
|
struct StatCardSpec {
|
|
const char* overline = nullptr; // small label at top (e.g. "LOCAL HASHRATE")
|
|
const char* value = nullptr; // main value text (e.g. "1.23 kH/s")
|
|
const char* subtitle = nullptr; // optional small line (e.g. "3 blocks")
|
|
ImU32 valueCol = 0; // value text colour (0 = OnSurface)
|
|
ImU32 accentCol = 0; // left-stripe colour (0 = no stripe)
|
|
bool centered = true; // centre text horizontally
|
|
bool hovered = false; // draw hover glow border
|
|
};
|
|
|
|
/// Compute a generous stat-card height with breathing room.
|
|
inline float StatCardHeight(float vs, float minH = 56.0f) {
|
|
float padV = Layout::spacingLg() * 2; // top + bottom
|
|
float content = Type().overline()->LegacySize
|
|
+ Layout::spacingMd()
|
|
+ Type().subtitle1()->LegacySize;
|
|
return std::max(minH, (content + padV) * std::max(vs, 0.85f));
|
|
}
|
|
|
|
inline void DrawStatCard(ImDrawList* dl,
|
|
const ImVec2& cMin, const ImVec2& cMax,
|
|
const StatCardSpec& card,
|
|
const GlassPanelSpec& glass = GlassPanelSpec())
|
|
{
|
|
float cardW = cMax.x - cMin.x;
|
|
float cardH = cMax.y - cMin.y;
|
|
float rnd = glass.rounding;
|
|
|
|
// 1. Glass background
|
|
DrawGlassPanel(dl, cMin, cMax, glass);
|
|
|
|
// 2. Accent stripe (left edge, clipped to card rounded corners).
|
|
// Draw a full-height rounded rect with card rounding (left corners)
|
|
// and clip to stripe width so the shape follows the corner radius.
|
|
if ((card.accentCol & IM_COL32_A_MASK) != 0) {
|
|
float stripeW = 4.0f;
|
|
dl->PushClipRect(cMin, ImVec2(cMin.x + stripeW, cMax.y), true);
|
|
dl->AddRectFilled(cMin, cMax, card.accentCol, rnd,
|
|
ImDrawFlags_RoundCornersLeft);
|
|
dl->PopClipRect();
|
|
}
|
|
|
|
// 3. Compute content block height
|
|
ImFont* ovFont = Type().overline();
|
|
ImFont* valFont = Type().subtitle1();
|
|
ImFont* subFont = Type().caption();
|
|
|
|
float blockH = 0;
|
|
if (card.overline) blockH += ovFont->LegacySize;
|
|
if (card.overline && card.value) blockH += Layout::spacingMd();
|
|
if (card.value) blockH += valFont->LegacySize;
|
|
if (card.subtitle) blockH += Layout::spacingSm() + subFont->LegacySize;
|
|
|
|
// 4. Vertically centre
|
|
float topY = cMin.y + (cardH - blockH) * 0.5f;
|
|
float padX = Layout::spacingLg();
|
|
float cy = topY;
|
|
|
|
// 5. Overline
|
|
if (card.overline) {
|
|
ImVec2 sz = ovFont->CalcTextSizeA(ovFont->LegacySize, 10000, 0, card.overline);
|
|
float tx = card.centered ? cMin.x + (cardW - sz.x) * 0.5f : cMin.x + padX;
|
|
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(tx, cy), OnSurfaceMedium(), card.overline);
|
|
cy += ovFont->LegacySize + Layout::spacingMd();
|
|
}
|
|
|
|
// 6. Value (with text shadow)
|
|
if (card.value) {
|
|
ImU32 col = card.valueCol ? card.valueCol : OnSurface();
|
|
ImVec2 sz = valFont->CalcTextSizeA(valFont->LegacySize, 10000, 0, card.value);
|
|
float tx = card.centered ? cMin.x + (cardW - sz.x) * 0.5f : cMin.x + padX;
|
|
DrawTextShadow(dl, valFont, valFont->LegacySize, ImVec2(tx, cy), col, card.value);
|
|
cy += valFont->LegacySize;
|
|
}
|
|
|
|
// 7. Subtitle
|
|
if (card.subtitle) {
|
|
cy += Layout::spacingSm();
|
|
ImVec2 sz = subFont->CalcTextSizeA(subFont->LegacySize, 10000, 0, card.subtitle);
|
|
float tx = card.centered ? cMin.x + (cardW - sz.x) * 0.5f : cMin.x + padX;
|
|
dl->AddText(subFont, subFont->LegacySize, ImVec2(tx, cy), OnSurfaceDisabled(), card.subtitle);
|
|
}
|
|
|
|
// 8. Hover glow
|
|
if (card.hovered) {
|
|
dl->AddRect(cMin, cMax,
|
|
schema::UI().resolveColor("var(--hover-overlay)", IM_COL32(255, 255, 255, 20)),
|
|
rnd, 0, 1.5f);
|
|
}
|
|
}
|
|
|
|
// ── Styled Button (font-only wrapper) ───────────────────────────────────
|
|
// Drop-in replacement for ImGui::Button that pushes the Button font style
|
|
// (from JSON config) without adding tactile visual effects.
|
|
|
|
inline bool StyledButton(const char* label, const ImVec2& size = ImVec2(0, 0), ImFont* font = nullptr)
|
|
{
|
|
ImGui::PushFont(font ? font : Type().button());
|
|
bool pressed = ImGui::Button(label, size);
|
|
ImGui::PopFont();
|
|
return pressed;
|
|
}
|
|
|
|
inline bool StyledSmallButton(const char* label, ImFont* font = nullptr)
|
|
{
|
|
ImGui::PushFont(font ? font : Type().button());
|
|
bool pressed = ImGui::SmallButton(label);
|
|
ImGui::PopFont();
|
|
return pressed;
|
|
}
|
|
|
|
// ============================================================================
|
|
// ButtonStyle-driven overloads (unified UI schema)
|
|
// ============================================================================
|
|
// These accept a ButtonStyle + ColorVarResolver to push font, colors,
|
|
// and size from the schema. Existing call sites are unaffected.
|
|
|
|
inline bool StyledButton(const char* label, const schema::ButtonStyle& style,
|
|
const schema::ColorVarResolver& colors)
|
|
{
|
|
// Resolve font
|
|
ImFont* font = nullptr;
|
|
if (!style.font.empty()) {
|
|
font = Type().resolveByName(style.font);
|
|
}
|
|
ImGui::PushFont(font ? font : Type().button());
|
|
|
|
// Push color overrides
|
|
int colorCount = 0;
|
|
if (!style.colors.background.empty()) {
|
|
ImGui::PushStyleColor(ImGuiCol_Button,
|
|
colors.resolve(style.colors.background));
|
|
colorCount++;
|
|
}
|
|
if (!style.colors.backgroundHover.empty()) {
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonHovered,
|
|
colors.resolve(style.colors.backgroundHover));
|
|
colorCount++;
|
|
}
|
|
if (!style.colors.backgroundActive.empty()) {
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonActive,
|
|
colors.resolve(style.colors.backgroundActive));
|
|
colorCount++;
|
|
}
|
|
if (!style.colors.color.empty()) {
|
|
ImGui::PushStyleColor(ImGuiCol_Text,
|
|
colors.resolve(style.colors.color));
|
|
colorCount++;
|
|
}
|
|
|
|
// Push style overrides
|
|
int styleCount = 0;
|
|
if (style.borderRadius >= 0) {
|
|
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, style.borderRadius);
|
|
styleCount++;
|
|
}
|
|
|
|
ImVec2 size(style.width > 0 ? style.width : 0,
|
|
style.height > 0 ? style.height : 0);
|
|
bool pressed = ImGui::Button(label, size);
|
|
|
|
ImGui::PopStyleVar(styleCount);
|
|
ImGui::PopStyleColor(colorCount);
|
|
ImGui::PopFont();
|
|
return pressed;
|
|
}
|
|
|
|
inline bool TactileButton(const char* label, const schema::ButtonStyle& style,
|
|
const schema::ColorVarResolver& colors)
|
|
{
|
|
// Resolve font
|
|
ImFont* font = nullptr;
|
|
if (!style.font.empty()) {
|
|
font = Type().resolveByName(style.font);
|
|
}
|
|
ImGui::PushFont(font ? font : Type().button());
|
|
|
|
// Push color overrides
|
|
int colorCount = 0;
|
|
if (!style.colors.background.empty()) {
|
|
ImGui::PushStyleColor(ImGuiCol_Button,
|
|
colors.resolve(style.colors.background));
|
|
colorCount++;
|
|
}
|
|
if (!style.colors.backgroundHover.empty()) {
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonHovered,
|
|
colors.resolve(style.colors.backgroundHover));
|
|
colorCount++;
|
|
}
|
|
if (!style.colors.backgroundActive.empty()) {
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonActive,
|
|
colors.resolve(style.colors.backgroundActive));
|
|
colorCount++;
|
|
}
|
|
if (!style.colors.color.empty()) {
|
|
ImGui::PushStyleColor(ImGuiCol_Text,
|
|
colors.resolve(style.colors.color));
|
|
colorCount++;
|
|
}
|
|
|
|
// Push style overrides
|
|
int styleCount = 0;
|
|
if (style.borderRadius >= 0) {
|
|
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, style.borderRadius);
|
|
styleCount++;
|
|
}
|
|
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
ImVec2 size(style.width > 0 ? style.width : 0,
|
|
style.height > 0 ? style.height : 0);
|
|
bool pressed = ImGui::Button(label, size);
|
|
|
|
// Glass overlay on the button rect
|
|
ImVec2 bMin = ImGui::GetItemRectMin();
|
|
ImVec2 bMax = ImGui::GetItemRectMax();
|
|
float rounding = ImGui::GetStyle().FrameRounding;
|
|
bool active = ImGui::IsItemActive();
|
|
bool hovered = ImGui::IsItemHovered();
|
|
DrawButtonGlassOverlay(dl, bMin, bMax, rounding, active, hovered);
|
|
|
|
ImGui::PopStyleVar(styleCount);
|
|
ImGui::PopStyleColor(colorCount);
|
|
ImGui::PopFont();
|
|
return pressed;
|
|
}
|
|
|
|
} // namespace material
|
|
} // namespace ui
|
|
} // namespace dragonx
|
|
|
|
namespace dragonx {
|
|
namespace ui {
|
|
namespace material {
|
|
|
|
// ============================================================================
|
|
// Scroll-edge clipping mask — CSS mask-image style vertex alpha fade.
|
|
// Call ApplyScrollEdgeMask after EndChild() to fade content at the
|
|
// top/bottom edges of a scrollable panel. Works by walking vertices
|
|
// added during rendering and scaling their alpha based on distance
|
|
// to the panel edges. No opaque overlay rectangles — content becomes
|
|
// truly transparent, revealing whatever is behind the panel.
|
|
//
|
|
// Usage pattern:
|
|
// float scrollY = 0, scrollMaxY = 0;
|
|
// int parentVtx = parentDL->VtxBuffer.Size;
|
|
// ImGui::BeginChild(...);
|
|
// ImDrawList* childDL = ImGui::GetWindowDrawList();
|
|
// int childVtx = childDL->VtxBuffer.Size;
|
|
// { ... scrollY = GetScrollY(); scrollMaxY = GetScrollMaxY(); ... }
|
|
// ImGui::EndChild();
|
|
// ApplyScrollEdgeMask(parentDL, parentVtx, childDL, childVtx,
|
|
// panelMin.y, panelMax.y, fadeZone, scrollY, scrollMaxY);
|
|
// ============================================================================
|
|
inline void ApplyScrollEdgeMask(ImDrawList* parentDL, int parentVtxStart,
|
|
ImDrawList* childDL, int childVtxStart,
|
|
float topEdge, float bottomEdge,
|
|
float fadeZone,
|
|
float scrollY, float scrollMaxY)
|
|
{
|
|
auto mask = [&](ImDrawList* dl, int startIdx) {
|
|
for (int vi = startIdx; vi < dl->VtxBuffer.Size; vi++) {
|
|
ImDrawVert& v = dl->VtxBuffer[vi];
|
|
float alpha = 1.0f;
|
|
|
|
// Top fade — only when scrolled down
|
|
if (scrollY > 1.0f) {
|
|
float d = v.pos.y - topEdge;
|
|
if (d < fadeZone)
|
|
alpha = std::min(alpha, std::max(0.0f, d / fadeZone));
|
|
}
|
|
// Bottom fade — only when not at scroll bottom
|
|
if (scrollMaxY > 0 && scrollY < scrollMaxY - 1.0f) {
|
|
float d = bottomEdge - v.pos.y;
|
|
if (d < fadeZone)
|
|
alpha = std::min(alpha, std::max(0.0f, d / fadeZone));
|
|
}
|
|
|
|
if (alpha < 1.0f) {
|
|
int a = (v.col >> IM_COL32_A_SHIFT) & 0xFF;
|
|
a = static_cast<int>(a * alpha);
|
|
v.col = (v.col & ~IM_COL32_A_MASK) | (static_cast<ImU32>(a) << IM_COL32_A_SHIFT);
|
|
}
|
|
}
|
|
};
|
|
|
|
if (parentDL) mask(parentDL, parentVtxStart);
|
|
if (childDL) mask(childDL, childVtxStart);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Smooth scrolling — exponential decay interpolation for mouse wheel.
|
|
// Call immediately after BeginChild() on a child that was created with
|
|
// ImGuiWindowFlags_NoScrollWithMouse. Captures wheel input and lerps
|
|
// scroll position each frame for a smooth feel.
|
|
//
|
|
// Usage:
|
|
// ImGui::BeginChild("##List", size, false,
|
|
// ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollWithMouse);
|
|
// ApplySmoothScroll();
|
|
// ... render content ...
|
|
// ImGui::EndChild();
|
|
// ============================================================================
|
|
|
|
/// Returns true when any smooth-scroll child is still interpolating toward
|
|
/// its target. Checked by the idle-rendering logic in main.cpp to keep
|
|
/// rendering frames until the animation settles.
|
|
inline bool& SmoothScrollAnimating() {
|
|
static bool sAnimating = false;
|
|
return sAnimating;
|
|
}
|
|
|
|
inline void ApplySmoothScroll(float speed = 12.0f)
|
|
{
|
|
struct ScrollState {
|
|
float target = 0.0f;
|
|
float current = 0.0f;
|
|
bool init = false;
|
|
};
|
|
static std::unordered_map<ImGuiID, ScrollState> sStates;
|
|
|
|
ImGuiWindow* win = ImGui::GetCurrentWindow();
|
|
if (!win) return;
|
|
|
|
ScrollState& s = sStates[win->ID];
|
|
float scrollMaxY = ImGui::GetScrollMaxY();
|
|
|
|
if (!s.init) {
|
|
s.target = ImGui::GetScrollY();
|
|
s.current = s.target;
|
|
s.init = true;
|
|
}
|
|
|
|
// If something external set scroll position (e.g. SetScrollHereY for
|
|
// auto-scroll), sync our state so the next wheel-up starts from the
|
|
// actual current position rather than an old remembered target.
|
|
float actualY = ImGui::GetScrollY();
|
|
if (std::abs(s.current - actualY) > 1.0f) {
|
|
s.target = actualY;
|
|
s.current = actualY;
|
|
}
|
|
|
|
// Capture mouse wheel when the cursor is inside this child window.
|
|
// Use a direct position check instead of IsWindowHovered() which can
|
|
// return false when an ActiveId exists (focused input, held button) or
|
|
// when tooltip/popup windows from row hovers interfere with hover
|
|
// routing. Still skip when a real popup (combo dropdown, context menu)
|
|
// is open so the popup handles its own scrolling.
|
|
{
|
|
bool popupOpen = ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel);
|
|
ImVec2 mpos = ImGui::GetIO().MousePos;
|
|
ImVec2 wmin = win->Pos;
|
|
ImVec2 wmax = ImVec2(wmin.x + win->Size.x, wmin.y + win->Size.y);
|
|
bool inside = (mpos.x >= wmin.x && mpos.x < wmax.x &&
|
|
mpos.y >= wmin.y && mpos.y < wmax.y);
|
|
if (!popupOpen && inside) {
|
|
float wheel = ImGui::GetIO().MouseWheel;
|
|
if (wheel != 0.0f) {
|
|
float step = ImGui::GetTextLineHeightWithSpacing() * 3.0f;
|
|
s.target -= wheel * step;
|
|
s.target = ImClamp(s.target, 0.0f, scrollMaxY);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clamp target if scrollMax changed (e.g., content resized)
|
|
if (s.target > scrollMaxY) s.target = scrollMaxY;
|
|
|
|
// Exponential decay lerp
|
|
float dt = ImGui::GetIO().DeltaTime;
|
|
s.current += (s.target - s.current) * (1.0f - expf(-speed * dt));
|
|
|
|
// Snap when close
|
|
if (std::abs(s.current - s.target) < 0.5f)
|
|
s.current = s.target;
|
|
else
|
|
SmoothScrollAnimating() = true; // still interpolating — keep rendering
|
|
|
|
ImGui::SetScrollY(s.current);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Dialog Title Bar with Close Button
|
|
// ============================================================================
|
|
// Draws a custom title bar for modal popups with a title and close button.
|
|
// Returns true if the close button was clicked.
|
|
|
|
inline bool DrawDialogTitleBar(const char* title, bool* p_open, ImU32 accent_col = 0)
|
|
{
|
|
bool closeClicked = false;
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
ImVec2 winPos = ImGui::GetWindowPos();
|
|
float winWidth = ImGui::GetWindowWidth();
|
|
float barHeight = 36.0f;
|
|
|
|
// Get accent color from theme if not provided
|
|
if (!accent_col) {
|
|
accent_col = schema::UI().resolveColor("var(--primary)", IM_COL32(76, 175, 80, 255));
|
|
}
|
|
|
|
// Draw title bar background with subtle gradient
|
|
ImVec2 barMin = winPos;
|
|
ImVec2 barMax(winPos.x + winWidth, winPos.y + barHeight);
|
|
|
|
// Slightly darker top edge
|
|
ImU32 barTop = IM_COL32(0, 0, 0, 40);
|
|
ImU32 barBot = IM_COL32(0, 0, 0, 20);
|
|
dl->AddRectFilledMultiColor(barMin, barMax, barTop, barTop, barBot, barBot);
|
|
|
|
// Accent line at bottom of title bar
|
|
dl->AddLine(ImVec2(barMin.x, barMax.y), ImVec2(barMax.x, barMax.y),
|
|
ScaleAlpha(accent_col, 0.6f), 1.0f);
|
|
|
|
// Title text
|
|
ImFont* titleFont = Type().subtitle1();
|
|
ImGui::PushFont(titleFont);
|
|
ImVec2 titleSize = ImGui::CalcTextSize(title);
|
|
float titleX = barMin.x + 16.0f;
|
|
float titleY = barMin.y + (barHeight - titleSize.y) * 0.5f;
|
|
DrawTextShadow(dl, ImVec2(titleX, titleY), OnSurface(), title);
|
|
ImGui::PopFont();
|
|
|
|
// Close button (X) on right side
|
|
if (p_open) {
|
|
float btnSize = 24.0f;
|
|
float btnX = barMax.x - btnSize - 12.0f;
|
|
float btnY = barMin.y + (barHeight - btnSize) * 0.5f;
|
|
ImVec2 btnMin(btnX, btnY);
|
|
ImVec2 btnMax(btnX + btnSize, btnY + btnSize);
|
|
|
|
bool hovered = ImGui::IsMouseHoveringRect(btnMin, btnMax);
|
|
bool held = false;
|
|
if (hovered && ImGui::IsMouseClicked(0)) {
|
|
held = true;
|
|
}
|
|
|
|
// Button background on hover
|
|
if (hovered) {
|
|
dl->AddRectFilled(btnMin, btnMax, IM_COL32(255, 255, 255, held ? 40 : 25), 4.0f);
|
|
}
|
|
|
|
// Draw X icon
|
|
ImGui::PushFont(Type().iconSmall());
|
|
ImVec2 iconSize = ImGui::CalcTextSize(ICON_MD_CLOSE);
|
|
float iconX = btnX + (btnSize - iconSize.x) * 0.5f;
|
|
float iconY = btnY + (btnSize - iconSize.y) * 0.5f;
|
|
dl->AddText(ImVec2(iconX, iconY),
|
|
hovered ? IM_COL32(255, 255, 255, 255) : OnSurfaceMedium(),
|
|
ICON_MD_CLOSE);
|
|
ImGui::PopFont();
|
|
|
|
// Handle click
|
|
if (hovered && ImGui::IsMouseReleased(0)) {
|
|
*p_open = false;
|
|
closeClicked = true;
|
|
}
|
|
}
|
|
|
|
// Reserve space for title bar so content starts below it
|
|
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + barHeight + 8.0f);
|
|
|
|
return closeClicked;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Blur-backdrop modal primitives
|
|
// ============================================================================
|
|
// Shared building blocks for the "Manage Portfolio"-style overlay (live-blur backdrop, floating
|
|
// 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.
|
|
|
|
// "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 bool*& BlurOverlayOpenFlagRef() { static bool* p = nullptr; return p; }
|
|
// Record that a blur overlay drew this frame, along with its open flag (so the latch below can tell
|
|
// "still open next frame" from "closed this frame" and match the modal's actual open state — no
|
|
// extra frame of suppression on close).
|
|
inline void MarkBlurOverlayDrawn(bool* openFlag) { BlurOverlayDrawnThisFrameRef() = true; BlurOverlayOpenFlagRef() = openFlag; }
|
|
inline bool AnyBlurOverlayActiveLastFrame() { return BlurOverlayActiveLastFrameRef(); }
|
|
// Latch whether an overlay will still be up next frame (drew this frame AND its open flag is still
|
|
// set after this frame's Esc/Close/outside-click handling); 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* pf = BlurOverlayOpenFlagRef();
|
|
bool stillOpen = BlurOverlayDrawnThisFrameRef() && (!pf || *pf);
|
|
if (BlurOverlayActiveLastFrameRef() && !stillOpen) effects::ImGuiAcrylic::InvalidateCapture();
|
|
BlurOverlayActiveLastFrameRef() = stillOpen;
|
|
BlurOverlayDrawnThisFrameRef() = false;
|
|
BlurOverlayOpenFlagRef() = nullptr;
|
|
}
|
|
|
|
// 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<std::string, BlurCaptureState> 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)
|
|
{
|
|
float avail = ImGui::GetContentRegionAvail().x;
|
|
if (avail > w) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + avail - w);
|
|
}
|
|
|
|
// Equal-width segmented control (rounded track + N cells, each a centered label + an active pill) at
|
|
// screen-space `origin`, spanning `totalW` x `height`. Each cell is a hit-tested InvisibleButton
|
|
// (Hand cursor on hover), disambiguated by `idBase`. Does NOT move the layout cursor. Returns the
|
|
// clicked cell index, or -1.
|
|
inline int SegmentedControl(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;
|
|
}
|
|
|
|
// The edge-fade scroll child (BeginFadeScrollChild/EndFadeScrollChild) depends on
|
|
// effects::ScrollFadeShader, whose GL/DX headers must not be pulled into this widely-included base
|
|
// header — it lives in material/overlay_scroll.h, included only by modal implementations.
|
|
|
|
// ============================================================================
|
|
// Fullscreen Overlay Dialog
|
|
// ============================================================================
|
|
// Creates a fullscreen semi-transparent overlay with a centered card dialog.
|
|
// Similar to the shutdown screen pattern but for interactive dialogs.
|
|
|
|
// 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.
|
|
inline bool g_overlayHideFrameClip = false;
|
|
|
|
// The dark base color for any full-window overlay backdrop (dialog scrims + the shutdown screen),
|
|
// so every overlay shares one backdrop tone. `opacity` is the alpha (dialogs vary it per call).
|
|
inline ImVec4 OverlayScrimColor(float opacity) { return ImVec4(0.04f, 0.04f, 0.06f, opacity); }
|
|
|
|
// 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, vp_size = vp->Size;
|
|
|
|
// 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);
|
|
|
|
ImGui::SetNextWindowPos(vp_pos);
|
|
ImGui::SetNextWindowSize(vp_size);
|
|
// 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();
|
|
// Blur overlays draw their own backdrop, so the window itself is transparent.
|
|
ImGui::PushStyleColor(ImGuiCol_WindowBg,
|
|
blur ? ImVec4(0, 0, 0, 0) : OverlayScrimColor(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 (spec.idSuffix && spec.idSuffix[0] != '\0') {
|
|
scrimId += "_"; scrimId += spec.idSuffix;
|
|
childId += "_"; childId += spec.idSuffix;
|
|
}
|
|
|
|
bool opened = ImGui::Begin(scrimId.c_str(), nullptr,
|
|
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
|
|
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
|
|
ImGuiWindowFlags_NoScrollWithMouse | ImGuiWindowFlags_NoNav |
|
|
ImGuiWindowFlags_NoSavedSettings);
|
|
|
|
if (!opened) {
|
|
ImGui::End();
|
|
ImGui::PopStyleVar(3);
|
|
ImGui::PopStyleColor();
|
|
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
|
|
// 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(spec.p_open);
|
|
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);
|
|
|
|
// Card geometry: both fixed- and auto-height cards are VERTICALLY CENTERED in the window.
|
|
// Fixed-height centers its known height; auto-height centers last frame's measured content
|
|
// height (cached per dialog id, so a re-opened dialog is centered from frame 1 — only the very
|
|
// first open of a given dialog this session briefly anchors near the top before it re-centers).
|
|
float cardX = vp_pos.x + (vp_size.x - cardWidth) * 0.5f;
|
|
float cardY, cardBottomY;
|
|
bool hideForMeasure = false; // true on an auto-height dialog's first (unmeasured) frame
|
|
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 {
|
|
g_overlayCurrentKey = childId;
|
|
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 {
|
|
// 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;
|
|
}
|
|
}
|
|
ImVec2 cardMin(cardX, cardY), cardMax(cardX + cardWidth, cardBottomY);
|
|
|
|
// Card background — a glass panel unless the content floats directly on the backdrop. Skipped on
|
|
// the measuring frame (its geometry is a placeholder; the whole card is hidden until centered).
|
|
if (!floating && !hideForMeasure) {
|
|
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 — but not on the measuring frame (placeholder geometry), the
|
|
// appearing frame (the opening click), or while a popup opened from the dialog is showing.
|
|
if (spec.p_open && !hideForMeasure && !overlayPopupOpen && !ImGui::IsWindowAppearing() &&
|
|
ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
|
|
!ImGui::IsMouseHoveringRect(cardMin, cardMax, false)) {
|
|
*spec.p_open = false;
|
|
}
|
|
|
|
// Content child.
|
|
ImGui::SetCursorScreenPos(ImVec2(cardX, cardY));
|
|
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);
|
|
// NoScrollWithMouse (not just NoScrollbar): a modal is a fixed frame — the wheel must never drift
|
|
// the WHOLE card. If content marginally overflows a fixed card, the wheel would otherwise scroll
|
|
// the entire dialog (title + footer and all). Inner scroll regions (lists, notes) still scroll on
|
|
// their own; auto-height cards resize to content so they never overflow anyway.
|
|
bool childVisible = ImGui::BeginChild(childId.c_str(),
|
|
ImVec2(cardWidth, fixedHeight ? (cardBottomY - cardY) : 0.0f),
|
|
cflags, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
|
// Floating (portfolio-style) cards: the padding applies to this content child only, so pop it
|
|
// now (nested children mustn't inherit it), and center button labels. Net style-var count stays
|
|
// at 2 (ChildRounding + ButtonTextAlign) so EndOverlayDialog's PopStyleVar(2) is unchanged.
|
|
if (floating) {
|
|
ImGui::PopStyleVar(); // WindowPadding
|
|
ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.5f, 0.5f));
|
|
}
|
|
|
|
// Measuring frame: clip the card to nothing. Content (title + the caller's body) still lays out,
|
|
// so the child's auto-height is measured for next frame, but nothing is drawn — no off-center
|
|
// flash. EndOverlayDialog pops this before EndChild. Only entered when childVisible (End runs).
|
|
if (childVisible && hideForMeasure) {
|
|
ImGui::PushClipRect(ImVec2(0, 0), ImVec2(0, 0), false);
|
|
g_overlayHideFrameClip = true;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Positional overload — the default look for the app's dialogs: content floats directly on the
|
|
// live-blur backdrop (no glass card) under a plain h6 heading, matching the Manage-Portfolio
|
|
// editor. Auto-height keeps short dialogs short. A dialog that needs the old contained card can
|
|
// pass an explicit OverlayDialogSpec with style = OverlayStyle::GlassCard.
|
|
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;
|
|
s.blurBackdrop = true;
|
|
s.floatingContent = true; // no glass card — content floats on the blur
|
|
s.plainHeading = true; // h6 heading instead of the boxed title bar (drops the ✕)
|
|
return BeginOverlayDialog(s);
|
|
}
|
|
|
|
inline void EndOverlayDialog()
|
|
{
|
|
if (g_overlayHideFrameClip) { ImGui::PopClipRect(); g_overlayHideFrameClip = false; }
|
|
ImGui::EndChild();
|
|
// 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()) {
|
|
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)
|
|
|
|
ImGui::End();
|
|
ImGui::PopStyleVar(3); // WindowRounding, WindowBorderSize, WindowPadding (for scrim)
|
|
ImGui::PopStyleColor(); // WindowBg scrim
|
|
}
|
|
|
|
inline void PlaceOverlayDialogActions(float totalWidth)
|
|
{
|
|
float rowStartX = ImGui::GetCursorPosX();
|
|
float contentW = ImGui::GetContentRegionAvail().x;
|
|
ImGui::SetCursorPosX(rowStartX + std::max(0.0f, (contentW - totalWidth) * 0.5f));
|
|
}
|
|
|
|
inline void BeginOverlayDialogFooter(float totalActionWidth, bool drawSeparator = true)
|
|
{
|
|
ImGui::Spacing();
|
|
if (drawSeparator) {
|
|
ImGui::Separator();
|
|
ImGui::Spacing();
|
|
}
|
|
PlaceOverlayDialogActions(totalActionWidth);
|
|
}
|
|
|
|
// ── Confirmation dialog header / footer ─────────────────────────────────
|
|
// Shared scaffolding for the "warning + Cancel/confirm" overlay dialogs.
|
|
|
|
// Warning icon + "Warning" label row, drawn in `col` (the caller owns the
|
|
// exact accent colour; new callers can omit it to use the theme warning).
|
|
// Reproduces: PushFont(iconLarge) + ICON_MD_WARNING, PopFont, SameLine,
|
|
// TextColored(label). The label text is passed in (already translated).
|
|
inline void DialogWarningHeader(const char* warningLabel, const ImVec4& col = WarningVec4())
|
|
{
|
|
ImGui::PushFont(Type().iconLarge());
|
|
ImGui::TextColored(col, ICON_MD_WARNING);
|
|
ImGui::PopFont();
|
|
ImGui::SameLine();
|
|
ImGui::TextColored(col, "%s", warningLabel);
|
|
}
|
|
|
|
// 50/50 Cancel / confirm footer for overlay dialogs. Sets `outCancel` /
|
|
// `outConfirm` when the respective button is pressed (the caller runs the
|
|
// action bodies). When `danger`, the confirm button gets the red danger
|
|
// style. Button height comes from ui.toml (falls back to 40). Reproduces
|
|
// the hand-written footer exactly (btnW split + SameLine + optional danger
|
|
// PushStyleColor/PopStyleColor(2)).
|
|
inline void DialogConfirmFooter(const char* cancelId, const char* confirmLabel,
|
|
bool danger, bool& outCancel, bool& outConfirm)
|
|
{
|
|
float btnH = schema::UI().drawElement("components.overlay-dialog", "confirm-btn-height").sizeOr(40.0f);
|
|
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
|
if (ImGui::Button(cancelId, ImVec2(btnW, btnH))) {
|
|
outCancel = true;
|
|
}
|
|
ImGui::SameLine();
|
|
if (danger) {
|
|
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.8f, 0.2f, 0.2f, 1.0f));
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.9f, 0.3f, 0.3f, 1.0f));
|
|
}
|
|
if (ImGui::Button(confirmLabel, ImVec2(btnW, btnH))) {
|
|
outConfirm = true;
|
|
}
|
|
if (danger) {
|
|
ImGui::PopStyleColor(2);
|
|
}
|
|
}
|
|
|
|
} // namespace material
|
|
} // namespace ui
|
|
} // namespace dragonx
|