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/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/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/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/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/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/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")
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index 3b7ed7d38b..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
@@ -20,6 +21,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/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"
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