diff --git a/src/ui/effects/effect_clock.h b/src/ui/effects/effect_clock.h new file mode 100644 index 0000000..263f1ff --- /dev/null +++ b/src/ui/effects/effect_clock.h @@ -0,0 +1,34 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 +#pragma once +#include + +namespace dragonx { namespace ui { namespace effects { + +constexpr double kEffectTwoPi = 6.283185307179586476925286766559; + +/// Fractional part in [0,1) of t*rate + offset, evaluated in double so the result stays +/// smooth after days of uptime (a float clock steps at one-frame granularity from ~3 days). +inline double cyclePhaseD(double t, double rate, double offset = 0.0) { + double p = std::fmod(t * rate + offset, 1.0); + if (p < 0.0) p += 1.0; + if (p >= 1.0) p = 0.0; // p was -tiny: p + 1.0 rounded up to 1.0 + return p; +} +inline float cyclePhase(double t, double rate, double offset = 0.0) { + return static_cast(cyclePhaseD(t, rate, offset)); +} +/// sin(2*pi*(t*cycles + offset)) - oscillator whose speed is in cycles per second. +inline float cycleSin(double t, double cycles, double offset = 0.0) { + return static_cast(std::sin(kEffectTwoPi * cyclePhaseD(t, cycles, offset))); +} +/// sin/cos(t*omega + offset), omega in rad/s; argument reduced in double. +inline float radSin(double t, double omega, double offset = 0.0) { + return static_cast(std::sin(std::fmod(t * omega + offset, kEffectTwoPi))); +} +inline float radCos(double t, double omega, double offset = 0.0) { + return static_cast(std::cos(std::fmod(t * omega + offset, kEffectTwoPi))); +} + +}}} // namespace diff --git a/src/ui/effects/theme_effects.cpp b/src/ui/effects/theme_effects.cpp index cc2d2f1..63cb8ff 100644 --- a/src/ui/effects/theme_effects.cpp +++ b/src/ui/effects/theme_effects.cpp @@ -4,6 +4,7 @@ #include "theme_effects.h" #include "low_spec.h" +#include "effect_clock.h" #include "../schema/ui_schema.h" #include "../layout.h" #include @@ -47,7 +48,7 @@ ThemeEffects& ThemeEffects::instance() { // ============================================================================ void ThemeEffects::beginFrame() { - if (!motion_frozen_) time_ = (float)ImGui::GetTime(); + if (!motion_frozen_) time_ = ImGui::GetTime(); ImGuiViewport* vp = ImGui::GetMainViewport(); vpMinY_ = vp->WorkPos.y; vpMaxY_ = vp->WorkPos.y + vp->WorkSize.y; @@ -248,7 +249,7 @@ void ThemeEffects::loadFromTheme() { 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); + float hue = cyclePhase(time_, hue_cycle_.speed, (double)hue_cycle_.offset + phaseOffset); 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)); @@ -293,7 +294,7 @@ 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; + float t = cyclePhase(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, @@ -338,7 +339,7 @@ void ThemeEffects::drawShimmer(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax, // 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; + float pos = cyclePhase(time_, 1.0 / period) * totalTravel - shimmer_.width; // Band bounds (horizontal, ignoring angle for simplicity in this version) float bandLeft = pMin.x + pos; @@ -408,10 +409,10 @@ void ThemeEffects::drawSpecularGlare(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax, 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; + const double freqX = kEffectTwoPi * specular_glare_.speed; + const double freqY = freqX * 1.618; // golden ratio offset → non-repeating path + float driftX = radSin(time_, freqX, phaseSeed) * 0.3f + 0.5f; + float driftY = radCos(time_, freqY, phaseSeed * 1.3) * 0.25f + 0.4f; float cx = pMin.x + w * driftX; float cy = pMin.y + h * driftY; @@ -484,7 +485,7 @@ void ThemeEffects::drawGlowPulse(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax, 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 phase = cycleSin(time_, glow_pulse_.speed) * 0.5f + 0.5f; float alpha = glow_pulse_.minAlpha + phase * (glow_pulse_.maxAlpha - glow_pulse_.minAlpha); if (alpha <= 0.001f) return; @@ -525,8 +526,7 @@ void ThemeEffects::drawGradientBorderShift(ImDrawList* dl, ImVec2 pMin, ImVec2 p // 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; + float phase = cycleSin(time_, gradient_border_.speed, phaseOffset) * 0.5f + 0.5f; // Extract RGBA from both colors and lerp RGB ca = unpackRGB(gradient_border_.colorA); @@ -649,7 +649,7 @@ 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 headPos = cyclePhase(time_, edge_trace_.speed); float traceLen = edge_trace_.length; RGB c = unpackRGB(edge_trace_.color); @@ -705,12 +705,12 @@ void ThemeEffects::drawEmberRise(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax) const 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); + float phase = cyclePhase(time_, ember_rise_.speed, i * 0.618033988); // 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 xDrift = radSin(time_, 0.7, i * 2.4) * 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 @@ -763,13 +763,13 @@ void ThemeEffects::drawViewportEmbers(ImDrawList* dl) const { 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); + float phase = cyclePhase(time_, ember_rise_.speed * 0.6, i * 0.618033988); // 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 xDrift = radSin(time_, 0.5, i * 1.7) * 8.0f * dp; float x = vpX + vpW * xHash + xDrift; float y = vpY + vpH * (1.0f - phase); // rise from bottom to top @@ -815,8 +815,7 @@ void ThemeEffects::drawSandstorm(ImDrawList* dl) const { 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); + float gust = 1.0f + sandstorm_.gustStrength * cycleSin(time_, sandstorm_.gustSpeed); // Diagonal traversal distance (how far a particle travels across viewport) float traversal = vpW + vpH * std::abs(windDy / std::max(0.01f, windDx)); @@ -844,14 +843,13 @@ void ThemeEffects::drawSandstorm(ImDrawList* dl) const { // 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); + float phase = cyclePhase(time_, (double)sandstorm_.speed * speedMul * gust, i * 0.618033988); // 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; + float yTurb = radSin(time_, 0.8, i * 3.7) * vpH * 0.04f; // Additional gust-correlated vertical shift - float yGust = std::sin(time_ * sandstorm_.gustSpeed * 6.28318f + i * 1.3f) * vpH * 0.02f; + float yGust = radSin(time_, sandstorm_.gustSpeed * 6.28318, i * 1.3) * 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; @@ -935,7 +933,7 @@ void ThemeEffects::drawPanelEffects(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax, 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 headPos = cyclePhase(time_, edge_trace_.speed, posKey); float traceLen = edge_trace_.length; RGB c = unpackRGB(edge_trace_.color); @@ -988,9 +986,9 @@ void ThemeEffects::drawPanelEffects(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax, 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 phase = cyclePhase(time_, ember_rise_.speed, i * 0.618033988 + seed); 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 xDrift = radSin(time_, 0.7, i * 2.4 + seed) * 3.0f; float x = pMin.x + w * xHash + xDrift; float y = pMax.y - phase * (h + 6.0f); @@ -1038,7 +1036,7 @@ void ThemeEffects::drawViewportOverlay(ImDrawList* dl) const { // 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); + float shift = cyclePhase(time_, viewport_overlay_.washRotateSpeed); ImU32 originals[4] = { corners[0], corners[1], corners[2], corners[3] }; for (int c = 0; c < 4; c++) { @@ -1068,7 +1066,7 @@ void ThemeEffects::drawViewportOverlay(ImDrawList* dl) const { // 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; + float pulse = cycleSin(time_, viewport_overlay_.washPulseSpeed) * 0.5f + 0.5f; colorMul = 1.0f - viewport_overlay_.washPulseDepth * (1.0f - pulse); } diff --git a/src/ui/effects/theme_effects.h b/src/ui/effects/theme_effects.h index fc05fc4..ad72ad8 100644 --- a/src/ui/effects/theme_effects.h +++ b/src/ui/effects/theme_effects.h @@ -133,7 +133,7 @@ private: bool enabled_ = true; bool reduced_transparency_ = false; - float time_ = 0.0f; + double time_ = 0.0; ///< ImGui clock, kept double: float steps coarsely after days of uptime bool motion_frozen_ = false; ///< Reduce Motion: beginFrame() leaves time_ alone float bgOpacity_ = 1.0f; ///< window-opacity multiplier for effect alphas diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 979c8ee..403eefb 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -69,6 +69,7 @@ #include "ui/material/scroll_edge_fade.h" #include "ui/node_status_banner.h" #include "ui/staleness_badge.h" +#include "ui/effects/effect_clock.h" #include "ui/notifications.h" #include "data/seed_migration_resume.h" #include "data/pin_vault_policy.h" @@ -13492,6 +13493,26 @@ void testPoolFeeParsing() EXPECT_FALSE(ok); // string, not number } +void testEffectClock() { + using namespace dragonx::ui::effects; + const double t7d = 7 * 86400.0, dt = 1.0 / 60.0; + // smooth after 7 days: consecutive frames advance by rate*dt (0.3/60 = 0.005 cycle) + for (int k = 0; k < 120; ++k) { + float a = cyclePhase(t7d + k * dt, 0.3), b = cyclePhase(t7d + (k + 1) * dt, 0.3); + float d = b - a; if (d < -0.5f) d += 1.0f; // wrap + EXPECT_NEAR(d, 0.005f, 1e-4f); + } + // legacy float clock pins the bug: at 30 days one float ULP is 0.25 s + const double t30 = 30 * 86400.0; + EXPECT_TRUE((float)(t30 + 0.5 / 60.0) == (float)(t30 + 1.5 / 60.0)); + EXPECT_TRUE(cyclePhase(t30 + 0.5 / 60.0, 0.3) != cyclePhase(t30 + 1.5 / 60.0, 0.3)); + // range and negative offsets + EXPECT_NEAR(cyclePhase(1.0, 0.0, -0.25), 0.75f, 1e-6f); + for (double t = 0; t < 1e7; t += 123457.7) { float p = cyclePhase(t, 0.37, -0.1); EXPECT_TRUE(p >= 0.0f && p < 1.0f); } + EXPECT_NEAR(cycleSin(t7d + 0.25, 1.0), 1.0f, 1e-5f); + EXPECT_NEAR(radSin(1e6, 0.0, 1.5707963267948966), 1.0f, 1e-6f); +} + // The effective pool list = official pools ∪ saved favorites ∪ current custom pool, // endpoint-deduped, with synthetic user rows flagged official=false. void testEffectivePools() @@ -18227,6 +18248,7 @@ int main() testPoolHashrateParsing(); testPoolFeeParsing(); testEffectivePools(); + testEffectClock(); testFormatFeePercent(); testPoolWeightedSelection(); testCoalescingWriter();