Files
ObsidianDragon/src/ui/effects/theme_effects.cpp
DanS d27f387d6d feat(ui): in-app FAQ, DPI-scaling audit fixes, mining/stratum polish + localize strings
Bundles the session's UI work (the touched files carry several of these
changes together, so they are committed as one coherent UI batch):

- FAQ: new RenderFaqDialog + data-driven faq_content, opened from a
  status-bar "?" (and the Windows title bar), styled like the Wallets
  modal with search, Wallet/Daemon tabs, and smooth scroll.
- DPI/font-scale audit: multiply hand-drawn absolute geometry by
  Layout::dpiScale() across ~30 files so nothing renders native-size at
  HiDPI / font_scale 1.5 (verified with a full sweep at 1.5x).
- Mining: chart now fills the horizontal space; thread stepper +/- buttons
  match the input-box height; move the stratum-host toggle into
  Node & Security (v1.3.0+).
- Settings: fix the auto-shield status text overlapping the grid.
- Sidebar: drop the peer-count badge on the Network button.
- i18n: wrap 193 hardcoded literals with TR() (keys/translations added in
  the preceding i18n commit), so the security/PIN/lock flow, seed-backup
  wizard, and witness-rebuild dialog localize.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-01 19:37:16 -05:00

1169 lines
49 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 "../layout.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();
const float dp = Layout::dpiScale();
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) * dp;
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) * dp;
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) * dp;
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) * dp;
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;
// Opt-in: also draw the shifting border on every glass panel (not just the
// active nav button). Off by default so themes that only want the button
// accent (e.g. Obsidian) are unaffected; Jade turns it on as its hero.
gradient_border_.panels = eff("gradient-border-panels").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) * dp;
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) * dp;
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, float phaseOffset,
float alphaMul) const {
if (!enabled_ || !gradient_border_.enabled) return;
// Smooth sinusoidal oscillation between color A and color B.
// phaseOffset shifts where in the cycle this element sits so a wall of
// panels reads like veins at different depths rather than one pulse.
float phase = std::sin((time_ * gradient_border_.speed + phaseOffset)
* 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 * alphaMul, 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;
const float dp = Layout::dpiScale();
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 * dp; // 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;
const float dp = Layout::dpiScale();
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 * dp;
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;
// Gradient border on panels — a slow color-shifting outline that hugs the
// panel's rounded corners (drawn via AddRect, so it follows the rounding
// exactly — no polygonal corners). Position-based phase offset makes each
// panel drift like a vein at a different depth; softer than the active
// nav button (alphaMul 0.6) so a screenful of panels stays calm.
if (gradient_border_.enabled && gradient_border_.panels) {
float w = pMax.x - pMin.x;
float h = pMax.y - pMin.y;
if (w > 80 && h > 40) { // skip small panels
float posKey = (pMin.x * 0.0073f + pMin.y * 0.0137f);
posKey = posKey - (int)posKey; // fractional 0..1
if (posKey < 0) posKey += 1.0f;
drawGradientBorderShift(dl, pMin, pMax, rounding, posKey, 0.6f);
}
}
// 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