diff --git a/qml/bitcoin_qml.qrc b/qml/bitcoin_qml.qrc
index 2fa5dc9e04..821956c55e 100644
--- a/qml/bitcoin_qml.qrc
+++ b/qml/bitcoin_qml.qrc
@@ -13,7 +13,8 @@
components/BlockCounter.qml
components/ConnectionOptions.qml
components/ConnectionSettings.qml
- components/DebugLogOutputView.qml
+ components/DebugLogItemRow.qml
+ components/DebugLogTitlesHeader.qml
components/DesktopMenuActions.qml
components/DesktopNativeMenuBar.qml
components/MempoolInformationRows.qml
@@ -50,6 +51,7 @@
components/PaymentDetailOptionsPopup.qml
components/QRCodePopup.qml
components/ReceiveOptionsPopup.qml
+ components/SearchBar.qml
components/Tooltip.qml
components/LabeledValueField.qml
components/LabeledBitcoinAddressField.qml
@@ -74,6 +76,7 @@
controls/CoreTextField.qml
controls/EditableKeyValueRow.qml
controls/ExternalLink.qml
+ controls/FilterButton.qml
controls/FocusBorder.qml
controls/FormRow.qml
controls/FormSection.qml
@@ -96,6 +99,7 @@
controls/OptionButton.qml
controls/OptionSwitch.qml
controls/OutlineButton.qml
+ controls/OverflowMenuButton.qml
controls/PageIndicator.qml
controls/PageHeading.qml
controls/PageStack.qml
@@ -141,7 +145,7 @@
pages/settings/SettingsAbout.qml
pages/settings/SettingsLanguage.qml
pages/settings/SettingsConnection.qml
- pages/settings/SettingsDebugLog.qml
+ pages/settings/SettingsDebugLogView.qml
pages/settings/SettingsDesignSystem.qml
pages/settings/SettingsDeveloper.qml
pages/settings/SettingsProxy.qml
@@ -153,7 +157,7 @@
pages/settings/MempoolSettingsPage.qml
pages/settings/NetworkTrafficSettingsPage.qml
pages/settings/ProxySettingsPage.qml
- pages/settings/RpcConsoleSettingsPage.qml
+ pages/settings/SettingsRpcConsoleView.qml
pages/settings/StorageSettingsPage.qml
pages/settings/WalletSectionPage.qml
pages/settings/WindowBehaviorSettingsPage.qml
@@ -211,6 +215,7 @@
res/icons/coinbase.png
res/icons/console.png
res/icons/cross.png
+ res/icons/cross-circle-filled.png
res/icons/cross-filled.png
res/icons/devices-filled.png
res/icons/edit.png
@@ -218,6 +223,8 @@
res/icons/error.png
res/icons/export.png
res/icons/file.png
+ res/icons/filter.png
+ res/icons/filter-active.png
res/icons/flip-vertical.png
res/icons/gear.png
res/icons/gear-outline.png
diff --git a/qml/components/DebugLogItemRow.qml b/qml/components/DebugLogItemRow.qml
new file mode 100644
index 0000000000..083be22c15
--- /dev/null
+++ b/qml/components/DebugLogItemRow.qml
@@ -0,0 +1,99 @@
+// 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 QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+
+import "../controls"
+
+Control {
+ id: root
+
+ property string timestamp: ""
+ property string message: ""
+ property bool isError: false
+ property bool isWarning: false
+ property bool alternate: false
+
+ property int typeColumnWidth: 32
+ property int timeColumnWidth: 80
+
+ readonly property color indicatorColor: isError
+ ? Theme.color.red
+ : isWarning ? Theme.color.amber : "transparent"
+ readonly property string typeLabel: isError
+ ? qsTr("Error")
+ : isWarning ? qsTr("Warning") : qsTr("Regular")
+
+ Accessible.role: Accessible.ListItem
+ Accessible.name: typeLabel + " " + timestamp + " " + message
+
+ implicitHeight: Math.max(48, messageText.contentHeight + 24)
+ padding: 0
+
+ background: Rectangle {
+ color: root.alternate ? Theme.color.neutral2 : Theme.color.neutral1
+
+ Behavior on color { ColorAnimation { duration: 150 } }
+ }
+
+ contentItem: RowLayout {
+ spacing: 0
+
+ Item { Layout.preferredWidth: 12 }
+
+ Item {
+ Layout.preferredWidth: root.typeColumnWidth
+ Layout.fillHeight: true
+
+ Rectangle {
+ id: typeIndicator
+ objectName: root.objectName.length > 0 ? root.objectName + "TypeIndicator" : ""
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ width: 8
+ height: 8
+ radius: width / 2
+ color: root.indicatorColor
+ }
+ }
+
+ CoreText {
+ id: timeText
+ objectName: root.objectName.length > 0 ? root.objectName + "Time" : ""
+ text: root.timestamp.length > 0 ? root.timestamp : "—"
+ color: Theme.color.neutral7
+ font.family: Theme.text.monoFamily
+ font.pixelSize: 11
+ horizontalAlignment: Text.AlignLeft
+ elide: Text.ElideRight
+ Layout.preferredWidth: root.timeColumnWidth
+ Layout.alignment: Qt.AlignTop
+ Layout.topMargin: 14
+ }
+
+ TextEdit {
+ id: messageText
+ objectName: root.objectName.length > 0 ? root.objectName + "Message" : ""
+ text: root.message
+ readOnly: true
+ selectByMouse: true
+ persistentSelection: false
+ textFormat: Text.PlainText
+ wrapMode: Text.WrapAnywhere
+ color: Theme.color.neutral9
+ selectionColor: Theme.color.orange
+ selectedTextColor: Theme.color.white
+ font: Theme.text.monoCaption.font
+ horizontalAlignment: Text.AlignLeft
+ Layout.fillWidth: true
+ Layout.minimumWidth: 0
+ Layout.alignment: Qt.AlignTop
+ Layout.topMargin: 14
+ }
+
+ Item { Layout.preferredWidth: 16 }
+ }
+}
diff --git a/qml/components/DebugLogOutputView.qml b/qml/components/DebugLogOutputView.qml
deleted file mode 100644
index ae7f16bf6a..0000000000
--- a/qml/components/DebugLogOutputView.qml
+++ /dev/null
@@ -1,415 +0,0 @@
-// 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 QtQuick.Controls 2.15
-import QtQuick.Layouts 1.15
-import org.bitcoincore.qt 1.0
-import "../controls"
-
-Item {
- id: root
-
- Accessible.role: Accessible.List
- Accessible.name: accessibleName
-
- property var listModel: null
- property string accessibleName: ""
-
- property int horizontalPadding: 0
- property int topPadding: 10
- property int bottomPadding: 16
- property int rowSpacing: 10
- property int columnSpacing: 10
- property int contentSpacing: 2
- property int lineNumberWidth: 20
- property int fontPixelSize: 12
- property int textLineHeight: 17
- property string fontFamily: Theme.text.family
- property string fontStyleName: "Regular"
- property bool autoScrollToBottom: false
-
- readonly property int lineNumberDigits: String(Math.max(1, count)).length
- readonly property string lineNumberSampleText: lineNumberDigits <= 3
- ? ""
- : lineNumberDigits === 4
- ? "8888"
- : "88888"
- readonly property int effectiveLineNumberWidth: lineNumberDigits <= 3
- ? lineNumberWidth
- : Math.max(lineNumberWidth, Math.ceil(lineNumberMetrics.advanceWidth))
- readonly property bool atBottom: list.atYEnd
- readonly property bool atTop: list.atYBeginning
- readonly property real contentY: list.contentY
- readonly property real contentHeight: list.contentHeight
- readonly property real originY: list.originY
- readonly property int count: list.count
- readonly property int instantiatedDelegateCount: root._instantiatedDelegateCount
-
- // These values describe the topmost visible row immediately before a
- // newest-first insertion at row zero. Once the insertion completes, that
- // row has moved down by the size of the inserted batch. Restoring its
- // pixel offset keeps the text under the user's eyes stationary.
- property int _prependAnchorIndex: -1
- property real _prependAnchorOffset: 0
- property int _prependCount: 0
- property bool _prependRestorePending: false
- property int _prependRestoreGeneration: 0
- property real _appendAnchorContentY: 0
- property int _appendCount: 0
- property bool _appendRestorePending: false
- property int _appendRestoreGeneration: 0
- property int _instantiatedDelegateCount: 0
-
- signal scrolled(real y)
-
- function scrollToTop() {
- list.positionViewAtBeginning()
- list.returnToBounds()
- }
-
- function scrollToBottom() {
- list.forceLayout()
- list.positionViewAtEnd()
- // positionViewAtEnd() aligns the final delegate, but ListView's
- // bottomMargin sits beyond that delegate. Include it so atYEnd is true
- // and the external Load more affordance becomes available.
- if (list.contentHeight + list.bottomMargin > list.height) {
- list.contentY = list.originY + list.contentHeight + list.bottomMargin - list.height
- }
- list.returnToBounds()
- }
-
- function positionViewAtIndex(index, mode) {
- list.positionViewAtIndex(index, mode === undefined ? ListView.Visible : mode)
- }
-
- function itemAtIndex(index) {
- return list.itemAtIndex(index)
- }
-
- function forceLayout() {
- list.forceLayout()
- }
-
- function _firstVisibleIndex() {
- // contentY can fall in the spacing between two variable-height rows.
- // Scan a small distance into the viewport rather than treating that
- // gap as if the view had no visible anchor.
- // ListView's origin can move away from zero as variable-height rows are
- // inserted or removed. indexAt() expects content coordinates, so use
- // contentY directly rather than treating zero as the logical start.
- const firstY = list.contentY
- const scanDistance = Math.min(list.height, root.rowSpacing + root.textLineHeight + 2)
- for (let offset = 0; offset <= scanDistance; ++offset) {
- const candidate = list.indexAt(1, firstY + offset)
- if (candidate >= 0) return candidate
- }
- return -1
- }
-
- function _capturePrependAnchor(first, last) {
- root._prependAnchorIndex = -1
- root._prependCount = 0
-
- if (first !== 0) return
-
- // A full snapshot diff can publish an older suffix before its newer
- // prefix. Restore the pre-append viewport synchronously so the prepend
- // anchor is captured from what the user was actually looking at.
- root._restorePendingAppendAnchor()
- if (list.count === 0 || root.atTop) return
-
- list.forceLayout()
- const anchorIndex = root._firstVisibleIndex()
- if (anchorIndex < 0) return
-
- const anchorItem = list.itemAtIndex(anchorIndex)
- if (!anchorItem) return
-
- root._prependAnchorIndex = anchorIndex
- root._prependAnchorOffset = anchorItem.y - list.contentY
- root._prependCount = last - first + 1
- }
-
- function _schedulePrependAnchorRestore(first, last) {
- if (first !== 0 || root._prependAnchorIndex < 0 || root._prependCount !== last - first + 1) {
- root._prependAnchorIndex = -1
- root._prependCount = 0
- return
- }
-
- const targetIndex = root._prependAnchorIndex + root._prependCount
- const targetOffset = root._prependAnchorOffset
- root._prependAnchorIndex = -1
- root._prependCount = 0
- root._prependRestorePending = true
- const generation = ++root._prependRestoreGeneration
- ++root._appendRestoreGeneration
- root._appendRestorePending = false
- Qt.callLater(function() {
- if (generation !== root._prependRestoreGeneration) return
- root._restorePrependAnchor(targetIndex, targetOffset)
- root._prependRestorePending = false
- })
- }
-
- function _restorePrependAnchor(targetIndex, targetOffset) {
- if (targetIndex < 0 || list.count === 0) return
-
- const boundedIndex = Math.min(targetIndex, list.count - 1)
- list.forceLayout()
- list.positionViewAtIndex(boundedIndex, ListView.Beginning)
- list.forceLayout()
-
- const anchorItem = list.itemAtIndex(boundedIndex)
- if (!anchorItem) return
-
- list.contentY = anchorItem.y - targetOffset
- list.returnToBounds()
- }
-
- function _captureAppendAnchor(first, last) {
- root._appendCount = 0
- if (first !== list.count || first === 0 || root._prependRestorePending) return
-
- // Coalesce multiple suffix batches in the same event turn around the
- // viewport that preceded all of them.
- root._restorePendingAppendAnchor()
- root._appendAnchorContentY = list.contentY
- root._appendCount = last - first + 1
- }
-
- function _scheduleAppendAnchorRestore(first, last) {
- if (root._appendCount === 0) return
-
- if (root._appendCount !== last - first + 1) {
- root._appendCount = 0
- return
- }
-
- const anchoredContentY = root._appendAnchorContentY
- root._appendCount = 0
- root._appendRestorePending = true
- const generation = ++root._appendRestoreGeneration
- Qt.callLater(function() {
- if (generation !== root._appendRestoreGeneration || root._prependRestorePending) return
- list.forceLayout()
- list.contentY = anchoredContentY
- list.returnToBounds()
- root._appendRestorePending = false
- })
- }
-
- function _restorePendingAppendAnchor() {
- if (!root._appendRestorePending) return
-
- ++root._appendRestoreGeneration
- root._appendRestorePending = false
- list.forceLayout()
- list.contentY = root._appendAnchorContentY
- list.returnToBounds()
- list.forceLayout()
- }
-
- TextMetrics {
- id: lineNumberMetrics
- font.family: root.fontFamily
- font.styleName: root.fontStyleName
- font.pixelSize: root.fontPixelSize
- text: root.lineNumberSampleText
- }
-
- ListView {
- id: list
- objectName: root.objectName.length > 0 ? root.objectName + "_list" : ""
- x: root.horizontalPadding
- width: Math.max(0, root.width - (root.horizontalPadding * 2))
- height: root.height
- clip: true
- model: root.listModel
- spacing: root.rowSpacing
- cacheBuffer: root.textLineHeight * 4
- // Text selection belongs to an individual row. Avoid carrying a
- // TextEdit's selection state into a different row through pooling;
- // ListView remains virtualized even without delegate reuse.
- reuseItems: false
- bottomMargin: root.bottomPadding
- boundsBehavior: Flickable.StopAtBounds
- // Keep the top padding inside the scrollable content, matching the
- // existing geometry (the first row starts at y=topPadding).
- header: Item {
- width: list.width
- height: root.topPadding
- }
- headerPositioning: ListView.InlineHeader
-
- ScrollBar.vertical: ScrollBar {
- policy: ScrollBar.AsNeeded
- minimumSize: 0.05
- }
-
- onContentYChanged: root.scrolled(contentY)
-
- delegate: RowLayout {
- id: rowRoot
-
- required property var model
- required property int index
-
- readonly property string rowCommand: rowRoot.model.command ?? ""
- readonly property string rowDate: rowRoot.model.dateLabel ?? ""
- readonly property string rowMessage: rowRoot.model.message ?? ""
- // The newest entry is always row one. Computing this from the
- // delegate index means a prepend does not require dataChanged for
- // every existing row merely to renumber it.
- readonly property string rowNumber: String(rowRoot.index + 1)
- readonly property int rowSeverity: Number(rowRoot.model.severity ?? DebugLogModel.InfoSeverity)
- readonly property bool hasCommand: rowCommand.length > 0
-
- objectName: root.objectName.length > 0 ? root.objectName + "_row_" + index : ""
- width: list.width
- height: implicitHeight
- spacing: root.columnSpacing
-
- Accessible.role: Accessible.ListItem
- Accessible.name: rowCommand.length > 0
- ? rowCommand + " " + rowMessage
- : rowMessage
-
- Component.onCompleted: ++root._instantiatedDelegateCount
- Component.onDestruction: --root._instantiatedDelegateCount
-
- Text {
- objectName: root.objectName.length > 0 ? root.objectName + "_lineNumber_" + rowRoot.index : ""
- text: rowRoot.rowNumber
- color: Theme.color.neutral7
- font.family: root.fontFamily
- font.styleName: root.fontStyleName
- font.pixelSize: root.fontPixelSize
- lineHeight: root.textLineHeight
- lineHeightMode: Text.FixedHeight
- horizontalAlignment: Text.AlignRight
- wrapMode: Text.NoWrap
-
- Layout.preferredWidth: root.effectiveLineNumberWidth
- Layout.alignment: Qt.AlignTop
- }
-
- ColumnLayout {
- objectName: root.objectName.length > 0 ? root.objectName + "_entryContent_" + rowRoot.index : ""
- spacing: root.contentSpacing
-
- Layout.fillWidth: true
- Layout.alignment: Qt.AlignTop
-
- RowLayout {
- objectName: root.objectName.length > 0 ? root.objectName + "_header_" + rowRoot.index : ""
- spacing: root.columnSpacing
-
- Layout.fillWidth: true
-
- Text {
- objectName: root.objectName.length > 0 ? root.objectName + "_command_" + rowRoot.index : ""
- text: rowRoot.rowCommand
- visible: rowRoot.hasCommand
- color: rowRoot.rowSeverity === DebugLogModel.ErrorSeverity
- ? Theme.color.red
- : Theme.color.green
- font.family: root.fontFamily
- font.styleName: root.fontStyleName
- font.pixelSize: root.fontPixelSize
- lineHeight: root.textLineHeight
- lineHeightMode: Text.FixedHeight
- elide: Text.ElideRight
- wrapMode: Text.NoWrap
-
- Layout.fillWidth: true
- Layout.alignment: Qt.AlignTop
- }
-
- TextEdit {
- objectName: root.objectName.length > 0 ? root.objectName + "_commandlessMessage_" + rowRoot.index : ""
- text: rowRoot.rowMessage
- visible: !rowRoot.hasCommand
- readOnly: true
- selectByMouse: true
- persistentSelection: false
- textFormat: Text.PlainText
- wrapMode: Text.WrapAnywhere
- font.family: root.fontFamily
- font.styleName: root.fontStyleName
- font.pixelSize: root.fontPixelSize
- color: Theme.color.neutral9
- selectionColor: Theme.color.orange
- selectedTextColor: Theme.color.white
- activeFocusOnPress: true
-
- Layout.fillWidth: true
- Layout.alignment: Qt.AlignTop
- }
-
- Text {
- objectName: root.objectName.length > 0 ? root.objectName + "_date_" + rowRoot.index : ""
- text: rowRoot.rowDate
- color: Theme.color.neutral7
- font.family: root.fontFamily
- font.styleName: root.fontStyleName
- font.pixelSize: root.fontPixelSize
- lineHeight: root.textLineHeight
- lineHeightMode: Text.FixedHeight
- horizontalAlignment: Text.AlignRight
- wrapMode: Text.NoWrap
-
- Layout.alignment: Qt.AlignTop
- }
- }
-
- TextEdit {
- objectName: root.objectName.length > 0 ? root.objectName + "_message_" + rowRoot.index : ""
- text: rowRoot.rowMessage
- visible: rowRoot.hasCommand
- readOnly: true
- selectByMouse: true
- persistentSelection: false
- textFormat: Text.PlainText
- wrapMode: Text.WrapAnywhere
- font.family: root.fontFamily
- font.styleName: root.fontStyleName
- font.pixelSize: root.fontPixelSize
- color: Theme.color.neutral9
- selectionColor: Theme.color.orange
- selectedTextColor: Theme.color.white
- activeFocusOnPress: true
-
- Layout.fillWidth: true
- Layout.alignment: Qt.AlignTop
- }
- }
- }
- }
-
- Connections {
- target: root.listModel
- enabled: root.listModel !== null
-
- function onRowsAboutToBeInserted(parent, first, last) {
- root._capturePrependAnchor(first, last)
- root._captureAppendAnchor(first, last)
- }
-
- function onRowsInserted(parent, first, last) {
- root._schedulePrependAnchorRestore(first, last)
- root._scheduleAppendAnchorRestore(first, last)
- }
- }
-
- Connections {
- target: list
- enabled: root.autoScrollToBottom
- function onContentHeightChanged() {
- root.scrollToBottom()
- }
- }
-}
diff --git a/qml/components/DebugLogTitlesHeader.qml b/qml/components/DebugLogTitlesHeader.qml
new file mode 100644
index 0000000000..f708eb6699
--- /dev/null
+++ b/qml/components/DebugLogTitlesHeader.qml
@@ -0,0 +1,83 @@
+// 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 QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+
+import "../controls"
+
+Control {
+ id: root
+
+ property int typeColumnWidth: 32
+ property int timeColumnWidth: 80
+ property int cornerRadius: 16
+
+ implicitHeight: 44
+ padding: 0
+
+ background: Rectangle {
+ objectName: "debugLogTitlesHeaderBackground"
+ color: Theme.color.neutral1
+ radius: root.cornerRadius
+
+ Rectangle {
+ objectName: "debugLogTitlesHeaderBottomFill"
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ height: parent.radius
+ color: parent.color
+ }
+
+ Rectangle {
+ objectName: "debugLogTitlesHeaderDivider"
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ height: 1
+ color: Theme.color.neutral2
+ }
+ }
+
+ contentItem: RowLayout {
+ spacing: 0
+
+ Item { Layout.preferredWidth: 12 }
+
+ CoreText {
+ text: qsTr("Type")
+ color: Theme.color.neutral7
+ font.family: Theme.text.family
+ font.pixelSize: 11
+ fontStyleName: "Semi Bold"
+ horizontalAlignment: Text.AlignLeft
+ Layout.preferredWidth: root.typeColumnWidth
+ }
+
+ CoreText {
+ text: qsTr("Time")
+ color: Theme.color.neutral7
+ font.family: Theme.text.family
+ font.pixelSize: 11
+ fontStyleName: "Semi Bold"
+ horizontalAlignment: Text.AlignLeft
+ Layout.preferredWidth: root.timeColumnWidth
+ }
+
+ CoreText {
+ text: qsTr("Message")
+ color: Theme.color.neutral7
+ font.family: Theme.text.family
+ font.pixelSize: 11
+ fontStyleName: "Semi Bold"
+ horizontalAlignment: Text.AlignLeft
+ Layout.fillWidth: true
+ Layout.minimumWidth: 0
+ }
+
+ Item { Layout.preferredWidth: 16 }
+ }
+}
diff --git a/qml/components/MonospaceOutputView.qml b/qml/components/MonospaceOutputView.qml
index 18c8e56066..79a5afdcd1 100644
--- a/qml/components/MonospaceOutputView.qml
+++ b/qml/components/MonospaceOutputView.qml
@@ -57,8 +57,13 @@ Item {
property color errorLeftColumnColor: leftColumnColor
property color selectionColor: Theme.color.orange
property color selectedTextColor: Theme.color.white
- property string filterText: ""
- readonly property string normalizedFilterText: filterText.toLowerCase()
+ property string searchText: ""
+ readonly property string normalizedSearchText: searchText.toLowerCase()
+ readonly property int searchResultCount: _searchMatches.length
+ property int currentSearchResultIndex: -1
+ property var _searchMatches: []
+ property var _selectedSearchEditor: null
+ property bool _resetSearchOnRefresh: false
// ── Layout metrics ───────────────────────────────────────────────────
@@ -110,6 +115,103 @@ Item {
flick.returnToBounds()
}
+ function scheduleSearchRefresh(resetCurrent) {
+ root._resetSearchOnRefresh = root._resetSearchOnRefresh || resetCurrent
+ searchRefreshTimer.restart()
+ }
+
+ function rebuildSearchMatches(resetCurrent) {
+ const matches = []
+ if (root.normalizedSearchText.length > 0) {
+ for (let row = 0; row < rowRepeater.count; ++row) {
+ const item = rowRepeater.itemAt(row)
+ if (!item || !item.contentEditor) continue
+ const editor = item.contentEditor
+ const plainText = editor.getText(0, editor.length)
+ const normalized = plainText.toLowerCase()
+ let offset = 0
+ while (offset <= normalized.length - root.normalizedSearchText.length) {
+ const matchOffset = normalized.indexOf(root.normalizedSearchText, offset)
+ if (matchOffset < 0) break
+ matches.push({
+ row: row,
+ start: matchOffset,
+ end: matchOffset + root.searchText.length
+ })
+ offset = matchOffset + Math.max(1, root.normalizedSearchText.length)
+ }
+ }
+ }
+
+ root._searchMatches = matches
+ if (matches.length === 0) {
+ root.currentSearchResultIndex = -1
+ } else if (resetCurrent || root.currentSearchResultIndex < 0) {
+ root.currentSearchResultIndex = 0
+ } else {
+ root.currentSearchResultIndex = Math.min(root.currentSearchResultIndex,
+ matches.length - 1)
+ }
+ root.applyCurrentSearchMatch()
+ }
+
+ function applyCurrentSearchMatch() {
+ if (root._selectedSearchEditor) {
+ root._selectedSearchEditor.deselect()
+ root._selectedSearchEditor = null
+ }
+ if (root.currentSearchResultIndex < 0
+ || root.currentSearchResultIndex >= root._searchMatches.length) return
+
+ const match = root._searchMatches[root.currentSearchResultIndex]
+ const item = rowRepeater.itemAt(match.row)
+ if (!item || !item.contentEditor) return
+ const editor = item.contentEditor
+ editor.select(match.start, match.end)
+ root._selectedSearchEditor = editor
+
+ // Scroll to the occurrence itself, not merely its containing row. A
+ // console response can span many lines, with the match near the end.
+ const startRect = editor.positionToRectangle(match.start)
+ const endRect = editor.positionToRectangle(Math.max(match.start, match.end - 1))
+ const matchTop = item.y + editor.y + startRect.y
+ const matchBottom = item.y + editor.y + endRect.y + endRect.height
+ if (matchTop < flick.contentY) {
+ flick.contentY = Math.max(0, matchTop)
+ } else if (matchBottom > flick.contentY + flick.height) {
+ flick.contentY = Math.max(0, matchBottom - flick.height)
+ }
+ flick.returnToBounds()
+ }
+
+ function showNextSearchResult() {
+ if (root.searchResultCount === 0) return
+ root.currentSearchResultIndex = (root.currentSearchResultIndex + 1)
+ % root.searchResultCount
+ root.applyCurrentSearchMatch()
+ }
+
+ function showPreviousSearchResult() {
+ if (root.searchResultCount === 0) return
+ root.currentSearchResultIndex = (root.currentSearchResultIndex
+ + root.searchResultCount - 1)
+ % root.searchResultCount
+ root.applyCurrentSearchMatch()
+ }
+
+ onSearchTextChanged: scheduleSearchRefresh(true)
+
+ Timer {
+ id: searchRefreshTimer
+ interval: 0
+ repeat: false
+ onTriggered: {
+ const resetCurrent = root._resetSearchOnRefresh
+ root._resetSearchOnRefresh = false
+ root.rebuildSearchMatches(resetCurrent)
+ }
+ }
+
// ── Signal ───────────────────────────────────────────────────────────
signal scrolled(real y)
@@ -166,6 +268,9 @@ Item {
id: rowRepeater
model: root.listModel
+ onItemAdded: root.scheduleSearchRefresh(false)
+ onItemRemoved: root.scheduleSearchRefresh(false)
+
delegate: RowLayout {
id: rowRoot
@@ -175,6 +280,7 @@ Item {
required property var model
required property int index
readonly property string rowContent: rowRoot.model[root.contentRole] ?? ""
+ onRowContentChanged: root.scheduleSearchRefresh(false)
readonly property int rowCategory: root.categoryRole !== ""
? Number(rowRoot.model[root.categoryRole] ?? -1)
: -1
@@ -197,14 +303,11 @@ Item {
: rowCategory === root.replyCategory
? root.replyLeftColumnColor
: root.leftColumnColor
- readonly property bool matchesFilter: root.normalizedFilterText.length === 0
- || rowContent.toLowerCase().indexOf(root.normalizedFilterText) !== -1
+ property alias contentEditor: contentTextEditor
objectName: root.objectName.length > 0 ? root.objectName + "_row_" + index : ""
width: contentColumn.width
- height: matchesFilter ? implicitHeight : 0
spacing: root.columnSpacing
- visible: matchesFilter
Accessible.role: Accessible.ListItem
Accessible.name: rowContent
@@ -228,11 +331,12 @@ Item {
// Main content column: TextEdit for per-row select + copy.
TextEdit {
+ id: contentTextEditor
objectName: root.objectName.length > 0 ? root.objectName + "_content_" + rowRoot.index : ""
text: rowRoot.rowContent
readOnly: true
selectByMouse: true
- persistentSelection: false
+ persistentSelection: root.normalizedSearchText.length > 0
textFormat: root.contentTextFormat
wrapMode: Text.WrapAnywhere
font.family: root.fontFamily
@@ -275,7 +379,10 @@ Item {
// accurate and the scroll reaches the true bottom.
Connections {
target: flick
- enabled: root.autoScrollToBottom
+ // While searching, navigation owns the viewport position. Otherwise a
+ // content relayout can pull the view back to the bottom immediately
+ // after applyCurrentSearchMatch() scrolls to the active occurrence.
+ enabled: root.autoScrollToBottom && root.normalizedSearchText.length === 0
function onContentHeightChanged() {
root.scrollToBottom()
}
diff --git a/qml/components/SearchBar.qml b/qml/components/SearchBar.qml
new file mode 100644
index 0000000000..e457829b6c
--- /dev/null
+++ b/qml/components/SearchBar.qml
@@ -0,0 +1,307 @@
+// 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 QtQuick.Controls 2.15
+import QtQuick.Controls.impl 2.15 as ControlsImpl
+import QtQuick.Layouts 1.15
+import org.bitcoincore.qt 1.0
+
+import "../controls"
+
+Control {
+ id: root
+
+ property alias text: searchField.text
+ property alias placeholderText: searchField.placeholderText
+ property alias placeholder: searchField.placeholderText
+ property alias inputField: searchField
+ readonly property alias cancelButton: clearButton
+ property string accessibleName: qsTr("Search")
+ property string cancelAccessibleName: qsTr("Clear search")
+ property bool showsSearchIcon: true
+ property bool showsCancel: true
+ property bool clearsOnCancel: true
+ property bool refocusesOnCancel: true
+ property bool showNavigationButtons: false
+ property bool navigationEnabled: true
+ property url searchIconSource: "image://images/search"
+ property url cancelIconSource: "qrc:/icons/cross-circle-filled"
+ property int searchIconSize: 14
+ property int cancelButtonSize: 14
+ property color cancelIconColor: Theme.color.neutral6
+ property color cancelIconHoverColor: Theme.color.neutral5
+ property color cancelIconPressedColor: Theme.color.neutral4
+ property Item nextTabItem: null
+ property string fieldObjectName: ""
+ property string searchIconObjectName: ""
+ property string clearButtonObjectName: ""
+ property string navigationControlObjectName: ""
+ property string previousButtonObjectName: ""
+ property string nextButtonObjectName: ""
+ readonly property alias navigationControl: searchNavigation
+ readonly property alias previousNavigationButton: previousButton
+ readonly property alias nextNavigationButton: nextButton
+
+ signal previousRequested()
+ signal nextRequested()
+ signal queryEdited(string query)
+ signal searchRequested(string query)
+ signal cancelRequested()
+
+ function focusSearch() {
+ searchField.forceActiveFocus()
+ }
+
+ function selectAll() {
+ searchField.selectAll()
+ }
+
+ implicitWidth: showNavigationButtons ? 416 : 340
+ implicitHeight: showNavigationButtons ? 48 : 40
+ padding: showNavigationButtons ? 4 : 0
+
+ background: Rectangle {
+ visible: root.showNavigationButtons
+ color: Theme.color.neutral1
+ radius: 8
+ }
+
+ contentItem: RowLayout {
+ spacing: 2
+
+ TextField {
+ id: searchField
+
+ objectName: root.fieldObjectName
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.minimumWidth: 64
+ leftPadding: root.showsSearchIcon ? 32 : 10
+ rightPadding: clearButton.visible ? root.cancelButtonSize + 14 : 10
+ topPadding: 0
+ bottomPadding: 0
+ placeholderTextColor: Theme.color.neutral7
+ color: Theme.color.neutral9
+ font: Theme.text.caption.font
+ verticalAlignment: TextInput.AlignVCenter
+ selectByMouse: true
+ Accessible.name: root.accessibleName
+ KeyNavigation.tab: root.showNavigationButtons
+ ? previousButton
+ : root.nextTabItem
+
+ Keys.onReturnPressed: function(event) {
+ if (root.showNavigationButtons && root.navigationEnabled) {
+ if (event.modifiers & Qt.ShiftModifier) {
+ root.previousRequested()
+ } else {
+ root.nextRequested()
+ }
+ } else {
+ root.searchRequested(searchField.text)
+ }
+ event.accepted = true
+ }
+
+ onTextEdited: root.queryEdited(text)
+
+ background: Rectangle {
+ color: Theme.color.neutral2
+ radius: 5
+ border.width: 0
+ }
+
+ Icon {
+ objectName: root.searchIconObjectName
+ anchors.left: parent.left
+ anchors.leftMargin: 9
+ anchors.verticalCenter: parent.verticalCenter
+ visible: root.showsSearchIcon
+ source: root.searchIconSource
+ color: Theme.color.neutral7
+ size: root.searchIconSize
+ hoverEnabled: false
+ }
+
+ AbstractButton {
+ id: clearButton
+
+ objectName: root.clearButtonObjectName
+ anchors.right: parent.right
+ anchors.rightMargin: 7
+ anchors.verticalCenter: parent.verticalCenter
+ width: root.cancelButtonSize
+ height: root.cancelButtonSize
+ padding: 0
+ visible: root.showsCancel && searchField.text.length > 0
+ hoverEnabled: AppMode.isDesktop
+ focusPolicy: Qt.NoFocus
+ Accessible.role: Accessible.Button
+ Accessible.name: root.cancelAccessibleName
+
+ background: null
+
+ contentItem: Item {
+ readonly property url source: root.cancelIconSource
+ readonly property color color: clearButton.pressed
+ ? root.cancelIconPressedColor
+ : clearButton.hovered
+ ? root.cancelIconHoverColor
+ : root.cancelIconColor
+ readonly property int size: Math.max(1, root.cancelButtonSize - 2)
+
+ ControlsImpl.IconImage {
+ anchors.centerIn: parent
+ width: parent.size
+ height: parent.size
+ source: parent.source
+ color: parent.color
+ fillMode: Image.PreserveAspectFit
+ smooth: true
+ }
+ }
+
+ HoverHandler {
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ onClicked: {
+ root.cancelRequested()
+ if (root.clearsOnCancel) searchField.clear()
+ if (root.refocusesOnCancel) searchField.forceActiveFocus()
+ }
+ }
+ }
+
+ Control {
+ id: searchNavigation
+
+ objectName: root.navigationControlObjectName
+ visible: root.showNavigationButtons
+ implicitWidth: 54
+ implicitHeight: 40
+ Layout.minimumWidth: 54
+ Layout.preferredWidth: 54
+ Layout.maximumWidth: 54
+ Layout.fillHeight: true
+ padding: 0
+ focusPolicy: Qt.NoFocus
+
+ Accessible.role: Accessible.Grouping
+ Accessible.name: qsTr("Search result navigation")
+ background: null
+
+ contentItem: RowLayout {
+ spacing: 2
+
+ SearchNavigationButton {
+ id: previousButton
+
+ objectName: root.previousButtonObjectName
+ enabled: root.navigationEnabled
+ accessibleName: qsTr("Previous search result")
+ rotationAngle: -90
+ KeyNavigation.tab: nextButton
+ KeyNavigation.backtab: searchField
+ onClicked: root.previousRequested()
+ }
+
+ SearchNavigationButton {
+ id: nextButton
+
+ objectName: root.nextButtonObjectName
+ enabled: root.navigationEnabled
+ accessibleName: qsTr("Next search result")
+ rotationAngle: 90
+ KeyNavigation.tab: root.nextTabItem
+ KeyNavigation.backtab: previousButton
+ onClicked: root.nextRequested()
+ }
+ }
+ }
+ }
+
+ component SearchNavigationButton: AbstractButton {
+ id: navigationButton
+
+ required property string accessibleName
+ required property real rotationAngle
+
+ implicitWidth: 26
+ implicitHeight: 40
+ Layout.minimumWidth: 26
+ Layout.preferredWidth: 26
+ Layout.maximumWidth: 26
+ Layout.fillHeight: true
+ padding: 0
+ hoverEnabled: AppMode.isDesktop
+ focusPolicy: Qt.TabFocus
+ Accessible.role: Accessible.Button
+ Accessible.name: accessibleName
+
+ background: Rectangle {
+ color: navigationButton.hovered || navigationButton.pressed
+ ? Theme.color.neutral2
+ : "transparent"
+ radius: 5
+ }
+
+ contentItem: Item {
+ SearchNavigationCaret {
+ objectName: navigationButton.objectName.length > 0
+ ? navigationButton.objectName + "Icon"
+ : ""
+ anchors.centerIn: parent
+ width: 14
+ height: 14
+ strokeColor: navigationButton.enabled
+ ? Theme.color.neutral8
+ : Theme.color.neutral4
+ rotation: navigationButton.rotationAngle
+ }
+ }
+
+ FocusBorder {
+ objectName: navigationButton.objectName.length > 0
+ ? navigationButton.objectName + "FocusBorder"
+ : ""
+ visible: navigationButton.activeFocus
+ borderRadius: 9
+ z: 1
+ }
+
+ HoverHandler {
+ cursorShape: navigationButton.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+ }
+ }
+
+ component SearchNavigationCaret: Canvas {
+ id: caret
+
+ required property color strokeColor
+ readonly property real strokeWidth: 2
+
+ antialiasing: true
+
+ onPaint: {
+ const context = getContext("2d")
+ context.clearRect(0, 0, width, height)
+ context.strokeStyle = strokeColor
+ context.lineWidth = strokeWidth
+ context.lineCap = "round"
+ context.lineJoin = "round"
+ context.beginPath()
+ context.moveTo(4.5, 2.75)
+ context.lineTo(9.5, 7)
+ context.lineTo(4.5, 11.25)
+ context.stroke()
+ }
+
+ onStrokeColorChanged: requestPaint()
+ onWidthChanged: requestPaint()
+ onHeightChanged: requestPaint()
+ }
+
+}
diff --git a/qml/components/SettingsView.qml b/qml/components/SettingsView.qml
index 323a07a30f..5cf0e1d3ac 100644
--- a/qml/components/SettingsView.qml
+++ b/qml/components/SettingsView.qml
@@ -355,7 +355,7 @@ Page {
Component {
id: rpcConsolePage
- SettingsPages.RpcConsoleSettingsPage {
+ SettingsPages.SettingsRpcConsoleView {
walletName: typeof walletController !== "undefined"
&& walletController.isWalletLoaded && walletController.selectedWallet
? walletController.selectedWallet.name
@@ -366,11 +366,7 @@ Page {
Component {
id: debugLogPage
- SettingsPages.SettingsDebugLog {
- showBackButton: false
- maximumContentWidth: width
- contentHorizontalPadding: width >= 900 ? 56 : width >= 640 ? 40 : 24
- }
+ SettingsPages.SettingsDebugLogView {}
}
Component {
diff --git a/qml/components/ToastBanner.qml b/qml/components/ToastBanner.qml
index 409c1e0cb0..c52a0a915e 100644
--- a/qml/components/ToastBanner.qml
+++ b/qml/components/ToastBanner.qml
@@ -147,12 +147,15 @@ Rectangle {
}
Icon {
+ objectName: root.objectName !== "" ? root.objectName + "CloseButton" : ""
visible: root.showsCloseButton
source: "image://images/cross"
color: root.textColor
size: 14
enabled: true
padding: 6
+ Accessible.role: Accessible.Button
+ Accessible.name: qsTr("Dismiss")
onClicked: root.dismissed()
HoverHandler {
cursorShape: Qt.PointingHandCursor
diff --git a/qml/controls/ContextMenuButton.qml b/qml/controls/ContextMenuButton.qml
index 6e24ae1302..3580151dbc 100644
--- a/qml/controls/ContextMenuButton.qml
+++ b/qml/controls/ContextMenuButton.qml
@@ -18,10 +18,11 @@ AbstractButton {
property url iconSource
property int role: ContextMenuButton.Normal
property bool autoClose: true
+ property bool selected: false
property color hoverBackgroundColor: Theme.color.neutral3
readonly property bool _destructive: role === ContextMenuButton.Destructive
- readonly property bool _highlighted: enabled && (hovered || down || visualFocus)
+ readonly property bool _highlighted: enabled && (selected || hovered || down || visualFocus)
readonly property color _idleColor: _destructive ? Theme.color.red : Theme.color.neutral8
readonly property color _hoverColor: _destructive ? Theme.color.red : Theme.color.neutral9
diff --git a/qml/controls/FilterButton.qml b/qml/controls/FilterButton.qml
new file mode 100644
index 0000000000..3ac3b2154a
--- /dev/null
+++ b/qml/controls/FilterButton.qml
@@ -0,0 +1,115 @@
+// 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 QtQuick.Controls 2.15
+import org.bitcoincore.qt 1.0
+
+AbstractButton {
+ id: root
+
+ property bool active: false
+ property url inactiveIconSource: "qrc:/icons/filter"
+ property url activeIconSource: "qrc:/icons/filter-active"
+ property color inactiveIconColor: Theme.color.neutral6
+ property color activeIconColor: Theme.color.orange
+ property color backgroundColor: Theme.color.neutral1
+ property color hoverBackgroundColor: Theme.color.neutral2
+ property int size: 36
+ property int iconSize: 24
+ property int transitionDuration: 140
+ property real minimizedIconScale: 0.72
+ readonly property Item iconItem: active ? activeFilterIcon : inactiveFilterIcon
+ readonly property alias inactiveIconItem: inactiveFilterIcon
+ readonly property alias activeIconItem: activeFilterIcon
+
+ implicitWidth: size
+ implicitHeight: size
+ width: size
+ height: size
+ padding: 0
+ hoverEnabled: AppMode.isDesktop
+ focusPolicy: Qt.StrongFocus
+
+ Accessible.role: Accessible.Button
+ Accessible.name: text.length > 0 ? text : qsTr("Filter")
+ Accessible.checkable: true
+ Accessible.checked: active
+
+ HoverHandler {
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ background: Rectangle {
+ color: root.hovered || root.down ? root.hoverBackgroundColor : root.backgroundColor
+ radius: 5
+
+ FocusBorder {
+ visible: root.visualFocus
+ borderRadius: 9
+ }
+ }
+
+ contentItem: Item {
+ Icon {
+ id: inactiveFilterIcon
+ objectName: root.objectName.length > 0 ? root.objectName + "InactiveIcon" : ""
+ anchors.centerIn: parent
+ source: root.inactiveIconSource
+ color: root.enabled
+ ? root.hovered || root.down
+ ? root.activeIconColor
+ : root.inactiveIconColor
+ : Theme.color.neutral4
+ size: root.iconSize
+ opacity: root.active ? 0 : 1
+ scale: root.active ? root.minimizedIconScale : 1
+ hoverEnabled: false
+
+ Behavior on color {
+ ColorAnimation { duration: 150 }
+ }
+ Behavior on opacity {
+ NumberAnimation {
+ duration: root.transitionDuration
+ easing.type: Easing.OutCubic
+ }
+ }
+ Behavior on scale {
+ NumberAnimation {
+ duration: root.transitionDuration
+ easing.type: Easing.OutCubic
+ }
+ }
+ }
+
+ Icon {
+ id: activeFilterIcon
+ objectName: root.objectName.length > 0 ? root.objectName + "ActiveIcon" : ""
+ anchors.centerIn: parent
+ source: root.activeIconSource
+ color: root.enabled ? root.activeIconColor : Theme.color.neutral4
+ size: root.iconSize
+ opacity: root.active ? 1 : 0
+ scale: root.active ? 1 : root.minimizedIconScale
+ hoverEnabled: false
+
+ Behavior on color {
+ ColorAnimation { duration: 150 }
+ }
+ Behavior on opacity {
+ NumberAnimation {
+ duration: root.transitionDuration
+ easing.type: Easing.OutCubic
+ }
+ }
+ Behavior on scale {
+ NumberAnimation {
+ duration: root.transitionDuration
+ easing.type: Easing.OutCubic
+ }
+ }
+ }
+ }
+}
diff --git a/qml/controls/OutlineButton.qml b/qml/controls/OutlineButton.qml
index 970e980785..f44ab8de6c 100644
--- a/qml/controls/OutlineButton.qml
+++ b/qml/controls/OutlineButton.qml
@@ -18,8 +18,8 @@ Button {
}
}
- property bool bold: false
- property bool embedded: false
+ property bool bold: true
+ property bool isOnSurface: false
property url iconSource: ""
property var textStyle: bold ? Theme.text.buttonStrong : Theme.text.button
property int textFontPixelSize: textStyle.pixelSize
@@ -60,12 +60,12 @@ Button {
id: bg
objectName: root.objectName.length > 0 ? root.objectName + "Background" : ""
implicitHeight: 46
- color: root.embedded
+ color: root.isOnSurface
? (root.hovered || root.down ? Theme.color.neutral3 : Theme.color.neutral2)
: "transparent"
radius: 5
border {
- width: root.embedded ? 0 : 1
+ width: root.isOnSurface ? 0 : 1
color: Theme.color.neutral2
Behavior on color {
diff --git a/qml/controls/OverflowMenuButton.qml b/qml/controls/OverflowMenuButton.qml
new file mode 100644
index 0000000000..17bcfaf532
--- /dev/null
+++ b/qml/controls/OverflowMenuButton.qml
@@ -0,0 +1,65 @@
+// 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 QtQuick.Controls 2.15
+import org.bitcoincore.qt 1.0
+
+AbstractButton {
+ id: root
+
+ property url iconSource: "image://images/ellipsis"
+ property color iconColor: Theme.color.neutral5
+ property color activeIconColor: Theme.color.orange
+ property color backgroundColor: Theme.color.neutral1
+ property color hoverBackgroundColor: Theme.color.neutral2
+ property int size: 36
+ property int iconSize: 30
+ readonly property alias iconItem: ellipsisIcon
+
+ implicitWidth: size
+ implicitHeight: size
+ width: size
+ height: size
+ padding: 0
+ hoverEnabled: AppMode.isDesktop
+ focusPolicy: Qt.StrongFocus
+
+ Accessible.role: Accessible.Button
+ Accessible.name: text.length > 0 ? text : qsTr("More options")
+
+ HoverHandler {
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ background: Rectangle {
+ color: root.hovered || root.down ? root.hoverBackgroundColor : root.backgroundColor
+ radius: 5
+
+ FocusBorder {
+ visible: root.visualFocus
+ borderRadius: 9
+ }
+ }
+
+ contentItem: Item {
+ Icon {
+ id: ellipsisIcon
+ objectName: root.objectName.length > 0 ? root.objectName + "Icon" : ""
+ anchors.centerIn: parent
+ source: root.iconSource
+ color: root.enabled
+ ? root.checked || root.hovered || root.down
+ ? root.activeIconColor
+ : root.iconColor
+ : Theme.color.neutral4
+ size: root.iconSize
+ hoverEnabled: false
+
+ Behavior on color {
+ ColorAnimation { duration: 150 }
+ }
+ }
+ }
+}
diff --git a/qml/models/debuglogmodel.cpp b/qml/models/debuglogmodel.cpp
index 6393611326..b40a6b7a3d 100644
--- a/qml/models/debuglogmodel.cpp
+++ b/qml/models/debuglogmodel.cpp
@@ -4,6 +4,7 @@
#include
+#include
#include
#include
@@ -16,14 +17,19 @@
#include
#include
#include
+#include
+#include
#include
#include
#include
static const QRegularExpression TIMESTAMP_RX(
- QStringLiteral(R"(^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)\s*(.*)$)"));
-static const QRegularExpression COMMAND_PREFIX_RX(
- QStringLiteral(R"(^([^:]{1,80}):\s+(.*)$)"));
+ QStringLiteral(R"(^(?:\[\*\]\s*)?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d+)?Z\s*(.*)$)"));
+static const QRegularExpression BRACKET_PREFIX_RX(
+ QStringLiteral(R"(^\[([^\]]+)\]\s*)"));
+static const QRegularExpression LEGACY_LEVEL_RX(
+ QStringLiteral(R"(^(ERROR|WARNING):\s*(.*)$)"),
+ QRegularExpression::CaseInsensitiveOption);
namespace {
constexpr qint64 TAIL_READ_BLOCK_SIZE{64 * 1024};
@@ -36,6 +42,31 @@ QByteArray ReadAnchor(QFile& file, qint64 file_size)
if (anchor_size <= 0 || !file.seek(file_size - anchor_size)) return {};
return file.read(anchor_size);
}
+
+bool IsLogLevel(const QString& value)
+{
+ static const QSet levels{
+ QStringLiteral("trace"),
+ QStringLiteral("debug"),
+ QStringLiteral("info"),
+ QStringLiteral("warning"),
+ QStringLiteral("error"),
+ };
+ return levels.contains(value);
+}
+
+bool IsLogCategory(const QString& value)
+{
+ static const QSet categories = [] {
+ QSet result{QStringLiteral("all")};
+ for (const LogCategory& category : LogInstance().LogCategoriesList()) {
+ result.insert(QString::fromStdString(category.category));
+ }
+ return result;
+ }();
+ return categories.contains(value);
+}
+
} // namespace
DebugLogModel::DebugLogModel(const fs::path& log_path, QObject* parent)
@@ -79,15 +110,10 @@ QVariant DebugLogModel::data(const QModelIndex& index, int role) const
const LogLine& line = m_display_lines.at(index.row());
switch (role) {
- // The number is derived from the model row. Prepending new records no
- // longer requires copying and renumbering every stored LogLine.
- case LineNumberRole: return QString::number(index.row() + 1);
- case ContentRole: return line.content;
- case RelativeTimeRole: return line.relativeTime;
- case CommandRole: return line.command;
- case MessageRole: return line.message;
- case DateLabelRole: return line.relativeTime;
- case SeverityRole: return line.severity;
+ case MessageRole: return line.message;
+ case TimestampRole: return line.timestamp;
+ case IsErrorRole: return line.is_error;
+ case IsWarningRole: return line.is_warning;
}
return {};
}
@@ -95,13 +121,10 @@ QVariant DebugLogModel::data(const QModelIndex& index, int role) const
QHash DebugLogModel::roleNames() const
{
return {
- {LineNumberRole, "lineNumber"},
- {ContentRole, "content"},
- {RelativeTimeRole, "relativeTime"},
- {CommandRole, "command"},
- {MessageRole, "message"},
- {DateLabelRole, "dateLabel"},
- {SeverityRole, "severity"},
+ {MessageRole, "message"},
+ {TimestampRole, "timestamp"},
+ {IsErrorRole, "isError"},
+ {IsWarningRole, "isWarning"},
};
}
@@ -114,7 +137,7 @@ void DebugLogModel::setLoadLimit(int limit)
Q_EMIT loadLimitChanged();
if (m_all_lines.size() > m_load_limit) {
- QList retained = m_all_lines.first(m_load_limit);
+ QList retained = m_all_lines.last(m_load_limit);
applyLines(std::move(retained), /*force_reset=*/false);
m_loaded_limit = std::min(m_loaded_limit, m_load_limit);
const bool has_more = m_load_limit < kMaxLoadLimit;
@@ -170,6 +193,14 @@ void DebugLogModel::setFilter(const QString& filter)
buildDisplayLines(/*force_reset=*/true);
}
+void DebugLogModel::setWarningsAndErrorsOnly(bool warnings_and_errors_only)
+{
+ if (m_warnings_and_errors_only == warnings_and_errors_only) return;
+ m_warnings_and_errors_only = warnings_and_errors_only;
+ Q_EMIT warningsAndErrorsOnlyChanged();
+ buildDisplayLines(/*force_reset=*/true);
+}
+
void DebugLogModel::refresh(bool full_load)
{
if (!m_active || m_stopping) return;
@@ -285,34 +316,6 @@ bool DebugLogModel::openLogFile()
return true;
}
-void DebugLogModel::updateRelativeTimes()
-{
- if (!m_active || (m_all_lines.isEmpty() && m_display_lines.isEmpty())) return;
- const qint64 now_ms = QDateTime::currentMSecsSinceEpoch();
-
- for (LogLine& line : m_all_lines) {
- if (line.timestamp_ms >= 0)
- line.relativeTime = RelativeTimeLabelStatic(line.timestamp_ms, now_ms);
- }
-
- int first_changed = -1;
- int last_changed = -1;
- for (int i = 0; i < m_display_lines.size(); ++i) {
- LogLine& line = m_display_lines[i];
- if (line.timestamp_ms < 0) continue;
- const QString next_label = RelativeTimeLabelStatic(line.timestamp_ms, now_ms);
- if (line.relativeTime == next_label) continue;
- line.relativeTime = next_label;
- if (first_changed < 0) first_changed = i;
- last_changed = i;
- }
-
- if (first_changed >= 0) {
- Q_EMIT dataChanged(index(first_changed, 0), index(last_changed, 0),
- {RelativeTimeRole, DateLabelRole});
- }
-}
-
void DebugLogModel::stop()
{
if (m_stopping) return;
@@ -585,8 +588,6 @@ QList DebugLogModel::ParseCompleteLines(
{
QList result;
result.reserve(std::min(max_filtered_lines, 1024));
- const qint64 now_ms = QDateTime::currentMSecsSinceEpoch();
-
qsizetype scan_end = bytes.size();
while (scan_end > 0 && result.size() < max_filtered_lines) {
if (cancelled.load(std::memory_order_relaxed)) return {};
@@ -608,18 +609,16 @@ QList DebugLogModel::ParseCompleteLines(
const QString line = QString::fromUtf8(raw);
const QRegularExpressionMatch match = TIMESTAMP_RX.match(line);
if (match.hasMatch()) {
- const QDateTime dt = QDateTime::fromString(match.captured(1), Qt::ISODateWithMs);
- entry.timestamp_ms = dt.isValid() ? dt.toMSecsSinceEpoch() : -1;
- raw_message = match.captured(2);
+ entry.timestamp = FormatTime(match.captured(1));
+ raw_message = match.captured(3);
+ if (line.startsWith(QLatin1String("[*]"))) {
+ raw_message.prepend(QStringLiteral("[*] "));
+ }
} else {
- entry.timestamp_ms = -1;
raw_message = line;
}
PopulateParsedFields(entry, raw_message);
- entry.relativeTime = entry.timestamp_ms >= 0
- ? RelativeTimeLabelStatic(entry.timestamp_ms, now_ms)
- : QString{};
- if (!entry.content.trimmed().isEmpty() || entry.timestamp_ms >= 0) {
+ if (!entry.message.isEmpty() || !entry.timestamp.isEmpty()) {
result.append(std::move(entry));
}
}
@@ -674,6 +673,9 @@ void DebugLogModel::onReadCompleted(const ReadResult& result,
if (result.full_snapshot) {
QList next_lines = result.lines;
if (next_lines.size() > m_load_limit) next_lines.resize(m_load_limit);
+ // File reads are newest-first so the bounded tail can stop early;
+ // the Debug Log V2 presentation is chronological.
+ std::reverse(next_lines.begin(), next_lines.end());
next_has_more = (result.has_more_lines || result.lines.size() > m_load_limit)
&& m_load_limit < kMaxLoadLimit;
force_reset = force_reset || m_all_lines.isEmpty();
@@ -752,14 +754,18 @@ QList DebugLogModel::filteredLines(
const QList& lines) const
{
QList filtered;
- if (m_filter.isEmpty()) {
- filtered = lines;
- } else {
- const QString f = m_filter.toLower();
- for (const LogLine& line : lines) {
- if (line.content.toLower().contains(f))
- filtered.append(line);
+ filtered.reserve(lines.size());
+ for (const LogLine& line : lines) {
+ if (m_warnings_and_errors_only && !line.is_error && !line.is_warning) continue;
+ if (!m_filter.isEmpty()) {
+ const QString searchable = line.timestamp + QLatin1Char(' ')
+ + (line.is_error ? QStringLiteral("error ")
+ : line.is_warning ? QStringLiteral("warning ")
+ : QStringLiteral("regular "))
+ + line.message;
+ if (!searchable.contains(m_filter, Qt::CaseInsensitive)) continue;
}
+ filtered.append(line);
}
return filtered;
}
@@ -785,6 +791,7 @@ bool DebugLogModel::applyDelta(QList lines)
const bool omitted_new_lines = lines.size() > m_load_limit;
if (omitted_new_lines) lines.resize(m_load_limit);
+ std::reverse(lines.begin(), lines.end());
const int old_size = static_cast(m_all_lines.size());
const int new_size = static_cast(lines.size());
@@ -792,48 +799,28 @@ bool DebugLogModel::applyDelta(QList lines)
old_size, std::max(0, m_load_limit - new_size));
const int old_remove_count = old_size - old_keep_count;
- int display_remove_count{0};
- if (m_filter.isEmpty()) {
- display_remove_count = old_remove_count;
- } else if (old_remove_count > 0) {
- const QString filter = m_filter.toLower();
- for (int i = old_keep_count; i < m_all_lines.size(); ++i) {
- if (m_all_lines.at(i).content.toLower().contains(filter)) {
- ++display_remove_count;
- }
- }
- }
+ const int display_remove_count = old_remove_count > 0
+ ? filteredLines(m_all_lines.first(old_remove_count)).size()
+ : 0;
QList display_insert = filteredLines(lines);
- const int display_insert_count = display_insert.size();
- const int surviving_display_count = m_display_lines.size() - display_remove_count;
-
- // Publish the prepend first so a ListView can anchor the previously visible
- // row. Any cap-induced removal is confined to the oldest filtered suffix.
- if (!display_insert.isEmpty()) {
- beginInsertRows(QModelIndex{}, 0, display_insert.size() - 1);
- display_insert.reserve(display_insert.size() + m_display_lines.size());
- display_insert.append(m_display_lines);
- m_display_lines = std::move(display_insert);
- endInsertRows();
- }
if (display_remove_count > 0) {
- const int first = display_insert_count + surviving_display_count;
- beginRemoveRows(QModelIndex{}, first, m_display_lines.size() - 1);
- m_display_lines.erase(m_display_lines.begin() + first,
- m_display_lines.end());
+ beginRemoveRows(QModelIndex{}, 0, display_remove_count - 1);
+ m_display_lines.erase(m_display_lines.begin(),
+ m_display_lines.begin() + display_remove_count);
endRemoveRows();
}
-
- lines.reserve(lines.size() + old_keep_count);
- lines.append(m_all_lines.cbegin(), m_all_lines.cbegin() + old_keep_count);
- m_all_lines = std::move(lines);
-
- if (display_insert_count > 0 && surviving_display_count > 0) {
- Q_EMIT dataChanged(index(display_insert_count, 0),
- index(display_insert_count + surviving_display_count - 1, 0),
- {LineNumberRole});
+ if (!display_insert.isEmpty()) {
+ const int first = m_display_lines.size();
+ beginInsertRows(QModelIndex{}, first, first + display_insert.size() - 1);
+ m_display_lines.append(display_insert);
+ endInsertRows();
}
+
+ QList retained = m_all_lines.mid(old_remove_count, old_keep_count);
+ retained.reserve(retained.size() + lines.size());
+ retained.append(lines);
+ m_all_lines = std::move(retained);
return omitted_new_lines || old_remove_count > 0;
}
@@ -861,19 +848,32 @@ void DebugLogModel::applyDisplayLines(QList lines, bool force_reset)
return;
}
- // Appends to debug.log can only add a prefix (newest rows) and pruning can
- // only remove a suffix. loadMore does the inverse operation at the bottom.
- // Locate the old first row in the new projection and preserve the largest
- // contiguous run from there. Stable byte offsets distinguish identical
- // timestamp/message duplicates.
- int prefix_count{-1};
+ // Full snapshots may add older history at the beginning, add newer rows at
+ // the end, or trim either side after a capacity change. Preserve the common
+ // contiguous run so the virtualized view can retain its visual anchor.
+ QHash new_positions;
+ new_positions.reserve(lines.size());
for (int i = 0; i < lines.size(); ++i) {
- if (lines.at(i) == m_display_lines.first()) {
- prefix_count = i;
- break;
+ new_positions.insert(lines.at(i).source_offset, i);
+ }
+
+ int old_start{-1};
+ int new_start{-1};
+ int common_count{0};
+ for (int i = 0; i < m_display_lines.size(); ++i) {
+ const auto position = new_positions.constFind(m_display_lines.at(i).source_offset);
+ if (position == new_positions.cend() || !(m_display_lines.at(i) == lines.at(*position))) continue;
+ old_start = i;
+ new_start = *position;
+ while (old_start + common_count < m_display_lines.size()
+ && new_start + common_count < lines.size()
+ && m_display_lines.at(old_start + common_count) == lines.at(new_start + common_count)) {
+ ++common_count;
}
+ break;
}
- if (prefix_count < 0) {
+
+ if (common_count == 0) {
beginRemoveRows(QModelIndex{}, 0, m_display_lines.size() - 1);
m_display_lines.clear();
endRemoveRows();
@@ -883,22 +883,27 @@ void DebugLogModel::applyDisplayLines(QList lines, bool force_reset)
return;
}
- int common_count{0};
- while (common_count < m_display_lines.size()
- && prefix_count + common_count < lines.size()
- && m_display_lines.at(common_count) == lines.at(prefix_count + common_count)) {
- ++common_count;
- }
-
- const int old_suffix_count = m_display_lines.size() - common_count;
+ const int old_suffix_count = m_display_lines.size() - old_start - common_count;
if (old_suffix_count > 0) {
- beginRemoveRows(QModelIndex{}, common_count, m_display_lines.size() - 1);
- m_display_lines.erase(m_display_lines.begin() + common_count,
+ const int first = old_start + common_count;
+ beginRemoveRows(QModelIndex{}, first, m_display_lines.size() - 1);
+ m_display_lines.erase(m_display_lines.begin() + first,
m_display_lines.end());
endRemoveRows();
}
-
- const int new_suffix_start = prefix_count + common_count;
+ if (old_start > 0) {
+ beginRemoveRows(QModelIndex{}, 0, old_start - 1);
+ m_display_lines.erase(m_display_lines.begin(), m_display_lines.begin() + old_start);
+ endRemoveRows();
+ }
+ if (new_start > 0) {
+ beginInsertRows(QModelIndex{}, 0, new_start - 1);
+ for (int i = new_start - 1; i >= 0; --i) {
+ m_display_lines.prepend(lines.at(i));
+ }
+ endInsertRows();
+ }
+ const int new_suffix_start = new_start + common_count;
if (new_suffix_start < lines.size()) {
const int first = m_display_lines.size();
const int count = lines.size() - new_suffix_start;
@@ -908,23 +913,6 @@ void DebugLogModel::applyDisplayLines(QList lines, bool force_reset)
}
endInsertRows();
}
-
- // Apply a racing loadMore suffix before a live-update prefix. The QML
- // view restores both anchors asynchronously; making the prepend the final
- // structural notification ensures its top-row anchor wins.
- if (prefix_count > 0) {
- beginInsertRows(QModelIndex{}, 0, prefix_count - 1);
- for (int i = prefix_count - 1; i >= 0; --i) {
- m_display_lines.prepend(lines.at(i));
- }
- endInsertRows();
- }
-
- if (prefix_count > 0 && common_count > 0) {
- Q_EMIT dataChanged(index(prefix_count, 0),
- index(m_display_lines.size() - 1, 0),
- {LineNumberRole});
- }
}
void DebugLogModel::buildDisplayLines(bool force_reset)
@@ -934,40 +922,61 @@ void DebugLogModel::buildDisplayLines(bool force_reset)
void DebugLogModel::PopulateParsedFields(LogLine& entry, const QString& raw_message)
{
- entry.content = raw_message.toHtmlEscaped();
- const QString trimmed = raw_message.trimmed();
- entry.command.clear();
- entry.message = trimmed;
- entry.severity = InfoSeverity;
-
- const QRegularExpressionMatch command_match = COMMAND_PREFIX_RX.match(trimmed);
- if (command_match.hasMatch()) {
- const QString command = command_match.captured(1).trimmed();
- const QString message = command_match.captured(2).trimmed();
- if (!command.isEmpty() && !message.isEmpty()) {
- entry.command = command;
- entry.message = message;
+ QString remaining = raw_message.trimmed();
+ QStringList preserved_prefixes;
+ entry.is_error = false;
+ entry.is_warning = false;
+
+ while (true) {
+ const QRegularExpressionMatch prefix_match = BRACKET_PREFIX_RX.match(remaining);
+ if (!prefix_match.hasMatch()) break;
+
+ const QString original = QStringLiteral("[%1]").arg(prefix_match.captured(1));
+ const QString value = prefix_match.captured(1).trimmed().toLower();
+ bool recognised{false};
+
+ if (IsLogLevel(value)) {
+ entry.is_error = value == QLatin1String("error");
+ entry.is_warning = value == QLatin1String("warning");
+ recognised = true;
+ } else {
+ const qsizetype separator = value.indexOf(QLatin1Char(':'));
+ if (separator > 0 && value.indexOf(QLatin1Char(':'), separator + 1) < 0) {
+ const QString category = value.first(separator);
+ const QString level = value.sliced(separator + 1);
+ if (IsLogCategory(category) && IsLogLevel(level)) {
+ entry.is_error = level == QLatin1String("error");
+ entry.is_warning = level == QLatin1String("warning");
+ recognised = true;
+ }
+ } else if (IsLogCategory(value)) {
+ recognised = true;
+ }
}
+
+ if (!recognised) preserved_prefixes.append(original);
+ remaining.remove(0, prefix_match.capturedLength());
+ remaining = remaining.trimmed();
}
- const QString severity_source = entry.command.isEmpty() ? trimmed : entry.command;
- if (severity_source.compare(QLatin1String("ERROR"), Qt::CaseInsensitive) == 0) {
- entry.severity = ErrorSeverity;
- } else if (severity_source.compare(QLatin1String("WARNING"), Qt::CaseInsensitive) == 0) {
- entry.severity = WarningSeverity;
+ const QRegularExpressionMatch legacy_match = LEGACY_LEVEL_RX.match(remaining);
+ if (legacy_match.hasMatch()) {
+ entry.is_error = legacy_match.captured(1).compare(QLatin1String("ERROR"), Qt::CaseInsensitive) == 0;
+ entry.is_warning = !entry.is_error;
+ remaining = legacy_match.captured(2).trimmed();
}
-}
-QString DebugLogModel::relativeTimeLabel(qint64 timestamp_ms, qint64 now_ms) const
-{
- return RelativeTimeLabelStatic(timestamp_ms, now_ms);
+ if (!preserved_prefixes.isEmpty()) {
+ remaining.prepend(preserved_prefixes.join(QLatin1Char(' ')) + QLatin1Char(' '));
+ }
+ entry.message = remaining.trimmed();
}
-QString DebugLogModel::RelativeTimeLabelStatic(qint64 timestamp_ms, qint64 now_ms)
+QString DebugLogModel::FormatTime(const QString& utc_seconds)
{
- const qint64 diff = (now_ms - timestamp_ms) / 1000;
- if (diff < 60) return QObject::tr("just now");
- if (diff < 3600) return QObject::tr("%1 min ago").arg(diff / 60);
- if (diff < 86400) return QObject::tr("%1 hr ago").arg(diff / 3600);
- return QObject::tr("%1 d ago").arg(diff / 86400);
+ const QDateTime utc_time = QDateTime::fromString(
+ utc_seconds + QLatin1Char('Z'), Qt::ISODate);
+ return utc_time.isValid()
+ ? utc_time.toLocalTime().toString(QStringLiteral("HH:mm:ss"))
+ : utc_seconds.right(8);
}
diff --git a/qml/models/debuglogmodel.h b/qml/models/debuglogmodel.h
index 6b45c12195..b4cbaed408 100644
--- a/qml/models/debuglogmodel.h
+++ b/qml/models/debuglogmodel.h
@@ -20,16 +20,11 @@ class QThread;
//! List model for the in-app debug.log viewer.
//!
-//! Exposes log lines as list items with display roles:
-//! - LineNumberRole — 1-based line number as a display string ("1", "2", …)
-//! - ContentRole — HTML-escaped message text (no inline style; colours
-//! are applied by the QML delegate)
-//! - RelativeTimeRole — human-readable age string ("just now", "3 min ago",
-//! …) updated by updateRelativeTimes()
-//! - CommandRole — parsed message prefix before ":" when present
-//! - MessageRole — parsed message body after the prefix
-//! - DateLabelRole — label shown in the row's right-hand date slot
-//! - SeverityRole — display severity used by the QML delegate
+//! Exposes structured log records for the Debug Log V2 table:
+//! - MessageRole — the message with recognised logging metadata removed
+//! - TimestampRole — local wall-clock time preserving the file's precision
+//! - IsErrorRole — true only for error-level records
+//! - IsWarningRole — true only for warning-level records
//!
//! Pagination: only the most recent `loadLimit` lines are kept in memory.
//! Call loadMore() to increase the limit by 1000, up to kMaxLoadLimit.
@@ -46,27 +41,18 @@ class DebugLogModel : public QAbstractListModel
Q_PROPERTY(bool active READ active WRITE setActive NOTIFY activeChanged)
Q_PROPERTY(int loadLimit READ loadLimit WRITE setLoadLimit NOTIFY loadLimitChanged)
Q_PROPERTY(QString filter READ filter WRITE setFilter NOTIFY filterChanged)
+ Q_PROPERTY(bool warningsAndErrorsOnly READ warningsAndErrorsOnly WRITE setWarningsAndErrorsOnly NOTIFY warningsAndErrorsOnlyChanged)
Q_PROPERTY(QString openError READ openError NOTIFY openErrorChanged)
public:
enum Role {
- LineNumberRole = Qt::UserRole + 1,
- ContentRole,
- RelativeTimeRole,
- CommandRole,
- MessageRole,
- DateLabelRole,
- SeverityRole,
+ MessageRole = Qt::UserRole + 1,
+ TimestampRole,
+ IsErrorRole,
+ IsWarningRole,
};
Q_ENUM(Role)
- enum Severity {
- InfoSeverity = 0,
- WarningSeverity,
- ErrorSeverity,
- };
- Q_ENUM(Severity)
-
//! Hard ceiling on loadLimit to protect against unbounded memory growth.
static constexpr int kMaxLoadLimit = 50'000;
@@ -93,12 +79,14 @@ class DebugLogModel : public QAbstractListModel
QString filter() const { return m_filter; }
void setFilter(const QString& filter);
+ bool warningsAndErrorsOnly() const { return m_warnings_and_errors_only; }
+ void setWarningsAndErrorsOnly(bool warnings_and_errors_only);
+
QString openError() const { return m_open_error; }
Q_INVOKABLE void refresh(bool full_load = false);
Q_INVOKABLE void loadMore();
Q_INVOKABLE bool openLogFile();
- Q_INVOKABLE void updateRelativeTimes();
void stop();
Q_SIGNALS:
@@ -106,31 +94,26 @@ class DebugLogModel : public QAbstractListModel
void activeChanged();
void loadLimitChanged();
void filterChanged();
+ void warningsAndErrorsOnlyChanged();
void openErrorChanged();
- //! Emitted when new lines are prepended at the top during an auto-refresh.
+ //! Emitted when new lines are appended during an auto-refresh.
void newLinesAdded(int count);
private:
struct LogLine {
- QString content; // HTML-escaped full message text
- QString command; // parsed message prefix, plain text
- QString message; // parsed message body, plain text
+ QString message;
+ QString timestamp;
qint64 source_offset{-1}; // byte offset in debug.log (stable across appends)
- qint64 timestamp_ms; // epoch ms, -1 if not parseable
- QString relativeTime; // cached human-readable age
- Severity severity{InfoSeverity};
+ bool is_error{false};
+ bool is_warning{false};
- // Identity for incremental display diffs. relativeTime is derived
- // (refreshed separately by the relative-time timer) and deliberately
- // excluded.
bool operator==(const LogLine& o) const
{
return source_offset == o.source_offset
- && content == o.content
- && command == o.command
&& message == o.message
- && severity == o.severity
- && timestamp_ms == o.timestamp_ms;
+ && timestamp == o.timestamp
+ && is_error == o.is_error
+ && is_warning == o.is_warning;
}
};
@@ -189,20 +172,20 @@ class DebugLogModel : public QAbstractListModel
bool applyDelta(QList lines);
void applyDisplayLines(QList lines, bool force_reset);
QList filteredLines(const QList& lines) const;
- QString relativeTimeLabel(qint64 timestamp_ms, qint64 now_ms) const;
static void PopulateParsedFields(LogLine& entry, const QString& raw_message);
- static QString RelativeTimeLabelStatic(qint64 timestamp_ms, qint64 now_ms);
+ static QString FormatTime(const QString& utc_seconds);
fs::path m_log_path;
- //! All loaded lines stored newest-first (index 0 = newest).
+ //! All loaded lines stored chronologically (index 0 = oldest loaded).
QList m_all_lines;
- //! Filtered subset of m_all_lines, also newest-first.
+ //! Filtered subset of m_all_lines, also chronological.
QList m_display_lines;
QString m_filter;
+ bool m_warnings_and_errors_only{false};
int m_load_limit{1000};
bool m_has_more_lines{false};
//! Tail capacity represented by m_all_lines. Kept separate from rowCount
diff --git a/qml/models/rpcconsolemodel.cpp b/qml/models/rpcconsolemodel.cpp
index 30359e48a4..5bac9a9ea0 100644
--- a/qml/models/rpcconsolemodel.cpp
+++ b/qml/models/rpcconsolemodel.cpp
@@ -313,28 +313,6 @@ void RpcConsoleModel::appendFormattedRow(const QString& time, int category, cons
m_output_model.appendRow(time, body, category);
}
-void RpcConsoleModel::ensureWelcomeMessage()
-{
- if (m_welcome_added) return;
- m_welcome_added = true;
-
- const QString warning_open = QStringLiteral("").arg(m_error_color.name());
- QString welcome_message =
- /*: RPC console starter message. Placeholders %1 and %2 are style tags
- and are intentionally adjacent to the warning text. */
- tr("Use ↑↓ arrows to navigate history. Type help for an overview of available commands. "
- "Type help-console for console syntax help.\n"
- "\n"
- "%1WARNING: Scammers and thieves will request that you type commands here to steal your coins. "
- "Do not type any commands unless you fully understand them.%2")
- .arg(warning_open,
- QStringLiteral(""));
- welcome_message.replace(QLatin1Char('\n'), QStringLiteral("
"));
- m_output_model.appendRow(QDateTime::currentDateTime().toString("hh:mm:ss"),
- welcome_message,
- CMD_REPLY);
-}
-
bool RpcConsoleModel::submitCommand(const QString& command, const QString& wallet_name)
{
const QString trimmed_command = command.trimmed();
@@ -448,8 +426,6 @@ void RpcConsoleModel::resetHistoryNavigation()
void RpcConsoleModel::clear()
{
m_output_model.resetAll();
- m_welcome_added = false;
- ensureWelcomeMessage();
}
void RpcConsoleModel::onNodeInitialized()
diff --git a/qml/models/rpcconsolemodel.h b/qml/models/rpcconsolemodel.h
index 8773af26ef..befbfeec0e 100644
--- a/qml/models/rpcconsolemodel.h
+++ b/qml/models/rpcconsolemodel.h
@@ -109,7 +109,6 @@ class RpcConsoleModel : public QObject
QAbstractListModel* outputModel() { return &m_output_model; }
Q_INVOKABLE bool submitCommand(const QString& command, const QString& wallet_name = {});
- Q_INVOKABLE void ensureWelcomeMessage();
/**
* Navigate command history.
@@ -150,7 +149,6 @@ private Q_SLOTS:
QColor m_reply_color{"#CCCCCC"};
QColor m_error_color{"#EC6363"};
QColor m_key_color{"#98C379"};
- bool m_welcome_added{false};
// History (stores redacted/filtered versions only)
QStringList m_history;
diff --git a/qml/pages/node/CommandConsole.qml b/qml/pages/node/CommandConsole.qml
index dfbb97cd19..ca67087fd1 100644
--- a/qml/pages/node/CommandConsole.qml
+++ b/qml/pages/node/CommandConsole.qml
@@ -1,4 +1,4 @@
-// Copyright (c) 2024 The Bitcoin Core developers
+// Copyright (c) 2024-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.
@@ -13,11 +13,14 @@ Page {
id: root
objectName: "commandConsole"
signal back()
- background: null
+ background: Rectangle {
+ color: Theme.color.neutral1
+ radius: 16
+ }
clip: true
- // Theme-aware palette for model-generated inline spans (welcome links,
- // warning text, and JSON keys). Row-level colours are applied by QML so the
+ // Theme-aware palette for model-generated inline spans (welcome links and
+ // JSON keys). Row-level colours are applied by QML so the
// output follows the design-system tokens.
readonly property color consoleRequestColor: Theme.color.blue
readonly property color consoleReplyColor: Theme.color.neutral9
@@ -26,9 +29,18 @@ Page {
readonly property int minimumOutputFontPixelSize: 10
readonly property int maximumOutputFontPixelSize: 18
property int outputFontPixelSize: Theme.text.caption.pixelSize
- property bool searchMode: false
+ property string searchText: ""
property string commandDraft: ""
- property string searchDraft: ""
+ readonly property alias searchResultCount: outputView.searchResultCount
+ readonly property alias currentSearchResultIndex: outputView.currentSearchResultIndex
+
+ function showNextSearchResult() {
+ outputView.showNextSearchResult()
+ }
+
+ function showPreviousSearchResult() {
+ outputView.showPreviousSearchResult()
+ }
// True while this view's tab is the selected one. As a persistent StackLayout
// child, the console is never destroyed on tab changes, and its autocomplete
@@ -61,7 +73,6 @@ Page {
Component.onCompleted: {
_pushPalette()
- rpcConsoleModel.ensureWelcomeMessage()
Qt.callLater(function() { if (root.visible) root.focusInput() })
}
Connections {
@@ -87,7 +98,6 @@ Page {
QtObject {
id: internal
property bool navigatingHistory: false
- property bool switchingInputMode: false
}
property bool showHeader: true
@@ -137,8 +147,8 @@ Page {
rightColumnRole: ""
categoryRole: "category"
fontPixelSize: root.outputFontPixelSize
- fontFamily: Theme.text.caption.family
- fontStyleName: Theme.text.caption.styleName
+ fontFamily: Theme.text.monoFamily
+ fontStyleName: "Regular"
textLineHeight: Math.round(root.outputFontPixelSize * 1.4)
contentColor: Theme.color.neutral9
leftColumnColor: root.consoleTimeColor
@@ -151,30 +161,29 @@ Page {
selectionColor: Theme.color.orange
accessibleName: qsTr("Console output")
autoScrollToBottom: true
- filterText: root.searchMode ? root.searchDraft : ""
- horizontalPadding: 20
- topPadding: 15
- bottomPadding: 15
+ searchText: root.searchText
+ horizontalPadding: 16
+ topPadding: 16
+ bottomPadding: 16
rowSpacing: 5
columnSpacing: 20
leftColumnWidth: 60
}
- // Autocomplete popup (anchored above the input area).
- // Sizing: width matches the input field; height hugs the suggestion
- // ListView's contentHeight so the opaque background cannot spill over
- // the output area (fixes the "display disappears on typing" regression).
- // z is raised so the popup reliably renders above neighbours.
- Popup {
+ // Autocomplete menu, aligned to the command field's left edge and styled
+ // like the shared application context menus.
+ ContextMenu {
id: autocompletePopup
objectName: "consoleAutocompletePopup"
parent: inputArea
- x: inputField.mapToItem(inputArea, 0, 0).x
+ x: inputContent.x + inputField.x
y: -height - 4
z: 10
- width: Math.min(inputField.width, 300)
- height: Math.min(autocompleteList.contentHeight + 8, 200)
- padding: 4
+ width: Math.min(inputField.width, 360)
+ height: Math.min(autocompleteList.contentHeight + 2 * menuPadding, 228)
+ minMenuWidth: 0
+ backgroundColor: Theme.color.neutral2
+ focus: false
// Deliberately NOT CloseOnPressOutside: a real mouse press on the submit
// button would otherwise close this popup (and clear filteredCommands)
// before the button's onClicked fires, so the button could never act on
@@ -182,42 +191,29 @@ Page {
// input losing focus (see the TapHandler and inputField.onActiveFocusChanged).
closePolicy: Popup.CloseOnEscape
onClosed: filteredCommands = []
- background: Rectangle {
- color: Theme.color.neutral1
- border.color: Theme.color.neutral3
- radius: 4
- }
ListView {
id: autocompleteList
objectName: "consoleAutocompleteList"
- anchors.fill: parent
+ Layout.preferredWidth: autocompletePopup.width - 2 * autocompletePopup.menuPadding
+ Layout.preferredHeight: Math.min(contentHeight, 216)
clip: true
currentIndex: autocompleteIndex
highlightFollowsCurrentItem: true
model: filteredCommands
- delegate: ItemDelegate {
+ delegate: ContextMenuButton {
required property string modelData
required property int index
objectName: "consoleAutocomplete_" + index
width: autocompleteList.width
- height: 28
- leftPadding: 8
- rightPadding: 8
- background: Rectangle {
- color: parent.hovered ? Theme.color.neutral2 : "transparent"
- radius: 2
- }
- contentItem: Text {
- text: modelData
- font.family: "monospace"
- font.pixelSize: 13
- color: index === autocompleteIndex ? Theme.color.orange : Theme.color.neutral9
- elide: Text.ElideRight
- }
+ text: modelData
+ autoClose: false
+ selected: index === autocompleteIndex
+ focusPolicy: Qt.NoFocus
+ onHoveredChanged: if (hovered) autocompleteIndex = index
// Apply the suggestion without stealing focus from the
// input field — per MarnixCroes PR #540 feedback.
- onClicked: applySuggestion(modelData)
+ onTriggered: applySuggestion(modelData)
}
}
}
@@ -231,36 +227,6 @@ Page {
inputField.forceActiveFocus()
}
- component ConsoleIconButton: AbstractButton {
- id: consoleIconButton
- required property url iconSource
- required property string accessibleName
-
- Layout.preferredWidth: 20
- Layout.preferredHeight: 20
- implicitWidth: 20
- implicitHeight: 20
- padding: 0
- hoverEnabled: AppMode.isDesktop
- focusPolicy: Qt.TabFocus
-
- Accessible.role: Accessible.Button
- Accessible.name: accessibleName
-
- background: Item {}
-
- contentItem: Icon {
- source: consoleIconButton.iconSource
- color: consoleIconButton.enabled ? Theme.color.neutral9 : Theme.color.neutral4
- size: 20
- opacity: consoleIconButton.hovered && consoleIconButton.enabled ? 0.75 : 1
- }
-
- HoverHandler {
- cursorShape: consoleIconButton.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
- }
- }
-
// Command input area
Rectangle {
id: inputArea
@@ -269,11 +235,10 @@ Page {
left: parent.left
right: parent.right
bottom: parent.bottom
- leftMargin: 20
- rightMargin: 20
}
- height: 41
- color: "transparent"
+ height: 64
+ color: Theme.color.neutral1
+ radius: 16
Rectangle {
id: inputDivider
@@ -284,29 +249,28 @@ Page {
right: parent.right
}
height: 1
- color: Theme.color.neutral5
+ color: Theme.color.neutral2
}
RowLayout {
id: inputContent
objectName: "consoleInputContent"
anchors {
- top: parent.top
- left: parent.left
- right: parent.right
- topMargin: 11
- leftMargin: 55
+ fill: parent
+ leftMargin: 12
+ rightMargin: 12
+ topMargin: 12
+ bottomMargin: 12
}
- height: 20
- spacing: 5
+ spacing: 8
Icon {
objectName: "consolePromptIcon"
- source: root.searchMode ? "image://images/search" : "image://images/caret-right"
- color: Theme.color.neutral9
- size: 20
- Layout.preferredWidth: 20
- Layout.preferredHeight: 20
+ source: "image://images/caret-right"
+ color: Theme.color.orange
+ size: 16
+ Layout.preferredWidth: 16
+ Layout.preferredHeight: 16
Layout.alignment: Qt.AlignVCenter
}
@@ -314,16 +278,23 @@ Page {
id: inputField
objectName: "consoleInput"
Layout.fillWidth: true
- Layout.preferredHeight: 20
- font: Theme.text.caption.font
+ Layout.fillHeight: true
+ font.family: Theme.text.monoFamily
+ font.pixelSize: 13
color: Theme.color.neutral9
- placeholderText: root.searchMode ? qsTr("Search...") : qsTr("Enter command...")
- placeholderTextColor: Theme.color.neutral6
- leftPadding: 0
- rightPadding: 0
+ placeholderText: qsTr("Enter command…")
+ placeholderTextColor: Theme.color.neutral7
+ leftPadding: 12
+ rightPadding: 12
topPadding: 0
bottomPadding: 0
- background: Item {}
+ verticalAlignment: TextInput.AlignVCenter
+ background: Rectangle {
+ color: Theme.color.neutral2
+ radius: 8
+ border.width: inputField.activeFocus ? 2 : 0
+ border.color: Theme.color.orange
+ }
// Enter accepts the highlighted autocomplete suggestion when the
// popup is open; otherwise it submits the command. This mirrors the
@@ -335,48 +306,37 @@ Page {
// Up/Down: navigate autocomplete when popup is open,
// otherwise browse command history.
Keys.onUpPressed: {
- if (!root.searchMode) {
- if (autocompletePopup.visible && filteredCommands.length > 0) {
- autocompleteIndex = Math.max(0, autocompleteIndex - 1)
- } else {
- internal.navigatingHistory = true
- var result = rpcConsoleModel.browseHistory(1, inputField.text)
- inputField.text = result
- inputField.cursorPosition = result.length
- internal.navigatingHistory = false
- }
+ if (autocompletePopup.visible && filteredCommands.length > 0) {
+ autocompleteIndex = Math.max(0, autocompleteIndex - 1)
+ } else {
+ internal.navigatingHistory = true
+ var result = rpcConsoleModel.browseHistory(1, inputField.text)
+ inputField.text = result
+ inputField.cursorPosition = result.length
+ internal.navigatingHistory = false
}
}
Keys.onDownPressed: {
- if (!root.searchMode) {
- if (autocompletePopup.visible && filteredCommands.length > 0) {
- autocompleteIndex = Math.min(filteredCommands.length - 1, autocompleteIndex + 1)
- } else {
- internal.navigatingHistory = true
- var result = rpcConsoleModel.browseHistory(-1, inputField.text)
- inputField.text = result
- inputField.cursorPosition = result.length
- internal.navigatingHistory = false
- }
+ if (autocompletePopup.visible && filteredCommands.length > 0) {
+ autocompleteIndex = Math.min(filteredCommands.length - 1, autocompleteIndex + 1)
+ } else {
+ internal.navigatingHistory = true
+ var result = rpcConsoleModel.browseHistory(-1, inputField.text)
+ inputField.text = result
+ inputField.cursorPosition = result.length
+ internal.navigatingHistory = false
}
}
// Tab key: accept the top autocomplete suggestion.
Keys.onTabPressed: {
- if (!root.searchMode && autocompletePopup.visible && filteredCommands.length > 0) {
+ if (autocompletePopup.visible && filteredCommands.length > 0) {
applySuggestion(filteredCommands[autocompleteIndex])
event.accepted = true
}
}
onTextChanged: {
- if (internal.switchingInputMode) return
- if (root.searchMode) {
- root.searchDraft = inputField.text
- filteredCommands = []
- autocompletePopup.close()
- return
- }
root.commandDraft = inputField.text
if (!internal.navigatingHistory) {
rpcConsoleModel.resetHistoryNavigation()
@@ -394,45 +354,6 @@ Page {
}
}
}
-
- RowLayout {
- id: inputActions
- objectName: "consoleInputActions"
- spacing: 5
- Layout.preferredWidth: 95
- Layout.preferredHeight: 20
- Layout.alignment: Qt.AlignVCenter
-
- ConsoleIconButton {
- objectName: "consoleModeToggleButton"
- iconSource: root.searchMode ? "image://images/console" : "image://images/search"
- accessibleName: root.searchMode ? qsTr("Switch to command input") : qsTr("Search console output")
- onClicked: root.toggleSearchMode()
- }
-
- ConsoleIconButton {
- objectName: "consoleFontIncreaseButton"
- iconSource: "image://images/plus"
- accessibleName: qsTr("Increase console text size")
- enabled: root.outputFontPixelSize < root.maximumOutputFontPixelSize
- onClicked: root.changeOutputFontSize(1)
- }
-
- ConsoleIconButton {
- objectName: "consoleFontDecreaseButton"
- iconSource: "image://images/minus"
- accessibleName: qsTr("Decrease console text size")
- enabled: root.outputFontPixelSize > root.minimumOutputFontPixelSize
- onClicked: root.changeOutputFontSize(-1)
- }
-
- ConsoleIconButton {
- objectName: "consoleClearButton"
- iconSource: "image://images/cross"
- accessibleName: qsTr("Clear console input or output")
- onClicked: root.clearInputOrOutput()
- }
- }
}
}
@@ -442,11 +363,6 @@ Page {
property int autocompleteIndex: 0
function updateFilteredCommands() {
- if (root.searchMode) {
- filteredCommands = []
- autocompletePopup.close()
- return
- }
// Guard: availableCommands is empty until the node is initialised.
// Calling this before init used to throw and abort the textChanged
// handler, which (combined with the oversized popup) made the
@@ -494,26 +410,10 @@ Page {
}
}
- function toggleSearchMode() {
- if (root.searchMode) {
- root.searchDraft = inputField.text
- } else {
- root.commandDraft = inputField.text
- filteredCommands = []
- autocompletePopup.close()
- }
- internal.switchingInputMode = true
- root.searchMode = !root.searchMode
- inputField.text = root.searchMode ? root.searchDraft : root.commandDraft
- internal.switchingInputMode = false
- inputField.forceActiveFocus()
- }
-
function changeOutputFontSize(delta) {
root.outputFontPixelSize = Math.max(root.minimumOutputFontPixelSize,
Math.min(root.maximumOutputFontPixelSize,
root.outputFontPixelSize + delta))
- inputField.forceActiveFocus()
}
function clearInputOrOutput() {
@@ -531,7 +431,6 @@ Page {
// Tab only fills the suggestion in (for adding arguments). To run a different
// command, dismiss the menu first (click away or type past the matches).
function runHighlightedOrSubmit() {
- if (root.searchMode) return
if (autocompletePopup.visible && filteredCommands.length > 0) {
inputField.text = filteredCommands[autocompleteIndex]
autocompletePopup.close()
diff --git a/qml/pages/settings/RpcConsoleSettingsPage.qml b/qml/pages/settings/RpcConsoleSettingsPage.qml
deleted file mode 100644
index 796d7be84f..0000000000
--- a/qml/pages/settings/RpcConsoleSettingsPage.qml
+++ /dev/null
@@ -1,52 +0,0 @@
-// 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 QtQuick.Controls 2.15
-
-import "../../controls"
-import "../node" as NodePages
-
-Page {
- id: root
- objectName: "rpcConsoleSettingsPage"
-
- property string walletName: ""
- property real maximumContentWidth: 840
- property real contentHorizontalPadding: width >= 900 ? 56 : width >= 640 ? 40 : 24
- readonly property alias consoleItem: rpcConsole
-
- background: null
- padding: 0
- clip: true
-
- header: SettingsHeader {
- objectName: "rpcConsoleHeader"
- title: qsTr("RPC console")
- showBackButton: false
- }
-
- Item {
- id: contentFrame
- anchors {
- top: parent.top
- bottom: parent.bottom
- horizontalCenter: parent.horizontalCenter
- topMargin: 20
- bottomMargin: 20
- }
- width: Math.max(0, Math.min(
- parent.width - root.contentHorizontalPadding * 2,
- root.maximumContentWidth))
-
- NodePages.CommandConsole {
- id: rpcConsole
- objectName: "rpcConsole"
- anchors.fill: parent
- showHeader: false
- tabActive: root.visible
- walletName: root.walletName
- }
- }
-}
diff --git a/qml/pages/settings/SettingsDebugLog.qml b/qml/pages/settings/SettingsDebugLog.qml
deleted file mode 100644
index a370c1d977..0000000000
--- a/qml/pages/settings/SettingsDebugLog.qml
+++ /dev/null
@@ -1,351 +0,0 @@
-// 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 QtQuick.Controls 2.15
-import QtQuick.Layouts 1.15
-import org.bitcoincore.qt 1.0
-import "../../controls"
-import "../../components"
-
-Page {
- signal back
-
- id: root
- objectName: "settingsDebugLog"
- background: null
- padding: 0
-
- property int pendingNewLines: 0
- property int displayedLines: 0
- property real maximumContentWidth: 600
- property real contentHorizontalPadding: 20
- property bool ownsDebugLogActivity: false
- onPendingNewLinesChanged: if (pendingNewLines > 0) displayedLines = pendingNewLines
- property bool userIsScrolled: false
-
- function updateDebugLogActivity() {
- if (root.visible) {
- debugLogModel.active = true
- root.ownsDebugLogActivity = true
- } else {
- if (root.ownsDebugLogActivity) debugLogModel.active = false
- root.ownsDebugLogActivity = false
- }
- }
-
- Connections {
- target: debugLogModel
- function onNewLinesAdded(count) {
- if (root.userIsScrolled) root.pendingNewLines += count
- }
- }
-
- // Debounce search text so the C++ filter does not run synchronously on
- // every key press for large log files.
- Timer {
- id: searchDebounce
- interval: 150
- repeat: false
- onTriggered: debugLogModel.filter = searchField.text
- }
-
- // Periodically refresh the "N min ago" labels in-place.
- Timer {
- interval: 60000
- repeat: true
- running: root.visible
- onTriggered: debugLogModel.updateRelativeTimes()
- }
-
- property bool showBackButton: true
-
- header: SettingsHeader {
- title: "debug.log"
- showBackButton: root.showBackButton
- backButtonObjectName: "debugLogBackButton"
- onBack: root.back()
- rightItem: RowLayout {
- spacing: 0
-
- AbstractButton {
- id: exportBtn
- objectName: "debugLogExportButton"
- implicitWidth: 52
- implicitHeight: 52
- hoverEnabled: true
- focusPolicy: Qt.TabFocus
- Accessible.name: qsTr("Export")
- Accessible.role: Accessible.Button
-
- background: Rectangle {
- radius: 5
- color: exportBtn.hovered ? Theme.color.neutral2
- : Theme.color.background
- Behavior on color { ColorAnimation { duration: 150 } }
- }
-
- contentItem: Item {
- Icon {
- anchors.centerIn: parent
- source: "image://images/export"
- color: Theme.color.neutral9
- size: 28
- }
- }
-
- onClicked: debugLogModel.openLogFile()
-
- HoverHandler { cursorShape: Qt.PointingHandCursor }
- }
- }
- }
-
- ColumnLayout {
- id: contentLayout
- objectName: "debugLogContentLayout"
- width: Math.max(0, Math.min(
- parent.width - root.contentHorizontalPadding * 2,
- root.maximumContentWidth))
- anchors {
- top: parent.top
- bottom: parent.bottom
- horizontalCenter: parent.horizontalCenter
- topMargin: 20
- bottomMargin: 20
- }
- spacing: 0
-
- RowLayout {
- id: searchRow
- objectName: "debugLogSearchRow"
- Layout.fillWidth: true
- Layout.preferredHeight: 44
- spacing: 10
-
- Icon {
- objectName: "debugLogSearchIcon"
- source: "image://images/search"
- color: Theme.color.neutral5
- size: 24
-
- Layout.preferredWidth: 24
- Layout.preferredHeight: 24
- Layout.alignment: Qt.AlignVCenter
- }
-
- TextField {
- id: searchField
- objectName: "debugLogSearchField"
- Layout.fillWidth: true
- Layout.preferredHeight: 44
- leftPadding: 0
- rightPadding: 0
- topPadding: 0
- bottomPadding: 0
- font: Theme.text.description.font
- color: Theme.color.neutral9
- placeholderTextColor: Theme.color.neutral5
- placeholderText: qsTr("Search...")
- // The C++ model intentionally retains its filter while this
- // page is cached or closed. Mirror that retained value so the
- // field and rows cannot disagree when the page is shown.
- text: debugLogModel.filter
- verticalAlignment: TextInput.AlignVCenter
- selectByMouse: true
- Accessible.name: qsTr("Search debug log")
- Accessible.role: Accessible.EditableText
- onTextChanged: searchDebounce.restart()
-
- background: Item {}
- }
-
- AbstractButton {
- id: refreshBtn
- objectName: "debugLogRefreshButton"
- Layout.preferredWidth: 20
- Layout.preferredHeight: 20
- implicitWidth: 20
- implicitHeight: 20
- padding: 0
- hoverEnabled: AppMode.isDesktop
- focusPolicy: Qt.TabFocus
- Accessible.name: qsTr("Refresh debug log")
- Accessible.role: Accessible.Button
-
- background: Item {}
-
- contentItem: Icon {
- id: refreshIcon
- objectName: "debugLogRefreshIcon"
- source: "image://images/refresh"
- color: refreshBtn.enabled ? Theme.color.neutral9 : Theme.color.neutral4
- size: 20
- opacity: refreshBtn.hovered && refreshBtn.enabled ? 0.75 : 1
-
- RotationAnimation on rotation {
- id: spinAnimation
- from: 0
- to: 360
- duration: 600
- running: false
- easing.type: Easing.InOutQuad
- }
- }
-
- onClicked: {
- debugLogModel.refresh()
- spinAnimation.restart()
- }
-
- HoverHandler {
- cursorShape: refreshBtn.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
- }
- }
- }
-
- Separator {
- objectName: "debugLogSearchDivider"
- Layout.fillWidth: true
- Layout.preferredHeight: 1
- }
-
- DebugLogOutputView {
- id: logView
- objectName: "debugLogListView"
- Layout.fillWidth: true
- Layout.fillHeight: true
-
- listModel: debugLogModel
- topPadding: 10
- accessibleName: qsTr("Debug log entries")
- autoScrollToBottom: false
- // DebugLogModel renders newest-first at the top, so leaving the
- // beginning is exactly when the "N new entries" pill applies.
- onScrolled: function() {
- // A ListView's content origin is not guaranteed to be zero,
- // particularly with variable-height rows and incremental model
- // changes. Its boundary state is the authoritative answer.
- root.userIsScrolled = !logView.atTop
- if (logView.atTop && root.pendingNewLines > 0) {
- root.pendingNewLines = 0
- }
- }
- }
-
- // Reserved slot so the log view's height does not jitter when the
- // "Load more" affordance appears / disappears. Only the button
- // itself toggles visibility.
- Item {
- Layout.fillWidth: true
- Layout.preferredHeight: 36
-
- TextButton {
- objectName: "debugLogLoadMoreButton"
- anchors.centerIn: parent
- text: qsTr("Load more")
- textSize: 13
- bold: false
- visible: debugLogModel.hasMoreLines && logView.atBottom
- onClicked: debugLogModel.loadMore()
- }
- }
- }
-
- Item {
- anchors.fill: contentLayout
- z: 10
-
- Rectangle {
- id: newEntriesPill
- anchors.horizontalCenter: parent.horizontalCenter
- y: logView.y + 10
-
- visible: opacity > 0
- opacity: (root.pendingNewLines > 0 && root.userIsScrolled) ? 1.0 : 0.0
- Behavior on opacity { NumberAnimation { duration: 150 } }
-
- width: 16 + arrowText.implicitWidth + 8 + countText.implicitWidth + 24 + closeText.width + 24
- height: 32
- radius: 16
-
- Behavior on color { ColorAnimation { duration: 150 } }
- color: pressHandler.pressed ? Theme.color.orangeLight2
- : hoverHandler.hovered ? Theme.color.orangeLight1
- : Theme.color.orange
-
- Text {
- id: arrowText
- text: "↑"
- color: "white"
- font.pixelSize: 15
- font.family: Theme.text.family
- font.bold: true
- anchors.left: parent.left
- anchors.leftMargin: 16
- anchors.verticalCenter: parent.verticalCenter
- }
-
- Text {
- id: countText
- text: root.displayedLines === 1
- ? qsTr("1 new entry")
- : qsTr("%1 new entries").arg(root.displayedLines)
- color: "white"
- font.pixelSize: 13
- font.family: Theme.text.family
- anchors.left: arrowText.right
- anchors.leftMargin: 8
- anchors.verticalCenter: parent.verticalCenter
- }
-
- Text {
- id: closeText
- text: "×"
- color: "white"
- font.pixelSize: 20
- font.bold: true
- anchors.right: parent.right
- anchors.rightMargin: 14
- anchors.verticalCenter: parent.verticalCenter
- opacity: closeArea.containsMouse ? 1.0 : 0.85
- }
-
- MouseArea {
- id: closeArea
- anchors {
- right: parent.right
- top: parent.top
- bottom: parent.bottom
- rightMargin: 6
- }
- width: 32
- hoverEnabled: true
- cursorShape: Qt.PointingHandCursor
- onClicked: root.pendingNewLines = 0
- }
-
- HoverHandler {
- id: hoverHandler
- acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
- cursorShape: Qt.PointingHandCursor
- }
-
- TapHandler {
- id: pressHandler
- acceptedButtons: Qt.LeftButton
- onTapped: {
- logView.scrollToTop()
- root.pendingNewLines = 0
- }
- }
- }
- }
-
- Component.onCompleted: root.updateDebugLogActivity()
- onVisibleChanged: root.updateDebugLogActivity()
- Component.onDestruction: {
- if (root.ownsDebugLogActivity) debugLogModel.active = false
- }
-}
diff --git a/qml/pages/settings/SettingsDebugLogView.qml b/qml/pages/settings/SettingsDebugLogView.qml
new file mode 100644
index 0000000000..f1505844b8
--- /dev/null
+++ b/qml/pages/settings/SettingsDebugLogView.qml
@@ -0,0 +1,355 @@
+// 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 QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+
+import "../../controls"
+import "../../components"
+
+SettingsPage {
+ id: root
+
+ objectName: "debugLogView"
+ title: qsTr("Debug log")
+ showBackButton: false
+ maximumContentWidth: width
+ contentSpacing: 20
+
+ property bool ownsDebugLogActivity: false
+ property bool followNewMessages: true
+ property bool followAppend: false
+ property int prependAnchorIndex: -1
+ property real prependAnchorOffset: 0
+
+ function emptyMessage() {
+ if (debugLogModel.filter.length > 0) return qsTr("No log messages match this search")
+ if (debugLogModel.warningsAndErrorsOnly) return qsTr("No warnings or errors in the loaded messages")
+ return qsTr("No log messages")
+ }
+
+ function updateDebugLogActivity() {
+ if (root.visible) {
+ debugLogModel.active = true
+ root.ownsDebugLogActivity = true
+ } else {
+ if (root.ownsDebugLogActivity) debugLogModel.active = false
+ root.ownsDebugLogActivity = false
+ }
+ }
+
+ function scrollToTop() {
+ logList.forceLayout()
+ logList.positionViewAtBeginning()
+ logList.contentY = logList.originY
+ logList.returnToBounds()
+ }
+
+ function scrollToBottom() {
+ logList.forceLayout()
+ logList.positionViewAtEnd()
+ logList.returnToBounds()
+ }
+
+ function firstVisibleIndex() {
+ const firstY = logList.contentY
+ for (let offset = 0; offset <= 48; ++offset) {
+ const candidate = logList.indexAt(1, firstY + offset)
+ if (candidate >= 0) return candidate
+ }
+ return -1
+ }
+
+ PageHeading {
+ id: pageHeading
+ objectName: "debugLogPageHeading"
+ Layout.fillWidth: true
+ description: qsTr("Live diagnostic messages from Bitcoin Core.")
+ }
+
+ RowLayout {
+ id: toolsRow
+ objectName: "debugLogToolsRow"
+ Layout.fillWidth: true
+ spacing: 16
+
+ SearchBar {
+ id: searchBar
+ objectName: "debugLogSearchBar"
+ fieldObjectName: "debugLogSearchField"
+ searchIconObjectName: "debugLogSearchIcon"
+ clearButtonObjectName: "debugLogSearchClearButton"
+ Layout.fillWidth: true
+ Layout.minimumWidth: 140
+ Layout.maximumWidth: implicitWidth
+ text: debugLogModel.filter
+ placeholderText: qsTr("Search messages")
+ accessibleName: qsTr("Search debug log messages")
+ onTextChanged: searchDebounce.restart()
+ }
+
+ FilterButton {
+ id: messageFilterButton
+ objectName: "debugLogFilterButton"
+ active: debugLogModel.warningsAndErrorsOnly
+ Accessible.name: qsTr("Filter debug log messages")
+ onClicked: {
+ if (messageFilterMenu.opened) {
+ messageFilterMenu.close()
+ } else {
+ messageFilterMenu.open()
+ }
+ }
+ }
+
+ ContextMenu {
+ id: messageFilterMenu
+ objectName: "debugLogFilterMenu"
+ parent: messageFilterButton
+ x: parent.width - width
+ y: parent.height + 2
+ modal: true
+ dim: false
+
+ ContextMenuPicker {
+ id: messageFilterPicker
+ objectName: "debugLogMessageFilterPicker"
+ objectNameRole: "objectName"
+ currentValue: debugLogModel.warningsAndErrorsOnly
+ ? "warnings-and-errors"
+ : "all"
+ model: [
+ { text: qsTr("All messages"), value: "all", objectName: "debugLogFilterAllMessages" },
+ { text: qsTr("Warnings and errors"), value: "warnings-and-errors", objectName: "debugLogFilterWarningsAndErrors" }
+ ]
+ onActivated: function(value) {
+ debugLogModel.warningsAndErrorsOnly = value === "warnings-and-errors"
+ messageFilterMenu.close()
+ }
+ }
+ }
+
+ Item { Layout.fillWidth: true }
+
+ OverflowMenuButton {
+ id: logOptionsButton
+ objectName: "debugLogOptionsButton"
+ checked: logOptionsMenu.opened
+ Accessible.name: qsTr("Debug log options")
+ onClicked: {
+ if (logOptionsMenu.opened) {
+ logOptionsMenu.close()
+ } else {
+ logOptionsMenu.open()
+ }
+ }
+ }
+
+ ContextMenu {
+ id: logOptionsMenu
+ objectName: "debugLogOptionsMenu"
+ parent: logOptionsButton
+ x: parent.width - width
+ y: parent.height + 2
+ modal: true
+ dim: false
+
+ ContextMenuButton {
+ objectName: "debugLogOpenFileButton"
+ text: qsTr("Open debug.log")
+ iconSource: "image://images/export"
+ onTriggered: debugLogModel.openLogFile()
+ }
+ }
+ }
+
+ Shortcut {
+ objectName: "debugLogFindShortcut"
+ enabled: root.visible
+ sequences: [StandardKey.Find]
+ onActivated: {
+ searchBar.focusSearch()
+ searchBar.selectAll()
+ }
+ }
+
+ FormSection {
+ id: tableSection
+ objectName: "debugLogTableSection"
+ Layout.fillWidth: true
+ rowSpacing: 0
+ backgroundColor: Theme.color.neutral1
+
+ DebugLogTitlesHeader {
+ id: titlesHeader
+ objectName: "debugLogTitlesHeader"
+ Layout.fillWidth: true
+ }
+
+ ListView {
+ id: logList
+ objectName: "debugLogListView"
+ Layout.fillWidth: true
+ Layout.preferredHeight: Math.max(300, root.height - 298)
+ clip: true
+ model: debugLogModel
+ spacing: 0
+ cacheBuffer: 48 * 6
+ reuseItems: false
+ boundsBehavior: Flickable.StopAtBounds
+
+ header: Item {
+ width: logList.width
+ height: debugLogModel.hasMoreLines ? 44 : 0
+
+ OutlineButton {
+ objectName: "debugLogLoadMoreButton"
+ anchors.centerIn: parent
+ height: 32
+ visible: parent.height > 0
+ text: qsTr("Load older messages")
+ textFontPixelSize: 13
+ onClicked: debugLogModel.loadMore()
+ }
+ }
+ headerPositioning: ListView.InlineHeader
+
+ ScrollBar.vertical: ScrollBar {
+ policy: ScrollBar.AsNeeded
+ minimumSize: 0.05
+ }
+
+ onAtYEndChanged: root.followNewMessages = atYEnd
+
+ delegate: DebugLogItemRow {
+ required property var model
+ required property int index
+
+ objectName: "debugLogItemRow_" + index
+ width: logList.width
+ alternate: index % 2 === 1
+ timestamp: model.timestamp ?? ""
+ message: model.message ?? ""
+ isError: Boolean(model.isError ?? false)
+ isWarning: Boolean(model.isWarning ?? false)
+ typeColumnWidth: titlesHeader.typeColumnWidth
+ timeColumnWidth: titlesHeader.timeColumnWidth
+ }
+
+ CoreText {
+ anchors.centerIn: parent
+ width: Math.max(0, parent.width - 48)
+ visible: logList.count === 0
+ text: debugLogModel.openError.length > 0
+ ? debugLogModel.openError
+ : root.emptyMessage()
+ color: debugLogModel.openError.length > 0
+ ? Theme.color.red
+ : Theme.color.neutral7
+ font: Theme.text.caption.font
+ horizontalAlignment: Text.AlignHCenter
+ wrap: true
+ }
+ }
+
+ Rectangle {
+ id: tableFooter
+ objectName: "debugLogTableFooter"
+ Layout.fillWidth: true
+ Layout.preferredHeight: 44
+ color: Theme.color.neutral1
+ radius: 16
+
+ Rectangle {
+ objectName: "debugLogTableFooterTopFill"
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ height: parent.radius
+ color: parent.color
+ }
+
+ Rectangle {
+ objectName: "debugLogTableFooterDivider"
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ height: 1
+ color: Theme.color.neutral2
+ }
+
+ OutlineButton {
+ id: scrollToBottomButton
+ objectName: "debugLogScrollToBottomButton"
+ anchors.right: parent.right
+ anchors.rightMargin: 16
+ anchors.verticalCenter: parent.verticalCenter
+ height: 32
+ text: qsTr("Scroll to bottom")
+ textFontPixelSize: 13
+ enabled: logList.count > 0 && !logList.atYEnd
+ onClicked: root.scrollToBottom()
+ }
+ }
+ }
+
+ Timer {
+ id: searchDebounce
+ interval: 150
+ repeat: false
+ onTriggered: debugLogModel.filter = searchBar.text
+ }
+
+ Connections {
+ target: debugLogModel
+
+ function onRowsAboutToBeInserted(parent, first, last) {
+ root.followAppend = first === logList.count && logList.atYEnd
+ if (first === 0 && logList.count > 0) {
+ logList.forceLayout()
+ const anchorIndex = root.firstVisibleIndex()
+ const anchorItem = anchorIndex >= 0 ? logList.itemAtIndex(anchorIndex) : null
+ if (anchorItem) {
+ root.prependAnchorIndex = anchorIndex + last - first + 1
+ root.prependAnchorOffset = anchorItem.y - logList.contentY
+ }
+ }
+ }
+
+ function onRowsInserted(parent, first, last) {
+ if (root.prependAnchorIndex >= 0 && first === 0) {
+ Qt.callLater(function() {
+ logList.forceLayout()
+ logList.positionViewAtIndex(root.prependAnchorIndex, ListView.Beginning)
+ logList.forceLayout()
+ const anchorItem = logList.itemAtIndex(root.prependAnchorIndex)
+ if (anchorItem) {
+ logList.contentY = anchorItem.y - root.prependAnchorOffset
+ logList.returnToBounds()
+ }
+ root.prependAnchorIndex = -1
+ })
+ } else if (root.followAppend) {
+ root.followAppend = false
+ Qt.callLater(root.scrollToBottom)
+ }
+ }
+
+ function onModelReset() {
+ if (logList.count > 0) Qt.callLater(root.scrollToBottom)
+ }
+ }
+
+ Component.onCompleted: {
+ root.pageHeader.objectName = "debugLogSettingsHeader"
+ root.contentLayout.objectName = "debugLogContentLayout"
+ root.updateDebugLogActivity()
+ if (logList.count > 0) Qt.callLater(root.scrollToBottom)
+ }
+ onVisibleChanged: root.updateDebugLogActivity()
+ Component.onDestruction: {
+ if (root.ownsDebugLogActivity) debugLogModel.active = false
+ }
+}
diff --git a/qml/pages/settings/SettingsRpcConsoleView.qml b/qml/pages/settings/SettingsRpcConsoleView.qml
new file mode 100644
index 0000000000..807bda313a
--- /dev/null
+++ b/qml/pages/settings/SettingsRpcConsoleView.qml
@@ -0,0 +1,212 @@
+// 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 QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+import org.bitcoincore.qt 1.0
+
+import "../../controls"
+import "../../components"
+import "../node" as NodePages
+
+SettingsPage {
+ id: root
+
+ objectName: "rpcConsoleSettingsPage"
+ title: qsTr("RPC console")
+ showBackButton: false
+ maximumContentWidth: width
+ contentSpacing: 20
+ contentBottomPadding: 20
+
+ property string walletName: ""
+ property bool warningVisible: true
+ readonly property alias consoleItem: rpcConsole
+
+ component FontSizeButton: AbstractButton {
+ id: fontSizeButton
+
+ required property string accessibleName
+ required property int labelPixelSize
+
+ implicitWidth: 36
+ implicitHeight: 36
+ padding: 0
+ hoverEnabled: AppMode.isDesktop
+ focusPolicy: Qt.TabFocus
+
+ Accessible.role: Accessible.Button
+ Accessible.name: accessibleName
+
+ background: Rectangle {
+ color: fontSizeButton.hovered || fontSizeButton.pressed
+ ? Theme.color.neutral2
+ : "transparent"
+ radius: 8
+ }
+
+ FocusBorder {
+ objectName: fontSizeButton.objectName + "FocusBorder"
+ visible: fontSizeButton.activeFocus
+ borderRadius: 12
+ z: 1
+ }
+
+ contentItem: CoreText {
+ objectName: fontSizeButton.objectName + "Label"
+ text: "A"
+ color: fontSizeButton.enabled ? Theme.color.neutral9 : Theme.color.neutral4
+ font.family: Theme.text.family
+ font.pixelSize: fontSizeButton.labelPixelSize
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ HoverHandler {
+ cursorShape: fontSizeButton.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
+ }
+ }
+
+ PageHeading {
+ id: pageHeading
+ objectName: "rpcConsolePageHeading"
+ Layout.fillWidth: true
+ description: qsTr("Execute RPC commands and inspect their responses.")
+ }
+
+ ToastBanner {
+ id: warningBanner
+ objectName: "rpcConsoleWarningBanner"
+ Layout.fillWidth: true
+ visible: root.warningVisible
+ iconSource: "image://images/alert-filled"
+ iconColor: Theme.color.red
+ textColor: Theme.color.neutral9
+ backgroundColor: Qt.rgba(Theme.color.red.r,
+ Theme.color.red.g,
+ Theme.color.red.b,
+ 0.12)
+ showsCloseButton: true
+ text: qsTr("Beware of scammers who may ask you to enter commands here to steal your funds. Only enter commands you fully understand.")
+ onDismissed: root.warningVisible = false
+ }
+
+ RowLayout {
+ id: toolbar
+ objectName: "rpcConsoleToolbar"
+ Layout.fillWidth: true
+ spacing: 16
+
+ SearchBar {
+ id: searchBar
+ objectName: "rpcConsoleSearchBar"
+ fieldObjectName: "rpcConsoleSearchField"
+ searchIconObjectName: "rpcConsoleSearchIcon"
+ clearButtonObjectName: "rpcConsoleSearchClearButton"
+ navigationControlObjectName: "rpcConsoleSearchNavigation"
+ previousButtonObjectName: "rpcConsoleSearchPreviousButton"
+ nextButtonObjectName: "rpcConsoleSearchNextButton"
+ Layout.fillWidth: true
+ Layout.minimumWidth: 140
+ Layout.maximumWidth: implicitWidth
+ placeholderText: qsTr("Search console")
+ accessibleName: qsTr("Search RPC console output")
+ showNavigationButtons: true
+ navigationEnabled: rpcConsole.searchResultCount > 0
+ nextTabItem: decreaseButton
+ onPreviousRequested: rpcConsole.showPreviousSearchResult()
+ onNextRequested: rpcConsole.showNextSearchResult()
+ }
+
+ Item { Layout.fillWidth: true }
+
+ Control {
+ id: fontStepper
+ objectName: "consoleFontStepper"
+ implicitWidth: 72
+ implicitHeight: 36
+ padding: 0
+ focusPolicy: Qt.NoFocus
+
+ Accessible.role: Accessible.SpinBox
+ Accessible.name: qsTr("Console font size")
+ Accessible.description: qsTr("%1 pixels").arg(rpcConsole.outputFontPixelSize)
+
+ background: Rectangle {
+ color: Theme.color.neutral1
+ radius: 8
+ }
+
+ contentItem: RowLayout {
+ spacing: 0
+
+ FontSizeButton {
+ id: decreaseButton
+
+ objectName: "consoleFontDecreaseButton"
+ accessibleName: qsTr("Decrease console text size")
+ labelPixelSize: 11
+ enabled: rpcConsole.outputFontPixelSize > rpcConsole.minimumOutputFontPixelSize
+ KeyNavigation.tab: increaseButton
+ KeyNavigation.backtab: searchBar.nextNavigationButton
+ onClicked: rpcConsole.changeOutputFontSize(-1)
+ }
+
+ FontSizeButton {
+ id: increaseButton
+
+ objectName: "consoleFontIncreaseButton"
+ accessibleName: qsTr("Increase console text size")
+ labelPixelSize: 17
+ enabled: rpcConsole.outputFontPixelSize < rpcConsole.maximumOutputFontPixelSize
+ KeyNavigation.backtab: decreaseButton
+ onClicked: rpcConsole.changeOutputFontSize(1)
+ }
+ }
+ }
+ }
+
+ NodePages.CommandConsole {
+ id: rpcConsole
+ objectName: "rpcConsole"
+ Layout.fillWidth: true
+ Layout.preferredHeight: Math.max(360, root.height - 342)
+ + (warningBanner.visible
+ ? 0
+ : warningBanner.implicitHeight + root.contentSpacing)
+ showHeader: false
+ tabActive: root.visible
+ walletName: root.walletName
+ searchText: searchBar.text
+ }
+
+ CoreText {
+ id: helpFooter
+ objectName: "rpcConsoleHelpFooter"
+ Layout.fillWidth: true
+ Layout.leftMargin: 12
+ Layout.rightMargin: 12
+ text: qsTr("Use ↑↓ arrows to navigate history. Type help for an overview of available commands. Type help-console for console syntax help.")
+ color: Theme.color.neutral7
+ font: Theme.text.caption.font
+ horizontalAlignment: Text.AlignHCenter
+ wrap: true
+ }
+
+ Shortcut {
+ objectName: "rpcConsoleFindShortcut"
+ enabled: root.visible
+ sequences: [StandardKey.Find]
+ onActivated: {
+ searchBar.focusSearch()
+ searchBar.selectAll()
+ }
+ }
+
+ Component.onCompleted: {
+ root.pageHeader.objectName = "rpcConsoleHeader"
+ root.contentLayout.objectName = "rpcConsoleContentLayout"
+ }
+}
diff --git a/qml/pages/wallet/Activity.qml b/qml/pages/wallet/Activity.qml
index 90e8aefd9c..68f4aaeb24 100644
--- a/qml/pages/wallet/Activity.qml
+++ b/qml/pages/wallet/Activity.qml
@@ -373,6 +373,7 @@ PageStack {
iconColor: Theme.color.neutral7
activeColor: Theme.color.orange
size: 30
+ iconSize: 24
onClicked: root.toggleFilters()
}
}
diff --git a/qml/pages/wallet/SignVerifyMessage.qml b/qml/pages/wallet/SignVerifyMessage.qml
index 1bb386162c..6fe0b25046 100644
--- a/qml/pages/wallet/SignVerifyMessage.qml
+++ b/qml/pages/wallet/SignVerifyMessage.qml
@@ -177,7 +177,7 @@ SettingsPage {
OutlineButton {
objectName: "signMessageClearButton"
- embedded: true
+ isOnSurface: true
Layout.preferredWidth: 140
text: qsTr("Clear all")
onClicked: root.clearSignForm()
@@ -280,7 +280,7 @@ SettingsPage {
OutlineButton {
objectName: "verifyMessageClearButton"
- embedded: true
+ isOnSurface: true
Layout.preferredWidth: 140
text: qsTr("Clear all")
onClicked: root.clearVerifyForm()
diff --git a/qml/res/icons/cross-circle-filled.png b/qml/res/icons/cross-circle-filled.png
new file mode 100644
index 0000000000..b9206f4820
Binary files /dev/null and b/qml/res/icons/cross-circle-filled.png differ
diff --git a/qml/res/icons/filter-active.png b/qml/res/icons/filter-active.png
new file mode 100644
index 0000000000..9becea3a30
Binary files /dev/null and b/qml/res/icons/filter-active.png differ
diff --git a/qml/res/icons/filter.png b/qml/res/icons/filter.png
new file mode 100644
index 0000000000..a79790037e
Binary files /dev/null and b/qml/res/icons/filter.png differ
diff --git a/qml/res/icons/search.png b/qml/res/icons/search.png
index 6a8ed9a7b5..967b50210e 100644
Binary files a/qml/res/icons/search.png and b/qml/res/icons/search.png differ
diff --git a/qml/res/src/filter-active.svg b/qml/res/src/filter-active.svg
new file mode 100644
index 0000000000..adeb535558
--- /dev/null
+++ b/qml/res/src/filter-active.svg
@@ -0,0 +1,11 @@
+
diff --git a/qml/res/src/filter.svg b/qml/res/src/filter.svg
new file mode 100644
index 0000000000..a4c4543330
--- /dev/null
+++ b/qml/res/src/filter.svg
@@ -0,0 +1,7 @@
+
diff --git a/test/functional/qml_test_console.py b/test/functional/qml_test_console.py
index 97590ad375..50d28b3c8d 100644
--- a/test/functional/qml_test_console.py
+++ b/test/functional/qml_test_console.py
@@ -21,7 +21,6 @@
import sys
import re
-import time
from qml_test_harness import (
QmlTestHarness,
@@ -72,9 +71,9 @@ def submit_console_command(gui, command):
gui.invoke("rpcConsole", "runHighlightedOrSubmit")
-def test_console_input_bar_matches_design(gui):
- """Console input bar follows the Figma Console input component geometry."""
- print("\n── test_console_input_bar_matches_design ───────────────────────")
+def test_console_page_matches_design(gui):
+ """Console page uses the settings layout, toolbar, and command footer."""
+ print("\n── test_console_page_matches_design ────────────────────────────")
root_width = gui.get_property("rpcConsole", "width")
row_x = gui.get_property("consoleInputRow", "x")
@@ -88,40 +87,33 @@ def test_console_input_bar_matches_design(gui):
prompt_width = gui.get_property("consolePromptIcon", "width")
prompt_height = gui.get_property("consolePromptIcon", "height")
input_x = gui.get_property("consoleInput", "x")
- action_x = gui.get_property("consoleInputActions", "x")
- action_width = gui.get_property("consoleInputActions", "width")
- action_height = gui.get_property("consoleInputActions", "height")
- assert_close(row_x, 20, "console input row x")
- assert_close(row_width, root_width - 40, "console input row width")
- assert_close(row_height, 41, "console input row height")
+ assert_close(row_x, 0, "console input row x")
+ assert_close(row_width, root_width, "console input row width")
+ assert_close(row_height, 64, "console input row height")
assert_close(divider_width, row_width, "console input divider width")
- assert_close(divider_height, 1, "console input divider height")
- assert_close(content_x, 55, "console input content x")
- assert_close(content_y, 11, "console input content y")
- assert_close(content_height, 20, "console input content height")
- assert_close(prompt_width, 20, "console prompt icon width")
- assert_close(prompt_height, 20, "console prompt icon height")
- assert_close(content_x + input_x, 80, "console text field x within row")
- assert_close(action_width, 95, "console action cluster width")
- assert_close(action_height, 20, "console action cluster height")
- assert_close(content_x + action_x, row_width - 95, "console action cluster right alignment")
- assert gui.get_property("consoleInput", "placeholderText") == "Enter command..."
- assert gui.get_property("rpcConsole", "searchMode") is False
-
- gui.click("consoleModeToggleButton")
- gui.wait_for_property("rpcConsole", "searchMode", True, timeout_ms=3000)
- assert gui.get_property("consoleInput", "placeholderText") == "Search..."
+ assert_close(divider_height, 1, "console footer separator height")
+ assert_close(content_x, 12, "console input content x")
+ assert_close(content_y, 12, "console input content y")
+ assert_close(content_height, 40, "console input content height")
+ assert_close(prompt_width, 16, "console prompt icon width")
+ assert_close(prompt_height, 16, "console prompt icon height")
+ assert_close(content_x + input_x, 36, "console text field x within row")
+ assert gui.get_property("consoleInput", "placeholderText") == "Enter command…"
+ assert gui.get_property("rpcConsoleSearchField", "placeholderText") == "Search console"
+ assert gui.get_property("rpcConsoleSearchPreviousButton", "enabled") is False
+ assert gui.get_property("rpcConsoleSearchNextButton", "enabled") is False
+ assert gui.get_property("rpcConsoleWarningBanner", "text") == (
+ "Beware of scammers who may ask you to enter commands here to steal your funds. "
+ "Only enter commands you fully understand."
+ )
gui.click("consoleFontIncreaseButton")
assert gui.get_property("rpcConsole", "outputFontPixelSize") == 14
gui.click("consoleFontDecreaseButton")
assert gui.get_property("rpcConsole", "outputFontPixelSize") == 13
- gui.click("consoleModeToggleButton")
- gui.wait_for_property("rpcConsole", "searchMode", False, timeout_ms=3000)
- assert gui.get_property("consoleInput", "placeholderText") == "Enter command..."
- print(" PASSED: console input bar geometry and controls match the design component")
+ print(" PASSED: settings toolbar, font stepper, and command footer match the design")
def assert_console_entry_geometry(gui, index, row_width):
@@ -140,20 +132,18 @@ def test_console_output_rows_match_design(gui):
"""Console output rows follow the Figma Console entry component geometry."""
print("\n── test_console_output_rows_match_design ───────────────────────")
- gui.wait_for_property("rpcConsole", "outputCount", lambda v: v >= 1, timeout_ms=3000)
+ assert gui.get_property("rpcConsole", "outputCount") == 0
root_width = gui.get_property("rpcConsole", "width")
- column_width = root_width - 40
+ column_width = root_width - 32
- assert_close(gui.get_property("consoleOutputArea_contentColumn", "x"), 20, "console output column x")
+ assert_close(gui.get_property("consoleOutputArea_contentColumn", "x"), 16, "console output column x")
assert_close(gui.get_property("consoleOutputArea_contentColumn", "width"), column_width, "console output column width")
- assert_close(gui.get_property("consoleOutputArea_contentColumn", "topPadding"), 15, "console output top padding")
- assert_console_entry_geometry(gui, 0, column_width)
-
- welcome_time = gui.get_text("consoleOutputArea_left_0")
- assert re.fullmatch(r"\d\d:\d\d:\d\d", welcome_time), f"Unexpected welcome timestamp: {welcome_time!r}"
- welcome_text = gui.get_text("consoleOutputArea_content_0")
- assert "Use ↑↓ arrows" in welcome_text
- assert "help-console" in welcome_text
+ assert_close(gui.get_property("consoleOutputArea_contentColumn", "topPadding"), 16, "console output top padding")
+ help_text = gui.get_text("rpcConsoleHelpFooter")
+ assert help_text == (
+ "Use ↑↓ arrows to navigate history. Type help for an overview of available commands. "
+ "Type help-console for console syntax help."
+ )
count_before = gui.get_property("rpcConsole", "outputCount")
submit_console_command(gui, "getblockcount")
@@ -172,6 +162,21 @@ def test_console_output_rows_match_design(gui):
request_text = gui.get_text(f"consoleOutputArea_content_{request_index}")
assert "getblockcount" in request_text
assert ">>" not in request_text
+
+ # Searching selects the matching occurrence without removing any rows.
+ output_count = gui.get_property("rpcConsole", "outputCount")
+ gui.set_text("rpcConsoleSearchField", "getblockcount")
+ gui.wait_for_property("rpcConsole", "searchResultCount", 1, timeout_ms=3000)
+ assert gui.get_property("rpcConsole", "outputCount") == output_count
+ assert gui.get_property("rpcConsoleSearchPreviousButton", "enabled") is True
+ assert gui.get_property("rpcConsoleSearchNextButton", "enabled") is True
+ assert gui.get_property(
+ f"consoleOutputArea_content_{request_index}", "selectedText"
+ ).lower() == "getblockcount"
+ gui.click("rpcConsoleSearchNextButton")
+ assert gui.get_property("rpcConsole", "currentSearchResultIndex") == 0
+ gui.set_text("rpcConsoleSearchField", "")
+ gui.wait_for_property("rpcConsole", "searchResultCount", 0, timeout_ms=3000)
print(" PASSED: console output entry geometry, timestamps, and categories match design")
@@ -235,6 +240,14 @@ def test_autocomplete_popup_appears(gui):
gui.set_text("consoleInput", "getblock")
# Popup should become visible since "getblock" matches commands like getblockcount
gui.wait_for_property("consoleAutocompletePopup", "visible", True, timeout_ms=3000)
+ popup_x = gui.get_property("consoleAutocompletePopup", "x")
+ field_x = (
+ gui.get_property("consoleInputContent", "x")
+ + gui.get_property("consoleInput", "x")
+ )
+ assert_close(popup_x, field_x, "autocomplete menu left alignment")
+ assert_close(gui.get_property("consoleAutocomplete_0", "height"), 36,
+ "autocomplete context-menu item height")
print(" PASSED: autocomplete popup appeared for partial command")
# Clear for next test
gui.set_text("consoleInput", "")
@@ -245,8 +258,9 @@ def test_autocomplete_popup_hidden_no_match(gui):
print("\n── test_autocomplete_popup_hidden_no_match ─────────────────────")
gui.set_text("consoleInput", "zzzznotacommand")
- visible = gui.get_property("consoleAutocompletePopup", "visible")
- assert visible == False, f"Expected popup hidden for no-match input, got {visible}"
+ gui.wait_for_property(
+ "consoleAutocompletePopup", "visible", False, timeout_ms=3000
+ )
print(" PASSED: autocomplete popup hidden for non-matching input")
gui.set_text("consoleInput", "")
@@ -286,27 +300,20 @@ def test_back_navigation(gui):
print(" PASSED: back navigation returned to NodeRunner")
-def test_clear_button_restores_welcome_output(gui):
- """The X action clears prior output and restores the welcome row."""
- print("\n── test_clear_button_restores_welcome_output ───────────────────")
+def test_clear_removes_output_and_keeps_help_footer(gui):
+ """Clearing removes console rows while keeping help outside the card."""
+ print("\n── test_clear_removes_output_and_keeps_help_footer ─────────────")
gui.set_text("consoleInput", "")
assert gui.get_property("rpcConsole", "outputCount") > 0
- welcome_time_before = gui.get_text("consoleOutputArea_left_0")
-
- time.sleep(1.1)
- gui.click("consoleClearButton")
- gui.wait_for_property("rpcConsole", "outputCount", 1, timeout_ms=3000)
- welcome_time_after = gui.get_text("consoleOutputArea_left_0")
- assert re.fullmatch(r"\d\d:\d\d:\d\d", welcome_time_after), (
- f"Unexpected welcome timestamp after clear: {welcome_time_after!r}"
+ gui.invoke("rpcConsole", "clearInputOrOutput")
+ gui.wait_for_property("rpcConsole", "outputCount", 0, timeout_ms=3000)
+ assert gui.get_text("rpcConsoleHelpFooter") == (
+ "Use ↑↓ arrows to navigate history. Type help for an overview of available commands. "
+ "Type help-console for console syntax help."
)
- assert welcome_time_after != welcome_time_before, "Expected clear to re-add welcome row with a fresh timestamp"
- welcome_text = gui.get_text("consoleOutputArea_content_0")
- assert "Use ↑↓ arrows" in welcome_text
- assert "help-console" in welcome_text
- print(" PASSED: clear action restored the welcome output with a fresh timestamp")
+ print(" PASSED: clear removed output and retained the external help footer")
# ── Entry point ───────────────────────────────────────────────────────────────
@@ -326,7 +333,7 @@ def main():
navigate_to_console(gui)
# Run the test cases.
- test_console_input_bar_matches_design(gui)
+ test_console_page_matches_design(gui)
test_console_output_rows_match_design(gui)
test_execute_getblockcount(gui)
test_execute_help(gui)
@@ -335,7 +342,7 @@ def main():
test_autocomplete_popup_hidden_no_match(gui)
test_autocomplete_click_applies_suggestion(gui)
test_autocomplete_help_variants(gui)
- test_clear_button_restores_welcome_output(gui)
+ test_clear_removes_output_and_keeps_help_footer(gui)
test_back_navigation(gui)
print("\nAll console tests passed.")
diff --git a/test/functional/qml_test_debug_log.py b/test/functional/qml_test_debug_log.py
old mode 100644
new mode 100755
index 05be208788..ce2cc0558f
--- a/test/functional/qml_test_debug_log.py
+++ b/test/functional/qml_test_debug_log.py
@@ -2,15 +2,11 @@
# 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.
-"""Test the in-app Debug Log viewer.
+"""Exercise the redesigned Settings → Debug Log page.
-Walks through Settings → Debug Log and verifies:
- 1. The viewer page loads with a visible search bar and log list.
- 2. The initial load is capped while older entries remain available.
- 3. Typing in the search field filters the list; clearing it restores
- the full count.
- 4. Auto-refresh replaces the capped tail without growing it unboundedly.
- 5. The list virtualizes offscreen rows and can load older rows on demand.
+The page shows logs oldest-to-newest with separate Type, Time, and
+Message columns. The initial window is capped, live rows arrive at the bottom,
+and older history can be prepended on demand.
This test requires the binary to be built with -DENABLE_TEST_AUTOMATION=ON.
"""
@@ -37,10 +33,8 @@ def assert_close(actual, expected, label, tolerance=1):
)
-# ── Harness ───────────────────────────────────────────────────────────────────
-
class DebugLogHarness:
- """Launches the GUI node as an onboarded profile on NodeRunner."""
+ """Launch the GUI node with an onboarded regtest profile."""
def __init__(self):
self.gui_binary = find_gui_binary()
@@ -52,16 +46,28 @@ def __init__(self):
self._seed_debug_log_history()
def _seed_debug_log_history(self):
- """Create enough history to exercise the initial cap and Load more."""
network_dir = os.path.join(self.datadir, "regtest")
os.makedirs(network_dir, exist_ok=True)
log_path = os.path.join(network_dir, "debug.log")
- with open(log_path, "w", encoding="utf-8") as f:
- for i in range(SEEDED_HISTORY_LINES):
- f.write(
- "2026-01-01T00:00:00Z "
- f"test-automation seeded-history marker {i}\n"
- )
+ with open(log_path, "w", encoding="utf-8") as log_file:
+ for index in range(SEEDED_HISTORY_LINES):
+ if index == 0:
+ message = "test-automation oldest-history marker"
+ elif index == 1194:
+ message = "[net:warning] test-automation warning marker"
+ elif index == 1195:
+ message = "[net] test-automation ordered-history marker older"
+ elif index == 1196:
+ message = "[rpc] test-automation ordered-history marker newer"
+ elif index == 1197:
+ message = "[net] test-automation network microsecond marker"
+ elif index == 1198:
+ message = "[rpc:error] test-automation rpc error marker"
+ elif index == 1199:
+ message = "[mempool] test-automation mempool marker"
+ else:
+ message = f"test-automation seeded-history marker {index}"
+ log_file.write(f"2026-01-01T00:00:00.123456Z {message}\n")
def start(self):
env = dict(os.environ)
@@ -70,9 +76,6 @@ def start(self):
self.gui_binary,
f"-datadir={self.datadir}",
f"-test-automation={self.socket_path}",
- # Runtime tests are not exercising first-run onboarding.
- # -disablewallet forces AppMode.walletEnabled=false so MainWindow
- # routes to the node/NodeRunner stack instead of desktopWallets.
"-qml_onboarded=1",
"-disablewallet",
"-logtimemicros",
@@ -82,8 +85,7 @@ def start(self):
]
print(f"Starting GUI: {' '.join(args)}")
self.process = subprocess.Popen(
- args, env=env,
- stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ args, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
self.driver = QmlDriver(self.socket_path, timeout=GUI_STARTUP_TIMEOUT)
print("QmlDriver connected to test bridge.")
@@ -103,373 +105,191 @@ def stop(self):
self.tmpdir = None
-# ── Navigation ────────────────────────────────────────────────────────────────
-
def navigate_to_debug_log(gui):
- """From the NodeRunner main screen, navigate to the Debug Log page.
-
- Requires -disablewallet so AppMode.walletEnabled is false and MainWindow
- routes to the node/NodeRunner stack rather than desktopWallets.
- """
gui.wait_for_page("nodeRunner", timeout_ms=10000)
gui.click("nodeSettingsButton")
gui.wait_for_page("settingsView", timeout_ms=5000)
gui.wait_for_property("settingsSidebar_debug-log", "visible", True, timeout_ms=5000)
gui.click("settingsSidebar_debug-log")
- gui.wait_for_page("settingsDebugLog", timeout_ms=5000)
-
+ gui.wait_for_page("debugLogView", timeout_ms=5000)
+
+
+def test_page_structure(gui):
+ print("\n── test_page_structure ──────────────────────────────────────────")
+ for object_name in (
+ "debugLogSettingsHeader",
+ "debugLogPageHeading",
+ "debugLogToolsRow",
+ "debugLogSearchField",
+ "debugLogOptionsButton",
+ "debugLogTableSectionCard",
+ "debugLogListView",
+ "debugLogTitlesHeader",
+ "debugLogTitlesHeaderDivider",
+ "debugLogTableFooter",
+ "debugLogTableFooterDivider",
+ "debugLogScrollToBottomButton",
+ ):
+ gui.wait_for_property(object_name, "visible", True, timeout_ms=5000)
-# ── Test cases ────────────────────────────────────────────────────────────────
-
-def test_viewer_visibility(gui):
- """Verify the search bar and log list are visible on the debug log page."""
- print("\n── test_viewer_visibility ────────────────────────────────────────")
- assert gui.get_property("debugLogSearchField", "visible"), \
- "debugLogSearchField is not visible"
- assert gui.get_property("debugLogListView", "visible"), \
- "debugLogListView is not visible"
- print(" PASSED: search bar and log list are visible")
-
-
-def test_log_has_entries(gui):
- """Verify that the first snapshot is capped at 1,000 rows."""
- print("\n── test_log_has_entries ──────────────────────────────────────────")
count = gui.wait_for_property(
"debugLogListView", "count", INITIAL_LOAD_LIMIT, timeout_ms=5000
)
- assert count == INITIAL_LOAD_LIMIT, (
- f"Expected the initial load to be capped at {INITIAL_LOAD_LIMIT}, "
- f"got count={count}"
- )
- print(f" PASSED: debugLogListView.count = {count}")
- return count
-
+ assert count == INITIAL_LOAD_LIMIT
-def test_search_layout_matches_design(gui):
- """Verify the search row, divider, and first log row follow the design geometry."""
- print("\n── test_search_layout_matches_design ────────────────────────────")
-
- gui.wait_for_property("debugLogSearchField", "height", 44, timeout_ms=3000)
- gui.wait_for_property("debugLogSearchDivider", "height", 1, timeout_ms=3000)
- gui.wait_for_property("debugLogListView", "topPadding", 10, timeout_ms=3000)
- gui.wait_for_property("debugLogListView_row_0", "visible", True, timeout_ms=3000)
- gui.wait_for_property("debugLogListView_lineNumber_0", "visible", True, timeout_ms=3000)
- gui.wait_for_property("debugLogListView_date_0", "visible", True, timeout_ms=3000)
-
- first_row_has_command = gui.get_property("debugLogListView_command_0", "visible")
- if first_row_has_command:
- gui.wait_for_property("debugLogListView_message_0", "visible", True, timeout_ms=3000)
- else:
- gui.wait_for_property("debugLogListView_commandlessMessage_0", "visible", True, timeout_ms=3000)
- gui.wait_for_property("debugLogListView_message_0", "visible", False, timeout_ms=3000)
-
- search_row_y = gui.get_property("debugLogSearchRow", "y")
- search_row_height = gui.get_property("debugLogSearchRow", "height")
- search_height = gui.get_property("debugLogSearchField", "height")
- divider_y = gui.get_property("debugLogSearchDivider", "y")
- divider_height = gui.get_property("debugLogSearchDivider", "height")
- list_y = gui.get_property("debugLogListView", "y")
- first_row_viewport_y = (
- gui.get_property("debugLogListView_row_0", "y")
- - gui.get_property("debugLogListView", "contentY")
- )
- effective_line_number_width = gui.get_property(
- "debugLogListView", "effectiveLineNumberWidth"
- )
- page_width = gui.get_property("settingsDebugLog", "width")
+ page_width = gui.get_property("debugLogView", "width")
content_width = gui.get_property("debugLogContentLayout", "width")
- content_horizontal_padding = gui.get_property(
- "settingsDebugLog", "contentHorizontalPadding"
- )
- maximum_content_width = gui.get_property(
- "settingsDebugLog", "maximumContentWidth"
- )
- expected_content_width = max(
- 0,
- min(page_width - content_horizontal_padding * 2, maximum_content_width),
- )
-
- assert_close(content_width, expected_content_width,
- "debug log content max width")
- assert_close(gui.get_property("debugLogContentLayout", "x"),
- (page_width - content_width) / 2,
- "debug log content horizontal centering")
- assert_close(search_row_height, 44, "debug log search row height")
- assert_close(gui.get_property("debugLogSearchRow", "spacing"), 10,
- "debug log search row spacing")
- assert_close(search_height, 44, "debug log search row height")
- assert_close(gui.get_property("debugLogSearchField", "leftPadding"), 0,
- "debug log search left padding")
- assert_close(gui.get_property("debugLogSearchField", "font.pixelSize"), 15,
- "debug log search font size")
- assert_close(gui.get_property("debugLogSearchIcon", "width"), 24,
- "debug log search icon width")
- assert_close(gui.get_property("debugLogSearchIcon", "height"), 24,
- "debug log search icon height")
- assert_close(gui.get_property("debugLogRefreshButton", "width"), 20,
- "debug log refresh button width")
- assert_close(gui.get_property("debugLogRefreshButton", "height"), 20,
- "debug log refresh button height")
- assert_close(gui.get_property("debugLogRefreshIcon", "width"), 20,
- "debug log refresh icon width")
- assert_close(gui.get_property("debugLogRefreshIcon", "height"), 20,
- "debug log refresh icon height")
+ padding = gui.get_property("debugLogView", "contentHorizontalPadding")
+ maximum_width = gui.get_property("debugLogView", "maximumContentWidth")
+ expected_width = max(0, min(page_width - padding * 2, maximum_width))
+ assert_close(content_width, expected_width, "debug log content width")
assert_close(
- gui.get_property("debugLogRefreshButton", "x") +
- gui.get_property("debugLogRefreshButton", "width"),
- gui.get_property("debugLogSearchRow", "width"),
- "debug log refresh button right alignment",
+ gui.get_property("debugLogContentLayout", "x"),
+ (page_width - content_width) / 2,
+ "debug log content centering",
)
- assert_close(divider_height, 1, "debug log search divider height")
- assert_close(divider_y, search_row_y + search_row_height,
- "debug log search divider y")
- assert_close(list_y, divider_y + divider_height,
- "debug log list y")
- assert_close(gui.get_property("debugLogListView", "topPadding"), 10,
- "debug log list top padding")
- assert_close(gui.get_property("debugLogListView", "rowSpacing"), 10,
- "debug log entry row spacing")
- assert_close(gui.get_property("debugLogListView", "columnSpacing"), 10,
- "debug log entry column spacing")
- assert_close(gui.get_property("debugLogListView", "contentSpacing"), 2,
- "debug log entry command/content spacing")
- assert_close(gui.get_property("debugLogListView", "lineNumberWidth"), 20,
- "debug log line number slot width")
- assert effective_line_number_width >= 20, (
- "Expected the effective line-number slot to honor its 20px minimum"
- )
- assert_close(gui.get_property("debugLogListView", "fontPixelSize"), 12,
- "debug log entry font size")
- assert_close(gui.get_property("debugLogListView", "textLineHeight"), 17,
- "debug log entry line height")
- assert_close(first_row_viewport_y, 10,
- "first debug log row viewport y")
- assert_close(gui.get_property("debugLogListView_row_0", "spacing"), 10,
- "first debug log row column gap")
- assert_close(gui.get_property("debugLogListView_entryContent_0", "spacing"), 2,
- "first debug log entry internal gap")
- assert_close(
- gui.get_property("debugLogListView_lineNumber_0", "width"),
- effective_line_number_width,
- "first debug log line number width",
- )
- first_text_object = (
- "debugLogListView_message_0"
- if first_row_has_command
- else "debugLogListView_commandlessMessage_0"
- )
- assert_close(gui.get_property(first_text_object, "font.pixelSize"), 12,
- "first debug log message font size")
- print(" PASSED: search row, divider, and log entry geometry match design")
-
-
-def test_refresh_button(gui, original_count):
- """Clicking refresh reloads the log without dropping existing entries."""
- print("\n── test_refresh_button ───────────────────────────────────────────")
- gui.click("debugLogRefreshButton")
- count = gui.wait_for_property(
- "debugLogListView", "count", lambda c: c >= original_count, timeout_ms=3000
- )
- assert count >= original_count, (
- f"Expected count >= {original_count} after refresh, got {count}"
- )
- print(f" PASSED: count after refresh = {count}")
+ assert_close(gui.get_property("debugLogOptionsButton", "height"), 36, "options")
+ assert gui.get_property("debugLogOptionsButton", "iconSource") == "image://images/ellipsis"
+ assert_close(gui.get_property("debugLogSearchField", "height"), 40, "search")
+ assert_close(gui.get_property("debugLogTitlesHeader", "height"), 44, "table header")
+ assert_close(gui.get_property("debugLogTitlesHeaderDivider", "height"), 1, "header divider")
+ assert_close(gui.get_property("debugLogTableFooter", "height"), 44, "table footer")
+ assert_close(gui.get_property("debugLogTableFooterDivider", "height"), 1, "footer divider")
+
+ gui.invoke("debugLogView", "scrollToTop")
+ gui.wait_for_property("debugLogItemRow_0", "visible", True, timeout_ms=3000)
+ for suffix in ("TypeIndicator", "Time", "Message"):
+ gui.wait_for_property(f"debugLogItemRow_0{suffix}", "visible", True, timeout_ms=3000)
+ assert gui.get_property("debugLogItemRow_0", "height") >= 48
+ print(" PASSED: redesigned table and capped initial window are visible")
return count
-def test_auto_refresh(gui, datadir, current_count):
- """Appending refreshes the capped tail without exceeding its load limit."""
- print("\n── test_auto_refresh ─────────────────────────────────────────────")
- log_path = os.path.join(datadir, "regtest", "debug.log")
- ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
- marker_body = "test-automation auto-refresh marker"
- marker = f"{ts} {marker_body}"
- with open(log_path, "a", encoding="utf-8") as f:
- f.write(marker + "\n")
-
- # A capped model can insert the new row and remove one old row without
- # changing count. Filter for the unique marker to observe the actual data
- # update rather than treating row-count growth as the refresh signal.
- gui.set_text("debugLogSearchField", marker_body)
+def test_structured_columns_and_filters(gui):
+ print("\n── test_structured_columns_and_filters ─────────────────────────")
+ gui.set_text("debugLogSearchField", "network microsecond marker")
gui.wait_for_property("debugLogListView", "count", 1, timeout_ms=5000)
+ timestamp = gui.get_property("debugLogItemRow_0Time", "text")
+ expected_time = datetime.datetime(
+ 2026, 1, 1, tzinfo=datetime.timezone.utc
+ ).astimezone().strftime("%H:%M:%S")
+ assert timestamp == expected_time, (
+ f"Expected local HH:MM:SS time {expected_time!r}, got {timestamp!r}"
+ )
gui.wait_for_property(
- "debugLogListView_commandlessMessage_0",
- "text",
- marker_body,
- timeout_ms=3000,
+ "debugLogItemRow_0Message", "text",
+ "test-automation network microsecond marker", timeout_ms=3000,
)
-
- gui.wait_for_property("debugLogListView_command_0", "visible", False, timeout_ms=3000)
- gui.wait_for_property("debugLogListView_commandlessMessage_0", "visible", True, timeout_ms=3000)
- gui.wait_for_property("debugLogListView_message_0", "visible", False, timeout_ms=3000)
- gui.invoke("debugLogListView_commandlessMessage_0", "selectAll")
+ gui.invoke("debugLogItemRow_0Message", "selectAll")
gui.wait_for_property(
- "debugLogListView_commandlessMessage_0",
- "selectedText",
- marker_body,
- timeout_ms=3000,
+ "debugLogItemRow_0Message", "selectedText",
+ "test-automation network microsecond marker", timeout_ms=3000,
)
- print(" PASSED: commandless log entry renders inline with the date")
- gui.set_text("debugLogSearchField", "")
- restored_count = gui.wait_for_property(
- "debugLogListView", "count", current_count, timeout_ms=3000
- )
- assert restored_count == INITIAL_LOAD_LIMIT, (
- f"Expected auto-refresh to retain the {INITIAL_LOAD_LIMIT}-row cap, "
- f"got {restored_count}"
- )
- print(
- f" PASSED: appended entry loaded and count remained capped at "
- f"{restored_count}"
- )
- return restored_count
+ gui.set_text("debugLogSearchField", "rpc error marker")
+ gui.wait_for_property("debugLogListView", "count", 1, timeout_ms=5000)
+ gui.click("debugLogOptionsButton")
+ gui.wait_for_property("debugLogOptionsMenu", "opened", True, timeout_ms=3000)
+ gui.click("debugLogFilterWarningsAndErrors")
+ gui.wait_for_property("debugLogListView", "count", 1, timeout_ms=3000)
+ gui.set_text("debugLogSearchField", "warning marker")
+ gui.wait_for_property("debugLogListView", "count", 1, timeout_ms=5000)
+ gui.click("debugLogOptionsButton")
+ gui.wait_for_property("debugLogOptionsMenu", "opened", True, timeout_ms=3000)
+ gui.click("debugLogFilterAllMessages")
+ gui.set_text("debugLogSearchField", "")
+ gui.wait_for_property("debugLogListView", "count", INITIAL_LOAD_LIMIT, timeout_ms=5000)
+ print(" PASSED: type, HH:MM:SS time, message, and warning/error filter work")
-def test_four_digit_line_numbers_are_not_clipped(gui, datadir, current_count):
- """Verify virtualized offscreen rows and the four-digit number column."""
- print("\n── test_four_digit_line_numbers_are_not_clipped ────────────────")
- target_count = 1000
- needed = max(0, target_count - current_count)
- if needed > 0:
- log_path = os.path.join(datadir, "regtest", "debug.log")
- ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
- with open(log_path, "a", encoding="utf-8") as f:
- for i in range(needed):
- f.write(f"{ts} four-digit-line-number marker {i}\n")
- count = gui.wait_for_property(
- "debugLogListView", "count", lambda c: c >= target_count, timeout_ms=5000
+def test_chronological_order(gui):
+ print("\n── test_chronological_order ─────────────────────────────────────")
+ gui.set_text("debugLogSearchField", "ordered-history marker")
+ gui.wait_for_property("debugLogListView", "count", 2, timeout_ms=5000)
+ gui.invoke("debugLogView", "scrollToTop")
+ gui.wait_for_property(
+ "debugLogItemRow_0Message", "text",
+ "test-automation ordered-history marker older", timeout_ms=3000,
)
- assert count >= target_count, (
- f"Expected at least {target_count} lines after append, got {count}"
+ gui.wait_for_property(
+ "debugLogItemRow_1Message", "text",
+ "test-automation ordered-history marker newer", timeout_ms=3000,
)
+ gui.set_text("debugLogSearchField", "")
+ gui.wait_for_property("debugLogListView", "count", INITIAL_LOAD_LIMIT, timeout_ms=5000)
+ print(" PASSED: rows are oldest-to-newest")
- last_index = count - 1
- last_line_number = f"debugLogListView_lineNumber_{last_index}"
- gui.invoke("debugLogListView", "scrollToTop")
- gui.wait_for_property("debugLogListView", "atTop", True, timeout_ms=3000)
- assert not gui.object_exists(last_line_number), (
- f"Expected offscreen row {last_index} not to be instantiated at the top"
- )
- gui.invoke("debugLogListView", "scrollToBottom")
- gui.wait_for_property("debugLogListView", "atBottom", True, timeout_ms=3000)
- gui.wait_for_property(last_line_number, "text", str(count), timeout_ms=3000)
- line_number_width = gui.get_property(last_line_number, "width")
- line_number_implicit_width = gui.get_property(last_line_number, "implicitWidth")
- assert line_number_width >= line_number_implicit_width, (
- f"Expected four-digit line number width {line_number_width} to fit "
- f"implicit width {line_number_implicit_width}"
+def test_live_append(gui, datadir):
+ print("\n── test_live_append ─────────────────────────────────────────────")
+ marker_body = "test-automation live network marker"
+ gui.set_text("debugLogSearchField", marker_body)
+ gui.wait_for_property("debugLogListView", "count", 0, timeout_ms=5000)
+
+ timestamp = datetime.datetime.now(datetime.timezone.utc).strftime(
+ "%Y-%m-%dT%H:%M:%S.654321Z"
)
- assert gui.get_property("debugLogListView", "effectiveLineNumberWidth") > 20, \
- "Expected effective line-number slot to expand beyond the design minimum"
- print(" PASSED: offscreen rows are virtualized and four-digit line numbers fit")
+ log_path = os.path.join(datadir, "regtest", "debug.log")
+ with open(log_path, "a", encoding="utf-8") as log_file:
+ log_file.write(f"{timestamp} [net] {marker_body}\n")
+ gui.wait_for_property("debugLogListView", "count", 1, timeout_ms=5000)
+ gui.wait_for_property("debugLogItemRow_0Message", "text", marker_body, timeout_ms=3000)
+ gui.set_text("debugLogSearchField", "")
+ gui.wait_for_property("debugLogListView", "count", INITIAL_LOAD_LIMIT, timeout_ms=5000)
+ print(" PASSED: live rows arrive without exceeding the loaded-row cap")
-def test_load_more_at_bottom(gui, current_count):
- """The bottom affordance appends older rows without moving the viewport."""
- print("\n── test_load_more_at_bottom ───────────────────────")
- gui.invoke("debugLogListView", "scrollToBottom")
- gui.wait_for_property("debugLogListView", "atBottom", True, timeout_ms=3000)
- gui.wait_for_property("debugLogLoadMoreButton", "visible", True, timeout_ms=3000)
- content_y_before = gui.get_property("debugLogListView", "contentY")
+def test_load_older(gui):
+ print("\n── test_load_older ──────────────────────────────────────────────")
+ gui.invoke("debugLogView", "scrollToTop")
+ gui.wait_for_property("debugLogLoadMoreButton", "visible", True, timeout_ms=3000)
gui.click("debugLogLoadMoreButton")
- expanded_count = gui.wait_for_property(
- "debugLogListView", "count", lambda c: c > current_count, timeout_ms=5000
+ expanded = gui.wait_for_property(
+ "debugLogListView", "count", lambda count: count > INITIAL_LOAD_LIMIT,
+ timeout_ms=5000,
)
- content_y_after = gui.get_property("debugLogListView", "contentY")
- assert_close(
- content_y_after,
- content_y_before,
- "load-more viewport anchor",
- tolerance=2,
- )
- gui.wait_for_property("debugLogLoadMoreButton", "visible", False, timeout_ms=3000)
- oldest_index = expanded_count - 1
- oldest_message = f"debugLogListView_commandlessMessage_{oldest_index}"
- gui.invoke("debugLogListView", "scrollToBottom")
+ gui.set_text("debugLogSearchField", "oldest-history marker")
+ gui.wait_for_property("debugLogListView", "count", 1, timeout_ms=5000)
gui.wait_for_property(
- oldest_message,
- "text",
- "test-automation seeded-history marker 0",
- timeout_ms=3000,
- )
- print(
- f" PASSED: loaded {expanded_count - current_count} older rows without "
- "moving the viewport, including the oldest seeded row"
+ "debugLogItemRow_0Message", "text",
+ "test-automation oldest-history marker", timeout_ms=3000,
)
- return expanded_count
+ assert expanded >= SEEDED_HISTORY_LINES
+ print(f" PASSED: older history was prepended ({expanded} rows loaded)")
def test_close_settings(gui):
- """Clicking Done exits the desktop settings shell."""
- print("\n── test_close_settings ───────────────────────────────────────────")
+ print("\n── test_close_settings ──────────────────────────────────────────")
gui.click("settingsDoneButton")
gui.wait_for_page("nodeSettingsButton", timeout_ms=5000)
print(" PASSED: Done closed node settings")
-def test_search_filter(gui, total_count):
- """Typing in the search field filters the list; clearing it restores all entries."""
- print("\n── test_search_filter ────────────────────────────────────────────")
-
- # "Bitcoin" appears in the startup banner ("Bitcoin Core version ...") so
- # it is guaranteed to match some lines but very likely not all of them.
- gui.set_text("debugLogSearchField", "Bitcoin")
- # Wait for the debounce timer (150 ms) to propagate searchFilter to the model.
- filtered = gui.wait_for_property(
- "debugLogListView", "count", lambda c: c < total_count
- )
- assert 0 < filtered < total_count, (
- f"Expected a non-zero subset of {total_count} lines after filtering for "
- f"'Bitcoin', got {filtered}"
- )
- print(f" Filtered count ('Bitcoin'): {filtered} / {total_count}")
-
- # Clear the search — full list should be restored.
- gui.set_text("debugLogSearchField", "")
- restored = gui.wait_for_property(
- "debugLogListView", "count", lambda c: c == total_count
- )
- assert restored == total_count, (
- f"Expected count to restore to {total_count} after clearing filter, "
- f"got {restored}"
- )
- print(f" Restored count (cleared): {restored}")
- print(" PASSED: search filter works correctly")
-
-
-# ── Entry point ───────────────────────────────────────────────────────────────
-
def run_tests():
harness = DebugLogHarness()
try:
harness.start()
gui = harness.driver
-
print("\nNavigating to Debug Log ...")
navigate_to_debug_log(gui)
print(f" -> page: {gui.get_current_page()}")
- test_viewer_visibility(gui)
- total = test_log_has_entries(gui)
- test_search_layout_matches_design(gui)
- test_search_filter(gui, total)
- total = test_auto_refresh(gui, harness.datadir, total)
- total = test_refresh_button(gui, total)
- test_four_digit_line_numbers_are_not_clipped(gui, harness.datadir, total)
- total = test_load_more_at_bottom(gui, total)
+ test_page_structure(gui)
+ test_structured_columns_and_filters(gui)
+ test_chronological_order(gui)
+ test_live_append(gui, harness.datadir)
+ test_load_older(gui)
test_close_settings(gui)
print("\n" + "=" * 50)
print("All debug log tests PASSED")
print("=" * 50)
- except Exception as e:
- print(f"\nFAILED: {e}", file=sys.stderr)
+ except Exception as error:
+ print(f"\nFAILED: {error}", file=sys.stderr)
import traceback
traceback.print_exc()
if harness.process:
@@ -482,8 +302,10 @@ def run_tests():
stderr_bytes = harness.process.communicate()[1]
if stderr_bytes:
print("\n--- GUI stderr ---", file=sys.stderr)
- print(stderr_bytes.decode("utf-8", errors="replace")[-4000:],
- file=sys.stderr)
+ print(
+ stderr_bytes.decode("utf-8", errors="replace")[-4000:],
+ file=sys.stderr,
+ )
except Exception:
pass
if harness.driver:
@@ -493,5 +315,5 @@ def run_tests():
harness.stop()
-if __name__ == '__main__':
+if __name__ == "__main__":
run_tests()
diff --git a/test/qml/bitcoin_qmltests.qrc b/test/qml/bitcoin_qmltests.qrc
index 5df8b0c0b2..8c40f48378 100644
--- a/test/qml/bitcoin_qmltests.qrc
+++ b/test/qml/bitcoin_qmltests.qrc
@@ -16,7 +16,7 @@
tst_createpassword.qml
tst_createtypeselector.qml
tst_createwalletwizard.qml
- tst_debuglogoutputview.qml
+ tst_debuglogview.qml
tst_desktopmenuactions.qml
tst_desktopwallets.qml
tst_dropdownbutton.qml
@@ -26,6 +26,7 @@
tst_formcontrols.qml
tst_mainrouting.qml
tst_mempoolinformationrows.qml
+ tst_monospaceoutputview.qml
tst_navbutton.qml
tst_nodefeedback.qml
tst_onboarding_datadir.qml
@@ -34,6 +35,7 @@
tst_proxylocationinput.qml
tst_requestpayment.qml
tst_rightcontenticon.qml
+ tst_searchbar.qml
tst_sendoptionspopup.qml
tst_send.qml
tst_signverifymessage.qml
diff --git a/test/qml/qml_tests_main.cpp b/test/qml/qml_tests_main.cpp
index 1f47a95bee..874bc80fbf 100644
--- a/test/qml/qml_tests_main.cpp
+++ b/test/qml/qml_tests_main.cpp
@@ -3374,29 +3374,21 @@ class MockDebugLogModel : public QAbstractListModel
Q_PROPERTY(bool active READ active WRITE setActive NOTIFY activeChanged)
Q_PROPERTY(bool hasMoreLines READ hasMoreLines NOTIFY hasMoreLinesChanged)
Q_PROPERTY(QString filter READ filter WRITE setFilter NOTIFY filterChanged)
+ Q_PROPERTY(bool warningsAndErrorsOnly READ warningsAndErrorsOnly WRITE setWarningsAndErrorsOnly NOTIFY warningsAndErrorsOnlyChanged)
Q_PROPERTY(QString openError READ openError NOTIFY openErrorChanged)
Q_PROPERTY(int count READ count NOTIFY countChanged)
Q_PROPERTY(int loadMoreCalls READ loadMoreCalls NOTIFY loadMoreCallsChanged)
+ Q_PROPERTY(int openLogFileCalls READ openLogFileCalls NOTIFY openLogFileCallsChanged)
public:
enum Role {
- LineNumberRole = Qt::UserRole + 1,
- ContentRole,
- RelativeTimeRole,
- CommandRole,
- MessageRole,
- DateLabelRole,
- SeverityRole,
+ MessageRole = Qt::UserRole + 1,
+ TimestampRole,
+ IsErrorRole,
+ IsWarningRole,
};
Q_ENUM(Role)
- enum Severity {
- InfoSeverity = 0,
- WarningSeverity,
- ErrorSeverity,
- };
- Q_ENUM(Severity)
-
int rowCount(const QModelIndex& parent = QModelIndex()) const override
{
return parent.isValid() ? 0 : m_rows.size();
@@ -3409,13 +3401,10 @@ class MockDebugLogModel : public QAbstractListModel
if (!index.isValid() || index.row() < 0 || index.row() >= m_rows.size()) return {};
const Row& row = m_rows.at(index.row());
switch (role) {
- case LineNumberRole: return QString::number(index.row() + 1);
- case ContentRole: return row.message;
- case RelativeTimeRole: return row.date_label;
- case CommandRole: return row.command;
case MessageRole: return row.message;
- case DateLabelRole: return row.date_label;
- case SeverityRole: return row.severity;
+ case TimestampRole: return row.timestamp;
+ case IsErrorRole: return row.is_error;
+ case IsWarningRole: return row.is_warning;
default: return {};
}
}
@@ -3423,13 +3412,10 @@ class MockDebugLogModel : public QAbstractListModel
QHash roleNames() const override
{
return {
- {LineNumberRole, "lineNumber"},
- {ContentRole, "content"},
- {RelativeTimeRole, "relativeTime"},
- {CommandRole, "command"},
{MessageRole, "message"},
- {DateLabelRole, "dateLabel"},
- {SeverityRole, "severity"},
+ {TimestampRole, "timestamp"},
+ {IsErrorRole, "isError"},
+ {IsWarningRole, "isWarning"},
};
}
@@ -3450,21 +3436,35 @@ class MockDebugLogModel : public QAbstractListModel
Q_EMIT filterChanged();
}
QString openError() const { return {}; }
+ bool warningsAndErrorsOnly() const { return m_warnings_and_errors_only; }
+ void setWarningsAndErrorsOnly(bool warnings_and_errors_only)
+ {
+ if (m_warnings_and_errors_only == warnings_and_errors_only) return;
+ m_warnings_and_errors_only = warnings_and_errors_only;
+ Q_EMIT warningsAndErrorsOnlyChanged();
+ }
int loadMoreCalls() const { return m_load_more_calls; }
+ int openLogFileCalls() const { return m_open_log_file_calls; }
Q_INVOKABLE void refresh(bool = false) {}
Q_INVOKABLE void loadMore()
{
++m_load_more_calls;
Q_EMIT loadMoreCallsChanged();
- appendRowsForTest(20);
+ prependRowsForTest(20);
setHasMoreLinesForTest(false);
}
- Q_INVOKABLE bool openLogFile() { return true; }
- Q_INVOKABLE void updateRelativeTimes() {}
+ Q_INVOKABLE bool openLogFile()
+ {
+ ++m_open_log_file_calls;
+ Q_EMIT openLogFileCallsChanged();
+ return true;
+ }
Q_INVOKABLE void resetForTest(int count, bool has_more_lines)
{
+ m_filter.clear();
+ m_warnings_and_errors_only = false;
beginResetModel();
m_rows.clear();
m_rows.reserve(count);
@@ -3475,8 +3475,12 @@ class MockDebugLogModel : public QAbstractListModel
m_next_new_row = 0;
m_next_old_row = count;
m_load_more_calls = 0;
+ m_open_log_file_calls = 0;
Q_EMIT countChanged();
+ Q_EMIT filterChanged();
+ Q_EMIT warningsAndErrorsOnlyChanged();
Q_EMIT loadMoreCallsChanged();
+ Q_EMIT openLogFileCallsChanged();
setHasMoreLinesForTest(has_more_lines);
}
@@ -3487,7 +3491,7 @@ class MockDebugLogModel : public QAbstractListModel
QList added;
added.reserve(count);
for (int i = 0; i < count; ++i) {
- added.append(makeRow(QStringLiteral("new-%1").arg(m_next_new_row++)));
+ added.append(makeRow(QStringLiteral("old-%1").arg(m_next_old_row++)));
}
beginInsertRows(QModelIndex(), 0, count - 1);
@@ -3506,7 +3510,7 @@ class MockDebugLogModel : public QAbstractListModel
const int first = m_rows.size();
beginInsertRows(QModelIndex(), first, first + count - 1);
for (int i = 0; i < count; ++i) {
- m_rows.append(makeRow(QStringLiteral("old-%1").arg(m_next_old_row++)));
+ m_rows.append(makeRow(QStringLiteral("new-%1").arg(m_next_new_row++)));
}
endInsertRows();
Q_EMIT countChanged();
@@ -3548,7 +3552,31 @@ class MockDebugLogModel : public QAbstractListModel
if (row < 0 || row >= m_rows.size() || m_rows.at(row).message == message) return;
m_rows[row].message = message;
const QModelIndex changed_index = index(row, 0);
- Q_EMIT dataChanged(changed_index, changed_index, {ContentRole, MessageRole});
+ Q_EMIT dataChanged(changed_index, changed_index, {MessageRole});
+ }
+
+ Q_INVOKABLE void setStructuredFieldsForTest(int row,
+ bool is_error,
+ const QString& timestamp)
+ {
+ if (row < 0 || row >= m_rows.size()) return;
+ Row& item = m_rows[row];
+ item.is_error = is_error;
+ item.is_warning = false;
+ item.timestamp = timestamp;
+ const QModelIndex changed_index = index(row, 0);
+ Q_EMIT dataChanged(changed_index, changed_index,
+ {IsErrorRole, IsWarningRole, TimestampRole});
+ }
+
+ Q_INVOKABLE void setWarningForTest(int row, bool is_warning)
+ {
+ if (row < 0 || row >= m_rows.size()) return;
+ Row& item = m_rows[row];
+ item.is_warning = is_warning;
+ if (is_warning) item.is_error = false;
+ const QModelIndex changed_index = index(row, 0);
+ Q_EMIT dataChanged(changed_index, changed_index, {IsErrorRole, IsWarningRole});
}
Q_INVOKABLE QString messageAt(int row) const
@@ -3568,34 +3596,38 @@ class MockDebugLogModel : public QAbstractListModel
void activeChanged();
void hasMoreLinesChanged();
void filterChanged();
+ void warningsAndErrorsOnlyChanged();
void openErrorChanged();
void newLinesAdded(int count);
void countChanged();
void loadMoreCallsChanged();
+ void openLogFileCallsChanged();
private:
struct Row {
- QString command;
QString message;
- QString date_label;
- int severity{InfoSeverity};
+ QString timestamp;
+ bool is_error{false};
+ bool is_warning{false};
};
static Row makeRow(const QString& message)
{
return Row{
- QStringLiteral("test"),
message,
- QStringLiteral("just now"),
- InfoSeverity,
+ QStringLiteral("15:42:08"),
+ false,
+ false,
};
}
bool m_active{false};
bool m_has_more_lines{false};
QString m_filter;
+ bool m_warnings_and_errors_only{false};
QList m_rows;
int m_load_more_calls{0};
+ int m_open_log_file_calls{0};
int m_next_new_row{0};
int m_next_old_row{0};
};
diff --git a/test/qml/tst_activity.qml b/test/qml/tst_activity.qml
index 9e3e2e014d..632a9cb5e7 100644
--- a/test/qml/tst_activity.qml
+++ b/test/qml/tst_activity.qml
@@ -138,6 +138,17 @@ TestCase {
}
}
+ function test_search_toggle_uses_toolbar_icon_size() {
+ const page = createTemporaryObject(activityComponent, this)
+ verify(page !== null)
+
+ const searchToggle = findChild(page, "activitySearchToggle")
+ verify(searchToggle !== null)
+ compare(searchToggle.width, 30)
+ compare(searchToggle.height, 30)
+ compare(searchToggle.iconSize, 24)
+ }
+
function test_navigateToTransaction_uses_lowest_output_and_supports_exact_output() {
testActivityListModel.setCountForTest(3)
const page = createTemporaryObject(activityComponent, this)
diff --git a/test/qml/tst_contextmenubutton.qml b/test/qml/tst_contextmenubutton.qml
index 6cfb2ba5bb..b0cb0888da 100644
--- a/test/qml/tst_contextmenubutton.qml
+++ b/test/qml/tst_contextmenubutton.qml
@@ -60,4 +60,12 @@ TestCase {
verify(button !== null)
compare(button.role, ContextMenuButton.Destructive)
}
+
+ function test_selected_uses_context_menu_selection_style() {
+ const button = createTemporaryObject(normalComponent, host)
+ verify(button !== null)
+
+ button.selected = true
+ compare(button.background.color, Theme.color.neutral3)
+ }
}
diff --git a/test/qml/tst_debuglogoutputview.qml b/test/qml/tst_debuglogoutputview.qml
deleted file mode 100644
index 79cfe14b61..0000000000
--- a/test/qml/tst_debuglogoutputview.qml
+++ /dev/null
@@ -1,175 +0,0 @@
-// 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 org.bitcoincore.qt 1.0
-import "../../qml/components"
-
-TestCase {
- id: testCase
- name: "DebugLogOutputView"
- when: windowShown
- width: 440
- height: 300
-
- Component {
- id: outputViewComponent
-
- DebugLogOutputView {
- objectName: "testDebugLogOutputView"
- width: 400
- height: 240
- listModel: testDebugLogModel
- accessibleName: "Test debug log"
- }
- }
-
- function init() {
- testDebugLogModel.resetForTest(0, false)
- }
-
- function createOutputView() {
- const view = createTemporaryObject(outputViewComponent, testCase.Window.window.contentItem)
- verify(view !== null)
- tryCompare(view, "count", testDebugLogModel.count)
- return view
- }
-
- function test_virtualizes_rows_and_scroll_helpers_reach_each_end() {
- testDebugLogModel.resetForTest(250, false)
- const view = createOutputView()
- const list = findChild(view, "testDebugLogOutputView_list")
- verify(list !== null)
-
- tryVerify(function() { return view.instantiatedDelegateCount > 0 })
- verify(view.instantiatedDelegateCount < view.count,
- "ListView should instantiate only a viewport-sized subset")
- verify(list.itemAtIndex(0) !== null)
- compare(list.itemAtIndex(200), null)
- compare(view.atTop, true)
- compare(view.atBottom, false)
-
- view.scrollToBottom()
- tryCompare(view, "atBottom", true)
- tryVerify(function() { return list.itemAtIndex(249) !== null })
- compare(findChild(list.itemAtIndex(249), "testDebugLogOutputView_lineNumber_249").text, "250")
-
- view.scrollToTop()
- tryCompare(view, "atTop", true)
- tryVerify(function() { return list.itemAtIndex(0) !== null })
- }
-
- function test_variable_height_prepend_and_tail_prune_keep_anchor() {
- testDebugLogModel.resetForTest(160, false)
- const wrappedMessage = "A wrapped debug-log message with selectable text. ".repeat(24)
- testDebugLogModel.setMessageForTest(10, wrappedMessage)
- testDebugLogModel.setMessageForTest(50, wrappedMessage)
- const view = createOutputView()
- const list = findChild(view, "testDebugLogOutputView_list")
- verify(list !== null)
-
- list.positionViewAtIndex(50, ListView.Beginning)
- tryVerify(function() {
- const item = list.itemAtIndex(50)
- return !view.atTop && item !== null && item.height > view.textLineHeight * 3
- })
- // Visiting another wrapped row first makes ListView refine its delegate
- // size estimate. Returning nearer the beginning then exercises an
- // anchor whose contentY is relative to a shifted (non-zero) origin.
- list.positionViewAtIndex(10, ListView.Beginning)
- tryVerify(function() {
- const item = list.itemAtIndex(10)
- return item !== null && item.height > view.textLineHeight * 3
- })
-
- const anchorMessage = testDebugLogModel.messageAt(10)
- const anchorOffset = list.itemAtIndex(10).y - view.contentY
- testDebugLogModel.prependAndPruneRowsForTest(3, 3)
-
- tryCompare(view, "count", 160)
- compare(testDebugLogModel.messageAt(13), anchorMessage)
- tryVerify(function() {
- const shiftedAnchor = list.itemAtIndex(13)
- return shiftedAnchor !== null
- && Math.abs((shiftedAnchor.y - view.contentY) - anchorOffset) < 0.5
- })
- compare(view.atTop, false)
-
- const shiftedMessage = findChild(
- list.itemAtIndex(13), "testDebugLogOutputView_message_13")
- verify(shiftedMessage !== null)
- compare(shiftedMessage.visible, true)
- shiftedMessage.selectAll()
- compare(shiftedMessage.selectedText, wrappedMessage)
-
- // Exercise the end calculation after a variable-height incremental
- // update, when ListView's logical origin is allowed to be non-zero.
- view.scrollToBottom()
- tryCompare(view, "atBottom", true)
- }
-
- function test_prepend_while_at_top_keeps_newest_rows_visible() {
- testDebugLogModel.resetForTest(80, false)
- const view = createOutputView()
- const list = findChild(view, "testDebugLogOutputView_list")
- verify(list !== null)
- compare(view.atTop, true)
-
- testDebugLogModel.prependRowsForTest(2)
-
- tryCompare(view, "count", 82)
- tryCompare(view, "atTop", true)
- tryVerify(function() { return list.itemAtIndex(0) !== null })
- compare(testDebugLogModel.messageAt(0), "new-0")
- compare(findChild(list.itemAtIndex(0), "testDebugLogOutputView_lineNumber_0").text, "1")
- }
-
- function test_appending_older_rows_does_not_jump_to_new_bottom() {
- testDebugLogModel.resetForTest(100, true)
- const view = createOutputView()
- view.scrollToBottom()
- tryCompare(view, "atBottom", true)
- const anchoredContentY = view.contentY
-
- testDebugLogModel.appendRowsForTest(20)
-
- tryCompare(view, "count", 120)
- tryVerify(function() { return !view.atBottom })
- verify(Math.abs(view.contentY - anchoredContentY) < 0.5,
- "Appending older rows should preserve the current viewport")
- }
-
- function test_full_snapshot_prefix_and_suffix_keep_prepend_anchor_data() {
- return [
- { tag: "prefix-first", prependFirst: true },
- { tag: "suffix-first", prependFirst: false },
- ]
- }
-
- function test_full_snapshot_prefix_and_suffix_keep_prepend_anchor(data) {
- testDebugLogModel.resetForTest(160, false)
- const view = createOutputView()
- const list = findChild(view, "testDebugLogOutputView_list")
- verify(list !== null)
-
- list.positionViewAtIndex(50, ListView.Beginning)
- tryVerify(function() { return list.itemAtIndex(50) !== null && !view.atTop })
- const anchorMessage = testDebugLogModel.messageAt(50)
- const anchorOffset = list.itemAtIndex(50).y - view.contentY
-
- // A reconciled snapshot can expose both ends in one GUI event turn.
- // The viewport must follow the shifted original row regardless of the
- // order in which those two insertion batches are published.
- testDebugLogModel.prependAndAppendRowsForTest(3, 2, data.prependFirst)
-
- tryCompare(view, "count", 165)
- compare(testDebugLogModel.messageAt(53), anchorMessage)
- tryVerify(function() {
- const shiftedAnchor = list.itemAtIndex(53)
- return shiftedAnchor !== null
- && Math.abs((shiftedAnchor.y - view.contentY) - anchorOffset) < 0.5
- })
- }
-}
diff --git a/test/qml/tst_debuglogview.qml b/test/qml/tst_debuglogview.qml
new file mode 100644
index 0000000000..fe6c12d6c5
--- /dev/null
+++ b/test/qml/tst_debuglogview.qml
@@ -0,0 +1,361 @@
+// 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 QtQuick.Window 2.15
+import QtTest 1.2
+
+import "../../qml/controls"
+import "../../qml/pages/settings"
+
+TestCase {
+ name: "SettingsDebugLogView"
+ when: windowShown
+ width: 900
+ height: 700
+
+ Window {
+ id: testWindow
+ width: 900
+ height: 700
+ visible: true
+ }
+
+ Component {
+ id: viewComponent
+
+ SettingsDebugLogView {
+ width: 900
+ height: 700
+ }
+ }
+
+ function init() {
+ testDebugLogModel.resetForTest(0, false)
+ }
+
+ function createView() {
+ const view = createTemporaryObject(viewComponent, testWindow.contentItem)
+ verify(view !== null)
+ const list = findChild(view, "debugLogListView")
+ verify(list !== null)
+ tryCompare(list, "count", testDebugLogModel.count)
+ return view
+ }
+
+ function test_uses_settings_primitives_and_neutral_card() {
+ testDebugLogModel.resetForTest(3, false)
+ const view = createView()
+
+ verify(findChild(view, "debugLogSettingsHeader") !== null)
+ verify(findChild(view, "debugLogPageHeading") !== null)
+ const searchBar = findChild(view, "debugLogSearchBar")
+ const searchField = findChild(view, "debugLogSearchField")
+ const searchIcon = findChild(view, "debugLogSearchIcon")
+ const searchClearButton = findChild(view, "debugLogSearchClearButton")
+ verify(searchBar !== null)
+ verify(searchField !== null)
+ verify(searchIcon !== null)
+ verify(searchClearButton !== null)
+ compare(searchBar.height, 40)
+ compare(searchField.height, 40)
+ compare(searchBar.background.visible, false)
+ compare(searchField.background.color, Theme.color.neutral2)
+ compare(searchIcon.source.toString(), "image://images/search")
+ compare(searchField.background.border.width, 0)
+ testWindow.requestActivate()
+ tryCompare(testWindow, "active", true)
+ searchField.forceActiveFocus()
+ tryCompare(searchField, "activeFocus", true)
+ compare(searchField.background.border.width, 0)
+ searchField.text = "rpc"
+ compare(searchClearButton.visible, true)
+ mouseClick(searchClearButton)
+ compare(searchField.text, "")
+ const filterButton = findChild(view, "debugLogFilterButton")
+ const inactiveFilterIcon = findChild(view, "debugLogFilterButtonInactiveIcon")
+ const activeFilterIcon = findChild(view, "debugLogFilterButtonActiveIcon")
+ const filterMenu = findChild(view, "debugLogFilterMenu")
+ const filterPicker = findChild(view, "debugLogMessageFilterPicker")
+ const optionsButton = findChild(view, "debugLogOptionsButton")
+ const optionsMenu = findChild(view, "debugLogOptionsMenu")
+ const openFileButton = findChild(view, "debugLogOpenFileButton")
+ verify(filterButton !== null)
+ verify(inactiveFilterIcon !== null)
+ verify(activeFilterIcon !== null)
+ verify(filterMenu !== null)
+ verify(optionsButton !== null)
+ verify(optionsMenu !== null)
+ verify(filterPicker !== null)
+ verify(openFileButton !== null)
+ compare(filterButton.active, false)
+ compare(filterButton.background.color, Theme.color.neutral1)
+ compare(filterButton.iconSize, 24)
+ verify(filterButton.x < optionsButton.x)
+ compare(filterButton.x, searchBar.x + searchBar.width + 16)
+ compare(inactiveFilterIcon.source.toString(), "qrc:/icons/filter")
+ compare(inactiveFilterIcon.color, Theme.color.neutral6)
+ compare(inactiveFilterIcon.opacity, 1)
+ compare(activeFilterIcon.source.toString(), "qrc:/icons/filter-active")
+ compare(activeFilterIcon.opacity, 0)
+ compare(optionsButton.iconSource.toString(), "image://images/ellipsis")
+ compare(optionsButton.iconSize, 30)
+ compare(optionsButton.iconItem.width, 30)
+ compare(optionsButton.iconItem.height, 30)
+ compare(optionsButton.background.color, Theme.color.neutral1)
+ compare(filterPicker.currentValue, "all")
+ mouseClick(filterButton)
+ tryCompare(filterMenu, "opened", true)
+ tryVerify(function() { return filterPicker.itemAtIndex(1) !== null })
+ const allMessagesOption = filterPicker.itemAtIndex(0)
+ const warningsAndErrorsOption = filterPicker.itemAtIndex(1)
+ compare(allMessagesOption.objectName, "debugLogFilterAllMessages")
+ compare(warningsAndErrorsOption.objectName, "debugLogFilterWarningsAndErrors")
+ compare(allMessagesOption.selected, true)
+ compare(warningsAndErrorsOption.selected, false)
+ filterMenu.close()
+ tryCompare(filterMenu, "visible", false)
+ verify(findChild(view, "debugLogOptionsDivider") === null)
+ mouseClick(optionsButton)
+ tryCompare(optionsMenu, "opened", true)
+ compare(openFileButton.text, "Open debug.log")
+ compare(openFileButton.iconSource.toString(), "image://images/export")
+ optionsMenu.close()
+ const section = findChild(view, "debugLogTableSection")
+ const card = findChild(view, "debugLogTableSectionCard")
+ const titles = findChild(view, "debugLogTitlesHeader")
+ const titlesDivider = findChild(view, "debugLogTitlesHeaderDivider")
+ const footer = findChild(view, "debugLogTableFooter")
+ const footerDivider = findChild(view, "debugLogTableFooterDivider")
+ const scrollButton = findChild(view, "debugLogScrollToBottomButton")
+ const loadMoreButton = findChild(view, "debugLogLoadMoreButton")
+ verify(section !== null)
+ verify(card !== null)
+ verify(titles !== null)
+ verify(titlesDivider !== null)
+ verify(footer !== null)
+ verify(footerDivider !== null)
+ verify(scrollButton !== null)
+ verify(loadMoreButton !== null)
+ compare(card.color, Theme.color.neutral1)
+ compare(titles.background.color, Theme.color.neutral1)
+ compare(footer.color, Theme.color.neutral1)
+ compare(titlesDivider.height, 1)
+ compare(titlesDivider.color, Theme.color.neutral2)
+ compare(footerDivider.height, 1)
+ compare(footerDivider.color, Theme.color.neutral2)
+ compare(titles.background.radius, 16)
+ compare(footer.radius, 16)
+ verify(findChild(view, "debugLogTitlesHeaderBottomFill") !== null)
+ verify(findChild(view, "debugLogTableFooterTopFill") !== null)
+ verify(scrollButton.textFontPixelSize === 13)
+ verify(loadMoreButton.textFontPixelSize === 13)
+ compare(scrollButton.bold, true)
+ compare(loadMoreButton.bold, true)
+ compare(scrollButton.background.border.color, Theme.color.neutral2)
+ compare(loadMoreButton.background.border.color, Theme.color.neutral2)
+ }
+
+ function test_open_debug_log_button_invokes_model() {
+ const view = createView()
+ const optionsButton = findChild(view, "debugLogOptionsButton")
+ const optionsMenu = findChild(view, "debugLogOptionsMenu")
+ const openFileButton = findChild(view, "debugLogOpenFileButton")
+
+ compare(testDebugLogModel.openLogFileCalls, 0)
+ mouseClick(optionsButton)
+ tryCompare(optionsMenu, "opened", true)
+ mouseClick(openFileButton)
+ compare(testDebugLogModel.openLogFileCalls, 1)
+ tryCompare(optionsMenu, "opened", false)
+ }
+
+ function test_toolbar_icons_keep_their_size_and_use_hover_tint() {
+ const view = createView()
+ const filterButton = findChild(view, "debugLogFilterButton")
+ const filterIcon = findChild(view, "debugLogFilterButtonInactiveIcon")
+ const optionsButton = findChild(view, "debugLogOptionsButton")
+ const optionsIcon = findChild(view, "debugLogOptionsButtonIcon")
+
+ compare(filterIcon.width, 24)
+ compare(filterIcon.height, 24)
+ compare(optionsIcon.width, 30)
+ compare(optionsIcon.height, 30)
+
+ mouseMove(filterButton, filterButton.width / 2, filterButton.height / 2)
+ tryCompare(filterButton, "hovered", true)
+ tryCompare(filterIcon, "color", Theme.color.orange)
+ compare(filterIcon.width, 24)
+ compare(filterIcon.height, 24)
+
+ mouseMove(optionsButton, optionsButton.width / 2, optionsButton.height / 2)
+ tryCompare(optionsButton, "hovered", true)
+ tryCompare(optionsIcon, "color", Theme.color.orange)
+ compare(optionsIcon.width, 30)
+ compare(optionsIcon.height, 30)
+ }
+
+ function test_find_shortcut_focuses_search() {
+ const view = createView()
+ const searchField = findChild(view, "debugLogSearchField")
+ const optionsButton = findChild(view, "debugLogOptionsButton")
+
+ testWindow.requestActivate()
+ tryCompare(testWindow, "active", true)
+ optionsButton.forceActiveFocus()
+ tryCompare(optionsButton, "activeFocus", true)
+ verify(!searchField.activeFocus)
+
+ // Qt maps ControlModifier to the Command key for standard shortcuts
+ // on macOS.
+ keyClick(Qt.Key_F, Qt.ControlModifier)
+ tryCompare(searchField, "activeFocus", true)
+ }
+
+ function test_fixed_columns_align_and_message_grows() {
+ testDebugLogModel.resetForTest(2, false)
+ const view = createView()
+ const list = findChild(view, "debugLogListView")
+ const titles = findChild(view, "debugLogTitlesHeader")
+ view.scrollToTop()
+ tryVerify(function() { return list.itemAtIndex(0) !== null })
+ const row = list.itemAtIndex(0)
+
+ compare(row.typeColumnWidth, titles.typeColumnWidth)
+ compare(row.timeColumnWidth, titles.timeColumnWidth)
+ compare(titles.typeColumnWidth, 32)
+ compare(titles.timeColumnWidth, 80)
+ tryVerify(function() {
+ return findChild(row, "debugLogItemRow_0Message").width > 0
+ })
+ compare(findChild(row, "debugLogItemRow_0Time").horizontalAlignment,
+ Text.AlignLeft)
+ compare(findChild(row, "debugLogItemRow_0Time").text,
+ "15:42:08")
+ }
+
+ function test_type_indicators_and_alternating_rows_use_theme_colors() {
+ testDebugLogModel.resetForTest(3, false)
+ testDebugLogModel.setStructuredFieldsForTest(1, true,
+ "15:42:09")
+ testDebugLogModel.setWarningForTest(2, true)
+ const view = createView()
+ const list = findChild(view, "debugLogListView")
+ view.scrollToTop()
+ tryVerify(function() { return list.itemAtIndex(2) !== null })
+
+ const regular = list.itemAtIndex(0)
+ const error = list.itemAtIndex(1)
+ const warning = list.itemAtIndex(2)
+ compare(findChild(regular, "debugLogItemRow_0TypeIndicator").color.a, 0)
+ compare(findChild(error, "debugLogItemRow_1TypeIndicator").color,
+ Theme.color.red)
+ compare(findChild(warning, "debugLogItemRow_2TypeIndicator").color,
+ Theme.color.amber)
+ compare(regular.background.color, Theme.color.neutral1)
+ compare(error.background.color, Theme.color.neutral2)
+ compare(warning.background.color, Theme.color.neutral1)
+ }
+
+ function test_message_wraps_and_is_selectable() {
+ testDebugLogModel.resetForTest(1, false)
+ const wrapped = "A long selectable debug message. ".repeat(40)
+ testDebugLogModel.setMessageForTest(0, wrapped)
+ const view = createView()
+ const list = findChild(view, "debugLogListView")
+ view.scrollToTop()
+ tryVerify(function() {
+ return list.itemAtIndex(0) !== null && list.itemAtIndex(0).height > 48
+ })
+ const message = findChild(list.itemAtIndex(0), "debugLogItemRow_0Message")
+ message.selectAll()
+ compare(message.selectedText, wrapped)
+ }
+
+ function test_search_and_filter_menu_update_model_and_button_state() {
+ testDebugLogModel.resetForTest(4, false)
+ const view = createView()
+ const search = findChild(view, "debugLogSearchField")
+ const filterButton = findChild(view, "debugLogFilterButton")
+ const inactiveFilterIcon = findChild(view, "debugLogFilterButtonInactiveIcon")
+ const activeFilterIcon = findChild(view, "debugLogFilterButtonActiveIcon")
+ const filterMenu = findChild(view, "debugLogFilterMenu")
+ const filterPicker = findChild(view, "debugLogMessageFilterPicker")
+
+ search.text = "rpc warning"
+ tryCompare(testDebugLogModel, "filter", "rpc warning")
+ mouseClick(filterButton)
+ tryCompare(filterMenu, "opened", true)
+ tryVerify(function() { return filterPicker.itemAtIndex(1) !== null })
+ const warningsAndErrorsOption = filterPicker.itemAtIndex(1)
+ const allOption = filterPicker.itemAtIndex(0)
+ mouseClick(warningsAndErrorsOption)
+ compare(testDebugLogModel.warningsAndErrorsOnly, true)
+ tryCompare(filterMenu, "opened", false)
+ tryCompare(filterMenu, "visible", false)
+ compare(filterButton.active, true)
+ tryCompare(inactiveFilterIcon, "opacity", 0)
+ tryCompare(inactiveFilterIcon, "scale", filterButton.minimizedIconScale)
+ tryCompare(activeFilterIcon, "opacity", 1)
+ tryCompare(activeFilterIcon, "scale", 1)
+ compare(activeFilterIcon.color, Theme.color.orange)
+ compare(warningsAndErrorsOption.selected, true)
+ compare(allOption.selected, false)
+
+ mouseClick(filterButton)
+ tryCompare(filterMenu, "opened", true)
+ tryVerify(function() { return filterPicker.itemAtIndex(0) !== null })
+ const reopenedAllOption = filterPicker.itemAtIndex(0)
+ const reopenedWarningsAndErrorsOption = filterPicker.itemAtIndex(1)
+ mouseClick(reopenedAllOption)
+ compare(testDebugLogModel.warningsAndErrorsOnly, false)
+ tryCompare(filterMenu, "opened", false)
+ compare(filterButton.active, false)
+ tryCompare(inactiveFilterIcon, "opacity", 1)
+ tryCompare(inactiveFilterIcon, "scale", 1)
+ tryCompare(activeFilterIcon, "opacity", 0)
+ tryCompare(activeFilterIcon, "scale", filterButton.minimizedIconScale)
+ compare(inactiveFilterIcon.color, Theme.color.neutral6)
+ compare(reopenedAllOption.selected, true)
+ compare(reopenedWarningsAndErrorsOption.selected, false)
+ }
+
+ function test_titles_stay_fixed_while_log_rows_scroll() {
+ testDebugLogModel.resetForTest(100, false)
+ const view = createView()
+ const list = findChild(view, "debugLogListView")
+ const titles = findChild(view, "debugLogTitlesHeader")
+ const scrollButton = findChild(view, "debugLogScrollToBottomButton")
+ const headerY = titles.mapToItem(view, 0, 0).y
+
+ view.scrollToTop()
+ tryCompare(list, "atYBeginning", true)
+ compare(scrollButton.enabled, true)
+ mouseClick(scrollButton)
+ tryCompare(list, "atYEnd", true)
+ compare(scrollButton.enabled, false)
+ compare(titles.mapToItem(view, 0, 0).y, headerY)
+ }
+
+ function test_load_older_preserves_visible_anchor() {
+ testDebugLogModel.resetForTest(100, true)
+ const view = createView()
+ const list = findChild(view, "debugLogListView")
+ tryCompare(list, "atYEnd", true)
+ list.positionViewAtIndex(30, ListView.Beginning)
+ tryVerify(function() { return list.itemAtIndex(30) !== null })
+ const anchorMessage = testDebugLogModel.messageAt(30)
+ const anchorOffset = list.itemAtIndex(30).y - list.contentY
+
+ testDebugLogModel.prependRowsForTest(3)
+
+ tryCompare(list, "count", 103)
+ compare(testDebugLogModel.messageAt(33), anchorMessage)
+ tryVerify(function() {
+ const shifted = list.itemAtIndex(33)
+ return shifted !== null
+ && Math.abs((shifted.y - list.contentY) - anchorOffset) < 0.5
+ })
+ }
+}
diff --git a/test/qml/tst_formcontrols.qml b/test/qml/tst_formcontrols.qml
index f826b1c9c8..e78e473833 100644
--- a/test/qml/tst_formcontrols.qml
+++ b/test/qml/tst_formcontrols.qml
@@ -322,18 +322,18 @@ TestCase {
compare(picker.currentValue, "dark")
}
- function test_outlineButtonSupportsEmbeddedAppearance() {
+ function test_outlineButtonSupportsOnSurfaceAppearance() {
const button = createTemporaryObject(outlineButtonComponent, host)
verify(button !== null)
const background = findChild(button, "exampleOutlineButtonBackground")
verify(background !== null)
- compare(button.embedded, false)
+ compare(button.isOnSurface, false)
compare(background.color, Qt.rgba(0, 0, 0, 0))
compare(background.border.width, 1)
compare(background.border.color, Theme.color.neutral2)
- button.embedded = true
+ button.isOnSurface = true
tryCompare(background, "color", Theme.color.neutral2)
compare(background.border.width, 0)
diff --git a/test/qml/tst_monospaceoutputview.qml b/test/qml/tst_monospaceoutputview.qml
new file mode 100644
index 0000000000..3f4a0433f9
--- /dev/null
+++ b/test/qml/tst_monospaceoutputview.qml
@@ -0,0 +1,167 @@
+// 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 QtQuick.Window 2.15
+import QtTest 1.2
+
+import "../../qml/components"
+
+TestCase {
+ name: "MonospaceOutputView"
+ when: windowShown
+ width: 520
+ height: 320
+
+ Window {
+ id: testWindow
+ width: 520
+ height: 320
+ visible: true
+ }
+
+ ListModel {
+ id: outputModel
+
+ ListElement { content: "alpha beta" }
+ ListElement { content: "ALPHA reply" }
+ ListElement { content: "none alpha alpha" }
+ }
+
+ ListModel {
+ id: multilineOutputModel
+
+ ListElement {
+ content: "first line
second line
third line
fourth line
final omega"
+ }
+ }
+
+ Component {
+ id: outputComponent
+
+ MonospaceOutputView {
+ objectName: "searchOutput"
+ width: 480
+ height: 120
+ listModel: outputModel
+ contentTextFormat: Text.RichText
+ autoScrollToBottom: false
+ }
+ }
+
+ Component {
+ id: multilineOutputComponent
+
+ MonospaceOutputView {
+ objectName: "multilineSearchOutput"
+ width: 480
+ height: 44
+ listModel: multilineOutputModel
+ contentTextFormat: Text.RichText
+ autoScrollToBottom: false
+ topPadding: 0
+ bottomPadding: 0
+ }
+ }
+
+ function createOutput() {
+ const output = createTemporaryObject(outputComponent, testWindow.contentItem)
+ verify(output !== null)
+ tryCompare(output, "count", 3)
+ return output
+ }
+
+ function test_search_highlights_and_cycles_without_filtering() {
+ const output = createOutput()
+ const firstRow = findChild(output, "searchOutput_row_0")
+ const secondRow = findChild(output, "searchOutput_row_1")
+ const thirdRow = findChild(output, "searchOutput_row_2")
+ const firstContent = findChild(output, "searchOutput_content_0")
+ const secondContent = findChild(output, "searchOutput_content_1")
+ const thirdContent = findChild(output, "searchOutput_content_2")
+ verify(firstRow !== null)
+ verify(secondRow !== null)
+ verify(thirdRow !== null)
+ verify(firstContent !== null)
+ verify(secondContent !== null)
+ verify(thirdContent !== null)
+
+ output.searchText = "alpha"
+ tryCompare(output, "searchResultCount", 4)
+ compare(output.count, 3)
+ verify(firstRow.visible)
+ verify(secondRow.visible)
+ verify(thirdRow.visible)
+ verify(firstRow.height > 0)
+ verify(secondRow.height > 0)
+ verify(thirdRow.height > 0)
+ compare(output.currentSearchResultIndex, 0)
+ compare(firstContent.selectedText.toLowerCase(), "alpha")
+
+ output.showNextSearchResult()
+ compare(output.currentSearchResultIndex, 1)
+ compare(firstContent.selectedText, "")
+ compare(secondContent.selectedText.toLowerCase(), "alpha")
+
+ output.showPreviousSearchResult()
+ compare(output.currentSearchResultIndex, 0)
+ compare(firstContent.selectedText.toLowerCase(), "alpha")
+
+ output.showPreviousSearchResult()
+ compare(output.currentSearchResultIndex, 3)
+ compare(thirdContent.selectedText.toLowerCase(), "alpha")
+
+ output.searchText = ""
+ tryCompare(output, "searchResultCount", 0)
+ compare(output.currentSearchResultIndex, -1)
+ compare(thirdContent.selectedText, "")
+ compare(output.count, 3)
+ }
+
+ function test_search_navigation_owns_scroll_position() {
+ const output = createOutput()
+ output.height = 30
+ output.autoScrollToBottom = true
+ output.scrollToBottom()
+ verify(output.contentY > 0)
+
+ output.searchText = "alpha"
+ tryCompare(output, "searchResultCount", 4)
+ const firstMatchY = output.contentY
+ wait(100)
+ compare(output.currentSearchResultIndex, 0)
+ compare(output.contentY, firstMatchY)
+
+ output.showPreviousSearchResult()
+ compare(output.currentSearchResultIndex, 3)
+ const lastMatchY = output.contentY
+ verify(lastMatchY > firstMatchY)
+ wait(100)
+ compare(output.currentSearchResultIndex, 3)
+ compare(output.contentY, lastMatchY)
+ }
+
+ function test_search_scrolls_to_match_inside_multiline_row() {
+ const output = createTemporaryObject(multilineOutputComponent,
+ testWindow.contentItem)
+ verify(output !== null)
+ tryCompare(output, "count", 1)
+ const row = findChild(output, "multilineSearchOutput_row_0")
+ const content = findChild(output, "multilineSearchOutput_content_0")
+ verify(row !== null)
+ verify(content !== null)
+
+ output.searchText = "omega"
+ tryCompare(output, "searchResultCount", 1)
+ compare(content.selectedText, "omega")
+
+ const matchRect = content.positionToRectangle(content.selectionStart)
+ const matchTop = row.y + content.y + matchRect.y
+ const matchBottom = matchTop + matchRect.height
+ verify(matchRect.y > output.height)
+ verify(output.contentY > row.y)
+ verify(matchTop >= output.contentY)
+ verify(matchBottom <= output.contentY + output.height + 0.5)
+ }
+}
diff --git a/test/qml/tst_searchbar.qml b/test/qml/tst_searchbar.qml
new file mode 100644
index 0000000000..ded3d03ad4
--- /dev/null
+++ b/test/qml/tst_searchbar.qml
@@ -0,0 +1,233 @@
+// 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 QtQuick.Window 2.15
+import QtTest 1.2
+import org.bitcoincore.qt 1.0
+
+import "../../qml/components"
+import "../../qml/controls"
+
+TestCase {
+ name: "SearchBar"
+ when: windowShown
+ width: 520
+ height: 180
+
+ Window {
+ id: testWindow
+ width: 520
+ height: 180
+ visible: true
+ }
+
+ Component {
+ id: searchBarComponent
+
+ SearchBar {
+ objectName: "sharedSearchBar"
+ fieldObjectName: "sharedSearchField"
+ searchIconObjectName: "sharedSearchIcon"
+ clearButtonObjectName: "sharedSearchClearButton"
+ navigationControlObjectName: "sharedSearchNavigation"
+ previousButtonObjectName: "sharedSearchPreviousButton"
+ nextButtonObjectName: "sharedSearchNextButton"
+ placeholderText: "Find output"
+ }
+ }
+
+ function createSearchBar() {
+ const searchBar = createTemporaryObject(searchBarComponent,
+ testWindow.contentItem)
+ verify(searchBar !== null)
+ return searchBar
+ }
+
+ function test_shared_surface_and_clear_button() {
+ const searchBar = createSearchBar()
+ const field = findChild(searchBar, "sharedSearchField")
+ const searchIcon = findChild(searchBar, "sharedSearchIcon")
+ const clearButton = findChild(searchBar, "sharedSearchClearButton")
+ verify(field !== null)
+ verify(searchIcon !== null)
+ verify(clearButton !== null)
+ compare(searchBar.height, 40)
+ compare(searchBar.background.visible, false)
+ compare(searchBar.padding, 0)
+ compare(searchBar.background.radius, 8)
+ compare(field.background.color, Theme.color.neutral2)
+ compare(field.background.radius, 5)
+ compare(field.placeholderText, "Find output")
+ compare(searchBar.placeholder, "Find output")
+ compare(searchBar.showsSearchIcon, true)
+ compare(searchBar.showsCancel, true)
+ compare(searchIcon.source.toString(), "image://images/search")
+ compare(searchIcon.size, 14)
+ verify(searchIcon.x < field.leftPadding)
+ compare(clearButton.visible, false)
+
+ testWindow.requestActivate()
+ tryCompare(testWindow, "active", true)
+ field.forceActiveFocus()
+ tryCompare(field, "activeFocus", true)
+ compare(field.background.border.width, 0)
+
+ searchBar.text = "needle"
+ compare(field.text, "needle")
+ compare(clearButton.visible, true)
+ compare(clearButton.width, 14)
+ compare(clearButton.height, 14)
+ compare(clearButton.contentItem.source.toString(),
+ "qrc:/icons/cross-circle-filled")
+ compare(clearButton.contentItem.size, 12)
+ compare(clearButton.contentItem.color, Theme.color.neutral6)
+ compare(clearButton.background, null)
+ const cancelSpy = signalSpy.createObject(searchBar, {
+ target: searchBar,
+ signalName: "cancelRequested"
+ })
+ verify(cancelSpy.valid)
+ mousePress(clearButton)
+ compare(clearButton.pressed, true)
+ compare(clearButton.contentItem.color, Theme.color.neutral4)
+ mouseRelease(clearButton)
+ compare(cancelSpy.count, 1)
+ compare(searchBar.text, "")
+ compare(clearButton.visible, false)
+
+ searchBar.text = "hidden cancel"
+ searchBar.showsCancel = false
+ compare(clearButton.visible, false)
+ searchBar.showsCancel = true
+ compare(clearButton.visible, true)
+ }
+
+ function test_search_submission_and_configuration() {
+ const searchBar = createSearchBar()
+ const field = searchBar.inputField
+ const searchIcon = findChild(searchBar, "sharedSearchIcon")
+ const searchSpy = signalSpy.createObject(searchBar, {
+ target: searchBar,
+ signalName: "searchRequested"
+ })
+ verify(searchSpy.valid)
+
+ searchBar.placeholder = "Search records"
+ compare(field.placeholderText, "Search records")
+ searchBar.showsSearchIcon = false
+ compare(searchIcon.visible, false)
+ compare(field.leftPadding, 10)
+ searchBar.showsSearchIcon = true
+ searchBar.searchIconSize = 16
+ compare(searchIcon.visible, true)
+ compare(searchIcon.size, 16)
+
+ searchBar.text = "status"
+ field.forceActiveFocus()
+ keyClick(Qt.Key_Return)
+ compare(searchSpy.count, 1)
+ compare(searchSpy.signalArguments[0][0], "status")
+ }
+
+ function test_optional_navigation_buttons() {
+ const searchBar = createSearchBar()
+ const navigation = findChild(searchBar, "sharedSearchNavigation")
+ const previousButton = findChild(searchBar, "sharedSearchPreviousButton")
+ const nextButton = findChild(searchBar, "sharedSearchNextButton")
+ const previousFocusBorder = findChild(searchBar, "sharedSearchPreviousButtonFocusBorder")
+ const nextFocusBorder = findChild(searchBar, "sharedSearchNextButtonFocusBorder")
+ const previousIcon = findChild(searchBar, "sharedSearchPreviousButtonIcon")
+ const nextIcon = findChild(searchBar, "sharedSearchNextButtonIcon")
+ verify(navigation !== null)
+ verify(previousButton !== null)
+ verify(nextButton !== null)
+ verify(previousFocusBorder !== null)
+ verify(nextFocusBorder !== null)
+ verify(previousIcon !== null)
+ verify(nextIcon !== null)
+ compare(previousButton.visible, false)
+ compare(nextButton.visible, false)
+ compare(navigation.visible, false)
+
+ searchBar.showNavigationButtons = true
+ searchBar.width = 140
+ compare(searchBar.height, 48)
+ compare(searchBar.inputField.height, 40)
+ compare(searchBar.background.visible, true)
+ compare(searchBar.background.color, Theme.color.neutral1)
+ compare(searchBar.padding, 4)
+ compare(navigation.visible, true)
+ compare(navigation.width, 54)
+ searchBar.navigationEnabled = false
+ compare(previousButton.visible, true)
+ compare(nextButton.visible, true)
+ compare(previousButton.width, 26)
+ compare(nextButton.width, 26)
+ compare(previousButton.enabled, false)
+ compare(nextButton.enabled, false)
+ compare(previousIcon.width, 14)
+ compare(previousIcon.height, 14)
+ compare(nextIcon.width, 14)
+ compare(nextIcon.height, 14)
+ compare(previousIcon.strokeWidth, 2)
+ compare(nextIcon.strokeWidth, 2)
+ compare(previousIcon.strokeColor, Theme.color.neutral4)
+ compare(nextIcon.strokeColor, Theme.color.neutral4)
+ compare(previousIcon.rotation, -90)
+ compare(nextIcon.rotation, 90)
+
+ searchBar.text = "a long search term that must not compress navigation"
+ searchBar.navigationEnabled = true
+ compare(previousButton.width, 26)
+ compare(nextButton.width, 26)
+ compare(previousIcon.width, 14)
+ compare(previousIcon.height, 14)
+ compare(nextIcon.width, 14)
+ compare(nextIcon.height, 14)
+ compare(previousIcon.strokeColor, Theme.color.neutral8)
+ compare(nextIcon.strokeColor, Theme.color.neutral8)
+ compare(previousButton.enabled, true)
+ compare(nextButton.enabled, true)
+
+ testWindow.requestActivate()
+ tryCompare(testWindow, "active", true)
+ searchBar.inputField.forceActiveFocus()
+ tryCompare(searchBar.inputField, "activeFocus", true)
+ keyClick(Qt.Key_Tab)
+ tryCompare(previousButton, "activeFocus", true)
+ tryCompare(previousFocusBorder, "visible", true)
+ compare(previousFocusBorder.border.color, Theme.color.purple)
+ compare(nextFocusBorder.visible, false)
+ keyClick(Qt.Key_Tab)
+ tryCompare(nextButton, "activeFocus", true)
+ tryCompare(nextFocusBorder, "visible", true)
+ compare(nextFocusBorder.border.color, Theme.color.purple)
+ compare(previousFocusBorder.visible, false)
+
+ // Restore the normal test width before pointer interaction. The
+ // constrained width above exists only to exercise layout compression.
+ searchBar.width = searchBar.implicitWidth
+ wait(0)
+ const previousSpy = signalSpy.createObject(searchBar, {
+ target: searchBar,
+ signalName: "previousRequested"
+ })
+ const nextSpy = signalSpy.createObject(searchBar, {
+ target: searchBar,
+ signalName: "nextRequested"
+ })
+ verify(previousSpy.valid)
+ verify(nextSpy.valid)
+ previousButton.clicked()
+ nextButton.clicked()
+ compare(previousSpy.count, 1)
+ compare(nextSpy.count, 1)
+ }
+
+ Component {
+ id: signalSpy
+ SignalSpy {}
+ }
+}
diff --git a/test/qml/tst_settingsnavigation.qml b/test/qml/tst_settingsnavigation.qml
index 91bd2673d2..00d0fc65fe 100644
--- a/test/qml/tst_settingsnavigation.qml
+++ b/test/qml/tst_settingsnavigation.qml
@@ -254,7 +254,7 @@ TestCase {
const displayPage = findChild(view, "displaySettingsPage")
verify(displayPage !== null)
verify(findChild(view, "networkTrafficSettingsPage") === null)
- verify(findChild(view, "settingsDebugLog") === null)
+ verify(findChild(view, "debugLogView") === null)
compare(testNetworkTrafficTower.active, false)
compare(testDebugLogModel.active, false)
@@ -290,38 +290,38 @@ TestCase {
view.selectSection("debug-log")
compare(findChild(view, "networkTrafficSettingsPage"), networkTrafficPage)
tryCompare(testNetworkTrafficTower, "active", false)
- const debugLogPage = findChild(view, "settingsDebugLog")
+ const debugLogPage = findChild(view, "debugLogView")
verify(debugLogPage !== null)
tryCompare(testDebugLogModel, "active", true)
view.selectSection("network-traffic")
compare(findChild(view, "networkTrafficSettingsPage"), networkTrafficPage)
- compare(findChild(view, "settingsDebugLog"), debugLogPage)
+ compare(findChild(view, "debugLogView"), debugLogPage)
compare(networkTrafficPage.trafficGraphScale, 3600)
tryCompare(testNetworkTrafficTower, "active", true)
tryCompare(testDebugLogModel, "active", false)
view.selectSection("debug-log")
- compare(findChild(view, "settingsDebugLog"), debugLogPage)
+ compare(findChild(view, "debugLogView"), debugLogPage)
tryCompare(testNetworkTrafficTower, "active", false)
tryCompare(testDebugLogModel, "active", true)
view.selectSection("about")
tryCompare(testDebugLogModel, "active", false)
verify(findChild(view, "aboutSettingsPage") !== null)
- compare(findChild(view, "settingsDebugLog"), debugLogPage)
+ compare(findChild(view, "debugLogView"), debugLogPage)
view.visible = false
compare(view.pageContainer.depth, 1)
tryCompare(testDebugLogModel, "active", false)
tryCompare(testNetworkTrafficTower, "active", false)
- compare(findChild(view, "settingsDebugLog"), debugLogPage)
+ compare(findChild(view, "debugLogView"), debugLogPage)
compare(findChild(view, "networkTrafficSettingsPage"), networkTrafficPage)
view.visible = true
compare(view.pageContainer.depth, 1)
verify(findChild(view, "aboutSettingsPage") !== null)
- compare(findChild(view, "settingsDebugLog"), debugLogPage)
+ compare(findChild(view, "debugLogView"), debugLogPage)
compare(findChild(view, "networkTrafficSettingsPage"), networkTrafficPage)
tryCompare(testDebugLogModel, "active", false)
tryCompare(testNetworkTrafficTower, "active", false)
@@ -442,7 +442,7 @@ TestCase {
verify(networkTrafficPage.contentLayout.width > 840)
view.selectSection("debug-log")
- const debugLogPage = findChild(view, "settingsDebugLog")
+ const debugLogPage = findChild(view, "debugLogView")
const debugLogContent = findChild(view, "debugLogContentLayout")
verify(debugLogPage !== null)
verify(debugLogContent !== null)
@@ -476,16 +476,160 @@ TestCase {
const page = findChild(view, "rpcConsoleSettingsPage")
const header = findChild(view, "rpcConsoleHeader")
+ const heading = findChild(view, "rpcConsolePageHeading")
+ const warning = findChild(view, "rpcConsoleWarningBanner")
+ const warningCloseButton = findChild(view, "rpcConsoleWarningBannerCloseButton")
+ const toolbar = findChild(view, "rpcConsoleToolbar")
+ const searchBar = findChild(view, "rpcConsoleSearchBar")
+ const search = findChild(view, "rpcConsoleSearchField")
+ const searchIcon = findChild(view, "rpcConsoleSearchIcon")
+ const searchClearButton = findChild(view, "rpcConsoleSearchClearButton")
+ const searchNavigation = findChild(view, "rpcConsoleSearchNavigation")
+ const previousSearchButton = findChild(view, "rpcConsoleSearchPreviousButton")
+ const nextSearchButton = findChild(view, "rpcConsoleSearchNextButton")
+ const previousSearchFocusBorder = findChild(view, "rpcConsoleSearchPreviousButtonFocusBorder")
+ const nextSearchFocusBorder = findChild(view, "rpcConsoleSearchNextButtonFocusBorder")
+ const previousSearchIcon = findChild(view, "rpcConsoleSearchPreviousButtonIcon")
+ const nextSearchIcon = findChild(view, "rpcConsoleSearchNextButtonIcon")
+ const fontStepper = findChild(view, "consoleFontStepper")
+ const decreaseButton = findChild(view, "consoleFontDecreaseButton")
+ const increaseButton = findChild(view, "consoleFontIncreaseButton")
+ const decreaseFocusBorder = findChild(view, "consoleFontDecreaseButtonFocusBorder")
+ const increaseFocusBorder = findChild(view, "consoleFontIncreaseButtonFocusBorder")
+ const decreaseLabel = findChild(view, "consoleFontDecreaseButtonLabel")
+ const increaseLabel = findChild(view, "consoleFontIncreaseButtonLabel")
const rpcConsole = findChild(view, "rpcConsole")
+ const commandInputRow = findChild(view, "consoleInputRow")
+ const commandInputDivider = findChild(view, "consoleInputDivider")
+ const commandInput = findChild(view, "consoleInput")
+ const helpFooter = findChild(view, "rpcConsoleHelpFooter")
verify(page !== null)
verify(header !== null)
+ verify(heading !== null)
+ verify(warning !== null)
+ verify(warningCloseButton !== null)
+ verify(toolbar !== null)
+ verify(searchBar !== null)
+ verify(search !== null)
+ verify(searchIcon !== null)
+ verify(searchClearButton !== null)
+ verify(searchNavigation !== null)
+ verify(previousSearchButton !== null)
+ verify(nextSearchButton !== null)
+ verify(previousSearchFocusBorder !== null)
+ verify(nextSearchFocusBorder !== null)
+ verify(previousSearchIcon !== null)
+ verify(nextSearchIcon !== null)
+ verify(fontStepper !== null)
+ verify(decreaseButton !== null)
+ verify(increaseButton !== null)
+ verify(decreaseFocusBorder !== null)
+ verify(increaseFocusBorder !== null)
+ verify(decreaseLabel !== null)
+ verify(increaseLabel !== null)
verify(rpcConsole !== null)
+ verify(commandInputRow !== null)
+ verify(commandInputDivider !== null)
+ verify(commandInput !== null)
+ verify(helpFooter !== null)
compare(header.title, "RPC console")
compare(header.showBackButton, false)
- compare(page.maximumContentWidth, 840)
+ compare(page.maximumContentWidth, page.width)
verify(page.contentHorizontalPadding >= 24)
+ compare(heading.description, "Execute RPC commands and inspect their responses.")
+ compare(warning.text,
+ "Beware of scammers who may ask you to enter commands here to steal your funds. Only enter commands you fully understand.")
+ tryCompare(warning, "opacity", 1)
+ compare(search.placeholderText, "Search console")
+ search.text = "help"
+ compare(rpcConsole.searchText, "help")
+ compare(searchBar.height, 48)
+ compare(search.height, 40)
+ compare(searchBar.background.visible, true)
+ compare(searchBar.background.color, Theme.color.neutral1)
+ compare(search.background.color, Theme.color.neutral2)
+ compare(searchIcon.source.toString(), "image://images/search")
+ compare(searchClearButton.visible, true)
+ compare(previousSearchIcon.width, 14)
+ compare(previousSearchIcon.height, 14)
+ compare(nextSearchIcon.width, 14)
+ compare(nextSearchIcon.height, 14)
+ compare(previousSearchIcon.strokeWidth, 2)
+ compare(nextSearchIcon.strokeWidth, 2)
+ compare(previousSearchIcon.strokeColor, Theme.color.neutral4)
+ compare(nextSearchIcon.strokeColor, Theme.color.neutral4)
+ compare(previousSearchIcon.rotation, -90)
+ compare(nextSearchIcon.rotation, 90)
+ compare(previousSearchButton.enabled, false)
+ compare(nextSearchButton.enabled, false)
+ verify(search.mapToItem(searchBar, 0, 0).x
+ < previousSearchButton.mapToItem(searchBar, 0, 0).x)
+ verify(previousSearchButton.mapToItem(searchBar, 0, 0).x
+ < nextSearchButton.mapToItem(searchBar, 0, 0).x)
+ verify(searchBar.x < fontStepper.x)
+ verify(heading.y < warning.y)
+ verify(warning.y < toolbar.y)
+ verify(toolbar.y < rpcConsole.y)
+ verify(helpFooter.y >= rpcConsole.y + rpcConsole.height)
+ compare(helpFooter.horizontalAlignment, Text.AlignHCenter)
+ compare(helpFooter.mapToItem(page, helpFooter.width / 2, 0).x,
+ rpcConsole.mapToItem(page, rpcConsole.width / 2, 0).x)
+ compare(helpFooter.text,
+ "Use ↑↓ arrows to navigate history. Type help for an overview of available commands. Type help-console for console syntax help.")
+ compare(commandInput.placeholderText, "Enter command…")
+ compare(commandInputRow.color, Theme.color.neutral1)
+ compare(commandInput.background.color, Theme.color.neutral2)
+ compare(commandInputDivider.height, 1)
+ compare(commandInputDivider.color, Theme.color.neutral2)
compare(rpcConsole.showHeader, false)
compare(rpcConsole.tabActive, true)
+ compare(rpcConsole.outputFontPixelSize, 13)
+ compare(fontStepper.width, 72)
+ compare(decreaseLabel.text, "A")
+ compare(increaseLabel.text, "A")
+ verify(decreaseLabel.font.pixelSize < increaseLabel.font.pixelSize)
+
+ settingsWindow.requestActivate()
+ tryCompare(settingsWindow, "active", true)
+ searchBar.navigationEnabled = true
+ search.forceActiveFocus()
+ tryCompare(search, "activeFocus", true)
+ keyClick(Qt.Key_Tab)
+ tryCompare(previousSearchButton, "activeFocus", true)
+ tryCompare(previousSearchFocusBorder, "visible", true)
+ compare(previousSearchFocusBorder.border.color, Theme.color.purple)
+ compare(nextSearchFocusBorder.visible, false)
+ keyClick(Qt.Key_Tab)
+ tryCompare(nextSearchButton, "activeFocus", true)
+ tryCompare(nextSearchFocusBorder, "visible", true)
+ compare(nextSearchFocusBorder.border.color, Theme.color.purple)
+ compare(previousSearchFocusBorder.visible, false)
+ keyClick(Qt.Key_Tab)
+ tryCompare(decreaseButton, "activeFocus", true)
+ tryCompare(decreaseFocusBorder, "visible", true)
+ compare(decreaseFocusBorder.border.color, Theme.color.purple)
+ compare(increaseFocusBorder.visible, false)
+ keyClick(Qt.Key_Tab)
+ tryCompare(increaseButton, "activeFocus", true)
+ tryCompare(increaseFocusBorder, "visible", true)
+ compare(increaseFocusBorder.border.color, Theme.color.purple)
+ compare(decreaseFocusBorder.visible, false)
+
+ mouseClick(increaseButton)
+ compare(rpcConsole.outputFontPixelSize, 14)
+ mouseClick(decreaseButton)
+ compare(rpcConsole.outputFontPixelSize, 13)
+ const consoleHeightBeforeDismiss = rpcConsole.height
+ const footerBottomBeforeDismiss = helpFooter.mapToItem(page, 0, helpFooter.height).y
+ mouseClick(warningCloseButton)
+ tryCompare(warning, "visible", false)
+ compare(page.warningVisible, false)
+ tryCompare(rpcConsole, "height",
+ consoleHeightBeforeDismiss + warning.implicitHeight + page.contentSpacing)
+ tryVerify(function() {
+ return Math.abs(helpFooter.mapToItem(page, 0, helpFooter.height).y
+ - footerBottomBeforeDismiss) < 0.5
+ })
view.selectSection("about")
tryCompare(rpcConsole, "tabActive", false)
@@ -729,7 +873,7 @@ TestCase {
{ id: "network-traffic", objectName: "networkTrafficSettingsPage" },
{ id: "mempool", objectName: "mempoolSettingsPage" },
{ id: "rpc-console", objectName: "rpcConsoleSettingsPage" },
- { id: "debug-log", objectName: "settingsDebugLog" },
+ { id: "debug-log", objectName: "debugLogView" },
{ id: "about", objectName: "aboutSettingsPage" }
]
diff --git a/test/qml/tst_signverifymessage.qml b/test/qml/tst_signverifymessage.qml
index 06d3c91843..eb71f57d86 100644
--- a/test/qml/tst_signverifymessage.qml
+++ b/test/qml/tst_signverifymessage.qml
@@ -194,7 +194,7 @@ TestCase {
verify(copySignatureButton !== null)
verify(signClearButton !== null)
verify(signClearButtonBackground !== null)
- compare(signClearButton.embedded, true)
+ compare(signClearButton.isOnSurface, true)
compare(signClearButtonBackground.border.width, 0)
compare(signClearButtonBackground.color, Theme.color.neutral2)
compare(signButton.enabled, false)
@@ -261,7 +261,7 @@ TestCase {
verify(resultIcon !== null)
verify(verifyClearButton !== null)
verify(verifyClearButtonBackground !== null)
- compare(verifyClearButton.embedded, true)
+ compare(verifyClearButton.isOnSurface, true)
compare(verifyClearButtonBackground.border.width, 0)
compare(verifyClearButtonBackground.color, Theme.color.neutral2)
compare(verifyButton.enabled, false)
diff --git a/test/test_debuglogmodel.cpp b/test/test_debuglogmodel.cpp
index cf8f133d42..4a2fb008c3 100644
--- a/test/test_debuglogmodel.cpp
+++ b/test/test_debuglogmodel.cpp
@@ -5,6 +5,7 @@
#include
#include
+#include
#include
#include
#include
@@ -43,7 +44,7 @@ QByteArray OversizedLine(char fill)
QString ContentAt(const DebugLogModel& model, int row)
{
- return model.data(model.index(row, 0), DebugLogModel::ContentRole).toString();
+ return model.data(model.index(row, 0), DebugLogModel::MessageRole).toString();
}
} // namespace
@@ -59,12 +60,12 @@ private Q_SLOTS:
void initialLoad_discardsOversizedPartialAndResynchronizes();
void initialLoad_skipsOversizedCompleteLine();
void deltaAfterEmptyLoad_preservesHasMoreSentinel();
- void liveRefresh_insertsAtTopWithoutResetAndPrunesTail();
+ void liveRefresh_appendsWithoutResetAndPrunesHead();
void liveRefresh_canFullyDisplaceCacheWithoutReset();
void liveRefresh_handlesDuplicateRecordsAndPartialWrites();
void liveRefresh_discardsOversizedPartialUntilNewline();
void liveRefresh_skipsOversizedCompleteLine();
- void loadMore_insertsOlderRowsAtBottom();
+ void loadMore_insertsOlderRowsAtTop();
void widerTailRequest_survivesRacesAndDeactivation();
void filter_updatesIncrementallyAndWhileInactive();
void rotation_fallsBackToFullSnapshot();
@@ -100,7 +101,7 @@ void DebugLogModelTests::inactiveModel_ignoresRefreshUntilActivated()
QSignalSpy insert_spy(&model, &QAbstractItemModel::rowsInserted);
QSignalSpy reset_spy(&model, &QAbstractItemModel::modelReset);
model.setActive(true);
- QTRY_COMPARE(ContentAt(model, 0), QStringLiteral("line two"));
+ QTRY_COMPARE(ContentAt(model, 1), QStringLiteral("line two"));
QCOMPARE(model.rowCount(), 2);
QCOMPARE(insert_spy.count(), 1);
QCOMPARE(reset_spy.count(), 0);
@@ -113,39 +114,59 @@ void DebugLogModelTests::parsedRoles_extractStructuredLogLines()
const QString log_path = dir.filePath("debug.log");
QByteArray records;
- records += Record("connect() to 127.0.0.1:9050 failed after wait: Connection refused (61)");
- records += Record("Writing 0 mempool transactions to file...");
- records += Record("ERROR: boom ");
- records += Record("UpdateTip: new best=abc height=1");
+ records += "2026-06-19T10:00:00.123456Z [net] Bound to 127.0.0.1\n";
+ records += "2026-06-19T10:00:01Z [rpc:error] boom \n";
+ records += "2026-06-19T10:00:02Z [mempool] Imported transactions\n";
+ records += "2026-06-19T10:00:02Z [bench] benchmark completed\n";
+ records += "2026-06-19T10:00:02Z [net:warning] peer is slow\n";
+ records += "2026-06-19T10:00:03Z ERROR: legacy failure\n";
+ records += "continuation without metadata\n";
QVERIFY(WriteBytes(log_path, records));
DebugLogModel model(fs::PathFromString(log_path.toStdString()));
model.setActive(true);
- QTRY_COMPARE(model.rowCount(), 4);
-
- const QModelIndex update_tip = model.index(0, 0);
- QCOMPARE(model.data(update_tip, DebugLogModel::LineNumberRole).toString(), QStringLiteral("1"));
- QCOMPARE(model.data(update_tip, DebugLogModel::CommandRole).toString(), QStringLiteral("UpdateTip"));
- QCOMPARE(model.data(update_tip, DebugLogModel::MessageRole).toString(), QStringLiteral("new best=abc height=1"));
- QCOMPARE(model.data(update_tip, DebugLogModel::ContentRole).toString(), QStringLiteral("UpdateTip: new best=abc height=1"));
- QCOMPARE(model.data(update_tip, DebugLogModel::SeverityRole).toInt(), int(DebugLogModel::InfoSeverity));
- QVERIFY(!model.data(update_tip, DebugLogModel::DateLabelRole).toString().isEmpty());
-
- const QModelIndex error = model.index(1, 0);
- QCOMPARE(model.data(error, DebugLogModel::CommandRole).toString(), QStringLiteral("ERROR"));
- QCOMPARE(model.data(error, DebugLogModel::MessageRole).toString(), QStringLiteral("boom "));
- QCOMPARE(model.data(error, DebugLogModel::ContentRole).toString(), QStringLiteral("ERROR: boom <bad>"));
- QCOMPARE(model.data(error, DebugLogModel::SeverityRole).toInt(), int(DebugLogModel::ErrorSeverity));
-
- const QModelIndex plain = model.index(2, 0);
- QCOMPARE(model.data(plain, DebugLogModel::CommandRole).toString(), QString{});
- QCOMPARE(model.data(plain, DebugLogModel::MessageRole).toString(),
- QStringLiteral("Writing 0 mempool transactions to file..."));
-
- const QModelIndex endpoint = model.index(3, 0);
- QCOMPARE(model.data(endpoint, DebugLogModel::CommandRole).toString(), QString{});
- QCOMPARE(model.data(endpoint, DebugLogModel::MessageRole).toString(),
- QStringLiteral("connect() to 127.0.0.1:9050 failed after wait: Connection refused (61)"));
+ QTRY_COMPARE(model.rowCount(), 7);
+
+ const QModelIndex network = model.index(0, 0);
+ QCOMPARE(model.data(network, DebugLogModel::MessageRole).toString(), QStringLiteral("Bound to 127.0.0.1"));
+ QCOMPARE(model.data(network, DebugLogModel::IsErrorRole).toBool(), false);
+ QCOMPARE(model.data(network, DebugLogModel::IsWarningRole).toBool(), false);
+ const QDateTime utc_time = QDateTime::fromString(
+ QStringLiteral("2026-06-19T10:00:00Z"), Qt::ISODate);
+ const QString expected_local_time = utc_time.toLocalTime().toString(
+ QStringLiteral("HH:mm:ss"));
+ QCOMPARE(model.data(network, DebugLogModel::TimestampRole).toString(), expected_local_time);
+
+ const QModelIndex rpc_error = model.index(1, 0);
+ QCOMPARE(model.data(rpc_error, DebugLogModel::MessageRole).toString(), QStringLiteral("boom "));
+ QCOMPARE(model.data(rpc_error, DebugLogModel::IsErrorRole).toBool(), true);
+ QCOMPARE(model.data(rpc_error, DebugLogModel::IsWarningRole).toBool(), false);
+
+ const QModelIndex mempool = model.index(2, 0);
+ QCOMPARE(model.data(mempool, DebugLogModel::MessageRole).toString(), QStringLiteral("Imported transactions"));
+ QCOMPARE(model.data(mempool, DebugLogModel::IsErrorRole).toBool(), false);
+
+ const QModelIndex bench = model.index(3, 0);
+ QCOMPARE(model.data(bench, DebugLogModel::MessageRole).toString(), QStringLiteral("benchmark completed"));
+
+ const QModelIndex warning = model.index(4, 0);
+ QCOMPARE(model.data(warning, DebugLogModel::MessageRole).toString(), QStringLiteral("peer is slow"));
+ QCOMPARE(model.data(warning, DebugLogModel::IsErrorRole).toBool(), false);
+ QCOMPARE(model.data(warning, DebugLogModel::IsWarningRole).toBool(), true);
+
+ const QModelIndex legacy_error = model.index(5, 0);
+ QCOMPARE(model.data(legacy_error, DebugLogModel::MessageRole).toString(), QStringLiteral("legacy failure"));
+ QCOMPARE(model.data(legacy_error, DebugLogModel::IsErrorRole).toBool(), true);
+ QCOMPARE(model.data(legacy_error, DebugLogModel::IsWarningRole).toBool(), false);
+
+ const QModelIndex continuation = model.index(6, 0);
+ QCOMPARE(model.data(continuation, DebugLogModel::TimestampRole).toString(), QString{});
+
+ model.setWarningsAndErrorsOnly(true);
+ QCOMPARE(model.rowCount(), 3);
+ QCOMPARE(ContentAt(model, 0), QStringLiteral("boom "));
+ QCOMPARE(ContentAt(model, 1), QStringLiteral("peer is slow"));
+ QCOMPARE(ContentAt(model, 2), QStringLiteral("legacy failure"));
}
void DebugLogModelTests::initialLoad_isSingleBatchAndDetectsHasMore()
@@ -175,8 +196,8 @@ void DebugLogModelTests::initialLoad_isSingleBatchAndDetectsHasMore()
QTRY_COMPARE(extra_model.rowCount(), 1000);
QTRY_VERIFY(extra_model.hasMoreLines());
QCOMPARE(reset_spy.count(), 1);
- QCOMPARE(ContentAt(extra_model, 0), QStringLiteral("line 1000"));
- QCOMPARE(ContentAt(extra_model, 999), QStringLiteral("line 1"));
+ QCOMPARE(ContentAt(extra_model, 0), QStringLiteral("line 1"));
+ QCOMPARE(ContentAt(extra_model, 999), QStringLiteral("line 1000"));
}
void DebugLogModelTests::initialLoad_handlesBlankLinesAtBlockBoundaries()
@@ -201,8 +222,8 @@ void DebugLogModelTests::initialLoad_handlesBlankLinesAtBlockBoundaries()
model.setActive(true);
QTRY_COMPARE(model.rowCount(), 400);
QVERIFY(model.hasMoreLines());
- QVERIFY(ContentAt(model, 0).startsWith(QStringLiteral("very long y")));
- QVERIFY(ContentAt(model, 0).size() > 64 * 1024);
+ QVERIFY(ContentAt(model, 399).startsWith(QStringLiteral("very long y")));
+ QVERIFY(ContentAt(model, 399).size() > 64 * 1024);
}
void DebugLogModelTests::initialLoad_discardsOversizedPartialAndResynchronizes()
@@ -216,8 +237,8 @@ void DebugLogModelTests::initialLoad_discardsOversizedPartialAndResynchronizes()
DebugLogModel model(fs::PathFromString(log_path.toStdString()));
model.setActive(true);
QTRY_COMPARE(model.rowCount(), 2);
- QCOMPARE(ContentAt(model, 0), QStringLiteral("older two"));
- QCOMPARE(ContentAt(model, 1), QStringLiteral("older one"));
+ QCOMPARE(ContentAt(model, 0), QStringLiteral("older one"));
+ QCOMPARE(ContentAt(model, 1), QStringLiteral("older two"));
// The newline completes the discarded physical line. Parsing resumes with
// the first normal record after it instead of exposing a tail fragment.
@@ -225,8 +246,8 @@ void DebugLogModelTests::initialLoad_discardsOversizedPartialAndResynchronizes()
QIODevice::Append | QIODevice::WriteOnly));
model.refresh();
QTRY_COMPARE(model.rowCount(), 3);
- QCOMPARE(ContentAt(model, 0), QStringLiteral("after oversized"));
QCOMPARE(ContentAt(model, 1), QStringLiteral("older two"));
+ QCOMPARE(ContentAt(model, 2), QStringLiteral("after oversized"));
}
void DebugLogModelTests::initialLoad_skipsOversizedCompleteLine()
@@ -240,8 +261,8 @@ void DebugLogModelTests::initialLoad_skipsOversizedCompleteLine()
DebugLogModel model(fs::PathFromString(log_path.toStdString()));
model.setActive(true);
QTRY_COMPARE(model.rowCount(), 2);
- QCOMPARE(ContentAt(model, 0), QStringLiteral("newer"));
- QCOMPARE(ContentAt(model, 1), QStringLiteral("older"));
+ QCOMPARE(ContentAt(model, 0), QStringLiteral("older"));
+ QCOMPARE(ContentAt(model, 1), QStringLiteral("newer"));
}
void DebugLogModelTests::deltaAfterEmptyLoad_preservesHasMoreSentinel()
@@ -262,17 +283,17 @@ void DebugLogModelTests::deltaAfterEmptyLoad_preservesHasMoreSentinel()
model.refresh();
QTRY_COMPARE(model.rowCount(), 1000);
QTRY_VERIFY(model.hasMoreLines());
- QCOMPARE(ContentAt(model, 0), QStringLiteral("line 1000"));
- QCOMPARE(ContentAt(model, 999), QStringLiteral("line 1"));
+ QCOMPARE(ContentAt(model, 0), QStringLiteral("line 1"));
+ QCOMPARE(ContentAt(model, 999), QStringLiteral("line 1000"));
QCOMPARE(reset_spy.count(), 1);
model.loadMore();
QTRY_COMPARE(model.rowCount(), 1001);
QVERIFY(!model.hasMoreLines());
- QCOMPARE(ContentAt(model, 1000), QStringLiteral("line 0"));
+ QCOMPARE(ContentAt(model, 0), QStringLiteral("line 0"));
}
-void DebugLogModelTests::liveRefresh_insertsAtTopWithoutResetAndPrunesTail()
+void DebugLogModelTests::liveRefresh_appendsWithoutResetAndPrunesHead()
{
QTemporaryDir dir;
QVERIFY(dir.isValid());
@@ -293,18 +314,16 @@ void DebugLogModelTests::liveRefresh_insertsAtTopWithoutResetAndPrunesTail()
QIODevice::Append | QIODevice::WriteOnly));
model.refresh();
- QTRY_COMPARE(ContentAt(model, 0), QStringLiteral("line 4"));
+ QTRY_COMPARE(ContentAt(model, 2), QStringLiteral("line 4"));
QCOMPARE(model.rowCount(), 3);
+ QCOMPARE(ContentAt(model, 0), QStringLiteral("line 2"));
QCOMPARE(ContentAt(model, 1), QStringLiteral("line 3"));
- QCOMPARE(ContentAt(model, 2), QStringLiteral("line 2"));
- QCOMPARE(model.data(model.index(2, 0), DebugLogModel::LineNumberRole).toString(),
- QStringLiteral("3"));
QCOMPARE(insert_spy.count(), 1);
- QCOMPARE(insert_spy.at(0).at(1).toInt(), 0);
- QCOMPARE(insert_spy.at(0).at(2).toInt(), 1);
+ QCOMPARE(insert_spy.at(0).at(1).toInt(), 1);
+ QCOMPARE(insert_spy.at(0).at(2).toInt(), 2);
QCOMPARE(remove_spy.count(), 1);
- QCOMPARE(remove_spy.at(0).at(1).toInt(), 3);
- QCOMPARE(remove_spy.at(0).at(2).toInt(), 4);
+ QCOMPARE(remove_spy.at(0).at(1).toInt(), 0);
+ QCOMPARE(remove_spy.at(0).at(2).toInt(), 1);
QCOMPARE(reset_spy.count(), 0);
QCOMPARE(new_lines_spy.count(), 1);
QCOMPARE(new_lines_spy.at(0).at(0).toInt(), 2);
@@ -335,16 +354,16 @@ void DebugLogModelTests::liveRefresh_canFullyDisplaceCacheWithoutReset()
QIODevice::Append | QIODevice::WriteOnly));
model.refresh();
- QTRY_COMPARE(ContentAt(model, 0), QStringLiteral("line 6"));
+ QTRY_COMPARE(ContentAt(model, 2), QStringLiteral("line 6"));
QCOMPARE(model.rowCount(), 3);
+ QCOMPARE(ContentAt(model, 0), QStringLiteral("line 4"));
QCOMPARE(ContentAt(model, 1), QStringLiteral("line 5"));
- QCOMPARE(ContentAt(model, 2), QStringLiteral("line 4"));
QCOMPARE(insert_spy.count(), 1);
QCOMPARE(insert_spy.at(0).at(1).toInt(), 0);
QCOMPARE(insert_spy.at(0).at(2).toInt(), 2);
QCOMPARE(remove_spy.count(), 1);
- QCOMPARE(remove_spy.at(0).at(1).toInt(), 3);
- QCOMPARE(remove_spy.at(0).at(2).toInt(), 5);
+ QCOMPARE(remove_spy.at(0).at(1).toInt(), 0);
+ QCOMPARE(remove_spy.at(0).at(2).toInt(), 2);
QCOMPARE(reset_spy.count(), 0);
QVERIFY(model.hasMoreLines());
}
@@ -381,7 +400,7 @@ void DebugLogModelTests::liveRefresh_handlesDuplicateRecordsAndPartialWrites()
QIODevice::Append | QIODevice::WriteOnly));
model.refresh();
QTRY_COMPARE(model.rowCount(), 4);
- QCOMPARE(ContentAt(model, 0), QStringLiteral("split record"));
+ QCOMPARE(ContentAt(model, 3), QStringLiteral("split record"));
QCOMPARE(insert_spy.count(), 2);
QCOMPARE(reset_spy.count(), 0);
}
@@ -420,8 +439,8 @@ void DebugLogModelTests::liveRefresh_discardsOversizedPartialUntilNewline()
QIODevice::Append | QIODevice::WriteOnly));
model.refresh();
QTRY_COMPARE(model.rowCount(), 2);
- QCOMPARE(ContentAt(model, 0), QStringLiteral("after oversized"));
- QCOMPARE(ContentAt(model, 1), QStringLiteral("baseline"));
+ QCOMPARE(ContentAt(model, 0), QStringLiteral("baseline"));
+ QCOMPARE(ContentAt(model, 1), QStringLiteral("after oversized"));
}
void DebugLogModelTests::liveRefresh_skipsOversizedCompleteLine()
@@ -441,12 +460,12 @@ void DebugLogModelTests::liveRefresh_skipsOversizedCompleteLine()
QIODevice::Append | QIODevice::WriteOnly));
model.refresh();
QTRY_COMPARE(model.rowCount(), 3);
- QCOMPARE(ContentAt(model, 0), QStringLiteral("after oversized"));
+ QCOMPARE(ContentAt(model, 0), QStringLiteral("baseline"));
QCOMPARE(ContentAt(model, 1), QStringLiteral("before oversized"));
- QCOMPARE(ContentAt(model, 2), QStringLiteral("baseline"));
+ QCOMPARE(ContentAt(model, 2), QStringLiteral("after oversized"));
}
-void DebugLogModelTests::loadMore_insertsOlderRowsAtBottom()
+void DebugLogModelTests::loadMore_insertsOlderRowsAtTop()
{
QTemporaryDir dir;
QVERIFY(dir.isValid());
@@ -465,12 +484,13 @@ void DebugLogModelTests::loadMore_insertsOlderRowsAtBottom()
QTRY_COMPARE(model.rowCount(), 1200);
QCOMPARE(model.loadLimit(), 2000);
QVERIFY(!model.hasMoreLines());
- QCOMPARE(ContentAt(model, 999), QStringLiteral("line 200"));
- QCOMPARE(ContentAt(model, 1000), QStringLiteral("line 199"));
- QCOMPARE(ContentAt(model, 1199), QStringLiteral("line 0"));
+ QCOMPARE(ContentAt(model, 0), QStringLiteral("line 0"));
+ QCOMPARE(ContentAt(model, 199), QStringLiteral("line 199"));
+ QCOMPARE(ContentAt(model, 200), QStringLiteral("line 200"));
+ QCOMPARE(ContentAt(model, 1199), QStringLiteral("line 1199"));
QCOMPARE(insert_spy.count(), 1);
- QCOMPARE(insert_spy.at(0).at(1).toInt(), 1000);
- QCOMPARE(insert_spy.at(0).at(2).toInt(), 1199);
+ QCOMPARE(insert_spy.at(0).at(1).toInt(), 0);
+ QCOMPARE(insert_spy.at(0).at(2).toInt(), 199);
QCOMPARE(reset_spy.count(), 0);
}
@@ -494,7 +514,8 @@ void DebugLogModelTests::widerTailRequest_survivesRacesAndDeactivation()
QCOMPARE(model.loadLimit(), 3000);
QTRY_COMPARE(model.rowCount(), 2500);
QVERIFY(!model.hasMoreLines());
- QCOMPARE(ContentAt(model, 2499), QStringLiteral("line 0"));
+ QCOMPARE(ContentAt(model, 0), QStringLiteral("line 0"));
+ QCOMPARE(ContentAt(model, 2499), QStringLiteral("line 2499"));
}
{
@@ -512,7 +533,7 @@ void DebugLogModelTests::widerTailRequest_survivesRacesAndDeactivation()
model.setActive(true);
QTRY_COMPARE(model.rowCount(), 1200);
QCOMPARE(model.loadLimit(), 2000);
- QCOMPARE(ContentAt(model, 1199), QStringLiteral("line 0"));
+ QCOMPARE(ContentAt(model, 0), QStringLiteral("line 0"));
}
}
@@ -536,14 +557,15 @@ void DebugLogModelTests::filter_updatesIncrementallyAndWhileInactive()
QIODevice::Append | QIODevice::WriteOnly));
model.refresh();
QTRY_COMPARE(model.rowCount(), 2);
- QCOMPARE(ContentAt(model, 0), QStringLiteral("keep new"));
+ QCOMPARE(ContentAt(model, 0), QStringLiteral("keep old"));
+ QCOMPARE(ContentAt(model, 1), QStringLiteral("keep new"));
QCOMPARE(insert_spy.count(), 1);
QCOMPARE(reset_spy.count(), 0);
model.setActive(false);
model.setFilter(QStringLiteral("drop"));
QCOMPARE(model.rowCount(), 2);
- QCOMPARE(ContentAt(model, 0), QStringLiteral("drop new"));
+ QCOMPARE(ContentAt(model, 0), QStringLiteral("drop old"));
QVERIFY(WriteBytes(log_path, Record("drop while inactive"),
QIODevice::Append | QIODevice::WriteOnly));
model.refresh();
@@ -552,7 +574,7 @@ void DebugLogModelTests::filter_updatesIncrementallyAndWhileInactive()
QSignalSpy reactivate_reset_spy(&model, &QAbstractItemModel::modelReset);
model.setActive(true);
- QTRY_COMPARE(ContentAt(model, 0), QStringLiteral("drop while inactive"));
+ QTRY_COMPARE(ContentAt(model, 2), QStringLiteral("drop while inactive"));
QCOMPARE(model.rowCount(), 3);
QCOMPARE(reactivate_reset_spy.count(), 0);
}
@@ -572,8 +594,8 @@ void DebugLogModelTests::rotation_fallsBackToFullSnapshot()
QVERIFY(WriteBytes(log_path, Record("rotated one") + Record("rotated two")));
model.refresh();
QTRY_COMPARE(model.rowCount(), 2);
- QCOMPARE(ContentAt(model, 0), QStringLiteral("rotated two"));
- QCOMPARE(ContentAt(model, 1), QStringLiteral("rotated one"));
+ QCOMPARE(ContentAt(model, 0), QStringLiteral("rotated one"));
+ QCOMPARE(ContentAt(model, 1), QStringLiteral("rotated two"));
QCOMPARE(reset_spy.count(), 1);
}
@@ -595,16 +617,17 @@ void DebugLogModelTests::loadLimit_changesKeepRetainedCacheBounded()
QVERIFY(model.hasMoreLines());
// Raising the cap while inactive forces a wider bounded tail read on the
- // next activation; older rows are appended at the bottom.
+ // next activation; older rows are prepended at the top.
QSignalSpy insert_spy(&model, &QAbstractItemModel::rowsInserted);
model.setLoadLimit(4);
QCOMPARE(model.rowCount(), 2);
model.setActive(true);
QTRY_COMPARE(model.rowCount(), 4);
- QCOMPARE(ContentAt(model, 3), QStringLiteral("line 1"));
+ QCOMPARE(ContentAt(model, 0), QStringLiteral("line 1"));
+ QCOMPARE(ContentAt(model, 3), QStringLiteral("line 4"));
QCOMPARE(insert_spy.count(), 1);
- QCOMPARE(insert_spy.at(0).at(1).toInt(), 2);
- QCOMPARE(insert_spy.at(0).at(2).toInt(), 3);
+ QCOMPARE(insert_spy.at(0).at(1).toInt(), 0);
+ QCOMPARE(insert_spy.at(0).at(2).toInt(), 1);
}
#ifdef BITCOINQML_NO_TEST_MAIN
diff --git a/test/test_rpcconsolemodel.cpp b/test/test_rpcconsolemodel.cpp
index a13e3b716e..6a0c0f73ea 100644
--- a/test/test_rpcconsolemodel.cpp
+++ b/test/test_rpcconsolemodel.cpp
@@ -173,8 +173,7 @@ private Q_SLOTS:
void stopRunsSynchronouslyWhileExecuting();
void walletNameScopesRpcToWalletUri();
void outputRowsExposeCategoryAndRawTimestamp();
- void welcomeMessageAddedOnce();
- void clearRestoresWelcomeMessageWithFreshTimestamp();
+ void clearRemovesOutput();
void outputTruncatedWhenResultTooLong();
void jsonReplyKeyColoringSkipsStringsContainingColons();
void availableCommandsIncludesHelpVariants();
@@ -366,74 +365,18 @@ void RpcConsoleModelTests::outputRowsExposeCategoryAndRawTimestamp()
QCOMPARE(out->data(out->index(1, 0), category_role).toInt(), int(RpcConsoleModel::CMD_REPLY));
}
-void RpcConsoleModelTests::welcomeMessageAddedOnce()
+void RpcConsoleModelTests::clearRemovesOutput()
{
RpcTestStubNode mock;
RpcConsoleModel model{mock};
auto* out = qobject_cast(model.outputModel());
QVERIFY(out != nullptr);
- const int timestamp_role = roleForName(out, "timestamp");
- const int content_role = roleForName(out, "content");
- const int category_role = roleForName(out, "category");
- QVERIFY(timestamp_role != -1);
- QVERIFY(content_role != -1);
- QVERIFY(category_role != -1);
-
- model.ensureWelcomeMessage();
- QCOMPARE(out->rowCount(), 1);
- model.ensureWelcomeMessage();
- QCOMPARE(out->rowCount(), 1);
-
- const QModelIndex welcome_index = out->index(0, 0);
- QCOMPARE(out->data(welcome_index, category_role).toInt(), int(RpcConsoleModel::CMD_REPLY));
- const QString timestamp = out->data(welcome_index, timestamp_role).toString();
- QCOMPARE(timestamp.size(), 8);
- QVERIFY(!timestamp.startsWith("["));
- QVERIFY(!timestamp.endsWith("]"));
-
- const QString welcome_html = out->data(welcome_index, content_role).toString();
- QVERIFY(welcome_html.contains("Use"));
- QVERIFY(welcome_html.contains("help-console"));
- QVERIFY(welcome_html.contains("Scammers and thieves"));
- QVERIFY(welcome_html.contains("(model.outputModel());
- QVERIFY(out != nullptr);
- const int timestamp_role = roleForName(out, "timestamp");
- const int content_role = roleForName(out, "content");
- const int category_role = roleForName(out, "category");
- QVERIFY(timestamp_role != -1);
- QVERIFY(content_role != -1);
- QVERIFY(category_role != -1);
-
- model.ensureWelcomeMessage();
- QCOMPARE(out->rowCount(), 1);
- const QString first_timestamp = out->data(out->index(0, 0), timestamp_role).toString();
-
submitAndSettle(model, "getblockcount");
- QVERIFY(out->rowCount() > 1);
+ QVERIFY(out->rowCount() > 0);
- QTest::qWait(1100);
model.clear();
- QCOMPARE(out->rowCount(), 1);
-
- const QModelIndex welcome_index = out->index(0, 0);
- QCOMPARE(out->data(welcome_index, category_role).toInt(), int(RpcConsoleModel::CMD_REPLY));
- const QString second_timestamp = out->data(welcome_index, timestamp_role).toString();
- QCOMPARE(second_timestamp.size(), 8);
- QVERIFY2(first_timestamp != second_timestamp, "clearing the console should re-add the welcome row with the current time");
-
- const QString welcome_html = out->data(welcome_index, content_role).toString();
- QVERIFY(welcome_html.contains("Use"));
- QVERIFY(welcome_html.contains("help-console"));
- QVERIFY(welcome_html.contains("Scammers and thieves"));
+ QCOMPARE(out->rowCount(), 0);
}
void RpcConsoleModelTests::outputTruncatedWhenResultTooLong()