diff --git a/qml/bitcoin_qml.qrc b/qml/bitcoin_qml.qrc index e0ad8a4ea0..7f422368eb 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 @@ -33,12 +32,14 @@ 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 components/StorageSettings.qml - components/ThemeSettings.qml components/ToastBanner.qml components/ToastPopup.qml components/TotalBytesIndicator.qml @@ -50,7 +51,6 @@ components/LabeledValueField.qml components/MultipleRecipientsSummary.qml components/SingleRecipientSummary.qml - components/WalletSettings.qml components/WalletMigrationPopup.qml components/WalletPassphrasePopup.qml controls/AddWalletButton.qml @@ -68,11 +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 @@ -83,7 +87,9 @@ controls/OptionSwitch.qml controls/OutlineButton.qml controls/PageIndicator.qml + controls/PageHeading.qml controls/PageStack.qml + controls/PopupPicker.qml controls/ProgressIndicator.qml controls/ProxyLocationInput.qml controls/QRImage.qml @@ -93,13 +99,16 @@ controls/SegmentedPicker.qml controls/Setting.qml controls/SettingsHeader.qml + controls/SettingsPage.qml 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 @@ -107,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 @@ -121,19 +128,24 @@ 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/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 @@ -163,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/SettingsPageContainer.qml b/qml/components/SettingsPageContainer.qml new file mode 100644 index 0000000000..bc5d1cdf6f --- /dev/null +++ b/qml/components/SettingsPageContainer.qml @@ -0,0 +1,118 @@ +// 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 sectionDepth(sectionId) { + const sectionStack = internal.sectionStacks[sectionId] + return sectionStack ? sectionStack.depth : 0 + } + + function resetSection(sectionId) { + const sectionStack = internal.sectionStacks[sectionId] + if (!sectionStack || sectionStack.depth <= 1) return + sectionStack.pop(null, StackView.Immediate) + } + + 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..83b875001d --- /dev/null +++ b/qml/components/SettingsSidebar.qml @@ -0,0 +1,166 @@ +// 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 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 + 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 + } + + 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 + 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 + 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.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 + 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..fd0119fdae --- /dev/null +++ b/qml/components/SettingsView.qml @@ -0,0 +1,359 @@ +// 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 "../pages/settings" as SettingsPages +import "../pages/wallet" as WalletPages + +Page { + id: root + objectName: "settingsView" + + signal doneClicked() + signal selectWalletRequested() + signal receiveRequested() + + property bool showDoneButton: true + property string selectedSectionId: "" + 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 settings"), + 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: "general", + pageComponent: displayPage + }, + { + id: "window-behavior", + label: qsTr("Window behavior"), + group: "general", + visible: AppMode.isDesktop, + pageComponent: windowBehaviorPage + }, + { + id: "storage", + label: qsTr("Storage"), + group: "general", + 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: "advanced", + 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"), + group: "advanced", + pageComponent: debugLogPage + }, + { + id: "about", + label: qsTr("About"), + group: "about", + pageComponent: aboutPage + } + ] + + 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 + pageContainer.showSection(resolvedId, root.componentForSection(resolvedId)) + } + + function ensureVisibleSelection() { + 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 + + onSectionsChanged: ensureVisibleSelection() + onVisibleChanged: { + if (visible) root.selectSection(root.selectedSectionId) + } + + Component.onCompleted: root.selectSection( + root.sectionIsVisible(root.selectedSectionId) ? root.selectedSectionId : root.firstVisibleSectionId()) + + Connections { + target: typeof walletController !== "undefined" ? walletController : null + + function onSelectedWalletChanged() { + pageContainer.resetSection("wallet") + } + + function onIsWalletLoadedChanged() { + if (!walletController.isWalletLoaded) pageContainer.resetSection("wallet") + } + } + + contentItem: RowLayout { + spacing: 0 + + Rectangle { + id: sidebarSurface + objectName: "settingsSidebarSurface" + 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 + + CoreText { + objectName: "settingsSidebarHeading" + 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: "settingsSidebar" + Layout.fillWidth: true + Layout.fillHeight: true + model: root.sections + groupTitles: root.groupTitles + currentSectionId: root.selectedSectionId + onSectionActivated: function(sectionId) { root.selectSection(sectionId) } + } + + NavButton { + objectName: "settingsDoneButton" + visible: root.showDoneButton + text: qsTr("Done") + Layout.alignment: Qt.AlignHCenter + Layout.bottomMargin: 20 + onClicked: root.doneClicked() + } + } + } + + SettingsPageContainer { + id: pageContainer + objectName: "settingsPageContainer" + Layout.minimumWidth: 0 + Layout.fillWidth: true + Layout.fillHeight: true + } + } + + Component { + id: walletPage + + SettingsPages.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 + SettingsPages.ExternalSignerSettingsPage {} + } + + Component { + id: displayPage + SettingsPages.DisplaySettingsPage {} + } + + Component { + id: windowBehaviorPage + SettingsPages.WindowBehaviorSettingsPage {} + } + + Component { + id: storagePage + SettingsPages.StorageSettingsPage {} + } + + Component { + id: connectionPage + SettingsPages.ConnectionSettingsPage {} + } + + Component { + id: networkTrafficPage + + SettingsPages.NetworkTrafficSettingsPage {} + } + + Component { + id: mempoolPage + SettingsPages.MempoolSettingsPage {} + } + + Component { + id: rpcConsolePage + + SettingsPages.RpcConsoleSettingsPage { + walletName: typeof walletController !== "undefined" + && walletController.isWalletLoaded && walletController.selectedWallet + ? walletController.selectedWallet.name + : "" + } + } + + Component { + id: debugLogPage + + SettingsPages.SettingsDebugLog { + showBackButton: false + maximumContentWidth: width + contentHorizontalPadding: width >= 900 ? 56 : width >= 640 ? 40 : 24 + } + } + + Component { + id: aboutPage + SettingsPages.AboutSettingsPage {} + } +} 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/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/ContextMenuPicker.qml b/qml/controls/ContextMenuPicker.qml index 5415993921..f767b9d490 100644 --- a/qml/controls/ContextMenuPicker.qml +++ b/qml/controls/ContextMenuPicker.qml @@ -15,10 +15,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 @@ -39,6 +41,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] @@ -88,12 +95,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 @@ -111,6 +119,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/FormRow.qml b/qml/controls/FormRow.qml new file mode 100644 index 0000000000..e00ce7799e --- /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.neutral2 + + 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/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/LinkRow.qml b/qml/controls/LinkRow.qml new file mode 100644 index 0000000000..5a20b9f55c --- /dev/null +++ b/qml/controls/LinkRow.qml @@ -0,0 +1,52 @@ +// 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/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/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/PopupPicker.qml b/qml/controls/PopupPicker.qml new file mode 100644 index 0000000000..6e55d8a9cb --- /dev/null +++ b/qml/controls/PopupPicker.qml @@ -0,0 +1,118 @@ +// 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.neutral2 : Theme.color.background + hoverBgColor: root.embedded ? Theme.color.neutral3 : Theme.color.neutral2 + onClicked: root.opened ? root.close() : root.open() + } + + ContextMenu { + id: popup + objectName: root.objectName.length > 0 ? root.objectName + "Menu" : "" + 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 + + 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/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/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..54d8899002 --- /dev/null +++ b/qml/controls/SettingsPage.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 + +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 + contentWidth: root.maximumContentWidth + + 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/qml/controls/TextFieldRow.qml b/qml/controls/TextFieldRow.qml new file mode 100644 index 0000000000..fd4fe798e3 --- /dev/null +++ b/qml/controls/TextFieldRow.qml @@ -0,0 +1,77 @@ +// 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/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/qml/controls/ValueRow.qml b/qml/controls/ValueRow.qml new file mode 100644 index 0000000000..2c18ced1f6 --- /dev/null +++ b/qml/controls/ValueRow.qml @@ -0,0 +1,44 @@ +// 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 + } + } +} 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/AboutSettingsPage.qml b/qml/pages/settings/AboutSettingsPage.qml new file mode 100644 index 0000000000..21de145d9e --- /dev/null +++ b/qml/pages/settings/AboutSettingsPage.qml @@ -0,0 +1,93 @@ +// 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: "aboutSettingsPage" + 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: "aboutVersionRow" + 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 { + objectName: "aboutDeveloperRow" + 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: "aboutExternalLinkPopup" + parent: Overlay.overlay + anchors.centerIn: parent + width: Math.min(450, Math.max(0, parent ? parent.width - 40 : 0)) + } + + Component { + id: developerPage + + SettingsDeveloper { + onBack: root.StackView.view.pop() + } + } +} diff --git a/qml/pages/settings/ConnectionSettingsPage.qml b/qml/pages/settings/ConnectionSettingsPage.qml new file mode 100644 index 0000000000..0c106bbdc4 --- /dev/null +++ b/qml/pages/settings/ConnectionSettingsPage.qml @@ -0,0 +1,95 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import "../../controls" +import "../../components" + +SettingsPage { + id: root + objectName: "connectionSettingsPage" + 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: "listenSwitch" + 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: "natpmpSwitch" + 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: "serverSwitch" + checked: root.serverSetting.value + onToggled: root.serverSetting.value = checked + } + } + } + + FormSection { + Layout.fillWidth: true + title: qsTr("Privacy") + + ListRow { + objectName: "proxySettingsRow" + 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/DisplaySettingsPage.qml b/qml/pages/settings/DisplaySettingsPage.qml new file mode 100644 index 0000000000..3958564149 --- /dev/null +++ b/qml/pages/settings/DisplaySettingsPage.qml @@ -0,0 +1,232 @@ +// 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" + +SettingsPage { + id: root + objectName: "displaySettingsPage" + title: qsTranslate("SettingsDisplay", "Display") + showBackButton: false + + FormSection { + Layout.fillWidth: true + title: qsTr("Appearance") + + FormRow { + objectName: "displayThemeRow" + Layout.fillWidth: true + title: qsTranslate("SettingsDisplay", "Theme") + trailingItem: SegmentedPicker { + objectName: "displayThemePicker" + implicitWidth: 190 + implicitHeight: 36 + model: [qsTr("Light"), qsTr("Dark")] + currentIndex: Theme.dark ? 1 : 0 + onSelected: function(index, option) { + Theme.dark = index === 1 + } + } + } + + FormRow { + objectName: "displayBlockStatusSizeRow" + Layout.fillWidth: true + title: qsTranslate("SettingsDisplay", "Block status size") + trailingItem: PopupPicker { + objectName: "displayBlockStatusSizePicker" + 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 { + objectName: "displayMoneyFontRow" + Layout.fillWidth: true + title: qsTr("Money font") + showDivider: false + trailingItem: PopupPicker { + objectName: "displayMoneyFontPicker" + 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 { + objectName: "displayUnitRow" + Layout.fillWidth: true + title: qsTranslate("SettingsDisplay", "Display unit") + trailingItem: PopupPicker { + objectName: "displayUnitPicker" + embedded: true + minimumMenuWidth: 400 + subtitleRole: "description" + objectNameRole: "objectName" + currentValue: optionsModel.displayUnit + model: [ + { + text: qsTr("BTC"), + value: 0, + objectName: "displayUnitBTC", + description: qsTr("8 decimal places (0.00000001 BTC = 1 sat)") + }, + { + text: qsTr("mBTC"), + value: 1, + objectName: "displayUnitMBTC", + description: qsTr("5 decimal places (0.00001 mBTC = 1 sat)") + }, + { + text: qsTr("bits"), + value: 2, + objectName: "displayUnitBits", + description: qsTr("2 decimal places (0.01 bits = 1 sat)") + }, + { + text: qsTr("sat"), + value: 3, + objectName: "displayUnitSAT", + description: qsTr("Satoshi, the smallest unit (1 sat = 0.00000001 BTC)") + } + ] + onActivated: function(value) { + optionsModel.displayUnit = value + } + } + } + + ListRow { + objectName: "displayLanguageRow" + Layout.fillWidth: true + title: qsTranslate("SettingsDisplay", "Language") + enabled: ((optionsModel.coreSettingStatuses || ({})).lang || ({})).canEdit !== false + showsDisclosureIndicator: true + disclosureIndicatorObjectName: "displayLanguageDisclosureIndicator" + trailingItem: CoreText { + text: optionsModel.languageLabel(optionsModel.language) + color: Theme.color.neutral7 + font: Theme.text.description.font + } + onClicked: root.StackView.view.push(languagePage) + } + + ListRow { + objectName: "displayTransactionUrlsRow" + Layout.fillWidth: true + title: qsTr("Third-party transaction URLs") + showDivider: false + showsDisclosureIndicator: true + onClicked: root.StackView.view.push(transactionUrlsPage) + } + } + + FormSection { + objectName: "displayDeveloperSection" + Layout.fillWidth: true + visible: BuildInfo.isDebug + title: qsTr("Developer") + + ListRow { + objectName: "displayDesignSystemRow" + 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 + + SettingsLanguage { + onBack: root.StackView.view.pop() + } + } + + Component { + id: designSystemPage + + SettingsDesignSystem { + objectName: "displayDesignSystemPage" + 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: "thirdPartyTransactionUrlsInput" + Layout.fillWidth: true + text: optionsModel.thirdPartyTransactionUrls + placeholderText: "https://example.com/tx/%s" + onEditingFinished: optionsModel.thirdPartyTransactionUrls = text + } + } + } + +} diff --git a/qml/pages/settings/ExternalSignerSettingsPage.qml b/qml/pages/settings/ExternalSignerSettingsPage.qml new file mode 100644 index 0000000000..d2d6f8460c --- /dev/null +++ b/qml/pages/settings/ExternalSignerSettingsPage.qml @@ -0,0 +1,168 @@ +// 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: "externalSignerSettingsPage" + 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: "externalSignerIntroduction" + Layout.fillWidth: true + description: qsTr("Connect a hardware wallet or another external signing tool.") + } + + FormSection { + 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: "externalSignerPathRow" + 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/MempoolSettingsPage.qml b/qml/pages/settings/MempoolSettingsPage.qml new file mode 100644 index 0000000000..b0da668bf3 --- /dev/null +++ b/qml/pages/settings/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: "mempoolSettingsPage" + 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: "mempoolRestartNotice" + visible: optionsModel.mempoolSettingsDirty + Layout.fillWidth: true + } + + FormSection { + Layout.fillWidth: true + title: qsTr("Mempool") + + ValueRow { + objectName: "mempoolTransactionsRow" + Layout.fillWidth: true + title: qsTr("Transactions") + value: Number(nodeModel.mempoolTransactionCount).toLocaleString(Qt.locale(), "f", 0) + } + + ValueRow { + objectName: "mempoolMemoryUsedRow" + 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: "mempoolSizeLimitRow" + Layout.fillWidth: true + title: qsTr("Mempool size limit (MB)") + enabled: root.maxMempoolStatus.canEdit !== false + fieldObjectName: "mempoolSizeLimitInput" + 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/NetworkTrafficSettingsPage.qml b/qml/pages/settings/NetworkTrafficSettingsPage.qml new file mode 100644 index 0000000000..845abab578 --- /dev/null +++ b/qml/pages/settings/NetworkTrafficSettingsPage.qml @@ -0,0 +1,160 @@ +// 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: "networkTrafficSettingsPage" + 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: "networkTrafficHeading" + Layout.fillWidth: true + description: qsTr("How much data you have sent to and received from your peers.") + } + + FormSection { + objectName: "networkTrafficSection" + Layout.fillWidth: true + + SegmentedPicker { + objectName: "networkTrafficRangePicker" + 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: "networkTrafficReceivedRow" + 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: "networkTrafficReceivedGraph" + 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: "networkTrafficSentRow" + 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: "networkTrafficSentGraph" + 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/ProxySettingsPage.qml b/qml/pages/settings/ProxySettingsPage.qml new file mode 100644 index 0000000000..87b378ea5a --- /dev/null +++ b/qml/pages/settings/ProxySettingsPage.qml @@ -0,0 +1,227 @@ +// 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: "proxySettingsPage" + title: qsTr("Proxy settings") + backButtonObjectName: "proxySettingsBackButton" + + 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: "proxySettingsSaveButton" + text: qsTr("Save") + enabled: root.canSaveProxyDraft + onClicked: root.save() + } + + SettingsRestartNotice { + objectName: "proxyRestartNotice" + visible: root.settingsModel.proxySettingsDirty + Layout.fillWidth: true + } + + FormSection { + 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.") + + FormRow { + Layout.fillWidth: true + title: qsTr("Enable") + supportingText: root.proxySetting.infoText + enabled: root.proxySetting.canEdit + trailingItem: OptionSwitch { + objectName: "proxyEnableSwitch" + checked: root.draftProxyEnabled + onToggled: root.draftProxyEnabled = checked + } + } + + TextFieldRow { + id: proxyAddressRow + objectName: "proxyAddressRow" + Layout.fillWidth: true + title: qsTr("Proxy location") + enabled: root.draftProxyEnabled && root.proxySetting.canEdit + fieldObjectName: "proxyAddressInput" + 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: "torProxySection" + 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: "torEnableSwitch" + checked: root.draftTorEnabled + onToggled: root.draftTorEnabled = checked + } + } + + TextFieldRow { + id: torAddressRow + objectName: "torAddressRow" + Layout.fillWidth: true + title: qsTr("Proxy location") + enabled: root.draftTorEnabled && root.onionSetting.canEdit + fieldObjectName: "torAddressInput" + 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: "discardProxyChangesPopup" + parent: Overlay.overlay + title: qsTr("Discard changes?") + message: qsTr("This will discard your proxy settings changes.") + messageObjectName: "discardProxyChangesMessage" + + AlertAction { + text: qsTr("Cancel") + role: AlertAction.Cancel + buttonObjectName: "discardProxyChangesCancelButton" + } + + AlertAction { + text: qsTr("Discard") + role: AlertAction.Destructive + buttonObjectName: "discardProxyChangesConfirmButton" + onTriggered: { + root.resetProxyDraft() + root.closeRequested() + } + } + } +} diff --git a/qml/pages/settings/RpcConsoleSettingsPage.qml b/qml/pages/settings/RpcConsoleSettingsPage.qml new file mode 100644 index 0000000000..796d7be84f --- /dev/null +++ b/qml/pages/settings/RpcConsoleSettingsPage.qml @@ -0,0 +1,52 @@ +// 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/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/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 8c504c0f80..942d724541 100644 --- a/qml/pages/settings/SettingsDesignSystem.qml +++ b/qml/pages/settings/SettingsDesignSystem.qml @@ -3,166 +3,415 @@ // 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"] - header: SettingsHeader { - title: qsTr("Design system") - onBack: root.back() + 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: "" + + // ── 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 + 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/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 ba55892808..0000000000 --- a/qml/pages/settings/SettingsWallet.qml +++ /dev/null @@ -1,53 +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 - readonly property int maximumContentWidth: 450 - - background: null - contentWidth: root.maximumContentWidth - - 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, root.maximumContentWidth) - 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/StorageSettingsPage.qml b/qml/pages/settings/StorageSettingsPage.qml new file mode 100644 index 0000000000..edf61e64e8 --- /dev/null +++ b/qml/pages/settings/StorageSettingsPage.qml @@ -0,0 +1,122 @@ +// 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: "storageSettingsPage" + 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: "pruneSwitch" + 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: "pruneTargetInput" + 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: "dataDirectoryValue" + 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: "storageRestartNotice" + visible: root.settingsModel.storageSettingsDirty + Layout.fillWidth: true + Layout.maximumWidth: root.contentLayout.width + } +} diff --git a/qml/pages/settings/WalletSectionPage.qml b/qml/pages/settings/WalletSectionPage.qml new file mode 100644 index 0000000000..397dd1d057 --- /dev/null +++ b/qml/pages/settings/WalletSectionPage.qml @@ -0,0 +1,210 @@ +// 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: "walletSettingsPage" + 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() + currentFile: 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: "walletSettingsBackupPathField" + 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: "walletInfoSection" + visible: root.walletLoaded + Layout.fillWidth: true + title: qsTr("Wallet info") + + TextFieldRow { + Layout.fillWidth: true + title: qsTr("Name") + fieldObjectName: "walletNameInput" + 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: "walletActionsSection" + visible: root.walletLoaded + Layout.fillWidth: true + title: qsTr("Wallet actions") + + ListRow { + objectName: "walletAddressesRow" + Layout.fillWidth: true + title: qsTr("Addresses") + showsDisclosureIndicator: true + onClicked: root.addressesRequested() + } + + ListRow { + objectName: "walletPasswordRow" + visible: root.canManagePassphrase + Layout.fillWidth: true + title: root.wallet && root.wallet.isEncrypted ? qsTr("Update password") : qsTr("Set password") + showsDisclosureIndicator: true + onClicked: root.passwordRequested() + } + + ListRow { + objectName: "walletBackupRow" + Layout.fillWidth: true + title: qsTr("Back up wallet") + showsDisclosureIndicator: true + onClicked: root.startBackup() + } + + ListRow { + objectName: "walletSignVerifyMessageRow" + 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/WindowBehaviorSettingsPage.qml b/qml/pages/settings/WindowBehaviorSettingsPage.qml new file mode 100644 index 0000000000..940cb0e433 --- /dev/null +++ b/qml/pages/settings/WindowBehaviorSettingsPage.qml @@ -0,0 +1,59 @@ +// 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: "windowBehaviorSettingsPage" + 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: "showTrayIconSwitch" + 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: "minimizeToTraySwitch" + 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: "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 a369e83604..a32c2f5e31 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" iconColor: Theme.color.neutral7 Layout.preferredWidth: 30 - property int index: 6 + property int index: 5 ButtonGroup.group: navigationTabs Tooltip { @@ -304,8 +291,7 @@ Page { } RequestPayment { onAddressHistoryRequested: { - settingsTabButton.checked = true - nodeSettings.openWalletAddressHistory() + root.openSettingsRoute("addresses") } } Item { @@ -348,19 +334,33 @@ 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: 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: settingsTabButton.checked || retainItem + onLoaded: { + retainItem = true + Qt.callLater(applyPendingRoute) + } + sourceComponent: SettingsView { + showDoneButton: false + onSelectWalletRequested: root.openWalletSelection() + onReceiveRequested: receiveTabButton.checked = true + } } } } diff --git a/qml/pages/wallet/WalletSettings.qml b/qml/pages/wallet/WalletSettings.qml deleted file mode 100644 index ab91761bf3..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() - currentFile: 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..799f988ccb 100644 --- a/test/functional/qml_test_activity_filter_export.py +++ b/test/functional/qml_test_activity_filter_export.py @@ -221,11 +221,11 @@ 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.wait_for_property("settingsSidebar_display", "visible", True, timeout_ms=5000) + gui.click("settingsSidebar_display") + 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) diff --git a/test/functional/qml_test_addresses.py b/test/functional/qml_test_addresses.py index 22b5154f1b..810b421f5a 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("settingsSidebar_wallet", "visible", True, timeout_ms=5000) + gui.click("settingsSidebar_wallet") gui.wait_for_property("walletSettingsPage", "visible", True, timeout_ms=5000) - gui.click("settingsAddresses") + gui.click("walletAddressesRow") 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..97590ad375 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("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) @@ -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("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("commandConsole", "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") @@ -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("rpcConsole", "searchMode") is False gui.click("consoleModeToggleButton") - gui.wait_for_property("commandConsole", "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("commandConsole", "outputFontPixelSize") == 14 + assert gui.get_property("rpcConsole", "outputFontPixelSize") == 14 gui.click("consoleFontDecreaseButton") - assert gui.get_property("commandConsole", "outputFontPixelSize") == 13 + assert gui.get_property("rpcConsole", "outputFontPixelSize") == 13 gui.click("consoleModeToggleButton") - gui.wait_for_property("commandConsole", "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") @@ -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("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") @@ -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("rpcConsole", "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("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 @@ -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("rpcConsole", "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("rpcConsole", "executing", False, timeout_ms=10000) - count_after = gui.get_property("commandConsole", "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, ( @@ -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("rpcConsole", "outputCount") submit_console_command(gui, "help") - gui.wait_for_property("commandConsole", "executing", False, timeout_ms=10000) + gui.wait_for_property("rpcConsole", "executing", False, timeout_ms=10000) - count_after = gui.get_property("commandConsole", "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})" ) @@ -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("rpcConsole", "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("rpcConsole", "executing", False, timeout_ms=10000) - count_after = gui.get_property("commandConsole", "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})" ) @@ -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("settingsDoneButton") 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("rpcConsole", "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("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 a5e8d747b2..05be208788 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) @@ -179,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") @@ -399,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("nodeSettingsDoneButton") + 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 6590749ec0..3d6924a7ee 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("aboutSettingsPage", timeout_ms=SETTINGS_TIMEOUT_MS) checkpoints.checkpoint("about settings opened", gui) - gui.click("gotoDeveloperSetting") + 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("settingsAbout", 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("settings_display") - gui.wait_for_page("gotoDisplayUnit", timeout_ms=SETTINGS_TIMEOUT_MS) + gui.click("settingsSidebar_display") + gui.wait_for_page("displayUnitPicker", 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("displayUnitPickerButton") + gui.wait_for_page("displayUnitPickerMenu", timeout_ms=SETTINGS_TIMEOUT_MS) + checkpoints.checkpoint("display unit picker opened", gui) + gui.click("displayUnitBTC") - gui.click("gotoLanguage") + 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("gotoLanguage", 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("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("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("settings_connection") - gui.wait_for_page("gotoProxy", timeout_ms=SETTINGS_TIMEOUT_MS) + gui.click("settingsSidebar_connection") + gui.wait_for_page("proxySettingsRow", 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("proxySettingsRow") + gui.wait_for_page("proxySettingsPage", 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("proxySettingsBackButton") + gui.wait_for_page("proxySettingsRow", 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("networkTrafficSettingsPage", 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("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 60c7d9135c..ee0fb4dc13 100644 --- a/test/functional/qml_test_external_signer.py +++ b/test/functional/qml_test_external_signer.py @@ -238,27 +238,28 @@ 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) 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) @@ -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("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 1b52fad0c4..9d0b1f8b11 100644 --- a/test/functional/qml_test_password_wallet.py +++ b/test/functional/qml_test_password_wallet.py @@ -234,8 +234,8 @@ 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_property("settingsSidebar_wallet", "visible", True, timeout_ms=10000) + gui.click("settingsSidebar_wallet") gui.wait_for_page("walletSettingsPage", timeout_ms=10000) @@ -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("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_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..29e847072d 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("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("settingsProxyDone", "enabled", True, timeout_ms=2000) - gui.click("settingsProxyDone") - gui.wait_for_page("gotoProxy", 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("nodeSettingsDoneButton"): - gui.click("nodeSettingsDoneButton") + if gui.object_exists("settingsDoneButton"): + gui.click("settingsDoneButton") else: - gui.click("settingsConnectionBack") + gui.click("activityTabButton") gui.settle() if gui.object_exists("desktopWalletSettingsTabButton"): gui.wait_for_property("desktopWalletSettingsTabButton", "visible", True, timeout_ms=5000) @@ -72,9 +72,9 @@ def test_default_proxy_toggle(gui): checked = gui.get_property("proxyEnableSwitch", "checked") assert not checked, f"Expected proxy disabled by default, got checked={checked}" - dirty = gui.get_property("settingsProxy", "proxySettingsDirty") + dirty = gui.get_property("proxyRestartNotice", "visible") assert not dirty, "Expected proxySettingsDirty=False before any change" - draft_dirty = gui.get_property("settingsProxy", "proxyDraftDirty") + draft_dirty = gui.get_property("proxySettingsPage", "proxyDraftDirty") assert not draft_dirty, "Expected proxyDraftDirty=False before any change" # Enable proxy. @@ -84,8 +84,8 @@ def test_default_proxy_toggle(gui): 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("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") @@ -96,7 +96,7 @@ def test_default_proxy_toggle(gui): 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("proxySettingsPage", "proxyDraftDirty", False, timeout_ms=2000) print(" proxyDraftDirty=False after reverting proxy change: OK") @@ -111,15 +111,12 @@ def test_proxy_valid_address(gui): 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.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("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) + gui.wait_for_property("proxySettingsPage", "draftProxyValidationError", "", timeout_ms=2000) + gui.wait_for_property("proxySettingsSaveButton", "enabled", True, timeout_ms=2000) print(" Valid address accepted: OK") @@ -134,17 +131,19 @@ def test_proxy_invalid_address(gui): gui.wait_for_property("proxyAddressInput", "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.wait_for_property( + "proxySettingsPage", + "draftProxyValidationError", + lambda error: len(error) > 0, + 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("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.wait_for_property("proxySettingsPage", "draftProxyValidationError", "", timeout_ms=2000) + gui.wait_for_property("proxySettingsSaveButton", "enabled", True, timeout_ms=2000) def test_tor_proxy_toggle(gui): @@ -180,25 +179,25 @@ def test_back_discards_proxy_draft(gui): 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("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("settingsProxyBack") + 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("settingsProxyBack") + gui.click("proxySettingsBackButton") 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_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("settingsProxy", "proxyDraftDirty", False, timeout_ms=2000) + gui.wait_for_property("proxySettingsPage", "proxyDraftDirty", False, timeout_ms=2000) print(" Back discard leaves persisted settings unchanged: OK") @@ -265,14 +264,14 @@ def run_tests(): 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) + 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("torAddressInput", "validInput", True, timeout_ms=2000) + 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_send_receive.py b/test/functional/qml_test_send_receive.py index 585e12232e..7e3db51cd7 100644 --- a/test/functional/qml_test_send_receive.py +++ b/test/functional/qml_test_send_receive.py @@ -395,6 +395,11 @@ def run_test(*, save_screenshots=False, screenshot_root=None): gui.click("activityTypeFilterButton") gui.click("activityTypeSent") gui.wait_for_property("activityFilterProxyModel", "count", 1, timeout_ms=20000) + wait_until( + lambda: gui.get_list_item_property("activityListView", 0, "amount") != "", + timeout=10, + description="sent Activity row delegate", + ) activity_amount_text = gui.get_list_item_property("activityListView", 0, "amount") activity_amount_sats = amount_text_to_sats(activity_amount_text) assert activity_amount_text.startswith("-"), ( diff --git a/test/functional/qml_test_settings_display.py b/test/functional/qml_test_settings_display.py index bfc4d708da..99007656c0 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", + "displayThemeRow", + "displayBlockStatusSizeRow", + "displayMoneyFontRow", + "displayUnitRow", + "displayLanguageRow", + "displayTransactionUrlsRow", ) -# ── 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("displaySettingsPage", timeout_ms=5000) + gui.wait_for_page("displayUnitPicker", 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("displayUnitPickerButton") + gui.wait_for_property(item_name, "visible", True, timeout_ms=3000) + gui.click(item_name) + gui.wait_for_property( + "displayUnitPicker", "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("displayLanguageRow") 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("displayLanguageRow", timeout_ms=5000) -# ── Individual test cases ───────────────────────────────────────────────────── +def reset_display_unit_to_btc(gui): + select_display_unit(gui, "displayUnitBTC", "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, "displayUnitSAT", "sat") + assert gui.get_property("displayUnitPicker", "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("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") 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("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) + 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, "displayUnitSAT", "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..19ba2dc736 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("windowBehaviorSettingsPage", timeout_ms=5000) def run_tests(): @@ -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 + "showTrayIconSwitch": True, + "minimizeToTraySwitch": False, + "minimizeOnCloseSwitch": 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,26 +149,26 @@ 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 ...") + print("Test 4: Show tray icon 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(" -> Show tray icon clicked off ✓") - print("Test 5: showTrayIconSwitch toggles back on ...") + print("Test 5: Show tray icon toggles back on ...") gui.click("showTrayIconSwitch") gui.wait_for_property("showTrayIconSwitch", "checked", True, timeout_ms=2000) - print(" -> showTrayIconSwitch restored to on ✓") + 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("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("settings_windowbehavior") - gui.wait_for_page("windowBehaviorPage", timeout_ms=5000) + gui.click("settingsSidebar_window-behavior") + gui.wait_for_page("windowBehaviorSettingsPage", timeout_ms=5000) print(" -> re-opened Window Behavior ✓") # ── Test 8: Close with minimizeOnClose keeps app alive ──────────────── diff --git a/test/functional/qml_test_wallet_settings.py b/test/functional/qml_test_wallet_settings.py index 82149b7ae9..ef3e665cfa 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,8 +146,8 @@ 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_property("settingsSidebar_wallet", "visible", True, timeout_ms=5000) + gui.click("settingsSidebar_wallet") gui.wait_for_page("walletSettingsPage", timeout_ms=10000) @@ -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("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) @@ -291,9 +277,11 @@ def case_backup_uses_automation_path(harness, checkpoints): checkpoints.checkpoint("wallet settings opened", gui) gui.set_text("walletSettingsBackupPathField", backup_dir) - gui.click("walletSettingsBackupRow") + gui.click("walletBackupRow") wait_for_file(backup_path) - assert gui.get_text("walletSettingsErrorText") == "", "Backup should not surface an error" + assert gui.get_property("walletSettingsPage", "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("walletSignVerifyMessageRow") 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("walletPasswordRow") gui.wait_for_page("walletPasswordSettingsPage", timeout_ms=10000) checkpoints.checkpoint("password subpage opened", gui) @@ -380,7 +368,7 @@ def case_subpages_close_when_wallet_becomes_unselected(harness, checkpoints): 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("walletNameInput", "visible", True, timeout_ms=5000) checkpoints.checkpoint("wallet reselected from settings page", gui) @@ -405,7 +393,7 @@ 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("walletPasswordRow") gui.wait_for_page("walletPasswordSettingsPage", timeout_ms=10000) checkpoints.checkpoint("password subpage opened for first 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("walletPasswordRow") 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 c82b227ecd..aa1287754f 100644 --- a/test/qml/bitcoin_qmltests.qrc +++ b/test/qml/bitcoin_qmltests.qrc @@ -17,19 +17,18 @@ tst_createwalletwizard.qml tst_debuglogoutputview.qml tst_desktopwallets.qml - tst_displaysettings.qml tst_dropdownbutton.qml tst_externalsignerreviewactions.qml tst_feeselection.qml tst_importwalletoptions.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 tst_proxylocationinput.qml tst_requestpayment.qml tst_rightcontenticon.qml @@ -37,15 +36,13 @@ tst_send.qml tst_setting.qml 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 f3949f0936..8185d5d38e 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; @@ -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_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() { diff --git a/test/qml/tst_desktopwallets.qml b/test/qml/tst_desktopwallets.qml index eacdeaec6d..2694dcd816 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,7 +83,6 @@ TestCase { const tabs = [ findChild(page, "blockClockTabButton"), findChild(page, "peersTabButton"), - findChild(page, "consoleTabButton"), findChild(page, "desktopWalletSettingsTabButton") ] @@ -93,28 +93,32 @@ TestCase { } compare(tabs[1].iconSize, 24) - compare(tabs[2].iconSize, 24) - compare(tabs[3].iconSize, 30) + compare(tabs[2].iconSize, 30) + compare(tabs[2].iconSource, "image://images/gear-outline") + compare(findChild(page, "consoleTabButton"), null) + compare(findChild(page, "desktopWalletSettingsPreviewTabButton"), null) } - function test_console_autocomplete_closes_when_switching_tabs() { + function test_settings_is_lazilyLoadedAndRetained() { 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() { @@ -139,13 +143,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") + 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, "walletSettingsPage") !== 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 new file mode 100644 index 0000000000..2f301f384c --- /dev/null +++ b/test/qml/tst_formcontrols.qml @@ -0,0 +1,396 @@ +// 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.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_settingsPageKeepsImplicitWidthIndependentOfLayout() { + const page = createTemporaryObject(settingsPageComponent, host) + verify(page !== null) + + const implicitWidthBefore = page.implicitWidth + const widthBefore = page.width + page.width = widthBefore / 2 + + verify(page.width !== widthBefore) + compare(page.implicitWidth, implicitWidthBefore) + } + + 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) + } +} 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 cd3e649665..0000000000 --- a/test/qml/tst_nodesettings.qml +++ /dev/null @@ -1,279 +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) - } - - function test_section_pages_keep_implicit_width_independent_of_layout() { - const page = createNodeSettingsPage() - - const sections = [ - { index: 1, name: "settingsWallet" }, - { index: 4, name: "settingsStoragePage" }, - { index: 7, name: "mempoolInformationSettingsPage" } - ] - - for (let i = 0; i < sections.length; ++i) { - page.currentSection = sections[i].index - wait(0) - - const sectionPage = findChild(page, sections[i].name) - verify(sectionPage !== null, sections[i].name + " was not created") - - const implicitWidthBefore = sectionPage.implicitWidth - const widthBefore = sectionPage.width - sectionPage.width = widthBefore / 2 - verify(sectionPage.width !== widthBefore, - sections[i].name + " ignored the width it was given") - compare(sectionPage.implicitWidth, implicitWidthBefore, - sections[i].name + " implicit width followed the width it was given") - } - } -} 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() + } +} 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 + } } diff --git a/test/qml/tst_settingsnavigation.qml b/test/qml/tst_settingsnavigation.qml new file mode 100644 index 0000000000..eba8c89a10 --- /dev/null +++ b/test/qml/tst_settingsnavigation.qml @@ -0,0 +1,800 @@ +// 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" + groupTitles: ({ + "wallet": "Wallet", + "general": "General", + "network": "Network" + }) + model: [ + { id: "wallet", label: "Wallet", group: "wallet" }, + { id: "display", label: "Display", group: "general" }, + { id: "hidden", label: "Hidden", group: "general", 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.mempoolSettingsDirty = false + nodeModel.resetMempoolInfoPollingTestState() + optionsModel.clearCoreSettingStatusesForTest() + walletController.reset() + walletController.setWalletLoaded(true) + walletController.selectedWallet.isEncrypted = false + 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) + 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) { + activatedSection = sectionId + }) + const connection = findChild(sidebar, "settingsSidebar_connection") + verify(connection !== null) + connection.clicked() + 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) + + 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, "displaySettingsPage") + verify(displayPage !== 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, "displaySettingsPage"), displayPage) + const networkTrafficPage = findChild(view, "networkTrafficSettingsPage") + verify(networkTrafficPage !== null) + tryCompare(testNetworkTrafficTower, "active", true) + 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) + 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, "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, "networkTrafficSettingsPage"), 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, "aboutSettingsPage") !== 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, "networkTrafficSettingsPage"), networkTrafficPage) + + view.visible = true + compare(view.pageContainer.depth, 1) + verify(findChild(view, "aboutSettingsPage") !== null) + compare(findChild(view, "settingsDebugLog"), debugLogPage) + compare(findChild(view, "networkTrafficSettingsPage"), 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_walletStackResetsWhenSelectedWalletChangesWhileHidden() { + const view = createTemporaryObject(settingsViewComponent, settingsWindow.contentItem) + verify(view !== null) + + view.selectSection("wallet") + const setPasswordRow = findChild(view, "walletPasswordRow") + verify(setPasswordRow !== null) + compare(setPasswordRow.title, "Set password") + setPasswordRow.clicked() + tryCompare(view.pageContainer, "depth", 2) + const setPasswordPage = findChild(view, "walletPasswordSettingsPage") + verify(setPasswordPage !== null) + compare(setPasswordPage.updating, false) + + view.selectSection("display") + compare(view.selectedSectionId, "display") + walletController.selectedWallet.isEncrypted = true + walletController.setSelectedWallet("encrypted-wallet") + compare(view.selectedSectionId, "display") + compare(view.pageContainer.sectionDepth("wallet"), 1) + + view.selectSection("wallet") + tryCompare(view.pageContainer, "depth", 1) + const updatePasswordRow = findChild(view, "walletPasswordRow") + verify(updatePasswordRow !== null) + compare(updatePasswordRow.title, "Update password") + updatePasswordRow.clicked() + tryCompare(view.pageContainer, "depth", 2) + const updatePasswordPage = findChild(view, "walletPasswordSettingsPage") + verify(updatePasswordPage !== null) + compare(updatePasswordPage.updating, true) + } + + function test_walletStackResetsWhenWalletUnloadsWhileHidden() { + const view = createTemporaryObject(settingsViewComponent, settingsWindow.contentItem) + verify(view !== null) + + view.selectSection("wallet") + const passwordRow = findChild(view, "walletPasswordRow") + verify(passwordRow !== null) + passwordRow.clicked() + tryCompare(view.pageContainer, "depth", 2) + verify(findChild(view, "walletPasswordSettingsPage") !== null) + + view.selectSection("display") + compare(view.selectedSectionId, "display") + walletController.setWalletLoaded(false) + compare(view.selectedSectionId, "display") + compare(view.pageContainer.sectionDepth("wallet"), 1) + + view.selectSection("wallet") + tryCompare(view.pageContainer, "depth", 1) + compare(view.pageContainer.currentItem.objectName, "walletSettingsPage") + } + + function test_settingsViewPinsSidebarAndLetsPageContainerGrow() { + const view = createTemporaryObject(settingsViewComponent, settingsWindow.contentItem) + verify(view !== null) + + const sidebarSurface = findChild(view, "settingsSidebarSurface") + const sidebarHeading = findChild(view, "settingsSidebarHeading") + const displayPage = findChild(view, "displaySettingsPage") + 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) + + 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, "networkTrafficSettingsPage") + 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_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("rpc-console").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_rpcConsoleUsesSettingsContainerAndTracksVisibility() { + const view = createTemporaryObject(settingsViewComponent, settingsWindow.contentItem) + verify(view !== null) + view.selectSection("rpc-console") + + const page = findChild(view, "rpcConsoleSettingsPage") + const header = findChild(view, "rpcConsoleHeader") + const rpcConsole = findChild(view, "rpcConsole") + 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) + + 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) + 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) + + 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) + verify(findChild(view, "displayDesignSystemPage") !== null) + } + + function test_mempoolPageUsesStandardFormRows() { + const view = createTemporaryObject(settingsViewComponent, settingsWindow.contentItem) + verify(view !== null) + view.selectSection("mempool") + + 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) + 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) + + 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_mempoolPollingAndRestartNoticeFollowVisibility() { + const view = createTemporaryObject(settingsViewComponent, settingsWindow.contentItem) + verify(view !== null) + compare(nodeModel.mempoolInfoPollingActive, false) + + view.selectSection("mempool") + const mempoolPage = findChild(view, "mempoolSettingsPage") + const restartNotice = findChild(view, "mempoolRestartNotice") + 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, "mempoolSettingsPage"), 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) + view.selectSection("connection") + + const proxySettingsRow = findChild(view, "proxySettingsRow") + verify(proxySettingsRow !== null) + proxySettingsRow.clicked() + tryCompare(view.pageContainer, "depth", 2) + + 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) + 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, "discardProxyChangesPopup") + verify(discardPopup !== null) + tryCompare(discardPopup, "opened", true) + const cancelButton = findChild(settingsWindow.contentItem, "discardProxyChangesCancelButton") + 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: "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: "aboutSettingsPage" } + ] + + 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, "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, "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") + 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, "aboutVersionRow") + const versionValue = findChild(view, "aboutVersionRowValue") + verify(versionRow !== null) + verify(versionValue !== null) + compare(versionRow.value, BuildInfo.fullClientVersion) + compare(versionValue.text, BuildInfo.fullClientVersion) + verify(versionValue.text.length > 0) + } + } + } +} 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) - } -}