diff --git a/CMakeLists.txt b/CMakeLists.txt index 3bd4607..4e80536 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -650,6 +650,7 @@ set(APP_HEADERS src/ui/windows/balance_tab.h src/ui/windows/balance_address_list.h src/ui/windows/balance_recent_tx.h + src/ui/windows/balance_lerp.h src/ui/windows/balance_tab_helpers.h src/ui/windows/send_tab.h src/ui/windows/receive_tab.h diff --git a/src/app.cpp b/src/app.cpp index 9335608..b99cbe4 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -844,6 +844,14 @@ void App::update() lite_wallet_->beginOpenExisting(); } + // The controller's open/lifecycle/sync/send/console work runs on its own threads and the UI + // shows progress for it: keep frames coming while any of it is in flight. + if (lite_wallet_->openInProgress() || lite_wallet_->lifecycleRequestInProgress() || + lite_wallet_->scanInProgress() || lite_wallet_->broadcastInProgress() || + lite_wallet_->consoleCommandInProgress()) { + ui::material::RequestProgressFrame(); + } + // Lite has no RPC daemon (tryConnect() is a no-op in lite builds), so derive the app's // "online" state — which gates the wallet UI via isConnected() — from the lite wallet. // A wallet is open only after a successful backend init against the lite server, so this @@ -4239,6 +4247,8 @@ void App::renderSeedMigrationDialog() const bool busy = seed_migration_in_flight_ || seed_migration_step_ == SeedMigrationStep::Sweeping || seed_migration_step_ == SeedMigrationStep::Adopting; + // The op runs off the UI thread; keep frames coming for its progress while it does. + if (busy || seed_migration_step_ == SeedMigrationStep::Working) ui::material::RequestProgressFrame(); ui::material::OverlayDialogSpec migOv; migOv.title = TR("mig_title"); migOv.p_open = &show_seed_migration_; @@ -6773,6 +6783,7 @@ void App::renderLoadingOverlay(float contentH) // The wallet-recovery dialog owns the screen while a salvage is pending — suppress the loading // spinner underneath it so "still loading" and "make a decision" are never the same screen. if (show_wallet_recovered_dialog_) return; + RequestProgressFrame(); // the spinner and status text keep moving while the node loads auto loadElem = [](const char* key, float fb) { float v = ui::schema::UI().drawElement("screens.loading", key).size; diff --git a/src/app_security.cpp b/src/app_security.cpp index 5fb2320..732a164 100644 --- a/src/app_security.cpp +++ b/src/app_security.cpp @@ -858,6 +858,12 @@ void App::checkIdleMining() { void App::renderLockScreen() { using namespace ui::material; + // Keep frames coming while something on the lock screen is moving without input: the unlock in + // flight (its dots), the lockout countdown, an error message about to clear. + if (lock_unlock_in_progress_ || lock_lockout_timer_ > 0.0f || lock_error_timer_ > 0.0f) { + RequestProgressFrame(); + } + // Full-window lock overlay: cover the ENTIRE viewport (sidebar included) so the whole screen is // obscured while locked, not just the tab-content area. A borderless, transparent, focused window // over the main viewport (same pattern as the modal overlays; opened from within ##ContentArea, diff --git a/src/main.cpp b/src/main.cpp index b050733..e3fdb7c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -71,6 +71,7 @@ extern "C" HRESULT __stdcall SHGetPropertyStoreForWindow(HWND hwnd, REFIID riid, #endif #endif +#include #include #include #include @@ -778,6 +779,102 @@ static void installPosixCrashHandler(const std::string& crashLogPath) } #endif // !_WIN32 +// ============================================================================ +// Frame pacing (P6) +// ============================================================================ +// The main loop renders a frame only when something needs one: +// - full rate: the user is interacting (and for kRecentInputMs after the last input event), smooth +// scrolling, the screenshot sweep, and a few settle frames after a window-state change or a window +// appearing; +// - ambient: continuous motion that isn't tied to input (theme-effect animation, the translucent +// backdrop, animated avatars, toast fades) at kAmbientFrameMs while the app has focus and +// kAmbientFrameUnfocusedMs while it doesn't; +// - progress: things that only need to keep visibly moving (spinners and progress bars for work on +// other threads, countdowns, a focused text field's caret, the send/refresh/switch/shutdown +// states) at kProgressFrameMs / kProgressFrameUnfocusedMs; +// - idle: no frames until input, RPC results, or the periodic housekeeping wake. +// Hidden or occluded windows get neither ambient nor progress frames. +// frameBudgetNs() caps every rate at the display refresh where swap/present doesn't block on vsync +// (WSLg, VMs, vsync forced off in the driver), which otherwise spins a core at hundreds of fps. +namespace { +constexpr Uint32 kAmbientFrameMs = 33; // ~30 fps while the app has focus +constexpr Uint32 kAmbientFrameUnfocusedMs = 100; // ~10 fps while another app has focus +constexpr Uint32 kProgressFrameMs = 100; // ~10 fps: spinners, progress, caret blink +constexpr Uint32 kProgressFrameUnfocusedMs = 250; // ~4 fps +constexpr Uint64 kRecentInputMs = 750; // stay at full rate this long after input stops +constexpr int kSettleFrames = 3; // full-rate frames after a window-state change + +// Minimum spacing between frame starts: 90% of the display's refresh interval, so a working vsync +// never waits here and a non-blocking swap can't outrun the display. +Uint64 frameBudgetNs(SDL_Window* window) +{ + float hz = 60.0f; + if (const SDL_DisplayMode* dm = SDL_GetCurrentDisplayMode(SDL_GetDisplayForWindow(window))) { + if (dm->refresh_rate > 1.0f) hz = dm->refresh_rate; + } + hz = std::clamp(hz, 30.0f, 240.0f); + return static_cast(0.9e9 / hz); +} + +bool isInputEvent(Uint32 type) +{ + switch (type) { + case SDL_EVENT_KEY_DOWN: + case SDL_EVENT_KEY_UP: + case SDL_EVENT_TEXT_INPUT: + case SDL_EVENT_TEXT_EDITING: + case SDL_EVENT_TEXT_EDITING_CANDIDATES: + case SDL_EVENT_MOUSE_MOTION: + case SDL_EVENT_MOUSE_BUTTON_DOWN: + case SDL_EVENT_MOUSE_BUTTON_UP: + case SDL_EVENT_MOUSE_WHEEL: + case SDL_EVENT_FINGER_DOWN: + case SDL_EVENT_FINGER_UP: + case SDL_EVENT_FINGER_MOTION: + case SDL_EVENT_PEN_DOWN: + case SDL_EVENT_PEN_UP: + case SDL_EVENT_PEN_MOTION: + case SDL_EVENT_GAMEPAD_BUTTON_DOWN: + case SDL_EVENT_GAMEPAD_BUTTON_UP: + return true; + default: + return false; + } +} + +// Window-state changes after which the compositor or the acrylic capture may need a few fresh frames. +bool isWindowStateEvent(Uint32 type) +{ + switch (type) { + case SDL_EVENT_WINDOW_SHOWN: + case SDL_EVENT_WINDOW_EXPOSED: + case SDL_EVENT_WINDOW_RESTORED: + case SDL_EVENT_WINDOW_MAXIMIZED: + case SDL_EVENT_WINDOW_RESIZED: + case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED: + case SDL_EVENT_WINDOW_DISPLAY_CHANGED: + case SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED: + case SDL_EVENT_WINDOW_FOCUS_GAINED: + case SDL_EVENT_WINDOW_FOCUS_LOST: + case SDL_EVENT_WINDOW_OCCLUDED: + return true; + default: + return false; + } +} + +// A held keyboard key keeps ImGui's own key repeat running even where the OS sends no repeat events. +// Keyboard only: gamepad keys come from polled analog state, so a drifting stick or a trigger resting +// past ImGui's 0.1 threshold would read as held forever (gamepad presses still count as input events). +bool anyKeyDown() +{ + for (int k = ImGuiKey_Keyboard_BEGIN; k < ImGuiKey_Keyboard_END; ++k) { + if (ImGui::IsKeyDown(static_cast(k))) return true; + } + return false; +} +} // namespace + int main(int argc, char* argv[]) { // Ensure ObsidianDragon config directory exists early (before any file I/O) @@ -1480,6 +1577,22 @@ int main(int argc, char* argv[]) bool needsRedraw = true; // P6: Start with a full redraw auto lastPeriodicFrame = std::chrono::steady_clock::now(); + // Frame pacing state (P6; see kAmbientFrameMs and friends above main()). + Uint64 lastInputMs = 0; // SDL_GetTicks() of the last input-class event + Uint64 nextAmbientFrameMs = 0; // when the next ambient frame is due; 0 = none scheduled + Uint64 lastFrameStartNs = 0; // software frame cap bookkeeping (SDL_GetTicksNS) + int settleFrames = kSettleFrames; + auto noteEvent = [&](const SDL_Event& e) { + if (isInputEvent(e.type)) lastInputMs = SDL_GetTicks(); + else if (isWindowStateEvent(e.type)) settleFrames = kSettleFrames; + }; + + // Headless verification hook (DRAGONX_FULL_SWEEP=1, handled after present), hoisted so the pacing + // decision can keep its warm-up frames coming. + const bool autoSweep = std::getenv("DRAGONX_FULL_SWEEP") != nullptr; + int autoSweepFrames = 0; + bool autoSweepStarted = false; + // Per-scale saved window sizes for DPI round-trips. // Each entry maps scale-percentage (100, 125, 150, 200, …) to the // physical pixel size the user had at that scale. When transitioning @@ -1543,11 +1656,17 @@ int main(int argc, char* argv[]) // above forces redraws while active, so this drains promptly even if the app is otherwise idle. if (dpiSettleFrames > 0) --dpiSettleFrames; - // P6: Idle rendering — sleep if nothing needs redrawing + // P6: Idle rendering — sleep until input, the next ambient frame, or a 200 ms housekeeping tick if (!needsRedraw) { - // Wait up to 200ms for user input or wake event + int waitMs = 200; + if (nextAmbientFrameMs != 0) { + const Uint64 nowMs = SDL_GetTicks(); + waitMs = nextAmbientFrameMs > nowMs + ? static_cast(std::min(nextAmbientFrameMs - nowMs, 200)) : 0; + } SDL_Event waitEvent; - if (SDL_WaitEventTimeout(&waitEvent, 200)) { + if (waitMs > 0 && SDL_WaitEventTimeout(&waitEvent, waitMs)) { + noteEvent(waitEvent); ImGui_ImplSDL3_ProcessEvent(&waitEvent); if (waitEvent.type == SDL_EVENT_QUIT) { app.beginShutdown(); @@ -1613,17 +1732,22 @@ int main(int argc, char* argv[]) auto sinceLast = std::chrono::duration_cast( now - lastPeriodicFrame).count(); - // Immediate triggers: async RPC results or visible notifications + // Immediate triggers: async RPC results, visible notifications, work in progress + // (also what keeps a hidden or occluded window responsive: ~5 fps while it lasts) bool hasImmediateWork = app.hasPendingRPCResults() || app.hasTransactionSendProgress() || app.isTransactionRefreshInProgress() + || app.isShuttingDown() || dragonx::ui::Notifications::instance().hasActive(); + // Ambient frame due (scheduled by the pacing decision at the end of the last frame) + const bool ambientDue = nextAmbientFrameMs != 0 && SDL_GetTicks() >= nextAmbientFrameMs; + // Periodic maintenance: fire refresh timers in app.update() // Low-spec: every 5s; normal: every 2s int periodMs = dragonx::ui::effects::isLowSpecMode() ? 5000 : 2000; - if (hasImmediateWork || sinceLast >= periodMs) { + if (ambientDue || hasImmediateWork || sinceLast >= periodMs) { needsRedraw = true; } else { continue; // Nothing to do — go back to sleep @@ -1631,6 +1755,17 @@ int main(int argc, char* argv[]) } } + // Software frame cap: never start frames faster than the display refreshes. A no-op where + // swap/present blocks on vsync; elsewhere it stops the loop spinning a core. The screenshot + // sweep switches vsync off on purpose and is exempt. Before the event poll, so the frame still + // renders with the freshest input. + if (!app.isScreenshotSweeping() && lastFrameStartNs != 0) { + const Uint64 budgetNs = frameBudgetNs(window); + const Uint64 sinceStartNs = SDL_GetTicksNS() - lastFrameStartNs; + if (sinceStartNs < budgetNs) SDL_DelayNS(budgetNs - sinceStartNs); + } + lastFrameStartNs = SDL_GetTicksNS(); + // Poll remaining events SDL_Event event; // Deferred font-scale commit: smooth visual update during Alt+scroll, @@ -1638,6 +1773,7 @@ int main(int argc, char* argv[]) static Uint64 fontScaleCommitTick = 0; // 0 = nothing pending while (SDL_PollEvent(&event)) { + noteEvent(event); // Alt + scroll wheel: smooth font scale adjustment if (event.type == SDL_EVENT_MOUSE_WHEEL) { SDL_Keymod mods = SDL_GetModState(); @@ -1866,7 +2002,9 @@ int main(int argc, char* argv[]) (int)io.DisplaySize.x, (int)io.DisplaySize.y); } - // Update theme visual effects timing + // Update theme visual effects timing (held still under Reduce Motion) + dragonx::ui::effects::ThemeEffects::instance().setMotionFrozen( + app.settings() && app.settings()->getReduceMotion()); dragonx::ui::effects::ThemeEffects::instance().beginFrame(); // Propagate window opacity to theme effects so they fade with the // backdrop while UI elements (cards, text, buttons) stay opaque. @@ -2058,16 +2196,11 @@ int main(int argc, char* argv[]) // Headless verification hook: DRAGONX_FULL_SWEEP=1 runs the full UI sweep once the app has // settled, then quits when it finishes. Used to run the sweep without clicking the button. - { - static const bool autoSweep = std::getenv("DRAGONX_FULL_SWEEP") != nullptr; - static int autoFrames = 0; - static bool autoStarted = false; - if (autoSweep) { - if (!autoStarted) { - if (++autoFrames >= 90) { app.startFullUiSweep(); autoStarted = true; } - } else if (!app.isScreenshotSweeping()) { - running = false; // sweep complete - } + if (autoSweep) { + if (!autoSweepStarted) { + if (++autoSweepFrames >= 90) { app.startFullUiSweep(); autoSweepStarted = true; } + } else if (!app.isScreenshotSweeping()) { + running = false; // sweep complete } } @@ -2090,40 +2223,82 @@ int main(int argc, char* argv[]) // Track last render for idle periodic timer lastPeriodicFrame = std::chrono::steady_clock::now(); - // P6: Determine if next frame needs redraw or can idle + // P6: Decide when the next frame is needed: right away (full rate), at the ambient rate, or not + // until the next event / housekeeping wake (idle). See the frame-pacing notes above main(). { ImGuiContext& g = *GImGui; + const Uint64 nowMs = SDL_GetTicks(); - // Detect actual user interaction — NOT WantCaptureMouse/Keyboard - // which are just "ImGui has windows that accept input" (always true). - bool mouseMoving = g.IO.MouseDelta.x != 0.0f || g.IO.MouseDelta.y != 0.0f; - bool mouseClicked = false; + // Hidden or occluded windows get no ambient frames (SDL reports occlusion on macOS and + // Wayland; elsewhere a covered window usually also lacks focus). Focus is per application: + // any of our windows, multi-viewport platform windows included, not just the main one. + const SDL_WindowFlags wflags = SDL_GetWindowFlags(window); + const bool visible = + (wflags & (SDL_WINDOW_HIDDEN | SDL_WINDOW_OCCLUDED | SDL_WINDOW_MINIMIZED)) == 0; + const bool focused = SDL_GetKeyboardFocus() != nullptr; + + // Interaction — not WantCaptureMouse/Keyboard, which only mean "ImGui has windows that + // accept input" (always true). A focused text field by itself (only the caret moves: the + // lock screen's passphrase box, the console input) is ambient below; it used to hold the + // loop at the display rate for as long as the field kept focus. + const bool textEditing = g.ActiveId != 0 && g.ActiveId == g.InputTextState.ID; + bool mouseDown = false; for (int i = 0; i < IM_ARRAYSIZE(g.IO.MouseDown); i++) - mouseClicked |= g.IO.MouseDown[i]; - bool scrolling = g.IO.MouseWheel != 0.0f || g.IO.MouseWheelH != 0.0f; - bool typing = g.IO.InputQueueCharacters.Size > 0; - bool widgetActive = g.ActiveId != 0 || g.MovingWindow != nullptr; + mouseDown |= g.IO.MouseDown[i]; + const bool uiActive = g.IO.MouseDelta.x != 0.0f || g.IO.MouseDelta.y != 0.0f + || mouseDown + || g.IO.MouseWheel != 0.0f || g.IO.MouseWheelH != 0.0f + || (g.ActiveId != 0 && !textEditing) || g.MovingWindow != nullptr + || g.InputEventsQueue.Size > 0 // input still trickling in + || (focused && anyKeyDown()) // ImGui key repeat + || (lastInputMs != 0 && nowMs - lastInputMs < kRecentInputMs); - bool uiActive = mouseMoving || mouseClicked || scrolling - || typing || widgetActive; + // A window that appeared this frame (a popup hidden for its auto-fit frame, an overlay + // dialog's scrim while its card measures and captures its blur) needs a few more frames + // to show, even when it opened from an RPC result or a timer rather than input. + for (ImGuiWindow* w : g.Windows) { + if (w->Active && w->Appearing) { settleFrames = kSettleFrames; break; } + } - // When the background is fully opaque, backdrop doesn't - // need continuous frames — let the app idle and save power. - float wo2 = app.settings() ? app.settings()->getWindowOpacity() : 1.0f; - bool opaqueBackground = dragonx::ui::effects::isLowSpecMode() || wo2 >= 0.99f; - bool backdropNeedsFrames = (backdrop_active || app.getGradientTexture() != 0) - && !opaqueBackground; - bool animating = app.isShuttingDown() - || app.isWalletSwitchInProgress() - || backdropNeedsFrames - || app.hasTransactionSendProgress() - || app.isTransactionRefreshInProgress() - || dragonx::ui::effects::ThemeEffects::instance().hasActiveAnimation() - || dragonx::ui::Notifications::instance().hasActive() - || dragonx::ui::material::SmoothScrollAnimating() - || dragonx::ui::ConsumeContactsAvatarAnimation(); - // If nothing is happening, allow the next iteration to idle - needsRedraw = uiActive || animating; + const bool fullRate = uiActive + || settleFrames > 0 + || app.isScreenshotSweeping() + || (autoSweep && !autoSweepStarted) + || dragonx::ui::material::SmoothScrollAnimating(); + if (settleFrames > 0) --settleFrames; + + // Ambient: continuous motion without input. Theme effects stop requesting frames under + // Reduce Motion (their clock is frozen above). The backdrop itself is static; the + // translucent window stays on this tier as the conservative choice for compositors. + const bool avatarAnimating = dragonx::ui::ConsumeContactsAvatarAnimation(); // consume every frame + const bool progressRequested = dragonx::ui::material::ConsumeProgressFrameRequest(); // ditto + const float wo2 = app.settings() ? app.settings()->getWindowOpacity() : 1.0f; + const bool opaqueBackground = dragonx::ui::effects::isLowSpecMode() || wo2 >= 0.99f; + const bool backdropNeedsFrames = (backdrop_active || app.getGradientTexture() != 0) + && !opaqueBackground; + const bool reduceMotion = app.settings() && app.settings()->getReduceMotion(); + const bool effectsAnimating = !reduceMotion + && dragonx::ui::effects::ThemeEffects::instance().hasActiveAnimation(); + const bool ambient = effectsAnimating + || backdropNeedsFrames + || avatarAnimating + || dragonx::ui::Notifications::instance().hasActive(); + + // Progress: only needs to keep visibly moving (a spinner, a percentage, dots, a caret). + const bool progress = progressRequested + || (textEditing && focused) + || app.isShuttingDown() + || app.isWalletSwitchInProgress() + || app.hasTransactionSendProgress() + || app.isTransactionRefreshInProgress(); + + needsRedraw = fullRate; + nextAmbientFrameMs = 0; + if (!fullRate && visible && (ambient || progress)) { + const Uint32 interval = ambient ? (focused ? kAmbientFrameMs : kAmbientFrameUnfocusedMs) + : (focused ? kProgressFrameMs : kProgressFrameUnfocusedMs); + nextAmbientFrameMs = lastFrameStartNs / 1000000 + interval; + } } } diff --git a/src/ui/effects/theme_effects.cpp b/src/ui/effects/theme_effects.cpp index 104faf4..cc2d2f1 100644 --- a/src/ui/effects/theme_effects.cpp +++ b/src/ui/effects/theme_effects.cpp @@ -47,7 +47,7 @@ ThemeEffects& ThemeEffects::instance() { // ============================================================================ void ThemeEffects::beginFrame() { - time_ = (float)ImGui::GetTime(); + if (!motion_frozen_) time_ = (float)ImGui::GetTime(); ImGuiViewport* vp = ImGui::GetMainViewport(); vpMinY_ = vp->WorkPos.y; vpMaxY_ = vp->WorkPos.y + vp->WorkSize.y; diff --git a/src/ui/effects/theme_effects.h b/src/ui/effects/theme_effects.h index c5cce20..fc05fc4 100644 --- a/src/ui/effects/theme_effects.h +++ b/src/ui/effects/theme_effects.h @@ -30,6 +30,10 @@ public: void setEnabled(bool enabled) { enabled_ = enabled; } bool isEnabled() const { return enabled_; } + /// Hold the effect clock still (Reduce Motion): effects keep their colors and layout but stop + /// moving, so the main loop doesn't need ambient frames for them. + void setMotionFrozen(bool frozen) { motion_frozen_ = frozen; } + /// Background opacity (from window opacity slider). Multiplies /// all effect alphas so they fade with the backdrop while UI stays opaque. void setBackgroundOpacity(float o) { bgOpacity_ = std::max(0.0f, std::min(1.0f, o)); } @@ -130,6 +134,7 @@ private: bool enabled_ = true; bool reduced_transparency_ = false; float time_ = 0.0f; + bool motion_frozen_ = false; ///< Reduce Motion: beginFrame() leaves time_ alone float bgOpacity_ = 1.0f; ///< window-opacity multiplier for effect alphas // Viewport bounds (set each frame) diff --git a/src/ui/material/draw_helpers.h b/src/ui/material/draw_helpers.h index f7cbd52..9dadfe4 100644 --- a/src/ui/material/draw_helpers.h +++ b/src/ui/material/draw_helpers.h @@ -1201,6 +1201,22 @@ inline bool& SmoothScrollAnimating() { return sAnimating; } +/// Ask main.cpp's frame pacing for progress-rate frames (~10 fps while focused) for the next frame. +/// For UI that keeps changing without input or RPC results: spinners and progress for work on other +/// threads, countdowns. Call it every frame the animation runs; main.cpp consumes it once per frame. +/// Without it, an app with theme effects off or Reduce Motion idles and such UI only moves on the +/// 2 s housekeeping wake. +inline bool& ProgressFrameRequested() { + static bool sRequested = false; + return sRequested; +} +inline void RequestProgressFrame() { ProgressFrameRequested() = true; } +inline bool ConsumeProgressFrameRequest() { + const bool v = ProgressFrameRequested(); + ProgressFrameRequested() = false; + return v; +} + inline void ApplySmoothScroll(float speed = 12.0f) { struct ScrollState { diff --git a/src/ui/windows/balance_components.cpp b/src/ui/windows/balance_components.cpp index f90d6e1..9bc115d 100644 --- a/src/ui/windows/balance_components.cpp +++ b/src/ui/windows/balance_components.cpp @@ -3,6 +3,7 @@ // Released under the GPLv3 #include "balance_components.h" +#include "balance_lerp.h" #include "balance_tab_helpers.h" #include "balance_address_list.h" #include "balance_recent_tx.h" @@ -46,18 +47,12 @@ void UpdateBalanceLerp(App* app) { const auto& S = schema::UISchema::instance(); const float kBalanceLerpSpeed = S.drawElement("tabs.balance", "balance-lerp-speed").sizeOr(8.0f); const auto& state = app->state(); - float dt = ImGui::GetIO().DeltaTime; - float speed = (app->settings() && app->settings()->getReduceMotion()) ? 999.0f : kBalanceLerpSpeed; - auto lerp = [](double& disp, double target, float dt, float spd) { - double diff = target - disp; - if (std::abs(diff) < 1e-9) { disp = target; return; } - disp += diff * (double)(dt * spd); - if (std::abs(target - disp) < 1e-9) disp = target; - }; - lerp(s_dispTotal, app->getTotalBalance(), dt, speed); - lerp(s_dispShielded, app->getShieldedBalance(), dt, speed); - lerp(s_dispTransparent, app->getTransparentBalance(), dt, speed); - lerp(s_dispUnconfirmed, state.unconfirmed_balance, dt, speed); + const float dt = ImGui::GetIO().DeltaTime; + const bool instant = app->settings() && app->settings()->getReduceMotion(); + StepDisplayLerp(s_dispTotal, app->getTotalBalance(), dt, kBalanceLerpSpeed, instant); + StepDisplayLerp(s_dispShielded, app->getShieldedBalance(), dt, kBalanceLerpSpeed, instant); + StepDisplayLerp(s_dispTransparent, app->getTransparentBalance(), dt, kBalanceLerpSpeed, instant); + StepDisplayLerp(s_dispUnconfirmed, state.unconfirmed_balance, dt, kBalanceLerpSpeed, instant); } // Render compact hero line: logo + balance + USD + mining on one line diff --git a/src/ui/windows/balance_lerp.h b/src/ui/windows/balance_lerp.h new file mode 100644 index 0000000..0a2e21a --- /dev/null +++ b/src/ui/windows/balance_lerp.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include + +namespace dragonx { +namespace ui { + +// One step of a displayed balance rolling toward its real value. Returns true while still moving. +// +// The per-step factor dt*speed is clamped to 1, so a long frame (the main loop idles between frames, +// and dt reaches the 0.25 s clamp) lands on the target instead of overshooting: the old unclamped +// `disp += diff*dt*speed` flipped between two wrong values at dt*speed == 2 and diverged to inf/NaN +// at Reduce Motion's speed of 999. `instant` (Reduce Motion) snaps to the target. A non-finite display +// value recovers on the next step, and the relative snap keeps the roll from stalling a few ULPs short +// of large targets (>= 2^23), where `disp + diff*a` stops changing. +inline bool StepDisplayLerp(double& disp, double target, float dt, float speed, bool instant) +{ + if (instant || !std::isfinite(disp) || !std::isfinite(target)) { + disp = target; + return false; + } + const double diff = target - disp; + if (diff == 0.0) return false; + const double a = std::clamp(static_cast(dt) * static_cast(speed), 0.0, 1.0); + const double next = disp + diff * a; + if (next == disp || std::abs(target - next) <= std::max(5e-9, 4.0 * std::abs(target) * DBL_EPSILON)) { + disp = target; + return false; + } + disp = next; + return true; +} + +} // namespace ui +} // namespace dragonx diff --git a/src/ui/windows/balance_tab.cpp b/src/ui/windows/balance_tab.cpp index 68e704c..2058ea4 100644 --- a/src/ui/windows/balance_tab.cpp +++ b/src/ui/windows/balance_tab.cpp @@ -226,7 +226,6 @@ static void RenderBalanceClassic(App* app) const auto syncBar = S.drawElement("tabs.balance", "sync-bar"); // Read layout properties from schema - const float kBalanceLerpSpeed = S.drawElement("tabs.balance", "balance-lerp-speed").sizeOr(8.0f); const float kHeroPadTop = S.drawElement("tabs.balance", "hero-pad-top").sizeOr(12.0f); const auto& state = app->state(); @@ -240,22 +239,8 @@ static void RenderBalanceClassic(App* app) const float cGap = Layout::cardGap(); const float dp = Layout::dpiScale(); - // Lerp displayed balances toward actual values - { - float dt = ImGui::GetIO().DeltaTime; - float speed = (app->settings() && app->settings()->getReduceMotion()) ? 999.0f : kBalanceLerpSpeed; - auto lerp = [](double& disp, double target, float dt, float spd) { - double diff = target - disp; - if (std::abs(diff) < 1e-9) { disp = target; return; } - disp += diff * (double)(dt * spd); - // Snap when very close - if (std::abs(target - disp) < 1e-9) disp = target; - }; - lerp(s_dispTotal, app->getTotalBalance(), dt, speed); - lerp(s_dispShielded, app->getShieldedBalance(), dt, speed); - lerp(s_dispTransparent, app->getTransparentBalance(), dt, speed); - lerp(s_dispUnconfirmed, state.unconfirmed_balance, dt, speed); - } + // Lerp displayed balances toward actual values (shared with the other layouts) + UpdateBalanceLerp(app); // ================================================================ // Card row — Total Balance | Shielded | Transparent | Market diff --git a/src/ui/windows/bootstrap_download_dialog.h b/src/ui/windows/bootstrap_download_dialog.h index bed3f97..c002195 100644 --- a/src/ui/windows/bootstrap_download_dialog.h +++ b/src/ui/windows/bootstrap_download_dialog.h @@ -62,6 +62,8 @@ public: using namespace material; const float dp = Layout::dpiScale(); + // The download runs on its own thread: keep frames coming for the progress bar. + if (s_state == State::Downloading) RequestProgressFrame(); OverlayDialogSpec ov; ov.title = TR("download_bootstrap"); ov.p_open = &s_open; diff --git a/src/ui/windows/console_tab.cpp b/src/ui/windows/console_tab.cpp index 88c7c62..02c9792 100644 --- a/src/ui/windows/console_tab.cpp +++ b/src/ui/windows/console_tab.cpp @@ -1427,6 +1427,7 @@ void ConsoleTab::renderInput(ConsoleCommandExecutor& exec) ImGui::PushStyleColor(ImGuiCol_FrameBgActive, inputLight ? IM_COL32(0, 0, 0, 16) : IM_COL32(255, 255, 255, 18)); const bool busy = exec.busy(); + if (busy) material::RequestProgressFrame(); // a command runs on a worker: show its result promptly if (busy) ImGui::BeginDisabled(); if (ImGui::InputText("##ConsoleInput", input_buffer_, sizeof(input_buffer_), flags, callback, this)) { std::string cmd(input_buffer_); diff --git a/src/ui/windows/daemon_download_dialog.h b/src/ui/windows/daemon_download_dialog.h index 3a7d15d..2525be4 100644 --- a/src/ui/windows/daemon_download_dialog.h +++ b/src/ui/windows/daemon_download_dialog.h @@ -99,6 +99,12 @@ public: // No window X during an active download/verify/extract — closing would orphan/block on the // worker; the in-dialog Cancel is the intended way to abort. const bool active = (p.state == St::Downloading || p.state == St::Verifying || p.state == St::Extracting); + // The updater works on its own thread: keep frames coming while it checks, lists, downloads or + // installs, so progress and the next screen show promptly even when the app would otherwise idle. + if (p.state != St::Unavailable && p.state != St::ReleaseList && + p.state != St::Done && p.state != St::Failed) { + RequestProgressFrame(); + } OverlayDialogSpec ov; ov.title = TR("daemon_update_title"); ov.p_open = active ? nullptr : &s_open; diff --git a/src/ui/windows/xmrig_download_dialog.h b/src/ui/windows/xmrig_download_dialog.h index 4f815a0..9ff59bc 100644 --- a/src/ui/windows/xmrig_download_dialog.h +++ b/src/ui/windows/xmrig_download_dialog.h @@ -86,6 +86,12 @@ public: const Progress p = s_sweep ? s_sweepProgress : (s_updater ? s_updater->getProgress() : Progress{}); const bool active = (p.state == St::Downloading || p.state == St::Verifying || p.state == St::Extracting); + // The updater works on its own thread: keep frames coming while it checks, lists, downloads or + // installs, so progress and the next screen show promptly even when the app would otherwise idle. + if (p.state != St::Unavailable && p.state != St::ReleaseList && + p.state != St::Done && p.state != St::Failed) { + RequestProgressFrame(); + } OverlayDialogSpec ov; ov.title = TR("xmrig_update_title"); ov.p_open = active ? nullptr : &s_open; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index e2db731..997b43b 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -34,6 +34,7 @@ #include "services/wallet_security_workflow_executor.h" #include "ui/explorer/explorer_block_cache.h" #include "ui/windows/balance_address_list.h" +#include "ui/windows/balance_lerp.h" #include "ui/windows/balance_recent_tx.h" #include "ui/windows/console_input_model.h" #include "ui/windows/console_model.h" @@ -77,6 +78,7 @@ #include #include +#include #include #include #include @@ -9300,6 +9302,45 @@ void testWalletIndex() #endif // !_WIN32 } +// The displayed-balance roll must never overshoot, must land on the target after an idle-length +// frame, and must survive Reduce Motion and non-finite values (it used to flip between two wrong +// values at dt*speed == 2 and diverge to inf/NaN under Reduce Motion's speed of 999). +void testBalanceDisplayLerp() +{ + using dragonx::ui::StepDisplayLerp; + for (float dt : {1.0f / 240.0f, 1.0f / 60.0f, 0.25f}) { + double disp = 0.0; + const double target = 123.45678901; + bool overshot = false; + for (int i = 0; i < 4000 && disp != target; ++i) { + StepDisplayLerp(disp, target, dt, 8.0f, false); + if (disp > target) overshot = true; + } + EXPECT_FALSE(overshot); + EXPECT_EQ(disp, target); + } + + double idle = 10.0; + EXPECT_FALSE(StepDisplayLerp(idle, 20.0, 0.25f, 8.0f, false)); + EXPECT_EQ(idle, 20.0); + + double instant = 1.0; + EXPECT_FALSE(StepDisplayLerp(instant, 5.0, 1.0f / 60.0f, 8.0f, true)); + EXPECT_EQ(instant, 5.0); + + double bad = std::numeric_limits::quiet_NaN(); + StepDisplayLerp(bad, 7.0, 1.0f / 60.0f, 8.0f, false); + EXPECT_EQ(bad, 7.0); + + for (double target : {1.2e7, 2.1e8}) { + double disp = 0.0; + int steps = 0; + while (StepDisplayLerp(disp, target, 1.0f / 60.0f, 8.0f, false) && steps < 10000) ++steps; + EXPECT_TRUE(steps < 10000); + EXPECT_EQ(disp, target); + } +} + int main() { testConnectionConfig(); @@ -9319,6 +9360,7 @@ int main() testParseDaemonRescanOutput(); testNetworkRefreshRpcCollectors(); testNetworkRefreshResultModels(); + testBalanceDisplayLerp(); testOperationStatusPollParsing(); testSecureVaultScope(); testWalletSecurityController();