From 874733c48129e489a9ad53db151e5b8d1b852ad1 Mon Sep 17 00:00:00 2001
From: johnny9 <985648+johnny9@users.noreply.github.com>
Date: Sun, 26 Apr 2026 01:57:59 -0400
Subject: [PATCH 1/3] qml, blockclock: add mini navigation indicator
Add a compact BlockClock representation for wallet navigation. The mini dial uses the shared BlockClockDial without time ticks or individual block segments, with immediate connecting animation, non-animated IBD/selected fills, and center/pause/error glyph states.
Keep the selected state visible across dynamic states, including pause, by using the selected yellow for the paused mini indicator. Also allow NavigationTab to host custom content and use pointing-hand cursors consistently for navigation buttons.
---
qml/bitcoin_qml.qrc | 1 +
qml/components/MiniBlockClock.qml | 158 ++++++++++++++++++++++++++
qml/components/blockclockdial.cpp | 168 ++++++++++++++++++++++++++--
qml/components/blockclockdial.h | 32 +++++-
qml/controls/NavButton.qml | 1 +
qml/controls/NavigationTab.qml | 16 ++-
qml/pages/wallet/DesktopWallets.qml | 12 +-
7 files changed, 371 insertions(+), 17 deletions(-)
create mode 100644 qml/components/MiniBlockClock.qml
diff --git a/qml/bitcoin_qml.qrc b/qml/bitcoin_qml.qrc
index 4945da9d1a..e3a1d7a636 100644
--- a/qml/bitcoin_qml.qrc
+++ b/qml/bitcoin_qml.qrc
@@ -14,6 +14,7 @@
components/ExternalSignerReviewActions.qml
components/ExternalPopup.qml
components/FeeSelection.qml
+ components/MiniBlockClock.qml
components/MonospaceOutputView.qml
components/InfoBanner.qml
components/NetworkTrafficGraph.qml
diff --git a/qml/components/MiniBlockClock.qml b/qml/components/MiniBlockClock.qml
new file mode 100644
index 0000000000..52572594b1
--- /dev/null
+++ b/qml/components/MiniBlockClock.qml
@@ -0,0 +1,158 @@
+// Copyright (c) 2026 The Bitcoin Core developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+import QtQuick 2.15
+
+import org.bitcoincore.qt 1.0
+
+import "../controls"
+
+Item {
+ id: root
+ objectName: "miniBlockClock"
+
+ property real iconSize: 18
+ property bool pageSelected: false
+ property bool connected: nodeModel.numOutboundPeers > 0
+ property bool synced: nodeModel.verificationProgress > 0.999
+ property bool paused: nodeModel.pause
+ property bool faulted: nodeModel.faulted
+
+ readonly property bool showConnectingState: !root.paused && !root.faulted && !root.connected
+ readonly property bool showIbdState: !root.paused && !root.faulted && root.connected && !root.synced
+ readonly property bool showClockState: !root.paused && !root.faulted && root.connected && root.synced
+ readonly property bool showDialState: dial.visible
+ readonly property real strokeWidth: Math.max(1, root.width / 12)
+ readonly property color dialGlyphColor: root.pageSelected ? Theme.color.confirmationColors[5] : Theme.color.neutral9
+ readonly property color pausedGlyphColor: root.pageSelected ? Theme.color.confirmationColors[5] : Theme.color.neutral6
+
+ implicitWidth: iconSize
+ implicitHeight: iconSize
+ width: implicitWidth
+ height: implicitHeight
+
+ readonly property var fullDialTimeRatioList: [1.0, 0.0]
+
+ BlockClockDial {
+ id: dial
+ visible: root.showConnectingState || root.showIbdState || root.showClockState
+ anchors.fill: parent
+ penWidth: root.strokeWidth
+ connectingAnimationDelayMs: 0
+ timeRatioList: root.pageSelected ? root.fullDialTimeRatioList : chainModel.timeRatioList
+ verificationProgress: root.pageSelected ? 1.0 : nodeModel.verificationProgress
+ connected: root.pageSelected || root.connected
+ synced: root.pageSelected || root.synced
+ paused: false
+ animateDial: root.showConnectingState && !root.pageSelected
+ showTimeTicks: false
+ showBlockSegments: false
+ useGradientArcWhenSynced: !root.pageSelected
+ backgroundColor: Theme.color.neutral3
+ timeTickColor: "transparent"
+ confirmationColors: Theme.color.confirmationColors
+ }
+
+ Item {
+ visible: root.showDialState
+ anchors.centerIn: parent
+ width: parent.width * (8 / 18)
+ height: width
+
+ Rectangle {
+ anchors.top: parent.top
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: Math.max(1, parent.width * 0.25)
+ height: width
+ radius: width / 2
+ color: root.dialGlyphColor
+ }
+
+ Rectangle {
+ anchors.top: parent.top
+ anchors.topMargin: parent.height * (3.5 / 8)
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: parent.width
+ height: Math.max(1, parent.height * (1.5 / 8))
+ radius: height / 2
+ color: root.dialGlyphColor
+ }
+
+ Rectangle {
+ anchors.top: parent.top
+ anchors.topMargin: parent.height * (6.5 / 8)
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: parent.width * 0.5
+ height: Math.max(1, parent.height * (1.5 / 8))
+ radius: height / 2
+ color: root.dialGlyphColor
+ }
+ }
+
+ Item {
+ visible: root.paused && !root.faulted
+ anchors.fill: parent
+
+ Rectangle {
+ anchors.centerIn: parent
+ width: parent.width - root.strokeWidth
+ height: width
+ radius: width / 2
+ color: "transparent"
+ border.width: root.strokeWidth
+ border.color: root.pausedGlyphColor
+ }
+
+ Rectangle {
+ anchors.centerIn: parent
+ anchors.horizontalCenterOffset: -parent.width * 0.12
+ width: Math.max(root.strokeWidth, parent.width * 0.11)
+ height: parent.height * 0.42
+ radius: width / 2
+ color: root.pausedGlyphColor
+ }
+
+ Rectangle {
+ anchors.centerIn: parent
+ anchors.horizontalCenterOffset: parent.width * 0.12
+ width: Math.max(root.strokeWidth, parent.width * 0.11)
+ height: parent.height * 0.42
+ radius: width / 2
+ color: root.pausedGlyphColor
+ }
+ }
+
+ Item {
+ visible: root.faulted
+ anchors.fill: parent
+
+ Rectangle {
+ anchors.centerIn: parent
+ width: parent.width - root.strokeWidth
+ height: width
+ radius: width / 2
+ color: "transparent"
+ border.width: root.strokeWidth
+ border.color: Theme.color.red
+ }
+
+ Rectangle {
+ anchors.centerIn: parent
+ anchors.verticalCenterOffset: -parent.height * 0.08
+ width: Math.max(root.strokeWidth, parent.width * 0.12)
+ height: parent.height * 0.34
+ radius: width / 2
+ color: Theme.color.red
+ }
+
+ Rectangle {
+ anchors.centerIn: parent
+ anchors.verticalCenterOffset: parent.height * 0.22
+ width: Math.max(root.strokeWidth, parent.width * 0.12)
+ height: width
+ radius: width / 2
+ color: Theme.color.red
+ }
+ }
+}
diff --git a/qml/components/blockclockdial.cpp b/qml/components/blockclockdial.cpp
index f39658b5f5..8c0efaa799 100644
--- a/qml/components/blockclockdial.cpp
+++ b/qml/components/blockclockdial.cpp
@@ -18,7 +18,7 @@ BlockClockDial::BlockClockDial(QQuickItem *parent)
m_animation_timer.setTimerType(Qt::PreciseTimer);
m_animation_timer.setInterval(16);
m_delay_timer.setSingleShot(true);
- m_delay_timer.setInterval(5000);
+ m_delay_timer.setInterval(m_connecting_animation_delay_ms);
connect(&m_delay_timer, &QTimer::timeout,
this, [this]() { this->m_animation_timer.start(); });
connect(&m_animation_timer, &QTimer::timeout,
@@ -29,7 +29,11 @@ BlockClockDial::BlockClockDial(QQuickItem *parent)
}
this->update();
});
- m_delay_timer.start();
+ if (m_connecting_animation_delay_ms == 0) {
+ m_animation_timer.start();
+ } else {
+ m_delay_timer.start();
+ }
}
void BlockClockDial::setupConnectingGradient(const QPen & pen)
@@ -43,6 +47,19 @@ void BlockClockDial::setupConnectingGradient(const QPen & pen)
m_connecting_gradient.setColorAt(1, "transparent");
}
+void BlockClockDial::setupSyncedGradient(const QRectF& bounds)
+{
+ m_synced_gradient.setCenter(bounds.center());
+ m_synced_gradient.setAngle(90);
+ m_synced_gradient.setColorAt(0, m_confirmation_colors[5]);
+ m_synced_gradient.setColorAt(0.16, m_confirmation_colors[5]);
+ m_synced_gradient.setColorAt(0.32, m_confirmation_colors[4]);
+ m_synced_gradient.setColorAt(0.48, m_confirmation_colors[3]);
+ m_synced_gradient.setColorAt(0.64, m_confirmation_colors[2]);
+ m_synced_gradient.setColorAt(0.8, m_confirmation_colors[1]);
+ m_synced_gradient.setColorAt(1, m_confirmation_colors[0]);
+}
+
qreal BlockClockDial::decrementGradientAngle(qreal angle)
{
if (angle == -360) {
@@ -54,6 +71,10 @@ qreal BlockClockDial::decrementGradientAngle(qreal angle)
qreal BlockClockDial::getTargetAnimationAngle()
{
+ if (m_time_ratio_list.isEmpty()) {
+ return connected() ? verificationProgress() * 360 : 360;
+ }
+
if (connected() && synced()) {
return m_time_ratio_list[0].toDouble() * 360;
} else if (connected()) {
@@ -80,12 +101,18 @@ qreal BlockClockDial::incrementAnimatingMaxAngle(qreal angle)
void BlockClockDial::setTimeRatioList(QVariantList new_list)
{
m_time_ratio_list = new_list;
+ if (!m_animate_dial) {
+ m_animating_max_angle = getTargetAnimationAngle();
+ }
update();
}
void BlockClockDial::setVerificationProgress(double progress)
{
m_verification_progress = progress;
+ if (!m_animate_dial) {
+ m_animating_max_angle = getTargetAnimationAngle();
+ }
update();
}
@@ -94,11 +121,21 @@ void BlockClockDial::setConnected(bool connected)
if (m_is_connected != connected) {
m_is_connected = connected;
m_animating_max_angle = 0;
- if (m_is_connected) {
+ if (!m_animate_dial) {
+ m_animation_timer.stop();
+ m_delay_timer.stop();
+ m_animating_max_angle = getTargetAnimationAngle();
+ } else if (m_is_connected) {
+ m_delay_timer.stop();
m_animation_timer.start();
} else {
m_animation_timer.stop();
- m_delay_timer.start();
+ if (m_connecting_animation_delay_ms == 0) {
+ m_delay_timer.stop();
+ m_animation_timer.start();
+ } else {
+ m_delay_timer.start();
+ }
}
update();
}
@@ -109,7 +146,11 @@ void BlockClockDial::setSynced(bool is_synced)
if (m_is_synced != is_synced) {
m_is_synced = is_synced;
m_animating_max_angle = 0;
- if (m_is_synced && connected()) {
+ if (!m_animate_dial) {
+ m_animation_timer.stop();
+ m_delay_timer.stop();
+ m_animating_max_angle = getTargetAnimationAngle();
+ } else if (m_is_synced && connected()) {
m_animation_timer.start();
}
update();
@@ -128,6 +169,68 @@ void BlockClockDial::setPaused(bool paused)
}
}
+void BlockClockDial::setAnimateDial(bool animate_dial)
+{
+ if (m_animate_dial != animate_dial) {
+ m_animate_dial = animate_dial;
+ if (m_animate_dial) {
+ m_animating_max_angle = 0;
+ if (m_is_connected) {
+ m_animation_timer.start();
+ } else {
+ m_animation_timer.stop();
+ m_delay_timer.start();
+ }
+ } else {
+ m_animation_timer.stop();
+ m_delay_timer.stop();
+ m_animating_max_angle = getTargetAnimationAngle();
+ }
+ update();
+ }
+}
+
+void BlockClockDial::setConnectingAnimationDelayMs(int connecting_animation_delay_ms)
+{
+ connecting_animation_delay_ms = qMax(connecting_animation_delay_ms, 0);
+ if (m_connecting_animation_delay_ms != connecting_animation_delay_ms) {
+ m_connecting_animation_delay_ms = connecting_animation_delay_ms;
+ m_delay_timer.setInterval(m_connecting_animation_delay_ms);
+ if (!m_is_connected && m_animate_dial) {
+ if (m_connecting_animation_delay_ms == 0) {
+ m_delay_timer.stop();
+ m_animation_timer.start();
+ } else if (!m_animation_timer.isActive()) {
+ m_delay_timer.start();
+ }
+ }
+ }
+}
+
+void BlockClockDial::setShowTimeTicks(bool show_time_ticks)
+{
+ if (m_show_time_ticks != show_time_ticks) {
+ m_show_time_ticks = show_time_ticks;
+ update();
+ }
+}
+
+void BlockClockDial::setShowBlockSegments(bool show_block_segments)
+{
+ if (m_show_block_segments != show_block_segments) {
+ m_show_block_segments = show_block_segments;
+ update();
+ }
+}
+
+void BlockClockDial::setUseGradientArcWhenSynced(bool use_gradient_arc_when_synced)
+{
+ if (m_use_gradient_arc_when_synced != use_gradient_arc_when_synced) {
+ m_use_gradient_arc_when_synced = use_gradient_arc_when_synced;
+ update();
+ }
+}
+
void BlockClockDial::setPenWidth(qreal width)
{
m_pen_width = width;
@@ -257,6 +360,45 @@ void BlockClockDial::paintProgress(QPainter * painter)
painter->drawArc(bounds, startAngle * 16, spanAngle * 16);
}
+void BlockClockDial::paintCurrentTimeArc(QPainter* painter)
+{
+ if (m_time_ratio_list.isEmpty()) {
+ return;
+ }
+
+ QPen pen(m_confirmation_colors[5]);
+ pen.setWidthF(m_pen_width);
+ pen.setCapStyle(Qt::RoundCap);
+ const QRectF bounds = getBoundsForPen(pen);
+ painter->setPen(pen);
+
+ const qreal start_angle = 90;
+ const qreal time_angle = m_time_ratio_list[0].toDouble() * 360;
+ const qreal span_angle = -1 * qMin(time_angle, m_animating_max_angle);
+ painter->drawArc(bounds, start_angle * 16, span_angle * 16);
+}
+
+void BlockClockDial::paintSyncedGradientArc(QPainter* painter)
+{
+ if (m_confirmation_colors.size() < 6 || m_time_ratio_list.isEmpty()) {
+ paintCurrentTimeArc(painter);
+ return;
+ }
+
+ QPen pen;
+ pen.setWidthF(m_pen_width);
+ pen.setCapStyle(Qt::RoundCap);
+ const QRectF bounds = getBoundsForPen(pen);
+ setupSyncedGradient(bounds);
+ pen.setBrush(QBrush(m_synced_gradient));
+ painter->setPen(pen);
+
+ const qreal start_angle = 90;
+ const qreal time_angle = m_time_ratio_list[0].toDouble() * 360;
+ const qreal span_angle = -1 * qMin(time_angle, m_animating_max_angle);
+ painter->drawArc(bounds, start_angle * 16, span_angle * 16);
+}
+
void BlockClockDial::paintConnectingAnimation(QPainter * painter)
{
QPen pen;
@@ -317,16 +459,26 @@ void BlockClockDial::paint(QPainter * painter)
painter->setRenderHint(QPainter::Antialiasing);
paintBackground(painter);
- paintTimeTicks(painter);
+ if (showTimeTicks()) {
+ paintTimeTicks(painter);
+ }
if (paused()) return;
if (connected() && synced()) {
- paintBlocks(painter);
+ if (showBlockSegments()) {
+ paintBlocks(painter);
+ } else if (useGradientArcWhenSynced()) {
+ paintSyncedGradientArc(painter);
+ } else {
+ paintCurrentTimeArc(painter);
+ }
} else if (connected()) {
paintProgress(painter);
} else if (m_animation_timer.isActive()) {
paintConnectingAnimation(painter);
}
- m_animating_max_angle = incrementAnimatingMaxAngle(m_animating_max_angle);
+ if (m_animate_dial) {
+ m_animating_max_angle = incrementAnimatingMaxAngle(m_animating_max_angle);
+ }
}
diff --git a/qml/components/blockclockdial.h b/qml/components/blockclockdial.h
index e7a103f0c0..d658ba6218 100644
--- a/qml/components/blockclockdial.h
+++ b/qml/components/blockclockdial.h
@@ -19,6 +19,11 @@ class BlockClockDial : public QQuickPaintedItem
Q_PROPERTY(bool connected READ connected WRITE setConnected)
Q_PROPERTY(bool synced READ synced WRITE setSynced)
Q_PROPERTY(bool paused READ paused WRITE setPaused)
+ Q_PROPERTY(bool animateDial READ animateDial WRITE setAnimateDial)
+ Q_PROPERTY(int connectingAnimationDelayMs READ connectingAnimationDelayMs WRITE setConnectingAnimationDelayMs)
+ Q_PROPERTY(bool showTimeTicks READ showTimeTicks WRITE setShowTimeTicks)
+ Q_PROPERTY(bool showBlockSegments READ showBlockSegments WRITE setShowBlockSegments)
+ Q_PROPERTY(bool useGradientArcWhenSynced READ useGradientArcWhenSynced WRITE setUseGradientArcWhenSynced)
Q_PROPERTY(qreal penWidth READ penWidth WRITE setPenWidth)
Q_PROPERTY(qreal scale READ scale WRITE setScale NOTIFY scaleChanged)
Q_PROPERTY(QColor backgroundColor READ backgroundColor WRITE setBackgroundColor)
@@ -34,6 +39,11 @@ class BlockClockDial : public QQuickPaintedItem
bool connected() const { return m_is_connected; };
bool synced() const { return m_is_synced; };
bool paused() const { return m_is_paused; };
+ bool animateDial() const { return m_animate_dial; };
+ int connectingAnimationDelayMs() const { return m_connecting_animation_delay_ms; };
+ bool showTimeTicks() const { return m_show_time_ticks; };
+ bool showBlockSegments() const { return m_show_block_segments; };
+ bool useGradientArcWhenSynced() const { return m_use_gradient_arc_when_synced; };
qreal penWidth() const { return m_pen_width; };
qreal scale() const { return m_scale; };
QColor backgroundColor() const { return m_background_color; };
@@ -46,6 +56,11 @@ public Q_SLOTS:
void setConnected(bool connected);
void setSynced(bool synced);
void setPaused(bool paused);
+ void setAnimateDial(bool animate_dial);
+ void setConnectingAnimationDelayMs(int connecting_animation_delay_ms);
+ void setShowTimeTicks(bool show_time_ticks);
+ void setShowBlockSegments(bool show_block_segments);
+ void setUseGradientArcWhenSynced(bool use_gradient_arc_when_synced);
void setPenWidth(qreal width);
void setScale(qreal scale);
void setBackgroundColor(QColor color);
@@ -58,25 +73,34 @@ public Q_SLOTS:
private:
void paintConnectingAnimation(QPainter * painter);
void paintProgress(QPainter * painter);
+ void paintCurrentTimeArc(QPainter* painter);
+ void paintSyncedGradientArc(QPainter* painter);
void paintBlocks(QPainter * painter);
void paintBackground(QPainter * painter);
void paintTimeTicks(QPainter * painter);
QRectF getBoundsForPen(const QPen & pen);
double degreesPerPixel();
void setupConnectingGradient(const QPen & pen);
+ void setupSyncedGradient(const QRectF& bounds);
qreal decrementGradientAngle(qreal angle);
qreal incrementAnimatingMaxAngle(qreal angle);
qreal getTargetAnimationAngle();
QVariantList m_time_ratio_list{0.0};
- double m_verification_progress;
- bool m_is_connected;
- bool m_is_synced;
- bool m_is_paused;
+ double m_verification_progress{0.0};
+ bool m_is_connected{false};
+ bool m_is_synced{false};
+ bool m_is_paused{false};
+ bool m_animate_dial{true};
+ int m_connecting_animation_delay_ms{5000};
+ bool m_show_time_ticks{true};
+ bool m_show_block_segments{true};
+ bool m_use_gradient_arc_when_synced{false};
qreal m_pen_width{4};
qreal m_scale{5/12};
QColor m_background_color{"#2D2D2D"};
QConicalGradient m_connecting_gradient;
+ QConicalGradient m_synced_gradient;
qreal m_connecting_start_angle = 90;
const qreal m_connecting_end_angle = -180;
QList m_confirmation_colors{};
diff --git a/qml/controls/NavButton.qml b/qml/controls/NavButton.qml
index e638d62248..4d40ef5712 100644
--- a/qml/controls/NavButton.qml
+++ b/qml/controls/NavButton.qml
@@ -103,6 +103,7 @@ AbstractButton {
MouseArea {
anchors.fill: parent
hoverEnabled: AppMode.isDesktop
+ cursorShape: root.enabled && root.hoverEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onEntered: {
root.background.state = "HOVER"
}
diff --git a/qml/controls/NavigationTab.qml b/qml/controls/NavigationTab.qml
index 219c90a135..7edbdd9dc5 100644
--- a/qml/controls/NavigationTab.qml
+++ b/qml/controls/NavigationTab.qml
@@ -13,6 +13,7 @@ Button {
property color textActiveColor: Theme.color.orange
property color iconColor: "transparent"
property string iconSource: ""
+ property Component customContent: null
id: root
checkable: true
@@ -22,23 +23,34 @@ Button {
bottomPadding: 0
topPadding: 0
+ HoverHandler {
+ cursorShape: root.enabled && root.hoverEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+ }
+
contentItem: Item {
width: parent.width
height: parent.height
+ Loader {
+ id: customContentLoader
+ active: root.customContent !== null
+ visible: active
+ sourceComponent: root.customContent
+ anchors.centerIn: parent
+ }
CoreText {
id: buttonText
font.pixelSize: 15
text: root.text
color: root.textColor
bold: true
- visible: root.text !== ""
+ visible: !customContentLoader.active && root.text !== ""
anchors.centerIn: parent
}
Icon {
id: icon
source: root.iconSource
color: iconColor
- visible: root.iconSource !== ""
+ visible: !customContentLoader.active && root.iconSource !== ""
anchors.centerIn: parent
}
}
diff --git a/qml/pages/wallet/DesktopWallets.qml b/qml/pages/wallet/DesktopWallets.qml
index 4f4dbf72af..597d8ff79e 100644
--- a/qml/pages/wallet/DesktopWallets.qml
+++ b/qml/pages/wallet/DesktopWallets.qml
@@ -102,13 +102,17 @@ Page {
Layout.rightMargin: 10
property int index: 3
ButtonGroup.group: navigationTabs
+ customContent: MiniBlockClock {
+ pageSelected: blockClockTabButton.checked
+ }
Tooltip {
id: blockClockTooltip
property var syncState: Utils.formatRemainingSyncTime(nodeModel.remainingSyncTime)
- property bool synced: nodeModel.verificationProgress > 0.9999
+ property bool synced: nodeModel.verificationProgress > 0.999
property bool paused: nodeModel.pause
property bool connected: nodeModel.numOutboundPeers > 0
+ property bool faulted: nodeModel.faulted
anchors.top: blockClockTabButton.bottom
anchors.topMargin: -5
@@ -116,11 +120,13 @@ Page {
visible: blockClockTabButton.hovered
text: {
- if (paused) {
+ if (faulted) {
+ qsTr("Error")
+ } else if (paused) {
qsTr("Paused")
} else if (connected && synced) {
qsTr("Blocktime\n" + Number(nodeModel.blockTipHeight).toLocaleString(Qt.locale(), 'f', 0))
- } else if (connected){
+ } else if (connected) {
qsTr("Downloading blocks\n" + syncState.text)
} else {
qsTr("Connecting")
From 9b5fda1fdb866447606ab0c76ee757cb70e5c0f1 Mon Sep 17 00:00:00 2001
From: johnny9 <985648+johnny9@users.noreply.github.com>
Date: Sun, 26 Apr 2026 01:58:09 -0400
Subject: [PATCH 2/3] qml, models: queue node model updates by value
NotifyBlockTip callbacks can arrive from node threads. The queued lambda previously captured the block tip and verification progress by reference, so IBD progress could read stale stack values and flicker to 0%.
Capture block height, block time, and verification progress by value before queueing onto the Qt thread, and queue connection-count updates through the same path. Add a regression test that fires block-tip updates from a worker thread and verifies the delivered payloads.
---
qml/models/nodemodel.cpp | 12 ++---
test/CMakeLists.txt | 1 +
test/test_nodemodel.cpp | 95 ++++++++++++++++++++++++++++++++++++++++
3 files changed, 103 insertions(+), 5 deletions(-)
create mode 100644 test/test_nodemodel.cpp
diff --git a/qml/models/nodemodel.cpp b/qml/models/nodemodel.cpp
index 41c23bedc8..bfddf7c7ac 100644
--- a/qml/models/nodemodel.cpp
+++ b/qml/models/nodemodel.cpp
@@ -154,12 +154,12 @@ void NodeModel::ConnectToBlockTipSignal()
assert(!m_handler_notify_block_tip);
m_handler_notify_block_tip = m_node.handleNotifyBlockTip(
- [this](SynchronizationState state, interfaces::BlockTip tip, double verification_progress) {
- QMetaObject::invokeMethod(this, [&, this] {
- setBlockTipHeight(tip.block_height);
+ [this]([[maybe_unused]] SynchronizationState state, interfaces::BlockTip tip, double verification_progress) {
+ QMetaObject::invokeMethod(this, [this, block_height = tip.block_height, block_time = tip.block_time, verification_progress] {
+ setBlockTipHeight(block_height);
setVerificationProgress(verification_progress);
- Q_EMIT setTimeRatioList(tip.block_time);
+ Q_EMIT setTimeRatioList(block_time);
});
});
}
@@ -170,7 +170,9 @@ void NodeModel::ConnectToNumConnectionsChangedSignal()
m_handler_notify_num_peers_changed = m_node.handleNotifyNumConnectionsChanged(
[this](int new_num_connections) {
- setNumOutboundPeers(new_num_connections);
+ QMetaObject::invokeMethod(this, [this, new_num_connections] {
+ setNumOutboundPeers(new_num_connections);
+ });
});
}
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index 3b7ed7d38b..a79f4eeec9 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -20,6 +20,7 @@ add_executable(bitcoinqml_unit_tests
test_walletqmlmodeltransaction.cpp
test_qmlbitcoinunits.cpp
test_imageprovider.cpp
+ test_nodemodel.cpp
test_networkstyle.cpp
test_qmlinitexecutor_api.cpp
test_options_model.cpp
diff --git a/test/test_nodemodel.cpp b/test/test_nodemodel.cpp
new file mode 100644
index 0000000000..0ea61b58ca
--- /dev/null
+++ b/test/test_nodemodel.cpp
@@ -0,0 +1,95 @@
+// Copyright (c) 2026 The Bitcoin Core developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+namespace {
+constexpr auto SIGNAL_TIMEOUT{5'000};
+
+class NoopHandler : public interfaces::Handler
+{
+public:
+ void disconnect() override {}
+};
+
+std::unique_ptr MakeNoopHandler()
+{
+ return std::make_unique();
+}
+} // namespace
+
+class NodeModelTests : public QObject
+{
+ Q_OBJECT
+
+private Q_SLOTS:
+ void blockTipUpdatesQueuedAcrossThreadsRetainPayloadValues();
+};
+
+void NodeModelTests::blockTipUpdatesQueuedAcrossThreadsRetainPayloadValues()
+{
+ using ::testing::_;
+ using ::testing::Invoke;
+ using ::testing::NiceMock;
+
+ NiceMock node;
+ interfaces::Node::NotifyBlockTipFn block_tip_fn;
+
+ EXPECT_CALL(node, handleNotifyBlockTip(_)).WillOnce(Invoke([&](interfaces::Node::NotifyBlockTipFn fn) {
+ block_tip_fn = std::move(fn);
+ return MakeNoopHandler();
+ }));
+ EXPECT_CALL(node, handleNotifyNumConnectionsChanged(_)).WillOnce(Invoke([](interfaces::Node::NotifyNumConnectionsChangedFn) {
+ return MakeNoopHandler();
+ }));
+ EXPECT_CALL(node, handleBannedListChanged(_)).WillOnce(Invoke([](interfaces::Node::BannedListChangedFn) {
+ return MakeNoopHandler();
+ }));
+
+ NodeModel model{node};
+ QVERIFY(block_tip_fn);
+
+ std::vector seen_progress;
+ std::vector seen_heights;
+
+ QObject::connect(&model, &NodeModel::verificationProgressChanged, &model, [&] {
+ seen_progress.push_back(model.verificationProgress());
+ });
+ QObject::connect(&model, &NodeModel::blockTipHeightChanged, &model, [&] {
+ seen_heights.push_back(model.blockTipHeight());
+ });
+
+ std::thread worker([&] {
+ block_tip_fn(SynchronizationState::INIT_DOWNLOAD, interfaces::BlockTip{123, 1'700'000'001, uint256{}}, 0.51);
+ block_tip_fn(SynchronizationState::INIT_DOWNLOAD, interfaces::BlockTip{456, 1'700'000'099, uint256{}}, 0.75);
+ });
+ worker.join();
+
+ QTRY_COMPARE_WITH_TIMEOUT(seen_progress.size(), size_t{2}, SIGNAL_TIMEOUT);
+ QTRY_COMPARE_WITH_TIMEOUT(seen_heights.size(), size_t{2}, SIGNAL_TIMEOUT);
+
+ QVERIFY(qFuzzyCompare(seen_progress.at(0), 0.51));
+ QVERIFY(qFuzzyCompare(seen_progress.at(1), 0.75));
+ QCOMPARE(seen_heights.at(0), 123);
+ QCOMPARE(seen_heights.at(1), 456);
+ QCOMPARE(model.blockTipHeight(), 456);
+ QVERIFY(qFuzzyCompare(model.verificationProgress(), 0.75));
+}
+
+int RunNodeModelTests(int argc, char* argv[])
+{
+ NodeModelTests tc;
+ return QTest::qExec(&tc, argc, argv);
+}
+
+#include
From f340044bb6beb60a6593bdf174d1cdb9173ec3b8 Mon Sep 17 00:00:00 2001
From: johnny9 <985648+johnny9@users.noreply.github.com>
Date: Sun, 26 Apr 2026 01:58:15 -0400
Subject: [PATCH 3/3] test: add BlockClock component coverage
BlockClock state depends on node and chain context objects while the dial itself is rendered by a QQuickPaintedItem. Add injected model refs and stable object names so QML tests and the automation bridge can drive the component deterministically.
Cover the dial painter with sampled-pixel unit tests, BlockClock state/format/toggle behavior with QML mocks, and the stable no-peer regtest path with a functional CONNECTING/PAUSE smoke test. Keep IBD, synced, and error permutations in unit/QML coverage instead of making the functional test timing-sensitive.
---
qml/components/BlockClock.qml | 52 +++---
qml/components/NetworkIndicator.qml | 3 +-
test/CMakeLists.txt | 1 +
test/functional/qml_test_blockclock.py | 53 ++++++
test/functional/qml_test_harness.py | 3 +-
.../qml/org/bitcoincore/qt/BlockClockDial.qml | 22 +++
test/mocks/qml/org/bitcoincore/qt/qmldir | 1 +
test/qml/tst_blockclock.qml | 172 ++++++++++++++++++
test/test_blockclockdial.cpp | 159 ++++++++++++++++
9 files changed, 443 insertions(+), 23 deletions(-)
create mode 100644 test/functional/qml_test_blockclock.py
create mode 100644 test/mocks/qml/org/bitcoincore/qt/BlockClockDial.qml
create mode 100644 test/qml/tst_blockclock.qml
create mode 100644 test/test_blockclockdial.cpp
diff --git a/qml/components/BlockClock.qml b/qml/components/BlockClock.qml
index 0f9de721c6..c9ce6c0334 100644
--- a/qml/components/BlockClock.qml
+++ b/qml/components/BlockClock.qml
@@ -14,24 +14,27 @@ import "../controls/utils.js" as Utils
Item {
id: root
+ objectName: "blockClock"
property real parentWidth: 600
property real parentHeight: 600
property bool showNetworkIndicator: true
+ property var nodeModelRef: typeof nodeModel !== "undefined" ? nodeModel : null
+ property var chainModelRef: typeof chainModel !== "undefined" ? chainModel : null
width: dial.width
- height: dial.height + networkIndicator.height + networkIndicator.anchors.topMargin
+ height: dial.height + (networkIndicator.visible ? networkIndicator.height + networkIndicator.anchors.topMargin : 0)
property alias header: mainText.text
property alias headerSize: mainText.font.pixelSize
property alias subText: subText.text
- property bool connected: nodeModel.numOutboundPeers > 0
- property bool synced: nodeModel.verificationProgress > 0.999
- property string syncProgress: formatProgressPercentage(nodeModel.verificationProgress * 100)
- property bool paused: false
- property var syncState: Utils.formatRemainingSyncTime(nodeModel.remainingSyncTime)
+ property bool connected: root.nodeModelRef !== null && root.nodeModelRef.numOutboundPeers > 0
+ property bool synced: root.nodeModelRef !== null && root.nodeModelRef.verificationProgress > 0.999
+ property string syncProgress: formatProgressPercentage((root.nodeModelRef !== null ? root.nodeModelRef.verificationProgress : 0) * 100)
+ property bool paused: root.nodeModelRef !== null && root.nodeModelRef.pause
+ property var syncState: Utils.formatRemainingSyncTime(root.nodeModelRef !== null ? root.nodeModelRef.remainingSyncTime : 0)
property string syncTime: syncState.text
property bool estimating: syncState.estimating
- property bool faulted: nodeModel.faulted
+ property bool faulted: root.nodeModelRef !== null && root.nodeModelRef.faulted
activeFocusOnTab: true
@@ -48,11 +51,11 @@ Item {
Math.min((root.parentWidth * dial.scale), (root.parentHeight * dial.scale)))}
height: dial.width
penWidth: dial.width / 50
- timeRatioList: chainModel.timeRatioList
- verificationProgress: nodeModel.verificationProgress
+ timeRatioList: root.chainModelRef !== null ? root.chainModelRef.timeRatioList : []
+ verificationProgress: root.nodeModelRef !== null ? root.nodeModelRef.verificationProgress : 0
paused: root.paused || root.faulted
connected: root.connected
- synced: nodeModel.verificationProgress > 0.999
+ synced: root.synced
backgroundColor: Theme.color.neutral2
timeTickColor: Theme.color.neutral5
confirmationColors: Theme.color.confirmationColors
@@ -139,8 +142,8 @@ Item {
anchors.top: subText.bottom
anchors.topMargin: dial.width / 10
anchors.horizontalCenter: root.horizontalCenter
- numOutboundPeers: nodeModel.numOutboundPeers
- maxNumOutboundPeers: nodeModel.maxNumOutboundPeers
+ numOutboundPeers: root.nodeModelRef !== null ? root.nodeModelRef.numOutboundPeers : 0
+ maxNumOutboundPeers: root.nodeModelRef !== null ? Math.max(root.nodeModelRef.maxNumOutboundPeers, 1) : 1
indicatorDimensions: dial.width * (3/200)
indicatorSpacing: dial.width / 40
paused: root.paused || root.faulted
@@ -148,22 +151,23 @@ Item {
NetworkIndicator {
id: networkIndicator
+ objectName: "blockClockNetworkIndicator"
show: root.showNetworkIndicator
+ chainModelRef: root.chainModelRef
anchors.top: dial.bottom
anchors.topMargin: networkIndicator.visible ? 30 : 0
anchors.horizontalCenter: root.horizontalCenter
}
MouseArea {
+ objectName: "blockClockToggleArea"
anchors.fill: dial
cursorShape: root.faulted ? Qt.ArrowCursor : Qt.PointingHandCursor
enabled: !root.faulted
- onClicked: {
- if (!root.faulted) {
- root.paused = !root.paused
- nodeModel.pause = root.paused
- }
+ function click() {
+ root.togglePause()
}
+ onClicked: click()
FocusBorder {
visible: root.activeFocus
}
@@ -171,7 +175,7 @@ Item {
states: [
State {
- name: "IBD"; when: !synced && !paused && connected
+ name: "IBD"; when: !faulted && !synced && !paused && connected
PropertyChanges {
target: root
header: root.syncProgress
@@ -180,10 +184,10 @@ Item {
},
State {
- name: "BLOCKCLOCK"; when: synced && !paused && connected
+ name: "BLOCKCLOCK"; when: !faulted && synced && !paused && connected
PropertyChanges {
target: root
- header: Number(nodeModel.blockTipHeight).toLocaleString(Qt.locale(), 'f', 0)
+ header: Number(root.nodeModelRef !== null ? root.nodeModelRef.blockTipHeight : 0).toLocaleString(Qt.locale(), 'f', 0)
subText: "Blocktime"
estimating: false
}
@@ -223,7 +227,7 @@ Item {
},
State {
- name: "CONNECTING"; when: !paused && !connected
+ name: "CONNECTING"; when: !faulted && !paused && !connected
PropertyChanges {
target: root
header: qsTr("Connecting")
@@ -254,4 +258,10 @@ Item {
return "0%"
}
}
+
+ function togglePause() {
+ if (!root.faulted && root.nodeModelRef !== null) {
+ root.nodeModelRef.pause = !root.paused
+ }
+ }
}
diff --git a/qml/components/NetworkIndicator.qml b/qml/components/NetworkIndicator.qml
index 7743822e7a..23fe45d498 100644
--- a/qml/components/NetworkIndicator.qml
+++ b/qml/components/NetworkIndicator.qml
@@ -14,12 +14,13 @@ Button {
property color bgColor
property bool shorten: false
property bool show: true
+ property var chainModelRef: typeof chainModel !== "undefined" ? chainModel : null
property int textSize: 15
topPadding: 2
bottomPadding: 2
leftPadding: 7
rightPadding: 7
- state: show ? chainModel.currentNetworkName : "MAIN"
+ state: show && root.chainModelRef !== null ? root.chainModelRef.currentNetworkName : "MAIN"
contentItem: CoreText {
text: root.text
font.pixelSize: root.textSize
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index a79f4eeec9..9a7b2a7f1a 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -13,6 +13,7 @@ add_executable(bitcoinqml_unit_tests
test_unit_tests_main.cpp
gmocktestfixture.h
test_bitcoinaddress.cpp
+ test_blockclockdial.cpp
test_bitcoinamount.cpp
test_peerlistmodel.cpp
test_peerstatsutil.cpp
diff --git a/test/functional/qml_test_blockclock.py b/test/functional/qml_test_blockclock.py
new file mode 100644
index 0000000000..adb5d2bfd2
--- /dev/null
+++ b/test/functional/qml_test_blockclock.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python3
+# Copyright (c) 2026 The Bitcoin Core developers
+# Distributed under the MIT software license, see the accompanying
+# file COPYING or http://www.opensource.org/licenses/mit-license.php.
+"""Functional smoke coverage for the BlockClock page."""
+
+import sys
+
+from qml_test_harness import QmlTestHarness, complete_onboarding, dump_qml_tree, parse_args
+
+
+def run_tests():
+ args = parse_args()
+ harness = QmlTestHarness(
+ socket_path=args.socket_path,
+ extra_args=[] if args.socket_path else ["-disablewallet"],
+ )
+ gui = None
+ try:
+ harness.start()
+ gui = harness.driver
+
+ complete_onboarding(gui)
+ gui.wait_for_page("blockClock", timeout_ms=10000)
+
+ gui.wait_for_property("blockClock", "state", "CONNECTING", timeout_ms=10000)
+ assert gui.get_property("blockClock", "header") == "Connecting"
+ assert gui.get_property("blockClock", "subText") == "Please wait"
+
+ gui.click("blockClockToggleArea")
+ gui.wait_for_property("blockClock", "state", "PAUSE", timeout_ms=5000)
+ assert gui.get_property("blockClock", "header") == "Paused"
+ assert gui.get_property("blockClock", "subText") == "Tap to resume"
+
+ gui.click("blockClockToggleArea")
+ gui.wait_for_property("blockClock", "state", "CONNECTING", timeout_ms=5000)
+ assert gui.get_property("blockClock", "header") == "Connecting"
+ assert gui.get_property("blockClock", "subText") == "Please wait"
+
+ print("BlockClock smoke test PASSED")
+ except Exception as err:
+ print(f"\nFAILED: {err}", file=sys.stderr)
+ import traceback
+ traceback.print_exc()
+ if gui is not None:
+ dump_qml_tree(gui)
+ sys.exit(1)
+ finally:
+ harness.stop()
+
+
+if __name__ == "__main__":
+ run_tests()
diff --git a/test/functional/qml_test_harness.py b/test/functional/qml_test_harness.py
index a7c489ba5d..7b12b889ee 100755
--- a/test/functional/qml_test_harness.py
+++ b/test/functional/qml_test_harness.py
@@ -131,7 +131,8 @@ def start(self):
"-debugexclude=libevent",
"-debugexclude=leveldb",
"-nolisten",
- ] + self.extra_args
+ ]
+ args.extend(self.extra_args)
print(f"Starting GUI: {' '.join(args)}")
self.process = subprocess.Popen(
diff --git a/test/mocks/qml/org/bitcoincore/qt/BlockClockDial.qml b/test/mocks/qml/org/bitcoincore/qt/BlockClockDial.qml
new file mode 100644
index 0000000000..5cb0fb1cce
--- /dev/null
+++ b/test/mocks/qml/org/bitcoincore/qt/BlockClockDial.qml
@@ -0,0 +1,22 @@
+// Copyright (c) 2026 The Bitcoin Core developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+import QtQuick 2.15
+
+Item {
+ property var timeRatioList: []
+ property real verificationProgress: 0
+ property bool connected: false
+ property bool synced: false
+ property bool paused: false
+ property bool animateDial: true
+ property int connectingAnimationDelayMs: 5000
+ property bool showTimeTicks: true
+ property bool showBlockSegments: true
+ property bool useGradientArcWhenSynced: false
+ property real penWidth: 4
+ property color backgroundColor: "transparent"
+ property var confirmationColors: []
+ property color timeTickColor: "transparent"
+}
diff --git a/test/mocks/qml/org/bitcoincore/qt/qmldir b/test/mocks/qml/org/bitcoincore/qt/qmldir
index 56f01a045b..06f291d8a6 100644
--- a/test/mocks/qml/org/bitcoincore/qt/qmldir
+++ b/test/mocks/qml/org/bitcoincore/qt/qmldir
@@ -1,2 +1,3 @@
module org.bitcoincore.qt
singleton AppMode 1.0 AppMode.qml
+BlockClockDial 1.0 BlockClockDial.qml
diff --git a/test/qml/tst_blockclock.qml b/test/qml/tst_blockclock.qml
new file mode 100644
index 0000000000..dcfc127a90
--- /dev/null
+++ b/test/qml/tst_blockclock.qml
@@ -0,0 +1,172 @@
+// Copyright (c) 2026 The Bitcoin Core developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+import QtQuick 2.15
+import QtTest 1.2
+import "../../qml/components"
+
+TestCase {
+ name: "BlockClock"
+ when: windowShown
+ width: 800
+ height: 700
+
+ QtObject {
+ id: nodeModelMock
+ property int blockTipHeight: 0
+ property int numOutboundPeers: 0
+ property int maxNumOutboundPeers: 10
+ property int remainingSyncTime: 0
+ property real verificationProgress: 0
+ property bool pause: false
+ property bool faulted: false
+ }
+
+ QtObject {
+ id: chainModelMock
+ property var timeRatioList: [0.25, 0.0]
+ property string currentNetworkName: "REGTEST"
+ }
+
+ Component {
+ id: blockClockComponent
+ BlockClock {
+ parentWidth: 600
+ parentHeight: 600
+ nodeModelRef: nodeModelMock
+ chainModelRef: chainModelMock
+ }
+ }
+
+ function resetMocks() {
+ nodeModelMock.blockTipHeight = 0
+ nodeModelMock.numOutboundPeers = 0
+ nodeModelMock.maxNumOutboundPeers = 10
+ nodeModelMock.remainingSyncTime = 0
+ nodeModelMock.verificationProgress = 0
+ nodeModelMock.pause = false
+ nodeModelMock.faulted = false
+ chainModelMock.timeRatioList = [0.25, 0.0]
+ chainModelMock.currentNetworkName = "REGTEST"
+ }
+
+ function createClock(properties) {
+ const clock = createTemporaryObject(blockClockComponent, this, properties || {})
+ verify(clock !== null)
+ wait(0)
+ return clock
+ }
+
+ function toggleArea(clock) {
+ const area = findChild(clock, "blockClockToggleArea")
+ verify(area !== null)
+ return area
+ }
+
+ function clickToggle(clock) {
+ clock.togglePause()
+ wait(0)
+ }
+
+ function test_connecting_state() {
+ resetMocks()
+ const clock = createClock()
+
+ compare(clock.objectName, "blockClock")
+ compare(clock.state, "CONNECTING")
+ compare(clock.header, "Connecting")
+ compare(clock.subText, "Please wait")
+ }
+
+ function test_ibd_state() {
+ resetMocks()
+ nodeModelMock.numOutboundPeers = 1
+ nodeModelMock.verificationProgress = 0.51
+ nodeModelMock.remainingSyncTime = 360000
+
+ const clock = createClock()
+
+ compare(clock.state, "IBD")
+ compare(clock.header, "51%")
+ compare(clock.subText, "~6 minutes left")
+ }
+
+ function test_blockclock_state() {
+ resetMocks()
+ nodeModelMock.numOutboundPeers = 1
+ nodeModelMock.verificationProgress = 1.0
+ nodeModelMock.blockTipHeight = 123456
+
+ const clock = createClock()
+
+ compare(clock.state, "BLOCKCLOCK")
+ compare(clock.header, Number(nodeModelMock.blockTipHeight).toLocaleString(Qt.locale(), "f", 0))
+ compare(clock.subText, "Blocktime")
+ }
+
+ function test_pause_state() {
+ resetMocks()
+ nodeModelMock.pause = true
+
+ const clock = createClock()
+
+ compare(clock.state, "PAUSE")
+ compare(clock.header, "Paused")
+ compare(clock.subText, "Tap to resume")
+ }
+
+ function test_error_state_overrides_and_disables_toggle() {
+ resetMocks()
+ nodeModelMock.numOutboundPeers = 1
+ nodeModelMock.verificationProgress = 1.0
+ nodeModelMock.faulted = true
+
+ const clock = createClock()
+ const area = toggleArea(clock)
+
+ compare(clock.state, "ERROR")
+ compare(clock.header, "Error")
+ clickToggle(clock)
+ compare(nodeModelMock.pause, false)
+ compare(clock.state, "ERROR")
+ }
+
+ function test_click_toggles_pause_model() {
+ resetMocks()
+ const clock = createClock()
+ toggleArea(clock)
+
+ clickToggle(clock)
+ compare(nodeModelMock.pause, true)
+ compare(clock.state, "PAUSE")
+
+ clickToggle(clock)
+ compare(nodeModelMock.pause, false)
+ compare(clock.state, "CONNECTING")
+ }
+
+ function test_formatProgressPercentage_thresholds() {
+ resetMocks()
+ const clock = createClock()
+
+ compare(clock.formatProgressPercentage(51), "51%")
+ compare(clock.formatProgressPercentage(0.51), "0.5%")
+ compare(clock.formatProgressPercentage(0.051), "0.05%")
+ compare(clock.formatProgressPercentage(0.001), "0%")
+ }
+
+ function test_hidden_network_indicator_removes_extra_height() {
+ resetMocks()
+ let clock = createClock({ showNetworkIndicator: true })
+ let indicator = findChild(clock, "blockClockNetworkIndicator")
+ verify(indicator !== null)
+ tryCompare(indicator, "state", "REGTEST")
+
+ clock = createClock({ showNetworkIndicator: false })
+ indicator = findChild(clock, "blockClockNetworkIndicator")
+ verify(indicator !== null)
+ verify(!indicator.visible)
+ compare(clock.height, clock.width)
+ }
+}
diff --git a/test/test_blockclockdial.cpp b/test/test_blockclockdial.cpp
new file mode 100644
index 0000000000..da84b38f28
--- /dev/null
+++ b/test/test_blockclockdial.cpp
@@ -0,0 +1,159 @@
+// Copyright (c) 2026 The Bitcoin Core developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+#include
+
+#include
+
+#include
+#include
+#include
+
+#include
+
+namespace {
+constexpr int DIAL_SIZE{120};
+const QColor BACKGROUND_COLOR{QStringLiteral("#202020")};
+const QList CONFIRMATION_COLORS{
+ QColor{QStringLiteral("#FF1C1C")},
+ QColor{QStringLiteral("#ED6E46")},
+ QColor{QStringLiteral("#EE8847")},
+ QColor{QStringLiteral("#EFA148")},
+ QColor{QStringLiteral("#F0BB49")},
+ QColor{QStringLiteral("#F1D54A")},
+};
+
+int ColorDistance(const QColor& a, const QColor& b)
+{
+ return std::abs(a.red() - b.red()) +
+ std::abs(a.green() - b.green()) +
+ std::abs(a.blue() - b.blue());
+}
+
+void ConfigureDial(BlockClockDial& dial)
+{
+ dial.setWidth(DIAL_SIZE);
+ dial.setHeight(DIAL_SIZE);
+ dial.setPenWidth(12);
+ dial.setBackgroundColor(BACKGROUND_COLOR);
+ dial.setConfirmationColors(CONFIRMATION_COLORS);
+ dial.setTimeTickColor(Qt::black);
+ dial.setShowTimeTicks(false);
+}
+
+QImage RenderDial(BlockClockDial& dial)
+{
+ QImage image{DIAL_SIZE, DIAL_SIZE, QImage::Format_ARGB32_Premultiplied};
+ image.fill(Qt::transparent);
+
+ QPainter painter{&image};
+ dial.paint(&painter);
+ return image;
+}
+
+int CountConfirmationPixels(const QImage& image)
+{
+ int count{0};
+ for (int y{0}; y < image.height(); ++y) {
+ for (int x{0}; x < image.width(); ++x) {
+ const QColor pixel{image.pixelColor(x, y)};
+ if (pixel.alpha() == 0) continue;
+ for (const QColor& confirmation_color : CONFIRMATION_COLORS) {
+ if (ColorDistance(pixel, confirmation_color) < 80) {
+ ++count;
+ break;
+ }
+ }
+ }
+ }
+ return count;
+}
+} // namespace
+
+class BlockClockDialTests : public QObject
+{
+ Q_OBJECT
+
+private Q_SLOTS:
+ void ibdProgressRendersImmediateHalfArc();
+ void syncedGradientToggleChangesRenderedColors();
+ void connectingDelayControlsInitialAnimation();
+};
+
+void BlockClockDialTests::ibdProgressRendersImmediateHalfArc()
+{
+ BlockClockDial dial;
+ ConfigureDial(dial);
+ dial.setAnimateDial(false);
+ dial.setConnected(true);
+ dial.setSynced(false);
+ dial.setVerificationProgress(0.5);
+
+ const QImage image{RenderDial(dial)};
+ const QColor active_pixel{image.pixelColor(QPoint{DIAL_SIZE - 7, DIAL_SIZE / 2})};
+ const QColor inactive_pixel{image.pixelColor(QPoint{7, DIAL_SIZE / 2})};
+
+ QVERIFY2(ColorDistance(active_pixel, CONFIRMATION_COLORS[5]) < 35,
+ qPrintable(QStringLiteral("expected active IBD arc pixel, got %1").arg(active_pixel.name(QColor::HexArgb))));
+ QVERIFY2(ColorDistance(inactive_pixel, BACKGROUND_COLOR) < 15,
+ qPrintable(QStringLiteral("expected background outside IBD arc, got %1").arg(inactive_pixel.name(QColor::HexArgb))));
+}
+
+void BlockClockDialTests::syncedGradientToggleChangesRenderedColors()
+{
+ BlockClockDial dial;
+ ConfigureDial(dial);
+ dial.setAnimateDial(false);
+ dial.setConnected(true);
+ dial.setSynced(true);
+ dial.setShowBlockSegments(false);
+ dial.setTimeRatioList({1.0, 0.0});
+
+ dial.setUseGradientArcWhenSynced(false);
+ const QImage uniform_image{RenderDial(dial)};
+ const QColor uniform_right{uniform_image.pixelColor(QPoint{DIAL_SIZE - 7, DIAL_SIZE / 2})};
+ const QColor uniform_bottom{uniform_image.pixelColor(QPoint{DIAL_SIZE / 2, DIAL_SIZE - 7})};
+ QVERIFY2(ColorDistance(uniform_right, CONFIRMATION_COLORS[5]) < 35,
+ qPrintable(QStringLiteral("expected uniform synced arc color, got %1").arg(uniform_right.name(QColor::HexArgb))));
+ QVERIFY2(ColorDistance(uniform_bottom, CONFIRMATION_COLORS[5]) < 35,
+ qPrintable(QStringLiteral("expected uniform synced arc color, got %1").arg(uniform_bottom.name(QColor::HexArgb))));
+
+ dial.setUseGradientArcWhenSynced(true);
+ const QImage gradient_image{RenderDial(dial)};
+ const QColor gradient_right{gradient_image.pixelColor(QPoint{DIAL_SIZE - 7, DIAL_SIZE / 2})};
+ const QColor gradient_bottom{gradient_image.pixelColor(QPoint{DIAL_SIZE / 2, DIAL_SIZE - 7})};
+ QVERIFY2(ColorDistance(gradient_right, gradient_bottom) > 25,
+ qPrintable(QStringLiteral("expected gradient samples to differ, got %1 and %2")
+ .arg(gradient_right.name(QColor::HexArgb), gradient_bottom.name(QColor::HexArgb))));
+}
+
+void BlockClockDialTests::connectingDelayControlsInitialAnimation()
+{
+ BlockClockDial delayed_dial;
+ ConfigureDial(delayed_dial);
+ delayed_dial.setConnected(false);
+ delayed_dial.setAnimateDial(true);
+ delayed_dial.setConnectingAnimationDelayMs(5000);
+ QCOMPARE(CountConfirmationPixels(RenderDial(delayed_dial)), 0);
+
+ BlockClockDial immediate_dial;
+ ConfigureDial(immediate_dial);
+ immediate_dial.setConnected(false);
+ immediate_dial.setAnimateDial(true);
+ immediate_dial.setConnectingAnimationDelayMs(0);
+
+ RenderDial(immediate_dial);
+ QVERIFY(CountConfirmationPixels(RenderDial(immediate_dial)) > 0);
+}
+
+int RunBlockClockDialTests(int argc, char* argv[])
+{
+ BlockClockDialTests tests;
+ return QTest::qExec(&tests, argc, argv);
+}
+
+#ifndef BITCOINQML_NO_TEST_MAIN
+QTEST_MAIN(BlockClockDialTests)
+#endif
+#include "test_blockclockdial.moc"