perf(ui): render frames on demand instead of continuously

While theme effects were on (the default) the main loop drew every
frame it could. Where vsync is off (offscreen, some drivers, remote
sessions) an idle wallet spun at ~3,000 fps, 88% of a core; with vsync
it still drew every refresh. Frames are now paced by what needs them:

- full rate while there is input, a held key, a window appearing or
  settling, a smooth scroll or the UI sweep;
- ambient (30 fps focused, 10 fps unfocused) for theme effects, the
  acrylic backdrop, avatar animations and notifications;
- progress (10 fps focused, 4 fps unfocused) for UI that changes
  without input. The updater and bootstrap downloads, console
  commands, lite-wallet operations, seed migration, the loading
  overlay and the lock-screen countdowns ask for it with
  material::RequestProgressFrame();
- otherwise idle, as before: wait for events, with a housekeeping wake
  every 2 s (5 s unfocused).

A software frame cap (0.9x the display refresh) bounds the loop where
vsync is off, and hidden, occluded or minimized windows get no ambient
frames. Reduce Motion freezes the theme-effect clock
(ThemeEffects::setMotionFrozen), so it needs no ambient frames either.

The displayed-balance roll goes through one clamped StepDisplayLerp
(balance_lerp.h). An idle-length frame pushed dt*speed past 1, so the
roll overshot, and under Reduce Motion's speed of 999 it diverged to
inf/NaN. The classic layout had its own copy of the old code.

Idle CPU, Lite build, offscreen (no vsync): 88% -> 5% of a core with
effects on, and 91% -> 3% with effects saved off (with the previous
commit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 19:52:09 -05:00
co-authored by Claude Opus 5.5
parent fffab228d1
commit 575abd4bcc
15 changed files with 362 additions and 74 deletions
+1
View File
@@ -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
+11
View File
@@ -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;
+6
View File
@@ -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,
+219 -44
View File
@@ -71,6 +71,7 @@ extern "C" HRESULT __stdcall SHGetPropertyStoreForWindow(HWND hwnd, REFIID riid,
#endif
#endif
#include <algorithm>
#include <cstdio>
#include <cstdarg>
#include <cstdlib>
@@ -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<Uint64>(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<ImGuiKey>(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<int>(std::min<Uint64>(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<std::chrono::milliseconds>(
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;
}
}
}
+1 -1
View File
@@ -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;
+5
View File
@@ -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)
+16
View File
@@ -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 {
+7 -12
View File
@@ -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
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include <algorithm>
#include <cfloat>
#include <cmath>
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<double>(dt) * static_cast<double>(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
+2 -17
View File
@@ -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
@@ -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;
+1
View File
@@ -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_);
+6
View File
@@ -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;
+6
View File
@@ -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;
+42
View File
@@ -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 <chrono>
#include <cmath>
#include <limits>
#include <thread>
#include <atomic>
#include <cstdlib>
@@ -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<double>::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();