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()