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