// 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 #include #include #include 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(((col >> IM_COL32_A_SHIFT) & 0xFF) * scale); if (a > 255) a = 255; return (col & ~IM_COL32_A_MASK) | (static_cast(a) << IM_COL32_A_SHIFT); } // 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) { if (node->Name && strcmp(node->Name, "##OverlayScrim") == 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) { // 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); } // Convenience: call right after any ImGui::Button() / SmallButton() call // to add tactile depth. Uses the last item rect automatically. inline void ApplyTactile(ImDrawList* dl = nullptr) { if (!dl) dl = ImGui::GetWindowDrawList(); ImVec2 bMin = ImGui::GetItemRectMin(); ImVec2 bMax = ImGui::GetItemRectMax(); float rounding = ImGui::GetStyle().FrameRounding; bool active = ImGui::IsItemActive(); 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); } // ── 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; }; inline void DrawGlassPanel(ImDrawList* dl, const ImVec2& pMin, const ImVec2& pMax, const GlassPanelSpec& spec = GlassPanelSpec()) { if (IsBackdropActive() && !dragonx::ui::effects::isLowSpecMode()) { // --- 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(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); } } // ============================================================================ // 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(a * alpha); v.col = (v.col & ~IM_COL32_A_MASK) | (static_cast(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 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; } // ============================================================================ // 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 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 g_overlayCardHeights; inline std::string g_overlayCurrentKey; inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth = 460.0f, float scrimOpacity = 0.92f, float cardBottomViewportRatio = 0.85f, const char* idSuffix = nullptr) { MarkOverlayDialogActive(); ImGuiViewport* vp = ImGui::GetMainViewport(); ImVec2 vp_pos = vp->Pos; ImVec2 vp_size = vp->Size; // Dialog widths are authored as raw pixels, but the fonts/spacing inside scale with // Layout::dpiScale() (which includes the user's font-size setting). Scale the card by the same // factor so the content doesn't outgrow a fixed card and overflow/misalign at non-default // scales. No-op at the default scale (dpiScale() == 1). Clamped to the viewport so a large scale // can't push the card off-screen. cardWidth *= Layout::dpiScale(); cardWidth = std::min(cardWidth, vp_size.x - 32.0f); // Fullscreen scrim overlay ImGui::SetNextWindowPos(vp_pos); ImGui::SetNextWindowSize(vp_size); // Focus the scrim so the dialog owns input — but NOT while a popup (a Combo dropdown, // color picker, etc.) opened from the dialog is showing, or forcing focus back to the // scrim every frame would immediately close that popup. const bool overlayPopupOpen = ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel); if (!overlayPopupOpen) ImGui::SetNextWindowFocus(); ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.04f, 0.04f, 0.06f, scrimOpacity)); ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); std::string scrimId = "##OverlayScrim"; std::string childId = "##OverlayDialogContent"; if (idSuffix && idSuffix[0] != '\0') { scrimId += "_"; scrimId += idSuffix; childId += "_"; childId += idSuffix; } 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; } ImDrawList* dl = ImGui::GetWindowDrawList(); // Consume pointer input on the scrim so the overlay owns clicks and wheel // events even when the click lands outside the card content. ImGui::SetCursorScreenPos(vp_pos); ImGui::InvisibleButton("##OverlayInputBlocker", vp_size, ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonRight | ImGuiButtonFlags_MouseButtonMiddle); // Calculate card position (centered) float cardX = vp_pos.x + (vp_size.x - cardWidth) * 0.5f; float cardY = vp_pos.y + vp_size.y * 0.15f; // Size the card height to its content. The content child below is AutoResizeY, so a glass card // drawn to a fixed viewport ratio left a tall band of empty glass under short dialogs. Reuse the // height the child reported LAST frame (content is stable frame-to-frame, so no visible lag) and // fall back to the ratio on the first frame. Still capped at the ratio so a very tall dialog can't // run off-screen (its content spills/scrolls as before). g_overlayCurrentKey = childId; float ratioMaxY = vp_pos.y + vp_size.y * cardBottomViewportRatio; auto prevHeightIt = g_overlayCardHeights.find(childId); float cardBottomY = (prevHeightIt != g_overlayCardHeights.end() && prevHeightIt->second > 0.0f) ? std::min(cardY + prevHeightIt->second, ratioMaxY) : ratioMaxY; // 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 ImGui::SetCursorScreenPos(ImVec2(cardX, cardY)); ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 16.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(28, 24)); ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0, 0, 0, 0)); // Transparent - glass already drawn bool childVisible = ImGui::BeginChild(childId.c_str(), ImVec2(cardWidth, 0), // 0 height = auto-size ImGuiChildFlags_AutoResizeY | ImGuiChildFlags_AlwaysUseWindowPadding, ImGuiWindowFlags_NoScrollbar); // Draw title bar with close button if (childVisible && title) { DrawDialogTitleBar(title, p_open); } return childVisible; } inline void EndOverlayDialog() { 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. if (!g_overlayCurrentKey.empty()) { g_overlayCardHeights[g_overlayCurrentKey] = ImGui::GetItemRectSize().y; } 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