Full-node GUI wallet for DragonX cryptocurrency. Built with Dear ImGui, SDL3, and OpenGL3/DX11. Features: - Send/receive shielded and transparent transactions - Autoshield with merged transaction display - Built-in CPU mining (xmrig) - Peer management and network monitoring - Wallet encryption with PIN lock - QR code generation for receive addresses - Transaction history with pagination - Console for direct RPC commands - Cross-platform (Linux, Windows)
780 lines
31 KiB
C++
780 lines
31 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 "../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 "imgui.h"
|
|
#include "imgui_internal.h"
|
|
#include <algorithm>
|
|
#include <unordered_map>
|
|
#include <cmath>
|
|
|
|
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);
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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 bool IsRectHovered(const ImVec2& r_min, const ImVec2& r_max, bool clip = true)
|
|
{
|
|
if (!ImGui::IsMouseHoveringRect(r_min, r_max, clip))
|
|
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();
|
|
}
|
|
|
|
// 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();
|
|
// 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);
|
|
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();
|
|
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);
|
|
}
|
|
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);
|
|
DrawTactileOverlay(dl, bMin, bMax, rounding, active);
|
|
return pressed;
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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.
|
|
{
|
|
float noiseMul = dragonx::ui::effects::ImGuiAcrylic::GetNoiseOpacity();
|
|
if (noiseMul > 0.0f) {
|
|
uint8_t origAlpha = (s_glassNoiseTint >> IM_COL32_A_SHIFT) & 0xFF;
|
|
uint8_t scaledAlpha = static_cast<uint8_t>(std::min(255.0f, origAlpha * noiseMul));
|
|
ImU32 noiseTint = (s_glassNoiseTint & ~(0xFFu << IM_COL32_A_SHIFT)) | (scaledAlpha << IM_COL32_A_SHIFT);
|
|
float inset = spec.rounding * 0.3f;
|
|
ImVec2 clipMin(pMin.x + inset, pMin.y + inset);
|
|
ImVec2 clipMax(pMax.x - inset, pMax.y - inset);
|
|
dl->PushClipRect(clipMin, clipMax, true);
|
|
dragonx::util::DrawTiledNoiseRect(dl, clipMin, clipMax, noiseTint);
|
|
dl->PopClipRect();
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
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);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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();
|
|
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);
|
|
}
|
|
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);
|
|
DrawTactileOverlay(dl, bMin, bMax, rounding, active);
|
|
|
|
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 hovered
|
|
if (ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows)) {
|
|
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);
|
|
}
|
|
|
|
} // namespace material
|
|
} // namespace ui
|
|
} // namespace dragonx
|