From 0fab8d05d95010fffebd9403ec0cefce9fdc6ab2 Mon Sep 17 00:00:00 2001 From: pseudoramdom Date: Tue, 18 Aug 2026 12:47:34 -0700 Subject: [PATCH 01/14] qml: add settings page layout primitives Introduce reusable SettingsPage, PageHeading, and FormSection controls. These provide constrained scrollable page content, consistent heading typography, and card-style sections with optional headers and footers. They are not wired into existing settings screens yet. --- qml/bitcoin_qml.qrc | 3 + qml/controls/FormSection.qml | 104 ++++++++++++++++++++++++++++++++ qml/controls/PageHeading.qml | 71 ++++++++++++++++++++++ qml/controls/SettingsHeader.qml | 16 ++--- qml/controls/SettingsPage.qml | 70 +++++++++++++++++++++ test/qml/tst_settingsheader.qml | 25 ++++++++ 6 files changed, 281 insertions(+), 8 deletions(-) create mode 100644 qml/controls/FormSection.qml create mode 100644 qml/controls/PageHeading.qml create mode 100644 qml/controls/SettingsPage.qml diff --git a/qml/bitcoin_qml.qrc b/qml/bitcoin_qml.qrc index e0ad8a4ea0..ad1e127a41 100644 --- a/qml/bitcoin_qml.qrc +++ b/qml/bitcoin_qml.qrc @@ -68,6 +68,7 @@ controls/EditableKeyValueRow.qml controls/ExternalLink.qml controls/FocusBorder.qml + controls/FormSection.qml controls/Header.qml controls/Icon.qml controls/IconButton.qml @@ -83,6 +84,7 @@ controls/OptionSwitch.qml controls/OutlineButton.qml controls/PageIndicator.qml + controls/PageHeading.qml controls/PageStack.qml controls/ProgressIndicator.qml controls/ProxyLocationInput.qml @@ -93,6 +95,7 @@ controls/SegmentedPicker.qml controls/Setting.qml controls/SettingsHeader.qml + controls/SettingsPage.qml controls/Skeleton.qml controls/SpinningIndicator.qml controls/TextButton.qml diff --git a/qml/controls/FormSection.qml b/qml/controls/FormSection.qml new file mode 100644 index 0000000000..9774f0c07f --- /dev/null +++ b/qml/controls/FormSection.qml @@ -0,0 +1,104 @@ +// 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.Layouts 1.15 + +ColumnLayout { + id: root + + default property alias content: contentColumn.data + property string title: "" + property string description: "" + property string footerText: "" + property bool showBackground: true + property int rowSpacing: 0 + property int sectionSpacing: 8 + property int cornerRadius: 16 + property color backgroundColor: Theme.color.neutral1 + property var titleTextStyle: Theme.text.subheading + property var descriptionTextStyle: Theme.text.caption + property var footerTextStyle: Theme.text.caption + + spacing: sectionHeader.visible ? sectionSpacing : 0 + implicitWidth: 450 + implicitHeight: sectionColumn.implicitHeight + + ColumnLayout { + id: sectionColumn + Layout.fillWidth: true + spacing: sectionHeader.visible || sectionFooter.visible ? root.sectionSpacing : 0 + + ColumnLayout { + id: sectionHeader + visible: root.title.length > 0 || root.description.length > 0 + Layout.fillWidth: true + Layout.leftMargin: 4 + Layout.rightMargin: 4 + spacing: 2 + + CoreText { + visible: root.title.length > 0 + Layout.fillWidth: true + text: root.title + color: Theme.color.neutral9 + font: root.titleTextStyle.font + lineHeight: root.titleTextStyle.lineHeight + lineHeightMode: Text.FixedHeight + horizontalAlignment: Text.AlignLeft + wrap: false + elide: Text.ElideRight + } + + CoreText { + visible: root.description.length > 0 + Layout.fillWidth: true + text: root.description + color: Theme.color.neutral7 + font: root.descriptionTextStyle.font + lineHeight: root.descriptionTextStyle.lineHeight + lineHeightMode: Text.FixedHeight + horizontalAlignment: Text.AlignLeft + wrap: true + } + } + + Rectangle { + id: card + objectName: root.objectName.length > 0 ? root.objectName + "Card" : "" + Layout.fillWidth: true + implicitHeight: contentColumn.implicitHeight + radius: root.cornerRadius + color: root.showBackground ? root.backgroundColor : "transparent" + clip: true + + Behavior on color { + ColorAnimation { duration: 150 } + } + + ColumnLayout { + id: contentColumn + anchors.left: parent.left + anchors.right: parent.right + spacing: root.rowSpacing + } + } + + CoreText { + id: sectionFooter + objectName: root.objectName.length > 0 ? root.objectName + "Footer" : "" + visible: root.footerText.length > 0 + Layout.fillWidth: true + Layout.leftMargin: 4 + Layout.rightMargin: 4 + text: root.footerText + color: Theme.color.neutral7 + font: root.footerTextStyle.font + lineHeight: root.footerTextStyle.lineHeight + lineHeightMode: Text.FixedHeight + horizontalAlignment: Text.AlignLeft + wrap: true + } + } +} diff --git a/qml/controls/PageHeading.qml b/qml/controls/PageHeading.qml new file mode 100644 index 0000000000..d6a9f84ae8 --- /dev/null +++ b/qml/controls/PageHeading.qml @@ -0,0 +1,71 @@ +// 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 + +Control { + id: root + + property string title: "" + property string description: "" + property alias trailingItem: trailingLoader.sourceComponent + property alias loadedTrailingItem: trailingLoader.item + property int contentSpacing: 16 + property var titleTextStyle: Theme.text.headline + property var descriptionTextStyle: Theme.text.description + + Accessible.name: title + Accessible.description: description + padding: 0 + implicitWidth: Math.max(320, contentItem.implicitWidth) + implicitHeight: contentItem.implicitHeight + background: null + + contentItem: RowLayout { + spacing: root.contentSpacing + + ColumnLayout { + Layout.fillWidth: true + Layout.minimumWidth: 0 + spacing: 4 + + CoreText { + objectName: root.objectName.length > 0 ? root.objectName + "Title" : "" + visible: root.title.length > 0 + Layout.fillWidth: true + text: root.title + color: Theme.color.neutral9 + font: root.titleTextStyle.font + lineHeight: root.titleTextStyle.lineHeight + lineHeightMode: Text.FixedHeight + horizontalAlignment: Text.AlignLeft + wrap: false + elide: Text.ElideRight + } + + CoreText { + objectName: root.objectName.length > 0 ? root.objectName + "Description" : "" + visible: root.description.length > 0 + Layout.fillWidth: true + text: root.description + color: Theme.color.neutral7 + font: root.descriptionTextStyle.font + lineHeight: root.descriptionTextStyle.lineHeight + lineHeightMode: Text.FixedHeight + horizontalAlignment: Text.AlignHCenter + wrap: true + } + } + + Loader { + id: trailingLoader + active: sourceComponent !== null + visible: item !== null + enabled: root.enabled + Layout.alignment: Qt.AlignTop | Qt.AlignRight + } + } +} diff --git a/qml/controls/SettingsHeader.qml b/qml/controls/SettingsHeader.qml index c3e7388783..579e8e3c26 100644 --- a/qml/controls/SettingsHeader.qml +++ b/qml/controls/SettingsHeader.qml @@ -64,16 +64,16 @@ Pane { rightMargin: -2 border.color: Theme.color.orange } - - Behavior on color { - ColorAnimation { duration: 150 } - } } - contentItem: Icon { - source: "image://images/caret-left" - color: Theme.color.neutral9 - size: 24 + contentItem: Item { + Icon { + objectName: "settingsHeaderBackIcon" + anchors.centerIn: parent + source: "image://images/caret-left" + color: Theme.color.neutral9 + size: 24 + } } HoverHandler { diff --git a/qml/controls/SettingsPage.qml b/qml/controls/SettingsPage.qml new file mode 100644 index 0000000000..5127c8f82d --- /dev/null +++ b/qml/controls/SettingsPage.qml @@ -0,0 +1,70 @@ +// 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 + +Page { + id: root + + default property alias content: contentLayout.data + + property bool showBackButton: true + property string backButtonObjectName: "" + property string backButtonText: "" + property alias rightItem: settingsHeader.rightItem + property real maximumContentWidth: 840 + property real contentHorizontalPadding: width >= 900 ? 56 : width >= 640 ? 40 : 24 + property real contentSpacing: 24 + property real contentTopPadding: 20 + property real contentBottomPadding: 40 + + readonly property alias pageHeader: settingsHeader + readonly property alias scrollView: scrollView + readonly property alias contentLayout: contentLayout + + signal back + + background: null + padding: 0 + + header: SettingsHeader { + id: settingsHeader + objectName: "settingsPageHeader" + title: root.title + showBackButton: root.showBackButton + backButtonObjectName: root.backButtonObjectName + backButtonText: root.backButtonText + onBack: root.back() + } + + ScrollView { + id: scrollView + objectName: "settingsPageScrollView" + anchors.fill: parent + contentWidth: availableWidth + contentHeight: contentFrame.height + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + Item { + id: contentFrame + width: scrollView.availableWidth + height: contentLayout.implicitHeight + root.contentTopPadding + root.contentBottomPadding + + ColumnLayout { + id: contentLayout + objectName: "settingsPageContentLayout" + anchors.top: parent.top + anchors.topMargin: root.contentTopPadding + anchors.horizontalCenter: parent.horizontalCenter + width: Math.max(0, Math.min( + parent.width - root.contentHorizontalPadding * 2, + root.maximumContentWidth)) + spacing: root.contentSpacing + } + } + } +} diff --git a/test/qml/tst_settingsheader.qml b/test/qml/tst_settingsheader.qml index f916a77ed4..528432d56e 100644 --- a/test/qml/tst_settingsheader.qml +++ b/test/qml/tst_settingsheader.qml @@ -43,6 +43,15 @@ TestCase { } } + Component { + id: compactBackHeader + SettingsHeader { + width: 400 + title: "Display" + backButtonObjectName: "compactSettingsBack" + } + } + // Regression: the right section must show its actions once visible. The // previous binding also read contentItem.visible, the child's *effective* // visibility, which includes the section's own Pane. Built while the parent @@ -69,4 +78,20 @@ TestCase { compare(backButton.iconSource.toString(), "image://images/caret-left") tryVerify(function() { return backButton.width > 40 }) } + + function test_compactBackIconKeepsSizeAcrossThemeChanges() { + const originalDark = Theme.dark + const header = createTemporaryObject(compactBackHeader, testCase) + verify(header !== null) + const icon = findChild(header, "settingsHeaderBackIcon") + verify(icon !== null) + + compare(icon.width, 24) + compare(icon.height, 24) + + Theme.dark = !originalDark + tryCompare(icon, "width", 24) + tryCompare(icon, "height", 24) + Theme.dark = originalDark + } } From 13c8533461379ef3bc446be33dfc5c76a304b1ce Mon Sep 17 00:00:00 2001 From: pseudoramdom Date: Tue, 18 Aug 2026 12:49:45 -0700 Subject: [PATCH 02/14] qml: add reusable settings row controls Add generic form, list, link, value, and textfield rows for settings screens. The controls provide consistent typography, dividers, disclosure indicators, disabled states, and composable leading, trailing, and body content. They are not wired into settings pages just yet. --- qml/bitcoin_qml.qrc | 5 ++ qml/controls/FormRow.qml | 157 ++++++++++++++++++++++++++++++++++ qml/controls/LinkRow.qml | 54 ++++++++++++ qml/controls/ListRow.qml | 69 +++++++++++++++ qml/controls/TextFieldRow.qml | 79 +++++++++++++++++ qml/controls/ValueRow.qml | 46 ++++++++++ 6 files changed, 410 insertions(+) create mode 100644 qml/controls/FormRow.qml create mode 100644 qml/controls/LinkRow.qml create mode 100644 qml/controls/ListRow.qml create mode 100644 qml/controls/TextFieldRow.qml create mode 100644 qml/controls/ValueRow.qml diff --git a/qml/bitcoin_qml.qrc b/qml/bitcoin_qml.qrc index ad1e127a41..58f2086527 100644 --- a/qml/bitcoin_qml.qrc +++ b/qml/bitcoin_qml.qrc @@ -68,12 +68,15 @@ controls/EditableKeyValueRow.qml controls/ExternalLink.qml controls/FocusBorder.qml + controls/FormRow.qml controls/FormSection.qml controls/Header.qml controls/Icon.qml controls/IconButton.qml controls/InformationPage.qml controls/KeyValueRow.qml + controls/LinkRow.qml + controls/ListRow.qml controls/LabeledTextInput.qml controls/LabeledCoinControlButton.qml controls/NavButton.qml @@ -99,10 +102,12 @@ controls/Skeleton.qml controls/SpinningIndicator.qml controls/TextButton.qml + controls/TextFieldRow.qml controls/Theme.qml controls/ToggleButton.qml controls/utils.js controls/ValueInput.qml + controls/ValueRow.qml controls/WalletTypeListItem.qml pages/initerrormessage.qml pages/MainWindow.qml diff --git a/qml/controls/FormRow.qml b/qml/controls/FormRow.qml new file mode 100644 index 0000000000..730fb6edc5 --- /dev/null +++ b/qml/controls/FormRow.qml @@ -0,0 +1,157 @@ +// 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 + +Control { + id: root + + property string title: "" + property string description: "" + property string supportingText: "" + property string errorText: "" + property alias leadingItem: leadingContainer.data + readonly property Item loadedLeadingItem: leadingContainer.children.length > 0 ? leadingContainer.children[0] : null + property alias trailingItem: trailingContainer.data + readonly property Item loadedTrailingItem: trailingContainer.children.length > 0 ? trailingContainer.children[0] : null + property alias bodyItem: bodyContainer.data + readonly property Item loadedBodyItem: bodyContainer.children.length > 0 ? bodyContainer.children[0] : null + property bool showDivider: true + property int minimumRowHeight: description.length > 0 || supportingText.length > 0 || errorText.length > 0 ? 62 : 48 + property int dividerLeftInset: leftPadding + property int dividerRightInset: rightPadding + property int contentSpacing: 12 + property int bodySpacing: 8 + property bool showsDisclosureIndicator: false + property string disclosureIndicatorObjectName: root.objectName.length > 0 + ? root.objectName + "DisclosureIndicator" + : "" + property color disclosureIndicatorColor: enabled ? Theme.color.neutral7 : Theme.color.neutral4 + property color titleColor: enabled ? Theme.color.neutral9 : Theme.color.neutral4 + property color descriptionColor: enabled ? Theme.color.neutral7 : Theme.color.neutral4 + property color supportingTextColor: enabled ? Theme.color.blue : Theme.color.neutral4 + property color errorTextColor: enabled ? Theme.color.red : Theme.color.neutral4 + property var titleTextStyle: Theme.text.description + property var descriptionTextStyle: Theme.text.caption + property var supportingTextStyle: Theme.text.caption + + Accessible.name: title + Accessible.description: description + padding: 0 + leftPadding: 16 + rightPadding: 16 + topPadding: 10 + bottomPadding: 10 + implicitWidth: Math.max(320, contentItem.implicitWidth + leftPadding + rightPadding) + implicitHeight: Math.max(minimumRowHeight, contentItem.implicitHeight + topPadding + bottomPadding) + + background: Item { + Rectangle { + visible: root.showDivider + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: root.dividerLeftInset + anchors.rightMargin: root.dividerRightInset + height: 1 + color: Theme.color.neutral3 + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + } + + contentItem: ColumnLayout { + spacing: root.bodySpacing + + RowLayout { + Layout.fillWidth: true + spacing: root.contentSpacing + + RowLayout { + id: leadingContainer + visible: children.length > 0 + enabled: root.enabled + Layout.alignment: Qt.AlignVCenter + spacing: 0 + } + + ColumnLayout { + Layout.fillWidth: true + Layout.minimumWidth: 0 + Layout.alignment: Qt.AlignVCenter + spacing: 2 + + CoreText { + objectName: root.objectName.length > 0 ? root.objectName + "Title" : "" + visible: root.title.length > 0 + Layout.fillWidth: true + text: root.title + color: root.titleColor + font: root.titleTextStyle.font + lineHeight: root.titleTextStyle.lineHeight + lineHeightMode: Text.FixedHeight + horizontalAlignment: Text.AlignLeft + wrap: false + elide: Text.ElideRight + } + + CoreText { + objectName: root.objectName.length > 0 ? root.objectName + "Description" : "" + visible: root.description.length > 0 + Layout.fillWidth: true + text: root.description + color: root.descriptionColor + font: root.descriptionTextStyle.font + lineHeight: root.descriptionTextStyle.lineHeight + lineHeightMode: Text.FixedHeight + horizontalAlignment: Text.AlignLeft + wrap: true + } + + CoreText { + objectName: root.objectName.length > 0 ? root.objectName + "SupportingText" : "" + visible: root.errorText.length > 0 || root.supportingText.length > 0 + Layout.fillWidth: true + text: root.errorText.length > 0 ? root.errorText : root.supportingText + color: root.errorText.length > 0 ? root.errorTextColor : root.supportingTextColor + font: root.supportingTextStyle.font + lineHeight: root.supportingTextStyle.lineHeight + lineHeightMode: Text.FixedHeight + horizontalAlignment: Text.AlignLeft + wrap: true + } + } + + RowLayout { + id: trailingContainer + visible: children.length > 0 + enabled: root.enabled + Layout.alignment: Qt.AlignVCenter + spacing: 0 + } + + CaretRightIcon { + id: disclosureIcon + objectName: root.disclosureIndicatorObjectName + visible: root.showsDisclosureIndicator + Layout.preferredWidth: visible ? disclosureIcon.size : 0 + Layout.preferredHeight: visible ? disclosureIcon.size : 0 + Layout.alignment: Qt.AlignVCenter + color: root.disclosureIndicatorColor + } + } + + ColumnLayout { + id: bodyContainer + visible: children.length > 0 + enabled: root.enabled + Layout.fillWidth: true + spacing: 0 + } + } +} diff --git a/qml/controls/LinkRow.qml b/qml/controls/LinkRow.qml new file mode 100644 index 0000000000..af322b9905 --- /dev/null +++ b/qml/controls/LinkRow.qml @@ -0,0 +1,54 @@ +pragma ComponentBehavior: Bound + +// 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.Layouts 1.15 + +ListRow { + id: root + + property string value: "" + property url link: "" + property url linkIconSource: "image://images/export" + property int linkIconSize: 18 + property int valueMaximumWidth: 300 + property color valueColor: enabled ? Theme.color.neutral9 : Theme.color.neutral4 + property color linkIconColor: valueColor + property var valueTextStyle: Theme.text.description + + signal activated(url link) + + Accessible.name: value.length > 0 ? title + ", " + value : title + accessibleRole: Accessible.Link + + trailingItem: RowLayout { + spacing: 6 + + CoreText { + objectName: root.objectName.length > 0 ? root.objectName + "Value" : "" + Layout.maximumWidth: root.valueMaximumWidth + text: root.value + color: root.valueColor + font: root.valueTextStyle.font + lineHeight: root.valueTextStyle.lineHeight + lineHeightMode: Text.FixedHeight + horizontalAlignment: Text.AlignRight + wrap: false + elide: Text.ElideMiddle + } + + Icon { + visible: root.linkIconSource.toString().length > 0 + Layout.preferredWidth: visible ? root.linkIconSize : 0 + Layout.preferredHeight: visible ? root.linkIconSize : 0 + source: root.linkIconSource + color: root.linkIconColor + size: root.linkIconSize + } + } + + onClicked: root.activated(root.link) +} diff --git a/qml/controls/ListRow.qml b/qml/controls/ListRow.qml new file mode 100644 index 0000000000..32d39ae5e2 --- /dev/null +++ b/qml/controls/ListRow.qml @@ -0,0 +1,69 @@ +// 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 alias title: row.title + property alias description: row.description + property alias supportingText: row.supportingText + property alias errorText: row.errorText + property alias leadingItem: row.leadingItem + property alias loadedLeadingItem: row.loadedLeadingItem + property alias trailingItem: row.trailingItem + property alias loadedTrailingItem: row.loadedTrailingItem + property alias showDivider: row.showDivider + property alias showsDisclosureIndicator: row.showsDisclosureIndicator + property alias disclosureIndicatorObjectName: row.disclosureIndicatorObjectName + property alias disclosureIndicatorColor: row.disclosureIndicatorColor + property bool selected: false + property int accessibleRole: Accessible.ListItem + property int cornerRadius: 16 + property color selectedBackgroundColor: Qt.rgba(Theme.color.orange.r, Theme.color.orange.g, Theme.color.orange.b, 0.15) + property color hoverBackgroundColor: Theme.color.neutral2 + property color selectedTextColor: Theme.color.orange + + Accessible.name: title + Accessible.description: description + Accessible.role: accessibleRole + hoverEnabled: AppMode.isDesktop + focusPolicy: Qt.StrongFocus + padding: 0 + implicitWidth: row.implicitWidth + implicitHeight: row.implicitHeight + + HoverHandler { + enabled: root.enabled && AppMode.isDesktop + cursorShape: Qt.PointingHandCursor + } + + background: Rectangle { + radius: root.cornerRadius + color: root.selected + ? root.selectedBackgroundColor + : root.down || root.hovered + ? root.hoverBackgroundColor + : "transparent" + + FocusBorder { + visible: root.visualFocus + borderRadius: root.cornerRadius + 2 + topMargin: -2 + bottomMargin: -2 + leftMargin: -2 + rightMargin: -2 + } + } + + contentItem: FormRow { + id: row + enabled: root.enabled + width: root.availableWidth + titleColor: root.selected && root.enabled ? root.selectedTextColor : (root.enabled ? Theme.color.neutral9 : Theme.color.neutral4) + } +} diff --git a/qml/controls/TextFieldRow.qml b/qml/controls/TextFieldRow.qml new file mode 100644 index 0000000000..21c7b63d6d --- /dev/null +++ b/qml/controls/TextFieldRow.qml @@ -0,0 +1,79 @@ +pragma ComponentBehavior: Bound + +// 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 + +FormRow { + id: root + + property string fieldObjectName: "" + property string text: "" + property string placeholderText: "" + property bool readOnly: false + property var validator: null + property int maximumLength: 32767 + property int inputMethodHints: Qt.ImhNone + property int echoMode: TextInput.Normal + property int fieldWidth: 180 + property int textAlignment: Text.AlignRight + property color fieldColor: enabled ? Theme.color.neutral9 : Theme.color.neutral4 + property color placeholderColor: enabled ? Theme.color.neutral5 : Theme.color.neutral4 + property color focusBorderColor: Theme.color.orange + property var fieldTextStyle: Theme.text.description + readonly property var field: loadedTrailingItem + + signal textEdited(string text) + signal editingFinished() + signal accepted() + + trailingItem: TextField { + id: input + objectName: root.fieldObjectName.length > 0 + ? root.fieldObjectName + : root.objectName.length > 0 ? root.objectName + "Field" : "" + implicitWidth: root.fieldWidth + implicitHeight: 32 + enabled: root.enabled + readOnly: root.readOnly + text: root.text + placeholderText: root.placeholderText + placeholderTextColor: root.placeholderColor + validator: root.validator + maximumLength: root.maximumLength + inputMethodHints: root.inputMethodHints + echoMode: root.echoMode + selectByMouse: true + leftPadding: 4 + rightPadding: 4 + color: root.fieldColor + font: root.fieldTextStyle.font + horizontalAlignment: root.textAlignment + verticalAlignment: TextInput.AlignVCenter + Accessible.name: root.title + Accessible.description: root.description + + background: FocusBorder { + visible: input.activeFocus + border.color: root.focusBorderColor + borderRadius: 6 + topMargin: -2 + bottomMargin: -2 + leftMargin: -2 + rightMargin: -2 + } + + onTextChanged: { + if (root.text !== text) root.text = text + } + onTextEdited: root.textEdited(text) + onEditingFinished: root.editingFinished() + onAccepted: { + root.accepted() + input.focus = false + } + } +} diff --git a/qml/controls/ValueRow.qml b/qml/controls/ValueRow.qml new file mode 100644 index 0000000000..5a77f6b8a1 --- /dev/null +++ b/qml/controls/ValueRow.qml @@ -0,0 +1,46 @@ +pragma ComponentBehavior: Bound + +// 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.Layouts 1.15 + +FormRow { + id: root + + property string value: "" + property url valueIconSource: "" + property int valueIconSize: 18 + property int valueMaximumWidth: 260 + property color valueColor: enabled ? Theme.color.neutral9 : Theme.color.neutral4 + property color valueIconColor: valueColor + property var valueTextStyle: Theme.text.description + + trailingItem: RowLayout { + spacing: 6 + + CoreText { + objectName: root.objectName.length > 0 ? root.objectName + "Value" : "" + Layout.maximumWidth: root.valueMaximumWidth + text: root.value + color: root.valueColor + font: root.valueTextStyle.font + lineHeight: root.valueTextStyle.lineHeight + lineHeightMode: Text.FixedHeight + horizontalAlignment: Text.AlignRight + wrap: false + elide: Text.ElideMiddle + } + + Icon { + visible: root.valueIconSource.toString().length > 0 + Layout.preferredWidth: visible ? root.valueIconSize : 0 + Layout.preferredHeight: visible ? root.valueIconSize : 0 + source: root.valueIconSource + color: root.valueIconColor + size: root.valueIconSize + } + } +} From 4dbf9eae5c5a867a6e19bf06053dd04a8f3aad3c Mon Sep 17 00:00:00 2001 From: pseudoramdom Date: Tue, 18 Aug 2026 12:54:38 -0700 Subject: [PATCH 03/14] qml: add picker control backed by context menu Add PopupPicker, a compact control that composes a DropdownButton, ContextMenu, and ContextMenuPicker. The control displays the current selection and manages opening, selecting, and closing the menu. It is intended for composition as the trailing control of a FormRow rather than as a settings row itself. Extend ContextMenuPicker with optional leading icons and icon-aware layout for richer selection choices. --- qml/bitcoin_qml.qrc | 1 + qml/controls/ContextMenuPicker.qml | 28 ++++++- qml/controls/PopupPicker.qml | 116 +++++++++++++++++++++++++++++ test/qml/bitcoin_qmltests.qrc | 1 + test/qml/tst_popuppicker.qml | 74 ++++++++++++++++++ 5 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 qml/controls/PopupPicker.qml create mode 100644 test/qml/tst_popuppicker.qml diff --git a/qml/bitcoin_qml.qrc b/qml/bitcoin_qml.qrc index 58f2086527..981e151454 100644 --- a/qml/bitcoin_qml.qrc +++ b/qml/bitcoin_qml.qrc @@ -89,6 +89,7 @@ controls/PageIndicator.qml controls/PageHeading.qml controls/PageStack.qml + controls/PopupPicker.qml controls/ProgressIndicator.qml controls/ProxyLocationInput.qml controls/QRImage.qml diff --git a/qml/controls/ContextMenuPicker.qml b/qml/controls/ContextMenuPicker.qml index 528c3e9d83..cb4def2f29 100644 --- a/qml/controls/ContextMenuPicker.qml +++ b/qml/controls/ContextMenuPicker.qml @@ -16,10 +16,12 @@ Item { property string textRole: "text" property string valueRole: "value" property string subtitleRole: "" + property string iconRole: "" property string objectNameRole: "" property string subtitleObjectNameRole: "" property var currentValue property url selectionIconSource: "image://images/check" + property int iconSize: 18 property int rowHeight: 36 property int subtitleRowHeight: 52 @@ -40,6 +42,11 @@ Item { const v = item[root.subtitleRole] return v === undefined || v === null ? "" : v } + function _rowIconSource(item) { + if (root.iconRole === "" || typeof item !== 'object' || item === null) return "" + const v = item[root.iconRole] + return v === undefined || v === null ? "" : v + } function _rowObjectName(item) { if (root.objectNameRole === "" || typeof item !== 'object' || item === null) return "" const v = item[root.objectNameRole] @@ -89,12 +96,13 @@ Item { property string rowText: root._rowText(rowData) property var rowValue: root._rowValue(rowData) property string subtitle: root._rowSubtitle(rowData) + property url rowIconSource: root._rowIconSource(rowData) property string subtitleObjectName: root._rowSubtitleObjectName(rowData) objectName: root._rowObjectName(rowData) readonly property bool selected: root.currentValue === rowValue - readonly property int _effectiveHeight: subtitle !== "" - ? root.subtitleRowHeight - : root.rowHeight + readonly property int _textHeight: subtitle !== "" ? root.subtitleRowHeight : root.rowHeight + readonly property int _iconHeight: rowIconSource.toString() !== "" ? root.iconSize + 12 : 0 + readonly property int _effectiveHeight: Math.max(_textHeight, _iconHeight) Accessible.name: rowText Accessible.checkable: true @@ -112,6 +120,20 @@ Item { contentItem: RowLayout { spacing: 7 + Item { + visible: _row.rowIconSource.toString() !== "" + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: visible ? root.iconSize : 0 + Layout.preferredHeight: visible ? root.iconSize : 0 + + Icon { + anchors.centerIn: parent + source: _row.rowIconSource + color: _row._highlighted ? _row._hoverColor : _row._idleColor + size: root.iconSize + } + } + ColumnLayout { Layout.fillWidth: true Layout.alignment: Qt.AlignVCenter diff --git a/qml/controls/PopupPicker.qml b/qml/controls/PopupPicker.qml new file mode 100644 index 0000000000..c55652858d --- /dev/null +++ b/qml/controls/PopupPicker.qml @@ -0,0 +1,116 @@ +// 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 + +Control { + id: root + + property var model: [] + property string textRole: "text" + property string valueRole: "value" + property string subtitleRole: "" + property string iconRole: "" + property string objectNameRole: "" + property string subtitleObjectNameRole: "" + property var currentValue + property string displayText: "" + property string placeholderText: "" + property string menuTitle: "" + property int minimumMenuWidth: 240 + property int textAlignment: Text.AlignRight + property int caretSize: 20 + property url selectionIconSource: "image://images/check" + property int iconSize: 18 + property var labelTextStyle: Theme.text.description + property bool embedded: false + readonly property string currentText: displayText.length > 0 ? displayText : _currentText() + readonly property bool opened: popup.visible + + signal activated(var value) + + function _count() { + if (root.model === null || root.model === undefined) return 0 + if (root.model.count !== undefined) return root.model.count + return root.model.length !== undefined ? root.model.length : 0 + } + + function _itemAt(index) { + if (root.model && typeof root.model.get === "function") return root.model.get(index) + return root.model[index] + } + + function _itemText(item) { + if (typeof item === "object" && item !== null && item[root.textRole] !== undefined) return item[root.textRole] + return item === undefined || item === null ? "" : String(item) + } + + function _itemValue(item) { + if (typeof item === "object" && item !== null && item[root.valueRole] !== undefined) return item[root.valueRole] + return item + } + + function _currentText() { + const count = root._count() + for (let i = 0; i < count; ++i) { + const item = root._itemAt(i) + if (root._itemValue(item) === root.currentValue) return root._itemText(item) + } + return root.placeholderText + } + + function open() { popup.open() } + function close() { popup.close() } + function itemAtIndex(index) { return picker.itemAtIndex(index) } + + padding: 0 + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + background: null + + contentItem: DropdownButton { + id: button + objectName: root.objectName.length > 0 ? root.objectName + "Button" : "" + enabled: root.enabled && root._count() > 0 + text: root.currentText + opened: root.opened + textAlignment: root.textAlignment + caretSize: root.caretSize + labelTextStyle: root.labelTextStyle + defaultBgColor: root.embedded ? Theme.color.neutral3 : Theme.color.background + onClicked: root.opened ? root.close() : root.open() + } + + ContextMenu { + id: popup + objectName: root.objectName.length > 0 ? root.objectName + "Menu" : "" + parent: button + modal: true + dim: false + minMenuWidth: Math.max(root.minimumMenuWidth, root.width) + x: button.width - width + y: button.height + 2 + + ContextMenuPicker { + id: picker + objectName: root.objectName.length > 0 ? root.objectName + "List" : "" + title: root.menuTitle + model: root.model + textRole: root.textRole + valueRole: root.valueRole + subtitleRole: root.subtitleRole + iconRole: root.iconRole + objectNameRole: root.objectNameRole + subtitleObjectNameRole: root.subtitleObjectNameRole + currentValue: root.currentValue + selectionIconSource: root.selectionIconSource + iconSize: root.iconSize + onActivated: function(value) { + root.close() + root.activated(value) + } + } + } +} diff --git a/test/qml/bitcoin_qmltests.qrc b/test/qml/bitcoin_qmltests.qrc index 4ffdd045ea..c7f0f7d6d9 100644 --- a/test/qml/bitcoin_qmltests.qrc +++ b/test/qml/bitcoin_qmltests.qrc @@ -28,6 +28,7 @@ tst_nodesettings.qml tst_onboarding_datadir.qml tst_peeractions.qml + tst_popuppicker.qml tst_proxylocationinput.qml tst_requestpayment.qml tst_rightcontenticon.qml diff --git a/test/qml/tst_popuppicker.qml b/test/qml/tst_popuppicker.qml new file mode 100644 index 0000000000..a2d30cf4a8 --- /dev/null +++ b/test/qml/tst_popuppicker.qml @@ -0,0 +1,74 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import QtQuick 2.15 +import QtTest 1.2 +import "../../qml/controls" + +TestCase { + name: "PopupPicker" + when: windowShown + width: 500 + height: 300 + + Item { + id: host + width: parent.width + height: parent.height + } + + Component { + id: adaptivePickerComponent + + Item { + property alias picker: picker + property string selectedValue: "embedded" + + PopupPicker { + id: picker + objectName: "adaptivePopupPicker" + currentValue: parent.selectedValue + selectionIconSource: "" + model: [ + { text: "Roboto Mono", value: "embedded" }, + { text: "System Monospace", value: "best_system" } + ] + } + } + } + + function test_closed_chip_tracks_current_label_width() { + const pickerHost = createTemporaryObject(adaptivePickerComponent, host) + verify(pickerHost !== null) + + const picker = pickerHost.picker + tryCompare(picker, "currentText", "Roboto Mono") + const shortWidth = picker.implicitWidth + verify(shortWidth > 0) + verify(shortWidth < picker.minimumMenuWidth) + + pickerHost.selectedValue = "best_system" + tryCompare(picker, "currentText", "System Monospace") + tryVerify(function() { return picker.implicitWidth > shortWidth }) + + pickerHost.selectedValue = "embedded" + tryCompare(picker, "currentText", "Roboto Mono") + tryCompare(picker, "implicitWidth", shortWidth) + } + + function test_menu_width_is_independent_from_closed_chip_width() { + const pickerHost = createTemporaryObject(adaptivePickerComponent, host) + verify(pickerHost !== null) + + const picker = pickerHost.picker + const menu = findChild(pickerHost, "adaptivePopupPickerMenu") + verify(menu !== null) + verify(picker.implicitWidth < picker.minimumMenuWidth) + + picker.open() + tryCompare(menu, "opened", true) + verify(menu.width >= picker.minimumMenuWidth) + picker.close() + } +} From d3590c199837e9e799fa8b9f67ede2b2e3e50f4b Mon Sep 17 00:00:00 2001 From: pseudoramdom Date: Tue, 18 Aug 2026 12:56:36 -0700 Subject: [PATCH 04/14] qml: polish shared settings controls Update context-menu surfaces and hover states to use the revised neutral palette. Improve segmented-picker contrast in light mode and standardize disclosure indicators to the compact settings-row size. --- qml/controls/CaretRightIcon.qml | 2 +- qml/controls/ContextMenu.qml | 3 ++- qml/controls/ContextMenuButton.qml | 3 ++- qml/controls/FormRow.qml | 2 +- qml/controls/OptionSwitch.qml | 8 ++++++++ qml/controls/PopupPicker.qml | 4 +++- qml/controls/SegmentedPicker.qml | 13 +++++++------ qml/controls/Theme.qml | 10 +++++----- qml/controls/ToggleButton.qml | 8 ++++++++ test/qml/tst_contextmenu.qml | 1 + test/qml/tst_contextmenubutton.qml | 5 +++++ 11 files changed, 43 insertions(+), 16 deletions(-) diff --git a/qml/controls/CaretRightIcon.qml b/qml/controls/CaretRightIcon.qml index fe39d43c11..2b8eac54a4 100644 --- a/qml/controls/CaretRightIcon.qml +++ b/qml/controls/CaretRightIcon.qml @@ -7,5 +7,5 @@ import QtQuick.Controls 2.15 Icon { source: "image://images/caret-right" - size: 18 + size: 14 } diff --git a/qml/controls/ContextMenu.qml b/qml/controls/ContextMenu.qml index 692a931b7f..f77e222de5 100644 --- a/qml/controls/ContextMenu.qml +++ b/qml/controls/ContextMenu.qml @@ -13,6 +13,7 @@ Popup { property int minMenuWidth: 240 property int itemSpacing: 0 property int menuPadding: 6 + property color backgroundColor: Theme.color.neutral1 default property alias menuItems: _column.data @@ -36,7 +37,7 @@ Popup { height: implicitHeight background: Rectangle { - color: Theme.color.neutral1 + color: root.backgroundColor border.color: Theme.dark ? Theme.color.neutral2 : Theme.color.neutral3 border.width: 1 radius: 5 diff --git a/qml/controls/ContextMenuButton.qml b/qml/controls/ContextMenuButton.qml index 4465cd6594..6e24ae1302 100644 --- a/qml/controls/ContextMenuButton.qml +++ b/qml/controls/ContextMenuButton.qml @@ -18,6 +18,7 @@ AbstractButton { property url iconSource property int role: ContextMenuButton.Normal property bool autoClose: true + property color hoverBackgroundColor: Theme.color.neutral3 readonly property bool _destructive: role === ContextMenuButton.Destructive readonly property bool _highlighted: enabled && (hovered || down || visualFocus) @@ -95,7 +96,7 @@ AbstractButton { } background: Rectangle { - color: root._highlighted ? Theme.color.neutral2 : "transparent" + color: root._highlighted ? root.hoverBackgroundColor : "transparent" radius: 6 } } diff --git a/qml/controls/FormRow.qml b/qml/controls/FormRow.qml index 730fb6edc5..e00ce7799e 100644 --- a/qml/controls/FormRow.qml +++ b/qml/controls/FormRow.qml @@ -57,7 +57,7 @@ Control { anchors.leftMargin: root.dividerLeftInset anchors.rightMargin: root.dividerRightInset height: 1 - color: Theme.color.neutral3 + color: Theme.color.neutral2 Behavior on color { ColorAnimation { duration: 150 } diff --git a/qml/controls/OptionSwitch.qml b/qml/controls/OptionSwitch.qml index 9a9ec32442..cb4f676ca9 100644 --- a/qml/controls/OptionSwitch.qml +++ b/qml/controls/OptionSwitch.qml @@ -9,6 +9,7 @@ Switch { id: root implicitWidth: 45 implicitHeight: 28 + focusPolicy: Qt.StrongFocus background: Rectangle { radius: Math.floor(height / 2) color: root.checked ? Theme.color.orange : Theme.color.neutral4 @@ -34,4 +35,11 @@ Switch { ColorAnimation { duration: 150 } } } + + FocusBorder { + objectName: root.objectName.length > 0 ? root.objectName + "FocusBorder" : "" + visible: root.visualFocus + borderRadius: Math.floor(height / 2) + z: 1 + } } diff --git a/qml/controls/PopupPicker.qml b/qml/controls/PopupPicker.qml index c55652858d..6e55d8a9cb 100644 --- a/qml/controls/PopupPicker.qml +++ b/qml/controls/PopupPicker.qml @@ -79,7 +79,8 @@ Control { textAlignment: root.textAlignment caretSize: root.caretSize labelTextStyle: root.labelTextStyle - defaultBgColor: root.embedded ? Theme.color.neutral3 : Theme.color.background + defaultBgColor: root.embedded ? Theme.color.neutral2 : Theme.color.background + hoverBgColor: root.embedded ? Theme.color.neutral3 : Theme.color.neutral2 onClicked: root.opened ? root.close() : root.open() } @@ -89,6 +90,7 @@ Control { parent: button modal: true dim: false + backgroundColor: root.embedded ? Theme.color.neutral2 : Theme.color.neutral1 minMenuWidth: Math.max(root.minimumMenuWidth, root.width) x: button.width - width y: button.height + 2 diff --git a/qml/controls/SegmentedPicker.qml b/qml/controls/SegmentedPicker.qml index a74b9fba88..7c39090d36 100644 --- a/qml/controls/SegmentedPicker.qml +++ b/qml/controls/SegmentedPicker.qml @@ -27,7 +27,7 @@ Control { padding: 5 background: Rectangle { - color: Theme.color.neutral3 + color: Theme.color.neutral2 radius: 8 Behavior on color { @@ -45,6 +45,7 @@ Control { required property int index required property var modelData + objectName: root.objectName.length > 0 ? root.objectName + "Option_" + index : "" Layout.fillWidth: true Layout.fillHeight: true Layout.preferredWidth: 1 @@ -53,13 +54,13 @@ Control { checked: index === root.currentIndex text: root.optionText(modelData) bgRadius: 5 - textColor: Theme.color.white - textHoverColor: Theme.color.orangeLight1 + textColor: Theme.color.neutral9 + textHoverColor: checked ? Theme.color.white : Theme.color.neutral9 textActiveColor: Theme.color.white textActiveBold: true - bgHoverColor: checked ? Theme.color.neutral6 : Theme.color.neutral4 - bgActiveColor: Theme.color.neutral6 - bgDefaultColor: Theme.color.neutral3 + bgHoverColor: checked ? Theme.color.orange : Theme.color.neutral4 + bgActiveColor: Theme.color.orange + bgDefaultColor: Theme.color.neutral2 onClicked: { root.selected(index, modelData) diff --git a/qml/controls/Theme.qml b/qml/controls/Theme.qml index bb3772e773..d7d8344dff 100644 --- a/qml/controls/Theme.qml +++ b/qml/controls/Theme.qml @@ -76,9 +76,9 @@ Control { amber: "#C9B500" purple: "#C075DC" neutral0: "#000000" - neutral1: "#1A1A1A" - neutral2: "#2D2D2D" - neutral3: "#444444" + neutral1: "#121212" + neutral2: "#222222" + neutral3: "#383838" neutral4: "#5C5C5C" neutral5: "#787878" neutral6: "#949494" @@ -108,8 +108,8 @@ Control { amber: "#C9B500" purple: "#BB6BD9" neutral0: "#FFFFFF" - neutral1: "#F8F8F8" - neutral2: "#F4F4F4" + neutral1: "#F6F6F6" + neutral2: "#EEEEEE" neutral3: "#EDEDED" neutral4: "#DEDEDE" neutral5: "#BBBBBB" diff --git a/qml/controls/ToggleButton.qml b/qml/controls/ToggleButton.qml index 6e3aa89cb4..dbd867e5c5 100644 --- a/qml/controls/ToggleButton.qml +++ b/qml/controls/ToggleButton.qml @@ -20,6 +20,7 @@ Button { id: root checkable: true hoverEnabled: AppMode.isDesktop + focusPolicy: Qt.StrongFocus leftPadding: 12 rightPadding: 12 topPadding: 5 @@ -47,6 +48,13 @@ Button { } } + FocusBorder { + objectName: root.objectName.length > 0 ? root.objectName + "FocusBorder" : "" + visible: root.visualFocus + borderRadius: root.bgRadius + 4 + z: 1 + } + states: [ State { name: "CHECKED"; when: root.checked diff --git a/test/qml/tst_contextmenu.qml b/test/qml/tst_contextmenu.qml index 19caf68d72..9763d2fdf8 100644 --- a/test/qml/tst_contextmenu.qml +++ b/test/qml/tst_contextmenu.qml @@ -87,6 +87,7 @@ TestCase { function test_empty_menu_clamps_to_min_width() { const menu = openMenu(emptyMenuComponent) compare(menu.implicitWidth, menu.minMenuWidth) + compare(menu.background.color, Theme.color.neutral1) } function test_escape_closes_focused_menu() { diff --git a/test/qml/tst_contextmenubutton.qml b/test/qml/tst_contextmenubutton.qml index 13c93bd79f..6cfb2ba5bb 100644 --- a/test/qml/tst_contextmenubutton.qml +++ b/test/qml/tst_contextmenubutton.qml @@ -47,7 +47,12 @@ TestCase { compare(button.role, ContextMenuButton.Normal) compare(button.autoClose, true) compare(button.focusPolicy, Qt.StrongFocus) + compare(button.hoverBackgroundColor, Theme.color.neutral3) verify(button.hoverEnabled) + + button.forceActiveFocus(Qt.TabFocusReason) + tryCompare(button, "visualFocus", true) + compare(button.background.color, Theme.color.neutral3) } function test_destructive_role_marker() { From 76300cdb6339f01754f9eae14372638a0e90fdf5 Mon Sep 17 00:00:00 2001 From: pseudoramdom Date: Tue, 18 Aug 2026 14:15:21 -0700 Subject: [PATCH 05/14] qml: showcase reusable settings form controls Rework the design-system page to use SettingsPage, FormSection, and the generic row controls. Add representative examples for switches, pickers, navigation, values, links, inline text fields, and supporting content. Cover the shared form components and their composition with QML tests. --- qml/pages/settings/SettingsDesignSystem.qml | 500 +++++++++++++++----- test/qml/bitcoin_qmltests.qrc | 1 + test/qml/tst_formcontrols.qml | 385 +++++++++++++++ 3 files changed, 762 insertions(+), 124 deletions(-) create mode 100644 test/qml/tst_formcontrols.qml diff --git a/qml/pages/settings/SettingsDesignSystem.qml b/qml/pages/settings/SettingsDesignSystem.qml index 8c504c0f80..ad2e5009c6 100644 --- a/qml/pages/settings/SettingsDesignSystem.qml +++ b/qml/pages/settings/SettingsDesignSystem.qml @@ -1,168 +1,420 @@ +pragma ComponentBehavior: Bound + // 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" - -Page { - signal back +SettingsPage { id: root - background: null - leftPadding: 20 - rightPadding: 20 - topPadding: 30 + title: qsTr("Design system") readonly property var typographyRoles: [ - { name: "display", group: "Headers" }, - { name: "headline", group: "Headers" }, - { name: "title", group: "Headers" }, - { name: "subtitle", group: "Headers" }, - { name: "heading", group: "Headers" }, - { name: "subheading", group: "Headers" }, - { name: "lead", group: "Body" }, - { name: "bodyLarge", group: "Body" }, - { name: "body", group: "Body" }, - { name: "description", group: "Body" }, - { name: "caption", group: "Body" }, - { name: "button", group: "Controls" }, - { name: "buttonStrong", group: "Controls" }, - { name: "monoLead", group: "Mono" }, - { name: "monoBody", group: "Mono" }, - { name: "monoDescription", group: "Mono" }, - { name: "monoCaption", group: "Mono" } + { + name: "display", + group: "Headers" + }, + { + name: "headline", + group: "Headers" + }, + { + name: "title", + group: "Headers" + }, + { + name: "subtitle", + group: "Headers" + }, + { + name: "heading", + group: "Headers" + }, + { + name: "subheading", + group: "Headers" + }, + { + name: "lead", + group: "Body" + }, + { + name: "bodyLarge", + group: "Body" + }, + { + name: "body", + group: "Body" + }, + { + name: "description", + group: "Body" + }, + { + name: "caption", + group: "Body" + }, + { + name: "button", + group: "Controls" + }, + { + name: "buttonStrong", + group: "Controls" + }, + { + name: "monoLead", + group: "Mono" + }, + { + name: "monoBody", + group: "Mono" + }, + { + name: "monoDescription", + group: "Mono" + }, + { + name: "monoCaption", + group: "Mono" + } ] - readonly property var paletteTokens: [ - "background", "white", - "orange", "orangeLight1", "orangeLight2", - "red", "green", "blue", "amber", "purple", - "neutral0", "neutral1", "neutral2", "neutral3", "neutral4", - "neutral5", "neutral6", "neutral7", "neutral8", "neutral9" - ] + readonly property var paletteTokens: ["background", "white", "orange", "orangeLight1", "orangeLight2", "red", "green", "blue", "amber", "purple", "neutral0", "neutral1", "neutral2", "neutral3", "neutral4", "neutral5", "neutral6", "neutral7", "neutral8", "neutral9"] + + property string exampleLanguage: "en" + property string exampleBlockClockMode: "compact" + property bool exampleStartupEnabled: true + property string exampleBlockStorageLimit: "2" + property string exampleProxyAddress: "127.0.0.1:9050" + property url lastExampleLink: "" - header: SettingsHeader { - title: qsTr("Design system") - onBack: root.back() + // ── Form controls ─────────────────────────────────────── + PageHeading { + Layout.fillWidth: true + title: qsTr("General") + description: qsTr("Generic form and navigation components using the active Theme tokens.") } - Flickable { - anchors.fill: parent - contentWidth: width - contentHeight: contentColumn.height - clip: true + FormSection { + objectName: "designSystemAppearanceSection" + Layout.fillWidth: true + title: qsTr("Appearance") - ColumnLayout { - id: contentColumn - width: Math.min(parent.width, 450) - anchors.horizontalCenter: parent.horizontalCenter - spacing: 24 + FormRow { + objectName: "designSystemThemeRow" + Layout.fillWidth: true + title: qsTr("Theme") + description: qsTr("Choose the application appearance.") + trailingItem: SegmentedPicker { + implicitWidth: 190 + implicitHeight: 36 + model: [qsTr("Light"), qsTr("Dark")] + currentIndex: Theme.dark ? 1 : 0 + onSelected: function (index, option) { + Theme.dark = index === 1; + } + } + } - // ── Typography ────────────────────────────────────────── - Text { - Layout.topMargin: 8 - Layout.fillWidth: true - font: Theme.text.title.font - color: Theme.color.neutral9 - text: qsTr("Typography") + FormRow { + objectName: "designSystemLanguageRow" + Layout.fillWidth: true + title: qsTr("Language") + description: qsTr("Choose the language used throughout the app.") + showDivider: false + trailingItem: PopupPicker { + objectName: "designSystemLanguagePicker" + embedded: true + implicitWidth: 150 + minimumMenuWidth: 180 + currentValue: root.exampleLanguage + model: [ + { + text: qsTr("English"), + value: "en" + }, + { + text: qsTr("Deutsch"), + value: "de" + }, + { + text: qsTr("Español"), + value: "es" + } + ] + onActivated: function (value) { + root.exampleLanguage = value; + } } + } + } - Repeater { - model: root.typographyRoles - delegate: ColumnLayout { - Layout.fillWidth: true - spacing: 4 + FormSection { + objectName: "designSystemBehaviorSection" + Layout.fillWidth: true + title: qsTr("Behavior") + description: qsTr("Rows can host any existing control without owning its state.") - Text { - Layout.fillWidth: true - font: Theme.text[modelData.name].font - lineHeight: Theme.text[modelData.name].lineHeight - lineHeightMode: Text.FixedHeight - color: Theme.color.neutral9 - text: modelData.name - elide: Text.ElideRight - } - Text { - Layout.fillWidth: true - font: Theme.text.caption.font - color: Theme.color.neutral6 - text: Theme.text[modelData.name].family + " " + - Theme.text[modelData.name].styleName + " · " + - Theme.text[modelData.name].pixelSize + "/" + - Theme.text[modelData.name].lineHeight + " · " + - modelData.group - } - Rectangle { - Layout.topMargin: 8 - Layout.fillWidth: true - height: 1 - color: Theme.color.neutral3 + FormRow { + objectName: "designSystemBlockClockRow" + Layout.fillWidth: true + title: qsTr("Block status size") + description: qsTr("Set the scale used for the block status display.") + trailingItem: SegmentedPicker { + implicitWidth: 220 + implicitHeight: 36 + model: [ + { + text: qsTr("Compact"), + value: "compact" + }, + { + text: qsTr("Showcase"), + value: "showcase" } + ] + currentIndex: root.exampleBlockClockMode === "compact" ? 0 : 1 + onSelected: function (index, option) { + root.exampleBlockClockMode = option.value; } } + } + + FormRow { + objectName: "designSystemStartupRow" + Layout.fillWidth: true + title: qsTr("Open at login") + description: qsTr("Start the app after signing in.") + showDivider: false + trailingItem: OptionSwitch { + objectName: "designSystemStartupSwitch" + checked: root.exampleStartupEnabled + onToggled: root.exampleStartupEnabled = checked + } + } + } + + FormSection { + objectName: "designSystemNavigationSection" + Layout.fillWidth: true + title: qsTr("Navigation rows") + description: qsTr("Use ListRow for destinations and disclosure actions.") + + ListRow { + objectName: "designSystemSelectedListRow" + Layout.fillWidth: true + title: qsTr("Selected destination") + description: qsTr("Selection and keyboard focus are independent states.") + selected: true + showsDisclosureIndicator: true + disclosureIndicatorColor: Theme.color.orange + } + + ListRow { + objectName: "designSystemDisclosureListRow" + Layout.fillWidth: true + title: qsTr("Advanced options") + description: qsTr("Open another page for settings that need more space.") + showDivider: false + showsDisclosureIndicator: true + } + } + + FormSection { + objectName: "designSystemValueSection" + Layout.fillWidth: true + title: qsTr("Values and links") + description: qsTr("Use value rows for read-only data and link rows for caller-owned navigation.") + + LinkRow { + objectName: "designSystemWebsiteRow" + Layout.fillWidth: true + title: qsTr("Website") + value: "bitcoincore.org" + link: "https://bitcoincore.org" + onActivated: function (link) { + root.lastExampleLink = link; + } + } + + LinkRow { + objectName: "designSystemSourceRow" + Layout.fillWidth: true + title: qsTr("Source code") + value: "github.com/bitcoin/bitcoin" + link: "https://github.com/bitcoin/bitcoin" + onActivated: function (link) { + root.lastExampleLink = link; + } + } + + ValueRow { + objectName: "designSystemVersionRow" + Layout.fillWidth: true + title: qsTr("Version") + value: "v31.99.0-unk" + showDivider: false + } + } + + FormSection { + objectName: "designSystemFieldSection" + Layout.fillWidth: true + title: qsTr("Inline fields and details") + description: qsTr("Use compact trailing editors for short values and body content for long details.") + + TextFieldRow { + objectName: "designSystemBlockStorageRow" + fieldObjectName: "designSystemBlockStorageField" + Layout.fillWidth: true + title: qsTr("Block storage limit (GB)") + fieldWidth: 72 + text: root.exampleBlockStorageLimit + validator: IntValidator { + bottom: 1 + } + onTextEdited: function (text) { + root.exampleBlockStorageLimit = text; + } + } + + TextFieldRow { + objectName: "designSystemProxyLocationRow" + fieldObjectName: "designSystemProxyLocationField" + Layout.fillWidth: true + title: qsTr("Proxy location") + fieldWidth: 200 + text: root.exampleProxyAddress + onTextEdited: function (text) { + root.exampleProxyAddress = text; + } + } + + FormRow { + objectName: "designSystemDataDirectoryRow" + Layout.fillWidth: true + title: qsTr("Data directory") + supportingText: qsTr("Selected before startup. The data directory cannot be changed while the node is running.") + showDivider: false + bodyItem: CoreText { + objectName: "designSystemDataDirectoryValue" + Layout.fillWidth: true + text: "/Users/example/Bitcoin" + color: Theme.color.neutral7 + font: Theme.text.caption.font + lineHeight: Theme.text.caption.lineHeight + lineHeightMode: Text.FixedHeight + horizontalAlignment: Text.AlignLeft + wrap: true + } + } + } + + // ── Typography ────────────────────────────────────────── + Text { + Layout.topMargin: 8 + Layout.fillWidth: true + font: Theme.text.title.font + color: Theme.color.neutral9 + text: qsTr("Typography") + } + + Repeater { + model: root.typographyRoles + delegate: ColumnLayout { + id: typographySample + required property var modelData + Layout.fillWidth: true + spacing: 4 - // ── Colors ────────────────────────────────────────────── Text { - Layout.topMargin: 16 Layout.fillWidth: true - font: Theme.text.title.font + font: Theme.text[typographySample.modelData.name].font + lineHeight: Theme.text[typographySample.modelData.name].lineHeight + lineHeightMode: Text.FixedHeight color: Theme.color.neutral9 - text: qsTr("Colors") + text: typographySample.modelData.name + elide: Text.ElideRight } Text { Layout.fillWidth: true font: Theme.text.caption.font color: Theme.color.neutral6 - text: qsTr("Palette tokens for the active theme. Toggle Theme to compare.") - wrapMode: Text.WordWrap + text: Theme.text[typographySample.modelData.name].family + " " + Theme.text[typographySample.modelData.name].styleName + " · " + Theme.text[typographySample.modelData.name].pixelSize + "/" + Theme.text[typographySample.modelData.name].lineHeight + " · " + typographySample.modelData.group } + Rectangle { + Layout.topMargin: 8 + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Theme.color.neutral3 + } + } + } - GridLayout { + // ── Colors ────────────────────────────────────────────── + Text { + Layout.topMargin: 16 + Layout.fillWidth: true + font: Theme.text.title.font + color: Theme.color.neutral9 + text: qsTr("Colors") + } + Text { + Layout.fillWidth: true + font: Theme.text.caption.font + color: Theme.color.neutral6 + text: qsTr("Palette tokens for the active theme. Toggle Theme to compare.") + wrapMode: Text.WordWrap + } + + GridLayout { + Layout.fillWidth: true + columns: 2 + columnSpacing: 12 + rowSpacing: 8 + + Repeater { + model: root.paletteTokens + delegate: RowLayout { + id: paletteSample + required property string modelData Layout.fillWidth: true - columns: 2 - columnSpacing: 12 - rowSpacing: 8 + spacing: 10 - Repeater { - model: root.paletteTokens - delegate: RowLayout { + Rectangle { + Layout.preferredWidth: 32 + Layout.preferredHeight: 32 + radius: 4 + color: Theme.color[paletteSample.modelData] + border.color: Theme.color.neutral4 + border.width: 1 + } + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Text { Layout.fillWidth: true - spacing: 10 - - Rectangle { - Layout.preferredWidth: 32 - Layout.preferredHeight: 32 - radius: 4 - color: Theme.color[modelData] - border.color: Theme.color.neutral4 - border.width: 1 - } - ColumnLayout { - Layout.fillWidth: true - spacing: 0 - Text { - Layout.fillWidth: true - font: Theme.text.description.font - color: Theme.color.neutral9 - text: modelData - elide: Text.ElideRight - } - Text { - Layout.fillWidth: true - font: Theme.text.caption.font - color: Theme.color.neutral6 - text: Theme.color[modelData].toString().toUpperCase() - } - } + font: Theme.text.description.font + color: Theme.color.neutral9 + text: paletteSample.modelData + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + font: Theme.text.caption.font + color: Theme.color.neutral6 + text: Theme.color[paletteSample.modelData].toString().toUpperCase() } } } - - Item { Layout.preferredHeight: 24 } } } + + Item { + Layout.preferredHeight: 24 + } } diff --git a/test/qml/bitcoin_qmltests.qrc b/test/qml/bitcoin_qmltests.qrc index c7f0f7d6d9..8b434c2863 100644 --- a/test/qml/bitcoin_qmltests.qrc +++ b/test/qml/bitcoin_qmltests.qrc @@ -20,6 +20,7 @@ tst_dropdownbutton.qml tst_externalsignerreviewactions.qml tst_feeselection.qml + tst_formcontrols.qml tst_mainrouting.qml tst_mempoolinformationrows.qml tst_mempoolinformationsettings.qml diff --git a/test/qml/tst_formcontrols.qml b/test/qml/tst_formcontrols.qml new file mode 100644 index 0000000000..277ceb6164 --- /dev/null +++ b/test/qml/tst_formcontrols.qml @@ -0,0 +1,385 @@ +// 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.Layouts 1.15 +import QtTest 1.2 +import "../../qml/controls" +import "../../qml/pages/settings" + +TestCase { + id: testCase + name: "FormControls" + when: windowShown + width: 640 + height: 640 + + Item { + id: host + anchors.fill: parent + } + + Component { + id: formRowComponent + + FormRow { + objectName: "exampleRow" + width: 480 + title: "Theme" + description: "Choose the application appearance." + supportingText: "Managed by the application." + trailingItem: OptionSwitch { + objectName: "exampleSwitch" + checked: true + } + } + } + + Component { + id: formSectionComponent + + FormSection { + objectName: "exampleSection" + width: 480 + title: "Appearance" + footerText: "Changes apply immediately." + + FormRow { + Layout.fillWidth: true + title: "First" + } + + FormRow { + Layout.fillWidth: true + title: "Second" + showDivider: false + } + } + } + + Component { + id: listRowComponent + + ListRow { + objectName: "exampleDisclosureRow" + width: 480 + title: "Display" + selected: true + showsDisclosureIndicator: true + disclosureIndicatorObjectName: "exampleDisclosureRowDisclosureIndicator" + } + } + + Component { + id: pageHeadingComponent + + PageHeading { + objectName: "exampleHeading" + width: 480 + title: "General" + description: "Customize the application." + } + } + + Component { + id: popupPickerComponent + + PopupPicker { + objectName: "examplePicker" + width: 180 + currentValue: "light" + model: [ + { text: "Light", value: "light" }, + { text: "Dark", value: "dark" } + ] + } + } + + Component { + id: designSystemPageComponent + + SettingsDesignSystem { + width: 600 + height: 900 + } + } + + Component { + id: settingsPageComponent + + SettingsPage { + objectName: "exampleSettingsPage" + width: 720 + height: 640 + title: "Settings" + backButtonObjectName: "exampleSettingsBack" + maximumContentWidth: 420 + contentSpacing: 12 + rightItem: NavButton { + objectName: "exampleSettingsAction" + text: "Done" + } + + FormSection { + objectName: "exampleSettingsSection" + Layout.fillWidth: true + title: "General" + + FormRow { + Layout.fillWidth: true + title: "Example" + showDivider: false + } + } + } + } + + Component { + id: valueRowComponent + + ValueRow { + objectName: "exampleValueRow" + width: 480 + title: "Version" + value: "v31.99.0-unk" + } + } + + Component { + id: linkRowComponent + + LinkRow { + objectName: "exampleLinkRow" + width: 480 + title: "Website" + value: "bitcoincore.org" + link: "https://bitcoincore.org" + } + } + + Component { + id: textFieldRowComponent + + TextFieldRow { + objectName: "exampleTextFieldRow" + fieldObjectName: "exampleTextField" + width: 480 + title: "Proxy location" + text: "127.0.0.1:9050" + } + } + + Component { + id: bodyRowComponent + + FormRow { + objectName: "exampleBodyRow" + width: 480 + title: "Data directory" + bodyItem: CoreText { + objectName: "exampleBodyContent" + Layout.fillWidth: true + text: "/Users/example/Bitcoin" + } + } + } + + function test_formRowLoadsAndDisablesTrailingControl() { + const row = createTemporaryObject(formRowComponent, host) + verify(row !== null) + tryVerify(function() { return row.loadedTrailingItem !== null }) + compare(row.loadedTrailingItem.objectName, "exampleSwitch") + compare(row.loadedTrailingItem.enabled, true) + + row.enabled = false + compare(row.loadedTrailingItem.enabled, false) + tryCompare(findChild(row, "exampleRowTitle"), "color", Theme.color.neutral4) + } + + function test_formSectionOwnsCardAndContent() { + const section = createTemporaryObject(formSectionComponent, host) + verify(section !== null) + const card = findChild(section, "exampleSectionCard") + verify(card !== null) + compare(card.color, Theme.color.neutral1) + compare(card.border.width, 0) + compare(card.radius, 16) + const footer = findChild(section, "exampleSectionFooter") + verify(footer !== null) + compare(footer.text, "Changes apply immediately.") + compare(footer.font.pixelSize, Theme.text.caption.font.pixelSize) + verify(section.implicitHeight > 0) + } + + function test_listRowSelectionAndActivation() { + const row = createTemporaryObject(listRowComponent, host) + verify(row !== null) + compare(row.selected, true) + compare(row.background.color, row.selectedBackgroundColor) + compare(row.cornerRadius, 16) + compare(row.background.radius, 16) + const disclosureIndicator = findChild(row, "exampleDisclosureRowDisclosureIndicator") + verify(disclosureIndicator !== null) + compare(disclosureIndicator.size, 14) + + let clickCount = 0 + row.clicked.connect(function() { clickCount += 1 }) + row.clicked() + compare(clickCount, 1) + } + + function test_pageHeadingUsesThemeTypography() { + const heading = createTemporaryObject(pageHeadingComponent, host) + verify(heading !== null) + const title = findChild(heading, "exampleHeadingTitle") + const description = findChild(heading, "exampleHeadingDescription") + verify(title !== null) + verify(description !== null) + compare(title.font.pixelSize, Theme.text.headline.pixelSize) + compare(description.font.pixelSize, Theme.text.description.font.pixelSize) + } + + function test_popupPickerMapsValuesAndLeavesStateCallerOwned() { + const picker = createTemporaryObject(popupPickerComponent, host) + verify(picker !== null) + compare(picker.currentText, "Light") + const button = findChild(picker, "examplePickerButton") + const menu = findChild(picker, "examplePickerMenu") + verify(button !== null) + verify(menu !== null) + compare(button.defaultBgColor, Theme.color.background) + compare(button.hoverBgColor, Theme.color.neutral2) + compare(menu.backgroundColor, Theme.color.neutral1) + + picker.embedded = true + compare(button.defaultBgColor, Theme.color.neutral2) + compare(button.hoverBgColor, Theme.color.neutral3) + compare(menu.backgroundColor, Theme.color.neutral2) + + picker.currentValue = "dark" + compare(picker.currentText, "Dark") + + let activatedValue = "" + picker.activated.connect(function(value) { activatedValue = value }) + tryVerify(function() { return picker.itemAtIndex(0) !== null }) + picker.itemAtIndex(0).triggered() + + compare(activatedValue, "light") + compare(picker.currentValue, "dark") + } + + function test_designSystemPageShowsGenericControlExamples() { + const page = createTemporaryObject(designSystemPageComponent, host) + verify(page !== null) + verify(findChild(page, "settingsPageContentLayout") !== null) + verify(findChild(page, "designSystemAppearanceSection") !== null) + verify(findChild(page, "designSystemThemeRow") !== null) + verify(findChild(page, "designSystemLanguagePicker") !== null) + verify(findChild(page, "designSystemBehaviorSection") !== null) + verify(findChild(page, "designSystemNavigationSection") !== null) + verify(findChild(page, "designSystemValueSection") !== null) + verify(findChild(page, "designSystemWebsiteRow") !== null) + verify(findChild(page, "designSystemVersionRow") !== null) + verify(findChild(page, "designSystemFieldSection") !== null) + verify(findChild(page, "designSystemBlockStorageField") !== null) + verify(findChild(page, "designSystemProxyLocationField") !== null) + verify(findChild(page, "designSystemDataDirectoryValue") !== null) + } + + function test_settingsPageOwnsNavigationAndConstrainedScrollableContent() { + const page = createTemporaryObject(settingsPageComponent, host) + verify(page !== null) + compare(page.pageHeader.title, "Settings") + compare(page.pageHeader.backButtonObjectName, "exampleSettingsBack") + verify(findChild(page, "exampleSettingsAction") !== null) + verify(findChild(page, "exampleSettingsSection") !== null) + compare(page.contentLayout.width, 420) + compare(page.contentLayout.spacing, 12) + compare(page.scrollView.contentWidth, page.scrollView.availableWidth) + + let backCount = 0 + page.back.connect(function() { backCount += 1 }) + page.pageHeader.back() + compare(backCount, 1) + } + + function test_valueRowDisplaysCallerOwnedValue() { + const row = createTemporaryObject(valueRowComponent, host) + verify(row !== null) + const value = findChild(row, "exampleValueRowValue") + verify(value !== null) + compare(value.text, "v31.99.0-unk") + + row.value = "v32.0" + compare(value.text, "v32.0") + } + + function test_pageHeadingCentersProminentDescription() { + const heading = createTemporaryObject(pageHeadingComponent, host) + verify(heading !== null) + const description = findChild(heading, "exampleHeadingDescription") + verify(description !== null) + compare(heading.descriptionTextStyle.font.pixelSize, Theme.text.description.font.pixelSize) + compare(description.font.pixelSize, Theme.text.description.font.pixelSize) + compare(description.horizontalAlignment, Text.AlignHCenter) + } + + function test_linkRowEmitsWithoutOpeningTheUrl() { + const row = createTemporaryObject(linkRowComponent, host) + verify(row !== null) + + let activatedLink = "" + row.activated.connect(function(link) { activatedLink = link.toString() }) + row.clicked() + + compare(activatedLink, "https://bitcoincore.org") + compare(findChild(row, "exampleLinkRowValue").text, "bitcoincore.org") + } + + function test_textFieldRowUsesCompactTrailingEditor() { + const row = createTemporaryObject(textFieldRowComponent, host) + verify(row !== null) + verify(row.field !== null) + compare(row.field.objectName, "exampleTextField") + compare(row.field, row.loadedTrailingItem) + compare(row.loadedBodyItem, null) + compare(row.text, "127.0.0.1:9050") + compare(row.field.horizontalAlignment, Text.AlignRight) + compare(row.focusBorderColor, Theme.color.orange) + compare(row.field.background.border.color, Theme.color.orange) + + row.text = "127.0.0.1:9150" + compare(row.field.text, "127.0.0.1:9150") + } + + function test_textFieldRowResignsFocusWhenAccepted() { + const row = createTemporaryObject(textFieldRowComponent, host) + verify(row !== null) + verify(row.field !== null) + + let acceptedCount = 0 + row.accepted.connect(function() { acceptedCount += 1 }) + + row.field.forceActiveFocus() + verify(row.field.activeFocus) + keyClick(Qt.Key_Return) + tryCompare(row.field, "activeFocus", false) + compare(acceptedCount, 1) + + row.field.forceActiveFocus() + verify(row.field.activeFocus) + keyClick(Qt.Key_Enter) + tryCompare(row.field, "activeFocus", false) + compare(acceptedCount, 2) + } + + function test_formRowAcceptsFullWidthBodyContent() { + const row = createTemporaryObject(bodyRowComponent, host) + verify(row !== null) + verify(row.loadedBodyItem !== null) + compare(row.loadedBodyItem.objectName, "exampleBodyContent") + compare(row.loadedBodyItem.text, "/Users/example/Bitcoin") + verify(row.implicitHeight > row.minimumRowHeight) + } +} From d4dd2aa75830c9d4af01208e8b0f9629df9c06c4 Mon Sep 17 00:00:00 2001 From: pseudoramdom Date: Tue, 18 Aug 2026 14:29:32 -0700 Subject: [PATCH 06/14] qml: add sidebar-based settings navigation shell Add a settings sidebar and a container that lazily creates and preserves a navigation stack for each visited section. --- qml/bitcoin_qml.qrc | 3 + qml/components/SettingsPageContainer.qml | 107 ++++++++++++++++++ qml/components/SettingsSidebar.qml | 127 +++++++++++++++++++++ qml/components/SettingsView.qml | 134 +++++++++++++++++++++++ 4 files changed, 371 insertions(+) create mode 100644 qml/components/SettingsPageContainer.qml create mode 100644 qml/components/SettingsSidebar.qml create mode 100644 qml/components/SettingsView.qml diff --git a/qml/bitcoin_qml.qrc b/qml/bitcoin_qml.qrc index 981e151454..08bef173f7 100644 --- a/qml/bitcoin_qml.qrc +++ b/qml/bitcoin_qml.qrc @@ -33,7 +33,10 @@ components/OptionPopup.qml components/PeersIndicator.qml components/ProxySettings.qml + components/SettingsPageContainer.qml components/SettingsRestartNotice.qml + components/SettingsSidebar.qml + components/SettingsView.qml components/StorageLocations.qml components/Separator.qml components/StorageOptions.qml diff --git a/qml/components/SettingsPageContainer.qml b/qml/components/SettingsPageContainer.qml new file mode 100644 index 0000000000..8a98e71834 --- /dev/null +++ b/qml/components/SettingsPageContainer.qml @@ -0,0 +1,107 @@ +// 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" + +Page { + id: root + + readonly property string currentSectionId: internal.currentSectionId + readonly property int depth: internal.currentStack ? internal.currentStack.depth : 0 + readonly property bool canGoBack: internal.currentStack ? internal.currentStack.canGoBack : false + readonly property var currentItem: internal.currentStack ? internal.currentStack.currentItem : null + readonly property var stack: internal.currentStack + + signal sectionChanged(string sectionId) + + function showSection(sectionId, page, properties) { + if (!page) { + if (internal.currentStack) internal.currentStack.visible = false + internal.currentStack = null + internal.currentSectionId = "" + root.sectionChanged("") + return null + } + + if (sectionId === root.currentSectionId && internal.currentStack) { + return internal.currentStack.currentItem + } + + let nextStack = internal.sectionStacks[sectionId] + if (!nextStack) { + nextStack = sectionStackComponent.createObject(stackHost, { + "sectionId": sectionId + }) + if (!nextStack) return null + + const nextSectionStacks = {} + for (const cachedSectionId in internal.sectionStacks) { + nextSectionStacks[cachedSectionId] = internal.sectionStacks[cachedSectionId] + } + nextSectionStacks[sectionId] = nextStack + internal.sectionStacks = nextSectionStacks + nextStack.push(page, properties || {}, StackView.Immediate) + } + + if (internal.currentStack) internal.currentStack.visible = false + internal.currentStack = nextStack + internal.currentSectionId = sectionId + nextStack.visible = true + root.sectionChanged(sectionId) + return nextStack.currentItem + } + + function push(page, properties) { + if (!page || !internal.currentStack) return null + return internal.currentStack.push(page, properties || {}) + } + + function pop() { + if (!internal.currentStack || !internal.currentStack.canGoBack) return null + return internal.currentStack.pop() + } + + function clear() { + const sectionStacks = internal.sectionStacks + if (internal.currentStack) internal.currentStack.visible = false + internal.currentStack = null + internal.currentSectionId = "" + internal.sectionStacks = ({}) + + for (const sectionId in sectionStacks) { + sectionStacks[sectionId].clear(StackView.Immediate) + sectionStacks[sectionId].destroy() + } + root.sectionChanged("") + } + + background: null + clip: true + + QtObject { + id: internal + property string currentSectionId: "" + property var currentStack: null + property var sectionStacks: ({}) + } + + Item { + id: stackHost + anchors.fill: parent + } + + Component { + id: sectionStackComponent + + PageStack { + required property string sectionId + objectName: "settingsNavigationStack_" + sectionId + anchors.fill: parent + visible: false + } + } +} diff --git a/qml/components/SettingsSidebar.qml b/qml/components/SettingsSidebar.qml new file mode 100644 index 0000000000..f0546de8a3 --- /dev/null +++ b/qml/components/SettingsSidebar.qml @@ -0,0 +1,127 @@ +pragma ComponentBehavior: Bound + +// 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 + +import "../controls" + +Control { + id: root + + property var model: [] + property string currentSectionId: "" + property int rowHeight: 36 + property int groupSpacing: 16 + property int cornerRadius: 8 + property color selectedBackgroundColor: Qt.rgba(Theme.color.orange.r, Theme.color.orange.g, Theme.color.orange.b, 0.15) + property color hoverBackgroundColor: Theme.color.neutral2 + readonly property var visibleSections: root.filteredSections() + readonly property alias listView: sectionList + + signal sectionActivated(string sectionId) + + function filteredSections() { + const result = [] + if (!root.model) return result + + if (root.model.count !== undefined && root.model.get !== undefined) { + for (let index = 0; index < root.model.count; ++index) { + const section = root.model.get(index) + if (section.visible !== false) result.push(section) + } + return result + } + + for (let index = 0; index < root.model.length; ++index) { + const section = root.model[index] + if (section.visible !== false) result.push(section) + } + return result + } + + background: null + padding: 0 + implicitWidth: 190 + implicitHeight: sectionList.contentHeight + + contentItem: ListView { + id: sectionList + objectName: "settingsSidebarList" + model: root.visibleSections + clip: true + boundsBehavior: Flickable.StopAtBounds + keyNavigationEnabled: true + + delegate: Item { + id: delegate + required property var modelData + required property int index + + readonly property bool startsGroup: delegate.index > 0 + && root.visibleSections[delegate.index - 1].group !== delegate.modelData.group + + width: sectionList.width + height: root.rowHeight + (delegate.startsGroup ? root.groupSpacing : 0) + + AbstractButton { + id: button + objectName: delegate.modelData.objectName || "settingsSidebar_" + delegate.modelData.id + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: root.rowHeight + hoverEnabled: AppMode.isDesktop + focusPolicy: Qt.TabFocus + leftPadding: 10 + rightPadding: 10 + Accessible.name: delegate.modelData.label + Accessible.role: Accessible.ListItem + + onClicked: root.sectionActivated(delegate.modelData.id) + + background: Rectangle { + radius: root.cornerRadius + color: delegate.modelData.id === root.currentSectionId + ? root.selectedBackgroundColor + : button.hovered + ? root.hoverBackgroundColor + : "transparent" + + FocusBorder { + visible: button.visualFocus + borderRadius: root.cornerRadius + 2 + topMargin: -2 + bottomMargin: -2 + leftMargin: -2 + rightMargin: -2 + } + } + + contentItem: CoreText { + text: delegate.modelData.label + color: delegate.modelData.id === root.currentSectionId + ? Theme.color.orange + : Theme.color.neutral9 + font: Theme.text.description.font + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + + HoverHandler { + cursorShape: Qt.PointingHandCursor + } + } + } + } +} diff --git a/qml/components/SettingsView.qml b/qml/components/SettingsView.qml new file mode 100644 index 0000000000..cc205efac8 --- /dev/null +++ b/qml/components/SettingsView.qml @@ -0,0 +1,134 @@ +pragma ComponentBehavior: Bound + +// 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" + +Page { + id: root + objectName: "settingsView" + + signal doneClicked() + + property bool showDoneButton: true + property string selectedSectionId: "" + property int sidebarWidth: 286 + readonly property alias sidebar: sidebar + readonly property alias pageContainer: pageContainer + readonly property var sections: [ + ] + + function sectionForId(sectionId) { + for (let index = 0; index < root.sections.length; ++index) { + if (root.sections[index].id === sectionId) return root.sections[index] + } + return null + } + + function componentForSection(sectionId) { + const section = root.sectionForId(sectionId) + return section ? section.pageComponent : null + } + + function sectionIsVisible(sectionId) { + const section = root.sectionForId(sectionId) + return section !== null && section.visible !== false + } + + function firstVisibleSectionId() { + for (let index = 0; index < root.sections.length; ++index) { + if (root.sections[index].visible !== false) return root.sections[index].id + } + return "" + } + + function selectSection(sectionId, forceReload) { + const resolvedId = root.sectionIsVisible(sectionId) ? sectionId : root.firstVisibleSectionId() + if (resolvedId.length === 0) { + root.selectedSectionId = "" + pageContainer.clear() + return + } + + if (forceReload === true) pageContainer.clear() + root.selectedSectionId = resolvedId + if (root.visible) pageContainer.showSection(resolvedId, root.componentForSection(resolvedId)) + } + + function ensureVisibleSelection() { + if (!root.sectionIsVisible(root.selectedSectionId)) root.selectSection(root.firstVisibleSectionId()) + } + + background: null + padding: 0 + + onSectionsChanged: ensureVisibleSelection() + onVisibleChanged: { + if (visible) root.selectSection(root.selectedSectionId) + } + + Component.onCompleted: root.selectSection( + root.sectionIsVisible(root.selectedSectionId) ? root.selectedSectionId : root.firstVisibleSectionId()) + + contentItem: RowLayout { + spacing: 0 + + Rectangle { + id: sidebarSurface + objectName: "settingsv2SettingsSidebarSurface" + Layout.preferredWidth: root.sidebarWidth + Layout.minimumWidth: root.sidebarWidth + Layout.maximumWidth: root.sidebarWidth + Layout.fillHeight: true + color: Theme.color.neutral1 + + Behavior on color { + ColorAnimation { duration: 150 } + } + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: 20 + anchors.rightMargin: 20 + anchors.topMargin: 20 + anchors.bottomMargin: 16 + spacing: 0 + + SettingsSidebar { + id: sidebar + objectName: "settingsv2SettingsSidebar" + Layout.fillWidth: true + Layout.fillHeight: true + model: root.sections + currentSectionId: root.selectedSectionId + onSectionActivated: function(sectionId) { root.selectSection(sectionId) } + } + + NavButton { + objectName: "settingsv2SettingsDoneButton" + visible: root.showDoneButton + text: qsTr("Done") + Layout.alignment: Qt.AlignHCenter + Layout.bottomMargin: 20 + onClicked: root.doneClicked() + } + } + } + + SettingsPageContainer { + id: pageContainer + objectName: "settingsv2SettingsPageContainer" + Layout.minimumWidth: 0 + Layout.fillWidth: true + Layout.fillHeight: true + } + } + +} From 12f3e5a5912b0607c90197c973ae8c2763ac17b0 Mon Sep 17 00:00:00 2001 From: pseudoramdom Date: Tue, 18 Aug 2026 14:46:33 -0700 Subject: [PATCH 07/14] qml: add redesigned settings entry point Add a second Settings tab alongside the existing settings interface so the redesign can be reviewed while pages are migrated incrementally. Lazily create the redesigned settings view on first use and retain it across top-level tab switches to preserve its navigation state. --- qml/pages/wallet/DesktopWallets.qml | 32 +++++++++++++++++++++++++++++ test/qml/tst_desktopwallets.qml | 26 ++++++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/qml/pages/wallet/DesktopWallets.qml b/qml/pages/wallet/DesktopWallets.qml index a369e83604..33b8db9396 100644 --- a/qml/pages/wallet/DesktopWallets.qml +++ b/qml/pages/wallet/DesktopWallets.qml @@ -278,6 +278,23 @@ Page { text: qsTr("Settings") } } + NavigationTab { + id: settingsv2TabButton + objectName: "desktopWalletSettingsPreviewTabButton" + iconSource: "image://images/gear" + iconColor: Theme.color.neutral7 + Layout.preferredWidth: 30 + property int index: 7 + ButtonGroup.group: navigationTabs + + Tooltip { + anchors.top: settingsv2TabButton.bottom + anchors.topMargin: -5 + anchors.horizontalCenter: settingsv2TabButton.horizontalCenter + visible: settingsv2TabButton.hovered + text: qsTr("Settings") + } + } } background: Rectangle { color: Theme.color.neutral4 @@ -363,6 +380,21 @@ Page { receiveTabButton.checked = true } } + Item { + Loader { + id: settingsPreviewLoader + objectName: "settingsPreviewLoader" + anchors.fill: parent + property bool retainItem: false + // Create Settings on first use, then retain its navigation + // stacks while the parent tab item is hidden. + active: settingsv2TabButton.checked || retainItem + onLoaded: retainItem = true + sourceComponent: SettingsView { + showDoneButton: false + } + } + } } WalletMigrationPopup { diff --git a/test/qml/tst_desktopwallets.qml b/test/qml/tst_desktopwallets.qml index eacdeaec6d..2f5d6a6090 100644 --- a/test/qml/tst_desktopwallets.qml +++ b/test/qml/tst_desktopwallets.qml @@ -83,7 +83,8 @@ TestCase { findChild(page, "blockClockTabButton"), findChild(page, "peersTabButton"), findChild(page, "consoleTabButton"), - findChild(page, "desktopWalletSettingsTabButton") + findChild(page, "desktopWalletSettingsTabButton"), + findChild(page, "desktopWalletSettingsPreviewTabButton") ] for (let i = 0; i < tabs.length; ++i) { @@ -95,6 +96,29 @@ TestCase { compare(tabs[1].iconSize, 24) compare(tabs[2].iconSize, 24) compare(tabs[3].iconSize, 30) + compare(tabs[4].iconSize, 30) + } + + function test_settings_preview_is_lazilyLoadedAndRetained() { + const page = createDesktopWallets() + const previewSettingsTab = findChild(page, "desktopWalletSettingsPreviewTabButton") + const previewLoader = findChild(page, "settingsPreviewLoader") + + verify(previewSettingsTab !== null) + verify(previewLoader !== null) + compare(previewLoader.active, false) + compare(previewLoader.item, null) + + previewSettingsTab.checked = true + tryCompare(previewSettingsTab, "checked", true) + tryCompare(previewLoader, "active", true) + tryVerify(function() { return previewLoader.item !== null }) + compare(previewLoader.item.objectName, "settingsView") + const settingsView = previewLoader.item + + previewSettingsTab.checked = false + compare(previewLoader.item, settingsView) + compare(previewLoader.active, true) } function test_console_autocomplete_closes_when_switching_tabs() { From 9649fbba79310a407c27fd030c4d1501d24fafeb Mon Sep 17 00:00:00 2001 From: pseudoramdom Date: Tue, 18 Aug 2026 14:50:42 -0700 Subject: [PATCH 08/14] qml: add redesigned settings pages Populate the sidebar-based settings view with redesigned Wallet, External signer, Display, Window behavior, Storage, Connection, Network traffic, Mempool information, and About pages. Integrate Debug log into the same constrained page container. Compose the pages from the shared settings controls while preserving existing models, validation, actions, and nested navigation flows. Wire wallet-related actions back to the desktop wallet view. Suspend Network Traffic and Debug Log activity while their cached pages are hidden. Add QML coverage for section navigation, stack preservation, page layout, picker mappings, proxy draft handling, lifecycle behavior, and top-level destination creation. --- qml/bitcoin_qml.qrc | 10 + qml/components/SettingsView.qml | 191 ++++++ qml/pages/settings/SettingsDebugLog.qml | 34 +- qml/pages/settings/SettingsDesignSystem.qml | 1 - .../settings/settingsv2/AboutSettingsPage.qml | 95 +++ .../settingsv2/ConnectionSettingsPage.qml | 97 +++ .../settingsv2/DisplaySettingsPage.qml | 225 +++++++ .../settingsv2/ExternalSignerSettingsPage.qml | 170 +++++ .../settingsv2/MempoolSettingsPage.qml | 101 +++ .../settingsv2/NetworkTrafficSettingsPage.qml | 162 +++++ .../settings/settingsv2/ProxySettingsPage.qml | 229 +++++++ .../settingsv2/StorageSettingsPage.qml | 124 ++++ .../settings/settingsv2/WalletSectionPage.qml | 208 ++++++ .../settingsv2/WindowBehaviorSettingsPage.qml | 61 ++ qml/pages/wallet/DesktopWallets.qml | 2 + test/qml/bitcoin_qmltests.qrc | 1 + test/qml/qml_tests_main.cpp | 1 + test/qml/tst_settingsnavigation.qml | 619 ++++++++++++++++++ 18 files changed, 2322 insertions(+), 9 deletions(-) create mode 100644 qml/pages/settings/settingsv2/AboutSettingsPage.qml create mode 100644 qml/pages/settings/settingsv2/ConnectionSettingsPage.qml create mode 100644 qml/pages/settings/settingsv2/DisplaySettingsPage.qml create mode 100644 qml/pages/settings/settingsv2/ExternalSignerSettingsPage.qml create mode 100644 qml/pages/settings/settingsv2/MempoolSettingsPage.qml create mode 100644 qml/pages/settings/settingsv2/NetworkTrafficSettingsPage.qml create mode 100644 qml/pages/settings/settingsv2/ProxySettingsPage.qml create mode 100644 qml/pages/settings/settingsv2/StorageSettingsPage.qml create mode 100644 qml/pages/settings/settingsv2/WalletSectionPage.qml create mode 100644 qml/pages/settings/settingsv2/WindowBehaviorSettingsPage.qml create mode 100644 test/qml/tst_settingsnavigation.qml diff --git a/qml/bitcoin_qml.qrc b/qml/bitcoin_qml.qrc index 08bef173f7..b473cbba8d 100644 --- a/qml/bitcoin_qml.qrc +++ b/qml/bitcoin_qml.qrc @@ -146,6 +146,16 @@ pages/settings/SettingsWallet.qml pages/settings/SettingsStorage.qml pages/settings/SettingsTheme.qml + pages/settings/settingsv2/AboutSettingsPage.qml + pages/settings/settingsv2/ConnectionSettingsPage.qml + pages/settings/settingsv2/DisplaySettingsPage.qml + pages/settings/settingsv2/ExternalSignerSettingsPage.qml + pages/settings/settingsv2/MempoolSettingsPage.qml + pages/settings/settingsv2/NetworkTrafficSettingsPage.qml + pages/settings/settingsv2/ProxySettingsPage.qml + pages/settings/settingsv2/StorageSettingsPage.qml + pages/settings/settingsv2/WalletSectionPage.qml + pages/settings/settingsv2/WindowBehaviorSettingsPage.qml pages/wallet/Activity.qml pages/wallet/ActivityDetails.qml pages/wallet/ActivityTransactionVisuals.qml diff --git a/qml/components/SettingsView.qml b/qml/components/SettingsView.qml index cc205efac8..0791683409 100644 --- a/qml/components/SettingsView.qml +++ b/qml/components/SettingsView.qml @@ -8,14 +8,20 @@ import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 +import org.bitcoincore.qt 1.0 import "../controls" +import "../pages/settings" as LegacySettings +import "../pages/settings/settingsv2" as SettingsV2 +import "../pages/wallet" as WalletPages Page { id: root objectName: "settingsView" signal doneClicked() + signal selectWalletRequested() + signal receiveRequested() property bool showDoneButton: true property string selectedSectionId: "" @@ -23,6 +29,70 @@ Page { readonly property alias sidebar: sidebar readonly property alias pageContainer: pageContainer readonly property var sections: [ + { + id: "wallet", + label: qsTr("Wallet"), + group: "wallet", + visible: AppMode.walletEnabled, + pageComponent: walletPage + }, + { + id: "external-signer", + label: qsTr("External signer"), + group: "wallet", + visible: AppMode.walletEnabled, + pageComponent: externalSignerPage + }, + { + id: "display", + label: qsTr("Display"), + group: "display", + pageComponent: displayPage + }, + { + id: "window-behavior", + label: qsTr("Window behavior"), + group: "display", + visible: AppMode.isDesktop, + pageComponent: windowBehaviorPage + }, + { + id: "storage", + label: qsTr("Storage"), + group: "display", + pageComponent: storagePage + }, + { + id: "connection", + label: qsTr("Connection"), + group: "network", + pageComponent: connectionPage + }, + { + id: "network-traffic", + label: qsTr("Network traffic"), + group: "network", + pageComponent: networkTrafficPage + }, + { + id: "mempool", + label: qsTr("Mempool information"), + group: "network", + visible: nodeModel.mempoolInformationAvailable, + pageComponent: mempoolPage + }, + { + id: "debug-log", + label: qsTr("Debug log"), + group: "developer", + pageComponent: debugLogPage + }, + { + id: "about", + label: qsTr("About"), + group: "about", + pageComponent: aboutPage + } ] function sectionForId(sectionId) { @@ -66,6 +136,17 @@ Page { if (!root.sectionIsVisible(root.selectedSectionId)) root.selectSection(root.firstVisibleSectionId()) } + function openWalletSettings() { + root.selectSection("wallet") + } + + function openWalletAddressHistory() { + if (!walletController.isWalletLoaded || !walletController.selectedWallet) return + walletController.selectedWallet.addressListModel.refresh() + root.selectSection("wallet", true) + pageContainer.push(addressListPage) + } + background: null padding: 0 @@ -77,6 +158,20 @@ Page { Component.onCompleted: root.selectSection( root.sectionIsVisible(root.selectedSectionId) ? root.selectedSectionId : root.firstVisibleSectionId()) + Connections { + target: typeof walletController !== "undefined" ? walletController : null + + function onSelectedWalletChanged() { + if (root.selectedSectionId === "wallet" && pageContainer.depth > 1) root.selectSection("wallet", true) + } + + function onIsWalletLoadedChanged() { + if (!walletController.isWalletLoaded && root.selectedSectionId === "wallet" && pageContainer.depth > 1) { + root.selectSection("wallet", true) + } + } + } + contentItem: RowLayout { spacing: 0 @@ -131,4 +226,100 @@ Page { } } + Component { + id: walletPage + + SettingsV2.WalletSectionPage { + onSelectWalletRequested: root.selectWalletRequested() + onPasswordRequested: pageContainer.push(walletPasswordPage, { + "updating": walletController.selectedWallet.isEncrypted + }) + onSignVerifyMessageRequested: pageContainer.push(signVerifyPage) + onAddressesRequested: { + if (!walletController.isWalletLoaded || !walletController.selectedWallet) return + walletController.selectedWallet.addressListModel.refresh() + pageContainer.push(addressListPage) + } + } + } + + Component { + id: walletPasswordPage + + WalletPages.WalletPasswordSettings { + onBack: pageContainer.pop() + onSaved: pageContainer.pop() + } + } + + Component { + id: signVerifyPage + + WalletPages.SignVerifyMessage { + onBack: pageContainer.pop() + } + } + + Component { + id: addressListPage + + WalletPages.AddressList { + onBack: pageContainer.pop() + onReceiveRequested: { + pageContainer.pop() + root.receiveRequested() + } + } + } + + Component { + id: externalSignerPage + SettingsV2.ExternalSignerSettingsPage {} + } + + Component { + id: displayPage + SettingsV2.DisplaySettingsPage {} + } + + Component { + id: windowBehaviorPage + SettingsV2.WindowBehaviorSettingsPage {} + } + + Component { + id: storagePage + SettingsV2.StorageSettingsPage {} + } + + Component { + id: connectionPage + SettingsV2.ConnectionSettingsPage {} + } + + Component { + id: networkTrafficPage + + SettingsV2.NetworkTrafficSettingsPage {} + } + + Component { + id: mempoolPage + SettingsV2.MempoolSettingsPage {} + } + + Component { + id: debugLogPage + + LegacySettings.SettingsDebugLog { + showBackButton: false + maximumContentWidth: width + contentHorizontalPadding: width >= 900 ? 56 : width >= 640 ? 40 : 24 + } + } + + Component { + id: aboutPage + SettingsV2.AboutSettingsPage {} + } } diff --git a/qml/pages/settings/SettingsDebugLog.qml b/qml/pages/settings/SettingsDebugLog.qml index ea18a082df..a370c1d977 100644 --- a/qml/pages/settings/SettingsDebugLog.qml +++ b/qml/pages/settings/SettingsDebugLog.qml @@ -15,12 +15,26 @@ Page { 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) { @@ -41,7 +55,7 @@ Page { Timer { interval: 60000 repeat: true - running: true + running: root.visible onTriggered: debugLogModel.updateRelativeTimes() } @@ -91,7 +105,9 @@ Page { ColumnLayout { id: contentLayout objectName: "debugLogContentLayout" - width: Math.max(0, Math.min(parent.width - 40, 600)) + width: Math.max(0, Math.min( + parent.width - root.contentHorizontalPadding * 2, + root.maximumContentWidth)) anchors { top: parent.top bottom: parent.bottom @@ -132,10 +148,9 @@ Page { color: Theme.color.neutral9 placeholderTextColor: Theme.color.neutral5 placeholderText: qsTr("Search...") - // The page is unloaded whenever another Settings section is - // selected, while the C++ model intentionally retains its - // filter. Mirror that retained value on re-entry so the field - // and the rows cannot disagree. + // 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 @@ -328,6 +343,9 @@ Page { } } - Component.onCompleted: debugLogModel.active = true - Component.onDestruction: debugLogModel.active = false + Component.onCompleted: root.updateDebugLogActivity() + onVisibleChanged: root.updateDebugLogActivity() + Component.onDestruction: { + if (root.ownsDebugLogActivity) debugLogModel.active = false + } } diff --git a/qml/pages/settings/SettingsDesignSystem.qml b/qml/pages/settings/SettingsDesignSystem.qml index ad2e5009c6..a29379ee66 100644 --- a/qml/pages/settings/SettingsDesignSystem.qml +++ b/qml/pages/settings/SettingsDesignSystem.qml @@ -129,7 +129,6 @@ SettingsPage { trailingItem: PopupPicker { objectName: "designSystemLanguagePicker" embedded: true - implicitWidth: 150 minimumMenuWidth: 180 currentValue: root.exampleLanguage model: [ diff --git a/qml/pages/settings/settingsv2/AboutSettingsPage.qml b/qml/pages/settings/settingsv2/AboutSettingsPage.qml new file mode 100644 index 0000000000..6eaca4061c --- /dev/null +++ b/qml/pages/settings/settingsv2/AboutSettingsPage.qml @@ -0,0 +1,95 @@ +pragma ComponentBehavior: Bound + +// 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 ".." as LegacySettings + +SettingsPage { + id: root + objectName: "settingsv2AboutSettingsPage" + title: qsTr("About") + showBackButton: false + + PageHeading { + Layout.fillWidth: true + description: qsTr("Bitcoin Core is an open source project. If you find it useful, please contribute.\n\nThis is experimental software.") + } + + FormSection { + Layout.fillWidth: true + + LinkRow { + Layout.fillWidth: true + title: qsTr("Website") + value: "bitcoincore.org" + link: "https://bitcoincore.org" + onActivated: function(link) { root.openExternalLink(link) } + } + + LinkRow { + Layout.fillWidth: true + title: qsTr("Source code") + value: "github.com/bitcoin/bitcoin" + link: "https://github.com/bitcoin/bitcoin" + onActivated: function(link) { root.openExternalLink(link) } + } + + LinkRow { + Layout.fillWidth: true + title: qsTr("License") + value: "MIT" + link: "https://opensource.org/licenses/MIT" + onActivated: function(link) { root.openExternalLink(link) } + } + + LinkRow { + objectName: "settingsv2AboutVersionRow" + Layout.fillWidth: true + title: qsTr("Version") + value: BuildInfo.fullClientVersion + link: "https://bitcoin.org/en/download" + linkIconSource: "" + showsDisclosureIndicator: true + onActivated: function(link) { root.openExternalLink(link) } + } + + ListRow { + Layout.fillWidth: true + title: qsTr("Developer options") + description: qsTr("Only use these if you have development experience.") + showDivider: false + showsDisclosureIndicator: true + onClicked: root.StackView.view.push(developerPage) + } + } + + function openExternalLink(link) { + externalLinkPopup.link = link + externalLinkPopup.open() + } + + ExternalPopup { + id: externalLinkPopup + objectName: "settingsv2AboutExternalLinkPopup" + parent: Overlay.overlay + anchors.centerIn: parent + width: Math.min(450, Math.max(0, parent ? parent.width - 40 : 0)) + } + + Component { + id: developerPage + + LegacySettings.SettingsDeveloper { + onBack: root.StackView.view.pop() + } + } +} diff --git a/qml/pages/settings/settingsv2/ConnectionSettingsPage.qml b/qml/pages/settings/settingsv2/ConnectionSettingsPage.qml new file mode 100644 index 0000000000..adab771b94 --- /dev/null +++ b/qml/pages/settings/settingsv2/ConnectionSettingsPage.qml @@ -0,0 +1,97 @@ +pragma ComponentBehavior: Bound + +// 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: "settingsv2ConnectionSettingsPage" + title: qsTr("Connection") + showBackButton: false + + property var settingsModel: optionsModel + property var coreSettingsModel: settingsModel.coreSettings + readonly property var listenSetting: coreSettingsModel.entry("listen") + readonly property var natpmpSetting: coreSettingsModel.entry("natpmp") + readonly property var serverSetting: coreSettingsModel.entry("server") + + SettingsRestartNotice { + visible: root.settingsModel.connectionSettingsDirty + Layout.fillWidth: true + } + + FormSection { + Layout.fillWidth: true + title: qsTr("Incoming connections") + + FormRow { + Layout.fillWidth: true + title: qsTr("Enable listening") + description: qsTr("Allow incoming peer connections.") + supportingText: root.listenSetting.infoText + enabled: root.listenSetting.canEdit + trailingItem: OptionSwitch { + objectName: "settingsv2ListenSwitch" + checked: root.listenSetting.value + onToggled: root.listenSetting.value = checked + } + } + + FormRow { + Layout.fillWidth: true + title: qsTr("Map port using NAT-PMP") + supportingText: root.natpmpSetting.infoText + enabled: root.natpmpSetting.canEdit + trailingItem: OptionSwitch { + objectName: "settingsv2NatpmpSwitch" + checked: root.natpmpSetting.value + onToggled: root.natpmpSetting.value = checked + } + } + + FormRow { + Layout.fillWidth: true + title: qsTr("Enable RPC server") + supportingText: root.serverSetting.infoText + enabled: root.serverSetting.canEdit + showDivider: false + trailingItem: OptionSwitch { + objectName: "settingsv2ServerSwitch" + checked: root.serverSetting.value + onToggled: root.serverSetting.value = checked + } + } + } + + FormSection { + Layout.fillWidth: true + title: qsTr("Privacy") + + ListRow { + objectName: "settingsv2ProxySettingsRow" + Layout.fillWidth: true + title: qsTr("Proxy settings") + description: qsTr("Route peer and Tor connections through SOCKS5 proxies.") + showDivider: false + showsDisclosureIndicator: true + onClicked: root.StackView.view.push(proxyPage) + } + } + + Component { + id: proxyPage + + ProxySettingsPage { + settingsModel: root.settingsModel + onCloseRequested: root.StackView.view.pop() + } + } +} diff --git a/qml/pages/settings/settingsv2/DisplaySettingsPage.qml b/qml/pages/settings/settingsv2/DisplaySettingsPage.qml new file mode 100644 index 0000000000..bc8133f837 --- /dev/null +++ b/qml/pages/settings/settingsv2/DisplaySettingsPage.qml @@ -0,0 +1,225 @@ +pragma ComponentBehavior: Bound + +// 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 ".." as LegacySettings + +SettingsPage { + id: root + objectName: "settingsv2DisplaySettingsPage" + title: qsTr("Display") + showBackButton: false + + FormSection { + Layout.fillWidth: true + title: qsTr("Appearance") + + FormRow { + Layout.fillWidth: true + title: qsTr("Theme") + trailingItem: SegmentedPicker { + objectName: "settingsv2DisplayThemePicker" + implicitWidth: 190 + implicitHeight: 36 + model: [qsTr("Light"), qsTr("Dark")] + currentIndex: Theme.dark ? 1 : 0 + onSelected: function(index, option) { + Theme.dark = index === 1 + } + } + } + + FormRow { + Layout.fillWidth: true + title: qsTr("Block status size") + trailingItem: PopupPicker { + objectName: "settingsv2DisplayBlockStatusSizePicker" + embedded: true + minimumMenuWidth: 520 + subtitleRole: "description" + iconRole: "icon" + iconSize: 40 + currentValue: Theme.blockclocksize >= 1 / 2 ? 1 / 2 : 1 / 3 + model: [ + { + text: qsTr("Compact"), + value: 1 / 3, + description: qsTr("For personal use on a computer or smartphone."), + icon: "image://images/blockclock-size-compact" + }, + { + text: qsTr("Showcase"), + value: 1 / 2, + description: qsTr("A larger block clock for public display on a tablet or other large screen."), + icon: "image://images/blockclock-size-showcase" + } + ] + onActivated: function(value) { + Theme.blockclocksize = value + } + } + } + + FormRow { + Layout.fillWidth: true + title: qsTr("Money font") + showDivider: false + trailingItem: PopupPicker { + objectName: "settingsv2DisplayMoneyFontPicker" + embedded: true + minimumMenuWidth: 400 + subtitleRole: "description" + currentValue: optionsModel.moneyFontChoice + model: [ + { + text: qsTr("Roboto Mono"), + value: "embedded", + description: qsTr("Included with Bitcoin Core") + }, + { + text: qsTr("System Monospace"), + value: "best_system", + description: qsTr("Uses your operating system’s default monospaced font") + } + ] + onActivated: function(value) { + optionsModel.moneyFontChoice = value + } + } + } + } + + FormSection { + Layout.fillWidth: true + title: qsTr("Language and format") + + FormRow { + Layout.fillWidth: true + title: qsTr("Display unit") + trailingItem: PopupPicker { + objectName: "settingsv2DisplayUnitPicker" + embedded: true + minimumMenuWidth: 400 + subtitleRole: "description" + currentValue: optionsModel.displayUnit + model: [ + { + text: qsTr("BTC"), + value: 0, + description: qsTr("8 decimal places (0.00000001 BTC = 1 sat)") + }, + { + text: qsTr("mBTC"), + value: 1, + description: qsTr("5 decimal places (0.00001 mBTC = 1 sat)") + }, + { + text: qsTr("bits"), + value: 2, + description: qsTr("2 decimal places (0.01 bits = 1 sat)") + }, + { + text: qsTr("sat"), + value: 3, + description: qsTr("Satoshi, the smallest unit (1 sat = 0.00000001 BTC)") + } + ] + onActivated: function(value) { + optionsModel.displayUnit = value + } + } + } + + ListRow { + objectName: "settingsv2DisplayLanguageRow" + Layout.fillWidth: true + title: qsTr("Language") + enabled: ((optionsModel.coreSettingStatuses || ({})).lang || ({})).canEdit !== false + showsDisclosureIndicator: true + disclosureIndicatorObjectName: "settingsv2DisplayLanguageDisclosureIndicator" + trailingItem: CoreText { + text: optionsModel.languageLabel(optionsModel.language) + color: Theme.color.neutral7 + font: Theme.text.description.font + } + onClicked: root.StackView.view.push(languagePage) + } + + ListRow { + Layout.fillWidth: true + title: qsTr("Third-party transaction URLs") + showDivider: false + showsDisclosureIndicator: true + onClicked: root.StackView.view.push(transactionUrlsPage) + } + } + + FormSection { + objectName: "settingsv2DisplayDeveloperSection" + Layout.fillWidth: true + visible: BuildInfo.isDebug + title: qsTr("Developer") + + ListRow { + objectName: "settingsv2DisplayDesignSystemRow" + Layout.fillWidth: true + title: qsTr("Design system") + description: qsTr("Preview reusable controls and design tokens.") + showDivider: false + showsDisclosureIndicator: true + onClicked: root.StackView.view.push(designSystemPage) + } + } + + Component { + id: languagePage + + LegacySettings.SettingsLanguage { + onBack: root.StackView.view.pop() + } + } + + Component { + id: designSystemPage + + LegacySettings.SettingsDesignSystem { + objectName: "settingsv2DisplayDesignSystemPage" + onBack: root.StackView.view.pop() + } + } + + Component { + id: transactionUrlsPage + + SettingsPage { + id: transactionUrls + title: qsTr("Transaction URLs") + maximumContentWidth: 560 + onBack: transactionUrls.StackView.view.pop() + + PageHeading { + Layout.fillWidth: true + title: qsTr("Third-party transaction URLs") + description: qsTr("Use %s for the transaction hash. Separate multiple URLs with |.") + } + + CoreTextField { + objectName: "settingsv2ThirdPartyTransactionUrlsInput" + Layout.fillWidth: true + text: optionsModel.thirdPartyTransactionUrls + placeholderText: "https://example.com/tx/%s" + onEditingFinished: optionsModel.thirdPartyTransactionUrls = text + } + } + } + +} diff --git a/qml/pages/settings/settingsv2/ExternalSignerSettingsPage.qml b/qml/pages/settings/settingsv2/ExternalSignerSettingsPage.qml new file mode 100644 index 0000000000..f8736ae515 --- /dev/null +++ b/qml/pages/settings/settingsv2/ExternalSignerSettingsPage.qml @@ -0,0 +1,170 @@ +pragma ComponentBehavior: Bound + +// 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: "settingsv2ExternalSignerSettingsPage" + title: qsTr("External signer") + showBackButton: false + + readonly property var signerStatus: (optionsModel.coreSettingStatuses || ({})).signer || ({}) + readonly property string signerPathError: optionsModel.externalSignerPathValidationError(signerPathInput.text) + readonly property bool signerConnected: root.signerPathError.length === 0 + && walletController.canCreateExternalSignerWallet + readonly property string signerStatusText: { + if (root.signerPathError.length > 0) return root.signerPathError + if (walletController.canCreateExternalSignerWallet) { + return qsTr("Detected external signer: %1").arg(walletController.externalSignerName) + } + if (walletController.externalSignerError.length > 0) { + return walletController.externalSignerError + } + if ((root.signerStatus.infoText || "").length > 0) { + return root.signerStatus.infoText + } + if (optionsModel.walletSettingsDirty) { + return qsTr("Path updated. Press Check device to rescan with the current signer command.") + } + if (optionsModel.externalSignerPath.length > 0) { + return qsTr("No external signer is currently detected.") + } + return qsTr("Set the command path for HWI or another external signer tool.") + } + + function commitSignerPath() { + if (root.signerPathError.length > 0) return false + const normalizedPath = signerPathInput.text.trim() + if (normalizedPath !== optionsModel.externalSignerPath) { + optionsModel.externalSignerPath = normalizedPath + } + return true + } + + function checkDevice() { + if (root.commitSignerPath()) walletController.refreshExternalSignerStatus() + } + + PageHeading { + objectName: "settingsv2ExternalSignerIntroduction" + Layout.fillWidth: true + description: qsTr("Connect a hardware wallet or another external signing tool.") + } + + FormSection { + objectName: "settingsv2ExternalSignerPathSection" + Layout.fillWidth: true + title: qsTr("Signer path") + footerText: qsTr("The add wallet flow can offer external wallets when exactly one supported signer is connected.") + + FormRow { + objectName: "settingsv2ExternalSignerPathRow" + Layout.fillWidth: true + enabled: root.signerStatus.canEdit !== false + showDivider: false + bodySpacing: 0 + topPadding: 16 + bottomPadding: 16 + bodyItem: RowLayout { + Layout.fillWidth: true + Layout.minimumWidth: 0 + spacing: 16 + + ColumnLayout { + Layout.fillWidth: true + Layout.minimumWidth: 0 + spacing: 12 + + TextField { + id: signerPathInput + objectName: "externalSignerPathInput" + Layout.fillWidth: true + implicitHeight: 37 + text: optionsModel.externalSignerPath + placeholderText: qsTr("Enter external signer path") + placeholderTextColor: Theme.color.neutral7 + color: Theme.color.neutral9 + font: Theme.text.description.font + selectByMouse: true + leftPadding: 15 + rightPadding: 10 + topPadding: 0 + bottomPadding: 0 + verticalAlignment: TextInput.AlignVCenter + background: Rectangle { + color: Theme.color.neutral2 + radius: 5 + + FocusBorder { + objectName: "externalSignerPathFocusBorder" + visible: signerPathInput.activeFocus + border.color: Theme.color.orange + borderRadius: 7 + topMargin: -2 + bottomMargin: -2 + leftMargin: -2 + rightMargin: -2 + } + } + onEditingFinished: root.checkDevice() + onAccepted: focus = false + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Rectangle { + objectName: "externalSignerStatusIndicator" + Layout.preferredWidth: 10 + Layout.preferredHeight: 10 + Layout.alignment: Qt.AlignTop + Layout.topMargin: 4 + radius: width / 2 + color: root.signerConnected ? Theme.color.green : Theme.color.red + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + + CoreText { + objectName: "externalSignerStatusText" + Layout.fillWidth: true + text: root.signerStatusText + color: Theme.color.neutral7 + font: Theme.text.description.font + lineHeight: Theme.text.description.lineHeight + lineHeightMode: Text.FixedHeight + horizontalAlignment: Text.AlignLeft + wrap: true + } + } + } + + ContinueButton { + objectName: "externalSignerCheckDeviceButton" + Layout.preferredWidth: 140 + Layout.preferredHeight: 40 + Layout.alignment: Qt.AlignVCenter + text: qsTr("Check device") + textStyle: Theme.text.subheading + enabled: root.signerPathError.length === 0 + && root.signerStatus.canEdit !== false + onClicked: root.checkDevice() + } + } + } + } + + Component.onCompleted: walletController.refreshExternalSignerStatus() +} diff --git a/qml/pages/settings/settingsv2/MempoolSettingsPage.qml b/qml/pages/settings/settingsv2/MempoolSettingsPage.qml new file mode 100644 index 0000000000..674aa8f244 --- /dev/null +++ b/qml/pages/settings/settingsv2/MempoolSettingsPage.qml @@ -0,0 +1,101 @@ +// 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.Layouts 1.15 + +import "../../../controls" +import "../../../components" + +SettingsPage { + id: root + objectName: "settingsv2MempoolSettingsPage" + title: qsTr("Mempool information") + showBackButton: false + + readonly property var maxMempoolStatus: (optionsModel.coreSettingStatuses || ({})).maxmempool || ({}) + property string mempoolSizeText: String(optionsModel.maxMempoolSizeMB) + property string mempoolSizeError: "" + + function formatMegabytes(valueMb) { + const rounded = Math.round(valueMb) + const decimals = Math.abs(valueMb - rounded) < 0.005 ? 0 : 2 + return Number(valueMb).toLocaleString(Qt.locale(), "f", decimals) + " MB" + } + + function validateMempoolSize(valueMb) { + if (isNaN(valueMb) + || valueMb < optionsModel.minMaxMempoolSizeMB + || valueMb > optionsModel.maxMaxMempoolSizeMB) { + return qsTr("Choose a value between %1 MB and %2 MB.") + .arg(optionsModel.minMaxMempoolSizeMB) + .arg(optionsModel.maxMaxMempoolSizeMB) + } + return "" + } + + SettingsRestartNotice { + objectName: "settingsv2MempoolRestartNotice" + visible: optionsModel.mempoolSettingsDirty + Layout.fillWidth: true + } + + FormSection { + Layout.fillWidth: true + title: qsTr("Mempool") + + ValueRow { + objectName: "settingsv2MempoolTransactionsRow" + Layout.fillWidth: true + title: qsTr("Transactions") + value: Number(nodeModel.mempoolTransactionCount).toLocaleString(Qt.locale(), "f", 0) + } + + ValueRow { + objectName: "settingsv2MempoolMemoryUsedRow" + Layout.fillWidth: true + title: qsTr("Memory used") + value: qsTr("%1 / %2") + .arg(root.formatMegabytes(nodeModel.mempoolUsageMB)) + .arg(root.formatMegabytes(nodeModel.mempoolMaxUsageMB)) + } + + TextFieldRow { + id: mempoolSizeRow + objectName: "settingsv2MempoolSizeLimitRow" + Layout.fillWidth: true + title: qsTr("Mempool size limit (MB)") + enabled: root.maxMempoolStatus.canEdit !== false + fieldObjectName: "settingsv2MempoolSizeLimitInput" + fieldWidth: 80 + text: root.mempoolSizeText + validator: IntValidator { + bottom: optionsModel.minMaxMempoolSizeMB + top: optionsModel.maxMaxMempoolSizeMB + } + inputMethodHints: Qt.ImhDigitsOnly + errorText: root.mempoolSizeError + supportingText: root.mempoolSizeError.length === 0 + ? root.maxMempoolStatus.infoText || "" + : "" + showDivider: false + onTextEdited: function(text) { + root.mempoolSizeText = text + root.mempoolSizeError = "" + } + onEditingFinished: { + const parsed = parseInt(mempoolSizeRow.text, 10) + root.mempoolSizeError = root.validateMempoolSize(parsed) + if (root.mempoolSizeError.length === 0) { + optionsModel.maxMempoolSizeMB = parsed + root.mempoolSizeText = String(parsed) + } + } + } + } + + Component.onCompleted: nodeModel.mempoolInfoPollingActive = visible + Component.onDestruction: nodeModel.mempoolInfoPollingActive = false + onVisibleChanged: nodeModel.mempoolInfoPollingActive = visible +} diff --git a/qml/pages/settings/settingsv2/NetworkTrafficSettingsPage.qml b/qml/pages/settings/settingsv2/NetworkTrafficSettingsPage.qml new file mode 100644 index 0000000000..40e92878a1 --- /dev/null +++ b/qml/pages/settings/settingsv2/NetworkTrafficSettingsPage.qml @@ -0,0 +1,162 @@ +pragma ComponentBehavior: Bound + +// 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" + +SettingsPage { + id: root + objectName: "settingsv2NetworkTrafficSettingsPage" + title: qsTr("Network traffic") + showBackButton: false + maximumContentWidth: width + + property int trafficGraphScale: 300 + property bool ownsNetworkTrafficActivity: false + readonly property var scaleOptions: [ + { text: qsTr("5 min"), seconds: 300 }, + { text: qsTr("1 hour"), seconds: 3600 }, + { text: qsTr("12 hours"), seconds: 3600 * 12 }, + { text: qsTr("1 day"), seconds: 3600 * 24 } + ] + + function scaleIndex(scale) { + for (let index = 0; index < root.scaleOptions.length; ++index) { + if (root.scaleOptions[index].seconds === scale) return index + } + return 0 + } + + function selectScale(scale) { + root.trafficGraphScale = scale + networkTrafficTower.updateFilterWindowSize(scale / 10) + } + + function formatBytes(bytes) { + const suffixes = ["Bytes", "KB", "MB", "GB", "TB", "PB"] + let index = 0 + while (bytes >= 1000 && index < suffixes.length - 1) { + bytes /= 1000 + index++ + } + return bytes.toFixed(0) + " " + suffixes[index] + } + + function updateNetworkTrafficActivity() { + if (root.visible) { + networkTrafficTower.active = true + root.ownsNetworkTrafficActivity = true + } else { + if (root.ownsNetworkTrafficActivity) networkTrafficTower.active = false + root.ownsNetworkTrafficActivity = false + } + } + + AppSettings { + id: settings + property alias trafficGraphScale: root.trafficGraphScale + } + + PageHeading { + objectName: "settingsv2NetworkTrafficHeading" + Layout.fillWidth: true + description: qsTr("How much data you have sent to and received from your peers.") + } + + FormSection { + objectName: "settingsv2NetworkTrafficSection" + Layout.fillWidth: true + + SegmentedPicker { + objectName: "settingsv2NetworkTrafficRangePicker" + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 16 + Layout.bottomMargin: 6 + implicitHeight: 36 + model: root.scaleOptions + currentIndex: root.scaleIndex(root.trafficGraphScale) + onSelected: function(index, option) { + root.selectScale(option.seconds) + } + } + + ValueRow { + objectName: "settingsv2NetworkTrafficReceivedRow" + Layout.fillWidth: true + title: qsTr("Received") + value: root.formatBytes(networkTrafficTower.totalBytesReceived) + showDivider: false + leadingItem: Rectangle { + implicitWidth: 10 + implicitHeight: 10 + radius: width / 2 + color: Theme.color.green + } + bodyItem: NetworkTrafficGraph { + objectName: "settingsv2NetworkTrafficReceivedGraph" + Layout.fillWidth: true + Layout.preferredHeight: 250 + backgroundColor: Theme.color.neutral1 + borderColor: Theme.color.neutral3 + fillColor: Theme.color.green + lineColor: Theme.color.green + markerLineColor: Theme.color.neutral3 + unitLabelColor: Theme.color.neutral7 + maxSamples: root.trafficGraphScale + maxValue: networkTrafficTower.maxReceivedRateBps + valueList: networkTrafficTower.receivedRateList + maxRateBps: networkTrafficTower.maxReceivedRateBps + } + } + + ValueRow { + objectName: "settingsv2NetworkTrafficSentRow" + Layout.fillWidth: true + title: qsTr("Sent") + value: root.formatBytes(networkTrafficTower.totalBytesSent) + showDivider: false + bottomPadding: 16 + leadingItem: Rectangle { + implicitWidth: 10 + implicitHeight: 10 + radius: width / 2 + color: Theme.color.blue + } + bodyItem: NetworkTrafficGraph { + objectName: "settingsv2NetworkTrafficSentGraph" + Layout.fillWidth: true + Layout.preferredHeight: 250 + backgroundColor: Theme.color.neutral1 + borderColor: Theme.color.neutral3 + fillColor: Theme.color.blue + lineColor: Theme.color.blue + markerLineColor: Theme.color.neutral3 + unitLabelColor: Theme.color.neutral7 + maxSamples: root.trafficGraphScale + maxValue: networkTrafficTower.maxSentRateBps + valueList: networkTrafficTower.sentRateList + maxRateBps: networkTrafficTower.maxSentRateBps + } + } + } + + Component.onCompleted: { + networkTrafficTower.updateFilterWindowSize(root.trafficGraphScale / 10) + root.updateNetworkTrafficActivity() + } + onVisibleChanged: root.updateNetworkTrafficActivity() + Component.onDestruction: { + if (root.ownsNetworkTrafficActivity) networkTrafficTower.active = false + } +} diff --git a/qml/pages/settings/settingsv2/ProxySettingsPage.qml b/qml/pages/settings/settingsv2/ProxySettingsPage.qml new file mode 100644 index 0000000000..b2a0a7fef0 --- /dev/null +++ b/qml/pages/settings/settingsv2/ProxySettingsPage.qml @@ -0,0 +1,229 @@ +pragma ComponentBehavior: Bound + +// 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: "settingsv2ProxySettingsPage" + title: qsTr("Proxy settings") + backButtonObjectName: "settingsv2ProxySettingsBackButton" + + property var settingsModel: optionsModel + property var coreSettingsModel: settingsModel.coreSettings + readonly property var proxySetting: coreSettingsModel.entry("proxy") + readonly property var onionSetting: coreSettingsModel.entry("onion") + property bool draftProxyEnabled: false + property string draftProxyAddress: "" + property string draftProxyValidationError: "" + property bool draftTorEnabled: false + property string draftTorAddress: "" + property string draftTorValidationError: "" + readonly property bool proxyDraftDirty: draftProxyEnabled !== proxySetting.enabled + || draftProxyAddress !== displayAddress(proxySetting) + || draftTorEnabled !== onionSetting.enabled + || draftTorAddress !== displayAddress(onionSetting) + readonly property bool proxyDraftValid: !draftProxyEnabled || draftProxyValidationError.length === 0 + readonly property bool torDraftValid: !draftTorEnabled || draftTorValidationError.length === 0 + readonly property bool canSaveProxyDraft: proxyDraftDirty && proxyDraftValid && torDraftValid + + signal closeRequested() + + function displayAddress(setting) { + return setting.address.length > 0 ? setting.address : setting.defaultAddress() + } + + function validateAddress(setting, address) { + return setting.validate(address.trim()) + } + + function resetProxyDraft() { + root.draftProxyEnabled = root.proxySetting.enabled + root.draftProxyAddress = root.displayAddress(root.proxySetting) + root.draftProxyValidationError = root.validateAddress(root.proxySetting, root.draftProxyAddress) + root.draftTorEnabled = root.onionSetting.enabled + root.draftTorAddress = root.displayAddress(root.onionSetting) + root.draftTorValidationError = root.validateAddress(root.onionSetting, root.draftTorAddress) + } + + function updateProxyAddress(address) { + root.draftProxyAddress = address + root.draftProxyValidationError = root.validateAddress(root.proxySetting, address) + } + + function updateTorAddress(address) { + root.draftTorAddress = address + root.draftTorValidationError = root.validateAddress(root.onionSetting, address) + } + + function commitProxyDraftEntry(setting, enabled, address, validationError) { + const trimmedAddress = address.trim() + if (!setting.canEdit) return true + if (validationError.length === 0 && trimmedAddress !== setting.address) { + if (!setting.commitAddress(trimmedAddress)) return false + } + if (setting.enabled !== enabled) { + setting.enabled = enabled + if (setting.enabled !== enabled) return false + } + return true + } + + function commitProxyDraft() { + if (!root.canSaveProxyDraft) return false + if (!root.commitProxyDraftEntry( + root.proxySetting, + root.draftProxyEnabled, + root.draftProxyAddress, + root.draftProxyValidationError)) return false + if (!root.commitProxyDraftEntry( + root.onionSetting, + root.draftTorEnabled, + root.draftTorAddress, + root.draftTorValidationError)) return false + root.resetProxyDraft() + return true + } + + function save() { + if (!root.commitProxyDraft()) return + root.closeRequested() + } + + function requestBack() { + if (root.proxyDraftDirty) { + discardProxyChangesPopup.open() + return + } + root.closeRequested() + } + + onBack: root.requestBack() + Component.onCompleted: root.resetProxyDraft() + + rightItem: NavButton { + objectName: "settingsv2ProxySettingsSaveButton" + text: qsTr("Save") + enabled: root.canSaveProxyDraft + onClicked: root.save() + } + + SettingsRestartNotice { + objectName: "settingsv2ProxyRestartNotice" + visible: root.settingsModel.proxySettingsDirty + Layout.fillWidth: true + } + + FormSection { + objectName: "settingsv2DefaultProxySection" + Layout.fillWidth: true + title: qsTr("Default proxy") + description: qsTr("Route peer connections through a SOCKS5 proxy. IPv4, IPv6, and Tor connections are supported.") + + FormRow { + Layout.fillWidth: true + title: qsTr("Enable") + supportingText: root.proxySetting.infoText + enabled: root.proxySetting.canEdit + trailingItem: OptionSwitch { + objectName: "settingsv2ProxyEnableSwitch" + checked: root.draftProxyEnabled + onToggled: root.draftProxyEnabled = checked + } + } + + TextFieldRow { + id: proxyAddressRow + objectName: "settingsv2ProxyAddressRow" + Layout.fillWidth: true + title: qsTr("Proxy location") + enabled: root.draftProxyEnabled && root.proxySetting.canEdit + fieldObjectName: "settingsv2ProxyAddressInput" + fieldWidth: 220 + text: root.draftProxyAddress + placeholderText: root.proxySetting.defaultAddress() + errorText: root.draftProxyEnabled ? root.draftProxyValidationError : "" + showDivider: false + onTextEdited: function(text) { root.updateProxyAddress(text) } + onEditingFinished: { + root.updateProxyAddress(proxyAddressRow.text) + if (root.draftProxyValidationError.length === 0) { + root.draftProxyAddress = proxyAddressRow.text.trim() + } + } + } + } + + FormSection { + objectName: "settingsv2TorProxySection" + Layout.fillWidth: true + title: qsTr("Tor proxy") + description: qsTr("Route Tor connections through a dedicated SOCKS5 proxy.") + + FormRow { + Layout.fillWidth: true + title: qsTr("Enable") + supportingText: root.onionSetting.infoText + enabled: root.onionSetting.canEdit + trailingItem: OptionSwitch { + objectName: "settingsv2TorEnableSwitch" + checked: root.draftTorEnabled + onToggled: root.draftTorEnabled = checked + } + } + + TextFieldRow { + id: torAddressRow + objectName: "settingsv2TorAddressRow" + Layout.fillWidth: true + title: qsTr("Proxy location") + enabled: root.draftTorEnabled && root.onionSetting.canEdit + fieldObjectName: "settingsv2TorAddressInput" + fieldWidth: 220 + text: root.draftTorAddress + placeholderText: root.onionSetting.defaultAddress() + errorText: root.draftTorEnabled ? root.draftTorValidationError : "" + showDivider: false + onTextEdited: function(text) { root.updateTorAddress(text) } + onEditingFinished: { + root.updateTorAddress(torAddressRow.text) + if (root.draftTorValidationError.length === 0) { + root.draftTorAddress = torAddressRow.text.trim() + } + } + } + } + + AlertPopup { + id: discardProxyChangesPopup + objectName: "settingsv2DiscardProxyChangesPopup" + parent: Overlay.overlay + title: qsTr("Discard changes?") + message: qsTr("This will discard your proxy settings changes.") + messageObjectName: "settingsv2DiscardProxyChangesMessage" + + AlertAction { + text: qsTr("Cancel") + role: AlertAction.Cancel + buttonObjectName: "settingsv2DiscardProxyChangesCancelButton" + } + + AlertAction { + text: qsTr("Discard") + role: AlertAction.Destructive + buttonObjectName: "settingsv2DiscardProxyChangesConfirmButton" + onTriggered: { + root.resetProxyDraft() + root.closeRequested() + } + } + } +} diff --git a/qml/pages/settings/settingsv2/StorageSettingsPage.qml b/qml/pages/settings/settingsv2/StorageSettingsPage.qml new file mode 100644 index 0000000000..e390f81518 --- /dev/null +++ b/qml/pages/settings/settingsv2/StorageSettingsPage.qml @@ -0,0 +1,124 @@ +pragma ComponentBehavior: Bound + +// 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.Layouts 1.15 + +import "../../../controls" +import "../../../components" + +SettingsPage { + id: root + objectName: "settingsv2StorageSettingsPage" + title: qsTr("Storage") + showBackButton: false + + property var settingsModel: optionsModel + property var coreSettingsModel: settingsModel.coreSettings + readonly property var pruneSetting: coreSettingsModel.entry("prune") + readonly property bool hasStorageResult: root.settingsModel + && root.settingsModel["storageAvailableText"] !== undefined + && root.settingsModel.storageAvailableText.length > 0 + && !root.settingsModel.storageCheckPending + readonly property int availableStorageGB: root.hasStorageResult + ? root.settingsModel.storageAvailableGB + : 0 + readonly property int assumedChainstateSizeGB: root.settingsModel + && root.settingsModel["assumedChainstateSize"] !== undefined + ? root.settingsModel.assumedChainstateSize + : 0 + readonly property int maxPruneSizeGB: root.hasStorageResult + ? Math.max(0, root.availableStorageGB - root.assumedChainstateSizeGB) + : 0 + property string pruneTargetText: String(pruneSetting.value) + property string pruneTargetError: "" + + function validatePruneTarget(value) { + if (isNaN(value) || value < 1) { + return qsTr("Choose a storage limit of at least 1 GB.") + } + if (root.hasStorageResult && value > root.maxPruneSizeGB) { + if (root.maxPruneSizeGB < 1) { + return qsTr("There is not enough available storage for reduced storage in this data directory.") + } + return qsTr("Choose a value between 1 GB and %1 GB for this data directory.").arg(root.maxPruneSizeGB) + } + return "" + } + + FormSection { + Layout.fillWidth: true + title: qsTr("Block storage") + + FormRow { + Layout.fillWidth: true + title: qsTr("Store recent blocks only") + supportingText: root.pruneSetting.infoText + enabled: root.pruneSetting.canEdit + trailingItem: OptionSwitch { + objectName: "settingsv2PruneSwitch" + checked: root.pruneSetting.enabled + onToggled: root.pruneSetting.enabled = checked + } + } + + TextFieldRow { + id: pruneTargetRow + Layout.fillWidth: true + title: qsTr("Block storage limit (GB)") + enabled: root.pruneSetting.enabled && root.pruneSetting.canEdit + fieldObjectName: "settingsv2PruneTargetInput" + fieldWidth: 80 + text: root.pruneTargetText + validator: IntValidator { bottom: 1 } + errorText: root.pruneTargetError + supportingText: root.pruneTargetError.length === 0 ? root.pruneSetting.infoText : "" + showDivider: false + onTextEdited: function(text) { + root.pruneTargetText = text + root.pruneTargetError = "" + } + onEditingFinished: { + const parsed = parseInt(pruneTargetRow.text) + root.pruneTargetError = root.validatePruneTarget(parsed) + if (root.pruneTargetError.length === 0) { + root.pruneSetting.value = parsed + root.pruneTargetText = String(parsed) + } + } + } + } + + FormSection { + Layout.fillWidth: true + title: qsTr("Data directory") + description: qsTr("Selected before startup. The data directory cannot be changed while the node is running.") + + FormRow { + Layout.fillWidth: true + title: qsTr("Location") + showDivider: false + bodyItem: CoreText { + objectName: "settingsv2DataDirectoryValue" + Layout.fillWidth: true + text: root.settingsModel.dataDir + color: Theme.color.neutral7 + font: Theme.text.caption.font + lineHeight: Theme.text.caption.lineHeight + lineHeightMode: Text.FixedHeight + horizontalAlignment: Text.AlignLeft + wrap: true + } + } + } + + SettingsRestartNotice { + objectName: "settingsv2StorageRestartNotice" + visible: root.settingsModel.storageSettingsDirty + Layout.fillWidth: true + Layout.maximumWidth: root.contentLayout.width + } +} diff --git a/qml/pages/settings/settingsv2/WalletSectionPage.qml b/qml/pages/settings/settingsv2/WalletSectionPage.qml new file mode 100644 index 0000000000..3928e2939a --- /dev/null +++ b/qml/pages/settings/settingsv2/WalletSectionPage.qml @@ -0,0 +1,208 @@ +pragma ComponentBehavior: Bound + +// 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.Dialogs +import QtQuick.Layouts 1.15 + +import "../../../controls" + +SettingsPage { + id: root + objectName: "settingsv2WalletSettingsPage" + title: qsTr("Wallet settings") + showBackButton: false + + property var wallet: walletController.selectedWallet + property string errorText: "" + property string pendingDisplayName: root.wallet ? root.wallet.displayName : "" + readonly property bool walletLoaded: walletController.isWalletLoaded + readonly property bool canManagePassphrase: root.wallet !== null && root.wallet.canManagePassphrase + + signal selectWalletRequested() + signal passwordRequested() + signal signVerifyMessageRequested() + signal addressesRequested() + + function backupFileName() { + const walletName = root.wallet && root.wallet.name.length > 0 + ? root.wallet.name.replace(/[\\/]/g, "_") + : "wallet" + return walletName + ".bak" + } + + function backupDefaultFileUrl() { + return "file://" + walletController.homePath() + "/" + root.backupFileName() + } + + function resolvedBackupPath(rawPath) { + let normalized = walletController.normalizeWalletPath(rawPath) + if (normalized.length === 0) return "" + + const hasKnownSuffix = /\.(bak|dat)$/i.test(normalized) + if (walletController.walletPathExists(normalized) && !hasKnownSuffix) { + normalized += "/" + root.backupFileName() + } else if (!hasKnownSuffix) { + normalized += ".bak" + } + return normalized + } + + function startBackup() { + if (!root.wallet) return + root.errorText = "" + root.wallet.clearSettingsError() + if (backupAutomationPath.text.length > 0) { + const automatedPath = root.resolvedBackupPath(backupAutomationPath.text) + backupAutomationPath.text = "" + if (!root.wallet.backupWallet(automatedPath)) root.errorText = root.wallet.settingsError + return + } + backupDialog.open() + } + + FileDialog { + id: backupDialog + fileMode: FileDialog.SaveFile + currentFolder: "file://" + walletController.homePath() + selectedFile: root.backupDefaultFileUrl() + defaultSuffix: "bak" + nameFilters: [qsTr("Wallet backup files (*.bak *.dat)"), qsTr("All files (*)")] + onAccepted: { + if (backupDialog.selectedFile.toString().length === 0) return + const normalized = root.resolvedBackupPath(backupDialog.selectedFile.toString()) + if (!root.wallet.backupWallet(normalized)) root.errorText = root.wallet.settingsError + else root.errorText = "" + } + } + + TextField { + id: backupAutomationPath + objectName: "settingsv2WalletSettingsBackupPathField" + visible: false + } + + Connections { + target: root.wallet + + function onSettingsErrorChanged() { + root.errorText = root.wallet ? root.wallet.settingsError : "" + } + + function onDisplayNameChanged() { + root.pendingDisplayName = root.wallet ? root.wallet.displayName : "" + } + } + + Connections { + target: walletController + + function onSelectedWalletChanged() { + root.pendingDisplayName = root.wallet ? root.wallet.displayName : "" + root.errorText = "" + } + } + + PageHeading { + visible: !root.walletLoaded + Layout.fillWidth: true + title: qsTr("No wallet selected") + description: qsTr("Select a wallet to manage wallet-specific settings.") + } + + OutlineButton { + visible: !root.walletLoaded + Layout.preferredWidth: 220 + Layout.alignment: Qt.AlignHCenter + text: qsTr("Select wallet") + onClicked: root.selectWalletRequested() + } + + FormSection { + objectName: "settingsv2WalletInfoSection" + visible: root.walletLoaded + Layout.fillWidth: true + title: qsTr("Wallet info") + + TextFieldRow { + Layout.fillWidth: true + title: qsTr("Name") + fieldObjectName: "settingsv2WalletNameInput" + fieldWidth: 220 + text: root.pendingDisplayName + onTextEdited: function(text) { root.pendingDisplayName = text } + onEditingFinished: { + if (!root.wallet) return + if (!walletController.setWalletDisplayName(root.wallet.name, root.pendingDisplayName)) { + root.errorText = root.wallet.settingsError + } + } + } + + ValueRow { + Layout.fillWidth: true + title: qsTr("Key scheme") + value: root.wallet ? root.wallet.keyScheme : "" + } + + ValueRow { + Layout.fillWidth: true + title: qsTr("Private keys") + value: root.wallet ? root.wallet.privateKeysStatus : "" + } + + ValueRow { + Layout.fillWidth: true + title: qsTr("External signer") + value: root.wallet ? root.wallet.externalSignerStatus : "" + showDivider: false + } + } + + FormSection { + objectName: "settingsv2WalletActionsSection" + visible: root.walletLoaded + Layout.fillWidth: true + title: qsTr("Wallet actions") + + ListRow { + Layout.fillWidth: true + title: qsTr("Addresses") + showsDisclosureIndicator: true + onClicked: root.addressesRequested() + } + + ListRow { + visible: root.canManagePassphrase + Layout.fillWidth: true + title: root.wallet && root.wallet.isEncrypted ? qsTr("Update password") : qsTr("Set password") + showsDisclosureIndicator: true + onClicked: root.passwordRequested() + } + + ListRow { + Layout.fillWidth: true + title: qsTr("Back up wallet") + showsDisclosureIndicator: true + onClicked: root.startBackup() + } + + ListRow { + Layout.fillWidth: true + title: qsTr("Sign or verify message") + showDivider: false + showsDisclosureIndicator: true + onClicked: root.signVerifyMessageRequested() + } + } + + FormRow { + visible: root.errorText.length > 0 + Layout.fillWidth: true + errorText: root.errorText + } +} diff --git a/qml/pages/settings/settingsv2/WindowBehaviorSettingsPage.qml b/qml/pages/settings/settingsv2/WindowBehaviorSettingsPage.qml new file mode 100644 index 0000000000..fabfff9dea --- /dev/null +++ b/qml/pages/settings/settingsv2/WindowBehaviorSettingsPage.qml @@ -0,0 +1,61 @@ +pragma ComponentBehavior: Bound + +// 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.Layouts 1.15 + +import "../../../controls" + +SettingsPage { + id: root + objectName: "settingsv2WindowBehaviorSettingsPage" + title: qsTr("Window behavior") + showBackButton: false + + property var windowBehaviorModel: desktopWindowBehaviorModel + + FormSection { + Layout.fillWidth: true + title: qsTr("Window behavior") + + FormRow { + Layout.fillWidth: true + title: qsTr("Show tray icon") + description: qsTr("Keep the app available in the system tray.") + enabled: root.windowBehaviorModel.desktopPlatform + trailingItem: OptionSwitch { + objectName: "settingsv2ShowTrayIconSwitch" + checked: root.windowBehaviorModel.showTrayIcon + onToggled: root.windowBehaviorModel.showTrayIcon = checked + } + } + + FormRow { + Layout.fillWidth: true + title: qsTr("Minimize to tray") + description: qsTr("Hide the window in the tray when minimized.") + enabled: root.windowBehaviorModel.desktopPlatform + && root.windowBehaviorModel.showTrayIcon + trailingItem: OptionSwitch { + objectName: "settingsv2MinimizeToTraySwitch" + checked: root.windowBehaviorModel.minimizeToTray + onToggled: root.windowBehaviorModel.minimizeToTray = checked + } + } + + FormRow { + Layout.fillWidth: true + title: qsTr("Minimize on close") + description: qsTr("Keep the node running when the window is closed.") + enabled: root.windowBehaviorModel.desktopPlatform + showDivider: false + trailingItem: OptionSwitch { + objectName: "settingsv2MinimizeOnCloseSwitch" + checked: root.windowBehaviorModel.minimizeOnClose + onToggled: root.windowBehaviorModel.minimizeOnClose = checked + } + } + } +} diff --git a/qml/pages/wallet/DesktopWallets.qml b/qml/pages/wallet/DesktopWallets.qml index 33b8db9396..a45f0869a5 100644 --- a/qml/pages/wallet/DesktopWallets.qml +++ b/qml/pages/wallet/DesktopWallets.qml @@ -392,6 +392,8 @@ Page { onLoaded: retainItem = true sourceComponent: SettingsView { showDoneButton: false + onSelectWalletRequested: root.openWalletSelection() + onReceiveRequested: receiveTabButton.checked = true } } } diff --git a/test/qml/bitcoin_qmltests.qrc b/test/qml/bitcoin_qmltests.qrc index 8b434c2863..8c964b14b2 100644 --- a/test/qml/bitcoin_qmltests.qrc +++ b/test/qml/bitcoin_qmltests.qrc @@ -37,6 +37,7 @@ tst_send.qml tst_setting.qml tst_settingsheader.qml + tst_settingsnavigation.qml tst_settingsstatus.qml tst_settingswallet.qml tst_settingswindowbehavior.qml diff --git a/test/qml/qml_tests_main.cpp b/test/qml/qml_tests_main.cpp index 767944cfe0..d31220a5c0 100644 --- a/test/qml/qml_tests_main.cpp +++ b/test/qml/qml_tests_main.cpp @@ -1974,6 +1974,7 @@ class MockOptionsModel : public QObject Q_INVOKABLE bool commitProxyLocation(const QString&) { return true; } Q_INVOKABLE bool commitTorLocation(const QString&) { return true; } Q_INVOKABLE QString defaultProxyAddress() const { return QStringLiteral("127.0.0.1:9050"); } + Q_INVOKABLE QString externalSignerPathValidationError(const QString&) const { return {}; } QObject* coreSettings() { return &m_core_settings; } QVariantMap coreSettingStatuses() const { QVariantMap statuses; diff --git a/test/qml/tst_settingsnavigation.qml b/test/qml/tst_settingsnavigation.qml new file mode 100644 index 0000000000..985f98a47d --- /dev/null +++ b/test/qml/tst_settingsnavigation.qml @@ -0,0 +1,619 @@ +pragma ComponentBehavior: Bound + +// 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 { + id: testCase + name: "SettingsNavigation" + when: windowShown + width: 720 + height: 640 + + property int firstCreated: 0 + property int firstDestroyed: 0 + property int pushedCreated: 0 + property int pushedDestroyed: 0 + property int secondCreated: 0 + property int secondDestroyed: 0 + + Item { + id: host + anchors.fill: parent + } + + Window { + id: settingsWindow + width: 900 + height: 700 + visible: true + } + + Component { + id: containerComponent + + SettingsPageContainer { + width: 480 + height: 560 + } + } + + Component { + id: sidebarComponent + + SettingsSidebar { + width: 190 + height: 300 + currentSectionId: "display" + model: [ + { id: "wallet", label: "Wallet", group: "wallet" }, + { id: "display", label: "Display", group: "display" }, + { id: "hidden", label: "Hidden", group: "display", visible: false }, + { id: "connection", label: "Connection", group: "network" } + ] + } + } + + Component { + id: settingsViewComponent + + SettingsView { + width: 900 + height: 700 + selectedSectionId: "display" + } + } + + Component { + id: firstPage + + Item { + objectName: "firstSettingsPage" + Component.onCompleted: testCase.firstCreated += 1 + Component.onDestruction: testCase.firstDestroyed += 1 + } + } + + Component { + id: pushedPage + + Item { + objectName: "pushedSettingsPage" + Component.onCompleted: testCase.pushedCreated += 1 + Component.onDestruction: testCase.pushedDestroyed += 1 + } + } + + Component { + id: secondPage + + Item { + objectName: "secondSettingsPage" + Component.onCompleted: testCase.secondCreated += 1 + Component.onDestruction: testCase.secondDestroyed += 1 + } + } + + function init() { + firstCreated = 0 + firstDestroyed = 0 + pushedCreated = 0 + pushedDestroyed = 0 + secondCreated = 0 + secondDestroyed = 0 + AppMode.walletEnabled = true + AppMode.isDesktop = true + nodeModel.mempoolInformationAvailable = true + Theme.dark = true + Theme.blockclocksize = 5 / 12 + optionsModel.displayUnit = 0 + optionsModel.moneyFontChoice = "embedded" + optionsModel.maxMempoolSizeMB = 300 + optionsModel.storageSettingsDirty = false + optionsModel.clearCoreSettingStatusesForTest() + const proxySetting = optionsModel.coreSettings.entry("proxy") + const onionSetting = optionsModel.coreSettings.entry("onion") + proxySetting.enabled = false + proxySetting.address = proxySetting.defaultAddress() + onionSetting.enabled = false + onionSetting.address = onionSetting.defaultAddress() + testNetworkTrafficTower.active = false + testDebugLogModel.active = false + } + + function test_sidebarFiltersGroupsAndEmitsStableSectionId() { + const sidebar = createTemporaryObject(sidebarComponent, host) + verify(sidebar !== null) + compare(sidebar.visibleSections.length, 3) + verify(findChild(sidebar, "settingsSidebar_wallet") !== null) + verify(findChild(sidebar, "settingsSidebar_display") !== null) + verify(findChild(sidebar, "settingsSidebar_hidden") === null) + + let activatedSection = "" + sidebar.sectionActivated.connect(function(sectionId) { + activatedSection = sectionId + }) + const connection = findChild(sidebar, "settingsSidebar_connection") + verify(connection !== null) + connection.clicked() + compare(activatedSection, "connection") + } + + function test_containerLazilyCachesAndRestoresEachSectionStack() { + const container = createTemporaryObject(containerComponent, host) + verify(container !== null) + + container.showSection("first", firstPage) + compare(container.currentSectionId, "first") + compare(container.depth, 1) + compare(firstCreated, 1) + compare(firstDestroyed, 0) + + container.push(pushedPage) + tryCompare(container, "depth", 2) + const pushedItem = container.currentItem + compare(firstDestroyed, 0) + compare(pushedCreated, 1) + compare(pushedDestroyed, 0) + + container.showSection("second", secondPage) + compare(container.currentSectionId, "second") + compare(container.depth, 1) + compare(firstDestroyed, 0) + compare(pushedDestroyed, 0) + compare(secondCreated, 1) + + container.showSection("first", firstPage) + compare(container.currentSectionId, "first") + compare(container.depth, 2) + compare(container.currentItem, pushedItem) + compare(firstCreated, 1) + compare(pushedCreated, 1) + compare(secondDestroyed, 0) + + container.clear() + compare(container.depth, 0) + tryCompare(testCase, "firstDestroyed", 1) + tryCompare(testCase, "pushedDestroyed", 1) + tryCompare(testCase, "secondDestroyed", 1) + } + + function test_selectingCurrentSectionDoesNotReloadItsStack() { + const container = createTemporaryObject(containerComponent, host) + verify(container !== null) + + container.showSection("first", firstPage) + container.push(pushedPage) + tryCompare(container, "depth", 2) + + container.showSection("first", firstPage) + compare(container.depth, 2) + compare(firstCreated, 1) + compare(firstDestroyed, 0) + compare(pushedCreated, 1) + compare(pushedDestroyed, 0) + } + + function test_settingsViewLazilyCachesSectionsAndIdlesExpensivePages() { + const view = createTemporaryObject(settingsViewComponent, settingsWindow.contentItem) + verify(view !== null) + compare(view.visible, true) + verify(view.componentForSection("display") !== null) + compare(view.selectedSectionId, "display") + tryCompare(view.pageContainer, "depth", 1) + const displayPage = findChild(view, "settingsv2DisplaySettingsPage") + verify(displayPage !== null) + verify(findChild(view, "settingsv2NetworkTrafficSettingsPage") === null) + verify(findChild(view, "settingsDebugLog") === null) + compare(testNetworkTrafficTower.active, false) + compare(testDebugLogModel.active, false) + + view.selectSection("network-traffic") + tryCompare(view, "selectedSectionId", "network-traffic") + compare(findChild(view, "settingsv2DisplaySettingsPage"), displayPage) + const networkTrafficPage = findChild(view, "settingsv2NetworkTrafficSettingsPage") + verify(networkTrafficPage !== null) + tryCompare(testNetworkTrafficTower, "active", true) + const networkTrafficHeading = findChild(view, "settingsv2NetworkTrafficHeading") + const networkTrafficDescription = findChild(view, "settingsv2NetworkTrafficHeadingDescription") + const networkTrafficSection = findChild(view, "settingsv2NetworkTrafficSection") + const networkTrafficRangePicker = findChild(view, "settingsv2NetworkTrafficRangePicker") + const networkTrafficReceivedGraph = findChild(view, "settingsv2NetworkTrafficReceivedGraph") + const networkTrafficSentRow = findChild(view, "settingsv2NetworkTrafficSentRow") + const networkTrafficSentGraph = findChild(view, "settingsv2NetworkTrafficSentGraph") + verify(networkTrafficHeading !== null) + verify(networkTrafficDescription !== null) + verify(networkTrafficSection !== null) + verify(networkTrafficRangePicker !== null) + verify(networkTrafficReceivedGraph !== null) + verify(networkTrafficSentRow !== null) + verify(networkTrafficSentGraph !== null) + compare(networkTrafficHeading.descriptionTextStyle.font.pixelSize, + Theme.text.description.font.pixelSize) + compare(networkTrafficDescription.horizontalAlignment, Text.AlignHCenter) + compare(networkTrafficSection.backgroundColor, Theme.color.neutral1) + compare(networkTrafficSentRow.bottomPadding, 16) + compare(networkTrafficRangePicker.model.length, 4) + networkTrafficRangePicker.selected(1, networkTrafficRangePicker.model[1]) + compare(testNetworkTrafficTower.lastFilterWindowSize, 360) + + view.selectSection("debug-log") + compare(findChild(view, "settingsv2NetworkTrafficSettingsPage"), networkTrafficPage) + tryCompare(testNetworkTrafficTower, "active", false) + const debugLogPage = findChild(view, "settingsDebugLog") + verify(debugLogPage !== null) + tryCompare(testDebugLogModel, "active", true) + + view.selectSection("network-traffic") + compare(findChild(view, "settingsv2NetworkTrafficSettingsPage"), networkTrafficPage) + compare(findChild(view, "settingsDebugLog"), debugLogPage) + compare(networkTrafficPage.trafficGraphScale, 3600) + tryCompare(testNetworkTrafficTower, "active", true) + tryCompare(testDebugLogModel, "active", false) + + view.selectSection("debug-log") + compare(findChild(view, "settingsDebugLog"), debugLogPage) + tryCompare(testNetworkTrafficTower, "active", false) + tryCompare(testDebugLogModel, "active", true) + + view.selectSection("about") + tryCompare(testDebugLogModel, "active", false) + verify(findChild(view, "settingsv2AboutSettingsPage") !== null) + compare(findChild(view, "settingsDebugLog"), 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, "settingsv2NetworkTrafficSettingsPage"), networkTrafficPage) + + view.visible = true + compare(view.pageContainer.depth, 1) + verify(findChild(view, "settingsv2AboutSettingsPage") !== null) + compare(findChild(view, "settingsDebugLog"), debugLogPage) + compare(findChild(view, "settingsv2NetworkTrafficSettingsPage"), networkTrafficPage) + tryCompare(testDebugLogModel, "active", false) + tryCompare(testNetworkTrafficTower, "active", false) + } + + function test_settingsViewPreservesAddressStackWhileHidden() { + const view = createTemporaryObject(settingsViewComponent, settingsWindow.contentItem) + verify(view !== null) + + view.openWalletAddressHistory() + tryCompare(view.pageContainer, "depth", 2) + const addressPage = view.pageContainer.currentItem + compare(addressPage.objectName, "addressListPage") + + view.visible = false + compare(view.pageContainer.depth, 2) + compare(view.pageContainer.currentItem, addressPage) + + view.visible = true + compare(view.pageContainer.depth, 2) + compare(view.pageContainer.currentItem, addressPage) + } + + function test_settingsViewPinsSidebarAndLetsPageContainerGrow() { + const view = createTemporaryObject(settingsViewComponent, settingsWindow.contentItem) + verify(view !== null) + + const sidebarSurface = findChild(view, "settingsv2SettingsSidebarSurface") + const displayPage = findChild(view, "settingsv2DisplaySettingsPage") + verify(sidebarSurface !== null) + verify(displayPage !== null) + + tryCompare(sidebarSurface, "x", 0) + tryCompare(sidebarSurface, "width", view.sidebarWidth) + compare(sidebarSurface.color, Theme.color.neutral1) + tryCompare(view.pageContainer, "x", view.sidebarWidth) + tryCompare(view.pageContainer, "width", view.width - view.sidebarWidth) + + verify(displayPage.contentHorizontalPadding >= 24) + verify(displayPage.contentLayout.width <= displayPage.maximumContentWidth) + verify(displayPage.contentLayout.width < view.pageContainer.width) + } + + function test_dataHeavyPagesUseAvailableWidthWithResponsivePadding() { + const view = createTemporaryObject(settingsViewComponent, settingsWindow.contentItem) + verify(view !== null) + view.width = 1500 + + view.selectSection("network-traffic") + const networkTrafficPage = findChild(view, "settingsv2NetworkTrafficSettingsPage") + verify(networkTrafficPage !== null) + verify(networkTrafficPage.contentHorizontalPadding >= 24) + tryCompare(networkTrafficPage.contentLayout, "width", + networkTrafficPage.scrollView.availableWidth + - networkTrafficPage.contentHorizontalPadding * 2) + verify(networkTrafficPage.contentLayout.width > 840) + + view.selectSection("debug-log") + const debugLogPage = findChild(view, "settingsDebugLog") + const debugLogContent = findChild(view, "debugLogContentLayout") + verify(debugLogPage !== null) + verify(debugLogContent !== null) + verify(debugLogPage.contentHorizontalPadding >= 24) + tryCompare(debugLogContent, "width", + debugLogPage.width - debugLogPage.contentHorizontalPadding * 2) + verify(debugLogContent.width > 840) + } + + function test_displayPageUsesInlineGenericPickers() { + const view = createTemporaryObject(settingsViewComponent, settingsWindow.contentItem) + verify(view !== null) + + const themePicker = findChild(view, "settingsv2DisplayThemePicker") + const blockStatusSizePicker = findChild(view, "settingsv2DisplayBlockStatusSizePicker") + const moneyFontPicker = findChild(view, "settingsv2DisplayMoneyFontPicker") + const displayUnitPicker = findChild(view, "settingsv2DisplayUnitPicker") + const languageDisclosure = findChild(view, "settingsv2DisplayLanguageDisclosureIndicator") + const developerSection = findChild(view, "settingsv2DisplayDeveloperSection") + const designSystemRow = findChild(view, "settingsv2DisplayDesignSystemRow") + + verify(themePicker !== null) + verify(blockStatusSizePicker !== null) + verify(moneyFontPicker !== null) + verify(displayUnitPicker !== null) + verify(languageDisclosure !== null) + verify(developerSection !== null) + verify(designSystemRow !== null) + compare(languageDisclosure.size, 14) + compare(developerSection.visible, BuildInfo.isDebug) + compare(displayUnitPicker.subtitleRole, "description") + compare(displayUnitPicker.minimumMenuWidth, 400) + tryVerify(function() { return displayUnitPicker.itemAtIndex(3) !== null }) + compare(displayUnitPicker.itemAtIndex(0).subtitle, + "8 decimal places (0.00000001 BTC = 1 sat)") + compare(displayUnitPicker.itemAtIndex(1).subtitle, + "5 decimal places (0.00001 mBTC = 1 sat)") + compare(displayUnitPicker.itemAtIndex(2).subtitle, + "2 decimal places (0.01 bits = 1 sat)") + compare(displayUnitPicker.itemAtIndex(3).subtitle, + "Satoshi, the smallest unit (1 sat = 0.00000001 BTC)") + + themePicker.selected(0, "Light") + compare(Theme.dark, false) + + compare(blockStatusSizePicker.subtitleRole, "description") + compare(blockStatusSizePicker.iconRole, "icon") + compare(blockStatusSizePicker.iconSize, 40) + compare(blockStatusSizePicker.minimumMenuWidth, 520) + compare(blockStatusSizePicker.currentText, "Compact") + tryVerify(function() { return blockStatusSizePicker.itemAtIndex(1) !== null }) + compare(blockStatusSizePicker.itemAtIndex(0).subtitle, + "For personal use on a computer or smartphone.") + compare(blockStatusSizePicker.itemAtIndex(1).subtitle, + "A larger block clock for public display on a tablet or other large screen.") + compare(blockStatusSizePicker.itemAtIndex(0).rowIconSource.toString(), + "image://images/blockclock-size-compact") + compare(blockStatusSizePicker.itemAtIndex(1).rowIconSource.toString(), + "image://images/blockclock-size-showcase") + compare(blockStatusSizePicker.itemAtIndex(0).implicitHeight, 52) + compare(blockStatusSizePicker.itemAtIndex(1).implicitHeight, 52) + blockStatusSizePicker.activated(1 / 2) + compare(Theme.blockclocksize, 1 / 2) + compare(blockStatusSizePicker.currentText, "Showcase") + + compare(moneyFontPicker.subtitleRole, "description") + compare(moneyFontPicker.minimumMenuWidth, 400) + compare(moneyFontPicker.currentText, "Roboto Mono") + tryVerify(function() { return moneyFontPicker.itemAtIndex(1) !== null }) + compare(moneyFontPicker.itemAtIndex(0).subtitle, "Included with Bitcoin Core") + compare(moneyFontPicker.itemAtIndex(1).subtitle, + "Uses your operating system’s default monospaced font") + const embeddedMoneyFontWidth = moneyFontPicker.width + moneyFontPicker.activated("best_system") + compare(optionsModel.moneyFontChoice, "best_system") + compare(moneyFontPicker.currentText, "System Monospace") + tryVerify(function() { return moneyFontPicker.width > embeddedMoneyFontWidth }) + moneyFontPicker.activated("embedded") + compare(optionsModel.moneyFontChoice, "embedded") + compare(moneyFontPicker.currentText, "Roboto Mono") + tryCompare(moneyFontPicker, "width", embeddedMoneyFontWidth) + + displayUnitPicker.activated(3) + compare(optionsModel.displayUnit, 3) + compare(displayUnitPicker.currentText, "sat") + + designSystemRow.clicked() + tryCompare(view.pageContainer, "depth", 2) + verify(findChild(view, "settingsv2DisplayDesignSystemPage") !== null) + } + + function test_mempoolPageUsesStandardFormRows() { + const view = createTemporaryObject(settingsViewComponent, settingsWindow.contentItem) + verify(view !== null) + view.selectSection("mempool") + + const transactionsRow = findChild(view, "settingsv2MempoolTransactionsRow") + const memoryUsedRow = findChild(view, "settingsv2MempoolMemoryUsedRow") + const sizeLimitRow = findChild(view, "settingsv2MempoolSizeLimitRow") + const sizeLimitInput = findChild(view, "settingsv2MempoolSizeLimitInput") + + verify(transactionsRow !== null) + verify(memoryUsedRow !== null) + verify(sizeLimitRow !== null) + verify(sizeLimitInput !== null) + compare(transactionsRow.titleTextStyle.font.pixelSize, Theme.text.description.font.pixelSize) + compare(transactionsRow.valueTextStyle.font.pixelSize, Theme.text.description.font.pixelSize) + compare(memoryUsedRow.valueTextStyle.font.pixelSize, Theme.text.description.font.pixelSize) + verify(findChild(view, "mempoolTransactionsRow") === null) + + sizeLimitRow.text = "512" + sizeLimitRow.editingFinished() + compare(optionsModel.maxMempoolSizeMB, 512) + compare(sizeLimitRow.errorText, "") + + sizeLimitRow.text = "0" + sizeLimitRow.editingFinished() + compare(optionsModel.maxMempoolSizeMB, 512) + verify(sizeLimitRow.errorText.length > 0) + } + + function test_connectionProxyPageUsesRedesignedFormAndDraftCommit() { + const view = createTemporaryObject(settingsViewComponent, settingsWindow.contentItem) + verify(view !== null) + view.selectSection("connection") + + const proxySettingsRow = findChild(view, "settingsv2ProxySettingsRow") + verify(proxySettingsRow !== null) + proxySettingsRow.clicked() + tryCompare(view.pageContainer, "depth", 2) + + const proxyPage = findChild(view, "settingsv2ProxySettingsPage") + const defaultProxySection = findChild(view, "settingsv2DefaultProxySection") + const torProxySection = findChild(view, "settingsv2TorProxySection") + const proxySwitch = findChild(view, "settingsv2ProxyEnableSwitch") + const proxyAddressRow = findChild(view, "settingsv2ProxyAddressRow") + const saveButton = findChild(view, "settingsv2ProxySettingsSaveButton") + + verify(proxyPage !== null) + verify(defaultProxySection !== null) + verify(torProxySection !== null) + verify(proxySwitch !== null) + verify(proxyAddressRow !== null) + verify(saveButton !== null) + compare(saveButton.text, "Save") + compare(saveButton.enabled, false) + compare(proxyAddressRow.titleTextStyle.font.pixelSize, Theme.text.description.font.pixelSize) + compare(proxyAddressRow.enabled, false) + + proxySwitch.checked = true + proxySwitch.toggled() + compare(proxyPage.draftProxyEnabled, true) + compare(proxyAddressRow.enabled, true) + + proxySwitch.checked = false + proxySwitch.toggled() + compare(proxyPage.proxyDraftDirty, false) + compare(saveButton.enabled, false) + + proxySwitch.checked = true + proxySwitch.toggled() + compare(proxyPage.proxyDraftDirty, true) + compare(saveButton.enabled, true) + + proxyAddressRow.text = "" + proxyAddressRow.textEdited(proxyAddressRow.text) + verify(proxyPage.draftProxyValidationError.length > 0) + compare(saveButton.enabled, false) + + proxyAddressRow.text = "10.0.0.1:9050" + proxyAddressRow.textEdited(proxyAddressRow.text) + compare(proxyPage.proxyDraftDirty, true) + compare(proxyPage.draftProxyValidationError, "") + compare(saveButton.enabled, true) + + proxyPage.back() + tryCompare(view.pageContainer, "depth", 2) + const discardPopup = findChild(settingsWindow.contentItem, "settingsv2DiscardProxyChangesPopup") + verify(discardPopup !== null) + tryCompare(discardPopup, "opened", true) + const cancelButton = findChild(settingsWindow.contentItem, "settingsv2DiscardProxyChangesCancelButton") + verify(cancelButton !== null) + cancelButton.clicked() + tryCompare(discardPopup, "opened", false) + + saveButton.clicked() + tryCompare(view.pageContainer, "depth", 1) + const proxySetting = optionsModel.coreSettings.entry("proxy") + compare(proxySetting.enabled, true) + compare(proxySetting.address, "10.0.0.1:9050") + } + + function test_settingsViewCanInstantiateEveryVisibleTopLevelDestination() { + const view = createTemporaryObject(settingsViewComponent, settingsWindow.contentItem) + verify(view !== null) + + const destinations = [ + { id: "wallet", objectName: "settingsv2WalletSettingsPage" }, + { id: "external-signer", objectName: "settingsv2ExternalSignerSettingsPage" }, + { id: "display", objectName: "settingsv2DisplaySettingsPage" }, + { id: "window-behavior", objectName: "settingsv2WindowBehaviorSettingsPage" }, + { id: "storage", objectName: "settingsv2StorageSettingsPage" }, + { id: "connection", objectName: "settingsv2ConnectionSettingsPage" }, + { id: "network-traffic", objectName: "settingsv2NetworkTrafficSettingsPage" }, + { id: "mempool", objectName: "settingsv2MempoolSettingsPage" }, + { id: "debug-log", objectName: "settingsDebugLog" }, + { id: "about", objectName: "settingsv2AboutSettingsPage" } + ] + + for (let index = 0; index < destinations.length; ++index) { + const destination = destinations[index] + view.selectSection(destination.id, true) + compare(view.selectedSectionId, destination.id) + tryCompare(view.pageContainer, "depth", 1) + verify(findChild(view, destination.objectName) !== null, + "Expected instantiated destination " + destination.id) + if (destination.id === "wallet") { + const walletInfoSection = findChild(view, "settingsv2WalletInfoSection") + const walletActionsSection = findChild(view, "settingsv2WalletActionsSection") + verify(walletInfoSection !== null) + verify(walletActionsSection !== null) + compare(walletInfoSection.title, "Wallet info") + compare(walletActionsSection.title, "Wallet actions") + } + if (destination.id === "external-signer") { + const signerPage = findChild(view, "settingsv2ExternalSignerSettingsPage") + const introduction = findChild(view, "settingsv2ExternalSignerIntroduction") + const signerSection = findChild(view, "settingsv2ExternalSignerPathSection") + const signerPathRow = findChild(view, "settingsv2ExternalSignerPathRow") + const signerFooter = findChild(view, "settingsv2ExternalSignerPathSectionFooter") + const signerPathInput = findChild(view, "externalSignerPathInput") + const signerPathFocusBorder = findChild(view, "externalSignerPathFocusBorder") + const signerStatusIndicator = findChild(view, "externalSignerStatusIndicator") + const signerStatusText = findChild(view, "externalSignerStatusText") + const checkDeviceButton = findChild(view, "externalSignerCheckDeviceButton") + verify(signerPage !== null) + verify(introduction !== null) + verify(signerSection !== null) + verify(signerPathRow !== null) + verify(signerFooter !== null) + verify(signerPathInput !== null) + verify(signerPathFocusBorder !== null) + verify(signerStatusIndicator !== null) + verify(signerStatusText !== null) + verify(checkDeviceButton !== null) + compare(introduction.title, "") + compare(introduction.description, "Connect a hardware wallet or another external signing tool.") + compare(signerPage.maximumContentWidth, 840) + compare(signerSection.title, "Signer path") + compare(signerPathRow.topPadding, 16) + compare(signerPathRow.bottomPadding, 16) + compare(signerFooter.text, + "The add wallet flow can offer external wallets when exactly one supported signer is connected.") + compare(signerStatusIndicator.color, Theme.color.red) + compare(checkDeviceButton.text, "Check device") + compare(signerPathInput.implicitHeight, 37) + compare(signerPathInput.leftPadding, 15) + compare(signerPathInput.rightPadding, 10) + compare(signerPathInput.background.color, Theme.color.neutral2) + compare(signerPathInput.background.radius, 5) + compare(signerPathFocusBorder.border.color, Theme.color.orange) + compare(signerStatusIndicator.width, 10) + compare(signerStatusIndicator.height, 10) + } + if (destination.id === "about") { + const versionRow = findChild(view, "settingsv2AboutVersionRow") + const versionValue = findChild(view, "settingsv2AboutVersionRowValue") + verify(versionRow !== null) + verify(versionValue !== null) + compare(versionRow.value, BuildInfo.fullClientVersion) + compare(versionValue.text, BuildInfo.fullClientVersion) + verify(versionValue.text.length > 0) + } + } + } +} From 3eeda449b99d78cea1065ee1d079288f12bf2084 Mon Sep 17 00:00:00 2001 From: pseudoramdom Date: Tue, 18 Aug 2026 15:18:41 -0700 Subject: [PATCH 09/14] qml: group related settings sidebar items Add a prominent Settings heading and organize sidebar destinations under Wallet, General, Network, and Advanced group labels. --- qml/components/SettingsSidebar.qml | 47 +++++++++++++++++++++++++++-- qml/components/SettingsView.qml | 33 ++++++++++++++++---- test/qml/tst_settingsnavigation.qml | 40 ++++++++++++++++++++++-- 3 files changed, 109 insertions(+), 11 deletions(-) diff --git a/qml/components/SettingsSidebar.qml b/qml/components/SettingsSidebar.qml index f0546de8a3..b9bb782098 100644 --- a/qml/components/SettingsSidebar.qml +++ b/qml/components/SettingsSidebar.qml @@ -15,9 +15,11 @@ Control { id: root property var model: [] + property var groupTitles: ({}) property string currentSectionId: "" property int rowHeight: 36 property int groupSpacing: 16 + property int groupTitleHeight: 25 property int cornerRadius: 8 property color selectedBackgroundColor: Qt.rgba(Theme.color.orange.r, Theme.color.orange.g, Theme.color.orange.b, 0.15) property color hoverBackgroundColor: Theme.color.neutral2 @@ -45,6 +47,12 @@ Control { return result } + function titleForGroup(groupId) { + if (!root.groupTitles) return "" + const title = root.groupTitles[groupId] + return title === undefined || title === null ? "" : String(title) + } + background: null padding: 0 implicitWidth: 190 @@ -63,11 +71,44 @@ Control { required property var modelData required property int index - readonly property bool startsGroup: delegate.index > 0 - && root.visibleSections[delegate.index - 1].group !== delegate.modelData.group + readonly property bool startsGroup: delegate.index === 0 + || root.visibleSections[delegate.index - 1].group !== delegate.modelData.group + readonly property string groupTitle: delegate.startsGroup + ? root.titleForGroup(delegate.modelData.group) + : "" + readonly property bool showsGroupTitle: delegate.groupTitle.length > 0 + readonly property int groupOffset: (delegate.showsGroupTitle ? root.groupTitleHeight : 0) + + (delegate.startsGroup && delegate.index > 0 ? root.groupSpacing : 0) width: sectionList.width - height: root.rowHeight + (delegate.startsGroup ? root.groupSpacing : 0) + height: root.rowHeight + delegate.groupOffset + + CoreText { + objectName: delegate.showsGroupTitle + ? "settingsSidebarGroup_" + delegate.modelData.group + : "" + visible: delegate.showsGroupTitle + anchors { + top: parent.top + left: parent.left + right: parent.right + topMargin: delegate.index > 0 ? root.groupSpacing : 0 + leftMargin: 10 + rightMargin: 10 + } + height: Theme.text.caption.lineHeight + text: delegate.groupTitle + color: Theme.color.neutral6 + font.family: Theme.text.caption.family + font.pixelSize: Theme.text.caption.pixelSize + fontStyleName: "Semi Bold" + lineHeight: Theme.text.caption.lineHeight + lineHeightMode: Text.FixedHeight + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + Accessible.ignored: true + } AbstractButton { id: button diff --git a/qml/components/SettingsView.qml b/qml/components/SettingsView.qml index 0791683409..e28eeef259 100644 --- a/qml/components/SettingsView.qml +++ b/qml/components/SettingsView.qml @@ -28,10 +28,16 @@ Page { property int sidebarWidth: 286 readonly property alias sidebar: sidebar readonly property alias pageContainer: pageContainer + readonly property var groupTitles: ({ + "wallet": qsTr("Wallet"), + "general": qsTr("General"), + "network": qsTr("Network"), + "advanced": qsTr("Advanced") + }) readonly property var sections: [ { id: "wallet", - label: qsTr("Wallet"), + label: qsTr("Wallet settings"), group: "wallet", visible: AppMode.walletEnabled, pageComponent: walletPage @@ -46,20 +52,20 @@ Page { { id: "display", label: qsTr("Display"), - group: "display", + group: "general", pageComponent: displayPage }, { id: "window-behavior", label: qsTr("Window behavior"), - group: "display", + group: "general", visible: AppMode.isDesktop, pageComponent: windowBehaviorPage }, { id: "storage", label: qsTr("Storage"), - group: "display", + group: "general", pageComponent: storagePage }, { @@ -77,14 +83,14 @@ Page { { id: "mempool", label: qsTr("Mempool information"), - group: "network", + group: "advanced", visible: nodeModel.mempoolInformationAvailable, pageComponent: mempoolPage }, { id: "debug-log", label: qsTr("Debug log"), - group: "developer", + group: "advanced", pageComponent: debugLogPage }, { @@ -196,12 +202,27 @@ Page { anchors.bottomMargin: 16 spacing: 0 + CoreText { + objectName: "settingsv2SettingsSidebarHeading" + Layout.fillWidth: true + Layout.leftMargin: 10 + Layout.rightMargin: 10 + Layout.bottomMargin: 24 + text: qsTr("Settings") + color: Theme.color.neutral9 + font: Theme.text.display.font + lineHeight: Theme.text.display.lineHeight + lineHeightMode: Text.FixedHeight + horizontalAlignment: Text.AlignLeft + } + SettingsSidebar { id: sidebar objectName: "settingsv2SettingsSidebar" Layout.fillWidth: true Layout.fillHeight: true model: root.sections + groupTitles: root.groupTitles currentSectionId: root.selectedSectionId onSectionActivated: function(sectionId) { root.selectSection(sectionId) } } diff --git a/test/qml/tst_settingsnavigation.qml b/test/qml/tst_settingsnavigation.qml index 985f98a47d..59a4929c1a 100644 --- a/test/qml/tst_settingsnavigation.qml +++ b/test/qml/tst_settingsnavigation.qml @@ -55,10 +55,15 @@ TestCase { width: 190 height: 300 currentSectionId: "display" + groupTitles: ({ + "wallet": "Wallet", + "general": "General", + "network": "Network" + }) model: [ { id: "wallet", label: "Wallet", group: "wallet" }, - { id: "display", label: "Display", group: "display" }, - { id: "hidden", label: "Hidden", group: "display", visible: false }, + { id: "display", label: "Display", group: "general" }, + { id: "hidden", label: "Hidden", group: "general", visible: false }, { id: "connection", label: "Connection", group: "network" } ] } @@ -138,6 +143,16 @@ TestCase { verify(findChild(sidebar, "settingsSidebar_wallet") !== null) verify(findChild(sidebar, "settingsSidebar_display") !== null) verify(findChild(sidebar, "settingsSidebar_hidden") === null) + const walletGroup = findChild(sidebar, "settingsSidebarGroup_wallet") + const generalGroup = findChild(sidebar, "settingsSidebarGroup_general") + const networkGroup = findChild(sidebar, "settingsSidebarGroup_network") + verify(walletGroup !== null) + verify(generalGroup !== null) + verify(networkGroup !== null) + compare(walletGroup.text, "Wallet") + compare(generalGroup.text, "General") + compare(networkGroup.text, "Network") + compare(walletGroup.font.styleName, "Semi Bold") let activatedSection = "" sidebar.sectionActivated.connect(function(sectionId) { @@ -310,13 +325,18 @@ TestCase { verify(view !== null) const sidebarSurface = findChild(view, "settingsv2SettingsSidebarSurface") + const sidebarHeading = findChild(view, "settingsv2SettingsSidebarHeading") const displayPage = findChild(view, "settingsv2DisplaySettingsPage") verify(sidebarSurface !== null) + verify(sidebarHeading !== null) verify(displayPage !== null) tryCompare(sidebarSurface, "x", 0) tryCompare(sidebarSurface, "width", view.sidebarWidth) compare(sidebarSurface.color, Theme.color.neutral1) + compare(sidebarHeading.text, "Settings") + compare(sidebarHeading.font.pixelSize, Theme.text.display.font.pixelSize) + compare(sidebarHeading.horizontalAlignment, Text.AlignLeft) tryCompare(view.pageContainer, "x", view.sidebarWidth) tryCompare(view.pageContainer, "width", view.width - view.sidebarWidth) @@ -350,6 +370,22 @@ TestCase { verify(debugLogContent.width > 840) } + function test_settingsViewGroupsRelatedDestinations() { + const view = createTemporaryObject(settingsViewComponent, settingsWindow.contentItem) + verify(view !== null) + + compare(view.sectionForId("wallet").label, "Wallet settings") + compare(view.sectionForId("wallet").group, "wallet") + compare(view.sectionForId("storage").group, "general") + compare(view.sectionForId("network-traffic").group, "network") + compare(view.sectionForId("mempool").group, "advanced") + compare(view.sectionForId("debug-log").group, "advanced") + compare(view.groupTitles.wallet, "Wallet") + compare(view.groupTitles.general, "General") + compare(view.groupTitles.network, "Network") + compare(view.groupTitles.advanced, "Advanced") + } + function test_displayPageUsesInlineGenericPickers() { const view = createTemporaryObject(settingsViewComponent, settingsWindow.contentItem) verify(view !== null) From 0189d83ab6e7d9bcde03df79d05510bc5ea84193 Mon Sep 17 00:00:00 2001 From: pseudoramdom Date: Tue, 18 Aug 2026 15:20:27 -0700 Subject: [PATCH 10/14] qml: move RPC console to redesigned settings --- qml/bitcoin_qml.qrc | 1 + qml/components/SettingsView.qml | 17 ++++++ .../settingsv2/RpcConsoleSettingsPage.qml | 54 +++++++++++++++++++ test/qml/tst_settingsnavigation.qml | 24 +++++++++ 4 files changed, 96 insertions(+) create mode 100644 qml/pages/settings/settingsv2/RpcConsoleSettingsPage.qml diff --git a/qml/bitcoin_qml.qrc b/qml/bitcoin_qml.qrc index b473cbba8d..c07367efb4 100644 --- a/qml/bitcoin_qml.qrc +++ b/qml/bitcoin_qml.qrc @@ -153,6 +153,7 @@ pages/settings/settingsv2/MempoolSettingsPage.qml pages/settings/settingsv2/NetworkTrafficSettingsPage.qml pages/settings/settingsv2/ProxySettingsPage.qml + pages/settings/settingsv2/RpcConsoleSettingsPage.qml pages/settings/settingsv2/StorageSettingsPage.qml pages/settings/settingsv2/WalletSectionPage.qml pages/settings/settingsv2/WindowBehaviorSettingsPage.qml diff --git a/qml/components/SettingsView.qml b/qml/components/SettingsView.qml index e28eeef259..a244f98080 100644 --- a/qml/components/SettingsView.qml +++ b/qml/components/SettingsView.qml @@ -87,6 +87,13 @@ Page { visible: nodeModel.mempoolInformationAvailable, pageComponent: mempoolPage }, + { + id: "rpc-console", + label: qsTr("RPC console"), + group: "advanced", + visible: AppMode.isDesktop, + pageComponent: rpcConsolePage + }, { id: "debug-log", label: qsTr("Debug log"), @@ -329,6 +336,16 @@ Page { SettingsV2.MempoolSettingsPage {} } + Component { + id: rpcConsolePage + + SettingsV2.RpcConsoleSettingsPage { + walletName: walletController.isWalletLoaded && walletController.selectedWallet + ? walletController.selectedWallet.name + : "" + } + } + Component { id: debugLogPage diff --git a/qml/pages/settings/settingsv2/RpcConsoleSettingsPage.qml b/qml/pages/settings/settingsv2/RpcConsoleSettingsPage.qml new file mode 100644 index 0000000000..0a045d02c0 --- /dev/null +++ b/qml/pages/settings/settingsv2/RpcConsoleSettingsPage.qml @@ -0,0 +1,54 @@ +pragma ComponentBehavior: Bound + +// 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: "settingsv2RpcConsoleSettingsPage" + + 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: "settingsv2RpcConsoleHeader" + 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: "settingsv2RpcConsole" + anchors.fill: parent + showHeader: false + tabActive: root.visible + walletName: root.walletName + } + } +} diff --git a/test/qml/tst_settingsnavigation.qml b/test/qml/tst_settingsnavigation.qml index 59a4929c1a..85cc2cec61 100644 --- a/test/qml/tst_settingsnavigation.qml +++ b/test/qml/tst_settingsnavigation.qml @@ -379,6 +379,7 @@ TestCase { compare(view.sectionForId("storage").group, "general") compare(view.sectionForId("network-traffic").group, "network") compare(view.sectionForId("mempool").group, "advanced") + compare(view.sectionForId("rpc-console").group, "advanced") compare(view.sectionForId("debug-log").group, "advanced") compare(view.groupTitles.wallet, "Wallet") compare(view.groupTitles.general, "General") @@ -386,6 +387,28 @@ TestCase { compare(view.groupTitles.advanced, "Advanced") } + function test_rpcConsoleUsesSettingsContainerAndTracksVisibility() { + const view = createTemporaryObject(settingsViewComponent, settingsWindow.contentItem) + verify(view !== null) + view.selectSection("rpc-console") + + const page = findChild(view, "settingsv2RpcConsoleSettingsPage") + const header = findChild(view, "settingsv2RpcConsoleHeader") + const rpcConsole = findChild(view, "settingsv2RpcConsole") + verify(page !== null) + verify(header !== null) + verify(rpcConsole !== null) + compare(header.title, "RPC console") + compare(header.showBackButton, false) + compare(page.maximumContentWidth, 840) + verify(page.contentHorizontalPadding >= 24) + compare(rpcConsole.showHeader, false) + compare(rpcConsole.tabActive, true) + + view.selectSection("about") + tryCompare(rpcConsole, "tabActive", false) + } + function test_displayPageUsesInlineGenericPickers() { const view = createTemporaryObject(settingsViewComponent, settingsWindow.contentItem) verify(view !== null) @@ -582,6 +605,7 @@ TestCase { { id: "connection", objectName: "settingsv2ConnectionSettingsPage" }, { id: "network-traffic", objectName: "settingsv2NetworkTrafficSettingsPage" }, { id: "mempool", objectName: "settingsv2MempoolSettingsPage" }, + { id: "rpc-console", objectName: "settingsv2RpcConsoleSettingsPage" }, { id: "debug-log", objectName: "settingsDebugLog" }, { id: "about", objectName: "settingsv2AboutSettingsPage" } ] From 61feab1deb73b818a1c2235767c53186fdcbead6 Mon Sep 17 00:00:00 2001 From: pseudoramdom Date: Tue, 18 Aug 2026 21:58:44 -0700 Subject: [PATCH 11/14] qml: delete legacy settings pages Delete unused legacy settings pages and tests, update navigation and resource wiring, and migrate functional coverage to Settings v2. --- qml/bitcoin_qml.qrc | 12 - qml/components/BlockClockDisplayMode.qml | 41 -- qml/components/SettingsView.qml | 5 +- qml/components/ThemeSettings.qml | 75 ---- qml/components/WalletSettings.qml | 140 ------- qml/pages/MainWindow.qml | 19 +- qml/pages/node/MempoolInformationSettings.qml | 56 --- qml/pages/node/NodeRunner.qml | 11 - qml/pages/node/NodeSettings.qml | 299 --------------- .../SettingsBlockClockDisplayMode.qml | 28 -- qml/pages/settings/SettingsDisplay.qml | 264 ------------- qml/pages/settings/SettingsDisplayUnit.qml | 68 ---- qml/pages/settings/SettingsTheme.qml | 30 -- qml/pages/settings/SettingsWallet.qml | 51 --- qml/pages/settings/SettingsWindowBehavior.qml | 89 ----- .../settings/settingsv2/AboutSettingsPage.qml | 1 + .../settingsv2/DisplaySettingsPage.qml | 20 +- .../settings/settingsv2/WalletSectionPage.qml | 4 + qml/pages/wallet/DesktopWallets.qml | 86 ++--- qml/pages/wallet/WalletSettings.qml | 352 ------------------ test/functional/qml_driver.py | 4 +- .../qml_test_activity_filter_export.py | 12 +- test/functional/qml_test_addresses.py | 8 +- .../qml_test_blocksonly_settings.py | 10 +- test/functional/qml_test_console.py | 65 ++-- test/functional/qml_test_debug_log.py | 9 +- .../functional/qml_test_disablewallet_boot.py | 60 ++- test/functional/qml_test_external_signer.py | 33 +- test/functional/qml_test_password_wallet.py | 10 +- test/functional/qml_test_peers.py | 20 +- test/functional/qml_test_proxy.py | 187 +++++----- test/functional/qml_test_settings_display.py | 317 ++++------------ test/functional/qml_test_tray.py | 48 +-- test/functional/qml_test_wallet_settings.py | 50 +-- test/qml/bitcoin_qmltests.qrc | 6 - test/qml/qml_tests_main.cpp | 2 +- test/qml/tst_desktopwallets.qml | 81 ++-- test/qml/tst_displaysettings.qml | 204 ---------- test/qml/tst_formcontrols.qml | 1 - test/qml/tst_mempoolinformationsettings.qml | 82 ---- test/qml/tst_nodefeedback.qml | 1 + test/qml/tst_nodesettings.qml | 253 ------------- test/qml/tst_settingsnavigation.qml | 72 +++- test/qml/tst_settingswallet.qml | 56 --- test/qml/tst_settingswindowbehavior.qml | 72 ---- test/qml/tst_walletsettings.qml | 77 ---- 46 files changed, 474 insertions(+), 2917 deletions(-) delete mode 100644 qml/components/BlockClockDisplayMode.qml delete mode 100644 qml/components/ThemeSettings.qml delete mode 100644 qml/components/WalletSettings.qml delete mode 100644 qml/pages/node/MempoolInformationSettings.qml delete mode 100644 qml/pages/node/NodeSettings.qml delete mode 100644 qml/pages/settings/SettingsBlockClockDisplayMode.qml delete mode 100644 qml/pages/settings/SettingsDisplay.qml delete mode 100644 qml/pages/settings/SettingsDisplayUnit.qml delete mode 100644 qml/pages/settings/SettingsTheme.qml delete mode 100644 qml/pages/settings/SettingsWallet.qml delete mode 100644 qml/pages/settings/SettingsWindowBehavior.qml delete mode 100644 qml/pages/wallet/WalletSettings.qml delete mode 100644 test/qml/tst_displaysettings.qml delete mode 100644 test/qml/tst_mempoolinformationsettings.qml delete mode 100644 test/qml/tst_nodesettings.qml delete mode 100644 test/qml/tst_settingswallet.qml delete mode 100644 test/qml/tst_settingswindowbehavior.qml delete mode 100644 test/qml/tst_walletsettings.qml diff --git a/qml/bitcoin_qml.qrc b/qml/bitcoin_qml.qrc index c07367efb4..9b06cf88f6 100644 --- a/qml/bitcoin_qml.qrc +++ b/qml/bitcoin_qml.qrc @@ -10,7 +10,6 @@ components/BitcoinAmountDisplayField.qml components/BitcoinAmountInputField.qml components/BlockClock.qml - components/BlockClockDisplayMode.qml components/BlockCounter.qml components/ConnectionOptions.qml components/ConnectionSettings.qml @@ -41,7 +40,6 @@ components/Separator.qml components/StorageOptions.qml components/StorageSettings.qml - components/ThemeSettings.qml components/ToastBanner.qml components/ToastPopup.qml components/TotalBytesIndicator.qml @@ -53,7 +51,6 @@ components/LabeledValueField.qml components/MultipleRecipientsSummary.qml components/SingleRecipientSummary.qml - components/WalletSettings.qml components/WalletMigrationPopup.qml components/WalletPassphrasePopup.qml controls/AddWalletButton.qml @@ -119,9 +116,7 @@ pages/node/BannedPeers.qml pages/node/CommandConsole.qml pages/node/NetworkTraffic.qml - pages/node/MempoolInformationSettings.qml pages/node/NodeRunner.qml - pages/node/NodeSettings.qml pages/node/Peers.qml pages/node/PeerDetails.qml pages/node/Shutdown.qml @@ -133,19 +128,13 @@ pages/onboarding/OnboardingStrengthen.qml pages/onboarding/OnboardingWizard.qml pages/settings/SettingsAbout.qml - pages/settings/SettingsDisplayUnit.qml pages/settings/SettingsLanguage.qml - pages/settings/SettingsWindowBehavior.qml - pages/settings/SettingsBlockClockDisplayMode.qml pages/settings/SettingsConnection.qml pages/settings/SettingsDebugLog.qml pages/settings/SettingsDesignSystem.qml pages/settings/SettingsDeveloper.qml - pages/settings/SettingsDisplay.qml pages/settings/SettingsProxy.qml - pages/settings/SettingsWallet.qml pages/settings/SettingsStorage.qml - pages/settings/SettingsTheme.qml pages/settings/settingsv2/AboutSettingsPage.qml pages/settings/settingsv2/ConnectionSettingsPage.qml pages/settings/settingsv2/DisplaySettingsPage.qml @@ -186,7 +175,6 @@ pages/wallet/SignVerifyMessage.qml pages/wallet/WalletBadge.qml pages/wallet/WalletPasswordSettings.qml - pages/wallet/WalletSettings.qml pages/wallet/WalletSelect.qml diff --git a/qml/components/BlockClockDisplayMode.qml b/qml/components/BlockClockDisplayMode.qml deleted file mode 100644 index cb3d4797a0..0000000000 --- a/qml/components/BlockClockDisplayMode.qml +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) 2023 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" - -ColumnLayout { - id: root - spacing: 15 - - ButtonGroup { - id: group - } - - OptionButton { - Layout.fillWidth: true - ButtonGroup.group: group - text: qsTr("Compact") - description: qsTr("For personal use on a computer or smartphone.") - image: "image://images/blockclock-size-compact" - checked: Theme.blockclocksize == (1/3) - onClicked: { - Theme.blockclocksize = (1/3) - } - } - - OptionButton { - Layout.fillWidth: true - ButtonGroup.group: group - text: qsTr("Showcase") - description: qsTr("A larger block clock for public display on a tablet or other large screen.") - image: "image://images/blockclock-size-showcase" - checked: Theme.blockclocksize == (1/2) - onClicked: { - Theme.blockclocksize = (1/2) - } - } -} diff --git a/qml/components/SettingsView.qml b/qml/components/SettingsView.qml index a244f98080..111675a027 100644 --- a/qml/components/SettingsView.qml +++ b/qml/components/SettingsView.qml @@ -142,7 +142,7 @@ Page { if (forceReload === true) pageContainer.clear() root.selectedSectionId = resolvedId - if (root.visible) pageContainer.showSection(resolvedId, root.componentForSection(resolvedId)) + pageContainer.showSection(resolvedId, root.componentForSection(resolvedId)) } function ensureVisibleSelection() { @@ -340,7 +340,8 @@ Page { id: rpcConsolePage SettingsV2.RpcConsoleSettingsPage { - walletName: walletController.isWalletLoaded && walletController.selectedWallet + walletName: typeof walletController !== "undefined" + && walletController.isWalletLoaded && walletController.selectedWallet ? walletController.selectedWallet.name : "" } diff --git a/qml/components/ThemeSettings.qml b/qml/components/ThemeSettings.qml deleted file mode 100644 index 9be3a8adcc..0000000000 --- a/qml/components/ThemeSettings.qml +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) 2023 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" - -ColumnLayout { - id: root - spacing: 4 - - signal designSystemRequested - - AppSettings { - id: settings - } - - Setting { - Layout.fillWidth: true - header: qsTr("Light") - actionItem: Icon { - anchors.centerIn: parent - visible: !Theme.dark - source: "image://images/check" - color: Theme.color.neutral9 - size: 24 - } - onClicked: { - Theme.dark = false - } - } - Separator { Layout.fillWidth: true } - Setting { - Layout.fillWidth: true - header: qsTr("Dark") - actionItem: Icon { - anchors.centerIn: parent - visible: Theme.dark - source: "image://images/check" - color: Theme.color.neutral9 - size: 24 - } - onClicked: { - Theme.dark = true; - } - } - CoreText { - Layout.topMargin: 36 - Layout.fillWidth: true - Layout.leftMargin: 4 - visible: BuildInfo.isDebug - horizontalAlignment: Text.AlignLeft - bold: true - font.pixelSize: 13 - color: Theme.color.neutral7 - text: qsTr("Developer") - } - Separator { - Layout.fillWidth: true - visible: BuildInfo.isDebug - } - Setting { - id: gotoDesignSystem - Layout.fillWidth: true - visible: BuildInfo.isDebug - header: qsTr("Design system") - actionItem: CaretRightIcon { - color: gotoDesignSystem.stateColor - } - onClicked: root.designSystemRequested() - } -} diff --git a/qml/components/WalletSettings.qml b/qml/components/WalletSettings.qml deleted file mode 100644 index 3da2e40076..0000000000 --- a/qml/components/WalletSettings.qml +++ /dev/null @@ -1,140 +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 "../controls" - -ColumnLayout { - id: root - - spacing: 0 - readonly property var signerStatus: (optionsModel.coreSettingStatuses || ({})).signer || ({}) - readonly property string signerPathError: optionsModel.externalSignerPathValidationError(signerPathInput.text) - - Component.onCompleted: walletController.refreshExternalSignerStatus() - - function commitSignerPath() { - if (signerPathError.length > 0) { - return false - } - const normalizedPath = signerPathInput.text.trim() - if (normalizedPath !== optionsModel.externalSignerPath) { - optionsModel.externalSignerPath = normalizedPath - } - return true - } - - CoreText { - Layout.topMargin: 16 - Layout.fillWidth: true - text: qsTr("Signer path") - font.pixelSize: 15 - color: Theme.color.neutral9 - } - - CoreTextField { - id: signerPathInput - objectName: "externalSignerPathInput" - Layout.topMargin: 8 - Layout.fillWidth: true - placeholderText: qsTr("Enter external signer path") - text: optionsModel.externalSignerPath - enabled: root.signerStatus.canEdit !== false - onEditingFinished: { - if (root.commitSignerPath()) { - walletController.refreshExternalSignerStatus() - } - } - } - - CoreText { - visible: root.signerPathError.length > 0 || (root.signerStatus.infoText || "").length > 0 - Layout.topMargin: 10 - Layout.fillWidth: true - wrapMode: Text.WordWrap - color: root.signerPathError.length > 0 ? Theme.color.red : Theme.color.neutral7 - text: root.signerPathError.length > 0 ? root.signerPathError : (root.signerStatus.infoText || "") - } - - CoreText { - Layout.topMargin: root.signerPathError.length > 0 ? 6 : 10 - Layout.fillWidth: true - wrapMode: Text.WordWrap - text: qsTr("The add wallet flow can offer external wallets when exactly one supported signer is connected.") - font.pixelSize: 15 - color: Theme.color.neutral7 - } - - Rectangle { - Layout.topMargin: 16 - Layout.fillWidth: true - radius: 5 - color: Qt.rgba(Theme.color.neutral2.r, Theme.color.neutral2.g, Theme.color.neutral2.b, 0.5) - implicitHeight: statusRow.implicitHeight + 20 - - RowLayout { - id: statusRow - anchors.fill: parent - anchors.margins: 10 - spacing: 8 - - Icon { - source: root.signerPathError.length > 0 - ? "image://images/error" - : walletController.canCreateExternalSignerWallet - ? "image://images/green-check" - : "image://images/info-filled" - color: root.signerPathError.length > 0 - ? Theme.color.red - : walletController.canCreateExternalSignerWallet - ? Theme.color.green - : Theme.color.neutral9 - size: 16 - Layout.alignment: Qt.AlignVCenter - } - - CoreText { - objectName: "externalSignerStatusText" - Layout.fillWidth: true - wrapMode: Text.WordWrap - color: Theme.color.neutral9 - text: { - if (root.signerPathError.length > 0) { - return root.signerPathError - } - if (walletController.canCreateExternalSignerWallet) { - return qsTr("Detected external signer: %1").arg(walletController.externalSignerName) - } - if (walletController.externalSignerError.length > 0) { - return walletController.externalSignerError - } - if (optionsModel.walletSettingsDirty) { - return qsTr("Path updated. Press Check device to rescan with the current signer command.") - } - if (optionsModel.externalSignerPath.length > 0) { - return qsTr("No external signer is currently detected.") - } - return qsTr("Set the command path for HWI or another external signer tool.") - } - } - } - } - - ContinueButton { - objectName: "externalSignerCheckDeviceButton" - Layout.topMargin: 20 - Layout.preferredWidth: Math.min(300, parent.width) - Layout.alignment: Qt.AlignHCenter - text: qsTr("Check device") - enabled: root.signerPathError.length === 0 && root.signerStatus.canEdit !== false - onClicked: { - if (root.commitSignerPath()) { - walletController.refreshExternalSignerStatus() - } - } - } -} diff --git a/qml/pages/MainWindow.qml b/qml/pages/MainWindow.qml index 22016968fd..2c4f6b16c1 100644 --- a/qml/pages/MainWindow.qml +++ b/qml/pages/MainWindow.qml @@ -315,23 +315,18 @@ ApplicationWindow { id: node NodeRunner { onSettingsClicked: { - nodeStack.push(nodeSettings) + nodeStack.push(settingsPage) } onPeersClicked: { peerTableModel.startAutoRefresh() nodeStack.push(peersPage) } - onConsoleClicked: { - nodeStack.push(consolePage) - } } } Component { - id: nodeSettings - NodeSettings { - onDoneClicked: { - nodeStack.pop() - } + id: settingsPage + SettingsView { + onDoneClicked: nodeStack.pop() } } Component { @@ -361,12 +356,6 @@ ApplicationWindow { onBack: nodeStack.pop() } } - Component { - id: consolePage - CommandConsole { - onBack: nodeStack.pop() - } - } } } } diff --git a/qml/pages/node/MempoolInformationSettings.qml b/qml/pages/node/MempoolInformationSettings.qml deleted file mode 100644 index 10196eb3ab..0000000000 --- a/qml/pages/node/MempoolInformationSettings.qml +++ /dev/null @@ -1,56 +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 "../../controls" -import "../../components" - -InformationPage { - id: root - objectName: "mempoolInformationSettingsPage" - property bool showBackButton: true - - showNavBar: false - header: SettingsHeader { - title: qsTr("Mempool information") - showBackButton: root.showBackButton - backButtonObjectName: "mempoolInformationBackButton" - onBack: root.back() - } - - bannerActive: false - bold: true - showHeader: false - headerText: "" - headerMargin: 0 - description: "" - descriptionMargin: 0 - detailActive: true - detailTopMargin: 0 - detailMaximumWidth: 450 - detailItem: ColumnLayout { - spacing: 4 - - SettingsRestartNotice { - objectName: "mempoolRestartNotice" - visible: optionsModel.mempoolSettingsDirty - Layout.fillWidth: true - Layout.bottomMargin: visible ? 12 : 0 - } - - MempoolInformationRows { - id: mempoolInformationRows - Layout.fillWidth: true - } - } - - Component.onCompleted: nodeModel.mempoolInfoPollingActive = visible - Component.onDestruction: nodeModel.mempoolInfoPollingActive = false - onVisibleChanged: { - nodeModel.mempoolInfoPollingActive = visible - } -} diff --git a/qml/pages/node/NodeRunner.qml b/qml/pages/node/NodeRunner.qml index d60c4b727e..0ea1ad915a 100644 --- a/qml/pages/node/NodeRunner.qml +++ b/qml/pages/node/NodeRunner.qml @@ -12,7 +12,6 @@ import "../../components" Page { signal settingsClicked signal peersClicked - signal consoleClicked id: root objectName: "nodeRunner" background: null @@ -43,16 +42,6 @@ Page { Layout.alignment: Qt.AlignVCenter onClicked: root.peersClicked() } - IconButton { - objectName: "consoleTabButton" - iconSource: "image://images/console" - iconColor: Theme.color.neutral7 - hoverColor: Theme.color.neutral9 - size: 34 - iconSize: 24 - Layout.alignment: Qt.AlignVCenter - onClicked: root.consoleClicked() - } IconButton { objectName: "nodeSettingsButton" iconSource: "image://images/gear" diff --git a/qml/pages/node/NodeSettings.qml b/qml/pages/node/NodeSettings.qml deleted file mode 100644 index 8a9a428d67..0000000000 --- a/qml/pages/node/NodeSettings.qml +++ /dev/null @@ -1,299 +0,0 @@ -// Copyright (c) 2022-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 "../wallet" -import "../settings" - -Page { - signal doneClicked - signal selectWalletRequested - signal receiveRequested - - property alias showDoneButton: doneButton.visible - - id: root - objectName: "nodeSettingsStack" - background: null - - readonly property int settingsSidebarWidth: 185 - readonly property int settingsContentWidth: 450 - readonly property int settingsContentGap: 50 - readonly property int settingsHorizontalPadding: 20 - readonly property int settingsSidebarItemHeight: 31 - readonly property int settingsSidebarGroupSpacing: 20 - readonly property int settingsBodyWidth: Math.min( - Math.max(0, width - settingsHorizontalPadding * 2), - settingsSidebarWidth + settingsContentGap + settingsContentWidth) - readonly property color settingsSidebarSelectedBackgroundColor: Qt.rgba(Theme.color.orange.r, Theme.color.orange.g, Theme.color.orange.b, 0.15) - property int currentSection: 0 - - function openWalletSettings() { - for (var i = 0; i < sidebarModel.count; i++) { - if (sidebarModel.get(i).section === "wallet") { - root.currentSection = i - return - } - } - } - - function openAddressHistory() { - if (!walletController.isWalletLoaded || !walletController.selectedWallet) { - return - } - walletController.selectedWallet.addressListModel.refresh() - openWalletSettings() - walletStack.push(addressListComp) - } - - function openWalletAddressHistory() { - root.openAddressHistory() - } - - Connections { - target: typeof walletController !== "undefined" ? walletController : null - function onOpenWalletSettingsRequested() { - root.openWalletSettings() - } - function onSelectedWalletChanged() { - if (walletStack.depth > 1) walletStack.pop(null) - } - function onIsWalletLoadedChanged() { - if (!walletController.isWalletLoaded && walletStack.depth > 1) walletStack.pop(null) - } - } - - ListModel { id: sidebarModel } - - Component.onCompleted: { - // Display order and grouping follow the desktop settings design - // (BitcoinDesign/Bitcoin-Core-App#163). Row order is kept in lockstep - // with the contentStack page order below, so a row's index is its page - // index. Peers and Console live on the main nav bar, not in settings. - sidebarModel.append({ label: qsTr("Wallet"), section: "wallet", group: "wallet", alwaysVisible: false }) - sidebarModel.append({ label: qsTr("External signer"), section: "externalsigner", group: "wallet", alwaysVisible: false }) - sidebarModel.append({ label: qsTr("Display"), section: "display", group: "display", alwaysVisible: true }) - sidebarModel.append({ label: qsTr("Window behavior"), section: "windowbehavior", group: "display", alwaysVisible: false }) - sidebarModel.append({ label: qsTr("Storage"), section: "storage", group: "display", alwaysVisible: true }) - sidebarModel.append({ label: qsTr("Connection"), section: "connection", group: "network", alwaysVisible: true }) - sidebarModel.append({ label: qsTr("Network traffic"), section: "networktraffic", group: "network", alwaysVisible: true }) - sidebarModel.append({ label: qsTr("Mempool information"), section: "mempool", group: "network", alwaysVisible: false }) - sidebarModel.append({ label: qsTr("Debug log"), section: "debuglog", group: "developer", alwaysVisible: true }) - sidebarModel.append({ label: qsTr("About"), section: "about", group: "about", alwaysVisible: true }) - root.selectFirstVisibleSection() - } - - function isSectionVisible(index) { - var item = sidebarModel.get(index) - if (item.alwaysVisible) return true - if (item.section === "wallet" || item.section === "externalsigner") - return AppMode.walletEnabled - if (item.section === "mempool") - return nodeModel.mempoolInformationAvailable - if (item.section === "windowbehavior") - return AppMode.isDesktop - return true - } - - // Land on the first visible row so node-only mode (where Wallet/External - // Signer are hidden) never opens on a hidden section. - function selectFirstVisibleSection() { - for (var i = 0; i < sidebarModel.count; i++) { - if (isSectionVisible(i)) { root.currentSection = i; return } - } - } - - // True when this row begins a new group relative to the previous *visible* - // row, so the delegate can add leading space between groups while skipping - // hidden rows. - function isFirstVisibleInGroup(index) { - var group = sidebarModel.get(index).group - for (var j = index - 1; j >= 0; j--) { - if (!isSectionVisible(j)) continue - return sidebarModel.get(j).group !== group - } - return false - } - - contentItem: Item { - RowLayout { - anchors.top: parent.top - anchors.horizontalCenter: parent.horizontalCenter - width: root.settingsBodyWidth - height: parent.height - spacing: root.settingsContentGap - - ColumnLayout { - Layout.preferredWidth: root.settingsSidebarWidth - Layout.maximumWidth: root.settingsSidebarWidth - Layout.minimumWidth: root.settingsSidebarWidth - Layout.fillWidth: false - Layout.fillHeight: true - Layout.topMargin: 25 - spacing: 0 - - Repeater { - model: sidebarModel - delegate: AbstractButton { - id: sidebarButton - objectName: "settings_" + model.section - Layout.fillWidth: true - Layout.preferredHeight: root.settingsSidebarItemHeight - Layout.topMargin: root.isFirstVisibleInGroup(index) ? root.settingsSidebarGroupSpacing : 0 - visible: root.isSectionVisible(index) - hoverEnabled: AppMode.isDesktop - focusPolicy: Qt.TabFocus - leftPadding: 10 - rightPadding: 10 - topPadding: 5 - bottomPadding: 5 - Accessible.name: model.label - Accessible.role: Accessible.ListItem - - onClicked: root.currentSection = index - - background: Rectangle { - radius: 5 - color: root.currentSection === index - ? root.settingsSidebarSelectedBackgroundColor - : sidebarButton.hovered - ? Theme.color.neutral1 - : "transparent" - Behavior on color { ColorAnimation { duration: 150 } } - - FocusBorder { - visible: sidebarButton.visualFocus - borderRadius: 7 - topMargin: -2 - bottomMargin: -2 - leftMargin: -2 - rightMargin: -2 - } - } - - contentItem: CoreText { - horizontalAlignment: Text.AlignLeft - verticalAlignment: Text.AlignVCenter - text: model.label - font.pixelSize: 15 - color: root.currentSection === index - ? Theme.color.orange - : Theme.color.neutral9 - } - - HoverHandler { - cursorShape: Qt.PointingHandCursor - } - } - } - - Item { Layout.fillHeight: true } - - NavButton { - id: doneButton - objectName: "nodeSettingsDoneButton" - text: qsTr("Done") - Layout.alignment: Qt.AlignHCenter - Layout.bottomMargin: 20 - onClicked: root.doneClicked() - } - } - - Rectangle { - Layout.preferredWidth: Math.min(root.settingsContentWidth, Math.max(0, root.settingsBodyWidth - root.settingsSidebarWidth - root.settingsContentGap)) - Layout.maximumWidth: root.settingsContentWidth - Layout.minimumWidth: 0 - Layout.fillWidth: true - Layout.fillHeight: true - color: "transparent" - clip: true - - StackLayout { - id: contentStack - anchors.fill: parent - // Content order is kept in lockstep with the sidebar row order - // so the selected row maps directly to its page. - currentIndex: root.currentSection - - PageStack { - id: walletStack - objectName: "walletSettingsStack" - initialItem: WalletSettings { - objectName: "walletSettingsPage" - // Reached from the settings sidebar, like the other - // sections, so it has no back button of its own; the - // pushed sub-pages carry theirs. Binding this to - // depth > 1 turned the back button on as soon as a - // sub-page was pushed, flashing it on this page for the - // duration of the push transition. - showBackButton: false - onBack: walletStack.pop() - onSelectWalletRequested: root.selectWalletRequested() - onPasswordRequested: walletStack.push(walletPasswordComp, { "updating": walletController.selectedWallet.isEncrypted }) - onSignVerifyMessageRequested: walletStack.push(signVerifyComp) - onAddressesRequested: { - if (walletController.isWalletLoaded && walletController.selectedWallet) { - walletController.selectedWallet.addressListModel.refresh() - walletStack.push(addressListComp) - } - } - } - Component { - id: walletPasswordComp - WalletPasswordSettings { - onBack: walletStack.pop() - onSaved: walletStack.pop() - } - } - Component { - id: signVerifyComp - SignVerifyMessage { - onBack: walletStack.pop() - } - } - Component { - id: addressListComp - AddressList { - onBack: walletStack.pop() - onReceiveRequested: { - walletStack.pop() - root.receiveRequested() - } - } - } - } - SettingsWallet { showBackButton: false } - SettingsDisplay { showBackButton: false } - SettingsWindowBehavior { showBackButton: false } - SettingsStorage { showBackButton: false } - SettingsConnection { showBackButton: false } - Loader { - id: networkTrafficLoader - objectName: "networkTrafficLoader" - active: root.visible && root.currentSection === 6 - sourceComponent: NetworkTraffic { showBackButton: false; showHeader: false } - } - MempoolInformationSettings { showBackButton: false } - Loader { - id: debugLogLoader - objectName: "settingsDebugLogLoader" - active: root.visible && root.currentSection === 8 - sourceComponent: SettingsDebugLog { showBackButton: false } - } - PageStack { - id: aboutStack - initialItem: SettingsAbout { - showBackButton: false - } - } - } - } - } - } -} diff --git a/qml/pages/settings/SettingsBlockClockDisplayMode.qml b/qml/pages/settings/SettingsBlockClockDisplayMode.qml deleted file mode 100644 index 852e2add26..0000000000 --- a/qml/pages/settings/SettingsBlockClockDisplayMode.qml +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) 2023 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" - -Page { - signal back - - id: root - background: null - leftPadding: 20 - rightPadding: 20 - topPadding: 30 - - header: SettingsHeader { - title: qsTr("Block clock display mode") - onBack: root.back() - } - BlockClockDisplayMode { - width: Math.min(parent.width, 450) - anchors.horizontalCenter: parent.horizontalCenter - } -} \ No newline at end of file diff --git a/qml/pages/settings/SettingsDisplay.qml b/qml/pages/settings/SettingsDisplay.qml deleted file mode 100644 index 8b2685af7b..0000000000 --- a/qml/pages/settings/SettingsDisplay.qml +++ /dev/null @@ -1,264 +0,0 @@ -// Copyright (c) 2023 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" - -Item { - signal back - property bool showBackButton: true - - id: root - - PageStack { - id: displaySettingsView - anchors.fill: parent - - initialItem: Page { - id: displaySettings - background: null - leftPadding: 20 - rightPadding: 20 - topPadding: 30 - - header: SettingsHeader { - title: qsTr("Display") - showBackButton: root.showBackButton - backButtonObjectName: "settingsDisplayBack" - onBack: root.back() - } - ColumnLayout { - spacing: 4 - width: Math.min(parent.width, 450) - anchors.horizontalCenter: parent.horizontalCenter - Setting { - id: gotoTheme - objectName: "gotoTheme" - Layout.fillWidth: true - header: qsTr("Theme") - actionItem: CaretRightIcon { - color: gotoTheme.stateColor - } - onClicked: { - displaySettingsView.push(theme_page) - } - } - Separator { Layout.fillWidth: true } - Setting { - id: gotoBlockClockSize - Layout.fillWidth: true - header: qsTr("Block status size") - actionItem: CaretRightIcon { - color: gotoBlockClockSize.stateColor - } - onClicked: { - displaySettingsView.push(blockclocksize_page) - } - } - Separator { Layout.fillWidth: true } - Setting { - id: gotoDisplayUnit - objectName: "gotoDisplayUnit" - Layout.fillWidth: true - header: qsTr("Display unit") - actionItem: CaretRightIcon { - color: gotoDisplayUnit.stateColor - } - onClicked: { - displaySettingsView.push(displayunit_page) - } - } - Separator { Layout.fillWidth: true } - Setting { - id: gotoLanguage - objectName: "gotoLanguage" - readonly property var settingStatus: (optionsModel.coreSettingStatuses || ({})).lang || ({}) - Layout.fillWidth: true - header: qsTr("Language") - state: settingStatus.canEdit === false ? "DISABLED" : "FILLED" - infoText: settingStatus.infoText || "" - showInfoText: infoText.length > 0 - actionItem: CaretRightIcon { - color: gotoLanguage.stateColor - } - onClicked: { - displaySettingsView.push(language_page) - } - } - Separator { Layout.fillWidth: true } - Setting { - id: gotoThirdPartyUrls - objectName: "gotoThirdPartyTransactionUrls" - Layout.fillWidth: true - header: qsTr("Third-party transaction URLs") - actionItem: CaretRightIcon { - color: gotoThirdPartyUrls.stateColor - } - onClicked: displaySettingsView.push(third_party_urls_page) - } - Separator { Layout.fillWidth: true } - Setting { - id: gotoMoneyFont - objectName: "gotoMoneyFont" - Layout.fillWidth: true - header: qsTr("Money font") - actionItem: CaretRightIcon { - color: gotoMoneyFont.stateColor - } - onClicked: displaySettingsView.push(money_font_page) - } - } - } - } - Component { - id: theme_page - SettingsTheme { - onBack: { - displaySettingsView.pop() - } - onDesignSystemRequested: { - displaySettingsView.push(design_system_page) - } - } - } - Component { - id: blockclocksize_page - SettingsBlockClockDisplayMode { - onBack: { - displaySettingsView.pop() - } - } - } - Component { - id: design_system_page - SettingsDesignSystem { - onBack: { - displaySettingsView.pop() - } - } - } - Component { - id: displayunit_page - SettingsDisplayUnit { - onBack: { - displaySettingsView.pop() - } - } - } - Component { - id: language_page - SettingsLanguage { - onBack: { - displaySettingsView.pop() - } - } - } - Component { - id: third_party_urls_page - Page { - background: null - implicitWidth: 450 - leftPadding: 20 - rightPadding: 20 - topPadding: 30 - - header: NavigationBar2 { - leftItem: NavButton { - iconSource: "image://images/caret-left" - text: qsTr("Back") - onClicked: displaySettingsView.pop() - } - centerItem: Header { - headerBold: true - headerSize: 18 - header: qsTr("Transaction URLs") - } - } - - ColumnLayout { - spacing: 15 - width: Math.min(parent.width, 450) - anchors.horizontalCenter: parent.horizontalCenter - - Header { - Layout.fillWidth: true - center: false - header: qsTr("Third-party transaction URLs") - headerSize: 18 - description: qsTr("Use %s for the transaction hash. Separate multiple URLs with |.") - descriptionSize: 15 - } - - CoreTextField { - objectName: "thirdPartyTransactionUrlsInput" - Layout.fillWidth: true - text: optionsModel.thirdPartyTransactionUrls - placeholderText: "https://example.com/tx/%s" - onEditingFinished: optionsModel.thirdPartyTransactionUrls = text - } - } - } - } - Component { - id: money_font_page - Page { - background: null - implicitWidth: 450 - leftPadding: 20 - rightPadding: 20 - topPadding: 30 - - header: NavigationBar2 { - leftItem: NavButton { - iconSource: "image://images/caret-left" - text: qsTr("Back") - onClicked: displaySettingsView.pop() - } - centerItem: Header { - headerBold: true - headerSize: 18 - header: qsTr("Money font") - } - } - - ColumnLayout { - spacing: 15 - width: Math.min(parent.width, 450) - anchors.horizontalCenter: parent.horizontalCenter - - OptionButton { - objectName: "moneyFontEmbedded" - Layout.fillWidth: true - text: qsTr("Embedded fixed-width font") - description: "111.11111111 BTC" - checked: optionsModel.moneyFontChoice === "embedded" - onClicked: optionsModel.moneyFontChoice = "embedded" - } - - OptionButton { - objectName: "moneyFontSystem" - Layout.fillWidth: true - text: qsTr("System fixed-width font") - description: "111.11111111 BTC" - checked: optionsModel.moneyFontChoice === "best_system" - onClicked: optionsModel.moneyFontChoice = "best_system" - } - - CoreText { - objectName: "moneyFontPreview" - Layout.fillWidth: true - text: "111.11111111 BTC" - color: Theme.color.neutral9 - horizontalAlignment: Text.AlignHCenter - font.family: optionsModel.moneyFont.family - font.weight: optionsModel.moneyFont.weight - font.pixelSize: 20 - } - } - } - } -} diff --git a/qml/pages/settings/SettingsDisplayUnit.qml b/qml/pages/settings/SettingsDisplayUnit.qml deleted file mode 100644 index 61c492ae2f..0000000000 --- a/qml/pages/settings/SettingsDisplayUnit.qml +++ /dev/null @@ -1,68 +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 "../../controls" -import "../../components" - -Page { - id: root - signal back - - objectName: "settingsDisplayUnitPage" - background: null - leftPadding: 20 - rightPadding: 20 - topPadding: 30 - - header: SettingsHeader { - title: qsTr("Display unit") - backButtonObjectName: "settingsDisplayUnitBack" - onBack: root.back() - } - - ColumnLayout { - spacing: 15 - width: Math.min(parent.width, 450) - anchors.horizontalCenter: parent.horizontalCenter - - OptionButton { - objectName: "displayUnitBTC" - Layout.fillWidth: true - text: qsTr("BTC") - description: qsTr("8 decimal places (0.00000001 BTC = 1 sat)") - checked: optionsModel.displayUnit === 0 - onClicked: optionsModel.displayUnit = 0 - } - - OptionButton { - objectName: "displayUnitMBTC" - Layout.fillWidth: true - text: qsTr("mBTC") - description: qsTr("5 decimal places (0.00001 mBTC = 1 sat)") - checked: optionsModel.displayUnit === 1 - onClicked: optionsModel.displayUnit = 1 - } - - OptionButton { - objectName: "displayUnitUBTC" - Layout.fillWidth: true - text: qsTr("bits") - description: qsTr("2 decimal places (0.01 bits = 1 sat)") - checked: optionsModel.displayUnit === 2 - onClicked: optionsModel.displayUnit = 2 - } - - OptionButton { - objectName: "displayUnitSAT" - Layout.fillWidth: true - text: qsTr("sat") - description: qsTr("Satoshi, the smallest unit (1 sat = 0.00000001 BTC)") - checked: optionsModel.displayUnit === 3 - onClicked: optionsModel.displayUnit = 3 - } - } -} diff --git a/qml/pages/settings/SettingsTheme.qml b/qml/pages/settings/SettingsTheme.qml deleted file mode 100644 index c6e43af9a3..0000000000 --- a/qml/pages/settings/SettingsTheme.qml +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) 2023 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" - -Page { - signal back - signal designSystemRequested - - id: root - background: null - leftPadding: 20 - rightPadding: 20 - topPadding: 30 - - header: SettingsHeader { - title: qsTr("Theme") - onBack: root.back() - } - ThemeSettings { - width: Math.min(parent.width, 450) - anchors.horizontalCenter: parent.horizontalCenter - onDesignSystemRequested: root.designSystemRequested() - } -} \ No newline at end of file diff --git a/qml/pages/settings/SettingsWallet.qml b/qml/pages/settings/SettingsWallet.qml deleted file mode 100644 index 12a38e4dee..0000000000 --- a/qml/pages/settings/SettingsWallet.qml +++ /dev/null @@ -1,51 +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 "../../controls" -import "../../components" - -Page { - id: root - objectName: "settingsWallet" - - signal back - property bool showBackButton: true - - background: null - - header: SettingsHeader { - title: qsTr("External signer") - showBackButton: root.showBackButton - backButtonObjectName: "settingsWalletBack" - onBack: root.back() - } - - ScrollView { - anchors.fill: parent - contentWidth: width - clip: true - - ColumnLayout { - width: Math.min(parent.width, 450) - anchors.horizontalCenter: parent.horizontalCenter - spacing: 0 - - SettingsRestartNotice { - objectName: "walletRestartNotice" - visible: optionsModel.walletSettingsDirty - Layout.fillWidth: true - Layout.topMargin: 10 - Layout.bottomMargin: 20 - } - - WalletSettings { - Layout.fillWidth: true - } - } - } -} diff --git a/qml/pages/settings/SettingsWindowBehavior.qml b/qml/pages/settings/SettingsWindowBehavior.qml deleted file mode 100644 index 9d33d203d9..0000000000 --- a/qml/pages/settings/SettingsWindowBehavior.qml +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (c) 2021-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" - -Item { - id: root - objectName: "windowBehaviorPage" - signal back - property bool showBackButton: true - - property var windowBehaviorModel: desktopWindowBehaviorModel - - Page { - anchors.fill: parent - background: null - leftPadding: 20 - rightPadding: 20 - topPadding: 30 - - header: SettingsHeader { - title: qsTr("Window behavior") - showBackButton: root.showBackButton - backButtonObjectName: "windowBehaviorBack" - onBack: root.back() - } - - ColumnLayout { - spacing: 4 - width: Math.min(parent.width, 450) - anchors.horizontalCenter: parent.horizontalCenter - - Setting { - id: showTrayIconSetting - Layout.fillWidth: true - header: qsTr("Show tray icon") - description: qsTr("Keep the app available in the system tray") - disabled: !windowBehaviorModel.desktopPlatform - actionItem: OptionSwitch { - objectName: "showTrayIconSwitch" - checked: windowBehaviorModel.showTrayIcon - onToggled: windowBehaviorModel.showTrayIcon = checked - } - onClicked: windowBehaviorModel.showTrayIcon = !windowBehaviorModel.showTrayIcon - } - - Separator { Layout.fillWidth: true } - - Setting { - id: minimizeToTraySetting - Layout.fillWidth: true - header: qsTr("Minimize to tray") - description: qsTr("Hide window to tray when minimized") - disabled: !windowBehaviorModel.desktopPlatform || - !windowBehaviorModel.showTrayIcon - actionItem: OptionSwitch { - objectName: "minimizeToTraySwitch" - checked: windowBehaviorModel.minimizeToTray - enabled: windowBehaviorModel.desktopPlatform && - windowBehaviorModel.showTrayIcon - onToggled: windowBehaviorModel.minimizeToTray = checked - } - onClicked: windowBehaviorModel.minimizeToTray = !windowBehaviorModel.minimizeToTray - } - - Separator { Layout.fillWidth: true } - - Setting { - id: minimizeOnCloseSetting - Layout.fillWidth: true - header: qsTr("Minimize on close") - description: qsTr("Keep node running when the window is closed") - disabled: !windowBehaviorModel.desktopPlatform - actionItem: OptionSwitch { - objectName: "minimizeOnCloseSwitch" - checked: windowBehaviorModel.minimizeOnClose - enabled: windowBehaviorModel.desktopPlatform - onToggled: windowBehaviorModel.minimizeOnClose = checked - } - onClicked: windowBehaviorModel.minimizeOnClose = !windowBehaviorModel.minimizeOnClose - } - } - } -} diff --git a/qml/pages/settings/settingsv2/AboutSettingsPage.qml b/qml/pages/settings/settingsv2/AboutSettingsPage.qml index 6eaca4061c..8a17151cf1 100644 --- a/qml/pages/settings/settingsv2/AboutSettingsPage.qml +++ b/qml/pages/settings/settingsv2/AboutSettingsPage.qml @@ -63,6 +63,7 @@ SettingsPage { } ListRow { + objectName: "settingsv2AboutDeveloperRow" Layout.fillWidth: true title: qsTr("Developer options") description: qsTr("Only use these if you have development experience.") diff --git a/qml/pages/settings/settingsv2/DisplaySettingsPage.qml b/qml/pages/settings/settingsv2/DisplaySettingsPage.qml index bc8133f837..b7afae4a49 100644 --- a/qml/pages/settings/settingsv2/DisplaySettingsPage.qml +++ b/qml/pages/settings/settingsv2/DisplaySettingsPage.qml @@ -16,7 +16,7 @@ import ".." as LegacySettings SettingsPage { id: root objectName: "settingsv2DisplaySettingsPage" - title: qsTr("Display") + title: qsTranslate("SettingsDisplay", "Display") showBackButton: false FormSection { @@ -24,8 +24,9 @@ SettingsPage { title: qsTr("Appearance") FormRow { + objectName: "settingsv2DisplayThemeRow" Layout.fillWidth: true - title: qsTr("Theme") + title: qsTranslate("SettingsDisplay", "Theme") trailingItem: SegmentedPicker { objectName: "settingsv2DisplayThemePicker" implicitWidth: 190 @@ -39,8 +40,9 @@ SettingsPage { } FormRow { + objectName: "settingsv2DisplayBlockStatusSizeRow" Layout.fillWidth: true - title: qsTr("Block status size") + title: qsTranslate("SettingsDisplay", "Block status size") trailingItem: PopupPicker { objectName: "settingsv2DisplayBlockStatusSizePicker" embedded: true @@ -70,6 +72,7 @@ SettingsPage { } FormRow { + objectName: "settingsv2DisplayMoneyFontRow" Layout.fillWidth: true title: qsTr("Money font") showDivider: false @@ -103,33 +106,39 @@ SettingsPage { title: qsTr("Language and format") FormRow { + objectName: "settingsv2DisplayUnitRow" Layout.fillWidth: true - title: qsTr("Display unit") + title: qsTranslate("SettingsDisplay", "Display unit") trailingItem: PopupPicker { objectName: "settingsv2DisplayUnitPicker" embedded: true minimumMenuWidth: 400 subtitleRole: "description" + objectNameRole: "objectName" currentValue: optionsModel.displayUnit model: [ { text: qsTr("BTC"), value: 0, + objectName: "settingsv2DisplayUnitBTC", description: qsTr("8 decimal places (0.00000001 BTC = 1 sat)") }, { text: qsTr("mBTC"), value: 1, + objectName: "settingsv2DisplayUnitMBTC", description: qsTr("5 decimal places (0.00001 mBTC = 1 sat)") }, { text: qsTr("bits"), value: 2, + objectName: "settingsv2DisplayUnitBits", description: qsTr("2 decimal places (0.01 bits = 1 sat)") }, { text: qsTr("sat"), value: 3, + objectName: "settingsv2DisplayUnitSAT", description: qsTr("Satoshi, the smallest unit (1 sat = 0.00000001 BTC)") } ] @@ -142,7 +151,7 @@ SettingsPage { ListRow { objectName: "settingsv2DisplayLanguageRow" Layout.fillWidth: true - title: qsTr("Language") + title: qsTranslate("SettingsDisplay", "Language") enabled: ((optionsModel.coreSettingStatuses || ({})).lang || ({})).canEdit !== false showsDisclosureIndicator: true disclosureIndicatorObjectName: "settingsv2DisplayLanguageDisclosureIndicator" @@ -155,6 +164,7 @@ SettingsPage { } ListRow { + objectName: "settingsv2DisplayTransactionUrlsRow" Layout.fillWidth: true title: qsTr("Third-party transaction URLs") showDivider: false diff --git a/qml/pages/settings/settingsv2/WalletSectionPage.qml b/qml/pages/settings/settingsv2/WalletSectionPage.qml index 3928e2939a..687fe8c3dc 100644 --- a/qml/pages/settings/settingsv2/WalletSectionPage.qml +++ b/qml/pages/settings/settingsv2/WalletSectionPage.qml @@ -170,6 +170,7 @@ SettingsPage { title: qsTr("Wallet actions") ListRow { + objectName: "settingsv2WalletAddressesRow" Layout.fillWidth: true title: qsTr("Addresses") showsDisclosureIndicator: true @@ -177,6 +178,7 @@ SettingsPage { } ListRow { + objectName: "settingsv2WalletPasswordRow" visible: root.canManagePassphrase Layout.fillWidth: true title: root.wallet && root.wallet.isEncrypted ? qsTr("Update password") : qsTr("Set password") @@ -185,6 +187,7 @@ SettingsPage { } ListRow { + objectName: "settingsv2WalletBackupRow" Layout.fillWidth: true title: qsTr("Back up wallet") showsDisclosureIndicator: true @@ -192,6 +195,7 @@ SettingsPage { } ListRow { + objectName: "settingsv2WalletSignVerifyMessageRow" Layout.fillWidth: true title: qsTr("Sign or verify message") showDivider: false diff --git a/qml/pages/wallet/DesktopWallets.qml b/qml/pages/wallet/DesktopWallets.qml index a45f0869a5..9a54022298 100644 --- a/qml/pages/wallet/DesktopWallets.qml +++ b/qml/pages/wallet/DesktopWallets.qml @@ -57,11 +57,16 @@ Page { } } + function openSettingsRoute(route) { + settingsLoader.pendingRoute = route + settingsTabButton.checked = true + Qt.callLater(settingsLoader.applyPendingRoute) + } + Connections { target: walletController function onOpenWalletSettingsRequested() { - settingsTabButton.checked = true - nodeSettings.openWalletSettings() + root.openSettingsRoute("wallet") } function onOpenReceiveRequested() { receiveTabButton.checked = true @@ -243,31 +248,13 @@ Page { text: qsTr("Peers") } } - NavigationTab { - id: consoleTabButton - objectName: "consoleTabButton" - iconSource: "image://images/console" - iconColor: Theme.color.neutral7 - iconSize: 24 - Layout.preferredWidth: 30 - property int index: 5 - ButtonGroup.group: navigationTabs - - Tooltip { - anchors.top: consoleTabButton.bottom - anchors.topMargin: -5 - anchors.horizontalCenter: consoleTabButton.horizontalCenter - visible: consoleTabButton.hovered - text: qsTr("Console") - } - } NavigationTab { id: settingsTabButton objectName: "desktopWalletSettingsTabButton" - iconSource: "image://images/gear-outline" + iconSource: "image://images/gear" iconColor: Theme.color.neutral7 Layout.preferredWidth: 30 - property int index: 6 + property int index: 5 ButtonGroup.group: navigationTabs Tooltip { @@ -278,23 +265,6 @@ Page { text: qsTr("Settings") } } - NavigationTab { - id: settingsv2TabButton - objectName: "desktopWalletSettingsPreviewTabButton" - iconSource: "image://images/gear" - iconColor: Theme.color.neutral7 - Layout.preferredWidth: 30 - property int index: 7 - ButtonGroup.group: navigationTabs - - Tooltip { - anchors.top: settingsv2TabButton.bottom - anchors.topMargin: -5 - anchors.horizontalCenter: settingsv2TabButton.horizontalCenter - visible: settingsv2TabButton.hovered - text: qsTr("Settings") - } - } } background: Rectangle { color: Theme.color.neutral4 @@ -321,8 +291,7 @@ Page { } RequestPayment { onAddressHistoryRequested: { - settingsTabButton.checked = true - nodeSettings.openWalletAddressHistory() + root.openSettingsRoute("addresses") } } Item { @@ -365,31 +334,28 @@ Page { } } } - CommandConsole { - showHeader: false - tabActive: consoleTabButton.checked - walletName: walletController.isWalletLoaded && walletController.selectedWallet - ? walletController.selectedWallet.name - : "" - } - NodeSettings { - id: nodeSettings - showDoneButton: false - onSelectWalletRequested: root.openWalletSelection() - onReceiveRequested: { - receiveTabButton.checked = true - } - } Item { Loader { - id: settingsPreviewLoader - objectName: "settingsPreviewLoader" + id: settingsLoader + objectName: "settingsLoader" anchors.fill: parent property bool retainItem: false + property string pendingRoute: "" + + function applyPendingRoute() { + if (!item || pendingRoute.length === 0) return + if (pendingRoute === "addresses") item.openWalletAddressHistory() + else item.selectSection(pendingRoute) + pendingRoute = "" + } + // Create Settings on first use, then retain its navigation // stacks while the parent tab item is hidden. - active: settingsv2TabButton.checked || retainItem - onLoaded: retainItem = true + active: settingsTabButton.checked || retainItem + onLoaded: { + retainItem = true + Qt.callLater(applyPendingRoute) + } sourceComponent: SettingsView { showDoneButton: false onSelectWalletRequested: root.openWalletSelection() diff --git a/qml/pages/wallet/WalletSettings.qml b/qml/pages/wallet/WalletSettings.qml deleted file mode 100644 index 5d6dad5d65..0000000000 --- a/qml/pages/wallet/WalletSettings.qml +++ /dev/null @@ -1,352 +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 QtQuick.Dialogs - -import org.bitcoincore.qt 1.0 - -import "../../controls" - -Page { - id: root - objectName: "walletSettingsPage" - - property WalletQmlModel wallet: walletController.selectedWallet - property string errorText: "" - property bool editingName: false - property string pendingDisplayName: "" - - property bool showBackButton: true - - signal back() - signal selectWalletRequested() - signal passwordRequested() - signal signVerifyMessageRequested() - signal addressesRequested() - - readonly property bool walletLoaded: walletController.isWalletLoaded - readonly property bool canManagePassphrase: root.wallet !== null && root.wallet.canManagePassphrase - - background: null - - function backupDefaultFileUrl() { - const wallet_name = root.wallet && root.wallet.name.length > 0 - ? root.wallet.name.replace(/[\\/]/g, "_") - : "wallet" - return "file://" + walletController.homePath() + "/" + wallet_name + ".bak" - } - - function backupFileName() { - const wallet_name = root.wallet && root.wallet.name.length > 0 - ? root.wallet.name.replace(/[\\/]/g, "_") - : "wallet" - return wallet_name + ".bak" - } - - function resolvedBackupPath(rawPath) { - let normalized = walletController.normalizeWalletPath(rawPath) - if (normalized.length === 0) { - return "" - } - - const hasKnownSuffix = /\.(bak|dat)$/i.test(normalized) - if (walletController.walletPathExists(normalized) && !hasKnownSuffix) { - normalized = normalized + "/" + root.backupFileName() - } else if (!hasKnownSuffix) { - normalized = normalized + ".bak" - } - - return normalized - } - - function startBackup() { - if (!root.wallet) { - return - } - root.errorText = "" - root.wallet.clearSettingsError() - if (backupAutomationPath.text.length > 0) { - const automatedPath = root.resolvedBackupPath(backupAutomationPath.text) - backupAutomationPath.text = "" - if (!root.wallet.backupWallet(automatedPath)) { - root.errorText = root.wallet.settingsError - } - return - } - backupDialog.open() - } - - function beginNameEdit() { - if (!root.wallet) { - return - } - root.pendingDisplayName = root.wallet.displayName - root.editingName = true - } - - function cancelNameEdit() { - root.pendingDisplayName = root.wallet ? root.wallet.displayName : "" - root.editingName = false - } - - function confirmNameEdit() { - if (!root.wallet) { - return - } - if (walletController.setWalletDisplayName(root.wallet.name, root.pendingDisplayName)) { - root.editingName = false - } - } - - header: SettingsHeader { - title: qsTr("Wallet settings") - showBackButton: root.showBackButton - backButtonObjectName: "walletSettingsBackButton" - onBack: root.back() - } - - FileDialog { - id: backupDialog - fileMode: FileDialog.SaveFile - currentFolder: "file://" + walletController.homePath() - selectedFile: root.backupDefaultFileUrl() - defaultSuffix: "bak" - nameFilters: [qsTr("Wallet backup files (*.bak *.dat)"), qsTr("All files (*)")] - onAccepted: { - if (backupDialog.selectedFile.toString().length === 0) { - return - } - const normalized = root.resolvedBackupPath(backupDialog.selectedFile.toString()) - if (!root.wallet.backupWallet(normalized)) { - root.errorText = root.wallet.settingsError - } else { - root.errorText = "" - } - } - } - - // Hidden automation hook so tests can inject a backup destination. - TextField { - id: backupAutomationPath - objectName: "walletSettingsBackupPathField" - visible: false - } - - Connections { - target: root.wallet - function onSettingsErrorChanged() { - root.errorText = root.wallet ? root.wallet.settingsError : "" - } - function onDisplayNameChanged() { - if (!root.editingName) { - root.pendingDisplayName = root.wallet ? root.wallet.displayName : "" - } - } - } - - Connections { - target: walletController - function onSelectedWalletChanged() { - root.editingName = false - root.pendingDisplayName = root.wallet ? root.wallet.displayName : "" - } - } - - ColumnLayout { - visible: !root.walletLoaded - width: Math.min(parent.width, 450) - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - spacing: 24 - - Header { - objectName: "walletSettingsEmptyHeader" - Layout.fillWidth: true - header: qsTr("No wallet selected") - headerBold: true - headerSize: 28 - description: qsTr("Select a wallet to manage wallet-specific settings.") - } - - OutlineButton { - objectName: "walletSettingsSelectWalletButton" - Layout.preferredWidth: 220 - Layout.alignment: Qt.AlignCenter - text: qsTr("Select wallet") - onClicked: root.selectWalletRequested() - } - } - - ColumnLayout { - visible: root.walletLoaded - width: Math.min(parent.width, 450) - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - spacing: 0 - - EditableKeyValueRow { - objectName: "walletSettingsNameRow" - Layout.fillWidth: true - Layout.topMargin: 12 - Layout.bottomMargin: 15 - keyObjectName: "walletSettingsNameKey" - valueObjectName: "walletSettingsNameValue" - editFieldObjectName: "walletSettingsNameEditField" - editButtonObjectName: "walletSettingsNameEditButton" - cancelButtonObjectName: "walletSettingsNameCancelButton" - confirmButtonObjectName: "walletSettingsNameConfirmButton" - label: qsTr("Name") - displayValue: root.wallet ? root.wallet.displayName : "" - editValue: root.pendingDisplayName - editing: root.editingName - onEditRequested: root.beginNameEdit() - onCancelRequested: root.cancelNameEdit() - onConfirmRequested: root.confirmNameEdit() - onEditValueEdited: value => root.pendingDisplayName = value - } - - Rectangle { Layout.fillWidth: true; height: 1; color: Theme.color.neutral4 } - - KeyValueRow { - keyWidth: 150 - Layout.fillWidth: true - Layout.topMargin: 15 - Layout.bottomMargin: 15 - key: KeyText { - objectName: "walletSettingsKeySchemeKey" - text: qsTr("Key scheme") - } - value: ValueText { - objectName: "walletSettingsKeySchemeValue" - text: root.wallet ? root.wallet.keyScheme : "" - } - } - - Rectangle { Layout.fillWidth: true; height: 1; color: Theme.color.neutral4 } - - KeyValueRow { - keyWidth: 150 - Layout.fillWidth: true - Layout.topMargin: 15 - Layout.bottomMargin: 15 - key: KeyText { - objectName: "walletSettingsPrivateKeysKey" - text: qsTr("Private keys") - } - value: ValueText { - objectName: "walletSettingsPrivateKeysValue" - text: root.wallet ? root.wallet.privateKeysStatus : "" - } - } - - Rectangle { Layout.fillWidth: true; height: 1; color: Theme.color.neutral4 } - - KeyValueRow { - keyWidth: 150 - Layout.fillWidth: true - Layout.topMargin: 15 - Layout.bottomMargin: 15 - key: KeyText { - objectName: "walletSettingsExternalSignerKey" - text: qsTr("External signer") - } - value: ValueText { - objectName: "walletSettingsExternalSignerValue" - text: root.wallet ? root.wallet.externalSignerStatus : "" - } - } - - Rectangle { Layout.fillWidth: true; height: 1; color: Theme.color.neutral4 } - - Setting { - id: addressesSetting - objectName: "settingsAddresses" - Layout.fillWidth: true - header: qsTr("Addresses") - actionItem: CaretRightIcon { - color: addressesSetting.stateColor - } - onClicked: root.addressesRequested() - } - - Rectangle { - objectName: "walletSettingsPasswordDivider" - visible: root.canManagePassphrase - Layout.fillWidth: true - height: 1 - color: Theme.color.neutral4 - } - - Setting { - id: passwordSetting - objectName: "walletSettingsPasswordRow" - visible: root.canManagePassphrase - Layout.fillWidth: true - header: root.wallet && root.wallet.isEncrypted ? qsTr("Update password") : qsTr("Set password") - actionItem: CaretRightIcon { - color: passwordSetting.stateColor - } - onClicked: root.passwordRequested() - } - - Rectangle { Layout.fillWidth: true; height: 1; color: Theme.color.neutral4 } - - Setting { - id: backupSetting - objectName: "walletSettingsBackupRow" - Layout.fillWidth: true - header: qsTr("Back up wallet") - actionItem: CaretRightIcon { - color: backupSetting.stateColor - } - onClicked: root.startBackup() - } - - Rectangle { Layout.fillWidth: true; height: 1; color: Theme.color.neutral4 } - - Setting { - id: signVerifyMessageSetting - objectName: "walletSettingsSignVerifyMessageRow" - Layout.fillWidth: true - header: qsTr("Sign or verify message") - actionItem: CaretRightIcon { - color: signVerifyMessageSetting.stateColor - } - onClicked: root.signVerifyMessageRequested() - } - - CoreText { - objectName: "walletSettingsErrorText" - Layout.fillWidth: true - Layout.topMargin: 12 - visible: text.length > 0 - text: root.errorText - color: Theme.color.red - font.pixelSize: 15 - horizontalAlignment: Text.AlignLeft - wrapMode: Text.WordWrap - } - } - - component KeyText: CoreText { - color: Theme.color.neutral7 - font.pixelSize: 18 - fontStyleName: "Regular" - wrap: false - horizontalAlignment: Qt.AlignLeft - verticalAlignment: Text.AlignVCenter - } - - component ValueText: CoreText { - color: Theme.color.neutral9 - font.pixelSize: 18 - fontStyleName: "Regular" - horizontalAlignment: Qt.AlignRight - verticalAlignment: Text.AlignVCenter - wrapMode: Text.WordWrap - } -} diff --git a/test/functional/qml_driver.py b/test/functional/qml_driver.py index 8816281e78..603fa82d02 100644 --- a/test/functional/qml_driver.py +++ b/test/functional/qml_driver.py @@ -317,12 +317,12 @@ def set_clipboard_text(self, text): def settle( self, timeout_ms=5000, - stack_view_names=("mainPageStack", "createWalletWizard", "nodeSettingsStack"), + stack_view_names=("mainPageStack", "createWalletWizard", "settingsNavigationStack_wallet"), ): """Wait for relevant StackView transitions to finish. The wallet flow transitions run through the app's main page stack and, - once opened, the nested create-wallet wizard stack and settings stack. + once opened, the nested create-wallet wizard and wallet-settings stacks. Waiting for their `busy` property to become false is more reliable than sleeping. Missing stack views are ignored so this remains safe before nested flows have been created. diff --git a/test/functional/qml_test_activity_filter_export.py b/test/functional/qml_test_activity_filter_export.py index 457ddacfe3..51663b78cf 100644 --- a/test/functional/qml_test_activity_filter_export.py +++ b/test/functional/qml_test_activity_filter_export.py @@ -221,12 +221,12 @@ def run_test(save_screenshots=False, screenshot_root=None): checkpoints.checkpoint("search by request label applied", gui) gui.click("desktopWalletSettingsTabButton") - gui.wait_for_property("settings_display", "visible", True, timeout_ms=5000) - gui.click("settings_display") - gui.wait_for_page("gotoDisplayUnit", timeout_ms=5000) - gui.click("gotoDisplayUnit") - gui.wait_for_page("settingsDisplayUnitPage", timeout_ms=5000) - gui.click("displayUnitSAT") + gui.wait_for_property("settingsSidebar_display", "visible", True, timeout_ms=5000) + gui.click("settingsSidebar_display") + gui.wait_for_page("settingsv2DisplayUnitPicker", timeout_ms=5000) + gui.click("settingsv2DisplayUnitPickerButton") + gui.wait_for_page("settingsv2DisplayUnitSAT", timeout_ms=5000) + gui.click("settingsv2DisplayUnitSAT") checkpoints.checkpoint("display unit switched to sats", gui) gui.click("activityTabButton") diff --git a/test/functional/qml_test_addresses.py b/test/functional/qml_test_addresses.py index 22b5154f1b..863c56e848 100755 --- a/test/functional/qml_test_addresses.py +++ b/test/functional/qml_test_addresses.py @@ -90,10 +90,10 @@ def open_address_list_from_settings(gui): gui.click("desktopWalletSettingsTabButton") gui.wait_for_property("desktopWalletSettingsTabButton", "checked", True, timeout_ms=5000) gui.settle() - gui.wait_for_property("settings_wallet", "visible", True, timeout_ms=5000) - gui.click("settings_wallet") - gui.wait_for_property("walletSettingsPage", "visible", True, timeout_ms=5000) - gui.click("settingsAddresses") + gui.wait_for_property("settingsSidebar_wallet", "visible", True, timeout_ms=5000) + gui.click("settingsSidebar_wallet") + gui.wait_for_property("settingsv2WalletSettingsPage", "visible", True, timeout_ms=5000) + gui.click("settingsv2WalletAddressesRow") gui.wait_for_property("addressListPage", "visible", True, timeout_ms=10000) diff --git a/test/functional/qml_test_blocksonly_settings.py b/test/functional/qml_test_blocksonly_settings.py index f9214a3672..638dc639db 100644 --- a/test/functional/qml_test_blocksonly_settings.py +++ b/test/functional/qml_test_blocksonly_settings.py @@ -23,17 +23,13 @@ def run_tests(): complete_onboarding(gui) gui.wait_for_page("nodeSettingsButton", timeout_ms=30000) gui.click("nodeSettingsButton") - # Node settings now uses a sidebar layout that lands on the About - # section; each sidebar row has objectName "settings_
". - gui.wait_for_page("settings_about", timeout_ms=10000) + gui.wait_for_page("settingsSidebar_about", timeout_ms=10000) # The Mempool Information sidebar row is gated on # nodeModel.mempoolInformationAvailable, which is false in -blocksonly # mode, so the row must be hidden. - mempool_visible = gui.get_property("settings_mempool", "visible") - assert mempool_visible is False, ( - "Mempool Information settings row should be hidden in -blocksonly mode, " - f"got {mempool_visible!r}" + assert not gui.object_exists("settingsSidebar_mempool"), ( + "Mempool Information settings row should not be instantiated in -blocksonly mode" ) print("Blocksonly settings smoke test PASSED") diff --git a/test/functional/qml_test_console.py b/test/functional/qml_test_console.py index 339cb431fd..7fd153a13c 100644 --- a/test/functional/qml_test_console.py +++ b/test/functional/qml_test_console.py @@ -5,7 +5,7 @@ """End-to-end tests for the RPC command console. Starts the GUI as a regtest node (no peers needed), completes onboarding, -then navigates to Settings → Console and exercises command execution. +then navigates to Settings → RPC console and exercises command execution. The console is only reachable after the node has fully started. We use a generous wait_for_page timeout (~90 s) for the initial node-runner screen @@ -39,17 +39,20 @@ def navigate_to_console(gui): - """From the NodeRunner main screen, navigate to the Console page. + """From the NodeRunner main screen, navigate to the RPC console settings page. Waits for the node-runner screen (which only appears once the node is - running), then clicks the console icon button in the header. + running), then opens Settings and selects RPC console in the sidebar. """ gui.wait_for_page( - "consoleTabButton", + "nodeSettingsButton", timeout_ms=NODE_RUNNING_TIMEOUT_MS, ) - gui.click("consoleTabButton") - gui.wait_for_page("commandConsole", timeout_ms=5000) + gui.click("nodeSettingsButton") + gui.wait_for_property("settingsSidebar_rpc-console", "visible", True, timeout_ms=5000) + gui.click("settingsSidebar_rpc-console") + gui.wait_for_page("settingsv2RpcConsoleSettingsPage", timeout_ms=5000) + gui.wait_for_page("settingsv2RpcConsole", timeout_ms=5000) # The command input auto-focuses on open (desktop), mirroring Core's # RPCConsole, so the user can type immediately. gui.wait_for_property("consoleInput", "activeFocus", True, timeout_ms=5000) @@ -66,14 +69,14 @@ def assert_close(actual, expected, label, tolerance=1): def submit_console_command(gui, command): gui.set_text("consoleInput", command) - gui.invoke("commandConsole", "runHighlightedOrSubmit") + gui.invoke("settingsv2RpcConsole", "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 ───────────────────────") - root_width = gui.get_property("commandConsole", "width") + root_width = gui.get_property("settingsv2RpcConsole", "width") row_x = gui.get_property("consoleInputRow", "x") row_width = gui.get_property("consoleInputRow", "width") row_height = gui.get_property("consoleInputRow", "height") @@ -104,19 +107,19 @@ def test_console_input_bar_matches_design(gui): 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("commandConsole", "searchMode") is False + assert gui.get_property("settingsv2RpcConsole", "searchMode") is False gui.click("consoleModeToggleButton") - gui.wait_for_property("commandConsole", "searchMode", True, timeout_ms=3000) + gui.wait_for_property("settingsv2RpcConsole", "searchMode", True, timeout_ms=3000) assert gui.get_property("consoleInput", "placeholderText") == "Search..." gui.click("consoleFontIncreaseButton") - assert gui.get_property("commandConsole", "outputFontPixelSize") == 14 + assert gui.get_property("settingsv2RpcConsole", "outputFontPixelSize") == 14 gui.click("consoleFontDecreaseButton") - assert gui.get_property("commandConsole", "outputFontPixelSize") == 13 + assert gui.get_property("settingsv2RpcConsole", "outputFontPixelSize") == 13 gui.click("consoleModeToggleButton") - gui.wait_for_property("commandConsole", "searchMode", False, timeout_ms=3000) + gui.wait_for_property("settingsv2RpcConsole", "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") @@ -137,8 +140,8 @@ 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("commandConsole", "outputCount", lambda v: v >= 1, timeout_ms=3000) - root_width = gui.get_property("commandConsole", "width") + gui.wait_for_property("settingsv2RpcConsole", "outputCount", lambda v: v >= 1, timeout_ms=3000) + root_width = gui.get_property("settingsv2RpcConsole", "width") column_width = root_width - 40 assert_close(gui.get_property("consoleOutputArea_contentColumn", "x"), 20, "console output column x") @@ -152,10 +155,10 @@ def test_console_output_rows_match_design(gui): assert "Use ↑↓ arrows" in welcome_text assert "help-console" in welcome_text - count_before = gui.get_property("commandConsole", "outputCount") + count_before = gui.get_property("settingsv2RpcConsole", "outputCount") submit_console_command(gui, "getblockcount") - gui.wait_for_property("commandConsole", "executing", False, timeout_ms=10000) - gui.wait_for_property("commandConsole", "outputCount", count_before + 2, timeout_ms=3000) + gui.wait_for_property("settingsv2RpcConsole", "executing", False, timeout_ms=10000) + gui.wait_for_property("settingsv2RpcConsole", "outputCount", count_before + 2, timeout_ms=3000) request_index = count_before reply_index = count_before + 1 @@ -176,13 +179,13 @@ def test_execute_getblockcount(gui): """Execute getblockcount and verify a request + reply pair appears (no error row).""" print("\n── test_execute_getblockcount ──────────────────────────────────") - count_before = gui.get_property("commandConsole", "outputCount") + count_before = gui.get_property("settingsv2RpcConsole", "outputCount") submit_console_command(gui, "getblockcount") # Wait for execution to complete. - gui.wait_for_property("commandConsole", "executing", False, timeout_ms=10000) + gui.wait_for_property("settingsv2RpcConsole", "executing", False, timeout_ms=10000) - count_after = gui.get_property("commandConsole", "outputCount") + count_after = gui.get_property("settingsv2RpcConsole", "outputCount") # Expect exactly 2 new rows: one CMD_REQUEST (command echo) and one # CMD_REPLY (the numeric block count). An error would add a third row. assert count_after == count_before + 2, ( @@ -196,12 +199,12 @@ def test_execute_help(gui): """Execute 'help' and verify output rows appear.""" print("\n── test_execute_help ───────────────────────────────────────────") - count_before = gui.get_property("commandConsole", "outputCount") + count_before = gui.get_property("settingsv2RpcConsole", "outputCount") submit_console_command(gui, "help") - gui.wait_for_property("commandConsole", "executing", False, timeout_ms=10000) + gui.wait_for_property("settingsv2RpcConsole", "executing", False, timeout_ms=10000) - count_after = gui.get_property("commandConsole", "outputCount") + count_after = gui.get_property("settingsv2RpcConsole", "outputCount") assert count_after > count_before, ( f"Expected output rows after help (before={count_before}, after={count_after})" ) @@ -212,13 +215,13 @@ def test_execute_invalid_command(gui): """Execute an unknown command and verify the submit button re-enables and output appears.""" print("\n── test_execute_invalid_command ────────────────────────────────") - count_before = gui.get_property("commandConsole", "outputCount") + count_before = gui.get_property("settingsv2RpcConsole", "outputCount") submit_console_command(gui, "thiscommanddoesnotexist") # Wait for execution to complete (button stays disabled since input was cleared). - gui.wait_for_property("commandConsole", "executing", False, timeout_ms=10000) + gui.wait_for_property("settingsv2RpcConsole", "executing", False, timeout_ms=10000) - count_after = gui.get_property("commandConsole", "outputCount") + count_after = gui.get_property("settingsv2RpcConsole", "outputCount") assert count_after > count_before, ( f"Expected error output rows after invalid command (before={count_before}, after={count_after})" ) @@ -275,10 +278,10 @@ def test_autocomplete_help_variants(gui): def test_back_navigation(gui): - """Navigate back from the Console page and verify we return to NodeRunner.""" + """Close Settings from the RPC console and verify we return to NodeRunner.""" print("\n── test_back_navigation ────────────────────────────────────────") - gui.click("consoleBackButton") + gui.click("settingsv2SettingsDoneButton") gui.wait_for_page("nodeRunner", timeout_ms=5000) print(" PASSED: back navigation returned to NodeRunner") @@ -288,12 +291,12 @@ def test_clear_button_restores_welcome_output(gui): print("\n── test_clear_button_restores_welcome_output ───────────────────") gui.set_text("consoleInput", "") - assert gui.get_property("commandConsole", "outputCount") > 0 + assert gui.get_property("settingsv2RpcConsole", "outputCount") > 0 welcome_time_before = gui.get_text("consoleOutputArea_left_0") time.sleep(1.1) gui.click("consoleClearButton") - gui.wait_for_property("commandConsole", "outputCount", 1, timeout_ms=3000) + gui.wait_for_property("settingsv2RpcConsole", "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), ( diff --git a/test/functional/qml_test_debug_log.py b/test/functional/qml_test_debug_log.py index a5e8d747b2..7e533d983e 100644 --- a/test/functional/qml_test_debug_log.py +++ b/test/functional/qml_test_debug_log.py @@ -113,10 +113,9 @@ def navigate_to_debug_log(gui): """ gui.wait_for_page("nodeRunner", timeout_ms=10000) gui.click("nodeSettingsButton") - gui.wait_for_page("nodeSettingsStack", timeout_ms=5000) - # Debug Log is a sidebar section (settings_debuglog) in the desktop layout. - gui.wait_for_property("settings_debuglog", "visible", True, timeout_ms=5000) - gui.click("settings_debuglog") + 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) @@ -399,7 +398,7 @@ def test_load_more_at_bottom(gui, current_count): def test_close_settings(gui): """Clicking Done exits the desktop settings shell.""" print("\n── test_close_settings ───────────────────────────────────────────") - gui.click("nodeSettingsDoneButton") + gui.click("settingsv2SettingsDoneButton") gui.wait_for_page("nodeSettingsButton", timeout_ms=5000) print(" PASSED: Done closed node settings") diff --git a/test/functional/qml_test_disablewallet_boot.py b/test/functional/qml_test_disablewallet_boot.py index 6590749ec0..16c396c8f6 100755 --- a/test/functional/qml_test_disablewallet_boot.py +++ b/test/functional/qml_test_disablewallet_boot.py @@ -88,7 +88,7 @@ def checkpoint(self, label, gui=None): def open_node_settings(gui): gui.click("nodeSettingsButton") - gui.wait_for_page("settings_about", timeout_ms=SETTINGS_TIMEOUT_MS) + gui.wait_for_page("settingsSidebar_about", timeout_ms=SETTINGS_TIMEOUT_MS) def prepend_config_line(datadir, line): @@ -128,10 +128,8 @@ def assert_wallet_ui_absent(gui): f"{sorted(unexpected)}" ) - wallet_settings_visible = gui.get_property("settings_wallet", "visible") - assert wallet_settings_visible is False, ( - "Wallet settings row should be hidden in -disablewallet mode, " - f"got {wallet_settings_visible!r}" + assert not gui.object_exists("settingsSidebar_wallet"), ( + "Wallet settings row should not be instantiated in -disablewallet mode" ) @@ -161,59 +159,55 @@ def assert_wallet_boot(gui): def walk_about_settings(gui, checkpoints): print(" Opening About settings (sidebar)") - gui.click("settings_about") - gui.wait_for_page("settingsAbout", timeout_ms=SETTINGS_TIMEOUT_MS) + gui.click("settingsSidebar_about") + gui.wait_for_page("settingsv2AboutSettingsPage", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("about settings opened", gui) - gui.click("gotoDeveloperSetting") + gui.click("settingsv2AboutDeveloperRow") gui.wait_for_page("settingsDeveloper", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("developer settings opened", gui) gui.click("settingsDeveloperBack") - gui.wait_for_page("settingsAbout", timeout_ms=SETTINGS_TIMEOUT_MS) + gui.wait_for_page("settingsv2AboutSettingsPage", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("returned from developer settings", gui) def walk_display_settings(gui, checkpoints): print(" Opening Display settings (sidebar)") - gui.click("settings_display") - gui.wait_for_page("gotoDisplayUnit", timeout_ms=SETTINGS_TIMEOUT_MS) + gui.click("settingsSidebar_display") + gui.wait_for_page("settingsv2DisplayUnitPicker", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("display settings opened", gui) - gui.click("gotoDisplayUnit") - gui.wait_for_page("settingsDisplayUnitPage", timeout_ms=SETTINGS_TIMEOUT_MS) - checkpoints.checkpoint("display unit settings opened", gui) - gui.click("settingsDisplayUnitBack") - gui.wait_for_page("gotoDisplayUnit", timeout_ms=SETTINGS_TIMEOUT_MS) + gui.click("settingsv2DisplayUnitPickerButton") + gui.wait_for_page("settingsv2DisplayUnitPickerMenu", timeout_ms=SETTINGS_TIMEOUT_MS) + checkpoints.checkpoint("display unit picker opened", gui) + gui.click("settingsv2DisplayUnitBTC") - gui.click("gotoLanguage") + gui.click("settingsv2DisplayLanguageRow") gui.wait_for_page("settingsLanguagePage", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("language settings opened", gui) gui.click("settingsLanguageBack") - gui.wait_for_page("gotoLanguage", timeout_ms=SETTINGS_TIMEOUT_MS) + gui.wait_for_page("settingsv2DisplayLanguageRow", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("returned from display sub-pages", gui) def walk_storage_settings(gui, checkpoints): print(" Opening Storage settings (sidebar)") - gui.click("settings_storage") - # currentSection is the sidebar row index; Storage sits at row 4 in the - # grouped sidebar order (Wallet, External Signer, Display, Window Behavior, - # Storage, ...). - gui.wait_for_property("nodeSettingsStack", "currentSection", 4, timeout_ms=SETTINGS_TIMEOUT_MS) + gui.click("settingsSidebar_storage") + gui.wait_for_page("settingsv2StorageSettingsPage", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("storage settings opened", gui) def walk_connection_settings(gui, checkpoints): print(" Opening Connection settings (sidebar)") - gui.click("settings_connection") - gui.wait_for_page("gotoProxy", timeout_ms=SETTINGS_TIMEOUT_MS) + gui.click("settingsSidebar_connection") + gui.wait_for_page("settingsv2ProxySettingsRow", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("connection settings opened", gui) - gui.click("gotoProxy") - gui.wait_for_page("settingsProxy", timeout_ms=SETTINGS_TIMEOUT_MS) + gui.click("settingsv2ProxySettingsRow") + gui.wait_for_page("settingsv2ProxySettingsPage", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("proxy settings opened", gui) - gui.click("settingsProxyBack") - gui.wait_for_page("gotoProxy", timeout_ms=SETTINGS_TIMEOUT_MS) + gui.click("settingsv2ProxySettingsBackButton") + gui.wait_for_page("settingsv2ProxySettingsRow", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("returned from proxy settings", gui) @@ -229,14 +223,14 @@ def walk_peers(gui, checkpoints): def walk_network_traffic_settings(gui, checkpoints): print(" Opening Network Traffic settings (sidebar)") - gui.click("settings_networktraffic") - gui.wait_for_property("nodeSettingsStack", "currentSection", 6, timeout_ms=SETTINGS_TIMEOUT_MS) + gui.click("settingsSidebar_network-traffic") + gui.wait_for_page("settingsv2NetworkTrafficSettingsPage", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("network traffic settings opened", gui) def walk_debug_log_settings(gui, checkpoints): print(" Opening Debug Log settings (sidebar)") - gui.click("settings_debuglog") + gui.click("settingsSidebar_debug-log") gui.wait_for_page("debugLogSearchField", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("debug log settings opened", gui) @@ -266,7 +260,7 @@ def run_node_only_flow(harness, checkpoints, *, full_walk): walk_network_traffic_settings(gui, checkpoints) walk_debug_log_settings(gui, checkpoints) - gui.click("nodeSettingsDoneButton") + gui.click("settingsv2SettingsDoneButton") gui.wait_for_page("nodeSettingsButton", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("node settings closed", gui) diff --git a/test/functional/qml_test_external_signer.py b/test/functional/qml_test_external_signer.py index 60c7d9135c..4ba0625101 100644 --- a/test/functional/qml_test_external_signer.py +++ b/test/functional/qml_test_external_signer.py @@ -238,34 +238,35 @@ def ensure_desktop_wallets_visible(gui): def open_wallet_settings(gui): ensure_desktop_wallets_visible(gui) gui.click("desktopWalletSettingsTabButton") - # External-signer config moved into the node-settings sidebar's - # "External Signer" section (objectName settings_externalsigner), whose page - # hosts externalSignerPathInput. - gui.wait_for_property("settings_externalsigner", "visible", True, timeout_ms=10000) - gui.click("settings_externalsigner") + # External-signer configuration lives in its own redesigned sidebar page. + gui.wait_for_property("settingsSidebar_external-signer", "visible", True, timeout_ms=10000) + gui.click("settingsSidebar_external-signer") gui.wait_for_property("externalSignerPathInput", "visible", True, timeout_ms=10000) def open_selected_wallet_settings(gui): ensure_desktop_wallets_visible(gui) gui.click("desktopWalletSettingsTabButton") - # Per-wallet settings live under the sidebar "Wallet" section - # (objectName settings_wallet), which opens walletSettingsPage. - gui.wait_for_property("settings_wallet", "visible", True, timeout_ms=10000) - gui.click("settings_wallet") + # Per-wallet settings live under the sidebar Wallet section. + gui.wait_for_property("settingsSidebar_wallet", "visible", True, timeout_ms=10000) + gui.click("settingsSidebar_wallet") try: - gui.wait_for_page("walletSettingsPage", timeout_ms=1000) + gui.wait_for_page("settingsv2WalletSettingsPage", timeout_ms=1000) except QmlDriverError: - # The wallet section is a PageStack; if a prior step left it on a - # sub-page, pop back to the wallet settings root. - for back_button in ("walletSettingsBackButton", "settingsWalletBack"): + # If a prior step left the preserved wallet stack on a subpage, return + # to the redesigned wallet settings root. + for back_button in ( + "walletPasswordBackButton", + "addressListBackButton", + "signVerifyMessageBackButton", + ): try: if gui.get_property(back_button, "visible") is True: gui.click(back_button) break except QmlDriverError: pass - gui.wait_for_page("walletSettingsPage", timeout_ms=10000) + gui.wait_for_page("settingsv2WalletSettingsPage", timeout_ms=10000) def configure_external_signer_via_gui(harness, checkpoints, signer_path): @@ -505,8 +506,8 @@ def run_test(args): configure_external_signer_via_gui(harness, checkpoints, signer_path) wallet_name = create_and_verify_external_wallet(harness, checkpoints) open_selected_wallet_settings(harness.driver) - harness.driver.wait_for_property("walletSettingsPasswordRow", "visible", False, timeout_ms=10000) - harness.driver.wait_for_property("walletSettingsBackupRow", "visible", True, timeout_ms=10000) + harness.driver.wait_for_property("settingsv2WalletPasswordRow", "visible", False, timeout_ms=10000) + harness.driver.wait_for_property("settingsv2WalletBackupRow", "visible", True, timeout_ms=10000) checkpoints.checkpoint("external signer wallet hides password settings", harness.driver) create_wallet(harness.gui_rpc_port, "miner", load_on_startup=False) diff --git a/test/functional/qml_test_password_wallet.py b/test/functional/qml_test_password_wallet.py index 1b52fad0c4..f619e6281a 100644 --- a/test/functional/qml_test_password_wallet.py +++ b/test/functional/qml_test_password_wallet.py @@ -234,9 +234,9 @@ def close_wallet_from_selector(gui, wallet_name): def open_wallet_settings_page(gui): gui.click("desktopWalletSettingsTabButton") - gui.wait_for_property("settings_wallet", "visible", True, timeout_ms=10000) - gui.click("settings_wallet") - gui.wait_for_page("walletSettingsPage", timeout_ms=10000) + gui.wait_for_property("settingsSidebar_wallet", "visible", True, timeout_ms=10000) + gui.click("settingsSidebar_wallet") + gui.wait_for_page("settingsv2WalletSettingsPage", timeout_ms=10000) def open_import_wallet_page(gui): @@ -551,8 +551,8 @@ def case_close_loaded_wallet_from_selector(harness, checkpoints): remaining_wallet = next(name for name in wallet_names if name != selected_wallet) open_wallet_settings_page(gui) - gui.wait_for_property("walletSettingsPasswordRow", "visible", True, timeout_ms=10000) - gui.wait_for_property("walletSettingsBackupRow", "visible", True, timeout_ms=10000) + gui.wait_for_property("settingsv2WalletPasswordRow", "visible", True, timeout_ms=10000) + gui.wait_for_property("settingsv2WalletBackupRow", "visible", True, timeout_ms=10000) checkpoints.checkpoint("wallet settings opened", gui) open_wallet_selector(gui) diff --git a/test/functional/qml_test_peers.py b/test/functional/qml_test_peers.py index c20c864ff3..12dad9ad46 100644 --- a/test/functional/qml_test_peers.py +++ b/test/functional/qml_test_peers.py @@ -464,22 +464,22 @@ def navigate_to_peers(gui): # Peers moved out of node settings into a dedicated NodeRunner header tab. gui.click("peersTabButton") gui.wait_for_page("peers") - _wait_for_node_settings_idle(gui) + _wait_for_page_stack_idle(gui) -def _wait_for_node_settings_idle(gui, timeout_ms=PEER_ACTION_TIMEOUT_SECS * 1000) -> None: +def _wait_for_page_stack_idle(gui, timeout_ms=PEER_ACTION_TIMEOUT_SECS * 1000) -> None: """Wait until page-stack transitions to/from the Peers page have settled. - Peers moved out of the NodeSettings stack onto the main page stack, so wait + Peers live on the main page stack, so wait on the app's stack views via settle() (missing stacks are ignored).""" gui.settle(timeout_ms=timeout_ms) def _open_peer_details(gui, node_id: int) -> None: - _wait_for_node_settings_idle(gui) + _wait_for_page_stack_idle(gui) gui.click(f"peerListItem_{node_id}") gui.wait_for_page("peerDetails", timeout_ms=8000) - _wait_for_node_settings_idle(gui) + _wait_for_page_stack_idle(gui) print(f" Opened PeerDetails for node id={node_id}") @@ -538,7 +538,7 @@ def test_ban_peer(gui, harness, node_id, duration_secs, duration_label): # Wait for PeerDetails to navigate back via its onDisconnected handler. gui.wait_for_page("peers", timeout_ms=PEER_ACTION_TIMEOUT_SECS * 1000) - _wait_for_node_settings_idle(gui) + _wait_for_page_stack_idle(gui) def test_unban_peer(gui, harness): @@ -547,14 +547,14 @@ def test_unban_peer(gui, harness): # StackView disables input during transitions (500ms pop animation for # PeerDetails→Peers). Wait for it to finish; pushing BannedPeers while # the StackView is busy is silently ignored by Qt. - _wait_for_node_settings_idle(gui) + _wait_for_page_stack_idle(gui) gui.wait_for_property("viewBannedPeersButton", "enabled", True, timeout_ms=PEER_ACTION_TIMEOUT_SECS * 1000) # The ban list button is in the Peers page footer. gui.click("viewBannedPeersButton") gui.wait_for_page("bannedPeers", timeout_ms=8000) - _wait_for_node_settings_idle(gui) + _wait_for_page_stack_idle(gui) print(" Navigated to BannedPeers page") gui.click("unbanButton_0") @@ -747,10 +747,10 @@ def run_tests(): # The 1-year ban from the last iteration is still in the ban list. test_unban_peer(gui, harness) - _wait_for_node_settings_idle(gui) + _wait_for_page_stack_idle(gui) gui.click("bannedPeersBackButton") gui.wait_for_page("peers") - _wait_for_node_settings_idle(gui) + _wait_for_page_stack_idle(gui) assert not gui.get_property("viewBannedPeersButton", "visible"), \ "viewBannedPeersButton should be hidden after UI unban" diff --git a/test/functional/qml_test_proxy.py b/test/functional/qml_test_proxy.py index 9a3253bec7..f49e944a62 100644 --- a/test/functional/qml_test_proxy.py +++ b/test/functional/qml_test_proxy.py @@ -36,27 +36,27 @@ def navigate_to_proxy_settings(gui): gui.click("nodeSettingsButton") gui.settle() - gui.wait_for_property("settings_connection", "visible", True, timeout_ms=5000) - gui.click("settings_connection") - gui.wait_for_page("gotoProxy", timeout_ms=5000) - gui.click("gotoProxy") - gui.wait_for_page("settingsProxy", timeout_ms=5000) + gui.wait_for_property("settingsSidebar_connection", "visible", True, timeout_ms=5000) + gui.click("settingsSidebar_connection") + gui.wait_for_page("settingsv2ProxySettingsRow", timeout_ms=5000) + gui.click("settingsv2ProxySettingsRow") + gui.wait_for_page("settingsv2ProxySettingsPage", timeout_ms=5000) print(" Navigated to Proxy Settings page.") def leave_proxy_settings_with_done(gui): """Commit draft proxy settings and return to Connection settings.""" - gui.wait_for_property("settingsProxyDone", "enabled", True, timeout_ms=2000) - gui.click("settingsProxyDone") - gui.wait_for_page("gotoProxy", timeout_ms=5000) + gui.wait_for_property("settingsv2ProxySettingsSaveButton", "enabled", True, timeout_ms=2000) + gui.click("settingsv2ProxySettingsSaveButton") + gui.wait_for_page("settingsv2ProxySettingsRow", timeout_ms=5000) def navigate_back_from_connection_settings(gui): """Navigate back from Connection settings to the runtime settings shell.""" - if gui.object_exists("nodeSettingsDoneButton"): - gui.click("nodeSettingsDoneButton") + if gui.object_exists("settingsv2SettingsDoneButton"): + gui.click("settingsv2SettingsDoneButton") else: - gui.click("settingsConnectionBack") + gui.click("activityTabButton") gui.settle() if gui.object_exists("desktopWalletSettingsTabButton"): gui.wait_for_property("desktopWalletSettingsTabButton", "visible", True, timeout_ms=5000) @@ -69,34 +69,34 @@ def test_default_proxy_toggle(gui): print("\n── test_default_proxy_toggle ──────────────────────────────────────") # Proxy should be disabled by default (fresh datadir, no prior config). - checked = gui.get_property("proxyEnableSwitch", "checked") + checked = gui.get_property("settingsv2ProxyEnableSwitch", "checked") assert not checked, f"Expected proxy disabled by default, got checked={checked}" - dirty = gui.get_property("settingsProxy", "proxySettingsDirty") + dirty = gui.get_property("settingsv2ProxyRestartNotice", "visible") assert not dirty, "Expected proxySettingsDirty=False before any change" - draft_dirty = gui.get_property("settingsProxy", "proxyDraftDirty") + draft_dirty = gui.get_property("settingsv2ProxySettingsPage", "proxyDraftDirty") assert not draft_dirty, "Expected proxyDraftDirty=False before any change" # Enable proxy. - gui.click("proxyEnableSwitch") - gui.wait_for_property("proxyEnableSwitch", "checked", True, timeout_ms=2000) - checked = gui.get_property("proxyEnableSwitch", "checked") + gui.click("settingsv2ProxyEnableSwitch") + gui.wait_for_property("settingsv2ProxyEnableSwitch", "checked", True, timeout_ms=2000) + checked = gui.get_property("settingsv2ProxyEnableSwitch", "checked") assert checked, "Expected proxyEnableSwitch to be checked after click" print(" Default proxy toggled ON: OK") - gui.wait_for_property("settingsProxy", "proxyDraftDirty", True, timeout_ms=2000) - dirty = gui.get_property("settingsProxy", "proxySettingsDirty") + gui.wait_for_property("settingsv2ProxySettingsPage", "proxyDraftDirty", True, timeout_ms=2000) + dirty = gui.get_property("settingsv2ProxyRestartNotice", "visible") assert not dirty, "Expected proxySettingsDirty=False before pressing Done" print(" Proxy edit is draft-only before Done: OK") # Disable proxy. - gui.click("proxyEnableSwitch") - gui.wait_for_property("proxyEnableSwitch", "checked", False, timeout_ms=2000) - checked = gui.get_property("proxyEnableSwitch", "checked") + gui.click("settingsv2ProxyEnableSwitch") + gui.wait_for_property("settingsv2ProxyEnableSwitch", "checked", False, timeout_ms=2000) + checked = gui.get_property("settingsv2ProxyEnableSwitch", "checked") assert not checked, "Expected proxyEnableSwitch to be unchecked after second click" print(" Default proxy toggled OFF: OK") - gui.wait_for_property("settingsProxy", "proxyDraftDirty", False, timeout_ms=2000) + gui.wait_for_property("settingsv2ProxySettingsPage", "proxyDraftDirty", False, timeout_ms=2000) print(" proxyDraftDirty=False after reverting proxy change: OK") @@ -104,22 +104,19 @@ def test_proxy_valid_address(gui): print("\n── test_proxy_valid_address ────────────────────────────────────────") # Enable proxy so the address field becomes active. - if not gui.get_property("proxyEnableSwitch", "checked"): - gui.click("proxyEnableSwitch") - gui.wait_for_property("proxyEnableSwitch", "checked", True, timeout_ms=2000) - - gui.wait_for_property("proxyAddressInput", "enabled", True, timeout_ms=2000) - gui.set_text("proxyAddressInput", "") - gui.wait_for_property("proxyAddressInput", "text", "", timeout_ms=2000) - gui.click("proxyAddressSetting") - gui.wait_for_property("proxyAddressInput", "activeFocus", True, timeout_ms=2000) - gui.type_text("proxyAddressInput", "10.0.0.1:9050") - gui.wait_for_property("proxyAddressInput", "text", "10.0.0.1:9050", timeout_ms=2000) - gui.wait_for_property("proxyAddressInput", "validInput", True, timeout_ms=2000) - - valid = gui.get_property("proxyAddressInput", "validInput") - assert valid, f"Expected '10.0.0.1:9050' to pass validation, got validInput={valid}" - gui.wait_for_property("settingsProxyDone", "enabled", True, timeout_ms=2000) + if not gui.get_property("settingsv2ProxyEnableSwitch", "checked"): + gui.click("settingsv2ProxyEnableSwitch") + gui.wait_for_property("settingsv2ProxyEnableSwitch", "checked", True, timeout_ms=2000) + + gui.wait_for_property("settingsv2ProxyAddressInput", "enabled", True, timeout_ms=2000) + gui.set_text("settingsv2ProxyAddressInput", "") + gui.wait_for_property("settingsv2ProxyAddressInput", "text", "", timeout_ms=2000) + gui.invoke("settingsv2ProxyAddressInput", "forceActiveFocus") + gui.wait_for_property("settingsv2ProxyAddressInput", "activeFocus", True, timeout_ms=2000) + gui.type_text("settingsv2ProxyAddressInput", "10.0.0.1:9050") + gui.wait_for_property("settingsv2ProxyAddressInput", "text", "10.0.0.1:9050", timeout_ms=2000) + gui.wait_for_property("settingsv2ProxySettingsPage", "draftProxyValidationError", "", timeout_ms=2000) + gui.wait_for_property("settingsv2ProxySettingsSaveButton", "enabled", True, timeout_ms=2000) print(" Valid address accepted: OK") @@ -127,45 +124,47 @@ def test_proxy_invalid_address(gui): print("\n── test_proxy_invalid_address ──────────────────────────────────────") # Enable proxy so the address field becomes active. - if not gui.get_property("proxyEnableSwitch", "checked"): - gui.click("proxyEnableSwitch") - gui.wait_for_property("proxyEnableSwitch", "checked", True, timeout_ms=2000) + if not gui.get_property("settingsv2ProxyEnableSwitch", "checked"): + gui.click("settingsv2ProxyEnableSwitch") + gui.wait_for_property("settingsv2ProxyEnableSwitch", "checked", True, timeout_ms=2000) - gui.wait_for_property("proxyAddressInput", "enabled", True, timeout_ms=2000) + gui.wait_for_property("settingsv2ProxyAddressInput", "enabled", True, timeout_ms=2000) # Enter an address with invalid IP octets. - gui.set_text("proxyAddressInput", "999.999.999.999:9050") - gui.wait_for_property("proxyAddressInput", "validInput", False, timeout_ms=2000) - - valid = gui.get_property("proxyAddressInput", "validInput") - assert not valid, f"Expected invalid address to fail validation, got validInput={valid}" - gui.wait_for_property("settingsProxyDone", "enabled", False, timeout_ms=2000) + gui.set_text("settingsv2ProxyAddressInput", "999.999.999.999:9050") + gui.wait_for_property( + "settingsv2ProxySettingsPage", + "draftProxyValidationError", + lambda error: len(error) > 0, + timeout_ms=2000, + ) + gui.wait_for_property("settingsv2ProxySettingsSaveButton", "enabled", False, timeout_ms=2000) print(" Invalid address rejected: OK") # Restore to a valid address for subsequent tests. - gui.set_text("proxyAddressInput", "127.0.0.1:9050") - gui.wait_for_property("proxyAddressInput", "validInput", True, timeout_ms=2000) - gui.wait_for_property("settingsProxyDone", "enabled", True, timeout_ms=2000) + gui.set_text("settingsv2ProxyAddressInput", "127.0.0.1:9050") + gui.wait_for_property("settingsv2ProxySettingsPage", "draftProxyValidationError", "", timeout_ms=2000) + gui.wait_for_property("settingsv2ProxySettingsSaveButton", "enabled", True, timeout_ms=2000) def test_tor_proxy_toggle(gui): print("\n── test_tor_proxy_toggle ───────────────────────────────────────────") # Tor proxy should be disabled by default. - checked = gui.get_property("torEnableSwitch", "checked") + checked = gui.get_property("settingsv2TorEnableSwitch", "checked") assert not checked, f"Expected Tor proxy disabled by default, got checked={checked}" # Enable Tor proxy. - gui.click("torEnableSwitch") - gui.wait_for_property("torEnableSwitch", "checked", True, timeout_ms=2000) - gui.wait_for_property("torAddressInput", "enabled", True, timeout_ms=2000) - checked = gui.get_property("torEnableSwitch", "checked") + gui.click("settingsv2TorEnableSwitch") + gui.wait_for_property("settingsv2TorEnableSwitch", "checked", True, timeout_ms=2000) + gui.wait_for_property("settingsv2TorAddressInput", "enabled", True, timeout_ms=2000) + checked = gui.get_property("settingsv2TorEnableSwitch", "checked") assert checked, "Expected torEnableSwitch to be checked after click" print(" Tor proxy toggled ON: OK") # Disable Tor proxy. - gui.click("torEnableSwitch") - gui.wait_for_property("torEnableSwitch", "checked", False, timeout_ms=2000) - checked = gui.get_property("torEnableSwitch", "checked") + gui.click("settingsv2TorEnableSwitch") + gui.wait_for_property("settingsv2TorEnableSwitch", "checked", False, timeout_ms=2000) + checked = gui.get_property("settingsv2TorEnableSwitch", "checked") assert not checked, "Expected torEnableSwitch to be unchecked after second click" print(" Tor proxy toggled OFF: OK") @@ -173,32 +172,32 @@ def test_tor_proxy_toggle(gui): def test_back_discards_proxy_draft(gui): print("\n── test_back_discards_proxy_draft ─────────────────────────────────") - if not gui.get_property("proxyEnableSwitch", "checked"): - gui.click("proxyEnableSwitch") - gui.wait_for_property("proxyEnableSwitch", "checked", True, timeout_ms=2000) + if not gui.get_property("settingsv2ProxyEnableSwitch", "checked"): + gui.click("settingsv2ProxyEnableSwitch") + gui.wait_for_property("settingsv2ProxyEnableSwitch", "checked", True, timeout_ms=2000) - gui.wait_for_property("proxyAddressInput", "enabled", True, timeout_ms=2000) - gui.set_text("proxyAddressInput", "10.0.0.5:9050") - gui.wait_for_property("proxyAddressInput", "text", "10.0.0.5:9050", timeout_ms=2000) - gui.wait_for_property("settingsProxy", "proxyDraftDirty", True, timeout_ms=2000) - dirty = gui.get_property("settingsProxy", "proxySettingsDirty") + gui.wait_for_property("settingsv2ProxyAddressInput", "enabled", True, timeout_ms=2000) + gui.set_text("settingsv2ProxyAddressInput", "10.0.0.5:9050") + gui.wait_for_property("settingsv2ProxyAddressInput", "text", "10.0.0.5:9050", timeout_ms=2000) + gui.wait_for_property("settingsv2ProxySettingsPage", "proxyDraftDirty", True, timeout_ms=2000) + dirty = gui.get_property("settingsv2ProxyRestartNotice", "visible") assert not dirty, "Expected model to remain unchanged before pressing Done" - gui.click("settingsProxyBack") - gui.wait_for_property("discardProxyChangesPopup", "visible", True, timeout_ms=2000) - gui.click("discardProxyChangesCancelButton") - gui.wait_for_property("discardProxyChangesPopup", "visible", False, timeout_ms=2000) - gui.wait_for_property("proxyAddressInput", "text", "10.0.0.5:9050", timeout_ms=2000) + gui.click("settingsv2ProxySettingsBackButton") + gui.wait_for_property("settingsv2DiscardProxyChangesPopup", "visible", True, timeout_ms=2000) + gui.click("settingsv2DiscardProxyChangesCancelButton") + gui.wait_for_property("settingsv2DiscardProxyChangesPopup", "visible", False, timeout_ms=2000) + gui.wait_for_property("settingsv2ProxyAddressInput", "text", "10.0.0.5:9050", timeout_ms=2000) print(" Back cancellation keeps draft changes: OK") - gui.click("settingsProxyBack") - gui.wait_for_property("discardProxyChangesPopup", "visible", True, timeout_ms=2000) - gui.click("discardProxyChangesConfirmButton") - gui.wait_for_page("gotoProxy", timeout_ms=5000) - gui.click("gotoProxy") - gui.wait_for_page("settingsProxy", timeout_ms=5000) - gui.wait_for_property("proxyEnableSwitch", "checked", False, timeout_ms=2000) - gui.wait_for_property("settingsProxy", "proxyDraftDirty", False, timeout_ms=2000) + gui.click("settingsv2ProxySettingsBackButton") + gui.wait_for_property("settingsv2DiscardProxyChangesPopup", "visible", True, timeout_ms=2000) + gui.click("settingsv2DiscardProxyChangesConfirmButton") + gui.wait_for_page("settingsv2ProxySettingsRow", timeout_ms=5000) + gui.click("settingsv2ProxySettingsRow") + gui.wait_for_page("settingsv2ProxySettingsPage", timeout_ms=5000) + gui.wait_for_property("settingsv2ProxyEnableSwitch", "checked", False, timeout_ms=2000) + gui.wait_for_property("settingsv2ProxySettingsPage", "proxyDraftDirty", False, timeout_ms=2000) print(" Back discard leaves persisted settings unchanged: OK") @@ -260,19 +259,19 @@ def run_tests(): # Prepare state for the persistence test. Runtime proxy settings remain # local drafts until the page-level Done button is pressed. if harness.datadir: - if not gui.get_property("proxyEnableSwitch", "checked"): - gui.click("proxyEnableSwitch") - gui.wait_for_property("proxyEnableSwitch", "checked", True, timeout_ms=2000) - gui.wait_for_property("proxyAddressInput", "enabled", True, timeout_ms=2000) - gui.set_text("proxyAddressInput", "10.0.0.1:9050") - gui.wait_for_property("proxyAddressInput", "validInput", True, timeout_ms=2000) - - if not gui.get_property("torEnableSwitch", "checked"): - gui.click("torEnableSwitch") - gui.wait_for_property("torEnableSwitch", "checked", True, timeout_ms=2000) - gui.wait_for_property("torAddressInput", "enabled", True, timeout_ms=2000) - gui.set_text("torAddressInput", "127.0.0.1:9150") - gui.wait_for_property("torAddressInput", "validInput", True, timeout_ms=2000) + if not gui.get_property("settingsv2ProxyEnableSwitch", "checked"): + gui.click("settingsv2ProxyEnableSwitch") + gui.wait_for_property("settingsv2ProxyEnableSwitch", "checked", True, timeout_ms=2000) + gui.wait_for_property("settingsv2ProxyAddressInput", "enabled", True, timeout_ms=2000) + gui.set_text("settingsv2ProxyAddressInput", "10.0.0.1:9050") + gui.wait_for_property("settingsv2ProxySettingsPage", "draftProxyValidationError", "", timeout_ms=2000) + + if not gui.get_property("settingsv2TorEnableSwitch", "checked"): + gui.click("settingsv2TorEnableSwitch") + gui.wait_for_property("settingsv2TorEnableSwitch", "checked", True, timeout_ms=2000) + gui.wait_for_property("settingsv2TorAddressInput", "enabled", True, timeout_ms=2000) + gui.set_text("settingsv2TorAddressInput", "127.0.0.1:9150") + gui.wait_for_property("settingsv2ProxySettingsPage", "draftTorValidationError", "", timeout_ms=2000) leave_proxy_settings_with_done(gui) navigate_back_from_connection_settings(gui) diff --git a/test/functional/qml_test_settings_display.py b/test/functional/qml_test_settings_display.py index bfc4d708da..d896370b30 100644 --- a/test/functional/qml_test_settings_display.py +++ b/test/functional/qml_test_settings_display.py @@ -2,24 +2,12 @@ # 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. -"""End-to-end tests for the Display settings page. - -Tests language selection, display unit switching (BTC / SAT), and the -"Ask before opening links" toggle. - -These tests run post-onboarding and do not require a peer connection, but -they do require the node to start up, so generous wait timeouts are used. - -This test requires: - - bitcoin-core-app built with -DENABLE_TEST_AUTOMATION=ON -""" +"""End-to-end tests for the redesigned Display settings page.""" import shutil import sys -import time from qml_test_harness import ( - GUI_STARTUP_TIMEOUT, QmlTestHarness, complete_onboarding, dump_qml_tree, @@ -27,118 +15,68 @@ ) from qml_driver import QmlDriverError -# The node must start up before post-onboarding pages are interactive. -# Use a generous timeout for waits that follow onboarding completion. -POST_ONBOARDING_TIMEOUT_MS = 30000 + +POST_ONBOARDING_TIMEOUT_MS = 30_000 DISPLAY_SETTING_ROWS = ( - "gotoTheme", - "gotoDisplayUnit", - "gotoLanguage", - "gotoThirdPartyTransactionUrls", - "gotoMoneyFont", + "settingsv2DisplayThemeRow", + "settingsv2DisplayBlockStatusSizeRow", + "settingsv2DisplayMoneyFontRow", + "settingsv2DisplayUnitRow", + "settingsv2DisplayLanguageRow", + "settingsv2DisplayTransactionUrlsRow", ) -# ── Navigation helpers ──────────────────────────────────────────────────────── - def navigate_to_display_settings(gui): - """From the NodeRunner main screen, navigate to the Display settings page.""" + """Open Settings and select Display from the sidebar.""" gui.click("nodeSettingsButton") - # Display is a sidebar section (settings_display) in the desktop layout. - gui.wait_for_property("settings_display", "visible", True, timeout_ms=5000) - gui.click("settings_display") - # SettingsDisplay is identified by the presence of gotoDisplayUnit. - gui.wait_for_page("gotoDisplayUnit", timeout_ms=5000) - print(" Navigated to Display settings page") + gui.wait_for_property("settingsSidebar_display", "visible", True, timeout_ms=5000) + gui.click("settingsSidebar_display") + gui.wait_for_page("settingsv2DisplaySettingsPage", timeout_ms=5000) + gui.wait_for_page("settingsv2DisplayUnitPicker", timeout_ms=5000) + print(" Navigated to redesigned Display settings page") def assert_display_rows_have_no_descriptions(gui): - """The top-level Display page follows the single-line row design.""" for row in DISPLAY_SETTING_ROWS: description = gui.get_property(row, "description") assert description == "", f"{row} should not show subtext, got: {description!r}" -def reset_display_unit_to_btc(gui): - """Reset the persisted display unit to BTC. - - Precondition: caller is on the SettingsDisplay page with gotoDisplayUnit - visible. Callers should wrap invocations in `try/except QmlDriverError: - pass` for best-effort teardown that does not mask the original test failure. - """ - gui.click("gotoDisplayUnit") - gui.wait_for_page("settingsDisplayUnitPage", timeout_ms=5000) - gui.click("displayUnitBTC") - gui.click("settingsDisplayUnitBack") - gui.wait_for_page("gotoDisplayUnit", timeout_ms=5000) +def select_display_unit(gui, item_name, expected_text): + gui.click("settingsv2DisplayUnitPickerButton") + gui.wait_for_property(item_name, "visible", True, timeout_ms=3000) + gui.click(item_name) + gui.wait_for_property( + "settingsv2DisplayUnitPicker", "currentText", expected_text, timeout_ms=3000 + ) -def reset_language_to_system_default(gui): - """Reset the persisted language to the System default (empty tag). - - Precondition: caller is on the SettingsDisplay page with gotoLanguage - visible. The helper navigates into SettingsLanguage, picks the empty-tag - delegate, and waits to return to SettingsDisplay. Callers should wrap - invocations in `try/except QmlDriverError: pass` for best-effort teardown - that does not mask the original test failure. - """ - gui.click("gotoLanguage") +def select_language(gui, search_text, item_name): + gui.click("settingsv2DisplayLanguageRow") gui.wait_for_page("settingsLanguagePage", timeout_ms=5000) - gui.wait_for_page("language_", timeout_ms=3000) # wait for delegate to render - gui.click("language_") # objectName: "language_" + "" = "language_" - gui.wait_for_page("gotoLanguage", timeout_ms=5000) + if search_text: + gui.set_text("languageSearch", search_text) + gui.wait_for_page(item_name, timeout_ms=3000) + gui.click(item_name) + gui.wait_for_page("settingsv2DisplayLanguageRow", timeout_ms=5000) -# ── Individual test cases ───────────────────────────────────────────────────── +def reset_display_unit_to_btc(gui): + select_display_unit(gui, "settingsv2DisplayUnitBTC", "BTC") + + +def reset_language_to_system_default(gui): + select_language(gui, "", "language_") + def test_display_unit_selection(gui): - """Select SAT on the Display unit page and verify it is reflected.""" print("\n── test_display_unit_selection ───────────────────────────────") - try: - gui.click("gotoDisplayUnit") - gui.wait_for_page("settingsDisplayUnitPage", timeout_ms=5000) - print(" Navigated to SettingsDisplayUnit page") - - # If SAT is selected (state persists across runs), switch to BTC first. - # We must navigate to a fresh page after the switch because clicking a - # checkable OptionButton breaks its declarative `checked:` binding. - if gui.get_property("displayUnitSAT", "checked"): - gui.click("displayUnitBTC") - gui.click("settingsDisplayUnitBack") - gui.wait_for_page("gotoDisplayUnit", timeout_ms=5000) - gui.click("gotoDisplayUnit") - gui.wait_for_page("settingsDisplayUnitPage", timeout_ms=5000) - print(" Switched to BTC starting state") - - btc_checked = gui.get_property("displayUnitBTC", "checked") - sat_checked = gui.get_property("displayUnitSAT", "checked") - assert btc_checked, ( - f"BTC should be checked at test start, got btc={btc_checked} sat={sat_checked}" - ) - assert not sat_checked, ( - f"SAT should not be checked at test start, got sat={sat_checked}" - ) - print(f" Starting state: BTC={btc_checked}, SAT={sat_checked} PASSED") - - # Select SAT. - gui.click("displayUnitSAT") - sat_after = gui.get_property("displayUnitSAT", "checked") - btc_after = gui.get_property("displayUnitBTC", "checked") - assert sat_after, f"SAT should be checked after clicking, got sat={sat_after}" - assert not btc_after, f"BTC should be unchecked after selecting SAT, got btc={btc_after}" - print(f" After SAT selection: SAT={sat_after}, BTC={btc_after} PASSED") - - # Go back and reset to BTC for future runs. - gui.click("settingsDisplayUnitBack") - gui.wait_for_page("gotoDisplayUnit", timeout_ms=5000) - - gui.click("gotoDisplayUnit") - gui.wait_for_page("settingsDisplayUnitPage", timeout_ms=5000) - gui.click("displayUnitBTC") - gui.click("settingsDisplayUnitBack") - gui.wait_for_page("gotoDisplayUnit", timeout_ms=5000) - print(" Reset display unit to BTC PASSED") + reset_display_unit_to_btc(gui) + select_display_unit(gui, "settingsv2DisplayUnitSAT", "sat") + assert gui.get_property("settingsv2DisplayUnitPicker", "currentValue") == 3 + print(" Display unit changed from BTC to sat PASSED") finally: try: reset_display_unit_to_btc(gui) @@ -147,61 +85,17 @@ def test_display_unit_selection(gui): def test_language_selection(gui): - """Select Spanish and verify translated headers update.""" print("\n── test_language_selection ───────────────────────────────────") - try: - gui.click("gotoLanguage") - gui.wait_for_page("settingsLanguagePage", timeout_ms=5000) - print(" Navigated to SettingsLanguage page") - - # Filter the list to Spanish so the delegate is rendered by the ListView. - gui.set_text("languageSearch", "español") - gui.wait_for_page("language_es", timeout_ms=3000) - gui.click("language_es") - # Selecting a language navigates back to SettingsDisplay automatically. - gui.wait_for_page("gotoLanguage", timeout_ms=5000) - print(" Selected Spanish (es) and returned to Display settings") - + select_language(gui, "español", "language_es") assert_display_rows_have_no_descriptions(gui) - # Verify translation propagated to other row headers on this page. - lang_header = gui.get_property("gotoLanguage", "header") - assert lang_header == "Idioma", ( - f"'Language' row header should be 'Idioma' in Spanish, got: {lang_header!r}" - ) - print(f" Language row header translated: {lang_header!r} PASSED") - - unit_header = gui.get_property("gotoDisplayUnit", "header") - assert unit_header == "Unidad de visualización", ( - f"'Display unit' row header should be translated in Spanish, got: {unit_header!r}" - ) - print(f" Display unit row header translated: {unit_header!r} PASSED") - - # Reset to System default (empty tag). - gui.click("gotoLanguage") - gui.wait_for_page("settingsLanguagePage", timeout_ms=5000) - gui.wait_for_page("language_", timeout_ms=3000) # wait for delegate to render - gui.click("language_") # objectName: "language_" + "" = "language_" - gui.wait_for_page("gotoLanguage", timeout_ms=5000) - - assert_display_rows_have_no_descriptions(gui) - print(" Reset to System default PASSED") - - # Verify English headers are restored after reset. - lang_header_reset = gui.get_property("gotoLanguage", "header") - assert lang_header_reset == "Language", ( - f"'Language' header should be restored to English after reset, got: {lang_header_reset!r}" - ) - unit_header_reset = gui.get_property("gotoDisplayUnit", "header") - assert unit_header_reset == "Display unit", ( - f"'Display unit' header should be restored to English after reset, got: {unit_header_reset!r}" - ) - print(f" Headers restored to English PASSED") + language_title = gui.get_property("settingsv2DisplayLanguageRow", "title") + unit_title = gui.get_property("settingsv2DisplayUnitRow", "title") + assert language_title == "Idioma", language_title + assert unit_title == "Unidad de visualización", unit_title + print(" Spanish translated the inline Display rows PASSED") finally: - # Best-effort: if the test failed mid-flow the persisted language may - # still be Spanish. Reset to System default so the restart phase starts - # from a known state within this test's temporary QSettings sandbox. try: reset_language_to_system_default(gui) except QmlDriverError: @@ -209,137 +103,68 @@ def test_language_selection(gui): def test_settings_persistence(datadir): - """Restart the app without -resetguisettings and verify settings persisted. - - Issue #512 requires: change unit/language → restart → verify persisted. - """ print("\n── test_settings_persistence ─────────────────────────────────") - - harness2 = QmlTestHarness( + harness = QmlTestHarness( extra_args=["-disablewallet"], reset_settings=False, datadir=datadir, ) try: - harness2.start() - gui2 = harness2.driver + harness.start() + gui = harness.driver + gui.wait_for_page("nodeSettingsButton", timeout_ms=POST_ONBOARDING_TIMEOUT_MS) + navigate_to_display_settings(gui) - try: - # Runtime restart tests launch as onboarded so they stay focused on - # display setting persistence, not first-run onboarding. - gui2.wait_for_page("nodeSettingsButton", timeout_ms=POST_ONBOARDING_TIMEOUT_MS) - print(" Reached NodeRunner main screen after restart") - - navigate_to_display_settings(gui2) - - # Verify SAT is still selected. - gui2.click("gotoDisplayUnit") - gui2.wait_for_page("settingsDisplayUnitPage", timeout_ms=5000) - sat_persisted = gui2.get_property("displayUnitSAT", "checked") - assert sat_persisted, ( - f"SAT should still be selected after restart, got checked={sat_persisted}" - ) - print(" Display unit (SAT) persisted across restart PASSED") - gui2.click("settingsDisplayUnitBack") - gui2.wait_for_page("gotoDisplayUnit", timeout_ms=5000) - - # Verify Spanish is still selected through translated Display rows. - lang_header = gui2.get_property("gotoLanguage", "header") - assert lang_header == "Idioma", ( - f"'Language' row header should be Spanish after restart, got: {lang_header!r}" - ) - unit_header = gui2.get_property("gotoDisplayUnit", "header") - assert unit_header == "Unidad de visualización", ( - f"'Display unit' row header should be Spanish after restart, got: {unit_header!r}" - ) - assert_display_rows_have_no_descriptions(gui2) - print(" Language (Español) persisted across restart PASSED") - finally: - # Best-effort: reset persisted settings to defaults before the - # harness shuts down. Values are flushed by QSettings on app exit, - # so this must run before harness2.stop(). Swallow driver errors - # so a mid-test failure is not masked. - try: - reset_display_unit_to_btc(gui2) - except QmlDriverError: - pass - try: - reset_language_to_system_default(gui2) - except QmlDriverError: - pass + assert gui.get_property("settingsv2DisplayUnitPicker", "currentValue") == 3 + assert gui.get_property("settingsv2DisplayLanguageRow", "title") == "Idioma" + assert gui.get_property("settingsv2DisplayUnitRow", "title") == "Unidad de visualización" + print(" Display unit and language persisted across restart PASSED") + reset_display_unit_to_btc(gui) + reset_language_to_system_default(gui) finally: - harness2.stop() - + harness.stop() -# ── Main ────────────────────────────────────────────────────────────────────── def run_tests(): args = parse_args() harness = QmlTestHarness(socket_path=args.socket_path, extra_args=["-disablewallet"]) + datadir = None + tmpdir = None try: harness.start() gui = harness.driver - - # Complete onboarding to reach the main node screen. complete_onboarding(gui) - - # Wait for the NodeRunner main screen. - # Uses a generous timeout because the node starts up after onboarding. gui.wait_for_page("nodeSettingsButton", timeout_ms=POST_ONBOARDING_TIMEOUT_MS) - print("Reached NodeRunner main screen") navigate_to_display_settings(gui) assert_display_rows_have_no_descriptions(gui) - test_display_unit_selection(gui) test_language_selection(gui) - # Set known state for persistence test: SAT + Spanish. - print("\n── Setting up state for persistence test ─────────────────────") - gui.click("gotoDisplayUnit") - gui.wait_for_page("settingsDisplayUnitPage", timeout_ms=5000) - gui.click("displayUnitSAT") - gui.click("settingsDisplayUnitBack") - gui.wait_for_page("gotoDisplayUnit", timeout_ms=5000) - gui.click("gotoLanguage") - gui.wait_for_page("settingsLanguagePage", timeout_ms=5000) - gui.set_text("languageSearch", "español") - gui.wait_for_page("language_es", timeout_ms=3000) - gui.click("language_es") - gui.wait_for_page("gotoLanguage", timeout_ms=5000) - print(" State set: SAT + Spanish") - + select_display_unit(gui, "settingsv2DisplayUnitSAT", "sat") + select_language(gui, "español", "language_es") datadir = harness.datadir tmpdir = harness.tmpdir - - except Exception as e: - print(f"\nFAILED: {e}", file=sys.stderr) - import traceback - traceback.print_exc() + except Exception as error: + print(f"\nFAILED: {error}", file=sys.stderr) if harness.driver: dump_qml_tree(harness.driver) - sys.exit(1) + raise finally: - # Keep the datadir on disk so the second harness can reuse it. harness.stop(cleanup=False) - # Phase 2: restart without -resetguisettings and verify persistence. try: test_settings_persistence(datadir) - except Exception as e: - print(f"\nFAILED: {e}", file=sys.stderr) - import traceback - traceback.print_exc() - sys.exit(1) finally: if tmpdir: shutil.rmtree(tmpdir, ignore_errors=True) - print("\n" + "=" * 60) - print("All display settings tests PASSED") - print("=" * 60) + print("\nAll display settings tests PASSED") -if __name__ == '__main__': - run_tests() +if __name__ == "__main__": + try: + run_tests() + except Exception: + sys.exit(1) diff --git a/test/functional/qml_test_tray.py b/test/functional/qml_test_tray.py index 0eb5c725df..eff2e81883 100644 --- a/test/functional/qml_test_tray.py +++ b/test/functional/qml_test_tray.py @@ -46,9 +46,9 @@ def navigate_to_window_behavior(gui): """Open Settings then navigate to the Window Behavior page.""" gui.click("nodeSettingsButton") # Wait for the settings list with the Window Behavior entry. - gui.wait_for_page("settings_windowbehavior", timeout_ms=5000) - gui.click("settings_windowbehavior") - gui.wait_for_page("windowBehaviorPage", timeout_ms=5000) + gui.wait_for_page("settingsSidebar_window-behavior", timeout_ms=5000) + gui.click("settingsSidebar_window-behavior") + gui.wait_for_page("settingsv2WindowBehaviorSettingsPage", timeout_ms=5000) def run_tests(): @@ -110,9 +110,9 @@ def run_tests(): # ── Test 2: Required controls are present ───────────────────────────── print("Test 2: Verify expected controls exist on the page ...") required_controls = [ - "showTrayIconSwitch", - "minimizeToTraySwitch", - "minimizeOnCloseSwitch", + "settingsv2ShowTrayIconSwitch", + "settingsv2MinimizeToTraySwitch", + "settingsv2MinimizeOnCloseSwitch", ] all_objects = gui.list_objects() object_names = {o["objectName"] for o in all_objects} @@ -126,9 +126,9 @@ def run_tests(): # showTrayIcon defaults to true; the others default to false. print("Test 3: Verify default switch states ...") expected_defaults = { - "showTrayIconSwitch": True, # show tray icon is on by default - "minimizeToTraySwitch": False, # minimize-to-tray is off by default - "minimizeOnCloseSwitch": False, # minimize-on-close is off by default + "settingsv2ShowTrayIconSwitch": True, + "settingsv2MinimizeToTraySwitch": False, + "settingsv2MinimizeOnCloseSwitch": False, } for switch_name, expected in expected_defaults.items(): checked = gui.get_property(switch_name, "checked") @@ -141,7 +141,7 @@ def run_tests(): print(f" -> {switch_name}.checked == {str(expected).lower()} ✓") # ── Tests 4–5: showTrayIcon toggle round-trip ───────────────────────── - # Run the toggle tests while only one windowBehaviorPage instance is in + # Run the toggle tests while only one Window Behavior page instance is in # the StackView (before the back/re-open cycle), so objectName lookups # are unambiguous. # @@ -149,32 +149,32 @@ def run_tests(): # disabled) is already covered by the C++ unit tests in # test_desktopwindowbehaviormodel.cpp. Here we only verify that the # toggle round-trip works correctly via the UI on the offscreen backend. - print("Test 4: showTrayIconSwitch toggles off and the model reflects the change ...") - gui.click("showTrayIconSwitch") - gui.wait_for_property("showTrayIconSwitch", "checked", False, timeout_ms=2000) - print(" -> showTrayIconSwitch clicked off ✓") + print("Test 4: Show tray icon toggles off and the model reflects the change ...") + gui.click("settingsv2ShowTrayIconSwitch") + gui.wait_for_property("settingsv2ShowTrayIconSwitch", "checked", False, timeout_ms=2000) + print(" -> Show tray icon clicked off ✓") - print("Test 5: showTrayIconSwitch toggles back on ...") - gui.click("showTrayIconSwitch") - gui.wait_for_property("showTrayIconSwitch", "checked", True, timeout_ms=2000) - print(" -> showTrayIconSwitch restored to on ✓") + print("Test 5: Show tray icon toggles back on ...") + gui.click("settingsv2ShowTrayIconSwitch") + gui.wait_for_property("settingsv2ShowTrayIconSwitch", "checked", True, timeout_ms=2000) + print(" -> Show tray icon restored to on ✓") # ── Test 6: Sidebar navigation after interaction ────────────────────── print("Test 6: Sidebar navigation still works after interacting with Window Behavior ...") - gui.click("settings_about") - gui.wait_for_page("settingsAbout", timeout_ms=5000) + gui.click("settingsSidebar_about") + gui.wait_for_page("settingsv2AboutSettingsPage", timeout_ms=5000) print(" -> switched to About section ✓") # ── Test 7: Re-open page (round-trip) ───────────────────────────────── print("Test 7: Re-open Window Behavior page (round-trip) ...") - gui.click("settings_windowbehavior") - gui.wait_for_page("windowBehaviorPage", timeout_ms=5000) + gui.click("settingsSidebar_window-behavior") + gui.wait_for_page("settingsv2WindowBehaviorSettingsPage", timeout_ms=5000) print(" -> re-opened Window Behavior ✓") # ── Test 8: Close with minimizeOnClose keeps app alive ──────────────── print("Test 8: Enable minimizeOnClose, close window, verify app survives ...") - gui.click("minimizeOnCloseSwitch") - gui.wait_for_property("minimizeOnCloseSwitch", "checked", True, timeout_ms=2000) + gui.click("settingsv2MinimizeOnCloseSwitch") + gui.wait_for_property("settingsv2MinimizeOnCloseSwitch", "checked", True, timeout_ms=2000) gui.close_window() # If minimizeOnClose works, the close event is intercepted and the # app stays alive. Verify we can still communicate with the bridge. diff --git a/test/functional/qml_test_wallet_settings.py b/test/functional/qml_test_wallet_settings.py index 82149b7ae9..d84d5e973c 100644 --- a/test/functional/qml_test_wallet_settings.py +++ b/test/functional/qml_test_wallet_settings.py @@ -44,7 +44,7 @@ def make_screenshot_root(): class CheckpointRecorder: - STACK_VIEW_NAMES = ("mainPageStack", "createWalletWizard", "nodeSettingsStack") + STACK_VIEW_NAMES = ("mainPageStack", "createWalletWizard", "settingsNavigationStack_wallet") def __init__(self, case_name, save_screenshots, screenshot_root): self.case_name = case_name @@ -146,9 +146,9 @@ def load_wallet(gui, harness, wallet_name): def open_wallet_settings(gui): gui.click("desktopWalletSettingsTabButton") gui.settle() - gui.wait_for_property("settings_wallet", "visible", True, timeout_ms=5000) - gui.click("settings_wallet") - gui.wait_for_page("walletSettingsPage", timeout_ms=10000) + gui.wait_for_property("settingsSidebar_wallet", "visible", True, timeout_ms=5000) + gui.click("settingsSidebar_wallet") + gui.wait_for_page("settingsv2WalletSettingsPage", timeout_ms=10000) def open_wallet_selector(gui): @@ -241,23 +241,9 @@ def case_rename_persists_across_restart(harness, checkpoints): open_wallet_settings(gui) checkpoints.checkpoint("wallet settings opened", gui) - # Regression: the divider between Addresses and Set password used - # height: visible ? 1 : 0, which left it laid out at height 0 even though it - # was visible, so the line never rendered. On a passphrase-managed wallet it - # must have a real, non-zero height. - gui.wait_for_property("walletSettingsPasswordDivider", "visible", True, timeout_ms=5000) - divider_height = gui.get_property("walletSettingsPasswordDivider", "height") - assert divider_height and divider_height > 0, ( - f"Addresses/Set password divider should render with a non-zero height, got {divider_height!r}" - ) - checkpoints.checkpoint("password divider renders", gui) - - gui.click("walletSettingsNameEditButton") - gui.wait_for_property("walletSettingsNameEditField", "visible", True, timeout_ms=5000) - gui.set_text("walletSettingsNameEditField", display_name) - gui.wait_for_property("walletSettingsNameConfirmButton", "enabled", True, timeout_ms=5000) - gui.click("walletSettingsNameConfirmButton") - gui.wait_for_property("walletSettingsNameValue", "text", display_name, timeout_ms=5000) + gui.wait_for_property("settingsv2WalletNameInput", "visible", True, timeout_ms=5000) + gui.set_text("settingsv2WalletNameInput", display_name) + gui.invoke("settingsv2WalletNameInput", "editingFinished") gui.wait_for_property("walletBadge", "text", display_name, timeout_ms=5000) checkpoints.checkpoint("wallet renamed", gui) @@ -290,10 +276,12 @@ def case_backup_uses_automation_path(harness, checkpoints): open_wallet_settings(gui) checkpoints.checkpoint("wallet settings opened", gui) - gui.set_text("walletSettingsBackupPathField", backup_dir) - gui.click("walletSettingsBackupRow") + gui.set_text("settingsv2WalletSettingsBackupPathField", backup_dir) + gui.click("settingsv2WalletBackupRow") wait_for_file(backup_path) - assert gui.get_text("walletSettingsErrorText") == "", "Backup should not surface an error" + assert gui.get_property("settingsv2WalletSettingsPage", "errorText") == "", ( + "Backup should not surface an error" + ) checkpoints.checkpoint("wallet backup created", gui) @@ -316,7 +304,7 @@ def case_sign_verify_message(harness, checkpoints): address = rpc_call(harness.gui_rpc_port, "getnewaddress", ["", "legacy"], wallet=wallet_name) open_wallet_settings(gui) - gui.click("walletSettingsSignVerifyMessageRow") + gui.click("settingsv2WalletSignVerifyMessageRow") gui.wait_for_page("signVerifyMessagePage", timeout_ms=10000) checkpoints.checkpoint("sign verify message page opened", gui) @@ -365,7 +353,7 @@ def case_subpages_close_when_wallet_becomes_unselected(harness, checkpoints): checkpoints.checkpoint("managed wallet loaded", gui) open_wallet_settings(gui) - gui.click("walletSettingsPasswordRow") + gui.click("settingsv2WalletPasswordRow") gui.wait_for_page("walletPasswordSettingsPage", timeout_ms=10000) checkpoints.checkpoint("password subpage opened", gui) @@ -373,14 +361,14 @@ def case_subpages_close_when_wallet_becomes_unselected(harness, checkpoints): gui.wait_for_property("walletCloseConfirmationPopup", "opened", True, timeout_ms=5000) gui.click("walletCloseConfirmationConfirmButton") gui.wait_for_property("walletCloseConfirmationPopup", "opened", False, timeout_ms=5000) - gui.wait_for_page("walletSettingsPage", timeout_ms=10000) + gui.wait_for_page("settingsv2WalletSettingsPage", timeout_ms=10000) gui.wait_for_property("walletBadge", "noWalletLoaded", True, timeout_ms=5000) checkpoints.checkpoint("password subpage unwound after wallet close", gui) select_wallet(gui, wallet_name) wait_for_wallet_ready(harness, gui) gui.wait_for_property("walletBadge", "noWalletLoaded", False, timeout_ms=5000) - gui.wait_for_property("walletSettingsNameRow", "visible", True, timeout_ms=5000) + gui.wait_for_property("settingsv2WalletNameInput", "visible", True, timeout_ms=5000) checkpoints.checkpoint("wallet reselected from settings page", gui) @@ -405,12 +393,12 @@ def case_password_page_closes_when_selected_wallet_changes(harness, checkpoints) checkpoints.checkpoint("first wallet selected", gui) open_wallet_settings(gui) - gui.click("walletSettingsPasswordRow") + gui.click("settingsv2WalletPasswordRow") gui.wait_for_page("walletPasswordSettingsPage", timeout_ms=10000) checkpoints.checkpoint("password subpage opened for first wallet", gui) select_wallet(gui, wallet_names[1]) - gui.wait_for_page("walletSettingsPage", timeout_ms=10000) + gui.wait_for_page("settingsv2WalletSettingsPage", timeout_ms=10000) gui.wait_for_property("walletBadge", "text", wallet_names[1], timeout_ms=20000) checkpoints.checkpoint("password subpage unwound after selecting second wallet", gui) @@ -432,7 +420,7 @@ def case_wrong_current_password_clears_current_field(harness, checkpoints): checkpoints.checkpoint("managed wallet loaded", gui) open_wallet_settings(gui) - gui.click("walletSettingsPasswordRow") + gui.click("settingsv2WalletPasswordRow") gui.wait_for_page("walletPasswordSettingsPage", timeout_ms=10000) gui.set_text("walletPasswordCurrentField", "wrong password") diff --git a/test/qml/bitcoin_qmltests.qrc b/test/qml/bitcoin_qmltests.qrc index 8c964b14b2..19fdff54fa 100644 --- a/test/qml/bitcoin_qmltests.qrc +++ b/test/qml/bitcoin_qmltests.qrc @@ -16,17 +16,14 @@ tst_createwalletwizard.qml tst_debuglogoutputview.qml tst_desktopwallets.qml - tst_displaysettings.qml tst_dropdownbutton.qml tst_externalsignerreviewactions.qml tst_feeselection.qml tst_formcontrols.qml tst_mainrouting.qml tst_mempoolinformationrows.qml - tst_mempoolinformationsettings.qml tst_navbutton.qml tst_nodefeedback.qml - tst_nodesettings.qml tst_onboarding_datadir.qml tst_peeractions.qml tst_popuppicker.qml @@ -39,14 +36,11 @@ tst_settingsheader.qml tst_settingsnavigation.qml tst_settingsstatus.qml - tst_settingswallet.qml - tst_settingswindowbehavior.qml tst_utils.qml tst_valueinput.qml tst_walletpassphrasepopup.qml tst_walletpasswordsettings.qml tst_walletselect.qml - tst_walletsettings.qml tst_wallettypelistitem.qml tst_watchonlyxpub.qml diff --git a/test/qml/qml_tests_main.cpp b/test/qml/qml_tests_main.cpp index d31220a5c0..87d178dfdf 100644 --- a/test/qml/qml_tests_main.cpp +++ b/test/qml/qml_tests_main.cpp @@ -1432,7 +1432,7 @@ class MockWalletController : public QObject Q_EMIT walletMigrationSucceeded(); } Q_INVOKABLE void requestOpenWalletSettings() { Q_EMIT openWalletSettingsRequested(); } - void setSelectedWalletObject(QObject* wallet) + Q_INVOKABLE void setSelectedWalletObject(QObject* wallet) { if (m_selected_wallet == wallet) return; m_selected_wallet = wallet; diff --git a/test/qml/tst_desktopwallets.qml b/test/qml/tst_desktopwallets.qml index 2f5d6a6090..5de5deb67e 100644 --- a/test/qml/tst_desktopwallets.qml +++ b/test/qml/tst_desktopwallets.qml @@ -26,6 +26,7 @@ TestCase { walletController.initialized = true walletController.isWalletLoaded = true walletController.noWalletsFound = false + walletController.setSelectedWalletObject(testWalletModel) walletListModel.reset() } @@ -82,9 +83,7 @@ TestCase { const tabs = [ findChild(page, "blockClockTabButton"), findChild(page, "peersTabButton"), - findChild(page, "consoleTabButton"), - findChild(page, "desktopWalletSettingsTabButton"), - findChild(page, "desktopWalletSettingsPreviewTabButton") + findChild(page, "desktopWalletSettingsTabButton") ] for (let i = 0; i < tabs.length; ++i) { @@ -94,51 +93,31 @@ TestCase { } compare(tabs[1].iconSize, 24) - compare(tabs[2].iconSize, 24) - compare(tabs[3].iconSize, 30) - compare(tabs[4].iconSize, 30) + compare(tabs[2].iconSize, 30) + compare(findChild(page, "consoleTabButton"), null) + compare(findChild(page, "desktopWalletSettingsPreviewTabButton"), null) } - function test_settings_preview_is_lazilyLoadedAndRetained() { + function test_settings_is_lazilyLoadedAndRetained() { const page = createDesktopWallets() - const previewSettingsTab = findChild(page, "desktopWalletSettingsPreviewTabButton") - const previewLoader = findChild(page, "settingsPreviewLoader") - - verify(previewSettingsTab !== null) - verify(previewLoader !== null) - compare(previewLoader.active, false) - compare(previewLoader.item, null) - - previewSettingsTab.checked = true - tryCompare(previewSettingsTab, "checked", true) - tryCompare(previewLoader, "active", true) - tryVerify(function() { return previewLoader.item !== null }) - compare(previewLoader.item.objectName, "settingsView") - const settingsView = previewLoader.item - - previewSettingsTab.checked = false - compare(previewLoader.item, settingsView) - compare(previewLoader.active, true) - } - - function test_console_autocomplete_closes_when_switching_tabs() { - const page = createDesktopWallets() - const consoleTab = findChild(page, "consoleTabButton") - const activityTab = findChild(page, "activityTabButton") - const popup = findChild(page, "consoleAutocompletePopup") - - verify(consoleTab !== null) - verify(activityTab !== null) - verify(popup !== null) - - consoleTab.checked = true - tryCompare(consoleTab, "checked", true) - popup.open() - tryCompare(popup, "visible", true) + const settingsTab = findChild(page, "desktopWalletSettingsTabButton") + const settingsLoader = findChild(page, "settingsLoader") - activityTab.checked = true - tryCompare(activityTab, "checked", true) - tryCompare(popup, "visible", false) + verify(settingsTab !== null) + verify(settingsLoader !== null) + compare(settingsLoader.active, false) + compare(settingsLoader.item, null) + + settingsTab.checked = true + tryCompare(settingsTab, "checked", true) + tryCompare(settingsLoader, "active", true) + tryVerify(function() { return settingsLoader.item !== null }) + compare(settingsLoader.item.objectName, "settingsView") + const settingsView = settingsLoader.item + + settingsTab.checked = false + compare(settingsLoader.item, settingsView) + compare(settingsLoader.active, true) } function test_receive_options_view_address_history_opens_settings_address_stack() { @@ -163,13 +142,15 @@ TestCase { verify(settingsTab !== null) compare(settingsTab.checked, true) - const settingsPage = findChild(page, "nodeSettingsStack") + const settingsPage = findChild(page, "settingsView") verify(settingsPage !== null) - tryVerify(function() { return findChild(page, "walletSettingsStack") !== null }) - const walletStack = findChild(page, "walletSettingsStack") - tryCompare(walletStack, "depth", 2) - compare(walletStack.currentItem.objectName, "addressListPage") - verify(findChild(page, "walletSettingsPage") !== null) + const settingsContainer = findChild(page, "settingsv2SettingsPageContainer") + verify(settingsContainer !== null) + tryCompare(settingsPage, "selectedSectionId", "wallet") + tryCompare(settingsContainer, "currentSectionId", "wallet") + tryCompare(settingsContainer, "depth", 2) + compare(settingsContainer.currentItem.objectName, "addressListPage") + verify(findChild(page, "settingsv2WalletSettingsPage") !== null) } } diff --git a/test/qml/tst_displaysettings.qml b/test/qml/tst_displaysettings.qml deleted file mode 100644 index 9eeed90673..0000000000 --- a/test/qml/tst_displaysettings.qml +++ /dev/null @@ -1,204 +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 QtTest 1.2 -import "../../qml/controls" - -TestCase { - name: "DisplaySettings" - when: windowShown - width: 600 - height: 800 - - // Minimal component exercising display-unit OptionButton binding logic. - // Uses OptionButton directly (no NavButton / org.bitcoincore.qt dependency) - // to test the optionsModel.displayUnit binding in isolation. - // ButtonGroup is intentionally omitted: the declarative 'checked:' bindings - // already model mutual exclusion through optionsModel, and ButtonGroup's - // managed-checked behavior conflicts with declarative bindings in tests. - Component { - id: displayUnitButtons - Column { - OptionButton { - objectName: "displayUnitBTC" - text: "BTC" - checked: optionsModel.displayUnit === 0 - onClicked: optionsModel.displayUnit = 0 - } - OptionButton { - objectName: "displayUnitMBTC" - text: "mBTC" - checked: optionsModel.displayUnit === 1 - onClicked: optionsModel.displayUnit = 1 - } - OptionButton { - objectName: "displayUnitUBTC" - text: "bits" - checked: optionsModel.displayUnit === 2 - onClicked: optionsModel.displayUnit = 2 - } - OptionButton { - objectName: "displayUnitSAT" - text: "sat" - checked: optionsModel.displayUnit === 3 - onClicked: optionsModel.displayUnit = 3 - } - } - } - - function test_displayUnit_BTC_button_checked_by_default() { - optionsModel.displayUnit = 0 - const obj = createTemporaryObject(displayUnitButtons, this) - verify(obj !== null) - - const btcBtn = findChild(obj, "displayUnitBTC") - verify(btcBtn !== null) - compare(btcBtn.checked, true) - - const satBtn = findChild(obj, "displayUnitSAT") - verify(satBtn !== null) - compare(satBtn.checked, false) - } - - function test_displayUnit_SAT_button_updates_on_model_change() { - optionsModel.displayUnit = 3 - const obj = createTemporaryObject(displayUnitButtons, this) - verify(obj !== null) - - const satBtn = findChild(obj, "displayUnitSAT") - verify(satBtn !== null) - compare(satBtn.checked, true) - - const btcBtn = findChild(obj, "displayUnitBTC") - verify(btcBtn !== null) - compare(btcBtn.checked, false) - - // Reset - optionsModel.displayUnit = 0 - } - - function test_displayUnit_clicking_SAT_updates_model() { - optionsModel.displayUnit = 0 - const obj = createTemporaryObject(displayUnitButtons, this) - verify(obj !== null) - - const satBtn = findChild(obj, "displayUnitSAT") - verify(satBtn !== null) - - // Invoke the onClicked handler directly to simulate a user press. - satBtn.clicked() - compare(optionsModel.displayUnit, 3) - - // Reset - optionsModel.displayUnit = 0 - } - - function test_displayUnit_clicking_BTC_after_SAT_resets_model() { - optionsModel.displayUnit = 3 - const obj = createTemporaryObject(displayUnitButtons, this) - verify(obj !== null) - - const btcBtn = findChild(obj, "displayUnitBTC") - verify(btcBtn !== null) - compare(btcBtn.checked, false) - - btcBtn.clicked() - compare(optionsModel.displayUnit, 0) - } - - function test_displayUnit_clicking_mBTC_updates_model() { - optionsModel.displayUnit = 0 - const obj = createTemporaryObject(displayUnitButtons, this) - verify(obj !== null) - - const mbtcBtn = findChild(obj, "displayUnitMBTC") - verify(mbtcBtn !== null) - - mbtcBtn.clicked() - compare(optionsModel.displayUnit, 1) - - optionsModel.displayUnit = 0 - } - - function test_displayUnit_clicking_bits_updates_model() { - optionsModel.displayUnit = 0 - const obj = createTemporaryObject(displayUnitButtons, this) - verify(obj !== null) - - const ubtcBtn = findChild(obj, "displayUnitUBTC") - verify(ubtcBtn !== null) - - ubtcBtn.clicked() - compare(optionsModel.displayUnit, 2) - - optionsModel.displayUnit = 0 - } - - // Mirrors the balance suffix expression in WalletBadge.qml. - // balanceSatoshi=1000 → plural "sats"; balanceSatoshi=1 → singular "sat". - Component { - id: balanceSuffixComponent - Text { - property string balance: "1 000" - property var balanceSatoshi: 1000 - text: balance + " " + optionsModel.displayUnitLabelForAmount(balanceSatoshi) - } - } - - function test_walletBadge_suffix_is_sats_in_sat_mode() { - optionsModel.displayUnit = 3 - const obj = createTemporaryObject(balanceSuffixComponent, this) - verify(obj !== null) - compare(obj.text, "1 000 sats") - optionsModel.displayUnit = 0 - } - - function test_walletBadge_suffix_is_sat_singular_in_sat_mode() { - optionsModel.displayUnit = 3 - const obj = createTemporaryObject(balanceSuffixComponent, this) - verify(obj !== null) - obj.balanceSatoshi = 1 - compare(obj.text, "1 000 sat") - optionsModel.displayUnit = 0 - } - - function test_walletBadge_suffix_is_btc_symbol_in_btc_mode() { - optionsModel.displayUnit = 0 - const obj = createTemporaryObject(balanceSuffixComponent, this) - verify(obj !== null) - compare(obj.text, "1 000 ₿") - } - - // Tests displayUnitLabelForAmount pluralization logic. - function test_displayUnitLabelForAmount_singular_in_sat_mode() { - optionsModel.displayUnit = 3 - compare(optionsModel.displayUnitLabelForAmount(1), "sat") - compare(optionsModel.displayUnitLabelForAmount(-1), "sat") - optionsModel.displayUnit = 0 - } - - function test_displayUnitLabelForAmount_plural_in_sat_mode() { - optionsModel.displayUnit = 3 - compare(optionsModel.displayUnitLabelForAmount(0), "sats") - compare(optionsModel.displayUnitLabelForAmount(2), "sats") - compare(optionsModel.displayUnitLabelForAmount(1000), "sats") - optionsModel.displayUnit = 0 - } - - function test_displayUnitLabelForAmount_btc_symbol_in_btc_mode() { - optionsModel.displayUnit = 0 - compare(optionsModel.displayUnitLabelForAmount(1), "₿") - compare(optionsModel.displayUnitLabelForAmount(1000), "₿") - } - - function test_displayUnitLabelForAmount_mbtc_and_bits() { - optionsModel.displayUnit = 1 - compare(optionsModel.displayUnitLabelForAmount(1000), "mBTC") - optionsModel.displayUnit = 2 - compare(optionsModel.displayUnitLabelForAmount(1000), "bits") - optionsModel.displayUnit = 0 - } -} diff --git a/test/qml/tst_formcontrols.qml b/test/qml/tst_formcontrols.qml index 277ceb6164..5c04a960cd 100644 --- a/test/qml/tst_formcontrols.qml +++ b/test/qml/tst_formcontrols.qml @@ -203,7 +203,6 @@ TestCase { const card = findChild(section, "exampleSectionCard") verify(card !== null) compare(card.color, Theme.color.neutral1) - compare(card.border.width, 0) compare(card.radius, 16) const footer = findChild(section, "exampleSectionFooter") verify(footer !== null) diff --git a/test/qml/tst_mempoolinformationsettings.qml b/test/qml/tst_mempoolinformationsettings.qml deleted file mode 100644 index f45e6095b5..0000000000 --- a/test/qml/tst_mempoolinformationsettings.qml +++ /dev/null @@ -1,82 +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.Window 2.15 -import QtTest 1.2 -import "../../qml/pages/node" - -TestCase { - name: "MempoolInformationSettings" - when: windowShown - width: 520 - height: 720 - - Window { - id: testWindow - width: 520 - height: 720 - visible: true - } - - Component { - id: mempoolInformationSettingsComponent - - MempoolInformationSettings { - width: 460 - height: 680 - } - } - - function init() { - nodeModel.resetMempoolInfoPollingTestState() - optionsModel.mempoolSettingsDirty = false - } - - function createMempoolInformationSettingsPage() { - const page = createTemporaryObject(mempoolInformationSettingsComponent, testWindow.contentItem) - verify(page !== null) - page.visible = false - wait(0) - page.visible = true - wait(0) - compare(page.visible, true) - return page - } - - function test_polling_activity_tracks_page_visibility() { - const page = createMempoolInformationSettingsPage() - compare(nodeModel.mempoolInfoPollingActive, true) - - page.visible = false - compare(nodeModel.mempoolInfoPollingActive, false) - - page.visible = true - compare(nodeModel.mempoolInfoPollingActive, true) - } - - function test_polling_activity_stops_on_page_destruction() { - const page = createMempoolInformationSettingsPage() - compare(nodeModel.mempoolInfoPollingActive, true) - - page.destroy() - wait(0) - compare(nodeModel.mempoolInfoPollingActive, false) - } - - function test_restart_notice_hidden_when_mempool_settings_unchanged() { - const page = createMempoolInformationSettingsPage() - const notice = findChild(page, "mempoolRestartNotice") - verify(notice !== null) - compare(notice.visible, false) - } - - function test_restart_notice_visible_when_mempool_settings_changed() { - optionsModel.mempoolSettingsDirty = true - const page = createMempoolInformationSettingsPage() - const notice = findChild(page, "mempoolRestartNotice") - verify(notice !== null) - compare(notice.visible, true) - } -} diff --git a/test/qml/tst_nodefeedback.qml b/test/qml/tst_nodefeedback.qml index f2bc312f99..29b63c78ad 100644 --- a/test/qml/tst_nodefeedback.qml +++ b/test/qml/tst_nodefeedback.qml @@ -172,6 +172,7 @@ TestCase { verify(warningButton !== null) verify(infoButton !== null) verify(settingsButton !== null) + compare(findChild(runner, "consoleTabButton"), null) tryCompare(warningButton, "visible", true) compare(warningButton.height, 34) diff --git a/test/qml/tst_nodesettings.qml b/test/qml/tst_nodesettings.qml deleted file mode 100644 index d89f849915..0000000000 --- a/test/qml/tst_nodesettings.qml +++ /dev/null @@ -1,253 +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.Window 2.15 -import QtTest 1.2 -import org.bitcoincore.qt 1.0 -import "../../qml/pages/node" - -TestCase { - name: "NodeSettings" - when: windowShown - width: 520 - height: 720 - - Window { - id: testWindow - width: 520 - height: 720 - visible: true - } - - Component { - id: nodeSettingsComponent - - NodeSettings { - width: 460 - height: 680 - } - } - - Component { - id: subPageComponent - Item {} - } - - function init() { - nodeModel.mempoolInformationAvailable = true - AppMode.walletEnabled = true - AppMode.isDesktop = true - testNetworkTrafficTower.active = false - testDebugLogModel.active = false - testDebugLogModel.filter = "" - testDebugLogModel.resetForTest(0, false) - } - - function createNodeSettingsPage() { - const page = createTemporaryObject(nodeSettingsComponent, testWindow.contentItem) - verify(page !== null) - wait(0) - return page - } - - function test_mempool_information_row_visible_when_available() { - const page = createNodeSettingsPage() - const row = findChild(page, "settings_mempool") - verify(row !== null) - compare(row.visible, true) - } - - function test_mempool_information_row_hidden_when_unavailable() { - nodeModel.mempoolInformationAvailable = false - - const page = createNodeSettingsPage() - const row = findChild(page, "settings_mempool") - verify(row !== null) - compare(row.visible, false) - } - - function test_sidebar_section_switching() { - const page = createNodeSettingsPage() - - // currentSection is the sidebar row index in the grouped order: - // Wallet(0), External Signer(1), Display(2), Window Behavior(3), - // Storage(4), Connection(5), Network Traffic(6), Mempool(7), - // Debug Log(8), About(9). With the wallet enabled the page lands on - // the first visible row, Wallet. - compare(page.currentSection, 0) - - const displayItem = findChild(page, "settings_display") - verify(displayItem !== null) - mouseClick(displayItem, displayItem.width / 2, displayItem.height / 2) - compare(page.currentSection, 2) - - const connectionItem = findChild(page, "settings_connection") - verify(connectionItem !== null) - mouseClick(connectionItem, connectionItem.width / 2, connectionItem.height / 2) - compare(page.currentSection, 5) - } - - function test_network_traffic_only_publishes_while_selected() { - const page = createNodeSettingsPage() - - compare(testNetworkTrafficTower.active, false) - verify(findChild(page, "networkTrafficPage") === null) - - const networkTrafficItem = findChild(page, "settings_networktraffic") - verify(networkTrafficItem !== null) - mouseClick(networkTrafficItem, networkTrafficItem.width / 2, networkTrafficItem.height / 2) - tryCompare(page, "currentSection", 6) - tryCompare(testNetworkTrafficTower, "active", true) - verify(findChild(page, "networkTrafficPage") !== null) - - // Leaving Settings unloads both graphs and suppresses worker snapshots, - // while the C++ sampler continues retaining raw history off-thread. - page.visible = false - tryCompare(testNetworkTrafficTower, "active", false) - tryVerify(function() { return findChild(page, "networkTrafficPage") === null }) - - page.visible = true - tryCompare(testNetworkTrafficTower, "active", true) - verify(findChild(page, "networkTrafficPage") !== null) - - const displayItem = findChild(page, "settings_display") - verify(displayItem !== null) - mouseClick(displayItem, displayItem.width / 2, displayItem.height / 2) - tryCompare(page, "currentSection", 2) - tryCompare(testNetworkTrafficTower, "active", false) - tryVerify(function() { return findChild(page, "networkTrafficPage") === null }) - } - - function test_debug_log_only_active_while_selected() { - const page = createNodeSettingsPage() - - compare(testDebugLogModel.active, false) - verify(findChild(page, "settingsDebugLog") === null) - - const debugLogItem = findChild(page, "settings_debuglog") - verify(debugLogItem !== null) - mouseClick(debugLogItem, debugLogItem.width / 2, debugLogItem.height / 2) - tryCompare(page, "currentSection", 8) - tryCompare(testDebugLogModel, "active", true) - verify(findChild(page, "settingsDebugLog") !== null) - - // DesktopWallets keeps NodeSettings in its outer StackLayout. Leaving - // Settings for Send must unload the debug log even if it remains the - // selected Settings section. - page.visible = false - tryCompare(testDebugLogModel, "active", false) - tryVerify(function() { return findChild(page, "settingsDebugLog") === null }) - - page.visible = true - tryCompare(testDebugLogModel, "active", true) - verify(findChild(page, "settingsDebugLog") !== null) - - const displayItem = findChild(page, "settings_display") - verify(displayItem !== null) - mouseClick(displayItem, displayItem.width / 2, displayItem.height / 2) - tryCompare(page, "currentSection", 2) - tryCompare(testDebugLogModel, "active", false) - tryVerify(function() { return findChild(page, "settingsDebugLog") === null }) - } - - function test_debug_log_load_more_appends_without_jumping_to_new_bottom() { - testDebugLogModel.resetForTest(100, true) - const page = createNodeSettingsPage() - - const debugLogItem = findChild(page, "settings_debuglog") - verify(debugLogItem !== null) - mouseClick(debugLogItem, debugLogItem.width / 2, debugLogItem.height / 2) - tryCompare(page, "currentSection", 8) - - const logView = findChild(page, "debugLogListView") - const loadMoreButton = findChild(page, "debugLogLoadMoreButton") - verify(logView !== null) - verify(loadMoreButton !== null) - tryCompare(logView, "count", 100) - - logView.scrollToBottom() - tryCompare(logView, "atBottom", true) - tryCompare(loadMoreButton, "visible", true) - const anchoredContentY = logView.contentY - mouseClick(loadMoreButton, loadMoreButton.width / 2, loadMoreButton.height / 2) - - tryCompare(testDebugLogModel, "loadMoreCalls", 1) - tryCompare(logView, "count", 120) - tryCompare(loadMoreButton, "visible", false) - verify(Math.abs(logView.contentY - anchoredContentY) < 0.5, - "Loading older rows should leave the previous bottom entries anchored: before=" - + anchoredContentY + ", after=" + logView.contentY - + ", contentHeight=" + logView.contentHeight) - compare(logView.atBottom, false) - } - - function test_debug_log_filter_matches_model_after_page_reentry() { - testDebugLogModel.filter = "retained filter" - const page = createNodeSettingsPage() - - const debugLogItem = findChild(page, "settings_debuglog") - verify(debugLogItem !== null) - mouseClick(debugLogItem, debugLogItem.width / 2, debugLogItem.height / 2) - tryCompare(page, "currentSection", 8) - - let searchField = findChild(page, "debugLogSearchField") - verify(searchField !== null) - compare(searchField.text, "retained filter") - searchField.text = "updated filter" - tryCompare(testDebugLogModel, "filter", "updated filter", 1000) - - const displayItem = findChild(page, "settings_display") - verify(displayItem !== null) - mouseClick(displayItem, displayItem.width / 2, displayItem.height / 2) - tryCompare(page, "currentSection", 2) - tryVerify(function() { return findChild(page, "settingsDebugLog") === null }) - - mouseClick(debugLogItem, debugLogItem.width / 2, debugLogItem.height / 2) - tryCompare(page, "currentSection", 8) - searchField = findChild(page, "debugLogSearchField") - verify(searchField !== null) - compare(searchField.text, "updated filter") - } - function test_wallet_section_hidden_when_disabled() { - AppMode.walletEnabled = false - - const page = createNodeSettingsPage() - const walletItem = findChild(page, "settings_wallet") - verify(walletItem !== null) - compare(walletItem.visible, false) - - const signerItem = findChild(page, "settings_externalsigner") - verify(signerItem !== null) - compare(signerItem.visible, false) - } - - function test_window_behavior_hidden_on_non_desktop() { - AppMode.isDesktop = false - - const page = createNodeSettingsPage() - const windowItem = findChild(page, "settings_windowbehavior") - verify(windowItem !== null) - compare(windowItem.visible, false) - } - - function test_wallet_settings_back_button_stays_hidden_when_subpage_open() { - const page = createNodeSettingsPage() - - const walletSettingsPage = findChild(page, "walletSettingsPage") - verify(walletSettingsPage !== null) - const walletStack = findChild(page, "walletSettingsStack") - verify(walletStack !== null) - - // The wallet settings page is reached from the sidebar and has no back - // button of its own. Pushing a sub-page must not turn it on: binding it - // to depth > 1 flashed the back button on this page during the push - // transition. - compare(walletSettingsPage.showBackButton, false) - walletStack.push(subPageComponent) - wait(0) - verify(walletStack.depth > 1) - compare(walletSettingsPage.showBackButton, false) - } -} diff --git a/test/qml/tst_settingsnavigation.qml b/test/qml/tst_settingsnavigation.qml index 85cc2cec61..d0e17bb5dd 100644 --- a/test/qml/tst_settingsnavigation.qml +++ b/test/qml/tst_settingsnavigation.qml @@ -125,6 +125,8 @@ TestCase { optionsModel.moneyFontChoice = "embedded" optionsModel.maxMempoolSizeMB = 300 optionsModel.storageSettingsDirty = false + optionsModel.mempoolSettingsDirty = false + nodeModel.resetMempoolInfoPollingTestState() optionsModel.clearCoreSettingStatusesForTest() const proxySetting = optionsModel.coreSettings.entry("proxy") const onionSetting = optionsModel.coreSettings.entry("onion") @@ -164,6 +166,28 @@ TestCase { compare(activatedSection, "connection") } + function test_settingsViewAppliesRuntimeVisibilityGates() { + AppMode.walletEnabled = false + AppMode.isDesktop = false + nodeModel.mempoolInformationAvailable = false + + const view = createTemporaryObject(settingsViewComponent, settingsWindow.contentItem) + verify(view !== null) + + compare(view.sectionIsVisible("wallet"), false) + compare(view.sectionIsVisible("external-signer"), false) + compare(view.sectionIsVisible("window-behavior"), false) + compare(view.sectionIsVisible("mempool"), false) + verify(findChild(view, "settingsSidebar_wallet") === null) + verify(findChild(view, "settingsSidebar_external-signer") === null) + verify(findChild(view, "settingsSidebar_window-behavior") === null) + verify(findChild(view, "settingsSidebar_mempool") === null) + + compare(view.sectionIsVisible("display"), true) + compare(view.selectedSectionId, "display") + verify(findChild(view, "settingsSidebar_display") !== null) + } + function test_containerLazilyCachesAndRestoresEachSectionStack() { const container = createTemporaryObject(containerComponent, host) verify(container !== null) @@ -482,9 +506,19 @@ TestCase { compare(moneyFontPicker.currentText, "Roboto Mono") tryCompare(moneyFontPicker, "width", embeddedMoneyFontWidth) - displayUnitPicker.activated(3) - compare(optionsModel.displayUnit, 3) - compare(displayUnitPicker.currentText, "sat") + const displayUnits = [ + { value: 1, text: "mBTC" }, + { value: 2, text: "bits" }, + { value: 3, text: "sat" }, + { value: 0, text: "BTC" } + ] + for (let index = 0; index < displayUnits.length; ++index) { + const unit = displayUnits[index] + displayUnitPicker.activated(unit.value) + compare(optionsModel.displayUnit, unit.value) + compare(displayUnitPicker.currentValue, unit.value) + compare(displayUnitPicker.currentText, unit.text) + } designSystemRow.clicked() tryCompare(view.pageContainer, "depth", 2) @@ -521,6 +555,38 @@ TestCase { verify(sizeLimitRow.errorText.length > 0) } + function test_mempoolPollingAndRestartNoticeFollowVisibility() { + const view = createTemporaryObject(settingsViewComponent, settingsWindow.contentItem) + verify(view !== null) + compare(nodeModel.mempoolInfoPollingActive, false) + + view.selectSection("mempool") + const mempoolPage = findChild(view, "settingsv2MempoolSettingsPage") + const restartNotice = findChild(view, "settingsv2MempoolRestartNotice") + verify(mempoolPage !== null) + verify(restartNotice !== null) + tryCompare(nodeModel, "mempoolInfoPollingActive", true) + compare(restartNotice.visible, false) + + optionsModel.mempoolSettingsDirty = true + tryCompare(restartNotice, "visible", true) + + view.selectSection("display") + tryCompare(nodeModel, "mempoolInfoPollingActive", false) + compare(findChild(view, "settingsv2MempoolSettingsPage"), mempoolPage) + + view.selectSection("mempool") + tryCompare(nodeModel, "mempoolInfoPollingActive", true) + view.visible = false + tryCompare(nodeModel, "mempoolInfoPollingActive", false) + view.visible = true + tryCompare(nodeModel, "mempoolInfoPollingActive", true) + + view.destroy() + wait(0) + compare(nodeModel.mempoolInfoPollingActive, false) + } + function test_connectionProxyPageUsesRedesignedFormAndDraftCommit() { const view = createTemporaryObject(settingsViewComponent, settingsWindow.contentItem) verify(view !== null) diff --git a/test/qml/tst_settingswallet.qml b/test/qml/tst_settingswallet.qml deleted file mode 100644 index d29a720d6b..0000000000 --- a/test/qml/tst_settingswallet.qml +++ /dev/null @@ -1,56 +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.Window 2.15 -import QtTest 1.2 -import "../../qml/pages/settings" - -TestCase { - name: "SettingsWallet" - when: windowShown - width: 520 - height: 720 - - Window { - id: testWindow - width: 520 - height: 720 - visible: true - } - - Component { - id: settingsWalletComponent - - SettingsWallet { - width: 460 - height: 680 - } - } - - function init() { - optionsModel.walletSettingsDirty = false - } - - function createSettingsWalletPage() { - const page = createTemporaryObject(settingsWalletComponent, testWindow.contentItem) - verify(page !== null) - return page - } - - function test_restart_notice_hidden_when_wallet_settings_unchanged() { - const page = createSettingsWalletPage() - const notice = findChild(page, "walletRestartNotice") - verify(notice !== null) - compare(notice.visible, false) - } - - function test_restart_notice_visible_when_wallet_settings_changed() { - optionsModel.walletSettingsDirty = true - const page = createSettingsWalletPage() - const notice = findChild(page, "walletRestartNotice") - verify(notice !== null) - compare(notice.visible, true) - } -} diff --git a/test/qml/tst_settingswindowbehavior.qml b/test/qml/tst_settingswindowbehavior.qml deleted file mode 100644 index f346c9f351..0000000000 --- a/test/qml/tst_settingswindowbehavior.qml +++ /dev/null @@ -1,72 +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 - -TestCase { - name: "SettingsWindowBehavior" - - function init() { - desktopWindowBehaviorModel.showTrayIcon = true - desktopWindowBehaviorModel.minimizeToTray = false - desktopWindowBehaviorModel.minimizeOnClose = false - } - - function test_desktopPlatform_isTrue() { - compare(desktopWindowBehaviorModel.desktopPlatform, true) - } - - function test_showTrayIcon_defaultsTrue() { - compare(desktopWindowBehaviorModel.showTrayIcon, true) - } - - function test_minimizeToTray_defaultsFalse() { - compare(desktopWindowBehaviorModel.minimizeToTray, false) - } - - function test_minimizeOnClose_defaultsFalse() { - compare(desktopWindowBehaviorModel.minimizeOnClose, false) - } - - function test_showTrayIcon_toggleRoundTrip() { - desktopWindowBehaviorModel.showTrayIcon = false - compare(desktopWindowBehaviorModel.showTrayIcon, false) - desktopWindowBehaviorModel.showTrayIcon = true - compare(desktopWindowBehaviorModel.showTrayIcon, true) - } - - function test_minimizeToTray_cascadesWhenTrayDisabled() { - desktopWindowBehaviorModel.minimizeToTray = true - compare(desktopWindowBehaviorModel.minimizeToTray, true) - desktopWindowBehaviorModel.showTrayIcon = false - compare(desktopWindowBehaviorModel.minimizeToTray, false) - } - - function test_minimizeToTray_blockedWithoutTray() { - desktopWindowBehaviorModel.showTrayIcon = false - desktopWindowBehaviorModel.minimizeToTray = true - compare(desktopWindowBehaviorModel.minimizeToTray, false) - } - - function test_minimizeOnClose_independentOfTray() { - desktopWindowBehaviorModel.showTrayIcon = false - desktopWindowBehaviorModel.minimizeOnClose = true - compare(desktopWindowBehaviorModel.minimizeOnClose, true) - } - - function test_shouldHideToTrayOnMinimize_requiresAllConditions() { - compare(desktopWindowBehaviorModel.shouldHideToTrayOnMinimize(), false) - desktopWindowBehaviorModel.minimizeToTray = true - compare(desktopWindowBehaviorModel.shouldHideToTrayOnMinimize(), true) - desktopWindowBehaviorModel.showTrayIcon = false - compare(desktopWindowBehaviorModel.shouldHideToTrayOnMinimize(), false) - } - - function test_shouldMinimizeWindowOnClose() { - compare(desktopWindowBehaviorModel.shouldMinimizeWindowOnClose(), false) - desktopWindowBehaviorModel.minimizeOnClose = true - compare(desktopWindowBehaviorModel.shouldMinimizeWindowOnClose(), true) - } -} diff --git a/test/qml/tst_walletsettings.qml b/test/qml/tst_walletsettings.qml deleted file mode 100644 index 3359f364c3..0000000000 --- a/test/qml/tst_walletsettings.qml +++ /dev/null @@ -1,77 +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 "../../qml/pages/wallet" - -TestCase { - name: "WalletSettings" - when: windowShown - width: 520 - height: 720 - - Component { - id: walletSettingsComponent - - WalletSettings { - width: 460 - height: 680 - } - } - - function init() { - testWalletModel.resetWalletSettingsTestState() - } - - function createWalletSettingsPage() { - const page = createTemporaryObject(walletSettingsComponent, this) - verify(page !== null) - return page - } - - function test_wallet_settings_password_and_backup_actions_remain_available() { - const page = createWalletSettingsPage() - let passwordRequests = 0 - let addressRequests = 0 - page.passwordRequested.connect(function() { - ++passwordRequests - }) - page.addressesRequested.connect(function() { - ++addressRequests - }) - - const addressesRow = findChild(page, "settingsAddresses") - verify(addressesRow !== null) - addressesRow.clicked() - compare(addressRequests, 1) - - const passwordRow = findChild(page, "walletSettingsPasswordRow") - verify(passwordRow !== null) - passwordRow.clicked() - compare(passwordRequests, 1) - - const backupPathField = findChild(page, "walletSettingsBackupPathField") - verify(backupPathField !== null) - backupPathField.text = "/tmp/qml-wallet-settings-test.bak" - - const backupRow = findChild(page, "walletSettingsBackupRow") - verify(backupRow !== null) - backupRow.clicked() - compare(testWalletModel.backupWalletCalls, 1) - compare(testWalletModel.lastBackupPath, "/tmp/qml-wallet-settings-test.bak") - } - - function test_wallet_settings_hides_password_action_for_external_signer_wallet() { - testWalletModel.setExternalSignerWalletSettingsTestState() - const page = createWalletSettingsPage() - - const passwordRow = findChild(page, "walletSettingsPasswordRow") - verify(passwordRow !== null) - compare(passwordRow.visible, false) - - const backupRow = findChild(page, "walletSettingsBackupRow") - verify(backupRow !== null) - } -} From e229df1d60e2d06ce69a76d30613ea39e7c81b35 Mon Sep 17 00:00:00 2001 From: pseudoramdom Date: Tue, 18 Aug 2026 22:14:01 -0700 Subject: [PATCH 12/14] qml: drop settingsv2 prefix naming Move the redesigned settings pages into the main settings directory and remove the settingsv2 prefix from QML object names and test selectors. --- qml/bitcoin_qml.qrc | 22 +-- qml/components/SettingsView.qml | 35 ++-- .../{settingsv2 => }/AboutSettingsPage.qml | 15 +- .../ConnectionSettingsPage.qml | 14 +- .../{settingsv2 => }/DisplaySettingsPage.qml | 47 +++-- .../ExternalSignerSettingsPage.qml | 12 +- .../{settingsv2 => }/MempoolSettingsPage.qml | 16 +- .../NetworkTrafficSettingsPage.qml | 20 +-- .../{settingsv2 => }/ProxySettingsPage.qml | 36 ++-- .../RpcConsoleSettingsPage.qml | 10 +- .../{settingsv2 => }/StorageSettingsPage.qml | 14 +- .../{settingsv2 => }/WalletSectionPage.qml | 20 +-- .../WindowBehaviorSettingsPage.qml | 10 +- qml/pages/wallet/DesktopWallets.qml | 2 +- .../qml_test_activity_filter_export.py | 8 +- test/functional/qml_test_addresses.py | 4 +- test/functional/qml_test_console.py | 52 +++--- test/functional/qml_test_debug_log.py | 13 +- .../functional/qml_test_disablewallet_boot.py | 34 ++-- test/functional/qml_test_external_signer.py | 8 +- test/functional/qml_test_password_wallet.py | 6 +- test/functional/qml_test_proxy.py | 170 +++++++++--------- test/functional/qml_test_settings_display.py | 42 ++--- test/functional/qml_test_tray.py | 30 ++-- test/functional/qml_test_wallet_settings.py | 28 +-- test/qml/tst_desktopwallets.qml | 5 +- test/qml/tst_settingsnavigation.qml | 135 +++++++------- 27 files changed, 407 insertions(+), 401 deletions(-) rename qml/pages/settings/{settingsv2 => }/AboutSettingsPage.qml (88%) rename qml/pages/settings/{settingsv2 => }/ConnectionSettingsPage.qml (89%) rename qml/pages/settings/{settingsv2 => }/DisplaySettingsPage.qml (83%) rename qml/pages/settings/{settingsv2 => }/ExternalSignerSettingsPage.qml (95%) rename qml/pages/settings/{settingsv2 => }/MempoolSettingsPage.qml (89%) rename qml/pages/settings/{settingsv2 => }/NetworkTrafficSettingsPage.qml (90%) rename qml/pages/settings/{settingsv2 => }/ProxySettingsPage.qml (88%) rename qml/pages/settings/{settingsv2 => }/RpcConsoleSettingsPage.qml (85%) rename qml/pages/settings/{settingsv2 => }/StorageSettingsPage.qml (93%) rename qml/pages/settings/{settingsv2 => }/WalletSectionPage.qml (92%) rename qml/pages/settings/{settingsv2 => }/WindowBehaviorSettingsPage.qml (87%) diff --git a/qml/bitcoin_qml.qrc b/qml/bitcoin_qml.qrc index 9b06cf88f6..7f422368eb 100644 --- a/qml/bitcoin_qml.qrc +++ b/qml/bitcoin_qml.qrc @@ -135,17 +135,17 @@ pages/settings/SettingsDeveloper.qml pages/settings/SettingsProxy.qml pages/settings/SettingsStorage.qml - pages/settings/settingsv2/AboutSettingsPage.qml - pages/settings/settingsv2/ConnectionSettingsPage.qml - pages/settings/settingsv2/DisplaySettingsPage.qml - pages/settings/settingsv2/ExternalSignerSettingsPage.qml - pages/settings/settingsv2/MempoolSettingsPage.qml - pages/settings/settingsv2/NetworkTrafficSettingsPage.qml - pages/settings/settingsv2/ProxySettingsPage.qml - pages/settings/settingsv2/RpcConsoleSettingsPage.qml - pages/settings/settingsv2/StorageSettingsPage.qml - pages/settings/settingsv2/WalletSectionPage.qml - pages/settings/settingsv2/WindowBehaviorSettingsPage.qml + pages/settings/AboutSettingsPage.qml + pages/settings/ConnectionSettingsPage.qml + pages/settings/DisplaySettingsPage.qml + pages/settings/ExternalSignerSettingsPage.qml + pages/settings/MempoolSettingsPage.qml + pages/settings/NetworkTrafficSettingsPage.qml + pages/settings/ProxySettingsPage.qml + pages/settings/RpcConsoleSettingsPage.qml + pages/settings/StorageSettingsPage.qml + pages/settings/WalletSectionPage.qml + pages/settings/WindowBehaviorSettingsPage.qml pages/wallet/Activity.qml pages/wallet/ActivityDetails.qml pages/wallet/ActivityTransactionVisuals.qml diff --git a/qml/components/SettingsView.qml b/qml/components/SettingsView.qml index 111675a027..787a8d3064 100644 --- a/qml/components/SettingsView.qml +++ b/qml/components/SettingsView.qml @@ -11,8 +11,7 @@ import QtQuick.Layouts 1.15 import org.bitcoincore.qt 1.0 import "../controls" -import "../pages/settings" as LegacySettings -import "../pages/settings/settingsv2" as SettingsV2 +import "../pages/settings" as SettingsPages import "../pages/wallet" as WalletPages Page { @@ -190,7 +189,7 @@ Page { Rectangle { id: sidebarSurface - objectName: "settingsv2SettingsSidebarSurface" + objectName: "settingsSidebarSurface" Layout.preferredWidth: root.sidebarWidth Layout.minimumWidth: root.sidebarWidth Layout.maximumWidth: root.sidebarWidth @@ -210,7 +209,7 @@ Page { spacing: 0 CoreText { - objectName: "settingsv2SettingsSidebarHeading" + objectName: "settingsSidebarHeading" Layout.fillWidth: true Layout.leftMargin: 10 Layout.rightMargin: 10 @@ -225,7 +224,7 @@ Page { SettingsSidebar { id: sidebar - objectName: "settingsv2SettingsSidebar" + objectName: "settingsSidebar" Layout.fillWidth: true Layout.fillHeight: true model: root.sections @@ -235,7 +234,7 @@ Page { } NavButton { - objectName: "settingsv2SettingsDoneButton" + objectName: "settingsDoneButton" visible: root.showDoneButton text: qsTr("Done") Layout.alignment: Qt.AlignHCenter @@ -247,7 +246,7 @@ Page { SettingsPageContainer { id: pageContainer - objectName: "settingsv2SettingsPageContainer" + objectName: "settingsPageContainer" Layout.minimumWidth: 0 Layout.fillWidth: true Layout.fillHeight: true @@ -257,7 +256,7 @@ Page { Component { id: walletPage - SettingsV2.WalletSectionPage { + SettingsPages.WalletSectionPage { onSelectWalletRequested: root.selectWalletRequested() onPasswordRequested: pageContainer.push(walletPasswordPage, { "updating": walletController.selectedWallet.isEncrypted @@ -302,44 +301,44 @@ Page { Component { id: externalSignerPage - SettingsV2.ExternalSignerSettingsPage {} + SettingsPages.ExternalSignerSettingsPage {} } Component { id: displayPage - SettingsV2.DisplaySettingsPage {} + SettingsPages.DisplaySettingsPage {} } Component { id: windowBehaviorPage - SettingsV2.WindowBehaviorSettingsPage {} + SettingsPages.WindowBehaviorSettingsPage {} } Component { id: storagePage - SettingsV2.StorageSettingsPage {} + SettingsPages.StorageSettingsPage {} } Component { id: connectionPage - SettingsV2.ConnectionSettingsPage {} + SettingsPages.ConnectionSettingsPage {} } Component { id: networkTrafficPage - SettingsV2.NetworkTrafficSettingsPage {} + SettingsPages.NetworkTrafficSettingsPage {} } Component { id: mempoolPage - SettingsV2.MempoolSettingsPage {} + SettingsPages.MempoolSettingsPage {} } Component { id: rpcConsolePage - SettingsV2.RpcConsoleSettingsPage { + SettingsPages.RpcConsoleSettingsPage { walletName: typeof walletController !== "undefined" && walletController.isWalletLoaded && walletController.selectedWallet ? walletController.selectedWallet.name @@ -350,7 +349,7 @@ Page { Component { id: debugLogPage - LegacySettings.SettingsDebugLog { + SettingsPages.SettingsDebugLog { showBackButton: false maximumContentWidth: width contentHorizontalPadding: width >= 900 ? 56 : width >= 640 ? 40 : 24 @@ -359,6 +358,6 @@ Page { Component { id: aboutPage - SettingsV2.AboutSettingsPage {} + SettingsPages.AboutSettingsPage {} } } diff --git a/qml/pages/settings/settingsv2/AboutSettingsPage.qml b/qml/pages/settings/AboutSettingsPage.qml similarity index 88% rename from qml/pages/settings/settingsv2/AboutSettingsPage.qml rename to qml/pages/settings/AboutSettingsPage.qml index 8a17151cf1..cc56b399b2 100644 --- a/qml/pages/settings/settingsv2/AboutSettingsPage.qml +++ b/qml/pages/settings/AboutSettingsPage.qml @@ -9,13 +9,12 @@ import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 import org.bitcoincore.qt 1.0 -import "../../../controls" -import "../../../components" -import ".." as LegacySettings +import "../../controls" +import "../../components" SettingsPage { id: root - objectName: "settingsv2AboutSettingsPage" + objectName: "aboutSettingsPage" title: qsTr("About") showBackButton: false @@ -52,7 +51,7 @@ SettingsPage { } LinkRow { - objectName: "settingsv2AboutVersionRow" + objectName: "aboutVersionRow" Layout.fillWidth: true title: qsTr("Version") value: BuildInfo.fullClientVersion @@ -63,7 +62,7 @@ SettingsPage { } ListRow { - objectName: "settingsv2AboutDeveloperRow" + objectName: "aboutDeveloperRow" Layout.fillWidth: true title: qsTr("Developer options") description: qsTr("Only use these if you have development experience.") @@ -80,7 +79,7 @@ SettingsPage { ExternalPopup { id: externalLinkPopup - objectName: "settingsv2AboutExternalLinkPopup" + objectName: "aboutExternalLinkPopup" parent: Overlay.overlay anchors.centerIn: parent width: Math.min(450, Math.max(0, parent ? parent.width - 40 : 0)) @@ -89,7 +88,7 @@ SettingsPage { Component { id: developerPage - LegacySettings.SettingsDeveloper { + SettingsDeveloper { onBack: root.StackView.view.pop() } } diff --git a/qml/pages/settings/settingsv2/ConnectionSettingsPage.qml b/qml/pages/settings/ConnectionSettingsPage.qml similarity index 89% rename from qml/pages/settings/settingsv2/ConnectionSettingsPage.qml rename to qml/pages/settings/ConnectionSettingsPage.qml index adab771b94..3a73ac4054 100644 --- a/qml/pages/settings/settingsv2/ConnectionSettingsPage.qml +++ b/qml/pages/settings/ConnectionSettingsPage.qml @@ -8,12 +8,12 @@ import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 -import "../../../controls" -import "../../../components" +import "../../controls" +import "../../components" SettingsPage { id: root - objectName: "settingsv2ConnectionSettingsPage" + objectName: "connectionSettingsPage" title: qsTr("Connection") showBackButton: false @@ -39,7 +39,7 @@ SettingsPage { supportingText: root.listenSetting.infoText enabled: root.listenSetting.canEdit trailingItem: OptionSwitch { - objectName: "settingsv2ListenSwitch" + objectName: "listenSwitch" checked: root.listenSetting.value onToggled: root.listenSetting.value = checked } @@ -51,7 +51,7 @@ SettingsPage { supportingText: root.natpmpSetting.infoText enabled: root.natpmpSetting.canEdit trailingItem: OptionSwitch { - objectName: "settingsv2NatpmpSwitch" + objectName: "natpmpSwitch" checked: root.natpmpSetting.value onToggled: root.natpmpSetting.value = checked } @@ -64,7 +64,7 @@ SettingsPage { enabled: root.serverSetting.canEdit showDivider: false trailingItem: OptionSwitch { - objectName: "settingsv2ServerSwitch" + objectName: "serverSwitch" checked: root.serverSetting.value onToggled: root.serverSetting.value = checked } @@ -76,7 +76,7 @@ SettingsPage { title: qsTr("Privacy") ListRow { - objectName: "settingsv2ProxySettingsRow" + objectName: "proxySettingsRow" Layout.fillWidth: true title: qsTr("Proxy settings") description: qsTr("Route peer and Tor connections through SOCKS5 proxies.") diff --git a/qml/pages/settings/settingsv2/DisplaySettingsPage.qml b/qml/pages/settings/DisplaySettingsPage.qml similarity index 83% rename from qml/pages/settings/settingsv2/DisplaySettingsPage.qml rename to qml/pages/settings/DisplaySettingsPage.qml index b7afae4a49..6345422bd9 100644 --- a/qml/pages/settings/settingsv2/DisplaySettingsPage.qml +++ b/qml/pages/settings/DisplaySettingsPage.qml @@ -10,12 +10,11 @@ import QtQuick.Layouts 1.15 import org.bitcoincore.qt 1.0 -import "../../../controls" -import ".." as LegacySettings +import "../../controls" SettingsPage { id: root - objectName: "settingsv2DisplaySettingsPage" + objectName: "displaySettingsPage" title: qsTranslate("SettingsDisplay", "Display") showBackButton: false @@ -24,11 +23,11 @@ SettingsPage { title: qsTr("Appearance") FormRow { - objectName: "settingsv2DisplayThemeRow" + objectName: "displayThemeRow" Layout.fillWidth: true title: qsTranslate("SettingsDisplay", "Theme") trailingItem: SegmentedPicker { - objectName: "settingsv2DisplayThemePicker" + objectName: "displayThemePicker" implicitWidth: 190 implicitHeight: 36 model: [qsTr("Light"), qsTr("Dark")] @@ -40,11 +39,11 @@ SettingsPage { } FormRow { - objectName: "settingsv2DisplayBlockStatusSizeRow" + objectName: "displayBlockStatusSizeRow" Layout.fillWidth: true title: qsTranslate("SettingsDisplay", "Block status size") trailingItem: PopupPicker { - objectName: "settingsv2DisplayBlockStatusSizePicker" + objectName: "displayBlockStatusSizePicker" embedded: true minimumMenuWidth: 520 subtitleRole: "description" @@ -72,12 +71,12 @@ SettingsPage { } FormRow { - objectName: "settingsv2DisplayMoneyFontRow" + objectName: "displayMoneyFontRow" Layout.fillWidth: true title: qsTr("Money font") showDivider: false trailingItem: PopupPicker { - objectName: "settingsv2DisplayMoneyFontPicker" + objectName: "displayMoneyFontPicker" embedded: true minimumMenuWidth: 400 subtitleRole: "description" @@ -106,11 +105,11 @@ SettingsPage { title: qsTr("Language and format") FormRow { - objectName: "settingsv2DisplayUnitRow" + objectName: "displayUnitRow" Layout.fillWidth: true title: qsTranslate("SettingsDisplay", "Display unit") trailingItem: PopupPicker { - objectName: "settingsv2DisplayUnitPicker" + objectName: "displayUnitPicker" embedded: true minimumMenuWidth: 400 subtitleRole: "description" @@ -120,25 +119,25 @@ SettingsPage { { text: qsTr("BTC"), value: 0, - objectName: "settingsv2DisplayUnitBTC", + objectName: "displayUnitBTC", description: qsTr("8 decimal places (0.00000001 BTC = 1 sat)") }, { text: qsTr("mBTC"), value: 1, - objectName: "settingsv2DisplayUnitMBTC", + objectName: "displayUnitMBTC", description: qsTr("5 decimal places (0.00001 mBTC = 1 sat)") }, { text: qsTr("bits"), value: 2, - objectName: "settingsv2DisplayUnitBits", + objectName: "displayUnitBits", description: qsTr("2 decimal places (0.01 bits = 1 sat)") }, { text: qsTr("sat"), value: 3, - objectName: "settingsv2DisplayUnitSAT", + objectName: "displayUnitSAT", description: qsTr("Satoshi, the smallest unit (1 sat = 0.00000001 BTC)") } ] @@ -149,12 +148,12 @@ SettingsPage { } ListRow { - objectName: "settingsv2DisplayLanguageRow" + objectName: "displayLanguageRow" Layout.fillWidth: true title: qsTranslate("SettingsDisplay", "Language") enabled: ((optionsModel.coreSettingStatuses || ({})).lang || ({})).canEdit !== false showsDisclosureIndicator: true - disclosureIndicatorObjectName: "settingsv2DisplayLanguageDisclosureIndicator" + disclosureIndicatorObjectName: "displayLanguageDisclosureIndicator" trailingItem: CoreText { text: optionsModel.languageLabel(optionsModel.language) color: Theme.color.neutral7 @@ -164,7 +163,7 @@ SettingsPage { } ListRow { - objectName: "settingsv2DisplayTransactionUrlsRow" + objectName: "displayTransactionUrlsRow" Layout.fillWidth: true title: qsTr("Third-party transaction URLs") showDivider: false @@ -174,13 +173,13 @@ SettingsPage { } FormSection { - objectName: "settingsv2DisplayDeveloperSection" + objectName: "displayDeveloperSection" Layout.fillWidth: true visible: BuildInfo.isDebug title: qsTr("Developer") ListRow { - objectName: "settingsv2DisplayDesignSystemRow" + objectName: "displayDesignSystemRow" Layout.fillWidth: true title: qsTr("Design system") description: qsTr("Preview reusable controls and design tokens.") @@ -193,7 +192,7 @@ SettingsPage { Component { id: languagePage - LegacySettings.SettingsLanguage { + SettingsLanguage { onBack: root.StackView.view.pop() } } @@ -201,8 +200,8 @@ SettingsPage { Component { id: designSystemPage - LegacySettings.SettingsDesignSystem { - objectName: "settingsv2DisplayDesignSystemPage" + SettingsDesignSystem { + objectName: "displayDesignSystemPage" onBack: root.StackView.view.pop() } } @@ -223,7 +222,7 @@ SettingsPage { } CoreTextField { - objectName: "settingsv2ThirdPartyTransactionUrlsInput" + objectName: "thirdPartyTransactionUrlsInput" Layout.fillWidth: true text: optionsModel.thirdPartyTransactionUrls placeholderText: "https://example.com/tx/%s" diff --git a/qml/pages/settings/settingsv2/ExternalSignerSettingsPage.qml b/qml/pages/settings/ExternalSignerSettingsPage.qml similarity index 95% rename from qml/pages/settings/settingsv2/ExternalSignerSettingsPage.qml rename to qml/pages/settings/ExternalSignerSettingsPage.qml index f8736ae515..843ef6b2ce 100644 --- a/qml/pages/settings/settingsv2/ExternalSignerSettingsPage.qml +++ b/qml/pages/settings/ExternalSignerSettingsPage.qml @@ -8,12 +8,12 @@ import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 -import "../../../controls" -import "../../../components" +import "../../controls" +import "../../components" SettingsPage { id: root - objectName: "settingsv2ExternalSignerSettingsPage" + objectName: "externalSignerSettingsPage" title: qsTr("External signer") showBackButton: false @@ -55,19 +55,19 @@ SettingsPage { } PageHeading { - objectName: "settingsv2ExternalSignerIntroduction" + objectName: "externalSignerIntroduction" Layout.fillWidth: true description: qsTr("Connect a hardware wallet or another external signing tool.") } FormSection { - objectName: "settingsv2ExternalSignerPathSection" + objectName: "externalSignerPathSection" Layout.fillWidth: true title: qsTr("Signer path") footerText: qsTr("The add wallet flow can offer external wallets when exactly one supported signer is connected.") FormRow { - objectName: "settingsv2ExternalSignerPathRow" + objectName: "externalSignerPathRow" Layout.fillWidth: true enabled: root.signerStatus.canEdit !== false showDivider: false diff --git a/qml/pages/settings/settingsv2/MempoolSettingsPage.qml b/qml/pages/settings/MempoolSettingsPage.qml similarity index 89% rename from qml/pages/settings/settingsv2/MempoolSettingsPage.qml rename to qml/pages/settings/MempoolSettingsPage.qml index 674aa8f244..b0da668bf3 100644 --- a/qml/pages/settings/settingsv2/MempoolSettingsPage.qml +++ b/qml/pages/settings/MempoolSettingsPage.qml @@ -5,12 +5,12 @@ import QtQuick 2.15 import QtQuick.Layouts 1.15 -import "../../../controls" -import "../../../components" +import "../../controls" +import "../../components" SettingsPage { id: root - objectName: "settingsv2MempoolSettingsPage" + objectName: "mempoolSettingsPage" title: qsTr("Mempool information") showBackButton: false @@ -36,7 +36,7 @@ SettingsPage { } SettingsRestartNotice { - objectName: "settingsv2MempoolRestartNotice" + objectName: "mempoolRestartNotice" visible: optionsModel.mempoolSettingsDirty Layout.fillWidth: true } @@ -46,14 +46,14 @@ SettingsPage { title: qsTr("Mempool") ValueRow { - objectName: "settingsv2MempoolTransactionsRow" + objectName: "mempoolTransactionsRow" Layout.fillWidth: true title: qsTr("Transactions") value: Number(nodeModel.mempoolTransactionCount).toLocaleString(Qt.locale(), "f", 0) } ValueRow { - objectName: "settingsv2MempoolMemoryUsedRow" + objectName: "mempoolMemoryUsedRow" Layout.fillWidth: true title: qsTr("Memory used") value: qsTr("%1 / %2") @@ -63,11 +63,11 @@ SettingsPage { TextFieldRow { id: mempoolSizeRow - objectName: "settingsv2MempoolSizeLimitRow" + objectName: "mempoolSizeLimitRow" Layout.fillWidth: true title: qsTr("Mempool size limit (MB)") enabled: root.maxMempoolStatus.canEdit !== false - fieldObjectName: "settingsv2MempoolSizeLimitInput" + fieldObjectName: "mempoolSizeLimitInput" fieldWidth: 80 text: root.mempoolSizeText validator: IntValidator { diff --git a/qml/pages/settings/settingsv2/NetworkTrafficSettingsPage.qml b/qml/pages/settings/NetworkTrafficSettingsPage.qml similarity index 90% rename from qml/pages/settings/settingsv2/NetworkTrafficSettingsPage.qml rename to qml/pages/settings/NetworkTrafficSettingsPage.qml index 40e92878a1..2ae1249cb5 100644 --- a/qml/pages/settings/settingsv2/NetworkTrafficSettingsPage.qml +++ b/qml/pages/settings/NetworkTrafficSettingsPage.qml @@ -10,12 +10,12 @@ import QtQuick.Layouts 1.15 import org.bitcoincore.qt 1.0 -import "../../../controls" -import "../../../components" +import "../../controls" +import "../../components" SettingsPage { id: root - objectName: "settingsv2NetworkTrafficSettingsPage" + objectName: "networkTrafficSettingsPage" title: qsTr("Network traffic") showBackButton: false maximumContentWidth: width @@ -67,17 +67,17 @@ SettingsPage { } PageHeading { - objectName: "settingsv2NetworkTrafficHeading" + objectName: "networkTrafficHeading" Layout.fillWidth: true description: qsTr("How much data you have sent to and received from your peers.") } FormSection { - objectName: "settingsv2NetworkTrafficSection" + objectName: "networkTrafficSection" Layout.fillWidth: true SegmentedPicker { - objectName: "settingsv2NetworkTrafficRangePicker" + objectName: "networkTrafficRangePicker" Layout.fillWidth: true Layout.leftMargin: 16 Layout.rightMargin: 16 @@ -92,7 +92,7 @@ SettingsPage { } ValueRow { - objectName: "settingsv2NetworkTrafficReceivedRow" + objectName: "networkTrafficReceivedRow" Layout.fillWidth: true title: qsTr("Received") value: root.formatBytes(networkTrafficTower.totalBytesReceived) @@ -104,7 +104,7 @@ SettingsPage { color: Theme.color.green } bodyItem: NetworkTrafficGraph { - objectName: "settingsv2NetworkTrafficReceivedGraph" + objectName: "networkTrafficReceivedGraph" Layout.fillWidth: true Layout.preferredHeight: 250 backgroundColor: Theme.color.neutral1 @@ -121,7 +121,7 @@ SettingsPage { } ValueRow { - objectName: "settingsv2NetworkTrafficSentRow" + objectName: "networkTrafficSentRow" Layout.fillWidth: true title: qsTr("Sent") value: root.formatBytes(networkTrafficTower.totalBytesSent) @@ -134,7 +134,7 @@ SettingsPage { color: Theme.color.blue } bodyItem: NetworkTrafficGraph { - objectName: "settingsv2NetworkTrafficSentGraph" + objectName: "networkTrafficSentGraph" Layout.fillWidth: true Layout.preferredHeight: 250 backgroundColor: Theme.color.neutral1 diff --git a/qml/pages/settings/settingsv2/ProxySettingsPage.qml b/qml/pages/settings/ProxySettingsPage.qml similarity index 88% rename from qml/pages/settings/settingsv2/ProxySettingsPage.qml rename to qml/pages/settings/ProxySettingsPage.qml index b2a0a7fef0..7126fdc814 100644 --- a/qml/pages/settings/settingsv2/ProxySettingsPage.qml +++ b/qml/pages/settings/ProxySettingsPage.qml @@ -8,14 +8,14 @@ import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 -import "../../../controls" -import "../../../components" +import "../../controls" +import "../../components" SettingsPage { id: root - objectName: "settingsv2ProxySettingsPage" + objectName: "proxySettingsPage" title: qsTr("Proxy settings") - backButtonObjectName: "settingsv2ProxySettingsBackButton" + backButtonObjectName: "proxySettingsBackButton" property var settingsModel: optionsModel property var coreSettingsModel: settingsModel.coreSettings @@ -110,20 +110,20 @@ SettingsPage { Component.onCompleted: root.resetProxyDraft() rightItem: NavButton { - objectName: "settingsv2ProxySettingsSaveButton" + objectName: "proxySettingsSaveButton" text: qsTr("Save") enabled: root.canSaveProxyDraft onClicked: root.save() } SettingsRestartNotice { - objectName: "settingsv2ProxyRestartNotice" + objectName: "proxyRestartNotice" visible: root.settingsModel.proxySettingsDirty Layout.fillWidth: true } FormSection { - objectName: "settingsv2DefaultProxySection" + objectName: "defaultProxySection" Layout.fillWidth: true title: qsTr("Default proxy") description: qsTr("Route peer connections through a SOCKS5 proxy. IPv4, IPv6, and Tor connections are supported.") @@ -134,7 +134,7 @@ SettingsPage { supportingText: root.proxySetting.infoText enabled: root.proxySetting.canEdit trailingItem: OptionSwitch { - objectName: "settingsv2ProxyEnableSwitch" + objectName: "proxyEnableSwitch" checked: root.draftProxyEnabled onToggled: root.draftProxyEnabled = checked } @@ -142,11 +142,11 @@ SettingsPage { TextFieldRow { id: proxyAddressRow - objectName: "settingsv2ProxyAddressRow" + objectName: "proxyAddressRow" Layout.fillWidth: true title: qsTr("Proxy location") enabled: root.draftProxyEnabled && root.proxySetting.canEdit - fieldObjectName: "settingsv2ProxyAddressInput" + fieldObjectName: "proxyAddressInput" fieldWidth: 220 text: root.draftProxyAddress placeholderText: root.proxySetting.defaultAddress() @@ -163,7 +163,7 @@ SettingsPage { } FormSection { - objectName: "settingsv2TorProxySection" + objectName: "torProxySection" Layout.fillWidth: true title: qsTr("Tor proxy") description: qsTr("Route Tor connections through a dedicated SOCKS5 proxy.") @@ -174,7 +174,7 @@ SettingsPage { supportingText: root.onionSetting.infoText enabled: root.onionSetting.canEdit trailingItem: OptionSwitch { - objectName: "settingsv2TorEnableSwitch" + objectName: "torEnableSwitch" checked: root.draftTorEnabled onToggled: root.draftTorEnabled = checked } @@ -182,11 +182,11 @@ SettingsPage { TextFieldRow { id: torAddressRow - objectName: "settingsv2TorAddressRow" + objectName: "torAddressRow" Layout.fillWidth: true title: qsTr("Proxy location") enabled: root.draftTorEnabled && root.onionSetting.canEdit - fieldObjectName: "settingsv2TorAddressInput" + fieldObjectName: "torAddressInput" fieldWidth: 220 text: root.draftTorAddress placeholderText: root.onionSetting.defaultAddress() @@ -204,22 +204,22 @@ SettingsPage { AlertPopup { id: discardProxyChangesPopup - objectName: "settingsv2DiscardProxyChangesPopup" + objectName: "discardProxyChangesPopup" parent: Overlay.overlay title: qsTr("Discard changes?") message: qsTr("This will discard your proxy settings changes.") - messageObjectName: "settingsv2DiscardProxyChangesMessage" + messageObjectName: "discardProxyChangesMessage" AlertAction { text: qsTr("Cancel") role: AlertAction.Cancel - buttonObjectName: "settingsv2DiscardProxyChangesCancelButton" + buttonObjectName: "discardProxyChangesCancelButton" } AlertAction { text: qsTr("Discard") role: AlertAction.Destructive - buttonObjectName: "settingsv2DiscardProxyChangesConfirmButton" + buttonObjectName: "discardProxyChangesConfirmButton" onTriggered: { root.resetProxyDraft() root.closeRequested() diff --git a/qml/pages/settings/settingsv2/RpcConsoleSettingsPage.qml b/qml/pages/settings/RpcConsoleSettingsPage.qml similarity index 85% rename from qml/pages/settings/settingsv2/RpcConsoleSettingsPage.qml rename to qml/pages/settings/RpcConsoleSettingsPage.qml index 0a045d02c0..055c33fef3 100644 --- a/qml/pages/settings/settingsv2/RpcConsoleSettingsPage.qml +++ b/qml/pages/settings/RpcConsoleSettingsPage.qml @@ -7,12 +7,12 @@ pragma ComponentBehavior: Bound import QtQuick 2.15 import QtQuick.Controls 2.15 -import "../../../controls" -import "../../node" as NodePages +import "../../controls" +import "../node" as NodePages Page { id: root - objectName: "settingsv2RpcConsoleSettingsPage" + objectName: "rpcConsoleSettingsPage" property string walletName: "" property real maximumContentWidth: 840 @@ -24,7 +24,7 @@ Page { clip: true header: SettingsHeader { - objectName: "settingsv2RpcConsoleHeader" + objectName: "rpcConsoleHeader" title: qsTr("RPC console") showBackButton: false } @@ -44,7 +44,7 @@ Page { NodePages.CommandConsole { id: rpcConsole - objectName: "settingsv2RpcConsole" + objectName: "rpcConsole" anchors.fill: parent showHeader: false tabActive: root.visible diff --git a/qml/pages/settings/settingsv2/StorageSettingsPage.qml b/qml/pages/settings/StorageSettingsPage.qml similarity index 93% rename from qml/pages/settings/settingsv2/StorageSettingsPage.qml rename to qml/pages/settings/StorageSettingsPage.qml index e390f81518..1769bbba57 100644 --- a/qml/pages/settings/settingsv2/StorageSettingsPage.qml +++ b/qml/pages/settings/StorageSettingsPage.qml @@ -7,12 +7,12 @@ pragma ComponentBehavior: Bound import QtQuick 2.15 import QtQuick.Layouts 1.15 -import "../../../controls" -import "../../../components" +import "../../controls" +import "../../components" SettingsPage { id: root - objectName: "settingsv2StorageSettingsPage" + objectName: "storageSettingsPage" title: qsTr("Storage") showBackButton: false @@ -59,7 +59,7 @@ SettingsPage { supportingText: root.pruneSetting.infoText enabled: root.pruneSetting.canEdit trailingItem: OptionSwitch { - objectName: "settingsv2PruneSwitch" + objectName: "pruneSwitch" checked: root.pruneSetting.enabled onToggled: root.pruneSetting.enabled = checked } @@ -70,7 +70,7 @@ SettingsPage { Layout.fillWidth: true title: qsTr("Block storage limit (GB)") enabled: root.pruneSetting.enabled && root.pruneSetting.canEdit - fieldObjectName: "settingsv2PruneTargetInput" + fieldObjectName: "pruneTargetInput" fieldWidth: 80 text: root.pruneTargetText validator: IntValidator { bottom: 1 } @@ -102,7 +102,7 @@ SettingsPage { title: qsTr("Location") showDivider: false bodyItem: CoreText { - objectName: "settingsv2DataDirectoryValue" + objectName: "dataDirectoryValue" Layout.fillWidth: true text: root.settingsModel.dataDir color: Theme.color.neutral7 @@ -116,7 +116,7 @@ SettingsPage { } SettingsRestartNotice { - objectName: "settingsv2StorageRestartNotice" + objectName: "storageRestartNotice" visible: root.settingsModel.storageSettingsDirty Layout.fillWidth: true Layout.maximumWidth: root.contentLayout.width diff --git a/qml/pages/settings/settingsv2/WalletSectionPage.qml b/qml/pages/settings/WalletSectionPage.qml similarity index 92% rename from qml/pages/settings/settingsv2/WalletSectionPage.qml rename to qml/pages/settings/WalletSectionPage.qml index 687fe8c3dc..be99331f1c 100644 --- a/qml/pages/settings/settingsv2/WalletSectionPage.qml +++ b/qml/pages/settings/WalletSectionPage.qml @@ -9,11 +9,11 @@ import QtQuick.Controls 2.15 import QtQuick.Dialogs import QtQuick.Layouts 1.15 -import "../../../controls" +import "../../controls" SettingsPage { id: root - objectName: "settingsv2WalletSettingsPage" + objectName: "walletSettingsPage" title: qsTr("Wallet settings") showBackButton: false @@ -82,7 +82,7 @@ SettingsPage { TextField { id: backupAutomationPath - objectName: "settingsv2WalletSettingsBackupPathField" + objectName: "walletSettingsBackupPathField" visible: false } @@ -123,7 +123,7 @@ SettingsPage { } FormSection { - objectName: "settingsv2WalletInfoSection" + objectName: "walletInfoSection" visible: root.walletLoaded Layout.fillWidth: true title: qsTr("Wallet info") @@ -131,7 +131,7 @@ SettingsPage { TextFieldRow { Layout.fillWidth: true title: qsTr("Name") - fieldObjectName: "settingsv2WalletNameInput" + fieldObjectName: "walletNameInput" fieldWidth: 220 text: root.pendingDisplayName onTextEdited: function(text) { root.pendingDisplayName = text } @@ -164,13 +164,13 @@ SettingsPage { } FormSection { - objectName: "settingsv2WalletActionsSection" + objectName: "walletActionsSection" visible: root.walletLoaded Layout.fillWidth: true title: qsTr("Wallet actions") ListRow { - objectName: "settingsv2WalletAddressesRow" + objectName: "walletAddressesRow" Layout.fillWidth: true title: qsTr("Addresses") showsDisclosureIndicator: true @@ -178,7 +178,7 @@ SettingsPage { } ListRow { - objectName: "settingsv2WalletPasswordRow" + objectName: "walletPasswordRow" visible: root.canManagePassphrase Layout.fillWidth: true title: root.wallet && root.wallet.isEncrypted ? qsTr("Update password") : qsTr("Set password") @@ -187,7 +187,7 @@ SettingsPage { } ListRow { - objectName: "settingsv2WalletBackupRow" + objectName: "walletBackupRow" Layout.fillWidth: true title: qsTr("Back up wallet") showsDisclosureIndicator: true @@ -195,7 +195,7 @@ SettingsPage { } ListRow { - objectName: "settingsv2WalletSignVerifyMessageRow" + objectName: "walletSignVerifyMessageRow" Layout.fillWidth: true title: qsTr("Sign or verify message") showDivider: false diff --git a/qml/pages/settings/settingsv2/WindowBehaviorSettingsPage.qml b/qml/pages/settings/WindowBehaviorSettingsPage.qml similarity index 87% rename from qml/pages/settings/settingsv2/WindowBehaviorSettingsPage.qml rename to qml/pages/settings/WindowBehaviorSettingsPage.qml index fabfff9dea..c821f50b42 100644 --- a/qml/pages/settings/settingsv2/WindowBehaviorSettingsPage.qml +++ b/qml/pages/settings/WindowBehaviorSettingsPage.qml @@ -6,11 +6,11 @@ pragma ComponentBehavior: Bound import QtQuick.Layouts 1.15 -import "../../../controls" +import "../../controls" SettingsPage { id: root - objectName: "settingsv2WindowBehaviorSettingsPage" + objectName: "windowBehaviorSettingsPage" title: qsTr("Window behavior") showBackButton: false @@ -26,7 +26,7 @@ SettingsPage { description: qsTr("Keep the app available in the system tray.") enabled: root.windowBehaviorModel.desktopPlatform trailingItem: OptionSwitch { - objectName: "settingsv2ShowTrayIconSwitch" + objectName: "showTrayIconSwitch" checked: root.windowBehaviorModel.showTrayIcon onToggled: root.windowBehaviorModel.showTrayIcon = checked } @@ -39,7 +39,7 @@ SettingsPage { enabled: root.windowBehaviorModel.desktopPlatform && root.windowBehaviorModel.showTrayIcon trailingItem: OptionSwitch { - objectName: "settingsv2MinimizeToTraySwitch" + objectName: "minimizeToTraySwitch" checked: root.windowBehaviorModel.minimizeToTray onToggled: root.windowBehaviorModel.minimizeToTray = checked } @@ -52,7 +52,7 @@ SettingsPage { enabled: root.windowBehaviorModel.desktopPlatform showDivider: false trailingItem: OptionSwitch { - objectName: "settingsv2MinimizeOnCloseSwitch" + objectName: "minimizeOnCloseSwitch" checked: root.windowBehaviorModel.minimizeOnClose onToggled: root.windowBehaviorModel.minimizeOnClose = checked } diff --git a/qml/pages/wallet/DesktopWallets.qml b/qml/pages/wallet/DesktopWallets.qml index 9a54022298..a32c2f5e31 100644 --- a/qml/pages/wallet/DesktopWallets.qml +++ b/qml/pages/wallet/DesktopWallets.qml @@ -251,7 +251,7 @@ Page { NavigationTab { id: settingsTabButton objectName: "desktopWalletSettingsTabButton" - iconSource: "image://images/gear" + iconSource: "image://images/gear-outline" iconColor: Theme.color.neutral7 Layout.preferredWidth: 30 property int index: 5 diff --git a/test/functional/qml_test_activity_filter_export.py b/test/functional/qml_test_activity_filter_export.py index 51663b78cf..799f988ccb 100644 --- a/test/functional/qml_test_activity_filter_export.py +++ b/test/functional/qml_test_activity_filter_export.py @@ -223,10 +223,10 @@ def run_test(save_screenshots=False, screenshot_root=None): gui.click("desktopWalletSettingsTabButton") gui.wait_for_property("settingsSidebar_display", "visible", True, timeout_ms=5000) gui.click("settingsSidebar_display") - gui.wait_for_page("settingsv2DisplayUnitPicker", timeout_ms=5000) - gui.click("settingsv2DisplayUnitPickerButton") - gui.wait_for_page("settingsv2DisplayUnitSAT", timeout_ms=5000) - gui.click("settingsv2DisplayUnitSAT") + gui.wait_for_page("displayUnitPicker", timeout_ms=5000) + gui.click("displayUnitPickerButton") + gui.wait_for_page("displayUnitSAT", timeout_ms=5000) + gui.click("displayUnitSAT") checkpoints.checkpoint("display unit switched to sats", gui) gui.click("activityTabButton") diff --git a/test/functional/qml_test_addresses.py b/test/functional/qml_test_addresses.py index 863c56e848..810b421f5a 100755 --- a/test/functional/qml_test_addresses.py +++ b/test/functional/qml_test_addresses.py @@ -92,8 +92,8 @@ def open_address_list_from_settings(gui): gui.settle() gui.wait_for_property("settingsSidebar_wallet", "visible", True, timeout_ms=5000) gui.click("settingsSidebar_wallet") - gui.wait_for_property("settingsv2WalletSettingsPage", "visible", True, timeout_ms=5000) - gui.click("settingsv2WalletAddressesRow") + gui.wait_for_property("walletSettingsPage", "visible", True, timeout_ms=5000) + gui.click("walletAddressesRow") gui.wait_for_property("addressListPage", "visible", True, timeout_ms=10000) diff --git a/test/functional/qml_test_console.py b/test/functional/qml_test_console.py index 7fd153a13c..97590ad375 100644 --- a/test/functional/qml_test_console.py +++ b/test/functional/qml_test_console.py @@ -51,8 +51,8 @@ def navigate_to_console(gui): gui.click("nodeSettingsButton") gui.wait_for_property("settingsSidebar_rpc-console", "visible", True, timeout_ms=5000) gui.click("settingsSidebar_rpc-console") - gui.wait_for_page("settingsv2RpcConsoleSettingsPage", timeout_ms=5000) - gui.wait_for_page("settingsv2RpcConsole", timeout_ms=5000) + gui.wait_for_page("rpcConsoleSettingsPage", timeout_ms=5000) + gui.wait_for_page("rpcConsole", timeout_ms=5000) # The command input auto-focuses on open (desktop), mirroring Core's # RPCConsole, so the user can type immediately. gui.wait_for_property("consoleInput", "activeFocus", True, timeout_ms=5000) @@ -69,14 +69,14 @@ def assert_close(actual, expected, label, tolerance=1): def submit_console_command(gui, command): gui.set_text("consoleInput", command) - gui.invoke("settingsv2RpcConsole", "runHighlightedOrSubmit") + 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 ───────────────────────") - root_width = gui.get_property("settingsv2RpcConsole", "width") + root_width = gui.get_property("rpcConsole", "width") row_x = gui.get_property("consoleInputRow", "x") row_width = gui.get_property("consoleInputRow", "width") row_height = gui.get_property("consoleInputRow", "height") @@ -107,19 +107,19 @@ def test_console_input_bar_matches_design(gui): 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("settingsv2RpcConsole", "searchMode") is False + assert gui.get_property("rpcConsole", "searchMode") is False gui.click("consoleModeToggleButton") - gui.wait_for_property("settingsv2RpcConsole", "searchMode", True, timeout_ms=3000) + gui.wait_for_property("rpcConsole", "searchMode", True, timeout_ms=3000) assert gui.get_property("consoleInput", "placeholderText") == "Search..." gui.click("consoleFontIncreaseButton") - assert gui.get_property("settingsv2RpcConsole", "outputFontPixelSize") == 14 + assert gui.get_property("rpcConsole", "outputFontPixelSize") == 14 gui.click("consoleFontDecreaseButton") - assert gui.get_property("settingsv2RpcConsole", "outputFontPixelSize") == 13 + assert gui.get_property("rpcConsole", "outputFontPixelSize") == 13 gui.click("consoleModeToggleButton") - gui.wait_for_property("settingsv2RpcConsole", "searchMode", False, timeout_ms=3000) + 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") @@ -140,8 +140,8 @@ 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("settingsv2RpcConsole", "outputCount", lambda v: v >= 1, timeout_ms=3000) - root_width = gui.get_property("settingsv2RpcConsole", "width") + gui.wait_for_property("rpcConsole", "outputCount", lambda v: v >= 1, timeout_ms=3000) + root_width = gui.get_property("rpcConsole", "width") column_width = root_width - 40 assert_close(gui.get_property("consoleOutputArea_contentColumn", "x"), 20, "console output column x") @@ -155,10 +155,10 @@ def test_console_output_rows_match_design(gui): assert "Use ↑↓ arrows" in welcome_text assert "help-console" in welcome_text - count_before = gui.get_property("settingsv2RpcConsole", "outputCount") + count_before = gui.get_property("rpcConsole", "outputCount") submit_console_command(gui, "getblockcount") - gui.wait_for_property("settingsv2RpcConsole", "executing", False, timeout_ms=10000) - gui.wait_for_property("settingsv2RpcConsole", "outputCount", count_before + 2, timeout_ms=3000) + gui.wait_for_property("rpcConsole", "executing", False, timeout_ms=10000) + gui.wait_for_property("rpcConsole", "outputCount", count_before + 2, timeout_ms=3000) request_index = count_before reply_index = count_before + 1 @@ -179,13 +179,13 @@ def test_execute_getblockcount(gui): """Execute getblockcount and verify a request + reply pair appears (no error row).""" print("\n── test_execute_getblockcount ──────────────────────────────────") - count_before = gui.get_property("settingsv2RpcConsole", "outputCount") + count_before = gui.get_property("rpcConsole", "outputCount") submit_console_command(gui, "getblockcount") # Wait for execution to complete. - gui.wait_for_property("settingsv2RpcConsole", "executing", False, timeout_ms=10000) + gui.wait_for_property("rpcConsole", "executing", False, timeout_ms=10000) - count_after = gui.get_property("settingsv2RpcConsole", "outputCount") + count_after = gui.get_property("rpcConsole", "outputCount") # Expect exactly 2 new rows: one CMD_REQUEST (command echo) and one # CMD_REPLY (the numeric block count). An error would add a third row. assert count_after == count_before + 2, ( @@ -199,12 +199,12 @@ def test_execute_help(gui): """Execute 'help' and verify output rows appear.""" print("\n── test_execute_help ───────────────────────────────────────────") - count_before = gui.get_property("settingsv2RpcConsole", "outputCount") + count_before = gui.get_property("rpcConsole", "outputCount") submit_console_command(gui, "help") - gui.wait_for_property("settingsv2RpcConsole", "executing", False, timeout_ms=10000) + gui.wait_for_property("rpcConsole", "executing", False, timeout_ms=10000) - count_after = gui.get_property("settingsv2RpcConsole", "outputCount") + count_after = gui.get_property("rpcConsole", "outputCount") assert count_after > count_before, ( f"Expected output rows after help (before={count_before}, after={count_after})" ) @@ -215,13 +215,13 @@ def test_execute_invalid_command(gui): """Execute an unknown command and verify the submit button re-enables and output appears.""" print("\n── test_execute_invalid_command ────────────────────────────────") - count_before = gui.get_property("settingsv2RpcConsole", "outputCount") + count_before = gui.get_property("rpcConsole", "outputCount") submit_console_command(gui, "thiscommanddoesnotexist") # Wait for execution to complete (button stays disabled since input was cleared). - gui.wait_for_property("settingsv2RpcConsole", "executing", False, timeout_ms=10000) + gui.wait_for_property("rpcConsole", "executing", False, timeout_ms=10000) - count_after = gui.get_property("settingsv2RpcConsole", "outputCount") + count_after = gui.get_property("rpcConsole", "outputCount") assert count_after > count_before, ( f"Expected error output rows after invalid command (before={count_before}, after={count_after})" ) @@ -281,7 +281,7 @@ def test_back_navigation(gui): """Close Settings from the RPC console and verify we return to NodeRunner.""" print("\n── test_back_navigation ────────────────────────────────────────") - gui.click("settingsv2SettingsDoneButton") + gui.click("settingsDoneButton") gui.wait_for_page("nodeRunner", timeout_ms=5000) print(" PASSED: back navigation returned to NodeRunner") @@ -291,12 +291,12 @@ def test_clear_button_restores_welcome_output(gui): print("\n── test_clear_button_restores_welcome_output ───────────────────") gui.set_text("consoleInput", "") - assert gui.get_property("settingsv2RpcConsole", "outputCount") > 0 + 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("settingsv2RpcConsole", "outputCount", 1, timeout_ms=3000) + 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), ( diff --git a/test/functional/qml_test_debug_log.py b/test/functional/qml_test_debug_log.py index 7e533d983e..05be208788 100644 --- a/test/functional/qml_test_debug_log.py +++ b/test/functional/qml_test_debug_log.py @@ -178,7 +178,16 @@ def test_search_layout_matches_design(gui): ) page_width = gui.get_property("settingsDebugLog", "width") content_width = gui.get_property("debugLogContentLayout", "width") - expected_content_width = max(0, min(page_width - 40, 600)) + 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") @@ -398,7 +407,7 @@ def test_load_more_at_bottom(gui, current_count): def test_close_settings(gui): """Clicking Done exits the desktop settings shell.""" print("\n── test_close_settings ───────────────────────────────────────────") - gui.click("settingsv2SettingsDoneButton") + gui.click("settingsDoneButton") gui.wait_for_page("nodeSettingsButton", timeout_ms=5000) print(" PASSED: Done closed node settings") diff --git a/test/functional/qml_test_disablewallet_boot.py b/test/functional/qml_test_disablewallet_boot.py index 16c396c8f6..3d6924a7ee 100755 --- a/test/functional/qml_test_disablewallet_boot.py +++ b/test/functional/qml_test_disablewallet_boot.py @@ -160,54 +160,54 @@ def assert_wallet_boot(gui): def walk_about_settings(gui, checkpoints): print(" Opening About settings (sidebar)") gui.click("settingsSidebar_about") - gui.wait_for_page("settingsv2AboutSettingsPage", timeout_ms=SETTINGS_TIMEOUT_MS) + gui.wait_for_page("aboutSettingsPage", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("about settings opened", gui) - gui.click("settingsv2AboutDeveloperRow") + gui.click("aboutDeveloperRow") gui.wait_for_page("settingsDeveloper", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("developer settings opened", gui) gui.click("settingsDeveloperBack") - gui.wait_for_page("settingsv2AboutSettingsPage", timeout_ms=SETTINGS_TIMEOUT_MS) + gui.wait_for_page("aboutSettingsPage", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("returned from developer settings", gui) def walk_display_settings(gui, checkpoints): print(" Opening Display settings (sidebar)") gui.click("settingsSidebar_display") - gui.wait_for_page("settingsv2DisplayUnitPicker", timeout_ms=SETTINGS_TIMEOUT_MS) + gui.wait_for_page("displayUnitPicker", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("display settings opened", gui) - gui.click("settingsv2DisplayUnitPickerButton") - gui.wait_for_page("settingsv2DisplayUnitPickerMenu", timeout_ms=SETTINGS_TIMEOUT_MS) + gui.click("displayUnitPickerButton") + gui.wait_for_page("displayUnitPickerMenu", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("display unit picker opened", gui) - gui.click("settingsv2DisplayUnitBTC") + gui.click("displayUnitBTC") - gui.click("settingsv2DisplayLanguageRow") + gui.click("displayLanguageRow") gui.wait_for_page("settingsLanguagePage", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("language settings opened", gui) gui.click("settingsLanguageBack") - gui.wait_for_page("settingsv2DisplayLanguageRow", timeout_ms=SETTINGS_TIMEOUT_MS) + gui.wait_for_page("displayLanguageRow", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("returned from display sub-pages", gui) def walk_storage_settings(gui, checkpoints): print(" Opening Storage settings (sidebar)") gui.click("settingsSidebar_storage") - gui.wait_for_page("settingsv2StorageSettingsPage", timeout_ms=SETTINGS_TIMEOUT_MS) + gui.wait_for_page("storageSettingsPage", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("storage settings opened", gui) def walk_connection_settings(gui, checkpoints): print(" Opening Connection settings (sidebar)") gui.click("settingsSidebar_connection") - gui.wait_for_page("settingsv2ProxySettingsRow", timeout_ms=SETTINGS_TIMEOUT_MS) + gui.wait_for_page("proxySettingsRow", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("connection settings opened", gui) - gui.click("settingsv2ProxySettingsRow") - gui.wait_for_page("settingsv2ProxySettingsPage", timeout_ms=SETTINGS_TIMEOUT_MS) + gui.click("proxySettingsRow") + gui.wait_for_page("proxySettingsPage", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("proxy settings opened", gui) - gui.click("settingsv2ProxySettingsBackButton") - gui.wait_for_page("settingsv2ProxySettingsRow", timeout_ms=SETTINGS_TIMEOUT_MS) + gui.click("proxySettingsBackButton") + gui.wait_for_page("proxySettingsRow", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("returned from proxy settings", gui) @@ -224,7 +224,7 @@ def walk_peers(gui, checkpoints): def walk_network_traffic_settings(gui, checkpoints): print(" Opening Network Traffic settings (sidebar)") gui.click("settingsSidebar_network-traffic") - gui.wait_for_page("settingsv2NetworkTrafficSettingsPage", timeout_ms=SETTINGS_TIMEOUT_MS) + gui.wait_for_page("networkTrafficSettingsPage", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("network traffic settings opened", gui) @@ -260,7 +260,7 @@ def run_node_only_flow(harness, checkpoints, *, full_walk): walk_network_traffic_settings(gui, checkpoints) walk_debug_log_settings(gui, checkpoints) - gui.click("settingsv2SettingsDoneButton") + gui.click("settingsDoneButton") gui.wait_for_page("nodeSettingsButton", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("node settings closed", gui) diff --git a/test/functional/qml_test_external_signer.py b/test/functional/qml_test_external_signer.py index 4ba0625101..ee0fb4dc13 100644 --- a/test/functional/qml_test_external_signer.py +++ b/test/functional/qml_test_external_signer.py @@ -251,7 +251,7 @@ def open_selected_wallet_settings(gui): gui.wait_for_property("settingsSidebar_wallet", "visible", True, timeout_ms=10000) gui.click("settingsSidebar_wallet") try: - gui.wait_for_page("settingsv2WalletSettingsPage", timeout_ms=1000) + gui.wait_for_page("walletSettingsPage", timeout_ms=1000) except QmlDriverError: # If a prior step left the preserved wallet stack on a subpage, return # to the redesigned wallet settings root. @@ -266,7 +266,7 @@ def open_selected_wallet_settings(gui): break except QmlDriverError: pass - gui.wait_for_page("settingsv2WalletSettingsPage", timeout_ms=10000) + gui.wait_for_page("walletSettingsPage", timeout_ms=10000) def configure_external_signer_via_gui(harness, checkpoints, signer_path): @@ -506,8 +506,8 @@ def run_test(args): configure_external_signer_via_gui(harness, checkpoints, signer_path) wallet_name = create_and_verify_external_wallet(harness, checkpoints) open_selected_wallet_settings(harness.driver) - harness.driver.wait_for_property("settingsv2WalletPasswordRow", "visible", False, timeout_ms=10000) - harness.driver.wait_for_property("settingsv2WalletBackupRow", "visible", True, timeout_ms=10000) + harness.driver.wait_for_property("walletPasswordRow", "visible", False, timeout_ms=10000) + harness.driver.wait_for_property("walletBackupRow", "visible", True, timeout_ms=10000) checkpoints.checkpoint("external signer wallet hides password settings", harness.driver) create_wallet(harness.gui_rpc_port, "miner", load_on_startup=False) diff --git a/test/functional/qml_test_password_wallet.py b/test/functional/qml_test_password_wallet.py index f619e6281a..9d0b1f8b11 100644 --- a/test/functional/qml_test_password_wallet.py +++ b/test/functional/qml_test_password_wallet.py @@ -236,7 +236,7 @@ def open_wallet_settings_page(gui): gui.click("desktopWalletSettingsTabButton") gui.wait_for_property("settingsSidebar_wallet", "visible", True, timeout_ms=10000) gui.click("settingsSidebar_wallet") - gui.wait_for_page("settingsv2WalletSettingsPage", timeout_ms=10000) + gui.wait_for_page("walletSettingsPage", timeout_ms=10000) def open_import_wallet_page(gui): @@ -551,8 +551,8 @@ def case_close_loaded_wallet_from_selector(harness, checkpoints): remaining_wallet = next(name for name in wallet_names if name != selected_wallet) open_wallet_settings_page(gui) - gui.wait_for_property("settingsv2WalletPasswordRow", "visible", True, timeout_ms=10000) - gui.wait_for_property("settingsv2WalletBackupRow", "visible", True, timeout_ms=10000) + gui.wait_for_property("walletPasswordRow", "visible", True, timeout_ms=10000) + gui.wait_for_property("walletBackupRow", "visible", True, timeout_ms=10000) checkpoints.checkpoint("wallet settings opened", gui) open_wallet_selector(gui) diff --git a/test/functional/qml_test_proxy.py b/test/functional/qml_test_proxy.py index f49e944a62..29e847072d 100644 --- a/test/functional/qml_test_proxy.py +++ b/test/functional/qml_test_proxy.py @@ -38,23 +38,23 @@ def navigate_to_proxy_settings(gui): gui.settle() gui.wait_for_property("settingsSidebar_connection", "visible", True, timeout_ms=5000) gui.click("settingsSidebar_connection") - gui.wait_for_page("settingsv2ProxySettingsRow", timeout_ms=5000) - gui.click("settingsv2ProxySettingsRow") - gui.wait_for_page("settingsv2ProxySettingsPage", timeout_ms=5000) + gui.wait_for_page("proxySettingsRow", timeout_ms=5000) + gui.click("proxySettingsRow") + gui.wait_for_page("proxySettingsPage", timeout_ms=5000) print(" Navigated to Proxy Settings page.") def leave_proxy_settings_with_done(gui): """Commit draft proxy settings and return to Connection settings.""" - gui.wait_for_property("settingsv2ProxySettingsSaveButton", "enabled", True, timeout_ms=2000) - gui.click("settingsv2ProxySettingsSaveButton") - gui.wait_for_page("settingsv2ProxySettingsRow", timeout_ms=5000) + gui.wait_for_property("proxySettingsSaveButton", "enabled", True, timeout_ms=2000) + gui.click("proxySettingsSaveButton") + gui.wait_for_page("proxySettingsRow", timeout_ms=5000) def navigate_back_from_connection_settings(gui): """Navigate back from Connection settings to the runtime settings shell.""" - if gui.object_exists("settingsv2SettingsDoneButton"): - gui.click("settingsv2SettingsDoneButton") + if gui.object_exists("settingsDoneButton"): + gui.click("settingsDoneButton") else: gui.click("activityTabButton") gui.settle() @@ -69,34 +69,34 @@ def test_default_proxy_toggle(gui): print("\n── test_default_proxy_toggle ──────────────────────────────────────") # Proxy should be disabled by default (fresh datadir, no prior config). - checked = gui.get_property("settingsv2ProxyEnableSwitch", "checked") + checked = gui.get_property("proxyEnableSwitch", "checked") assert not checked, f"Expected proxy disabled by default, got checked={checked}" - dirty = gui.get_property("settingsv2ProxyRestartNotice", "visible") + dirty = gui.get_property("proxyRestartNotice", "visible") assert not dirty, "Expected proxySettingsDirty=False before any change" - draft_dirty = gui.get_property("settingsv2ProxySettingsPage", "proxyDraftDirty") + draft_dirty = gui.get_property("proxySettingsPage", "proxyDraftDirty") assert not draft_dirty, "Expected proxyDraftDirty=False before any change" # Enable proxy. - gui.click("settingsv2ProxyEnableSwitch") - gui.wait_for_property("settingsv2ProxyEnableSwitch", "checked", True, timeout_ms=2000) - checked = gui.get_property("settingsv2ProxyEnableSwitch", "checked") + gui.click("proxyEnableSwitch") + gui.wait_for_property("proxyEnableSwitch", "checked", True, timeout_ms=2000) + checked = gui.get_property("proxyEnableSwitch", "checked") assert checked, "Expected proxyEnableSwitch to be checked after click" print(" Default proxy toggled ON: OK") - gui.wait_for_property("settingsv2ProxySettingsPage", "proxyDraftDirty", True, timeout_ms=2000) - dirty = gui.get_property("settingsv2ProxyRestartNotice", "visible") + gui.wait_for_property("proxySettingsPage", "proxyDraftDirty", True, timeout_ms=2000) + dirty = gui.get_property("proxyRestartNotice", "visible") assert not dirty, "Expected proxySettingsDirty=False before pressing Done" print(" Proxy edit is draft-only before Done: OK") # Disable proxy. - gui.click("settingsv2ProxyEnableSwitch") - gui.wait_for_property("settingsv2ProxyEnableSwitch", "checked", False, timeout_ms=2000) - checked = gui.get_property("settingsv2ProxyEnableSwitch", "checked") + gui.click("proxyEnableSwitch") + gui.wait_for_property("proxyEnableSwitch", "checked", False, timeout_ms=2000) + checked = gui.get_property("proxyEnableSwitch", "checked") assert not checked, "Expected proxyEnableSwitch to be unchecked after second click" print(" Default proxy toggled OFF: OK") - gui.wait_for_property("settingsv2ProxySettingsPage", "proxyDraftDirty", False, timeout_ms=2000) + gui.wait_for_property("proxySettingsPage", "proxyDraftDirty", False, timeout_ms=2000) print(" proxyDraftDirty=False after reverting proxy change: OK") @@ -104,19 +104,19 @@ def test_proxy_valid_address(gui): print("\n── test_proxy_valid_address ────────────────────────────────────────") # Enable proxy so the address field becomes active. - if not gui.get_property("settingsv2ProxyEnableSwitch", "checked"): - gui.click("settingsv2ProxyEnableSwitch") - gui.wait_for_property("settingsv2ProxyEnableSwitch", "checked", True, timeout_ms=2000) - - gui.wait_for_property("settingsv2ProxyAddressInput", "enabled", True, timeout_ms=2000) - gui.set_text("settingsv2ProxyAddressInput", "") - gui.wait_for_property("settingsv2ProxyAddressInput", "text", "", timeout_ms=2000) - gui.invoke("settingsv2ProxyAddressInput", "forceActiveFocus") - gui.wait_for_property("settingsv2ProxyAddressInput", "activeFocus", True, timeout_ms=2000) - gui.type_text("settingsv2ProxyAddressInput", "10.0.0.1:9050") - gui.wait_for_property("settingsv2ProxyAddressInput", "text", "10.0.0.1:9050", timeout_ms=2000) - gui.wait_for_property("settingsv2ProxySettingsPage", "draftProxyValidationError", "", timeout_ms=2000) - gui.wait_for_property("settingsv2ProxySettingsSaveButton", "enabled", True, timeout_ms=2000) + if not gui.get_property("proxyEnableSwitch", "checked"): + gui.click("proxyEnableSwitch") + gui.wait_for_property("proxyEnableSwitch", "checked", True, timeout_ms=2000) + + gui.wait_for_property("proxyAddressInput", "enabled", True, timeout_ms=2000) + gui.set_text("proxyAddressInput", "") + gui.wait_for_property("proxyAddressInput", "text", "", timeout_ms=2000) + gui.invoke("proxyAddressInput", "forceActiveFocus") + gui.wait_for_property("proxyAddressInput", "activeFocus", True, timeout_ms=2000) + gui.type_text("proxyAddressInput", "10.0.0.1:9050") + gui.wait_for_property("proxyAddressInput", "text", "10.0.0.1:9050", timeout_ms=2000) + gui.wait_for_property("proxySettingsPage", "draftProxyValidationError", "", timeout_ms=2000) + gui.wait_for_property("proxySettingsSaveButton", "enabled", True, timeout_ms=2000) print(" Valid address accepted: OK") @@ -124,47 +124,47 @@ def test_proxy_invalid_address(gui): print("\n── test_proxy_invalid_address ──────────────────────────────────────") # Enable proxy so the address field becomes active. - if not gui.get_property("settingsv2ProxyEnableSwitch", "checked"): - gui.click("settingsv2ProxyEnableSwitch") - gui.wait_for_property("settingsv2ProxyEnableSwitch", "checked", True, timeout_ms=2000) + if not gui.get_property("proxyEnableSwitch", "checked"): + gui.click("proxyEnableSwitch") + gui.wait_for_property("proxyEnableSwitch", "checked", True, timeout_ms=2000) - gui.wait_for_property("settingsv2ProxyAddressInput", "enabled", True, timeout_ms=2000) + gui.wait_for_property("proxyAddressInput", "enabled", True, timeout_ms=2000) # Enter an address with invalid IP octets. - gui.set_text("settingsv2ProxyAddressInput", "999.999.999.999:9050") + gui.set_text("proxyAddressInput", "999.999.999.999:9050") gui.wait_for_property( - "settingsv2ProxySettingsPage", + "proxySettingsPage", "draftProxyValidationError", lambda error: len(error) > 0, timeout_ms=2000, ) - gui.wait_for_property("settingsv2ProxySettingsSaveButton", "enabled", False, timeout_ms=2000) + gui.wait_for_property("proxySettingsSaveButton", "enabled", False, timeout_ms=2000) print(" Invalid address rejected: OK") # Restore to a valid address for subsequent tests. - gui.set_text("settingsv2ProxyAddressInput", "127.0.0.1:9050") - gui.wait_for_property("settingsv2ProxySettingsPage", "draftProxyValidationError", "", timeout_ms=2000) - gui.wait_for_property("settingsv2ProxySettingsSaveButton", "enabled", True, timeout_ms=2000) + gui.set_text("proxyAddressInput", "127.0.0.1:9050") + gui.wait_for_property("proxySettingsPage", "draftProxyValidationError", "", timeout_ms=2000) + gui.wait_for_property("proxySettingsSaveButton", "enabled", True, timeout_ms=2000) def test_tor_proxy_toggle(gui): print("\n── test_tor_proxy_toggle ───────────────────────────────────────────") # Tor proxy should be disabled by default. - checked = gui.get_property("settingsv2TorEnableSwitch", "checked") + checked = gui.get_property("torEnableSwitch", "checked") assert not checked, f"Expected Tor proxy disabled by default, got checked={checked}" # Enable Tor proxy. - gui.click("settingsv2TorEnableSwitch") - gui.wait_for_property("settingsv2TorEnableSwitch", "checked", True, timeout_ms=2000) - gui.wait_for_property("settingsv2TorAddressInput", "enabled", True, timeout_ms=2000) - checked = gui.get_property("settingsv2TorEnableSwitch", "checked") + gui.click("torEnableSwitch") + gui.wait_for_property("torEnableSwitch", "checked", True, timeout_ms=2000) + gui.wait_for_property("torAddressInput", "enabled", True, timeout_ms=2000) + checked = gui.get_property("torEnableSwitch", "checked") assert checked, "Expected torEnableSwitch to be checked after click" print(" Tor proxy toggled ON: OK") # Disable Tor proxy. - gui.click("settingsv2TorEnableSwitch") - gui.wait_for_property("settingsv2TorEnableSwitch", "checked", False, timeout_ms=2000) - checked = gui.get_property("settingsv2TorEnableSwitch", "checked") + gui.click("torEnableSwitch") + gui.wait_for_property("torEnableSwitch", "checked", False, timeout_ms=2000) + checked = gui.get_property("torEnableSwitch", "checked") assert not checked, "Expected torEnableSwitch to be unchecked after second click" print(" Tor proxy toggled OFF: OK") @@ -172,32 +172,32 @@ def test_tor_proxy_toggle(gui): def test_back_discards_proxy_draft(gui): print("\n── test_back_discards_proxy_draft ─────────────────────────────────") - if not gui.get_property("settingsv2ProxyEnableSwitch", "checked"): - gui.click("settingsv2ProxyEnableSwitch") - gui.wait_for_property("settingsv2ProxyEnableSwitch", "checked", True, timeout_ms=2000) + if not gui.get_property("proxyEnableSwitch", "checked"): + gui.click("proxyEnableSwitch") + gui.wait_for_property("proxyEnableSwitch", "checked", True, timeout_ms=2000) - gui.wait_for_property("settingsv2ProxyAddressInput", "enabled", True, timeout_ms=2000) - gui.set_text("settingsv2ProxyAddressInput", "10.0.0.5:9050") - gui.wait_for_property("settingsv2ProxyAddressInput", "text", "10.0.0.5:9050", timeout_ms=2000) - gui.wait_for_property("settingsv2ProxySettingsPage", "proxyDraftDirty", True, timeout_ms=2000) - dirty = gui.get_property("settingsv2ProxyRestartNotice", "visible") + gui.wait_for_property("proxyAddressInput", "enabled", True, timeout_ms=2000) + gui.set_text("proxyAddressInput", "10.0.0.5:9050") + gui.wait_for_property("proxyAddressInput", "text", "10.0.0.5:9050", timeout_ms=2000) + gui.wait_for_property("proxySettingsPage", "proxyDraftDirty", True, timeout_ms=2000) + dirty = gui.get_property("proxyRestartNotice", "visible") assert not dirty, "Expected model to remain unchanged before pressing Done" - gui.click("settingsv2ProxySettingsBackButton") - gui.wait_for_property("settingsv2DiscardProxyChangesPopup", "visible", True, timeout_ms=2000) - gui.click("settingsv2DiscardProxyChangesCancelButton") - gui.wait_for_property("settingsv2DiscardProxyChangesPopup", "visible", False, timeout_ms=2000) - gui.wait_for_property("settingsv2ProxyAddressInput", "text", "10.0.0.5:9050", timeout_ms=2000) + gui.click("proxySettingsBackButton") + gui.wait_for_property("discardProxyChangesPopup", "visible", True, timeout_ms=2000) + gui.click("discardProxyChangesCancelButton") + gui.wait_for_property("discardProxyChangesPopup", "visible", False, timeout_ms=2000) + gui.wait_for_property("proxyAddressInput", "text", "10.0.0.5:9050", timeout_ms=2000) print(" Back cancellation keeps draft changes: OK") - gui.click("settingsv2ProxySettingsBackButton") - gui.wait_for_property("settingsv2DiscardProxyChangesPopup", "visible", True, timeout_ms=2000) - gui.click("settingsv2DiscardProxyChangesConfirmButton") - gui.wait_for_page("settingsv2ProxySettingsRow", timeout_ms=5000) - gui.click("settingsv2ProxySettingsRow") - gui.wait_for_page("settingsv2ProxySettingsPage", timeout_ms=5000) - gui.wait_for_property("settingsv2ProxyEnableSwitch", "checked", False, timeout_ms=2000) - gui.wait_for_property("settingsv2ProxySettingsPage", "proxyDraftDirty", False, timeout_ms=2000) + gui.click("proxySettingsBackButton") + gui.wait_for_property("discardProxyChangesPopup", "visible", True, timeout_ms=2000) + gui.click("discardProxyChangesConfirmButton") + gui.wait_for_page("proxySettingsRow", timeout_ms=5000) + gui.click("proxySettingsRow") + gui.wait_for_page("proxySettingsPage", timeout_ms=5000) + gui.wait_for_property("proxyEnableSwitch", "checked", False, timeout_ms=2000) + gui.wait_for_property("proxySettingsPage", "proxyDraftDirty", False, timeout_ms=2000) print(" Back discard leaves persisted settings unchanged: OK") @@ -259,19 +259,19 @@ def run_tests(): # Prepare state for the persistence test. Runtime proxy settings remain # local drafts until the page-level Done button is pressed. if harness.datadir: - if not gui.get_property("settingsv2ProxyEnableSwitch", "checked"): - gui.click("settingsv2ProxyEnableSwitch") - gui.wait_for_property("settingsv2ProxyEnableSwitch", "checked", True, timeout_ms=2000) - gui.wait_for_property("settingsv2ProxyAddressInput", "enabled", True, timeout_ms=2000) - gui.set_text("settingsv2ProxyAddressInput", "10.0.0.1:9050") - gui.wait_for_property("settingsv2ProxySettingsPage", "draftProxyValidationError", "", timeout_ms=2000) - - if not gui.get_property("settingsv2TorEnableSwitch", "checked"): - gui.click("settingsv2TorEnableSwitch") - gui.wait_for_property("settingsv2TorEnableSwitch", "checked", True, timeout_ms=2000) - gui.wait_for_property("settingsv2TorAddressInput", "enabled", True, timeout_ms=2000) - gui.set_text("settingsv2TorAddressInput", "127.0.0.1:9150") - gui.wait_for_property("settingsv2ProxySettingsPage", "draftTorValidationError", "", timeout_ms=2000) + if not gui.get_property("proxyEnableSwitch", "checked"): + gui.click("proxyEnableSwitch") + gui.wait_for_property("proxyEnableSwitch", "checked", True, timeout_ms=2000) + gui.wait_for_property("proxyAddressInput", "enabled", True, timeout_ms=2000) + gui.set_text("proxyAddressInput", "10.0.0.1:9050") + gui.wait_for_property("proxySettingsPage", "draftProxyValidationError", "", timeout_ms=2000) + + if not gui.get_property("torEnableSwitch", "checked"): + gui.click("torEnableSwitch") + gui.wait_for_property("torEnableSwitch", "checked", True, timeout_ms=2000) + gui.wait_for_property("torAddressInput", "enabled", True, timeout_ms=2000) + gui.set_text("torAddressInput", "127.0.0.1:9150") + gui.wait_for_property("proxySettingsPage", "draftTorValidationError", "", timeout_ms=2000) leave_proxy_settings_with_done(gui) navigate_back_from_connection_settings(gui) diff --git a/test/functional/qml_test_settings_display.py b/test/functional/qml_test_settings_display.py index d896370b30..99007656c0 100644 --- a/test/functional/qml_test_settings_display.py +++ b/test/functional/qml_test_settings_display.py @@ -18,12 +18,12 @@ POST_ONBOARDING_TIMEOUT_MS = 30_000 DISPLAY_SETTING_ROWS = ( - "settingsv2DisplayThemeRow", - "settingsv2DisplayBlockStatusSizeRow", - "settingsv2DisplayMoneyFontRow", - "settingsv2DisplayUnitRow", - "settingsv2DisplayLanguageRow", - "settingsv2DisplayTransactionUrlsRow", + "displayThemeRow", + "displayBlockStatusSizeRow", + "displayMoneyFontRow", + "displayUnitRow", + "displayLanguageRow", + "displayTransactionUrlsRow", ) @@ -32,8 +32,8 @@ def navigate_to_display_settings(gui): gui.click("nodeSettingsButton") gui.wait_for_property("settingsSidebar_display", "visible", True, timeout_ms=5000) gui.click("settingsSidebar_display") - gui.wait_for_page("settingsv2DisplaySettingsPage", timeout_ms=5000) - gui.wait_for_page("settingsv2DisplayUnitPicker", timeout_ms=5000) + gui.wait_for_page("displaySettingsPage", timeout_ms=5000) + gui.wait_for_page("displayUnitPicker", timeout_ms=5000) print(" Navigated to redesigned Display settings page") @@ -44,26 +44,26 @@ def assert_display_rows_have_no_descriptions(gui): def select_display_unit(gui, item_name, expected_text): - gui.click("settingsv2DisplayUnitPickerButton") + gui.click("displayUnitPickerButton") gui.wait_for_property(item_name, "visible", True, timeout_ms=3000) gui.click(item_name) gui.wait_for_property( - "settingsv2DisplayUnitPicker", "currentText", expected_text, timeout_ms=3000 + "displayUnitPicker", "currentText", expected_text, timeout_ms=3000 ) def select_language(gui, search_text, item_name): - gui.click("settingsv2DisplayLanguageRow") + gui.click("displayLanguageRow") gui.wait_for_page("settingsLanguagePage", timeout_ms=5000) if search_text: gui.set_text("languageSearch", search_text) gui.wait_for_page(item_name, timeout_ms=3000) gui.click(item_name) - gui.wait_for_page("settingsv2DisplayLanguageRow", timeout_ms=5000) + gui.wait_for_page("displayLanguageRow", timeout_ms=5000) def reset_display_unit_to_btc(gui): - select_display_unit(gui, "settingsv2DisplayUnitBTC", "BTC") + select_display_unit(gui, "displayUnitBTC", "BTC") def reset_language_to_system_default(gui): @@ -74,8 +74,8 @@ def test_display_unit_selection(gui): print("\n── test_display_unit_selection ───────────────────────────────") try: reset_display_unit_to_btc(gui) - select_display_unit(gui, "settingsv2DisplayUnitSAT", "sat") - assert gui.get_property("settingsv2DisplayUnitPicker", "currentValue") == 3 + select_display_unit(gui, "displayUnitSAT", "sat") + assert gui.get_property("displayUnitPicker", "currentValue") == 3 print(" Display unit changed from BTC to sat PASSED") finally: try: @@ -90,8 +90,8 @@ def test_language_selection(gui): select_language(gui, "español", "language_es") assert_display_rows_have_no_descriptions(gui) - language_title = gui.get_property("settingsv2DisplayLanguageRow", "title") - unit_title = gui.get_property("settingsv2DisplayUnitRow", "title") + language_title = gui.get_property("displayLanguageRow", "title") + unit_title = gui.get_property("displayUnitRow", "title") assert language_title == "Idioma", language_title assert unit_title == "Unidad de visualización", unit_title print(" Spanish translated the inline Display rows PASSED") @@ -115,9 +115,9 @@ def test_settings_persistence(datadir): gui.wait_for_page("nodeSettingsButton", timeout_ms=POST_ONBOARDING_TIMEOUT_MS) navigate_to_display_settings(gui) - assert gui.get_property("settingsv2DisplayUnitPicker", "currentValue") == 3 - assert gui.get_property("settingsv2DisplayLanguageRow", "title") == "Idioma" - assert gui.get_property("settingsv2DisplayUnitRow", "title") == "Unidad de visualización" + assert gui.get_property("displayUnitPicker", "currentValue") == 3 + assert gui.get_property("displayLanguageRow", "title") == "Idioma" + assert gui.get_property("displayUnitRow", "title") == "Unidad de visualización" print(" Display unit and language persisted across restart PASSED") reset_display_unit_to_btc(gui) @@ -142,7 +142,7 @@ def run_tests(): test_display_unit_selection(gui) test_language_selection(gui) - select_display_unit(gui, "settingsv2DisplayUnitSAT", "sat") + select_display_unit(gui, "displayUnitSAT", "sat") select_language(gui, "español", "language_es") datadir = harness.datadir tmpdir = harness.tmpdir diff --git a/test/functional/qml_test_tray.py b/test/functional/qml_test_tray.py index eff2e81883..19ba2dc736 100644 --- a/test/functional/qml_test_tray.py +++ b/test/functional/qml_test_tray.py @@ -48,7 +48,7 @@ def navigate_to_window_behavior(gui): # Wait for the settings list with the Window Behavior entry. gui.wait_for_page("settingsSidebar_window-behavior", timeout_ms=5000) gui.click("settingsSidebar_window-behavior") - gui.wait_for_page("settingsv2WindowBehaviorSettingsPage", timeout_ms=5000) + gui.wait_for_page("windowBehaviorSettingsPage", timeout_ms=5000) def run_tests(): @@ -110,9 +110,9 @@ def run_tests(): # ── Test 2: Required controls are present ───────────────────────────── print("Test 2: Verify expected controls exist on the page ...") required_controls = [ - "settingsv2ShowTrayIconSwitch", - "settingsv2MinimizeToTraySwitch", - "settingsv2MinimizeOnCloseSwitch", + "showTrayIconSwitch", + "minimizeToTraySwitch", + "minimizeOnCloseSwitch", ] all_objects = gui.list_objects() object_names = {o["objectName"] for o in all_objects} @@ -126,9 +126,9 @@ def run_tests(): # showTrayIcon defaults to true; the others default to false. print("Test 3: Verify default switch states ...") expected_defaults = { - "settingsv2ShowTrayIconSwitch": True, - "settingsv2MinimizeToTraySwitch": False, - "settingsv2MinimizeOnCloseSwitch": False, + "showTrayIconSwitch": True, + "minimizeToTraySwitch": False, + "minimizeOnCloseSwitch": False, } for switch_name, expected in expected_defaults.items(): checked = gui.get_property(switch_name, "checked") @@ -150,31 +150,31 @@ def run_tests(): # test_desktopwindowbehaviormodel.cpp. Here we only verify that the # toggle round-trip works correctly via the UI on the offscreen backend. print("Test 4: Show tray icon toggles off and the model reflects the change ...") - gui.click("settingsv2ShowTrayIconSwitch") - gui.wait_for_property("settingsv2ShowTrayIconSwitch", "checked", False, timeout_ms=2000) + gui.click("showTrayIconSwitch") + gui.wait_for_property("showTrayIconSwitch", "checked", False, timeout_ms=2000) print(" -> Show tray icon clicked off ✓") print("Test 5: Show tray icon toggles back on ...") - gui.click("settingsv2ShowTrayIconSwitch") - gui.wait_for_property("settingsv2ShowTrayIconSwitch", "checked", True, timeout_ms=2000) + gui.click("showTrayIconSwitch") + gui.wait_for_property("showTrayIconSwitch", "checked", True, timeout_ms=2000) print(" -> Show tray icon restored to on ✓") # ── Test 6: Sidebar navigation after interaction ────────────────────── print("Test 6: Sidebar navigation still works after interacting with Window Behavior ...") gui.click("settingsSidebar_about") - gui.wait_for_page("settingsv2AboutSettingsPage", timeout_ms=5000) + gui.wait_for_page("aboutSettingsPage", timeout_ms=5000) print(" -> switched to About section ✓") # ── Test 7: Re-open page (round-trip) ───────────────────────────────── print("Test 7: Re-open Window Behavior page (round-trip) ...") gui.click("settingsSidebar_window-behavior") - gui.wait_for_page("settingsv2WindowBehaviorSettingsPage", timeout_ms=5000) + gui.wait_for_page("windowBehaviorSettingsPage", timeout_ms=5000) print(" -> re-opened Window Behavior ✓") # ── Test 8: Close with minimizeOnClose keeps app alive ──────────────── print("Test 8: Enable minimizeOnClose, close window, verify app survives ...") - gui.click("settingsv2MinimizeOnCloseSwitch") - gui.wait_for_property("settingsv2MinimizeOnCloseSwitch", "checked", True, timeout_ms=2000) + gui.click("minimizeOnCloseSwitch") + gui.wait_for_property("minimizeOnCloseSwitch", "checked", True, timeout_ms=2000) gui.close_window() # If minimizeOnClose works, the close event is intercepted and the # app stays alive. Verify we can still communicate with the bridge. diff --git a/test/functional/qml_test_wallet_settings.py b/test/functional/qml_test_wallet_settings.py index d84d5e973c..ef3e665cfa 100644 --- a/test/functional/qml_test_wallet_settings.py +++ b/test/functional/qml_test_wallet_settings.py @@ -148,7 +148,7 @@ def open_wallet_settings(gui): gui.settle() gui.wait_for_property("settingsSidebar_wallet", "visible", True, timeout_ms=5000) gui.click("settingsSidebar_wallet") - gui.wait_for_page("settingsv2WalletSettingsPage", timeout_ms=10000) + gui.wait_for_page("walletSettingsPage", timeout_ms=10000) def open_wallet_selector(gui): @@ -241,9 +241,9 @@ def case_rename_persists_across_restart(harness, checkpoints): open_wallet_settings(gui) checkpoints.checkpoint("wallet settings opened", gui) - gui.wait_for_property("settingsv2WalletNameInput", "visible", True, timeout_ms=5000) - gui.set_text("settingsv2WalletNameInput", display_name) - gui.invoke("settingsv2WalletNameInput", "editingFinished") + gui.wait_for_property("walletNameInput", "visible", True, timeout_ms=5000) + gui.set_text("walletNameInput", display_name) + gui.invoke("walletNameInput", "editingFinished") gui.wait_for_property("walletBadge", "text", display_name, timeout_ms=5000) checkpoints.checkpoint("wallet renamed", gui) @@ -276,10 +276,10 @@ def case_backup_uses_automation_path(harness, checkpoints): open_wallet_settings(gui) checkpoints.checkpoint("wallet settings opened", gui) - gui.set_text("settingsv2WalletSettingsBackupPathField", backup_dir) - gui.click("settingsv2WalletBackupRow") + gui.set_text("walletSettingsBackupPathField", backup_dir) + gui.click("walletBackupRow") wait_for_file(backup_path) - assert gui.get_property("settingsv2WalletSettingsPage", "errorText") == "", ( + assert gui.get_property("walletSettingsPage", "errorText") == "", ( "Backup should not surface an error" ) checkpoints.checkpoint("wallet backup created", gui) @@ -304,7 +304,7 @@ def case_sign_verify_message(harness, checkpoints): address = rpc_call(harness.gui_rpc_port, "getnewaddress", ["", "legacy"], wallet=wallet_name) open_wallet_settings(gui) - gui.click("settingsv2WalletSignVerifyMessageRow") + gui.click("walletSignVerifyMessageRow") gui.wait_for_page("signVerifyMessagePage", timeout_ms=10000) checkpoints.checkpoint("sign verify message page opened", gui) @@ -353,7 +353,7 @@ def case_subpages_close_when_wallet_becomes_unselected(harness, checkpoints): checkpoints.checkpoint("managed wallet loaded", gui) open_wallet_settings(gui) - gui.click("settingsv2WalletPasswordRow") + gui.click("walletPasswordRow") gui.wait_for_page("walletPasswordSettingsPage", timeout_ms=10000) checkpoints.checkpoint("password subpage opened", gui) @@ -361,14 +361,14 @@ def case_subpages_close_when_wallet_becomes_unselected(harness, checkpoints): gui.wait_for_property("walletCloseConfirmationPopup", "opened", True, timeout_ms=5000) gui.click("walletCloseConfirmationConfirmButton") gui.wait_for_property("walletCloseConfirmationPopup", "opened", False, timeout_ms=5000) - gui.wait_for_page("settingsv2WalletSettingsPage", timeout_ms=10000) + gui.wait_for_page("walletSettingsPage", timeout_ms=10000) gui.wait_for_property("walletBadge", "noWalletLoaded", True, timeout_ms=5000) checkpoints.checkpoint("password subpage unwound after wallet close", gui) select_wallet(gui, wallet_name) wait_for_wallet_ready(harness, gui) gui.wait_for_property("walletBadge", "noWalletLoaded", False, timeout_ms=5000) - gui.wait_for_property("settingsv2WalletNameInput", "visible", True, timeout_ms=5000) + gui.wait_for_property("walletNameInput", "visible", True, timeout_ms=5000) checkpoints.checkpoint("wallet reselected from settings page", gui) @@ -393,12 +393,12 @@ def case_password_page_closes_when_selected_wallet_changes(harness, checkpoints) checkpoints.checkpoint("first wallet selected", gui) open_wallet_settings(gui) - gui.click("settingsv2WalletPasswordRow") + gui.click("walletPasswordRow") gui.wait_for_page("walletPasswordSettingsPage", timeout_ms=10000) checkpoints.checkpoint("password subpage opened for first wallet", gui) select_wallet(gui, wallet_names[1]) - gui.wait_for_page("settingsv2WalletSettingsPage", timeout_ms=10000) + gui.wait_for_page("walletSettingsPage", timeout_ms=10000) gui.wait_for_property("walletBadge", "text", wallet_names[1], timeout_ms=20000) checkpoints.checkpoint("password subpage unwound after selecting second wallet", gui) @@ -420,7 +420,7 @@ def case_wrong_current_password_clears_current_field(harness, checkpoints): checkpoints.checkpoint("managed wallet loaded", gui) open_wallet_settings(gui) - gui.click("settingsv2WalletPasswordRow") + gui.click("walletPasswordRow") gui.wait_for_page("walletPasswordSettingsPage", timeout_ms=10000) gui.set_text("walletPasswordCurrentField", "wrong password") diff --git a/test/qml/tst_desktopwallets.qml b/test/qml/tst_desktopwallets.qml index 5de5deb67e..2694dcd816 100644 --- a/test/qml/tst_desktopwallets.qml +++ b/test/qml/tst_desktopwallets.qml @@ -94,6 +94,7 @@ TestCase { compare(tabs[1].iconSize, 24) compare(tabs[2].iconSize, 30) + compare(tabs[2].iconSource, "image://images/gear-outline") compare(findChild(page, "consoleTabButton"), null) compare(findChild(page, "desktopWalletSettingsPreviewTabButton"), null) } @@ -145,12 +146,12 @@ TestCase { const settingsPage = findChild(page, "settingsView") verify(settingsPage !== null) - const settingsContainer = findChild(page, "settingsv2SettingsPageContainer") + const settingsContainer = findChild(page, "settingsPageContainer") verify(settingsContainer !== null) tryCompare(settingsPage, "selectedSectionId", "wallet") tryCompare(settingsContainer, "currentSectionId", "wallet") tryCompare(settingsContainer, "depth", 2) compare(settingsContainer.currentItem.objectName, "addressListPage") - verify(findChild(page, "settingsv2WalletSettingsPage") !== null) + verify(findChild(page, "walletSettingsPage") !== null) } } diff --git a/test/qml/tst_settingsnavigation.qml b/test/qml/tst_settingsnavigation.qml index d0e17bb5dd..c2cb40a755 100644 --- a/test/qml/tst_settingsnavigation.qml +++ b/test/qml/tst_settingsnavigation.qml @@ -250,26 +250,26 @@ TestCase { verify(view.componentForSection("display") !== null) compare(view.selectedSectionId, "display") tryCompare(view.pageContainer, "depth", 1) - const displayPage = findChild(view, "settingsv2DisplaySettingsPage") + const displayPage = findChild(view, "displaySettingsPage") verify(displayPage !== null) - verify(findChild(view, "settingsv2NetworkTrafficSettingsPage") === null) + verify(findChild(view, "networkTrafficSettingsPage") === null) verify(findChild(view, "settingsDebugLog") === null) compare(testNetworkTrafficTower.active, false) compare(testDebugLogModel.active, false) view.selectSection("network-traffic") tryCompare(view, "selectedSectionId", "network-traffic") - compare(findChild(view, "settingsv2DisplaySettingsPage"), displayPage) - const networkTrafficPage = findChild(view, "settingsv2NetworkTrafficSettingsPage") + compare(findChild(view, "displaySettingsPage"), displayPage) + const networkTrafficPage = findChild(view, "networkTrafficSettingsPage") verify(networkTrafficPage !== null) tryCompare(testNetworkTrafficTower, "active", true) - const networkTrafficHeading = findChild(view, "settingsv2NetworkTrafficHeading") - const networkTrafficDescription = findChild(view, "settingsv2NetworkTrafficHeadingDescription") - const networkTrafficSection = findChild(view, "settingsv2NetworkTrafficSection") - const networkTrafficRangePicker = findChild(view, "settingsv2NetworkTrafficRangePicker") - const networkTrafficReceivedGraph = findChild(view, "settingsv2NetworkTrafficReceivedGraph") - const networkTrafficSentRow = findChild(view, "settingsv2NetworkTrafficSentRow") - const networkTrafficSentGraph = findChild(view, "settingsv2NetworkTrafficSentGraph") + const networkTrafficHeading = findChild(view, "networkTrafficHeading") + const networkTrafficDescription = findChild(view, "networkTrafficHeadingDescription") + const networkTrafficSection = findChild(view, "networkTrafficSection") + const networkTrafficRangePicker = findChild(view, "networkTrafficRangePicker") + const networkTrafficReceivedGraph = findChild(view, "networkTrafficReceivedGraph") + const networkTrafficSentRow = findChild(view, "networkTrafficSentRow") + const networkTrafficSentGraph = findChild(view, "networkTrafficSentGraph") verify(networkTrafficHeading !== null) verify(networkTrafficDescription !== null) verify(networkTrafficSection !== null) @@ -287,14 +287,14 @@ TestCase { compare(testNetworkTrafficTower.lastFilterWindowSize, 360) view.selectSection("debug-log") - compare(findChild(view, "settingsv2NetworkTrafficSettingsPage"), networkTrafficPage) + compare(findChild(view, "networkTrafficSettingsPage"), networkTrafficPage) tryCompare(testNetworkTrafficTower, "active", false) const debugLogPage = findChild(view, "settingsDebugLog") verify(debugLogPage !== null) tryCompare(testDebugLogModel, "active", true) view.selectSection("network-traffic") - compare(findChild(view, "settingsv2NetworkTrafficSettingsPage"), networkTrafficPage) + compare(findChild(view, "networkTrafficSettingsPage"), networkTrafficPage) compare(findChild(view, "settingsDebugLog"), debugLogPage) compare(networkTrafficPage.trafficGraphScale, 3600) tryCompare(testNetworkTrafficTower, "active", true) @@ -307,7 +307,7 @@ TestCase { view.selectSection("about") tryCompare(testDebugLogModel, "active", false) - verify(findChild(view, "settingsv2AboutSettingsPage") !== null) + verify(findChild(view, "aboutSettingsPage") !== null) compare(findChild(view, "settingsDebugLog"), debugLogPage) view.visible = false @@ -315,13 +315,13 @@ TestCase { tryCompare(testDebugLogModel, "active", false) tryCompare(testNetworkTrafficTower, "active", false) compare(findChild(view, "settingsDebugLog"), debugLogPage) - compare(findChild(view, "settingsv2NetworkTrafficSettingsPage"), networkTrafficPage) + compare(findChild(view, "networkTrafficSettingsPage"), networkTrafficPage) view.visible = true compare(view.pageContainer.depth, 1) - verify(findChild(view, "settingsv2AboutSettingsPage") !== null) + verify(findChild(view, "aboutSettingsPage") !== null) compare(findChild(view, "settingsDebugLog"), debugLogPage) - compare(findChild(view, "settingsv2NetworkTrafficSettingsPage"), networkTrafficPage) + compare(findChild(view, "networkTrafficSettingsPage"), networkTrafficPage) tryCompare(testDebugLogModel, "active", false) tryCompare(testNetworkTrafficTower, "active", false) } @@ -348,9 +348,9 @@ TestCase { const view = createTemporaryObject(settingsViewComponent, settingsWindow.contentItem) verify(view !== null) - const sidebarSurface = findChild(view, "settingsv2SettingsSidebarSurface") - const sidebarHeading = findChild(view, "settingsv2SettingsSidebarHeading") - const displayPage = findChild(view, "settingsv2DisplaySettingsPage") + const sidebarSurface = findChild(view, "settingsSidebarSurface") + const sidebarHeading = findChild(view, "settingsSidebarHeading") + const displayPage = findChild(view, "displaySettingsPage") verify(sidebarSurface !== null) verify(sidebarHeading !== null) verify(displayPage !== null) @@ -375,7 +375,7 @@ TestCase { view.width = 1500 view.selectSection("network-traffic") - const networkTrafficPage = findChild(view, "settingsv2NetworkTrafficSettingsPage") + const networkTrafficPage = findChild(view, "networkTrafficSettingsPage") verify(networkTrafficPage !== null) verify(networkTrafficPage.contentHorizontalPadding >= 24) tryCompare(networkTrafficPage.contentLayout, "width", @@ -416,9 +416,9 @@ TestCase { verify(view !== null) view.selectSection("rpc-console") - const page = findChild(view, "settingsv2RpcConsoleSettingsPage") - const header = findChild(view, "settingsv2RpcConsoleHeader") - const rpcConsole = findChild(view, "settingsv2RpcConsole") + const page = findChild(view, "rpcConsoleSettingsPage") + const header = findChild(view, "rpcConsoleHeader") + const rpcConsole = findChild(view, "rpcConsole") verify(page !== null) verify(header !== null) verify(rpcConsole !== null) @@ -437,13 +437,13 @@ TestCase { const view = createTemporaryObject(settingsViewComponent, settingsWindow.contentItem) verify(view !== null) - const themePicker = findChild(view, "settingsv2DisplayThemePicker") - const blockStatusSizePicker = findChild(view, "settingsv2DisplayBlockStatusSizePicker") - const moneyFontPicker = findChild(view, "settingsv2DisplayMoneyFontPicker") - const displayUnitPicker = findChild(view, "settingsv2DisplayUnitPicker") - const languageDisclosure = findChild(view, "settingsv2DisplayLanguageDisclosureIndicator") - const developerSection = findChild(view, "settingsv2DisplayDeveloperSection") - const designSystemRow = findChild(view, "settingsv2DisplayDesignSystemRow") + const themePicker = findChild(view, "displayThemePicker") + const blockStatusSizePicker = findChild(view, "displayBlockStatusSizePicker") + const moneyFontPicker = findChild(view, "displayMoneyFontPicker") + const displayUnitPicker = findChild(view, "displayUnitPicker") + const languageDisclosure = findChild(view, "displayLanguageDisclosureIndicator") + const developerSection = findChild(view, "displayDeveloperSection") + const designSystemRow = findChild(view, "displayDesignSystemRow") verify(themePicker !== null) verify(blockStatusSizePicker !== null) @@ -522,7 +522,7 @@ TestCase { designSystemRow.clicked() tryCompare(view.pageContainer, "depth", 2) - verify(findChild(view, "settingsv2DisplayDesignSystemPage") !== null) + verify(findChild(view, "displayDesignSystemPage") !== null) } function test_mempoolPageUsesStandardFormRows() { @@ -530,10 +530,10 @@ TestCase { verify(view !== null) view.selectSection("mempool") - const transactionsRow = findChild(view, "settingsv2MempoolTransactionsRow") - const memoryUsedRow = findChild(view, "settingsv2MempoolMemoryUsedRow") - const sizeLimitRow = findChild(view, "settingsv2MempoolSizeLimitRow") - const sizeLimitInput = findChild(view, "settingsv2MempoolSizeLimitInput") + const transactionsRow = findChild(view, "mempoolTransactionsRow") + const memoryUsedRow = findChild(view, "mempoolMemoryUsedRow") + const sizeLimitRow = findChild(view, "mempoolSizeLimitRow") + const sizeLimitInput = findChild(view, "mempoolSizeLimitInput") verify(transactionsRow !== null) verify(memoryUsedRow !== null) @@ -542,7 +542,6 @@ TestCase { compare(transactionsRow.titleTextStyle.font.pixelSize, Theme.text.description.font.pixelSize) compare(transactionsRow.valueTextStyle.font.pixelSize, Theme.text.description.font.pixelSize) compare(memoryUsedRow.valueTextStyle.font.pixelSize, Theme.text.description.font.pixelSize) - verify(findChild(view, "mempoolTransactionsRow") === null) sizeLimitRow.text = "512" sizeLimitRow.editingFinished() @@ -561,8 +560,8 @@ TestCase { compare(nodeModel.mempoolInfoPollingActive, false) view.selectSection("mempool") - const mempoolPage = findChild(view, "settingsv2MempoolSettingsPage") - const restartNotice = findChild(view, "settingsv2MempoolRestartNotice") + const mempoolPage = findChild(view, "mempoolSettingsPage") + const restartNotice = findChild(view, "mempoolRestartNotice") verify(mempoolPage !== null) verify(restartNotice !== null) tryCompare(nodeModel, "mempoolInfoPollingActive", true) @@ -573,7 +572,7 @@ TestCase { view.selectSection("display") tryCompare(nodeModel, "mempoolInfoPollingActive", false) - compare(findChild(view, "settingsv2MempoolSettingsPage"), mempoolPage) + compare(findChild(view, "mempoolSettingsPage"), mempoolPage) view.selectSection("mempool") tryCompare(nodeModel, "mempoolInfoPollingActive", true) @@ -592,17 +591,17 @@ TestCase { verify(view !== null) view.selectSection("connection") - const proxySettingsRow = findChild(view, "settingsv2ProxySettingsRow") + const proxySettingsRow = findChild(view, "proxySettingsRow") verify(proxySettingsRow !== null) proxySettingsRow.clicked() tryCompare(view.pageContainer, "depth", 2) - const proxyPage = findChild(view, "settingsv2ProxySettingsPage") - const defaultProxySection = findChild(view, "settingsv2DefaultProxySection") - const torProxySection = findChild(view, "settingsv2TorProxySection") - const proxySwitch = findChild(view, "settingsv2ProxyEnableSwitch") - const proxyAddressRow = findChild(view, "settingsv2ProxyAddressRow") - const saveButton = findChild(view, "settingsv2ProxySettingsSaveButton") + const proxyPage = findChild(view, "proxySettingsPage") + const defaultProxySection = findChild(view, "defaultProxySection") + const torProxySection = findChild(view, "torProxySection") + const proxySwitch = findChild(view, "proxyEnableSwitch") + const proxyAddressRow = findChild(view, "proxyAddressRow") + const saveButton = findChild(view, "proxySettingsSaveButton") verify(proxyPage !== null) verify(defaultProxySection !== null) @@ -643,10 +642,10 @@ TestCase { proxyPage.back() tryCompare(view.pageContainer, "depth", 2) - const discardPopup = findChild(settingsWindow.contentItem, "settingsv2DiscardProxyChangesPopup") + const discardPopup = findChild(settingsWindow.contentItem, "discardProxyChangesPopup") verify(discardPopup !== null) tryCompare(discardPopup, "opened", true) - const cancelButton = findChild(settingsWindow.contentItem, "settingsv2DiscardProxyChangesCancelButton") + const cancelButton = findChild(settingsWindow.contentItem, "discardProxyChangesCancelButton") verify(cancelButton !== null) cancelButton.clicked() tryCompare(discardPopup, "opened", false) @@ -663,17 +662,17 @@ TestCase { verify(view !== null) const destinations = [ - { id: "wallet", objectName: "settingsv2WalletSettingsPage" }, - { id: "external-signer", objectName: "settingsv2ExternalSignerSettingsPage" }, - { id: "display", objectName: "settingsv2DisplaySettingsPage" }, - { id: "window-behavior", objectName: "settingsv2WindowBehaviorSettingsPage" }, - { id: "storage", objectName: "settingsv2StorageSettingsPage" }, - { id: "connection", objectName: "settingsv2ConnectionSettingsPage" }, - { id: "network-traffic", objectName: "settingsv2NetworkTrafficSettingsPage" }, - { id: "mempool", objectName: "settingsv2MempoolSettingsPage" }, - { id: "rpc-console", objectName: "settingsv2RpcConsoleSettingsPage" }, + { id: "wallet", objectName: "walletSettingsPage" }, + { id: "external-signer", objectName: "externalSignerSettingsPage" }, + { id: "display", objectName: "displaySettingsPage" }, + { id: "window-behavior", objectName: "windowBehaviorSettingsPage" }, + { id: "storage", objectName: "storageSettingsPage" }, + { id: "connection", objectName: "connectionSettingsPage" }, + { id: "network-traffic", objectName: "networkTrafficSettingsPage" }, + { id: "mempool", objectName: "mempoolSettingsPage" }, + { id: "rpc-console", objectName: "rpcConsoleSettingsPage" }, { id: "debug-log", objectName: "settingsDebugLog" }, - { id: "about", objectName: "settingsv2AboutSettingsPage" } + { id: "about", objectName: "aboutSettingsPage" } ] for (let index = 0; index < destinations.length; ++index) { @@ -684,19 +683,19 @@ TestCase { verify(findChild(view, destination.objectName) !== null, "Expected instantiated destination " + destination.id) if (destination.id === "wallet") { - const walletInfoSection = findChild(view, "settingsv2WalletInfoSection") - const walletActionsSection = findChild(view, "settingsv2WalletActionsSection") + const walletInfoSection = findChild(view, "walletInfoSection") + const walletActionsSection = findChild(view, "walletActionsSection") verify(walletInfoSection !== null) verify(walletActionsSection !== null) compare(walletInfoSection.title, "Wallet info") compare(walletActionsSection.title, "Wallet actions") } if (destination.id === "external-signer") { - const signerPage = findChild(view, "settingsv2ExternalSignerSettingsPage") - const introduction = findChild(view, "settingsv2ExternalSignerIntroduction") - const signerSection = findChild(view, "settingsv2ExternalSignerPathSection") - const signerPathRow = findChild(view, "settingsv2ExternalSignerPathRow") - const signerFooter = findChild(view, "settingsv2ExternalSignerPathSectionFooter") + const signerPage = findChild(view, "externalSignerSettingsPage") + const introduction = findChild(view, "externalSignerIntroduction") + const signerSection = findChild(view, "externalSignerPathSection") + const signerPathRow = findChild(view, "externalSignerPathRow") + const signerFooter = findChild(view, "externalSignerPathSectionFooter") const signerPathInput = findChild(view, "externalSignerPathInput") const signerPathFocusBorder = findChild(view, "externalSignerPathFocusBorder") const signerStatusIndicator = findChild(view, "externalSignerStatusIndicator") @@ -732,8 +731,8 @@ TestCase { compare(signerStatusIndicator.height, 10) } if (destination.id === "about") { - const versionRow = findChild(view, "settingsv2AboutVersionRow") - const versionValue = findChild(view, "settingsv2AboutVersionRowValue") + const versionRow = findChild(view, "aboutVersionRow") + const versionValue = findChild(view, "aboutVersionRowValue") verify(versionRow !== null) verify(versionValue !== null) compare(versionRow.value, BuildInfo.fullClientVersion) From 2f1f623eb0941a06a693fda76f24b276fdf67443 Mon Sep 17 00:00:00 2001 From: pseudoramdom Date: Wed, 26 Aug 2026 11:35:10 -0700 Subject: [PATCH 13/14] qml: redesign debug log settings page Replace the legacy debug log view with SettingsDebugLogView using the shared settings components and theme. Present log entries as type, time, and message columns. Add search, warning/error filtering, incremental loading, scroll-to-bottom controls, and an option to open debug.log. Update the debug log model to parse severity and HH:MM:SS timestamps, and extend the unit, QML, and functional test coverage. --- qml/bitcoin_qml.qrc | 5 +- qml/components/DebugLogItemRow.qml | 99 ++++ qml/components/DebugLogOutputView.qml | 415 ----------------- qml/components/DebugLogTitlesHeader.qml | 74 +++ qml/components/SettingsView.qml | 6 +- qml/models/debuglogmodel.cpp | 342 +++++++------- qml/models/debuglogmodel.h | 69 ++- qml/pages/settings/SettingsDebugLog.qml | 351 --------------- qml/pages/settings/SettingsDebugLogView.qml | 356 +++++++++++++++ test/functional/qml_test_debug_log.py | 475 ++++++-------------- test/qml/bitcoin_qmltests.qrc | 2 +- test/qml/qml_tests_main.cpp | 108 +++-- test/qml/tst_debuglogoutputview.qml | 175 -------- test/qml/tst_debuglogview.qml | 268 +++++++++++ test/qml/tst_settingsnavigation.qml | 18 +- test/test_debuglogmodel.cpp | 176 ++++---- 16 files changed, 1321 insertions(+), 1618 deletions(-) create mode 100644 qml/components/DebugLogItemRow.qml delete mode 100644 qml/components/DebugLogOutputView.qml create mode 100644 qml/components/DebugLogTitlesHeader.qml delete mode 100644 qml/pages/settings/SettingsDebugLog.qml create mode 100644 qml/pages/settings/SettingsDebugLogView.qml mode change 100644 => 100755 test/functional/qml_test_debug_log.py delete mode 100644 test/qml/tst_debuglogoutputview.qml create mode 100644 test/qml/tst_debuglogview.qml diff --git a/qml/bitcoin_qml.qrc b/qml/bitcoin_qml.qrc index 7f422368eb..a51c752fb1 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/MempoolInformationRows.qml components/DeveloperOptions.qml components/ExternalSignerReviewActions.qml @@ -130,7 +131,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 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..3ffd9f9b01 --- /dev/null +++ b/qml/components/DebugLogTitlesHeader.qml @@ -0,0 +1,74 @@ +// 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.neutral3 + radius: root.cornerRadius + + Rectangle { + objectName: "debugLogTitlesHeaderBottomFill" + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: parent.radius + color: parent.color + } + } + + 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/SettingsView.qml b/qml/components/SettingsView.qml index 787a8d3064..5f72c5f488 100644 --- a/qml/components/SettingsView.qml +++ b/qml/components/SettingsView.qml @@ -349,11 +349,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/models/debuglogmodel.cpp b/qml/models/debuglogmodel.cpp index 6393611326..e7bfbe482c 100644 --- a/qml/models/debuglogmodel.cpp +++ b/qml/models/debuglogmodel.cpp @@ -4,26 +4,31 @@ #include +#include #include #include #include -#include #include #include #include #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 +41,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 +109,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 +120,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 +136,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 +192,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 +315,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 +587,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 +608,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 +672,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 +753,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 +790,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 +798,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 +847,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 +882,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 +912,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 +921,57 @@ 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); + return 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/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..b0fe7adbee --- /dev/null +++ b/qml/pages/settings/SettingsDebugLogView.qml @@ -0,0 +1,356 @@ +// 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 + + TextField { + id: searchField + objectName: "debugLogSearchField" + Layout.fillWidth: true + Layout.minimumWidth: 140 + Layout.maximumWidth: 340 + implicitHeight: 36 + leftPadding: 38 + rightPadding: 12 + topPadding: 0 + bottomPadding: 0 + text: debugLogModel.filter + placeholderText: qsTr("Search messages") + placeholderTextColor: Theme.color.neutral7 + color: Theme.color.neutral9 + font: Theme.text.caption.font + verticalAlignment: TextInput.AlignVCenter + selectByMouse: true + Accessible.name: qsTr("Search debug log messages") + onTextChanged: searchDebounce.restart() + + background: Rectangle { + color: Theme.color.neutral1 + radius: 8 + border.width: searchField.activeFocus ? 2 : 0 + border.color: Theme.color.orange + + Behavior on border.color { ColorAnimation { duration: 150 } } + } + + Icon { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + source: "image://images/search" + color: Theme.color.neutral7 + size: 16 + } + } + + Item { Layout.fillWidth: true } + + IconButton { + id: logOptionsButton + objectName: "debugLogOptionsButton" + size: 36 + iconSize: 20 + iconSource: "image://images/ellipsis" + 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 + + 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" + logOptionsMenu.close() + } + } + + ContextMenuDivider { + objectName: "debugLogOptionsDivider" + } + + 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: { + searchField.forceActiveFocus() + searchField.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 - 294) + 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 + bold: false + 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.neutral3 + radius: 16 + + Rectangle { + objectName: "debugLogTableFooterTopFill" + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: parent.radius + color: parent.color + } + + 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 + bold: false + enabled: logList.count > 0 && !logList.atYEnd + onClicked: root.scrollToBottom() + } + } + } + + Timer { + id: searchDebounce + interval: 150 + repeat: false + onTriggered: debugLogModel.filter = searchField.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/test/functional/qml_test_debug_log.py b/test/functional/qml_test_debug_log.py old mode 100644 new mode 100755 index 05be208788..04e3d8134f --- 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,182 @@ 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", + "debugLogTableFooter", + "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"), 36, "search") + assert_close(gui.get_property("debugLogTitlesHeader", "height"), 44, "table header") + assert_close(gui.get_property("debugLogTableFooter", "height"), 44, "table footer") + + 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") + assert timestamp == "00:00:00", f"Expected HH:MM:SS time, 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 +293,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 +306,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 19fdff54fa..9098b71bb1 100644 --- a/test/qml/bitcoin_qmltests.qrc +++ b/test/qml/bitcoin_qmltests.qrc @@ -14,7 +14,7 @@ tst_createpassword.qml tst_createtypeselector.qml tst_createwalletwizard.qml - tst_debuglogoutputview.qml + tst_debuglogview.qml tst_desktopwallets.qml tst_dropdownbutton.qml tst_externalsignerreviewactions.qml diff --git a/test/qml/qml_tests_main.cpp b/test/qml/qml_tests_main.cpp index 87d178dfdf..19906c8b30 100644 --- a/test/qml/qml_tests_main.cpp +++ b/test/qml/qml_tests_main.cpp @@ -3145,29 +3145,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(); @@ -3180,13 +3172,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 {}; } } @@ -3194,13 +3183,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"}, }; } @@ -3221,21 +3207,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); @@ -3246,8 +3246,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); } @@ -3258,7 +3262,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); @@ -3277,7 +3281,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(); @@ -3319,7 +3323,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 @@ -3339,34 +3367,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_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..bb2b73f5bf --- /dev/null +++ b/test/qml/tst_debuglogview.qml @@ -0,0 +1,268 @@ +// 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 searchField = findChild(view, "debugLogSearchField") + verify(searchField !== null) + compare(searchField.background.border.width, 0) + testWindow.requestActivate() + tryCompare(testWindow, "active", true) + searchField.forceActiveFocus() + tryCompare(searchField, "activeFocus", true) + compare(searchField.background.border.width, 2) + const optionsButton = findChild(view, "debugLogOptionsButton") + const optionsMenu = findChild(view, "debugLogOptionsMenu") + const filterPicker = findChild(view, "debugLogMessageFilterPicker") + const openFileButton = findChild(view, "debugLogOpenFileButton") + verify(optionsButton !== null) + verify(optionsMenu !== null) + verify(filterPicker !== null) + verify(openFileButton !== null) + compare(optionsButton.iconSource.toString(), "image://images/ellipsis") + compare(filterPicker.currentValue, "all") + mouseClick(optionsButton) + tryCompare(optionsMenu, "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) + optionsMenu.close() + compare(openFileButton.text, "Open debug.log") + compare(openFileButton.iconSource.toString(), "image://images/export") + const section = findChild(view, "debugLogTableSection") + const card = findChild(view, "debugLogTableSectionCard") + const titles = findChild(view, "debugLogTitlesHeader") + const footer = findChild(view, "debugLogTableFooter") + const scrollButton = findChild(view, "debugLogScrollToBottomButton") + const loadMoreButton = findChild(view, "debugLogLoadMoreButton") + verify(section !== null) + verify(card !== null) + verify(titles !== null) + verify(footer !== null) + verify(scrollButton !== null) + verify(loadMoreButton !== null) + compare(card.color, Theme.color.neutral1) + compare(titles.background.color, Theme.color.neutral3) + compare(footer.color, Theme.color.neutral3) + 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) + } + + 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_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_context_menu_filter_update_model() { + testDebugLogModel.resetForTest(4, false) + const view = createView() + const search = findChild(view, "debugLogSearchField") + const optionsButton = findChild(view, "debugLogOptionsButton") + const optionsMenu = findChild(view, "debugLogOptionsMenu") + const filterPicker = findChild(view, "debugLogMessageFilterPicker") + + search.text = "rpc warning" + tryCompare(testDebugLogModel, "filter", "rpc warning") + mouseClick(optionsButton) + tryCompare(optionsMenu, "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(optionsMenu, "opened", false) + tryCompare(optionsMenu, "visible", false) + compare(warningsAndErrorsOption.selected, true) + compare(allOption.selected, false) + + mouseClick(optionsButton) + tryCompare(optionsMenu, "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(optionsMenu, "opened", false) + 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_settingsnavigation.qml b/test/qml/tst_settingsnavigation.qml index c2cb40a755..a1844360ba 100644 --- a/test/qml/tst_settingsnavigation.qml +++ b/test/qml/tst_settingsnavigation.qml @@ -253,7 +253,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) @@ -289,38 +289,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) @@ -384,7 +384,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) @@ -671,7 +671,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/test_debuglogmodel.cpp b/test/test_debuglogmodel.cpp index cf8f133d42..5d86eb270e 100644 --- a/test/test_debuglogmodel.cpp +++ b/test/test_debuglogmodel.cpp @@ -43,7 +43,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 +59,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 +100,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 +113,55 @@ 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); + 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); + QCOMPARE(model.data(network, DebugLogModel::TimestampRole).toString(), QStringLiteral("10:00:00")); + + 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 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)")); + 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 +191,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 +217,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 +232,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 +241,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 +256,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 +278,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 +309,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 +349,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 +395,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 +434,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 +455,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 +479,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 +509,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 +528,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 +552,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 +569,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 +589,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 +612,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 From e9b0e307b789409d57625b16790ed33867e3c8b2 Mon Sep 17 00:00:00 2001 From: pseudoramdom Date: Wed, 26 Aug 2026 14:00:29 -0700 Subject: [PATCH 14/14] qml: redesign RPC console settings page Replace the legacy RPC console with SettingsRpcConsoleView using the shared settings layout and theme. Add a dismissible security warning, reusable search bar with result navigation, font controls, context-menu command completion, and themed console and command-input surfaces. Highlight search matches without filtering output and scroll to their exact position. --- qml/bitcoin_qml.qrc | 3 +- qml/components/DebugLogTitlesHeader.qml | 11 +- qml/components/MonospaceOutputView.qml | 123 ++++++- qml/components/SearchBar.qml | 306 ++++++++++++++++++ qml/components/SettingsView.qml | 2 +- qml/components/ToastBanner.qml | 3 + qml/controls/ContextMenuButton.qml | 3 +- qml/models/rpcconsolemodel.cpp | 24 -- qml/models/rpcconsolemodel.h | 2 - qml/pages/node/CommandConsole.qml | 274 +++++----------- qml/pages/settings/RpcConsoleSettingsPage.qml | 54 ---- qml/pages/settings/SettingsDebugLogView.qml | 60 ++-- qml/pages/settings/SettingsRpcConsoleView.qml | 214 ++++++++++++ test/functional/qml_test_console.py | 124 +++---- test/functional/qml_test_debug_log.py | 6 +- test/qml/bitcoin_qmltests.qrc | 2 + test/qml/tst_contextmenubutton.qml | 8 + test/qml/tst_debuglogview.qml | 27 +- test/qml/tst_monospaceoutputview.qml | 167 ++++++++++ test/qml/tst_searchbar.qml | 182 +++++++++++ test/qml/tst_settingsnavigation.qml | 146 ++++++++- test/test_rpcconsolemodel.cpp | 65 +--- 22 files changed, 1364 insertions(+), 442 deletions(-) create mode 100644 qml/components/SearchBar.qml delete mode 100644 qml/pages/settings/RpcConsoleSettingsPage.qml create mode 100644 qml/pages/settings/SettingsRpcConsoleView.qml create mode 100644 test/qml/tst_monospaceoutputview.qml create mode 100644 test/qml/tst_searchbar.qml diff --git a/qml/bitcoin_qml.qrc b/qml/bitcoin_qml.qrc index a51c752fb1..c37d7b00e9 100644 --- a/qml/bitcoin_qml.qrc +++ b/qml/bitcoin_qml.qrc @@ -48,6 +48,7 @@ components/PaymentDetailOptionsPopup.qml components/QRCodePopup.qml components/ReceiveOptionsPopup.qml + components/SearchBar.qml components/Tooltip.qml components/LabeledValueField.qml components/MultipleRecipientsSummary.qml @@ -143,7 +144,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 diff --git a/qml/components/DebugLogTitlesHeader.qml b/qml/components/DebugLogTitlesHeader.qml index 3ffd9f9b01..f708eb6699 100644 --- a/qml/components/DebugLogTitlesHeader.qml +++ b/qml/components/DebugLogTitlesHeader.qml @@ -20,7 +20,7 @@ Control { background: Rectangle { objectName: "debugLogTitlesHeaderBackground" - color: Theme.color.neutral3 + color: Theme.color.neutral1 radius: root.cornerRadius Rectangle { @@ -31,6 +31,15 @@ Control { 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 { 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..f1ece3e450 --- /dev/null +++ b/qml/components/SearchBar.qml @@ -0,0 +1,306 @@ +// 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" + +Control { + id: root + + property alias text: searchField.text + property alias placeholderText: searchField.placeholderText + property alias inputField: searchField + property string accessibleName: qsTr("Search") + property bool showNavigationButtons: false + property bool navigationEnabled: true + 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() + + 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: 32 + rightPadding: clearButton.visible ? 28 : 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) return + if (event.modifiers & Qt.ShiftModifier) { + root.previousRequested() + } else { + root.nextRequested() + } + event.accepted = true + } + + background: Rectangle { + color: Theme.color.neutral2 + radius: 5 + border.width: searchField.activeFocus ? 2 : 0 + border.color: Theme.color.orange + } + + Icon { + objectName: root.searchIconObjectName + anchors.left: parent.left + anchors.leftMargin: 9 + anchors.verticalCenter: parent.verticalCenter + source: "image://images/search" + color: Theme.color.neutral7 + size: 14 + hoverEnabled: false + } + + AbstractButton { + id: clearButton + + readonly property color clearColor: Theme.color.neutral4 + + objectName: root.clearButtonObjectName + anchors.right: parent.right + anchors.rightMargin: 7 + anchors.verticalCenter: parent.verticalCenter + width: 14 + height: 14 + padding: 0 + visible: searchField.text.length > 0 + hoverEnabled: AppMode.isDesktop + focusPolicy: Qt.NoFocus + Accessible.role: Accessible.Button + Accessible.name: qsTr("Clear search") + + background: Rectangle { + color: clearButton.hovered || clearButton.pressed + ? Theme.color.neutral3 + : "transparent" + border.width: 1 + border.color: clearButton.clearColor + radius: width / 2 + } + + contentItem: ClearSearchIcon { + strokeColor: clearButton.clearColor + } + + HoverHandler { + cursorShape: Qt.PointingHandCursor + } + + onClicked: { + searchField.clear() + 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() + } + + component ClearSearchIcon: Canvas { + id: clearIcon + + required property color strokeColor + readonly property real size: 6 + readonly property real strokeWidth: 1.5 + + antialiasing: true + + onPaint: { + const context = getContext("2d") + const centerX = width / 2 + const centerY = height / 2 + const halfSize = size / 2 + context.clearRect(0, 0, width, height) + context.strokeStyle = strokeColor + context.lineWidth = strokeWidth + context.lineCap = "round" + context.beginPath() + context.moveTo(centerX - halfSize, centerY - halfSize) + context.lineTo(centerX + halfSize, centerY + halfSize) + context.moveTo(centerX + halfSize, centerY - halfSize) + context.lineTo(centerX - halfSize, centerY + halfSize) + context.stroke() + } + + onStrokeColorChanged: requestPaint() + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + } +} diff --git a/qml/components/SettingsView.qml b/qml/components/SettingsView.qml index 5f72c5f488..d3526329fa 100644 --- a/qml/components/SettingsView.qml +++ b/qml/components/SettingsView.qml @@ -338,7 +338,7 @@ Page { Component { id: rpcConsolePage - SettingsPages.RpcConsoleSettingsPage { + SettingsPages.SettingsRpcConsoleView { walletName: typeof walletController !== "undefined" && walletController.isWalletLoaded && walletController.selectedWallet ? walletController.selectedWallet.name diff --git a/qml/components/ToastBanner.qml b/qml/components/ToastBanner.qml index 6edffb5f37..6dab6933ef 100644 --- a/qml/components/ToastBanner.qml +++ b/qml/components/ToastBanner.qml @@ -131,12 +131,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/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..5a7b790a05 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,28 @@ 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 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 + 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 +190,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 +226,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 +234,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 +248,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 +277,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 +305,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 +353,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 +362,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 +409,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 +430,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 055c33fef3..0000000000 --- a/qml/pages/settings/RpcConsoleSettingsPage.qml +++ /dev/null @@ -1,54 +0,0 @@ -pragma ComponentBehavior: Bound - -// 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/SettingsDebugLogView.qml b/qml/pages/settings/SettingsDebugLogView.qml index b0fe7adbee..2e7a485447 100644 --- a/qml/pages/settings/SettingsDebugLogView.qml +++ b/qml/pages/settings/SettingsDebugLogView.qml @@ -75,44 +75,19 @@ SettingsPage { Layout.fillWidth: true spacing: 16 - TextField { - id: searchField - objectName: "debugLogSearchField" + SearchBar { + id: searchBar + objectName: "debugLogSearchBar" + fieldObjectName: "debugLogSearchField" + searchIconObjectName: "debugLogSearchIcon" + clearButtonObjectName: "debugLogSearchClearButton" Layout.fillWidth: true Layout.minimumWidth: 140 - Layout.maximumWidth: 340 - implicitHeight: 36 - leftPadding: 38 - rightPadding: 12 - topPadding: 0 - bottomPadding: 0 + Layout.maximumWidth: implicitWidth text: debugLogModel.filter placeholderText: qsTr("Search messages") - placeholderTextColor: Theme.color.neutral7 - color: Theme.color.neutral9 - font: Theme.text.caption.font - verticalAlignment: TextInput.AlignVCenter - selectByMouse: true - Accessible.name: qsTr("Search debug log messages") + accessibleName: qsTr("Search debug log messages") onTextChanged: searchDebounce.restart() - - background: Rectangle { - color: Theme.color.neutral1 - radius: 8 - border.width: searchField.activeFocus ? 2 : 0 - border.color: Theme.color.orange - - Behavior on border.color { ColorAnimation { duration: 150 } } - } - - Icon { - anchors.left: parent.left - anchors.leftMargin: 12 - anchors.verticalCenter: parent.verticalCenter - source: "image://images/search" - color: Theme.color.neutral7 - size: 16 - } } Item { Layout.fillWidth: true } @@ -178,8 +153,8 @@ SettingsPage { enabled: root.visible sequences: [StandardKey.Find] onActivated: { - searchField.forceActiveFocus() - searchField.selectAll() + searchBar.focusSearch() + searchBar.selectAll() } } @@ -200,7 +175,7 @@ SettingsPage { id: logList objectName: "debugLogListView" Layout.fillWidth: true - Layout.preferredHeight: Math.max(300, root.height - 294) + Layout.preferredHeight: Math.max(300, root.height - 298) clip: true model: debugLogModel spacing: 0 @@ -268,7 +243,7 @@ SettingsPage { objectName: "debugLogTableFooter" Layout.fillWidth: true Layout.preferredHeight: 44 - color: Theme.color.neutral3 + color: Theme.color.neutral1 radius: 16 Rectangle { @@ -280,6 +255,15 @@ SettingsPage { 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" @@ -300,7 +284,7 @@ SettingsPage { id: searchDebounce interval: 150 repeat: false - onTriggered: debugLogModel.filter = searchField.text + onTriggered: debugLogModel.filter = searchBar.text } Connections { diff --git a/qml/pages/settings/SettingsRpcConsoleView.qml b/qml/pages/settings/SettingsRpcConsoleView.qml new file mode 100644 index 0000000000..ccd72fad16 --- /dev/null +++ b/qml/pages/settings/SettingsRpcConsoleView.qml @@ -0,0 +1,214 @@ +pragma ComponentBehavior: Bound + +// 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/test/functional/qml_test_console.py b/test/functional/qml_test_console.py index 97590ad375..26b28467a1 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", "") @@ -286,27 +299,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 +332,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 +341,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 index 04e3d8134f..b9b787cd61 100755 --- a/test/functional/qml_test_debug_log.py +++ b/test/functional/qml_test_debug_log.py @@ -125,7 +125,9 @@ def test_page_structure(gui): "debugLogTableSectionCard", "debugLogListView", "debugLogTitlesHeader", + "debugLogTitlesHeaderDivider", "debugLogTableFooter", + "debugLogTableFooterDivider", "debugLogScrollToBottomButton", ): gui.wait_for_property(object_name, "visible", True, timeout_ms=5000) @@ -148,9 +150,11 @@ def test_page_structure(gui): ) 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"), 36, "search") + 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) diff --git a/test/qml/bitcoin_qmltests.qrc b/test/qml/bitcoin_qmltests.qrc index 9098b71bb1..efbdca2105 100644 --- a/test/qml/bitcoin_qmltests.qrc +++ b/test/qml/bitcoin_qmltests.qrc @@ -22,6 +22,7 @@ tst_formcontrols.qml tst_mainrouting.qml tst_mempoolinformationrows.qml + tst_monospaceoutputview.qml tst_navbutton.qml tst_nodefeedback.qml tst_onboarding_datadir.qml @@ -30,6 +31,7 @@ tst_proxylocationinput.qml tst_requestpayment.qml tst_rightcontenticon.qml + tst_searchbar.qml tst_sendoptionspopup.qml tst_send.qml tst_setting.qml 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_debuglogview.qml b/test/qml/tst_debuglogview.qml index bb2b73f5bf..dbb3720114 100644 --- a/test/qml/tst_debuglogview.qml +++ b/test/qml/tst_debuglogview.qml @@ -50,14 +50,29 @@ TestCase { 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, 2) + searchField.text = "rpc" + compare(searchClearButton.visible, true) + mouseClick(searchClearButton) + compare(searchField.text, "") const optionsButton = findChild(view, "debugLogOptionsButton") const optionsMenu = findChild(view, "debugLogOptionsMenu") const filterPicker = findChild(view, "debugLogMessageFilterPicker") @@ -83,18 +98,26 @@ TestCase { 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.neutral3) - compare(footer.color, Theme.color.neutral3) + 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) 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..214af52568 --- /dev/null +++ b/test/qml/tst_searchbar.qml @@ -0,0 +1,182 @@ +// 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(searchIcon.source.toString(), "image://images/search") + compare(searchIcon.size, 14) + verify(searchIcon.x < field.leftPadding) + compare(clearButton.visible, false) + + searchBar.text = "needle" + compare(field.text, "needle") + compare(clearButton.visible, true) + compare(clearButton.width, 14) + compare(clearButton.height, 14) + compare(clearButton.contentItem.size, 6) + compare(clearButton.contentItem.strokeWidth, 1.5) + compare(clearButton.background.border.color, Theme.color.neutral4) + compare(clearButton.contentItem.strokeColor, Theme.color.neutral4) + compare(clearButton.background.radius, clearButton.width / 2) + mouseClick(clearButton) + compare(searchBar.text, "") + compare(clearButton.visible, false) + } + + 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 a1844360ba..0e5dd1d63f 100644 --- a/test/qml/tst_settingsnavigation.qml +++ b/test/qml/tst_settingsnavigation.qml @@ -418,16 +418,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) 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()