Files
ObsidianDragon/src/ui/effects/theme_effects.cpp
DanS dcd09dc542 refactor(audit): batch 7 — RPC/settings/lite/effects mechanical dedups
Six behavior-preserving consolidations from the audit:

- RPCClient::call() overloads share a private performCall() (payload dump +
  curl_easy_perform + http code) and a static parseRpcResult() (error->RpcError
  extraction). The timeout overload restores the prior timeout in both the
  success and catch paths, exactly as before.
- The six UnifiedCallback methods delegate to one splitUnified() that builds
  the (Callback, ErrorCallback) pair once (null-check preserved).
- One liteTrimCopy() in lite_connection_service replaces 5 file-local
  trim-copy helpers across the lite slice.
- lite_wallet_controller's inline height JSON parse now routes through the
  tested parseLiteHeightResponse().
- theme_effects.cpp: unpackRGB()/scaledAlpha() replace 14 RGB-unpack + 5
  alpha-scale copy-paste blocks.
- settings.cpp load() uses loadScalar()/loadClamped() helpers for ~44 scalar
  fields. Besides removing boilerplate this HARDENS loading: ~44 fields that
  previously only checked contains() now also verify the JSON type, so a
  malformed value in settings.json falls back to the default instead of
  throwing/misreading. Custom cases (enum parses, legacy migrations, arrays)
  stay inline; save() is unchanged.

Full-node + Lite build clean; ctest 1/1; hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 19:56:54 -05:00

1141 lines
47 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "theme_effects.h"
#include "low_spec.h"
#include "../schema/ui_schema.h"
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <algorithm>
#include "../../util/logger.h"
namespace dragonx {
namespace ui {
namespace effects {
namespace {
// Colors are packed little-endian RGBA (R at bit 0, G at 8, B at 16, A at 24) — the
// same layout IM_COL32 produces. unpackRGB pulls out the three color channels (alpha is
// handled separately at each site).
struct RGB { int r, g, b; };
static inline RGB unpackRGB(ImU32 c) {
return RGB{ (int)(c & 0xFF), (int)((c >> 8) & 0xFF), (int)((c >> 16) & 0xFF) };
}
// Scales a 0..1 base alpha by the background opacity into a clamped 0..255 int.
static inline int scaledAlpha(float base, float bgOpacity) {
return std::clamp((int)(base * bgOpacity * 255.0f), 0, 255);
}
} // namespace
// ============================================================================
// Singleton
// ============================================================================
ThemeEffects& ThemeEffects::instance() {
static ThemeEffects s;
return s;
}
// ============================================================================
// Frame update
// ============================================================================
void ThemeEffects::beginFrame() {
time_ = (float)ImGui::GetTime();
ImGuiViewport* vp = ImGui::GetMainViewport();
vpMinY_ = vp->WorkPos.y;
vpMaxY_ = vp->WorkPos.y + vp->WorkSize.y;
}
// ============================================================================
// Load config from [effects] section via UISchema
// ============================================================================
void ThemeEffects::loadFromTheme() {
auto& S = schema::UI();
auto eff = [&](const char* name) {
return S.drawElement("effects", name);
};
// ---- Hue Cycle ----
hue_cycle_.enabled = eff("hue-cycle-enabled").sizeOr(0.0f) > 0.5f;
hue_cycle_.speed = eff("hue-cycle-speed").sizeOr(0.1f);
hue_cycle_.sat = eff("hue-cycle-saturation").sizeOr(0.6f);
hue_cycle_.val = eff("hue-cycle-value").sizeOr(0.85f);
hue_cycle_.range = eff("hue-cycle-range").sizeOr(1.0f);
hue_cycle_.offset = eff("hue-cycle-offset").sizeOr(0.0f);
// ---- Rainbow Border ----
rainbow_border_.enabled = eff("rainbow-border-enabled").sizeOr(0.0f) > 0.5f;
rainbow_border_.speed = eff("rainbow-border-speed").sizeOr(0.05f);
rainbow_border_.alpha = eff("rainbow-border-alpha").sizeOr(0.25f);
rainbow_border_.stops.clear();
// Read color stops from extraColors (stored as stop-0, stop-1, etc.)
for (int i = 0; i < 8; i++) {
char key[32];
snprintf(key, sizeof(key), "rainbow-border-stop-%d", i);
auto elem = eff(key);
if (!elem.color.empty()) {
rainbow_border_.stops.push_back(parseHexColor(elem.color));
} else if (elem.size > 0) {
break; // no more stops
}
}
// Fallback default stops
if (rainbow_border_.enabled && rainbow_border_.stops.empty()) {
rainbow_border_.stops.push_back(IM_COL32(255, 107, 157, 255));
rainbow_border_.stops.push_back(IM_COL32(192, 132, 252, 255));
rainbow_border_.stops.push_back(IM_COL32(103, 232, 249, 255));
rainbow_border_.stops.push_back(IM_COL32(252, 165, 165, 255));
}
// ---- Shimmer ----
shimmer_.enabled = eff("shimmer-enabled").sizeOr(0.0f) > 0.5f;
shimmer_.speed = eff("shimmer-speed").sizeOr(0.12f);
shimmer_.width = eff("shimmer-width").sizeOr(80.0f);
shimmer_.alpha = eff("shimmer-alpha").sizeOr(0.06f);
shimmer_.angle = eff("shimmer-angle").sizeOr(30.0f);
// Shimmer color: read from the schema's color resolver
auto shimmerColorElem = eff("shimmer-color");
if (!shimmerColorElem.color.empty()) {
shimmer_.color = S.resolveColor(shimmerColorElem.color, IM_COL32(255,255,255,255));
} else {
shimmer_.color = IM_COL32(255, 255, 255, 255);
}
// ---- Positional Hue ----
positional_hue_.enabled = eff("positional-hue-enabled").sizeOr(0.0f) > 0.5f;
positional_hue_.strength = eff("positional-hue-strength").sizeOr(0.3f);
auto topElem = eff("positional-hue-top");
auto botElem = eff("positional-hue-bottom");
if (!topElem.color.empty())
positional_hue_.topColor = parseHexColorVec4(topElem.color);
if (!botElem.color.empty())
positional_hue_.bottomColor = parseHexColorVec4(botElem.color);
// ---- Glow Pulse ----
glow_pulse_.enabled = eff("glow-pulse-enabled").sizeOr(0.0f) > 0.5f;
glow_pulse_.speed = eff("glow-pulse-speed").sizeOr(2.0f);
glow_pulse_.minAlpha = eff("glow-pulse-min-alpha").sizeOr(0.0f);
glow_pulse_.maxAlpha = eff("glow-pulse-max-alpha").sizeOr(0.15f);
glow_pulse_.radius = eff("glow-pulse-radius").sizeOr(4.0f);
auto glowColorElem = eff("glow-pulse-color");
if (!glowColorElem.color.empty()) {
glow_pulse_.color = S.resolveColor(glowColorElem.color, IM_COL32(255, 218, 0, 255));
} else {
glow_pulse_.color = IM_COL32(255, 218, 0, 255);
}
// ---- Edge Trace ----
edge_trace_.enabled = eff("edge-trace-enabled").sizeOr(0.0f) > 0.5f;
edge_trace_.speed = eff("edge-trace-speed").sizeOr(0.3f);
edge_trace_.length = eff("edge-trace-length").sizeOr(0.20f);
edge_trace_.thickness = eff("edge-trace-thickness").sizeOr(1.5f);
edge_trace_.alpha = eff("edge-trace-alpha").sizeOr(0.6f);
auto edgeColorElem = eff("edge-trace-color");
if (!edgeColorElem.color.empty()) {
edge_trace_.color = S.resolveColor(edgeColorElem.color, IM_COL32(255, 255, 255, 255));
} else {
edge_trace_.color = IM_COL32(255, 255, 255, 255);
}
// ---- Ember Rise ----
ember_rise_.enabled = eff("ember-rise-enabled").sizeOr(0.0f) > 0.5f;
ember_rise_.count = (int)eff("ember-rise-count").sizeOr(8.0f);
ember_rise_.speed = eff("ember-rise-speed").sizeOr(0.4f);
ember_rise_.particleSize = eff("ember-rise-particle-size").sizeOr(1.5f);
ember_rise_.alpha = eff("ember-rise-alpha").sizeOr(0.5f);
auto emberColorElem = eff("ember-rise-color");
if (!emberColorElem.color.empty()) {
ember_rise_.color = S.resolveColor(emberColorElem.color, IM_COL32(255, 120, 20, 255));
} else {
ember_rise_.color = IM_COL32(255, 120, 20, 255);
}
// ---- Gradient Border Shift ----
gradient_border_.enabled = eff("gradient-border-enabled").sizeOr(0.0f) > 0.5f;
gradient_border_.speed = eff("gradient-border-speed").sizeOr(0.15f);
gradient_border_.thickness = eff("gradient-border-thickness").sizeOr(1.5f);
gradient_border_.alpha = eff("gradient-border-alpha").sizeOr(0.6f);
auto gbColorA = eff("gradient-border-color-a");
if (!gbColorA.color.empty()) {
gradient_border_.colorA = S.resolveColor(gbColorA.color, IM_COL32(206, 147, 216, 255));
}
auto gbColorB = eff("gradient-border-color-b");
if (!gbColorB.color.empty()) {
gradient_border_.colorB = S.resolveColor(gbColorB.color, IM_COL32(26, 35, 126, 255));
}
// ---- Specular Glare ----
specular_glare_.enabled = eff("specular-glare-enabled").sizeOr(0.0f) > 0.5f;
specular_glare_.speed = eff("specular-glare-speed").sizeOr(0.02f);
specular_glare_.intensity = eff("specular-glare-intensity").sizeOr(0.06f);
specular_glare_.radius = eff("specular-glare-radius").sizeOr(0.5f);
specular_glare_.count = (int)eff("specular-glare-count").sizeOr(2.0f);
auto glareColorElem = eff("specular-glare-color");
if (!glareColorElem.color.empty()) {
specular_glare_.color = S.resolveColor(glareColorElem.color, IM_COL32(255, 255, 255, 255));
} else {
specular_glare_.color = IM_COL32(255, 255, 255, 255);
}
// ---- Sandstorm ----
sandstorm_.enabled = eff("sandstorm-enabled").sizeOr(0.0f) > 0.5f;
sandstorm_.count = (int)eff("sandstorm-count").sizeOr(80.0f);
sandstorm_.speed = eff("sandstorm-speed").sizeOr(0.35f);
sandstorm_.windAngle = eff("sandstorm-wind-angle").sizeOr(15.0f);
sandstorm_.particleSize = eff("sandstorm-particle-size").sizeOr(1.5f);
sandstorm_.alpha = eff("sandstorm-alpha").sizeOr(0.35f);
sandstorm_.gustSpeed = eff("sandstorm-gust-speed").sizeOr(0.07f);
sandstorm_.gustStrength = eff("sandstorm-gust-strength").sizeOr(0.4f);
sandstorm_.streakLength = eff("sandstorm-streak-length").sizeOr(3.0f);
auto sandColorElem = eff("sandstorm-color");
if (!sandColorElem.color.empty()) {
sandstorm_.color = S.resolveColor(sandColorElem.color, IM_COL32(200, 160, 96, 255));
} else {
sandstorm_.color = IM_COL32(200, 160, 96, 255);
}
// ---- Viewport Overlay (shader-like post-processing) ----
viewport_overlay_.colorWashEnabled = eff("viewport-wash-enabled").sizeOr(0.0f) > 0.5f;
viewport_overlay_.washAlpha = eff("viewport-wash-alpha").sizeOr(0.05f);
viewport_overlay_.washRotateSpeed = eff("viewport-wash-rotate").sizeOr(0.0f);
viewport_overlay_.washPulseSpeed = eff("viewport-wash-pulse").sizeOr(0.0f);
viewport_overlay_.washPulseDepth = eff("viewport-wash-pulse-depth").sizeOr(0.0f);
auto washTL = eff("viewport-wash-tl");
auto washTR = eff("viewport-wash-tr");
auto washBL = eff("viewport-wash-bl");
auto washBR = eff("viewport-wash-br");
if (!washTL.color.empty()) viewport_overlay_.cornerTL = S.resolveColor(washTL.color, IM_COL32(255,100,0,255));
if (!washTR.color.empty()) viewport_overlay_.cornerTR = S.resolveColor(washTR.color, IM_COL32(68,34,0,255));
if (!washBL.color.empty()) viewport_overlay_.cornerBL = S.resolveColor(washBL.color, IM_COL32(68,17,0,255));
if (!washBR.color.empty()) viewport_overlay_.cornerBR = S.resolveColor(washBR.color, IM_COL32(255,153,0,255));
viewport_overlay_.vignetteEnabled = eff("viewport-vignette-enabled").sizeOr(0.0f) > 0.5f;
viewport_overlay_.vignetteRadius = eff("viewport-vignette-radius").sizeOr(0.35f);
viewport_overlay_.vignetteAlpha = eff("viewport-vignette-alpha").sizeOr(0.30f);
auto vigColorElem = eff("viewport-vignette-color");
if (!vigColorElem.color.empty()) {
viewport_overlay_.vignetteColor = S.resolveColor(vigColorElem.color, IM_COL32(0,0,0,255));
} else {
viewport_overlay_.vignetteColor = IM_COL32(0,0,0,255);
}
DEBUG_LOGF("[ThemeEffects] Loaded — hue:%d rainbow:%d shimmer:%d pos_hue:%d glow:%d edge:%d ember:%d glare:%d sand:%d wash:%d vignette:%d\n",
hue_cycle_.enabled, rainbow_border_.enabled, shimmer_.enabled,
positional_hue_.enabled, glow_pulse_.enabled,
edge_trace_.enabled, ember_rise_.enabled, specular_glare_.enabled,
sandstorm_.enabled,
viewport_overlay_.colorWashEnabled, viewport_overlay_.vignetteEnabled);
}
// ============================================================================
// Hue-Cycling Accents
// ============================================================================
ImU32 ThemeEffects::getAccentColor(float phaseOffset) const {
if (!enabled_ || !hue_cycle_.enabled) return IM_COL32(255, 218, 0, 255);
float hue = std::fmod(hue_cycle_.offset + time_ * hue_cycle_.speed + phaseOffset, 1.0f);
if (hue < 0.0f) hue += 1.0f;
// Clamp to configured range
hue = hue_cycle_.offset + std::fmod(hue, std::max(0.01f, hue_cycle_.range));
hue = std::fmod(hue, 1.0f);
float r, g, b;
ImGui::ColorConvertHSVtoRGB(hue, hue_cycle_.sat, hue_cycle_.val, r, g, b);
return IM_COL32((int)(r * 255), (int)(g * 255), (int)(b * 255), 255);
}
// ============================================================================
// Rainbow Gradient Border
// ============================================================================
ImU32 ThemeEffects::sampleGradient(float t) const {
if (rainbow_border_.stops.empty()) return IM_COL32(255,255,255,128);
int n = (int)rainbow_border_.stops.size();
if (n == 1) return rainbow_border_.stops[0];
t = std::fmod(t, 1.0f);
if (t < 0.0f) t += 1.0f;
float segment = t * n;
int idx = (int)segment;
float frac = segment - idx;
int next = (idx + 1) % n;
idx = idx % n;
ImU32 c1 = rainbow_border_.stops[idx];
ImU32 c2 = rainbow_border_.stops[next];
// Lerp RGBA components
int r = (int)((c1 & 0xFF) + frac * (((int)(c2 & 0xFF)) - (int)(c1 & 0xFF)));
int g = (int)(((c1 >> 8) & 0xFF) + frac * (((int)((c2 >> 8) & 0xFF)) - (int)((c1 >> 8) & 0xFF)));
int b = (int)(((c1 >> 16) & 0xFF) + frac * (((int)((c2 >> 16) & 0xFF)) - (int)((c1 >> 16) & 0xFF)));
int a = (int)(rainbow_border_.alpha * bgOpacity_ * 255.0f);
return IM_COL32(r, g, b, a);
}
void ThemeEffects::drawRainbowBorder(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
float rounding, float thickness) const {
if (!enabled_ || !rainbow_border_.enabled || rainbow_border_.stops.empty()) return;
float t = time_ * rainbow_border_.speed;
// Draw the rainbow border as line segments around the rounded perimeter.
// Each segment gets a color sampled from the gradient at its position,
// giving a smoothly rotating hue-shift that follows rounded corners.
//
// We need enough segments so that the curved corners are smooth.
// Each corner arc ≈ π*r/2 pixels. We want ~1 segment per 2-3px of arc
// to look smooth, plus the straight edges.
float w = pMax.x - pMin.x;
float h = pMax.y - pMin.y;
float r = std::min(rounding, std::min(w, h) * 0.5f);
float cornerSegs = (r > 0.5f) ? std::ceil(r * 1.5f) : 0.0f; // segments per corner arc
int segments = std::max(48, (int)(4 * cornerSegs + 2 * (w + h) / 8.0f));
segments = std::min(segments, 256); // cap for perf
ImVec2 prev = perimeterPoint(pMin, pMax, 0.0f, rounding);
for (int i = 1; i <= segments; i++) {
float frac = (float)i / (float)segments;
ImVec2 pt = perimeterPoint(pMin, pMax, frac, rounding);
// Sample gradient at the midpoint of this segment for smooth color
float midFrac = (frac - 0.5f / segments);
ImU32 c = sampleGradient(t + midFrac);
dl->AddLine(prev, pt, c, thickness);
prev = pt;
}
}
// ============================================================================
// Shimmer Sweep
// ============================================================================
void ThemeEffects::drawShimmer(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
float rounding) const {
if (!enabled_ || !shimmer_.enabled) return;
float w = pMax.x - pMin.x;
float h = pMax.y - pMin.y;
if (w <= 0 || h <= 0) return;
// Sweep position: band moves left to right across the panel
float period = 1.0f / std::max(0.01f, shimmer_.speed);
float totalTravel = w + shimmer_.width * 2.0f; // band enters and exits
float pos = std::fmod(time_ / period, 1.0f) * totalTravel - shimmer_.width;
// Band bounds (horizontal, ignoring angle for simplicity in this version)
float bandLeft = pMin.x + pos;
float bandRight = bandLeft + shimmer_.width;
// Clip to panel
float clLeft = std::max(bandLeft, pMin.x);
float clRight = std::min(bandRight, pMax.x);
if (clLeft >= clRight) return;
float mid = (clLeft + clRight) * 0.5f;
int peakA = scaledAlpha(shimmer_.alpha, bgOpacity_);
// Extract shimmer color RGB (ignore alpha, use shimmer_.alpha)
RGB s = unpackRGB(shimmer_.color);
ImU32 clear = IM_COL32(s.r, s.g, s.b, 0);
ImU32 peak = IM_COL32(s.r, s.g, s.b, peakA);
// Inset clip rect by corner rounding to prevent shimmer bleeding into rounded corners
float cr = std::min(rounding, std::min(w, h) * 0.5f) * 0.3f;
dl->PushClipRect(ImVec2(pMin.x + cr, pMin.y + cr),
ImVec2(pMax.x - cr, pMax.y - cr), true);
// Left half: transparent → peak
if (clLeft < mid) {
dl->AddRectFilledMultiColor(
ImVec2(clLeft, pMin.y), ImVec2(mid, pMax.y),
clear, peak, peak, clear);
}
// Right half: peak → transparent
if (mid < clRight) {
dl->AddRectFilledMultiColor(
ImVec2(mid, pMin.y), ImVec2(clRight, pMax.y),
peak, clear, clear, peak);
}
dl->PopClipRect();
}
// ============================================================================
// Specular Glare — polished-surface highlights (obsidian / glass look)
// ============================================================================
void ThemeEffects::drawSpecularGlare(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
float rounding) const {
if (!enabled_ || !specular_glare_.enabled || reduced_transparency_) return;
float w = pMax.x - pMin.x;
float h = pMax.y - pMin.y;
if (w <= 20 || h <= 10) return; // skip tiny panels
float minDim = std::min(w, h);
float glareR = minDim * specular_glare_.radius;
RGB c = unpackRGB(specular_glare_.color);
// Per-panel seed from position — unique drift per panel
float seed = pMin.x * 0.0073f + pMin.y * 0.0137f;
// Clip to panel with rounding inset
float cr = std::min(rounding, minDim * 0.5f) * 0.3f;
dl->PushClipRect(ImVec2(pMin.x + cr, pMin.y + cr),
ImVec2(pMax.x - cr, pMax.y - cr), true);
for (int s = 0; s < specular_glare_.count; s++) {
float phaseSeed = seed + s * 3.14159265f;
// Slow Lissajous drift — each spot follows a unique orbit
float freqX = 2.0f * 3.14159265f * specular_glare_.speed;
float freqY = freqX * 1.618f; // golden ratio offset → non-repeating path
float driftX = std::sin(time_ * freqX + phaseSeed) * 0.3f + 0.5f;
float driftY = std::cos(time_ * freqY + phaseSeed * 1.3f) * 0.25f + 0.4f;
float cx = pMin.x + w * driftX;
float cy = pMin.y + h * driftY;
// Concentric circles with Gaussian falloff for a soft, blurred glow.
// More rings = smoother gradient, more segments = rounder circles.
const int rings = 20;
for (int r = rings; r >= 1; r--) {
float frac = (float)r / (float)rings; // 1.0 → outer, 0.05 → innermost
float ringRadius = glareR * frac;
// Gaussian falloff: exp(-frac² * 4) — soft blur-like appearance
float alphaMul = std::exp(-frac * frac * 4.0f);
int a = (int)(specular_glare_.intensity * alphaMul * bgOpacity_ * 255.0f);
if (a <= 0) continue;
dl->AddCircleFilled(ImVec2(cx, cy), ringRadius,
IM_COL32(c.r, c.g, c.b, a), 32);
}
}
dl->PopClipRect();
}
// ============================================================================
// Positional Hue Tinting
// ============================================================================
ImU32 ThemeEffects::getPositionalTint(float screenY) const {
if (!enabled_ || !positional_hue_.enabled) return IM_COL32(255,255,255,255);
float t = 0.5f;
float range = vpMaxY_ - vpMinY_;
if (range > 0) {
t = std::clamp((screenY - vpMinY_) / range, 0.0f, 1.0f);
}
// Lerp between top and bottom colors
float r = positional_hue_.topColor.x + t * (positional_hue_.bottomColor.x - positional_hue_.topColor.x);
float g = positional_hue_.topColor.y + t * (positional_hue_.bottomColor.y - positional_hue_.topColor.y);
float b = positional_hue_.topColor.z + t * (positional_hue_.bottomColor.z - positional_hue_.topColor.z);
return IM_COL32((int)(r * 255), (int)(g * 255), (int)(b * 255), 255);
}
ImU32 ThemeEffects::tintByPosition(ImU32 baseColor, float screenY) const {
if (!enabled_ || !positional_hue_.enabled || positional_hue_.strength <= 0.0f) return baseColor;
ImU32 tint = getPositionalTint(screenY);
float s = positional_hue_.strength;
float inv = 1.0f - s;
RGB base = unpackRGB(baseColor);
int bA = (baseColor >> 24) & 0xFF;
RGB t = unpackRGB(tint);
int r = (int)(base.r * inv + t.r * s);
int g = (int)(base.g * inv + t.g * s);
int b = (int)(base.b * inv + t.b * s);
return IM_COL32(r, g, b, bA);
}
// ============================================================================
// Glow Pulse
// ============================================================================
void ThemeEffects::drawGlowPulse(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
float rounding) const {
if (!enabled_ || !glow_pulse_.enabled) return;
// Sinusoidal oscillation
float phase = std::sin(time_ * glow_pulse_.speed * 2.0f * 3.14159265f) * 0.5f + 0.5f;
float alpha = glow_pulse_.minAlpha + phase * (glow_pulse_.maxAlpha - glow_pulse_.minAlpha);
if (alpha <= 0.001f) return;
int a = scaledAlpha(alpha, bgOpacity_);
RGB c = unpackRGB(glow_pulse_.color);
// Border-only glow: concentric outlines with smooth Gaussian falloff.
// Use many fine sub-rings (4× the pixel radius) to avoid visible
// stepping as the overall alpha pulses.
float maxExpand = glow_pulse_.radius;
int rings = std::max(4, (int)(maxExpand * 4.0f));
for (int i = 1; i <= rings; i++) {
float expand = maxExpand * (float)i / (float)rings;
float frac = (float)i / (float)rings; // 0→1 from inner to outer
// Gaussian-like falloff: smooth and continuous
float falloff = std::exp(-frac * frac * 3.0f);
int ringAlpha = (int)(a * falloff);
if (ringAlpha <= 0) continue;
float thickness = std::max(0.5f, 1.5f * (1.0f - frac));
dl->AddRect(
ImVec2(pMin.x - expand, pMin.y - expand),
ImVec2(pMax.x + expand, pMax.y + expand),
IM_COL32(c.r, c.g, c.b, ringAlpha),
rounding + expand * 0.5f, 0, thickness);
}
}
// ============================================================================
// Gradient Border Shift — border color oscillates between two gem colors
// ============================================================================
void ThemeEffects::drawGradientBorderShift(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
float rounding) const {
if (!enabled_ || !gradient_border_.enabled) return;
// Smooth sinusoidal oscillation between color A and color B
float phase = std::sin(time_ * gradient_border_.speed * 2.0f * 3.14159265f) * 0.5f + 0.5f;
// Extract RGBA from both colors and lerp
RGB ca = unpackRGB(gradient_border_.colorA);
RGB cb = unpackRGB(gradient_border_.colorB);
int r = ca.r + (int)((cb.r - ca.r) * phase);
int g = ca.g + (int)((cb.g - ca.g) * phase);
int b = ca.b + (int)((cb.b - ca.b) * phase);
int a = scaledAlpha(gradient_border_.alpha, bgOpacity_);
// Draw the shifting border
dl->AddRect(pMin, pMax, IM_COL32(r, g, b, a),
rounding, 0, gradient_border_.thickness);
// Subtle outer glow ring at lower alpha for depth
float expand = gradient_border_.thickness;
int glowA = a / 3;
if (glowA > 0) {
dl->AddRect(
ImVec2(pMin.x - expand, pMin.y - expand),
ImVec2(pMax.x + expand, pMax.y + expand),
IM_COL32(r, g, b, glowA),
rounding + expand * 0.5f, 0, 1.0f);
}
}
// ============================================================================
// Edge Trace — a bright segment that traces the border perimeter
// ============================================================================
ImVec2 ThemeEffects::perimeterPoint(ImVec2 pMin, ImVec2 pMax, float t, float rounding) const {
float w = pMax.x - pMin.x;
float h = pMax.y - pMin.y;
if (w <= 0 || h <= 0) return pMin;
// Clamp rounding to half of the smaller dimension
float r = std::min(rounding, std::min(w, h) * 0.5f);
if (r < 0.5f) r = 0.0f; // treat tiny rounding as none
t = std::fmod(t, 1.0f);
if (t < 0.0f) t += 1.0f;
if (r <= 0.0f) {
// Original rectangular path
float perimeter = 2.0f * (w + h);
float d = t * perimeter;
if (d < w) return ImVec2(pMin.x + d, pMin.y);
else if (d < w + h) return ImVec2(pMax.x, pMin.y + (d - w));
else if (d < 2*w+h) return ImVec2(pMax.x - (d - w - h), pMax.y);
else return ImVec2(pMin.x, pMax.y - (d - 2*w - h));
}
// Rounded rect perimeter:
// Segments (clockwise starting from top-left of top edge):
// 1. Top edge: (x+r, y) → (x+w-r, y) len = w - 2r
// 2. TR arc: center (x+w-r, y+r) -90° → 0° len = πr/2
// 3. Right edge: (x+w, y+r) → (x+w, y+h-r) len = h - 2r
// 4. BR arc: center (x+w-r, y+h-r) 0° → 90° len = πr/2
// 5. Bottom edge: (x+w-r, y+h) → (x+r, y+h) len = w - 2r
// 6. BL arc: center (x+r, y+h-r) 90° → 180° len = πr/2
// 7. Left edge: (x, y+h-r) → (x, y+r) len = h - 2r
// 8. TL arc: center (x+r, y+r) 180° → 270° len = πr/2
const float PI = 3.14159265f;
float arcLen = PI * r * 0.5f;
float edgeW = w - 2.0f * r;
float edgeH = h - 2.0f * r;
float perimeter = 2.0f * edgeW + 2.0f * edgeH + 4.0f * arcLen;
float d = t * perimeter;
// Segment boundaries (cumulative)
float s1 = edgeW; // top edge
float s2 = s1 + arcLen; // TR arc
float s3 = s2 + edgeH; // right edge
float s4 = s3 + arcLen; // BR arc
float s5 = s4 + edgeW; // bottom edge
float s6 = s5 + arcLen; // BL arc
float s7 = s6 + edgeH; // left edge
// s8 = s7 + arcLen = perimeter // TL arc
if (d < s1) {
// Top edge
return ImVec2(pMin.x + r + d, pMin.y);
} else if (d < s2) {
// TR arc: center (pMax.x - r, pMin.y + r), angle -90° to 0°
float frac = (d - s1) / arcLen;
float angle = -PI * 0.5f + frac * PI * 0.5f;
return ImVec2(pMax.x - r + r * std::cos(angle),
pMin.y + r + r * std::sin(angle));
} else if (d < s3) {
// Right edge
return ImVec2(pMax.x, pMin.y + r + (d - s2));
} else if (d < s4) {
// BR arc: center (pMax.x - r, pMax.y - r), angle 0° to 90°
float frac = (d - s3) / arcLen;
float angle = frac * PI * 0.5f;
return ImVec2(pMax.x - r + r * std::cos(angle),
pMax.y - r + r * std::sin(angle));
} else if (d < s5) {
// Bottom edge (right to left)
return ImVec2(pMax.x - r - (d - s4), pMax.y);
} else if (d < s6) {
// BL arc: center (pMin.x + r, pMax.y - r), angle 90° to 180°
float frac = (d - s5) / arcLen;
float angle = PI * 0.5f + frac * PI * 0.5f;
return ImVec2(pMin.x + r + r * std::cos(angle),
pMax.y - r + r * std::sin(angle));
} else if (d < s7) {
// Left edge (bottom to top)
return ImVec2(pMin.x, pMax.y - r - (d - s6));
} else {
// TL arc: center (pMin.x + r, pMin.y + r), angle 180° to 270°
float frac = (d - s7) / arcLen;
float angle = PI + frac * PI * 0.5f;
return ImVec2(pMin.x + r + r * std::cos(angle),
pMin.y + r + r * std::sin(angle));
}
}
void ThemeEffects::drawEdgeTrace(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
float rounding) const {
if (!enabled_ || !edge_trace_.enabled) return;
float headPos = std::fmod(time_ * edge_trace_.speed, 1.0f);
float traceLen = edge_trace_.length;
RGB c = unpackRGB(edge_trace_.color);
// Draw the trace as connected line segments with fading alpha.
// Use enough segments to make curved corners smooth.
float w = pMax.x - pMin.x;
float h = pMax.y - pMin.y;
float r = std::min(rounding, std::min(w, h) * 0.5f);
float cornerSegs = (r > 0.5f) ? std::ceil(r * 1.5f) : 0.0f;
// Scale by trace length — we only draw a fraction of the perimeter
int segments = std::max(24, (int)((4 * cornerSegs + 2 * (w + h) / 8.0f) * traceLen + 8));
segments = std::min(segments, 128);
ImVec2 prev = perimeterPoint(pMin, pMax, headPos, rounding);
for (int i = 1; i <= segments; i++) {
float frac = (float)i / (float)segments; // 0→1 from head to tail
float pos = headPos - frac * traceLen;
if (pos < 0.0f) pos += 1.0f;
ImVec2 pt = perimeterPoint(pMin, pMax, pos, rounding);
// Alpha fades from head (1.0) to tail (0.0), with a cubic falloff
float aFrac = 1.0f - frac;
aFrac = aFrac * aFrac; // quadratic falloff for natural fade
int a = (int)(edge_trace_.alpha * aFrac * bgOpacity_ * 255.0f);
if (a > 0) {
dl->AddLine(prev, pt, IM_COL32(c.r, c.g, c.b, a), edge_trace_.thickness);
}
prev = pt;
}
// Bright dot at the head
int headA = scaledAlpha(edge_trace_.alpha, bgOpacity_);
ImVec2 headPt = perimeterPoint(pMin, pMax, headPos, rounding);
dl->AddCircleFilled(headPt, edge_trace_.thickness * 1.5f,
IM_COL32(c.r, c.g, c.b, headA), 8);
}
// ============================================================================
// Ember Rise — fire particles that drift upward from the element
// ============================================================================
void ThemeEffects::drawEmberRise(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax) const {
if (!enabled_ || !ember_rise_.enabled) return;
float w = pMax.x - pMin.x;
float h = pMax.y - pMin.y;
if (w <= 0 || h <= 0) return;
RGB c = unpackRGB(ember_rise_.color);
for (int i = 0; i < ember_rise_.count; i++) {
// Golden-ratio spacing gives evenly distributed phases
float phase = std::fmod(time_ * ember_rise_.speed + i * 0.618033988f, 1.0f);
// Deterministic pseudo-random x position per particle
// Simple hash: sin of large prime multiples
float xHash = std::sin((float)(i + 1) * 127.1f) * 0.5f + 0.5f;
float xDrift = std::sin(time_ * 0.7f + i * 2.4f) * 4.0f; // gentle sway
float x = pMin.x + w * xHash + xDrift;
float y = pMax.y - phase * (h + 8.0f); // rise from bottom past top
// Fade in at bottom (phase 0→0.2), full in middle, fade out at top (0.7→1.0)
float aFrac;
if (phase < 0.15f) {
aFrac = phase / 0.15f;
} else if (phase > 0.7f) {
aFrac = (1.0f - phase) / 0.3f;
} else {
aFrac = 1.0f;
}
// Size decreases as particle rises (embers shrink)
float size = ember_rise_.particleSize * (1.0f - phase * 0.4f);
int a = (int)(ember_rise_.alpha * aFrac * bgOpacity_ * 255.0f);
// Warm core
dl->AddCircleFilled(ImVec2(x, y), size,
IM_COL32(c.r, c.g, c.b, a), 6);
// Softer outer glow
if (a > 30) {
dl->AddCircleFilled(ImVec2(x, y), size * 2.0f,
IM_COL32(c.r, c.g, c.b, a / 4), 6);
}
}
}
// ============================================================================
// Viewport-wide ambient ember particles (fire theme atmosphere)
// ============================================================================
void ThemeEffects::drawViewportEmbers(ImDrawList* dl) const {
if (!enabled_ || !ember_rise_.enabled) return;
ImGuiViewport* vp = ImGui::GetMainViewport();
float vpW = vp->WorkSize.x;
float vpH = vp->WorkSize.y;
float vpX = vp->WorkPos.x;
float vpY = vp->WorkPos.y;
if (vpW <= 0 || vpH <= 0) return;
RGB c = unpackRGB(ember_rise_.color);
// Viewport embers: more particles, spread across the whole screen
int vpCount = ember_rise_.count * 3;
float vpAlpha = ember_rise_.alpha * 0.4f * bgOpacity_; // softer than panel embers
for (int i = 0; i < vpCount; i++) {
float phase = std::fmod(time_ * ember_rise_.speed * 0.6f + i * 0.618033988f, 1.0f);
// Deterministic x position spread across viewport
float xHash = std::sin((float)(i + 1) * 127.1f) * 43758.5453f;
xHash = xHash - (int)xHash; // fractional part
if (xHash < 0) xHash += 1.0f;
float xDrift = std::sin(time_ * 0.5f + i * 1.7f) * 8.0f;
float x = vpX + vpW * xHash + xDrift;
float y = vpY + vpH * (1.0f - phase); // rise from bottom to top
// Fade in/out
float aFrac;
if (phase < 0.1f) aFrac = phase / 0.1f;
else if (phase > 0.75f) aFrac = (1.0f - phase) / 0.25f;
else aFrac = 1.0f;
float size = ember_rise_.particleSize * 0.8f * (1.0f - phase * 0.3f);
int a = (int)(vpAlpha * aFrac * 255.0f);
if (a <= 0) continue;
dl->AddCircleFilled(ImVec2(x, y), size,
IM_COL32(c.r, c.g, c.b, a), 6);
if (a > 20) {
dl->AddCircleFilled(ImVec2(x, y), size * 2.5f,
IM_COL32(c.r, c.g, c.b, a / 5), 6);
}
}
}
// ============================================================================
// Sandstorm — wind-driven sand/dust particles blowing across the viewport
// ============================================================================
void ThemeEffects::drawSandstorm(ImDrawList* dl) const {
if (!enabled_ || !sandstorm_.enabled || effects::isLowSpecMode()) return;
ImGuiViewport* vp = ImGui::GetMainViewport();
float vpW = vp->WorkSize.x;
float vpH = vp->WorkSize.y;
float vpX = vp->WorkPos.x;
float vpY = vp->WorkPos.y;
if (vpW <= 0 || vpH <= 0) return;
RGB c = unpackRGB(sandstorm_.color);
// Wind direction vector from angle (degrees from horizontal)
float windRad = sandstorm_.windAngle * 3.14159265f / 180.0f;
float windDx = std::cos(windRad); // horizontal component
float windDy = std::sin(windRad); // vertical component (positive = downward)
// Global gust modulation: smoothly varies wind speed
float gust = 1.0f + sandstorm_.gustStrength *
std::sin(time_ * sandstorm_.gustSpeed * 2.0f * 3.14159265f);
// Diagonal traversal distance (how far a particle travels across viewport)
float traversal = vpW + vpH * std::abs(windDy / std::max(0.01f, windDx));
for (int i = 0; i < sandstorm_.count; i++) {
// Deterministic pseudo-random properties per particle
float hash1 = std::sin((float)(i + 1) * 127.1f) * 43758.5453f;
hash1 = hash1 - (int)hash1;
if (hash1 < 0) hash1 += 1.0f;
float hash2 = std::sin((float)(i + 1) * 269.5f) * 27183.3291f;
hash2 = hash2 - (int)hash2;
if (hash2 < 0) hash2 += 1.0f;
float hash3 = std::sin((float)(i + 1) * 419.3f) * 15731.7927f;
hash3 = hash3 - (int)hash3;
if (hash3 < 0) hash3 += 1.0f;
// Per-particle speed variation (0.5x to 1.5x base speed)
float speedMul = 0.5f + hash1;
// Per-particle size variation (0.4x to 1.6x base)
float size = sandstorm_.particleSize * (0.4f + hash2 * 1.2f);
// Per-particle alpha variation (0.5x to 1.0x)
float alphaMul = 0.5f + hash3 * 0.5f;
// Phase: horizontal progress across viewport (0→1)
// Different particles have staggered starts via golden ratio
float phase = std::fmod(
time_ * sandstorm_.speed * speedMul * gust + i * 0.618033988f, 1.0f);
// Y position: distributed across viewport with turbulent drift
float yBase = vpY + vpH * hash2;
float yTurb = std::sin(time_ * 0.8f + i * 3.7f) * vpH * 0.04f;
// Additional gust-correlated vertical shift
float yGust = std::sin(time_ * sandstorm_.gustSpeed * 6.28318f + i * 1.3f) * vpH * 0.02f;
// Position: particle blows right-to-left with downward drift
float x = vpX + vpW * (1.0f - phase) + phase * traversal * (1.0f - windDx) * 0.1f;
float y = yBase + yTurb + yGust + phase * vpH * windDy * 0.3f;
// Wrap Y if it goes out of bounds
if (y < vpY) y += vpH;
if (y > vpY + vpH) y -= vpH;
// Fade in at entry edge (phase 0→0.1), fade out at exit (0.85→1.0)
float aFrac;
if (phase < 0.1f) aFrac = phase / 0.1f;
else if (phase > 0.85f) aFrac = (1.0f - phase) / 0.15f;
else aFrac = 1.0f;
int a = (int)(sandstorm_.alpha * alphaMul * aFrac * bgOpacity_ * 255.0f);
if (a <= 0) continue;
// Fast particles draw as motion-blurred streaks
float particleSpeed = sandstorm_.speed * speedMul * gust;
float streakLen = sandstorm_.streakLength * particleSpeed * size;
if (streakLen > size * 1.5f) {
// Draw as a short line (motion streak)
float sx = -windDx * streakLen;
float sy = -windDy * streakLen * 0.3f;
dl->AddLine(
ImVec2(x, y),
ImVec2(x + sx, y + sy),
IM_COL32(c.r, c.g, c.b, a),
size * 0.7f);
// Bright head dot
dl->AddCircleFilled(ImVec2(x, y), size * 0.5f,
IM_COL32(c.r, c.g, c.b, std::min(255, a * 3 / 2)), 5);
} else {
// Small/slow particles: simple circle
dl->AddCircleFilled(ImVec2(x, y), size,
IM_COL32(c.r, c.g, c.b, a), 6);
}
// Occasional larger dust puff (every ~8th particle, double size, half alpha)
if (i % 8 == 0 && a > 15) {
dl->AddCircleFilled(ImVec2(x, y), size * 2.5f,
IM_COL32(c.r, c.g, c.b, a / 4), 8);
}
}
}
// ============================================================================
// Panel-level effects (applied to every glass panel via DrawGlassPanel)
// ============================================================================
void ThemeEffects::drawPanelEffects(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
float rounding) const {
if (!enabled_ || effects::isLowSpecMode()) return;
// Edge trace on panels — use position-based phase offset so each
// panel's tracer is at a different position around the border
if (edge_trace_.enabled) {
float w = pMax.x - pMin.x;
float h = pMax.y - pMin.y;
if (w > 80 && h > 40) { // skip small panels
// Generate a consistent phase offset from panel position
float posKey = (pMin.x * 0.0073f + pMin.y * 0.0137f);
posKey = posKey - (int)posKey; // fractional 0..1
if (posKey < 0) posKey += 1.0f;
float headPos = std::fmod(time_ * edge_trace_.speed + posKey, 1.0f);
float traceLen = edge_trace_.length;
RGB c = unpackRGB(edge_trace_.color);
// Panel edge trace is subtler than sidebar — 60% alpha
float panelAlpha = edge_trace_.alpha * 0.6f * bgOpacity_;
const int segments = 10;
ImVec2 prev = perimeterPoint(pMin, pMax, headPos, rounding);
for (int i = 1; i <= segments; i++) {
float frac = (float)i / (float)segments;
float pos = headPos - frac * traceLen;
if (pos < 0.0f) pos += 1.0f;
ImVec2 pt = perimeterPoint(pMin, pMax, pos, rounding);
float aFrac = 1.0f - frac;
aFrac = aFrac * aFrac;
int a = (int)(panelAlpha * aFrac * 255.0f);
if (a > 0) {
dl->AddLine(prev, pt, IM_COL32(c.r, c.g, c.b, a),
edge_trace_.thickness * 0.8f);
}
prev = pt;
}
// Head dot
int headA = (int)(panelAlpha * 255.0f);
ImVec2 headPt = perimeterPoint(pMin, pMax, headPos, rounding);
dl->AddCircleFilled(headPt, edge_trace_.thickness * 1.2f,
IM_COL32(c.r, c.g, c.b, headA), 6);
}
}
// Ember rise on panels — sparse embers from larger panels only
if (ember_rise_.enabled) {
float w = pMax.x - pMin.x;
float h = pMax.y - pMin.y;
if (w > 120 && h > 60) { // only on large panels
// Fewer particles per panel (scale by area relative to viewport)
int panelCount = std::max(2, ember_rise_.count / 3);
float panelAlpha = ember_rise_.alpha * 0.5f * bgOpacity_;
RGB c = unpackRGB(ember_rise_.color);
// Use panel position as seed for unique particle distribution
float seed = pMin.x * 0.013f + pMin.y * 0.031f;
dl->PushClipRect(
ImVec2(pMin.x - 2, pMin.y - 4),
ImVec2(pMax.x + 2, pMax.y + 2), true);
for (int i = 0; i < panelCount; i++) {
float phase = std::fmod(time_ * ember_rise_.speed + i * 0.618033988f + seed, 1.0f);
float xHash = std::sin((float)(i + 1) * 127.1f + seed * 100.0f) * 0.5f + 0.5f;
float xDrift = std::sin(time_ * 0.7f + i * 2.4f + seed) * 3.0f;
float x = pMin.x + w * xHash + xDrift;
float y = pMax.y - phase * (h + 6.0f);
float aFrac;
if (phase < 0.15f) aFrac = phase / 0.15f;
else if (phase > 0.7f) aFrac = (1.0f - phase) / 0.3f;
else aFrac = 1.0f;
float size = ember_rise_.particleSize * 0.8f * (1.0f - phase * 0.4f);
int a = (int)(panelAlpha * aFrac * 255.0f);
if (a <= 0) continue;
dl->AddCircleFilled(ImVec2(x, y), size,
IM_COL32(c.r, c.g, c.b, a), 6);
}
dl->PopClipRect();
}
}
}
// ============================================================================
// Viewport Overlay — shader-like full-screen color wash + vignette
// ============================================================================
void ThemeEffects::drawViewportOverlay(ImDrawList* dl) const {
if (!enabled_ || effects::isLowSpecMode()) return;
ImGuiViewport* vp = ImGui::GetMainViewport();
ImVec2 vpPos = vp->WorkPos;
ImVec2 vpSize = vp->WorkSize;
if (vpSize.x <= 0 || vpSize.y <= 0) return;
ImVec2 vpMax = ImVec2(vpPos.x + vpSize.x, vpPos.y + vpSize.y);
// === Color Wash: full-screen 4-corner animated gradient ===
if (viewport_overlay_.colorWashEnabled) {
ImU32 corners[4] = {
viewport_overlay_.cornerTL,
viewport_overlay_.cornerTR,
viewport_overlay_.cornerBR,
viewport_overlay_.cornerBL
};
// Rotation: smoothly shift which color appears at which corner
if (viewport_overlay_.washRotateSpeed > 0.001f) {
float shift = std::fmod(time_ * viewport_overlay_.washRotateSpeed, 1.0f);
ImU32 originals[4] = { corners[0], corners[1], corners[2], corners[3] };
for (int c = 0; c < 4; c++) {
float pos = std::fmod(shift + c * 0.25f, 1.0f);
float segment = pos * 4.0f;
int idx = ((int)segment) % 4;
float frac = segment - (int)segment;
int next = (idx + 1) % 4;
int r1 = originals[idx] & 0xFF, g1 = (originals[idx]>>8)&0xFF, b1 = (originals[idx]>>16)&0xFF;
int r2 = originals[next] & 0xFF, g2 = (originals[next]>>8)&0xFF, b2 = (originals[next]>>16)&0xFF;
corners[c] = IM_COL32(
r1 + (int)(frac * (r2 - r1)),
g1 + (int)(frac * (g2 - g1)),
b1 + (int)(frac * (b2 - b1)),
255);
}
}
// Calculate final alpha — keep it constant to avoid integer
// quantisation stepping at low values (0.040.12 → only 1030
// integer levels). Breathing is achieved by modulating the RGB
// colour intensity instead, which has far more resolution.
float alpha = viewport_overlay_.washAlpha * bgOpacity_;
int a = std::clamp((int)(alpha * 255.0f + 0.5f), 0, 255);
if (a > 0) {
// Smooth breathing via colour modulation (not alpha)
float colorMul = 1.0f;
if (viewport_overlay_.washPulseSpeed > 0.001f) {
float pulse = std::sin(time_ * viewport_overlay_.washPulseSpeed * 2.0f * 3.14159265f) * 0.5f + 0.5f;
colorMul = 1.0f - viewport_overlay_.washPulseDepth * (1.0f - pulse);
}
// Apply colour modulation to each corner while keeping alpha constant
auto modCorner = [&](ImU32 c) -> ImU32 {
int r = std::clamp((int)((c & 0xFF) * colorMul + 0.5f), 0, 255);
int g = std::clamp((int)(((c >> 8) & 0xFF) * colorMul + 0.5f), 0, 255);
int b = std::clamp((int)(((c >> 16) & 0xFF) * colorMul + 0.5f), 0, 255);
return IM_COL32(r, g, b, a);
};
ImU32 c0 = modCorner(corners[0]);
ImU32 c1 = modCorner(corners[1]);
ImU32 c2 = modCorner(corners[2]);
ImU32 c3 = modCorner(corners[3]);
dl->AddRectFilledMultiColor(vpPos, vpMax, c0, c1, c2, c3);
}
}
// === Vignette: edge darkening/tinting (4 gradient strips) ===
// Overlapping strips naturally darken corners more than edges — cinematic look
if (viewport_overlay_.vignetteEnabled) {
RGB c = unpackRGB(viewport_overlay_.vignetteColor);
int maxA = scaledAlpha(viewport_overlay_.vignetteAlpha, bgOpacity_);
if (maxA > 0) {
ImU32 edgeCol = IM_COL32(c.r, c.g, c.b, maxA);
ImU32 clearCol = IM_COL32(c.r, c.g, c.b, 0);
float fadeW = vpSize.x * viewport_overlay_.vignetteRadius;
float fadeH = vpSize.y * viewport_overlay_.vignetteRadius;
// Top strip: dark at top edge, transparent at fade boundary
dl->AddRectFilledMultiColor(
vpPos,
ImVec2(vpMax.x, vpPos.y + fadeH),
edgeCol, edgeCol, clearCol, clearCol);
// Bottom strip
dl->AddRectFilledMultiColor(
ImVec2(vpPos.x, vpMax.y - fadeH),
vpMax,
clearCol, clearCol, edgeCol, edgeCol);
// Left strip
dl->AddRectFilledMultiColor(
vpPos,
ImVec2(vpPos.x + fadeW, vpMax.y),
edgeCol, clearCol, clearCol, edgeCol);
// Right strip
dl->AddRectFilledMultiColor(
ImVec2(vpMax.x - fadeW, vpPos.y),
vpMax,
clearCol, edgeCol, edgeCol, clearCol);
}
}
}
// ============================================================================
// Hex color parsing helpers
// ============================================================================
ImU32 ThemeEffects::parseHexColor(const std::string& hex, ImU32 fallback) {
if (hex.empty() || hex[0] != '#') return fallback;
unsigned int val = 0;
if (hex.size() == 7) { // #RRGGBB
val = (unsigned int)strtoul(hex.c_str() + 1, nullptr, 16);
int r = (val >> 16) & 0xFF;
int g = (val >> 8) & 0xFF;
int b = val & 0xFF;
return IM_COL32(r, g, b, 255);
} else if (hex.size() == 9) { // #RRGGBBAA
val = (unsigned int)strtoul(hex.c_str() + 1, nullptr, 16);
int r = (val >> 24) & 0xFF;
int g = (val >> 16) & 0xFF;
int b = (val >> 8) & 0xFF;
int a = val & 0xFF;
return IM_COL32(r, g, b, a);
}
return fallback;
}
ImVec4 ThemeEffects::parseHexColorVec4(const std::string& hex, ImVec4 fallback) {
ImU32 c = parseHexColor(hex, 0);
if (c == 0 && hex != "#000000") return fallback;
float r = (c & 0xFF) / 255.0f;
float g = ((c >> 8) & 0xFF) / 255.0f;
float b = ((c >> 16) & 0xFF) / 255.0f;
float a = ((c >> 24) & 0xFF) / 255.0f;
return ImVec4(r, g, b, a);
}
} // namespace effects
} // namespace ui
} // namespace dragonx