From d8f33d23660b45d74f12d26b2edaa2042bf8165b Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 11 Mar 2026 23:02:47 -0500 Subject: [PATCH 1/7] feat(qt): modernize splash screen with rounded corners and refined layout Redesign the splash screen with a modern card-style appearance featuring rounded corners, a translucent background with OS compositor support, and a refined visual layout with properly scoped QPainter lifetime. --- src/qt/splashscreen.cpp | 154 +++++++++++++++++++++++++--------------- 1 file changed, 97 insertions(+), 57 deletions(-) diff --git a/src/qt/splashscreen.cpp b/src/qt/splashscreen.cpp index dfe27bab5f93..8f27b1b3b2c9 100644 --- a/src/qt/splashscreen.cpp +++ b/src/qt/splashscreen.cpp @@ -29,6 +29,8 @@ #include #include +// Padding around the card +static constexpr int SPLASH_PADDING = 16; SplashScreen::SplashScreen(const NetworkStyle* networkStyle) : QWidget() @@ -41,15 +43,11 @@ SplashScreen::SplashScreen(const NetworkStyle* networkStyle) // no window decorations setWindowFlags(Qt::FramelessWindowHint); - // Geometries of splashscreen - int width = 380; - int height = 460; - int logoWidth = 270; - int logoHeight = 270; - - // set reference point, paddings - int paddingTop = 10; - int titleVersionVSpace = 25; + // Modern splash dimensions — wider and shorter for a contemporary feel + int width = 440; + int height = 360; + int logoSize = 140; + int cornerRadius = 16; float fontFactor = 1.0; float scale = qApp->devicePixelRatio(); @@ -63,7 +61,6 @@ SplashScreen::SplashScreen(const NetworkStyle* networkStyle) QFont fontBold = GUIUtil::getFontBold(); QPixmap pixmapLogo = networkStyle->getSplashImage(); - pixmapLogo.setDevicePixelRatio(scale); // Adjust logo color based on the current theme QImage imgLogo = pixmapLogo.toImage().convertToFormat(QImage::Format_ARGB32); @@ -75,59 +72,93 @@ SplashScreen::SplashScreen(const NetworkStyle* networkStyle) } } pixmapLogo.convertFromImage(imgLogo); + pixmapLogo.setDevicePixelRatio(scale); + + int canvasWidth = width + SPLASH_PADDING * 2; + int canvasHeight = height + SPLASH_PADDING * 2; - pixmap = QPixmap(width * scale, height * scale); + pixmap = QPixmap(canvasWidth * scale, canvasHeight * scale); pixmap.setDevicePixelRatio(scale); - pixmap.fill(GUIUtil::getThemedQColor(GUIUtil::ThemedColor::BORDER_WIDGET)); + pixmap.fill(Qt::transparent); - QPainter pixPaint(&pixmap); + // Scope the painter so it releases the pixmap before signal setup + { + QPainter pixPaint(&pixmap); + pixPaint.setRenderHint(QPainter::Antialiasing, true); + pixPaint.setRenderHint(QPainter::SmoothPixmapTransform, true); - QRect rect = QRect(1, 1, width - 2, height - 2); - pixPaint.fillRect(rect, GUIUtil::getThemedQColor(GUIUtil::ThemedColor::BACKGROUND_WIDGET)); + QColor bgColor = GUIUtil::getThemedQColor(GUIUtil::ThemedColor::BACKGROUND_WIDGET); - pixPaint.drawPixmap((width / 2) - (logoWidth / 2), (height / 2) - (logoHeight / 2) + 20, pixmapLogo.scaled(logoWidth * scale, logoHeight * scale, Qt::KeepAspectRatio, Qt::SmoothTransformation)); - pixPaint.setPen(GUIUtil::getThemedQColor(GUIUtil::ThemedColor::DEFAULT)); + // Draw rounded background card with a subtle border + QRectF cardRect(SPLASH_PADDING, SPLASH_PADDING, width, height); + pixPaint.setPen(QPen(QColor(128, 128, 128, 40), 0.5)); + pixPaint.setBrush(bgColor); + pixPaint.drawRoundedRect(cardRect, cornerRadius, cornerRadius); - // check font size and drawing with - fontBold.setPointSize(50 * fontFactor); - pixPaint.setFont(fontBold); - QFontMetrics fm = pixPaint.fontMetrics(); - int titleTextWidth = GUIUtil::TextWidth(fm, titleText); - if (titleTextWidth > width * 0.8) { - fontFactor = 0.75; - } + // Offsets relative to card origin + int cardX = SPLASH_PADDING; + int cardY = SPLASH_PADDING; + int contentTop = cardY + 48; + + // Draw logo centered horizontally, near the top + int logoX = cardX + (width - logoSize) / 2; + int logoY = contentTop; + pixPaint.drawPixmap(logoX, logoY, logoSize, logoSize, pixmapLogo); + + QColor textColor = GUIUtil::getThemedQColor(GUIUtil::ThemedColor::DEFAULT); + + // Title text below logo + pixPaint.setPen(textColor); - fontBold.setPointSize(50 * fontFactor); - pixPaint.setFont(fontBold); - fm = pixPaint.fontMetrics(); - titleTextWidth = GUIUtil::TextWidth(fm, titleText); - int titleTextHeight = fm.height(); - pixPaint.drawText((width / 2) - (titleTextWidth / 2), titleTextHeight + paddingTop, titleText); - - fontNormal.setPointSize(16 * fontFactor); - pixPaint.setFont(fontNormal); - fm = pixPaint.fontMetrics(); - int versionTextWidth = GUIUtil::TextWidth(fm, versionText); - pixPaint.drawText((width / 2) - (versionTextWidth / 2), titleTextHeight + paddingTop + titleVersionVSpace, versionText); - - // draw additional text if special network - if(!titleAddText.isEmpty()) { - fontBold.setPointSize(10 * fontFactor); + fontBold.setPointSize(28 * fontFactor); pixPaint.setFont(fontBold); + QFontMetrics fm = pixPaint.fontMetrics(); + int titleTextWidth = GUIUtil::TextWidth(fm, titleText); + if (titleTextWidth > width * 0.8) { + fontFactor = 0.75; + fontBold.setPointSize(28 * fontFactor); + pixPaint.setFont(fontBold); + fm = pixPaint.fontMetrics(); + titleTextWidth = GUIUtil::TextWidth(fm, titleText); + } + int titleBaseline = logoY + logoSize + 36 + fm.ascent(); + pixPaint.drawText(cardX + (width - titleTextWidth) / 2, titleBaseline, titleText); + + // Version text — subtle, below title + QColor subtleColor(textColor); + subtleColor.setAlpha(130); + pixPaint.setPen(subtleColor); + fontNormal.setPointSize(12 * fontFactor); + pixPaint.setFont(fontNormal); fm = pixPaint.fontMetrics(); - int titleAddTextWidth = GUIUtil::TextWidth(fm, titleAddText); - // Draw the badge background with the network-specific color - QRect badgeRect = QRect(width - titleAddTextWidth - 20, 5, width, fm.height() + 10); - pixPaint.fillRect(badgeRect, networkStyle->getBadgeColor()); - // Draw the text itself using white color, regardless of the current theme - pixPaint.setPen(QColor(255, 255, 255)); - pixPaint.drawText(width - titleAddTextWidth - 10, paddingTop + 10, titleAddText); - } - - pixPaint.end(); + int versionTextWidth = GUIUtil::TextWidth(fm, versionText); + int versionBaseline = titleBaseline + fm.height() + 4; + pixPaint.drawText(cardX + (width - versionTextWidth) / 2, versionBaseline, versionText); + + // Draw network badge if special network (testnet, devnet, regtest) + if(!titleAddText.isEmpty()) { + fontBold.setPointSize(9 * fontFactor); + pixPaint.setFont(fontBold); + fm = pixPaint.fontMetrics(); + int titleAddTextWidth = GUIUtil::TextWidth(fm, titleAddText); + int badgePadH = 10; + int badgePadV = 4; + int badgeW = titleAddTextWidth + badgePadH * 2; + int badgeH = fm.height() + badgePadV * 2; + int badgeX = cardX + width - badgeW - 16; + int badgeY = cardY + 14; + // Rounded badge + pixPaint.setPen(Qt::NoPen); + pixPaint.setBrush(networkStyle->getBadgeColor()); + pixPaint.drawRoundedRect(badgeX, badgeY, badgeW, badgeH, badgeH / 2, badgeH / 2); + // Badge text + pixPaint.setPen(QColor(255, 255, 255)); + pixPaint.drawText(badgeX + badgePadH, badgeY + badgePadV + fm.ascent(), titleAddText); + } + } // QPainter released here // Resize window and move to center of desktop, disallow resizing - QRect r(QPoint(), QSize(width, height)); + QRect r(QPoint(), QSize(canvasWidth, canvasHeight)); resize(r.size()); setFixedSize(r.size()); move(QGuiApplication::primaryScreen()->geometry().center() - r.center()); @@ -231,13 +262,22 @@ void SplashScreen::showMessage(const QString &message, int alignment, const QCol void SplashScreen::paintEvent(QPaintEvent *event) { QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.drawPixmap(0, 0, pixmap); + + // Draw status message near the bottom of the card QFont messageFont = GUIUtil::getFontNormal(); - messageFont.setPointSize(14); + messageFont.setPointSize(12); painter.setFont(messageFont); - painter.drawPixmap(0, 0, pixmap); - QRect r = rect().adjusted(5, 5, -5, -15); - painter.setPen(curColor); - painter.drawText(r, curAlignment, curMessage); + + QRect messageRect = rect().adjusted( + SPLASH_PADDING + 24, 0, + -SPLASH_PADDING - 24, + -SPLASH_PADDING - 28); + QColor msgColor(curColor); + msgColor.setAlpha(160); + painter.setPen(msgColor); + painter.drawText(messageRect, curAlignment, curMessage); } void SplashScreen::closeEvent(QCloseEvent *event) From 09fc59c803aab5571b698a45dac689d82587392d Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 11 Mar 2026 23:05:24 -0500 Subject: [PATCH 2/7] feat(qt): add phase-based progress bar to splash screen Add a progress bar that tracks initialization phases with weighted time estimates. Includes smooth easing transitions, phase-aware progress for long operations like wallet loading and rescan, and snap-to-100% on completion. - Phase lookup uses _() to translate keys at runtime for locale-safe matching - messageColor is const, captured by value in lambdas for thread safety - Animation timer deferred until first message to avoid idle repaints - Time-based exponential fill capped at 95% (EXPONENTIAL_FILL_CAP) --- src/qt/splashscreen.cpp | 202 +++++++++++++++++++++++++++++++++++----- src/qt/splashscreen.h | 19 ++++ 2 files changed, 196 insertions(+), 25 deletions(-) diff --git a/src/qt/splashscreen.cpp b/src/qt/splashscreen.cpp index 8f27b1b3b2c9..c9b394671139 100644 --- a/src/qt/splashscreen.cpp +++ b/src/qt/splashscreen.cpp @@ -22,7 +22,7 @@ #include #include -#include +#include #include #include @@ -32,8 +32,55 @@ // Padding around the card static constexpr int SPLASH_PADDING = 16; +// Cap for time-based exponential fill: prevents bar from appearing complete +// before the phase actually finishes (bar fills to 95% of range, then waits) +static constexpr qreal EXPONENTIAL_FILL_CAP = 0.95; + +/** Phase weight table: maps init message keys to overall progress ranges. + * Keys are untranslated strings passed through _() at lookup time so that + * phase matching works correctly regardless of the active locale. + * Phases without ShowProgress use time-based exponential fill. + * Phases with ShowProgress interpolate linearly within their range. + * Phases with resetsBar=true reset the bar to 0 and use the full range, + * since they can take an arbitrarily long time (e.g. rescanning, wallet loading). */ +struct PhaseInfo { + const char* msg_key; // untranslated init message (translated at lookup time) + qreal start; // overall progress at phase start (0.0-1.0) + qreal end; // overall progress at phase end (0.0-1.0) + bool resetsBar; // if true, resets the bar to show this phase's own 0-100% + bool snapsToEnd; // if true, instantly completes the bar (for "Done loading") +}; + +static const PhaseInfo PHASE_TABLE[] = { + {"Loading P2P addresses…", 0.00, 0.02, false, false}, + {"Loading banlist…", 0.02, 0.03, false, false}, + {"Loading block index…", 0.03, 0.75, false, false}, + {"Verifying blocks…", 0.75, 0.88, false, false}, + {"Replaying blocks…", 0.75, 0.88, false, false}, + {"Pruning blockstore…", 0.88, 0.90, false, false}, + {"Starting network threads…", 0.90, 0.94, false, false}, + {"Rescanning…", 0.00, 1.00, true, false}, + {"Verifying wallet(s)…", 0.00, 0.20, true, false}, + {"Loading wallet…", 0.20, 1.00, false, false}, + {"Done loading", 0.94, 1.00, false, true}, +}; + +/** Look up phase by translating each key and checking if the message contains it. + * Uses contains() because ShowProgress may prepend the wallet name. */ +static const PhaseInfo* LookupPhase(const QString& message) +{ + for (const auto& phase : PHASE_TABLE) { + QString translated = QString::fromStdString(_(phase.msg_key).translated); + if (message.contains(translated, Qt::CaseInsensitive)) { + return &phase; + } + } + return nullptr; +} + SplashScreen::SplashScreen(const NetworkStyle* networkStyle) - : QWidget() + : QWidget(), + messageColor(GUIUtil::getThemedQColor(GUIUtil::ThemedColor::DEFAULT)) { // transparent background @@ -81,7 +128,7 @@ SplashScreen::SplashScreen(const NetworkStyle* networkStyle) pixmap.setDevicePixelRatio(scale); pixmap.fill(Qt::transparent); - // Scope the painter so it releases the pixmap before signal setup + // Scope the painter so it releases the pixmap before timer/signal setup { QPainter pixPaint(&pixmap); pixPaint.setRenderHint(QPainter::Antialiasing, true); @@ -105,10 +152,8 @@ SplashScreen::SplashScreen(const NetworkStyle* networkStyle) int logoY = contentTop; pixPaint.drawPixmap(logoX, logoY, logoSize, logoSize, pixmapLogo); - QColor textColor = GUIUtil::getThemedQColor(GUIUtil::ThemedColor::DEFAULT); - // Title text below logo - pixPaint.setPen(textColor); + pixPaint.setPen(messageColor); fontBold.setPointSize(28 * fontFactor); pixPaint.setFont(fontBold); @@ -125,7 +170,7 @@ SplashScreen::SplashScreen(const NetworkStyle* networkStyle) pixPaint.drawText(cardX + (width - titleTextWidth) / 2, titleBaseline, titleText); // Version text — subtle, below title - QColor subtleColor(textColor); + QColor subtleColor(messageColor); subtleColor.setAlpha(130); pixPaint.setPen(subtleColor); fontNormal.setPointSize(12 * fontFactor); @@ -165,6 +210,26 @@ SplashScreen::SplashScreen(const NetworkStyle* networkStyle) installEventFilter(this); + // Animation timer: smoothly advance displayProgress toward target and repaint. + // Timer is started on first message to avoid wasteful repaints before init begins. + // Note: calcOverallProgress() is safe to call here because animTimer is only + // started after phaseTimer.start() in showMessage(). + connect(&animTimer, &QTimer::timeout, this, [this]() { + qreal target = calcOverallProgress(); + // Smoothly animate toward target (never go backwards) + if (target > displayProgress) { + qreal gap = target - displayProgress; + // Faster easing for larger gaps so fast phases don't lag behind + qreal ease = gap > 0.1 ? 0.3 : 0.15; + displayProgress += gap * ease; + // Snap if very close + if (target - displayProgress < 0.002) displayProgress = target; + } + update(); + // Stop timer once progress is complete + if (displayProgress >= 0.999) animTimer.stop(); + }); + GUIUtil::handleCloseWindowShortcut(this); } @@ -197,29 +262,51 @@ bool SplashScreen::eventFilter(QObject * obj, QEvent * ev) { return QObject::eventFilter(obj, ev); } -static void InitMessage(SplashScreen *splash, const std::string &message) +qreal SplashScreen::calcOverallProgress() const { - bool invoked = QMetaObject::invokeMethod(splash, "showMessage", - Qt::QueuedConnection, - Q_ARG(QString, QString::fromStdString(message)), - Q_ARG(int, Qt::AlignBottom | Qt::AlignHCenter), - Q_ARG(QColor, GUIUtil::getThemedQColor(GUIUtil::ThemedColor::DEFAULT))); - assert(invoked); + if (curProgress >= 0) { + // Phase with real sub-progress: interpolate linearly within phase range + return phaseStart + (phaseEnd - phaseStart) * (curProgress / 100.0); + } + // Phase without sub-progress: exponential approach toward phaseEnd + qreal elapsed = phaseTimer.elapsed() / 1000.0; + // Long phases (rescan, wallet load) can take minutes/hours — use a very slow curve + // Normal phases: reaches ~90% of range in ~15s + // Long phases: reaches ~50% in ~2min, ~75% in ~5min + qreal tau = phaseIsLong ? 120.0 : 5.0; + qreal fraction = 1.0 - std::exp(-elapsed / tau); + return phaseStart + (phaseEnd - phaseStart) * fraction * EXPONENTIAL_FILL_CAP; } -static void ShowProgress(SplashScreen *splash, const std::string &title, int nProgress, bool resume_possible) +/** Thread-safe helper: queue a message and/or progress update to the GUI thread. + * @param color Text color, captured by value from the GUI thread at signal + * connection time to avoid cross-thread access to member fields. */ +static void PostMessageAndProgress(SplashScreen *splash, const QColor &color, + const std::string &message, int progress) { - InitMessage(splash, title + std::string("\n") + - (resume_possible ? SplashScreen::tr("(press q to shutdown and continue later)").toStdString() - : SplashScreen::tr("press q to shutdown").toStdString()) + - strprintf("\n%d", nProgress) + "%"); + if (!message.empty()) { + bool invoked = QMetaObject::invokeMethod(splash, "showMessage", + Qt::QueuedConnection, + Q_ARG(QString, QString::fromStdString(message)), + Q_ARG(int, Qt::AlignBottom | Qt::AlignHCenter), + Q_ARG(QColor, color)); + assert(invoked); + } + bool invoked = QMetaObject::invokeMethod(splash, "setProgress", + Qt::QueuedConnection, Q_ARG(int, progress)); + assert(invoked); } void SplashScreen::subscribeToCoreSignals() { - // Connect signals to client - m_handler_init_message = m_node->handleInitMessage(std::bind(InitMessage, this, std::placeholders::_1)); - m_handler_show_progress = m_node->handleShowProgress(std::bind(ShowProgress, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3)); + // Capture messageColor by value for thread-safe use in non-GUI thread callbacks + const QColor color = messageColor; + m_handler_init_message = m_node->handleInitMessage([this, color](const std::string& msg) { + PostMessageAndProgress(this, color, msg, /*progress=*/-1); + }); + m_handler_show_progress = m_node->handleShowProgress([this, color](const std::string& title, int nProgress, bool /*resume_possible*/) { + PostMessageAndProgress(this, color, title, nProgress); + }); m_handler_init_wallet = m_node->handleInitWallet([this]() { handleLoadWallet(); }); } @@ -227,8 +314,11 @@ void SplashScreen::handleLoadWallet() { #ifdef ENABLE_WALLET if (!WalletModel::isWalletEnabled()) return; - m_handler_load_wallet = m_node->walletLoader().handleLoadWallet([this](std::unique_ptr wallet) { - m_connected_wallet_handlers.emplace_back(wallet->handleShowProgress(std::bind(ShowProgress, this, std::placeholders::_1, std::placeholders::_2, false))); + const QColor color = messageColor; + m_handler_load_wallet = m_node->walletLoader().handleLoadWallet([this, color](std::unique_ptr wallet) { + m_connected_wallet_handlers.emplace_back(wallet->handleShowProgress([this, color](const std::string& title, int nProgress) { + PostMessageAndProgress(this, color, title, nProgress); + })); m_connected_wallets.emplace_back(std::move(wallet)); }); #endif @@ -239,6 +329,7 @@ void SplashScreen::unsubscribeFromCoreSignals() // Disconnect signals from client m_handler_init_message->disconnect(); m_handler_show_progress->disconnect(); + m_handler_init_wallet->disconnect(); #ifdef ENABLE_WALLET if (m_handler_load_wallet != nullptr) { m_handler_load_wallet->disconnect(); @@ -256,16 +347,60 @@ void SplashScreen::showMessage(const QString &message, int alignment, const QCol curMessage = message; curAlignment = alignment; curColor = color; + + // Start animation timer on first message (avoids wasteful repaints before init begins) + if (!animTimer.isActive()) { + phaseTimer.start(); + animTimer.start(30); + } + + // Look up the phase range for this message + const PhaseInfo* phase = LookupPhase(message); + if (phase != nullptr) { + if (phase->snapsToEnd) { + // Final phase: snap to 100% immediately since the splash + // will be destroyed moments after "Done loading" arrives + displayProgress = 1.0; + phaseStart = 1.0; + phaseEnd = 1.0; + animTimer.stop(); + } else if (phase->resetsBar) { + // Long independent phases get their own full bar + displayProgress = 0.0; + phaseStart = phase->start; + phaseEnd = phase->end; + phaseIsLong = true; + animTimer.start(30); + } else { + // Normal phase: ensure we never jump backwards + phaseIsLong = false; + phaseStart = std::max(phase->start, displayProgress); + phaseEnd = std::max(phase->end, phaseStart); + } + phaseTimer.restart(); + } + update(); } +void SplashScreen::setProgress(int value) +{ + curProgress = value; +} + void SplashScreen::paintEvent(QPaintEvent *event) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); painter.drawPixmap(0, 0, pixmap); - // Draw status message near the bottom of the card + int barMargin = 32; + int barHeight = 4; + int barRadius = barHeight / 2; + int barLeft = SPLASH_PADDING + barMargin; + int barWidth = width() - SPLASH_PADDING * 2 - barMargin * 2; + + // Draw status message above the progress bar area QFont messageFont = GUIUtil::getFontNormal(); messageFont.setPointSize(12); painter.setFont(messageFont); @@ -278,6 +413,23 @@ void SplashScreen::paintEvent(QPaintEvent *event) msgColor.setAlpha(160); painter.setPen(msgColor); painter.drawText(messageRect, curAlignment, curMessage); + + // Draw unified progress bar at the bottom of the card + int barY = height() - SPLASH_PADDING - 18; + + // Track background (always visible once we have a message) + if (!curMessage.isEmpty()) { + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(128, 128, 128, 40)); + painter.drawRoundedRect(QRectF(barLeft, barY, barWidth, barHeight), barRadius, barRadius); + + // Fill based on overall progress + int fillWidth = static_cast(barWidth * std::min(displayProgress, 1.0)); + if (fillWidth > 0) { + painter.setBrush(GUIUtil::getThemedQColor(GUIUtil::ThemedColor::BLUE)); + painter.drawRoundedRect(QRectF(barLeft, barY, fillWidth, barHeight), barRadius, barRadius); + } + } } void SplashScreen::closeEvent(QCloseEvent *event) diff --git a/src/qt/splashscreen.h b/src/qt/splashscreen.h index d326bceea920..163716152bcf 100644 --- a/src/qt/splashscreen.h +++ b/src/qt/splashscreen.h @@ -5,6 +5,8 @@ #ifndef BITCOIN_QT_SPLASHSCREEN_H #define BITCOIN_QT_SPLASHSCREEN_H +#include +#include #include #include @@ -40,6 +42,9 @@ public Q_SLOTS: /** Show message and progress */ void showMessage(const QString &message, int alignment, const QColor &color); + /** Set progress bar value (-1 = no sub-progress, 0-100 = phase sub-progress) */ + void setProgress(int value); + /** Handle wallet load notifications. */ void handleLoadWallet(); @@ -53,11 +58,25 @@ public Q_SLOTS: void unsubscribeFromCoreSignals(); /** Initiate shutdown */ void shutdown(); + /** Calculate overall progress (0.0-1.0) based on current phase and sub-progress */ + qreal calcOverallProgress() const; QPixmap pixmap; + /** Cached on GUI thread at construction for thread-safe use in cross-thread callbacks. + * Const after construction — no synchronization needed. */ + const QColor messageColor; QString curMessage; QColor curColor; int curAlignment{0}; + int curProgress{-1}; + + // Phase-based progress tracking + qreal phaseStart{0.0}; // Overall progress at start of current phase + qreal phaseEnd{0.0}; // Overall progress at end of current phase + bool phaseIsLong{false}; // True for long independent phases (rescan, wallet load) + QElapsedTimer phaseTimer; // Time since current phase started + qreal displayProgress{0.0}; // Smoothly animated display value (0.0-1.0) + QTimer animTimer; interfaces::Node* m_node = nullptr; bool m_shutdown = false; From 938f517b268a56a9e4eb4e346cac65339563c670 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 11 Mar 2026 23:57:49 -0500 Subject: [PATCH 3/7] fix(qt): only reinitialize splash phase on actual phase transition During wallet rescans, repeated ShowProgress callbacks with the same message re-entered the resetsBar block, slamming displayProgress back to 0 and restarting phaseTimer on every update. Track the current phase pointer and skip reinitialization when the phase hasn't changed. --- src/qt/splashscreen.cpp | 7 +++++-- src/qt/splashscreen.h | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/qt/splashscreen.cpp b/src/qt/splashscreen.cpp index c9b394671139..94ba74e42844 100644 --- a/src/qt/splashscreen.cpp +++ b/src/qt/splashscreen.cpp @@ -354,9 +354,12 @@ void SplashScreen::showMessage(const QString &message, int alignment, const QCol animTimer.start(30); } - // Look up the phase range for this message + // Look up the phase range for this message; only reinitialize when the + // phase actually changes so that repeated progress callbacks (e.g. during + // wallet rescans) don't reset displayProgress and phaseTimer each time. const PhaseInfo* phase = LookupPhase(message); - if (phase != nullptr) { + if (phase != nullptr && phase != m_current_phase) { + m_current_phase = phase; if (phase->snapsToEnd) { // Final phase: snap to 100% immediately since the splash // will be destroyed moments after "Done loading" arrives diff --git a/src/qt/splashscreen.h b/src/qt/splashscreen.h index 163716152bcf..e7ab442aef77 100644 --- a/src/qt/splashscreen.h +++ b/src/qt/splashscreen.h @@ -75,6 +75,7 @@ public Q_SLOTS: qreal phaseEnd{0.0}; // Overall progress at end of current phase bool phaseIsLong{false}; // True for long independent phases (rescan, wallet load) QElapsedTimer phaseTimer; // Time since current phase started + const struct PhaseInfo* m_current_phase{nullptr}; // Current phase (defined in splashscreen.cpp) qreal displayProgress{0.0}; // Smoothly animated display value (0.0-1.0) QTimer animTimer; From 4c97eb52c4b29734d3249d9cecb8ef1be07d4283 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 12 Mar 2026 10:02:11 -0500 Subject: [PATCH 4/7] fix(qt): reset splash progress bar on multi-wallet rescans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When multiple wallets rescan at startup, each emits ShowProgress with a wallet-name-prefixed message (e.g. '[wallet2] Rescanning…'). Since these map to the same PhaseInfo entry, the phase \!= m_current_phase guard skipped reinitialization for subsequent wallets. Track the triggering message text and also reinitialize resetsBar phases when the message changes. --- src/qt/splashscreen.cpp | 7 ++++++- src/qt/splashscreen.h | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/qt/splashscreen.cpp b/src/qt/splashscreen.cpp index 94ba74e42844..d4a6be1b741a 100644 --- a/src/qt/splashscreen.cpp +++ b/src/qt/splashscreen.cpp @@ -357,9 +357,14 @@ void SplashScreen::showMessage(const QString &message, int alignment, const QCol // Look up the phase range for this message; only reinitialize when the // phase actually changes so that repeated progress callbacks (e.g. during // wallet rescans) don't reset displayProgress and phaseTimer each time. + // For resetsBar phases, also reinitialize when the message text changes + // (e.g. different wallet name prefix during multi-wallet rescans). const PhaseInfo* phase = LookupPhase(message); - if (phase != nullptr && phase != m_current_phase) { + const bool phase_changed = phase != nullptr && + (phase != m_current_phase || (phase->resetsBar && message != m_current_phase_message)); + if (phase_changed) { m_current_phase = phase; + m_current_phase_message = message; if (phase->snapsToEnd) { // Final phase: snap to 100% immediately since the splash // will be destroyed moments after "Done loading" arrives diff --git a/src/qt/splashscreen.h b/src/qt/splashscreen.h index e7ab442aef77..9cfb540dffa3 100644 --- a/src/qt/splashscreen.h +++ b/src/qt/splashscreen.h @@ -76,6 +76,7 @@ public Q_SLOTS: bool phaseIsLong{false}; // True for long independent phases (rescan, wallet load) QElapsedTimer phaseTimer; // Time since current phase started const struct PhaseInfo* m_current_phase{nullptr}; // Current phase (defined in splashscreen.cpp) + QString m_current_phase_message; // Message that triggered current phase qreal displayProgress{0.0}; // Smoothly animated display value (0.0-1.0) QTimer animTimer; From 3b41475ba68eead3bfe152e7441521db4f2d7bf4 Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Wed, 18 Mar 2026 01:52:56 +0300 Subject: [PATCH 5/7] qt: show real wallet rescan progress on startup During startup, the splash only subscribed to wallet ShowProgress after a wallet finished loading. That meant startup rescans were seen as a generic init message and the new PHASE_TABLE logic fell back to the synthetic time-based long-phase path, even while the wallet log reported real rescan percentages. Add a separate wallet-loading callback that fires once a wallet object exists and can emit progress, but before AttachChain begins startup rescan work. The splash now subscribes through that early hook, so startup wallet rescans use the real wallet ShowProgress values instead of the fake exponential fill. --- src/interfaces/wallet.h | 5 +++++ src/qt/splashscreen.cpp | 10 +++++----- src/qt/splashscreen.h | 6 +++--- src/wallet/context.h | 1 + src/wallet/interfaces.cpp | 4 ++++ src/wallet/wallet.cpp | 17 +++++++++++++++++ src/wallet/wallet.h | 2 ++ 7 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/interfaces/wallet.h b/src/interfaces/wallet.h index dd4e53dac764..3a3cea2e983b 100644 --- a/src/interfaces/wallet.h +++ b/src/interfaces/wallet.h @@ -400,6 +400,11 @@ class WalletLoader : public ChainClient using LoadWalletFn = std::function wallet)>; virtual std::unique_ptr handleLoadWallet(LoadWalletFn fn) = 0; + //! Register handler for wallet-loading messages. This callback is triggered + //! after a wallet object exists and can emit progress, but before startup + //! load work like AttachChain()/rescan necessarily completes. + virtual std::unique_ptr handleLoadWalletLoading(LoadWalletFn fn) = 0; + //! Return pointer to internal context, useful for testing. virtual wallet::WalletContext* context() { return nullptr; } }; diff --git a/src/qt/splashscreen.cpp b/src/qt/splashscreen.cpp index d4a6be1b741a..39df1702ea0c 100644 --- a/src/qt/splashscreen.cpp +++ b/src/qt/splashscreen.cpp @@ -307,15 +307,15 @@ void SplashScreen::subscribeToCoreSignals() m_handler_show_progress = m_node->handleShowProgress([this, color](const std::string& title, int nProgress, bool /*resume_possible*/) { PostMessageAndProgress(this, color, title, nProgress); }); - m_handler_init_wallet = m_node->handleInitWallet([this]() { handleLoadWallet(); }); + m_handler_init_wallet = m_node->handleInitWallet([this]() { handleLoadingWallet(); }); } -void SplashScreen::handleLoadWallet() +void SplashScreen::handleLoadingWallet() { #ifdef ENABLE_WALLET if (!WalletModel::isWalletEnabled()) return; const QColor color = messageColor; - m_handler_load_wallet = m_node->walletLoader().handleLoadWallet([this, color](std::unique_ptr wallet) { + m_handler_loading_wallet = m_node->walletLoader().handleLoadWalletLoading([this, color](std::unique_ptr wallet) { m_connected_wallet_handlers.emplace_back(wallet->handleShowProgress([this, color](const std::string& title, int nProgress) { PostMessageAndProgress(this, color, title, nProgress); })); @@ -331,8 +331,8 @@ void SplashScreen::unsubscribeFromCoreSignals() m_handler_show_progress->disconnect(); m_handler_init_wallet->disconnect(); #ifdef ENABLE_WALLET - if (m_handler_load_wallet != nullptr) { - m_handler_load_wallet->disconnect(); + if (m_handler_loading_wallet != nullptr) { + m_handler_loading_wallet->disconnect(); } #endif // ENABLE_WALLET for (const auto& handler : m_connected_wallet_handlers) { diff --git a/src/qt/splashscreen.h b/src/qt/splashscreen.h index 9cfb540dffa3..918a0ae675cc 100644 --- a/src/qt/splashscreen.h +++ b/src/qt/splashscreen.h @@ -45,8 +45,8 @@ public Q_SLOTS: /** Set progress bar value (-1 = no sub-progress, 0-100 = phase sub-progress) */ void setProgress(int value); - /** Handle wallet load notifications. */ - void handleLoadWallet(); + /** Handle early wallet-loading notifications so startup progress can be observed. */ + void handleLoadingWallet(); protected: bool eventFilter(QObject * obj, QEvent * ev) override; @@ -85,7 +85,7 @@ public Q_SLOTS: std::unique_ptr m_handler_init_message; std::unique_ptr m_handler_show_progress; std::unique_ptr m_handler_init_wallet; - std::unique_ptr m_handler_load_wallet; + std::unique_ptr m_handler_loading_wallet; std::list> m_connected_wallets; std::list> m_connected_wallet_handlers; }; diff --git a/src/wallet/context.h b/src/wallet/context.h index 269b24e82cda..0ba5076659a3 100644 --- a/src/wallet/context.h +++ b/src/wallet/context.h @@ -46,6 +46,7 @@ struct WalletContext { // this could introduce inconsistent lock ordering and cause deadlocks. Mutex wallets_mutex; std::vector> wallets GUARDED_BY(wallets_mutex); + std::list wallet_loading_fns GUARDED_BY(wallets_mutex); std::list wallet_load_fns GUARDED_BY(wallets_mutex); interfaces::CoinJoin::Loader* coinjoin_loader{nullptr}; // Some Dash RPCs rely on WalletContext yet access NodeContext members diff --git a/src/wallet/interfaces.cpp b/src/wallet/interfaces.cpp index 340d29a35c9e..13c7471aece9 100644 --- a/src/wallet/interfaces.cpp +++ b/src/wallet/interfaces.cpp @@ -792,6 +792,10 @@ class WalletLoaderImpl : public WalletLoader { return HandleLoadWallet(m_context, std::move(fn)); } + std::unique_ptr handleLoadWalletLoading(LoadWalletFn fn) override + { + return HandleLoadWalletLoading(m_context, std::move(fn)); + } WalletContext* context() override { return &m_context; } WalletContext m_context; diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index f1b18d0e4ead..0a258b2b2374 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -193,6 +193,21 @@ std::unique_ptr HandleLoadWallet(WalletContext& context, Lo return interfaces::MakeHandler([&context, it] { LOCK(context.wallets_mutex); context.wallet_load_fns.erase(it); }); } +std::unique_ptr HandleLoadWalletLoading(WalletContext& context, LoadWalletFn load_wallet) +{ + LOCK(context.wallets_mutex); + auto it = context.wallet_loading_fns.emplace(context.wallet_loading_fns.end(), std::move(load_wallet)); + return interfaces::MakeHandler([&context, it] { LOCK(context.wallets_mutex); context.wallet_loading_fns.erase(it); }); +} + +void NotifyWalletLoading(WalletContext& context, const std::shared_ptr& wallet) +{ + LOCK(context.wallets_mutex); + for (auto& load_wallet : context.wallet_loading_fns) { + load_wallet(interfaces::MakeWallet(context, wallet)); + } +} + void NotifyWalletLoaded(WalletContext& context, const std::shared_ptr& wallet) { LOCK(context.wallets_mutex); @@ -3282,6 +3297,8 @@ std::shared_ptr CWallet::Create(WalletContext& context, const std::stri // Try to top up keypool. No-op if the wallet is locked. walletInstance->TopUpKeyPool(); + NotifyWalletLoading(context, walletInstance); + if (chain && !AttachChain(walletInstance, *chain, error, warnings)) { walletInstance->m_chain_notifications_handler.reset(); // Reset this pointer so that the wallet will actually be unloaded return nullptr; diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index 603601af23b2..3c1db801b676 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -76,7 +76,9 @@ std::shared_ptr GetWallet(WalletContext& context, const std::string& na std::shared_ptr LoadWallet(WalletContext& context, const std::string& name, std::optional load_on_start, const DatabaseOptions& options, DatabaseStatus& status, bilingual_str& error, std::vector& warnings); std::shared_ptr CreateWallet(WalletContext& context, const std::string& name, std::optional load_on_start, DatabaseOptions& options, DatabaseStatus& status, bilingual_str& error, std::vector& warnings); std::shared_ptr RestoreWallet(WalletContext& context, const fs::path& backup_file, const std::string& wallet_name, std::optional load_on_start, DatabaseStatus& status, bilingual_str& error, std::vector& warnings); +std::unique_ptr HandleLoadWalletLoading(WalletContext& context, LoadWalletFn load_wallet); std::unique_ptr HandleLoadWallet(WalletContext& context, LoadWalletFn load_wallet); +void NotifyWalletLoading(WalletContext& context, const std::shared_ptr& wallet); void NotifyWalletLoaded(WalletContext& context, const std::shared_ptr& wallet); std::unique_ptr MakeWalletDatabase(const std::string& name, const DatabaseOptions& options, DatabaseStatus& status, bilingual_str& error); From ff400c43c2833b4bd2f7c069d7a6b937a5aab606 Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Wed, 18 Mar 2026 02:10:53 +0300 Subject: [PATCH 6/7] qt: simplify splash fallback progress state Reset curProgress whenever the splash changes phases so stale numeric progress from the previous phase cannot leak into the next one before a fresh ShowProgress update arrives. Also drop the separate phaseIsLong state and use a single fallback curve for phases that only emit initMessage(). After wiring startup rescans to real wallet ShowProgress updates, the extra slow-fallback state no longer buys us anything. --- src/qt/splashscreen.cpp | 17 ++++++++--------- src/qt/splashscreen.h | 1 - 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/qt/splashscreen.cpp b/src/qt/splashscreen.cpp index 39df1702ea0c..b1c433a306f1 100644 --- a/src/qt/splashscreen.cpp +++ b/src/qt/splashscreen.cpp @@ -265,16 +265,14 @@ bool SplashScreen::eventFilter(QObject * obj, QEvent * ev) { qreal SplashScreen::calcOverallProgress() const { if (curProgress >= 0) { - // Phase with real sub-progress: interpolate linearly within phase range + // When a phase reports real sub-progress, map that 0-100 value into the + // phase's assigned range directly. return phaseStart + (phaseEnd - phaseStart) * (curProgress / 100.0); } - // Phase without sub-progress: exponential approach toward phaseEnd + // Otherwise fall back to a time-based curve so phases driven only by + // initMessage() still show movement without claiming exact progress. qreal elapsed = phaseTimer.elapsed() / 1000.0; - // Long phases (rescan, wallet load) can take minutes/hours — use a very slow curve - // Normal phases: reaches ~90% of range in ~15s - // Long phases: reaches ~50% in ~2min, ~75% in ~5min - qreal tau = phaseIsLong ? 120.0 : 5.0; - qreal fraction = 1.0 - std::exp(-elapsed / tau); + qreal fraction = 1.0 - std::exp(-elapsed / 5.0); return phaseStart + (phaseEnd - phaseStart) * fraction * EXPONENTIAL_FILL_CAP; } @@ -365,6 +363,9 @@ void SplashScreen::showMessage(const QString &message, int alignment, const QCol if (phase_changed) { m_current_phase = phase; m_current_phase_message = message; + // Progress values are phase-local; drop any numeric progress from the + // previous phase until a new ShowProgress update arrives. + curProgress = -1; if (phase->snapsToEnd) { // Final phase: snap to 100% immediately since the splash // will be destroyed moments after "Done loading" arrives @@ -377,11 +378,9 @@ void SplashScreen::showMessage(const QString &message, int alignment, const QCol displayProgress = 0.0; phaseStart = phase->start; phaseEnd = phase->end; - phaseIsLong = true; animTimer.start(30); } else { // Normal phase: ensure we never jump backwards - phaseIsLong = false; phaseStart = std::max(phase->start, displayProgress); phaseEnd = std::max(phase->end, phaseStart); } diff --git a/src/qt/splashscreen.h b/src/qt/splashscreen.h index 918a0ae675cc..218da8fab23d 100644 --- a/src/qt/splashscreen.h +++ b/src/qt/splashscreen.h @@ -73,7 +73,6 @@ public Q_SLOTS: // Phase-based progress tracking qreal phaseStart{0.0}; // Overall progress at start of current phase qreal phaseEnd{0.0}; // Overall progress at end of current phase - bool phaseIsLong{false}; // True for long independent phases (rescan, wallet load) QElapsedTimer phaseTimer; // Time since current phase started const struct PhaseInfo* m_current_phase{nullptr}; // Current phase (defined in splashscreen.cpp) QString m_current_phase_message; // Message that triggered current phase From da5e9c12129c4ff2a91cc0b51c7efc1f4268c74f Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Wed, 18 Mar 2026 02:43:09 +0300 Subject: [PATCH 7/7] qt: cache translated splash phase labels --- src/qt/splashscreen.cpp | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/qt/splashscreen.cpp b/src/qt/splashscreen.cpp index b1c433a306f1..d7039def9ea8 100644 --- a/src/qt/splashscreen.cpp +++ b/src/qt/splashscreen.cpp @@ -69,10 +69,17 @@ static const PhaseInfo PHASE_TABLE[] = { * Uses contains() because ShowProgress may prepend the wallet name. */ static const PhaseInfo* LookupPhase(const QString& message) { - for (const auto& phase : PHASE_TABLE) { - QString translated = QString::fromStdString(_(phase.msg_key).translated); + static const auto cache = [] { + std::vector> phases_with_translations; + for (const auto& phase : PHASE_TABLE) { + phases_with_translations.push_back({&phase, QString::fromStdString(_(phase.msg_key).translated)}); + } + return phases_with_translations; + }(); + + for (const auto& [phase, translated] : cache) { if (message.contains(translated, Qt::CaseInsensitive)) { - return &phase; + return phase; } } return nullptr;