diff --git a/CMakeLists.txt b/CMakeLists.txt index 69ee54611a..2c86b4acbf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -254,7 +254,7 @@ if(WITH_USDT) endif() if(BUILD_GUI) - set(qt_components Core Gui Widgets LinguistTools) + set(qt_components Core Gui Widgets Qml Quick QuickControls2 LinguistTools) if(ENABLE_WALLET) list(APPEND qt_components Network) endif() diff --git a/cmake/module/Maintenance.cmake b/cmake/module/Maintenance.cmake index 15fbf2bea8..20d2ff0f09 100644 --- a/cmake/module/Maintenance.cmake +++ b/cmake/module/Maintenance.cmake @@ -36,7 +36,7 @@ function(add_windows_deploy_target) OUTPUT ${PROJECT_BINARY_DIR}/bitcoin-win64-setup.exe COMMAND ${CMAKE_COMMAND} -E make_directory ${PROJECT_BINARY_DIR}/release COMMAND ${CMAKE_STRIP} $ -o ${PROJECT_BINARY_DIR}/release/$ - COMMAND ${CMAKE_STRIP} $ -o ${PROJECT_BINARY_DIR}/release/$ + COMMAND ${CMAKE_STRIP} $ -o ${PROJECT_BINARY_DIR}/release/$ COMMAND ${CMAKE_STRIP} $ -o ${PROJECT_BINARY_DIR}/release/$ COMMAND ${CMAKE_STRIP} $ -o ${PROJECT_BINARY_DIR}/release/$ COMMAND ${CMAKE_STRIP} $ -o ${PROJECT_BINARY_DIR}/release/$ @@ -65,8 +65,8 @@ function(add_macos_deploy_target) add_custom_command( OUTPUT ${PROJECT_BINARY_DIR}/${macos_app}/Contents/MacOS/Bitcoin-Qt - COMMAND ${CMAKE_COMMAND} --install ${PROJECT_BINARY_DIR} --config $ --component bitcoin-qt --prefix ${macos_app}/Contents/MacOS --strip - COMMAND ${CMAKE_COMMAND} -E rename ${macos_app}/Contents/MacOS/bin/$ ${macos_app}/Contents/MacOS/Bitcoin-Qt + COMMAND ${CMAKE_COMMAND} --install ${PROJECT_BINARY_DIR} --config $ --component bitcoin-qml --prefix ${macos_app}/Contents/MacOS --strip + COMMAND ${CMAKE_COMMAND} -E rename ${macos_app}/Contents/MacOS/bin/$ ${macos_app}/Contents/MacOS/Bitcoin-Qt COMMAND ${CMAKE_COMMAND} -E rm -rf ${macos_app}/Contents/MacOS/bin COMMAND ${CMAKE_COMMAND} -E rm -rf ${macos_app}/Contents/MacOS/share VERBATIM diff --git a/contrib/guix/symbol-check.py b/contrib/guix/symbol-check.py index 86b7965277..65f3f5f599 100755 --- a/contrib/guix/symbol-check.py +++ b/contrib/guix/symbol-check.py @@ -130,6 +130,7 @@ 'Metal', # 3D graphics 'QuartzCore', # animation 'Security', # access control and authentication +'SystemConfiguration', # network configuration 'UniformTypeIdentifiers', # collection of types that map to MIME and file types } @@ -158,6 +159,7 @@ 'NETAPI32.dll', # network management 'ole32.dll', # component object model 'OLEAUT32.dll', # OLE Automation API +'Secur32.dll', # SSPI (Security Support Provider Interface) 'SHLWAPI.dll', # light weight shell API 'USER32.dll', # user interface 'USERENV.dll', # user management diff --git a/depends/packages/native_qt.mk b/depends/packages/native_qt.mk index 2bf088c10a..6d2aec3734 100644 --- a/depends/packages/native_qt.mk +++ b/depends/packages/native_qt.mk @@ -11,13 +11,21 @@ $(package)_patches += rcc_hardcode_timestamp.patch $(package)_patches += qttools_skip_dependencies.patch $(package)_patches += fix-macos26-qyield.patch +$(package)_qtshadertools_file_name=$(qt_details_qtshadertools_file_name) +$(package)_qtshadertools_sha256_hash=$(qt_details_qtshadertools_sha256_hash) + +$(package)_qtdeclarative_file_name=$(qt_details_qtdeclarative_file_name) +$(package)_qtdeclarative_sha256_hash=$(qt_details_qtdeclarative_sha256_hash) + $(package)_qttranslations_file_name=$(qt_details_qttranslations_file_name) $(package)_qttranslations_sha256_hash=$(qt_details_qttranslations_sha256_hash) $(package)_qttools_file_name=$(qt_details_qttools_file_name) $(package)_qttools_sha256_hash=$(qt_details_qttools_sha256_hash) -$(package)_extra_sources := $($(package)_qttranslations_file_name) +$(package)_extra_sources := $($(package)_qtshadertools_file_name) +$(package)_extra_sources += $($(package)_qtdeclarative_file_name) +$(package)_extra_sources += $($(package)_qttranslations_file_name) $(package)_extra_sources += $($(package)_qttools_file_name) $(package)_top_download_path=$(qt_details_top_download_path) @@ -51,14 +59,13 @@ $(package)_config_opts += -no-feature-concurrent $(package)_config_opts += -no-feature-network $(package)_config_opts += -no-feature-printsupport $(package)_config_opts += -no-feature-sql -$(package)_config_opts += -no-feature-testlib $(package)_config_opts += -no-feature-xml -$(package)_config_opts += -no-gui $(package)_config_opts += -no-widgets $(package)_config_opts += -no-glib $(package)_config_opts += -no-icu $(package)_config_opts += -no-libudev +$(package)_config_opts += -no-opengl $(package)_config_opts += -no-openssl $(package)_config_opts += -no-zstd $(package)_config_opts += -qt-pcre @@ -67,6 +74,7 @@ $(package)_config_opts += -no-feature-backtrace $(package)_config_opts += -no-feature-permissions $(package)_config_opts += -no-feature-process $(package)_config_opts += -no-feature-settings +$(package)_config_opts += -no-feature-vulkan # Core tools. $(package)_config_opts += -no-feature-androiddeployqt @@ -106,6 +114,8 @@ endef define $(package)_fetch_cmds $(call fetch_file,$(package),$($(package)_download_path),$($(package)_download_file),$($(package)_file_name),$($(package)_sha256_hash)) && \ +$(call fetch_file,$(package),$($(package)_download_path),$($(package)_qtshadertools_file_name),$($(package)_qtshadertools_file_name),$($(package)_qtshadertools_sha256_hash)) && \ +$(call fetch_file,$(package),$($(package)_download_path),$($(package)_qtdeclarative_file_name),$($(package)_qtdeclarative_file_name),$($(package)_qtdeclarative_sha256_hash)) && \ $(call fetch_file,$(package),$($(package)_download_path),$($(package)_qttranslations_file_name),$($(package)_qttranslations_file_name),$($(package)_qttranslations_sha256_hash)) && \ $(call fetch_file,$(package),$($(package)_download_path),$($(package)_qttools_file_name),$($(package)_qttools_file_name),$($(package)_qttools_sha256_hash)) && \ $(call fetch_file,$(package),$($(package)_top_download_path),$($(package)_top_cmakelists_download_file),$($(package)_top_cmakelists_file_name)-$($(package)_version),$($(package)_top_cmakelists_sha256_hash)) && \ @@ -116,6 +126,8 @@ endef define $(package)_extract_cmds mkdir -p $($(package)_extract_dir) && \ echo "$($(package)_sha256_hash) $($(package)_source)" > $($(package)_extract_dir)/.$($(package)_file_name).hash && \ + echo "$($(package)_qtshadertools_sha256_hash) $($(package)_source_dir)/$($(package)_qtshadertools_file_name)" >> $($(package)_extract_dir)/.$($(package)_file_name).hash && \ + echo "$($(package)_qtdeclarative_sha256_hash) $($(package)_source_dir)/$($(package)_qtdeclarative_file_name)" >> $($(package)_extract_dir)/.$($(package)_file_name).hash && \ echo "$($(package)_qttranslations_sha256_hash) $($(package)_source_dir)/$($(package)_qttranslations_file_name)" >> $($(package)_extract_dir)/.$($(package)_file_name).hash && \ echo "$($(package)_qttools_sha256_hash) $($(package)_source_dir)/$($(package)_qttools_file_name)" >> $($(package)_extract_dir)/.$($(package)_file_name).hash && \ echo "$($(package)_top_cmakelists_sha256_hash) $($(package)_source_dir)/$($(package)_top_cmakelists_file_name)-$($(package)_version)" >> $($(package)_extract_dir)/.$($(package)_file_name).hash && \ @@ -124,6 +136,10 @@ define $(package)_extract_cmds $(build_SHA256SUM) -c $($(package)_extract_dir)/.$($(package)_file_name).hash && \ mkdir -p qtbase && \ $(build_TAR) --no-same-owner --strip-components=1 -xf $($(package)_source) -C qtbase && \ + mkdir -p qtshadertools && \ + $(build_TAR) --no-same-owner --strip-components=1 -xf $($(package)_source_dir)/$($(package)_qtshadertools_file_name) -C qtshadertools && \ + mkdir -p qtdeclarative && \ + $(build_TAR) --no-same-owner --strip-components=1 -xf $($(package)_source_dir)/$($(package)_qtdeclarative_file_name) -C qtdeclarative && \ mkdir -p qttranslations && \ $(build_TAR) --no-same-owner --strip-components=1 -xf $($(package)_source_dir)/$($(package)_qttranslations_file_name) -C qttranslations && \ mkdir -p qttools && \ diff --git a/depends/packages/qt.mk b/depends/packages/qt.mk index 9b7cec341d..b0f51fe437 100644 --- a/depends/packages/qt.mk +++ b/depends/packages/qt.mk @@ -27,13 +27,21 @@ $(package)_patches += fix-gcc16-sfinae-qanystringview.patch $(package)_patches += fix-macos26-qyield.patch $(package)_patches += fix-qbytearray-include.patch +$(package)_qtshadertools_file_name=$(qt_details_qtshadertools_file_name) +$(package)_qtshadertools_sha256_hash=$(qt_details_qtshadertools_sha256_hash) + +$(package)_qtdeclarative_file_name=$(qt_details_qtdeclarative_file_name) +$(package)_qtdeclarative_sha256_hash=$(qt_details_qtdeclarative_sha256_hash) + $(package)_qttranslations_file_name=$(qt_details_qttranslations_file_name) $(package)_qttranslations_sha256_hash=$(qt_details_qttranslations_sha256_hash) $(package)_qttools_file_name=$(qt_details_qttools_file_name) $(package)_qttools_sha256_hash=$(qt_details_qttools_sha256_hash) -$(package)_extra_sources := $($(package)_qttranslations_file_name) +$(package)_extra_sources := $($(package)_qtshadertools_file_name) +$(package)_extra_sources += $($(package)_qtdeclarative_file_name) +$(package)_extra_sources += $($(package)_qttranslations_file_name) $(package)_extra_sources += $($(package)_qttools_file_name) $(package)_top_download_path=$(qt_details_top_download_path) @@ -127,6 +135,9 @@ $(package)_config_opts += -no-feature-macdeployqt $(package)_config_opts += -no-feature-qmake $(package)_config_opts += -no-feature-windeployqt +# QML features. +$(package)_config_opts += -no-feature-qml-profiler + ifeq ($(host),$(build)) # Qt Tools module. $(package)_config_opts += -feature-linguist @@ -224,6 +235,8 @@ endef define $(package)_fetch_cmds $(call fetch_file,$(package),$($(package)_download_path),$($(package)_download_file),$($(package)_file_name),$($(package)_sha256_hash)) && \ +$(call fetch_file,$(package),$($(package)_download_path),$($(package)_qtshadertools_file_name),$($(package)_qtshadertools_file_name),$($(package)_qtshadertools_sha256_hash)) && \ +$(call fetch_file,$(package),$($(package)_download_path),$($(package)_qtdeclarative_file_name),$($(package)_qtdeclarative_file_name),$($(package)_qtdeclarative_sha256_hash)) && \ $(call fetch_file,$(package),$($(package)_download_path),$($(package)_qttranslations_file_name),$($(package)_qttranslations_file_name),$($(package)_qttranslations_sha256_hash)) && \ $(call fetch_file,$(package),$($(package)_download_path),$($(package)_qttools_file_name),$($(package)_qttools_file_name),$($(package)_qttools_sha256_hash)) && \ $(call fetch_file,$(package),$($(package)_top_download_path),$($(package)_top_cmakelists_download_file),$($(package)_top_cmakelists_file_name)-$($(package)_version),$($(package)_top_cmakelists_sha256_hash)) && \ @@ -235,6 +248,8 @@ ifeq ($(host),$(build)) define $(package)_extract_cmds mkdir -p $($(package)_extract_dir) && \ echo "$($(package)_sha256_hash) $($(package)_source)" > $($(package)_extract_dir)/.$($(package)_file_name).hash && \ + echo "$($(package)_qtshadertools_sha256_hash) $($(package)_source_dir)/$($(package)_qtshadertools_file_name)" >> $($(package)_extract_dir)/.$($(package)_file_name).hash && \ + echo "$($(package)_qtdeclarative_sha256_hash) $($(package)_source_dir)/$($(package)_qtdeclarative_file_name)" >> $($(package)_extract_dir)/.$($(package)_file_name).hash && \ echo "$($(package)_qttranslations_sha256_hash) $($(package)_source_dir)/$($(package)_qttranslations_file_name)" >> $($(package)_extract_dir)/.$($(package)_file_name).hash && \ echo "$($(package)_qttools_sha256_hash) $($(package)_source_dir)/$($(package)_qttools_file_name)" >> $($(package)_extract_dir)/.$($(package)_file_name).hash && \ echo "$($(package)_top_cmakelists_sha256_hash) $($(package)_source_dir)/$($(package)_top_cmakelists_file_name)-$($(package)_version)" >> $($(package)_extract_dir)/.$($(package)_file_name).hash && \ @@ -243,6 +258,10 @@ define $(package)_extract_cmds $(build_SHA256SUM) -c $($(package)_extract_dir)/.$($(package)_file_name).hash && \ mkdir -p qtbase && \ $(build_TAR) --no-same-owner --strip-components=1 -xf $($(package)_source) -C qtbase && \ + mkdir -p qtshadertools && \ + $(build_TAR) --no-same-owner --strip-components=1 -xf $($(package)_source_dir)/$($(package)_qtshadertools_file_name) -C qtshadertools && \ + mkdir -p qtdeclarative && \ + $(build_TAR) --no-same-owner --strip-components=1 -xf $($(package)_source_dir)/$($(package)_qtdeclarative_file_name) -C qtdeclarative && \ mkdir -p qttranslations && \ $(build_TAR) --no-same-owner --strip-components=1 -xf $($(package)_source_dir)/$($(package)_qttranslations_file_name) -C qttranslations && \ mkdir -p qttools && \ @@ -256,12 +275,18 @@ else define $(package)_extract_cmds mkdir -p $($(package)_extract_dir) && \ echo "$($(package)_sha256_hash) $($(package)_source)" > $($(package)_extract_dir)/.$($(package)_file_name).hash && \ + echo "$($(package)_qtshadertools_sha256_hash) $($(package)_source_dir)/$($(package)_qtshadertools_file_name)" >> $($(package)_extract_dir)/.$($(package)_file_name).hash && \ + echo "$($(package)_qtdeclarative_sha256_hash) $($(package)_source_dir)/$($(package)_qtdeclarative_file_name)" >> $($(package)_extract_dir)/.$($(package)_file_name).hash && \ echo "$($(package)_top_cmakelists_sha256_hash) $($(package)_source_dir)/$($(package)_top_cmakelists_file_name)-$($(package)_version)" >> $($(package)_extract_dir)/.$($(package)_file_name).hash && \ echo "$($(package)_top_cmake_ecmoptionaladdsubdirectory_sha256_hash) $($(package)_source_dir)/$($(package)_top_cmake_ecmoptionaladdsubdirectory_file_name)-$($(package)_version)" >> $($(package)_extract_dir)/.$($(package)_file_name).hash && \ echo "$($(package)_top_cmake_qttoplevelhelpers_sha256_hash) $($(package)_source_dir)/$($(package)_top_cmake_qttoplevelhelpers_file_name)-$($(package)_version)" >> $($(package)_extract_dir)/.$($(package)_file_name).hash && \ $(build_SHA256SUM) -c $($(package)_extract_dir)/.$($(package)_file_name).hash && \ mkdir -p qtbase && \ $(build_TAR) --no-same-owner --strip-components=1 -xf $($(package)_source) -C qtbase && \ + mkdir -p qtshadertools && \ + $(build_TAR) --no-same-owner --strip-components=1 -xf $($(package)_source_dir)/$($(package)_qtshadertools_file_name) -C qtshadertools && \ + mkdir qtdeclarative && \ + $(build_TAR) --no-same-owner --strip-components=1 -xf $($(package)_source_dir)/$($(package)_qtdeclarative_file_name) -C qtdeclarative && \ cp $($(package)_source_dir)/$($(package)_top_cmakelists_file_name)-$($(package)_version) ./$($(package)_top_cmakelists_file_name) && \ mkdir -p cmake && \ cp $($(package)_source_dir)/$($(package)_top_cmake_ecmoptionaladdsubdirectory_file_name)-$($(package)_version) cmake/$($(package)_top_cmake_ecmoptionaladdsubdirectory_file_name) && \ diff --git a/depends/packages/qt_details.mk b/depends/packages/qt_details.mk index e49ac6a287..1e7264ab1a 100644 --- a/depends/packages/qt_details.mk +++ b/depends/packages/qt_details.mk @@ -5,6 +5,12 @@ qt_details_suffix := everywhere-src-$(qt_details_version).tar.xz qt_details_qtbase_file_name := qtbase-$(qt_details_suffix) qt_details_qtbase_sha256_hash := 56001b905601bb9023d399f3ba780d7fa940f3e4861e496a7c490331f49e0b80 +qt_details_qtshadertools_file_name = qtshadertools-$(qt_details_suffix) +qt_details_qtshadertools_sha256_hash = f6ec88bf42deba84d8f6b5d0914636ceed4749ccb51d1945b2f79b322b7ecf47 + +qt_details_qtdeclarative_file_name = qtdeclarative-$(qt_details_suffix) +qt_details_qtdeclarative_sha256_hash = 1f03a2b8f5588b4face7da87926e9b2c1372b3a32157c52df07a75067a9db1af + qt_details_qttranslations_file_name := qttranslations-$(qt_details_suffix) qt_details_qttranslations_sha256_hash := c3c61d79c3d8fe316a20b3617c64673ce5b5519b2e45535f49bee313152fa531 diff --git a/depends/patches/qt/qtbase_skip_tools.patch b/depends/patches/qt/qtbase_skip_tools.patch index eef65425d4..d5377c3f45 100644 --- a/depends/patches/qt/qtbase_skip_tools.patch +++ b/depends/patches/qt/qtbase_skip_tools.patch @@ -3,8 +3,7 @@ Skip building/installing unneeded tools: 1. Wrapper CMake scripts. 2. CI support files. 3. tracepointgen and tracegen tools. -4. Qt Look Ahead LR Parser Generator (qlalr). -5. Qt Vulkan Header Generator (qvkgen). +4. Qt Vulkan Header Generator (qvkgen). --- a/qtbase/cmake/QtBaseGlobalTargets.cmake @@ -47,11 +46,10 @@ Skip building/installing unneeded tools: --- a/qtbase/src/tools/CMakeLists.txt +++ b/qtbase/src/tools/CMakeLists.txt -@@ -11,8 +11,6 @@ if (QT_FEATURE_dbus) - add_subdirectory(qdbuscpp2xml) +@@ -12,7 +12,6 @@ if (QT_FEATURE_dbus) add_subdirectory(qdbusxml2cpp) endif() --add_subdirectory(qlalr) + add_subdirectory(qlalr) -add_subdirectory(qvkgen) if (QT_FEATURE_commandlineparser) add_subdirectory(qtpaths) diff --git a/depends/patches/qt/static_fixes.patch b/depends/patches/qt/static_fixes.patch index 1ae3f5774a..317dd3608d 100644 --- a/depends/patches/qt/static_fixes.patch +++ b/depends/patches/qt/static_fixes.patch @@ -78,3 +78,13 @@ index e8fb442dd43..e964138115c 100644 CODE "// xkb.h is using a variable called 'explicit', which is a reserved keyword in C++ #define explicit dont_use_cxx_explicit +--- a/qtbase/src/plugins/platforms/xcb/CMakeLists.txt ++++ b/qtbase/src/plugins/platforms/xcb/CMakeLists.txt +@@ -65,6 +65,7 @@ qt_internal_add_module(XcbQpaPrivate + XCB::XFIXES + XCB::XKB + XKB::XKB ++ $ + NO_UNITY_BUILD # X11 define clashes + ) + diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 524c28165c..be9dd7069a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -398,6 +398,7 @@ endif() if(BUILD_GUI) add_subdirectory(qt) + add_subdirectory(qml) endif() diff --git a/src/logging/timer.h b/src/logging/timer.h index 2b183822e9..1706c9e71b 100644 --- a/src/logging/timer.h +++ b/src/logging/timer.h @@ -99,13 +99,13 @@ class Timer #define LOG_TIME_MICROS_WITH_CATEGORY(end_msg, log_category) \ - BCLog::Timer UNIQUE_NAME(logging_timer)(__func__, end_msg, log_category) + BCLog::Timer BITCOIN_UNIQUE_NAME(logging_timer)(__func__, end_msg, log_category) #define LOG_TIME_MILLIS_WITH_CATEGORY(end_msg, log_category) \ - BCLog::Timer UNIQUE_NAME(logging_timer)(__func__, end_msg, log_category) + BCLog::Timer BITCOIN_UNIQUE_NAME(logging_timer)(__func__, end_msg, log_category) #define LOG_TIME_MILLIS_WITH_CATEGORY_MSG_ONCE(end_msg, log_category) \ - BCLog::Timer UNIQUE_NAME(logging_timer)(__func__, end_msg, log_category, /* msg_on_completion=*/false) + BCLog::Timer BITCOIN_UNIQUE_NAME(logging_timer)(__func__, end_msg, log_category, /* msg_on_completion=*/false) #define LOG_TIME_SECONDS(end_msg) \ - BCLog::Timer UNIQUE_NAME(logging_timer)(__func__, end_msg) + BCLog::Timer BITCOIN_UNIQUE_NAME(logging_timer)(__func__, end_msg) #endif // BITCOIN_LOGGING_TIMER_H diff --git a/src/qml/.gitignore b/src/qml/.gitignore new file mode 100644 index 0000000000..17eb6eecef --- /dev/null +++ b/src/qml/.gitignore @@ -0,0 +1 @@ +/moc_*.cpp diff --git a/src/qml/CMakeLists.txt b/src/qml/CMakeLists.txt new file mode 100644 index 0000000000..7268341dfa --- /dev/null +++ b/src/qml/CMakeLists.txt @@ -0,0 +1,115 @@ +# Copyright (c) 2025-present The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/license/mit/. + +set(CMAKE_AUTOMOC ON) + +option(ENABLE_TEST_AUTOMATION "Enable test automation bridge for QML UI testing" OFF) + +set(QML_QRC "${CMAKE_CURRENT_SOURCE_DIR}/bitcoin_qml.qrc") +qt6_add_resources(QML_QRC_CPP ${QML_QRC}) + +file(GLOB_RECURSE QML_SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/*.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/*.h" +) +list(FILTER QML_SOURCES EXCLUDE REGEX "/main\\.cpp$") +list(FILTER QML_SOURCES EXCLUDE REGEX "/androidnotifier\\.(cpp|h)$") +if(NOT ENABLE_TEST_AUTOMATION) + list(FILTER QML_SOURCES EXCLUDE REGEX "/test/") +endif() +list(APPEND QML_SOURCES ${QML_QRC_CPP}) + +add_library(bitcoinqml STATIC ${QML_SOURCES}) + +target_compile_definitions(bitcoinqml + PUBLIC + QT_NO_KEYWORDS + QT_USE_QSTRINGBUILDER +) +if(ENABLE_TEST_AUTOMATION) + target_compile_definitions(bitcoinqml PUBLIC ENABLE_TEST_AUTOMATION) +endif() + +target_include_directories(bitcoinqml + PUBLIC + ${PROJECT_SOURCE_DIR}/src + ${PROJECT_BINARY_DIR}/src +) + +target_link_libraries(bitcoinqml + PUBLIC + core_interface + bitcoin_node + univalue + Boost::headers + $ + Qt6::Qml + Qt6::Quick + Qt6::QuickControls2 + Qt6::Network + Qt6::Widgets +) + +# Compile QML-app-specific translations and embed them as resources. +qt6_add_translation(QML_GUI_QM_FILES + ${CMAKE_CURRENT_SOURCE_DIR}/locale/bitcoin_qml_es.ts +) +set_source_files_properties(${QML_GUI_QM_FILES} PROPERTIES + QT_RESOURCE_ALIAS "bitcoin_qml_es.qm" +) +qt6_add_resources(bitcoinqml "qml_gui_translations" + PREFIX "/translations" + FILES ${QML_GUI_QM_FILES} +) + +# Embed bitcoin-qt translations built by the Qt subdirectory. +file(GLOB BITCOIN_QT_TS_FILES CONFIGURE_DEPENDS + "${PROJECT_SOURCE_DIR}/src/qt/locale/bitcoin_*.ts" +) +set(BITCOIN_QT_QM_FILES) +foreach(ts_file IN LISTS BITCOIN_QT_TS_FILES) + get_filename_component(locale_name "${ts_file}" NAME_WE) + set(qm_file "${PROJECT_BINARY_DIR}/src/qt/locale/${locale_name}.qm") + set_source_files_properties(${qm_file} PROPERTIES + GENERATED TRUE + QT_RESOURCE_ALIAS "${locale_name}.qm" + ) + list(APPEND BITCOIN_QT_QM_FILES ${qm_file}) +endforeach() +qt6_add_resources(bitcoinqml "bitcoin_qt_translations" + PREFIX "/translations" + FILES ${BITCOIN_QT_QM_FILES} +) +add_dependencies(bitcoinqml bitcoin-qt) + +add_executable(bitcoin-qml WIN32 + main.cpp + ${PROJECT_SOURCE_DIR}/src/init/bitcoin-qt.cpp +) + +# TODO: Switch to own resource file. +add_windows_resources(bitcoin-qml ${PROJECT_SOURCE_DIR}/src/qt/res/bitcoin-qt-res.rc) +add_windows_application_manifest(bitcoin-qml) + +target_link_libraries(bitcoin-qml + PRIVATE + bitcoinqml + bitcoinqt +) + +install_binary_component(bitcoin-qml) + +if(BUILD_GUI_TESTS) + add_subdirectory(test) +endif() + +# Use the `bitcoin-qml` target as a drop-in replacement +# for the legacy QtWidgets-based `bitcoin-qt`. +set_target_properties(bitcoin-qt PROPERTIES + OUTPUT_NAME "bitcoin-qt-legacy" + EXCLUDE_FROM_ALL TRUE +) +set_target_properties(bitcoin-qml PROPERTIES + OUTPUT_NAME "bitcoin-qt" +) diff --git a/src/qml/androidnotifier.cpp b/src/qml/androidnotifier.cpp new file mode 100644 index 0000000000..509c579aba --- /dev/null +++ b/src/qml/androidnotifier.cpp @@ -0,0 +1,107 @@ +// 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. + +#include + +#include + +extern "C" { + JNIEXPORT jboolean JNICALL Java_org_bitcoincore_qt_BitcoinQtService_register(JNIEnv *env, jobject obj); +} + +static JavaVM * g_vm = nullptr; +static jobject g_obj; + +JNIEXPORT jboolean JNICALL Java_org_bitcoincore_qt_BitcoinQtService_register(JNIEnv * env, jobject obj) +{ + env->GetJavaVM(&g_vm); + g_obj = env->NewGlobalRef(obj); + + return (jboolean) true; +} + +namespace { + +JNIEnv* getJNIEnv(JavaVM* javaVM) { + JNIEnv* env; + jint result = javaVM->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6); + + if (result == JNI_EDETACHED) { + javaVM->AttachCurrentThread(&env, nullptr); + } else if (result != JNI_OK) { + // Error handling + return nullptr; + } + + return env; +} + +} + +AndroidNotifier::AndroidNotifier(const NodeModel & node_model, + QObject * parent) +: QObject(parent) +, m_node_model(node_model) +{ + QObject::connect(&node_model, &NodeModel::blockTipHeightChanged, + this, &AndroidNotifier::onBlockTipHeightChanged); + QObject::connect(&node_model, &NodeModel::numPeersChanged, + this, &AndroidNotifier::onNumOutboundPeersChanged); + QObject::connect(&node_model, &NodeModel::pauseChanged, + this, &AndroidNotifier::onPausedChanged); + QObject::connect(&node_model, &NodeModel::verificationProgressChanged, + this, &AndroidNotifier::onVerificationProgressChanged); +} + +void AndroidNotifier::onBlockTipHeightChanged() +{ + if (g_vm != nullptr) { + JNIEnv * env = getJNIEnv(g_vm); + if (env == nullptr) { + return; + } + jclass clazz = env->GetObjectClass(g_obj); + jmethodID mid = env->GetMethodID(clazz, "updateBlockTipHeight", "(I)V"); + env->CallVoidMethod(g_obj, mid, m_node_model.blockTipHeight()); + } +} + +void AndroidNotifier::onNumOutboundPeersChanged() +{ + if (g_vm != nullptr) { + JNIEnv * env = getJNIEnv(g_vm); + if (env == nullptr) { + return; + } + jclass clazz = env->GetObjectClass(g_obj); + jmethodID mid = env->GetMethodID(clazz, "updateNumberOfPeers", "(I)V"); + env->CallVoidMethod(g_obj, mid, m_node_model.numPeers()); + } +} + +void AndroidNotifier::onVerificationProgressChanged() +{ + if (g_vm != nullptr) { + JNIEnv * env = getJNIEnv(g_vm); + if (env == nullptr) { + return; + } + jclass clazz = env->GetObjectClass(g_obj); + jmethodID mid = env->GetMethodID(clazz, "updateVerificationProgress", "(D)V"); + env->CallVoidMethod(g_obj, mid, static_cast(m_node_model.verificationProgress())); + } +} + +void AndroidNotifier::onPausedChanged() +{ + if (g_vm != nullptr) { + JNIEnv * env = getJNIEnv(g_vm); + if (env == nullptr) { + return; + } + jclass clazz = env->GetObjectClass(g_obj); + jmethodID mid = env->GetMethodID(clazz, "updatePaused", "(Z)V"); + env->CallVoidMethod(g_obj, mid, static_cast(m_node_model.pause())); + } +} diff --git a/src/qml/androidnotifier.h b/src/qml/androidnotifier.h new file mode 100644 index 0000000000..54642bf05b --- /dev/null +++ b/src/qml/androidnotifier.h @@ -0,0 +1,26 @@ +#ifndef BITCOIN_QML_ANDROIDNOTIFIER_H +#define BITCOIN_QML_ANDROIDNOTIFIER_H + +#include + +#include +#include + +class AndroidNotifier : public QObject +{ + Q_OBJECT + +public: + explicit AndroidNotifier(const NodeModel & node_model, QObject * parent = nullptr); + +public Q_SLOTS: + void onBlockTipHeightChanged(); + void onNumOutboundPeersChanged(); + void onVerificationProgressChanged(); + void onPausedChanged(); + +private: + const NodeModel & m_node_model; +}; + +#endif // BITCOIN_QML_ANDROIDNOTIFIER_H diff --git a/src/qml/appmode.h b/src/qml/appmode.h new file mode 100644 index 0000000000..ba15865eda --- /dev/null +++ b/src/qml/appmode.h @@ -0,0 +1,55 @@ +// Copyright (c) 2022 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QML_APPMODE_H +#define BITCOIN_QML_APPMODE_H + +#include + +class AppMode : public QObject +{ + Q_OBJECT + Q_PROPERTY(bool isDesktop READ isDesktop NOTIFY modeChanged) + Q_PROPERTY(bool isMobile READ isMobile NOTIFY modeChanged) + Q_PROPERTY(bool walletEnabled READ walletEnabled NOTIFY walletEnabledChanged) + Q_PROPERTY(QString state READ state NOTIFY modeChanged) + +public: + enum Mode { + DESKTOP, + MOBILE + }; + + explicit AppMode(Mode mode, bool wallet_enabled) + : m_mode(mode) + , m_wallet_enabled(wallet_enabled) + { + } + + bool isMobile() { return m_mode == MOBILE; } + bool isDesktop() { return m_mode == DESKTOP; } + bool walletEnabled() { return m_wallet_enabled; } + QString state() + { + switch (m_mode) { + case MOBILE: + return "MOBILE"; + case DESKTOP: + return "DESKTOP"; + default: + return "DESKTOP"; + } + } + Mode mode() const { return m_mode; } + +Q_SIGNALS: + void modeChanged(); + void walletEnabledChanged(); + +private: + const Mode m_mode; + const bool m_wallet_enabled; +}; + +#endif // BITCOIN_QML_APPMODE_H diff --git a/src/qml/bitcoin.cpp b/src/qml/bitcoin.cpp new file mode 100644 index 0000000000..812d20c960 --- /dev/null +++ b/src/qml/bitcoin.cpp @@ -0,0 +1,547 @@ +// 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. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef __ANDROID__ +#include +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef ENABLE_TEST_AUTOMATION +#include +#endif +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +QT_BEGIN_NAMESPACE +class QMessageLogContext; +QT_END_NAMESPACE + +#if defined(QT_STATICPLUGIN) +#include +Q_IMPORT_PLUGIN(QtQmlPlugin) +Q_IMPORT_PLUGIN(QtQmlModelsPlugin) +Q_IMPORT_PLUGIN(QtQuick2DialogsPlugin) +Q_IMPORT_PLUGIN(QtQuick2DialogsPrivatePlugin) +Q_IMPORT_PLUGIN(QtQuick2Plugin) +Q_IMPORT_PLUGIN(QtQuick2WindowPlugin) +Q_IMPORT_PLUGIN(QtQuickControls1Plugin) +Q_IMPORT_PLUGIN(QmlFolderListModelPlugin) +Q_IMPORT_PLUGIN(QmlSettingsPlugin) +Q_IMPORT_PLUGIN(QtQuickLayoutsPlugin) +Q_IMPORT_PLUGIN(QtQuickControls2Plugin) +Q_IMPORT_PLUGIN(QtQuickTemplates2Plugin) +#endif + +namespace { +void SetupUIArgs(ArgsManager& argsman) +{ + argsman.AddArg("-lang=", "Set language, for example \"de_DE\" (default: system locale)", ArgsManager::ALLOW_ANY, OptionsCategory::GUI); + argsman.AddArg("-min", "Start minimized", ArgsManager::ALLOW_ANY, OptionsCategory::GUI); + argsman.AddArg("-resetguisettings", "Reset all settings changed in the GUI", ArgsManager::ALLOW_ANY, OptionsCategory::GUI); +#ifdef ENABLE_TEST_AUTOMATION + argsman.AddArg("-test-automation=", "Enable test automation bridge on the given Unix socket path", ArgsManager::ALLOW_ANY, OptionsCategory::GUI); + argsman.AddArg("-test-settings-dir=", "Store QSettings in this directory while test automation is enabled", ArgsManager::ALLOW_ANY, OptionsCategory::GUI); +#endif +} + +AppMode SetupAppMode() +{ + bool wallet_enabled; + AppMode::Mode mode; + #ifdef __ANDROID__ + mode = AppMode::MOBILE; + #else + mode = AppMode::DESKTOP; + #endif // __ANDROID__ + + #ifdef ENABLE_WALLET + wallet_enabled = !gArgs.GetBoolArg("-disablewallet", false); + #else + wallet_enabled = false; + #endif // ENABLE_WALLET + + return AppMode(mode, wallet_enabled); +} + +bool InitErrorMessageBox( + const bilingual_str& message, + [[maybe_unused]] const std::string& caption, + [[maybe_unused]] unsigned int style) +{ + QQmlApplicationEngine engine; + + AppMode app_mode = SetupAppMode(); + BuildInfo build_info; + + qmlRegisterSingletonInstance("org.bitcoincore.qt", 1, 0, "AppMode", &app_mode); + qmlRegisterSingletonInstance("org.bitcoincore.qt", 1, 0, "BuildInfo", &build_info); + engine.rootContext()->setContextProperty("message", QString::fromStdString(message.translated)); + engine.load(QUrl(QStringLiteral("qrc:///qml/pages/initerrormessage.qml"))); + if (engine.rootObjects().isEmpty()) { + return EXIT_FAILURE; + } + qGuiApp->exec(); + return false; +} + +void RecordStartupWarning(QStringList& startup_warnings, const bilingual_str& message) +{ + const QString warning{QString::fromStdString(message.translated).trimmed()}; + if (!warning.isEmpty() && !startup_warnings.contains(warning)) { + startup_warnings.push_back(warning); + } +} + +/* qDebug() message handler --> debug.log */ +void DebugMessageHandler(QtMsgType type, const QMessageLogContext& context, const QString& msg) +{ + Q_UNUSED(context); + if (type == QtDebugMsg) { + LogDebug(BCLog::QT, "GUI: %s\n", msg.toStdString()); + } else { + LogPrintf("GUI: %s\n", msg.toStdString()); + } +} + +bool ConfigurationFileExists(ArgsManager& argsman) +{ + fs::path settings_path; + if (!argsman.GetSettingsPath(&settings_path)) { + // settings file is disabled + return true; + } + if (fs::exists(settings_path)) { + return true; + } + + const fs::path rel_config_path = argsman.GetPathArg("-conf", BITCOIN_CONF_FILENAME); + const fs::path abs_config_path = AbsPathForConfigVal(argsman, rel_config_path, true); + if (fs::exists(abs_config_path)) { + return true; + } + + return false; +} + +void setupChainQSettings(QGuiApplication* app, QString chain) +{ + if (chain.compare("MAIN") == 0) { + app->setApplicationName(QAPP_APP_NAME_DEFAULT); + } else if (chain.compare("TEST") == 0) { + app->setApplicationName(QAPP_APP_NAME_TESTNET); + } else if (chain.compare("SIGNET") == 0) { + app->setApplicationName(QAPP_APP_NAME_SIGNET); + } else if (chain.compare("REGTEST") == 0) { + app->setApplicationName(QAPP_APP_NAME_REGTEST); + } +} + +void LoadFontResource(const QString& path) +{ + if (QFontDatabase::addApplicationFont(path) < 0) { + qWarning() << "Failed to load font resource:" << path; + } +} + +#ifdef ENABLE_TEST_AUTOMATION +void ApplyTestSettingsDir() +{ + const std::string settings_dir{gArgs.GetArg("-test-settings-dir", "")}; + if (settings_dir.empty()) { + return; + } + + QSettings::setDefaultFormat(QSettings::IniFormat); + QSettings::setPath(QSettings::IniFormat, QSettings::UserScope, QString::fromStdString(settings_dir)); +} +#endif +} // namespace + + +int QmlGuiMain(int argc, char* argv[]) +{ +#ifdef WIN32 + common::WinCmdLineArgs winArgs; + std::tie(argc, argv) = winArgs.get(); +#endif // WIN32 + + Q_INIT_RESOURCE(bitcoin_qml); + qRegisterMetaType("interfaces::BlockAndHeaderTipInfo"); + + QGuiApplication::styleHints()->setTabFocusBehavior(Qt::TabFocusAllControls); + QGuiApplication app(argc, argv); + QQuickStyle::setStyle(QStringLiteral("Basic")); + + std::unique_ptr init = interfaces::MakeGuiInit(argc, argv); + QStringList startup_warnings; + auto handler_message_box = ::uiInterface.ThreadSafeMessageBox_connect( + [&startup_warnings](const bilingual_str& message, const std::string& caption, unsigned int style) { + if (style & CClientUIInterface::ICON_WARNING) { + RecordStartupWarning(startup_warnings, message); + return false; + } + return InitErrorMessageBox(message, caption, style); + }); + + SetupEnvironment(); + util::ThreadSetInternalName("main"); + + // must be set before parsing command-line options; otherwise, + // if invalid parameters were passed, QSetting initialization would fail + // and the error will be displayed on terminal. + // must be set before OptionsModel is initialized or translations are loaded, + // as it is used to locate QSettings + app.setOrganizationName(QAPP_ORG_NAME); + app.setOrganizationDomain(QAPP_ORG_DOMAIN); + app.setApplicationName(QAPP_APP_NAME_DEFAULT); + + // Manages translators swapped on runtime language changes. + // app_translator: bitcoin-qt strings (shared C++ layer) + // qml_translator: QML-app-specific strings + std::unique_ptr app_translator; + std::unique_ptr qml_translator; + const auto reset_translator = [](std::unique_ptr& t) { + if (t) { QCoreApplication::removeTranslator(t.get()); t.reset(); } + }; + const auto install_language = [&](const QString& lang) { + reset_translator(app_translator); + reset_translator(qml_translator); + if (!lang.isEmpty()) { + auto t = std::make_unique(); + if (t->load(QStringLiteral(":/translations/bitcoin_%1.qm").arg(lang))) + { QCoreApplication::installTranslator(t.get()); app_translator = std::move(t); } + + auto tq = std::make_unique(); + if (tq->load(QStringLiteral(":/translations/bitcoin_qml_%1.qm").arg(lang))) + { QCoreApplication::installTranslator(tq.get()); qml_translator = std::move(tq); } + } + }; + + // Parse command-line options. We do this after qt in order to show an error if there are problems parsing these. + SetupServerArgs(gArgs, init->canListenIpc()); + + SetupUIArgs(gArgs); + std::string error; + if (!gArgs.ParseParameters(argc, argv, error)) { + InitError(Untranslated(strprintf("Cannot parse command line arguments: %s\n", error))); + return EXIT_FAILURE; + } +#ifdef ENABLE_TEST_AUTOMATION + ApplyTestSettingsDir(); +#endif + + if (auto error = common::InitConfig( + gArgs, + [](const bilingual_str& msg, const std::vector& details) { + return InitError(msg, details); + })) { + return EXIT_FAILURE; + } + + // legacy GUI: parameterSetup() + // Default printtoconsole to false for the GUI. GUI programs should not + // print to the console unnecessarily. + gArgs.SoftSetBoolArg("-printtoconsole", false); + InitLogging(gArgs); + InitParameterInteraction(gArgs); + + QVariant need_onboarding(true); + if (gArgs.IsArgSet("-datadir") && !gArgs.GetPathArg("-datadir").empty()) { + need_onboarding.setValue(false); + } else if (ConfigurationFileExists(gArgs)) { + need_onboarding.setValue(false); + } + + if (gArgs.IsArgSet("-resetguisettings")) { + need_onboarding.setValue(true); + } + + // legacy GUI: createNode() + std::unique_ptr node = init->makeNode(); + std::unique_ptr chain = init->makeChain(); + + // legacy GUI: baseInitialize() + if (!node->baseInitialize()) { + // A dialog with detailed error will have been shown by InitError(). + return EXIT_FAILURE; + } + + handler_message_box.disconnect(); + + AppMode app_mode = SetupAppMode(); +#ifdef ENABLE_WALLET + const bool wallet_enabled = app_mode.walletEnabled(); +#endif + + NodeModel node_model{*node}; + node_model.addStartupWarnings(startup_warnings); + QmlInitExecutor init_executor{*node}; +#ifdef ENABLE_WALLET + std::unique_ptr wallet_controller; + if (wallet_enabled) { + wallet_controller = std::make_unique(*node); + QObject::connect(&init_executor, &QmlInitExecutor::initializeResult, wallet_controller.get(), [wallet_controller = wallet_controller.get()](bool success) { + if (success) { + wallet_controller->initialize(); + } + }); + } +#endif + QObject::connect(&node_model, &NodeModel::requestedInitialize, &init_executor, &QmlInitExecutor::initialize); + bool shutdown_requested{false}; + QObject::connect(&node_model, &NodeModel::requestedShutdown, [&] { + if (shutdown_requested) { + return; + } + shutdown_requested = true; +#ifdef ENABLE_WALLET + if (wallet_controller) { + wallet_controller->unloadWallets(); + } +#endif + node->startShutdown(); + init_executor.shutdown(); + }); + QObject::connect(&init_executor, &QmlInitExecutor::initializeResult, &node_model, &NodeModel::initializeResult); + QObject::connect(&init_executor, &QmlInitExecutor::shutdownResult, qGuiApp, &QGuiApplication::quit, Qt::QueuedConnection); + QObject::connect(&init_executor, &QmlInitExecutor::runawayException, &node_model, &NodeModel::handleRunawayException); + + NetworkTrafficTower network_traffic_tower{node_model}; + NetworkStatusModel network_status_model; +#ifdef __ANDROID__ + AndroidNotifier android_notifier{node_model}; +#endif + + ChainModel chain_model{*chain}; + chain_model.setCurrentNetworkName(QString::fromStdString(gArgs.GetChainTypeString())); + setupChainQSettings(&app, chain_model.currentNetworkName()); + + QObject::connect(&node_model, &NodeModel::setTimeRatioList, &chain_model, &ChainModel::setTimeRatioList); + QObject::connect(&node_model, &NodeModel::setTimeRatioListInitial, &chain_model, &ChainModel::setTimeRatioListInitial); + + + qGuiApp->setQuitOnLastWindowClosed(false); + QObject::connect(qGuiApp, &QGuiApplication::lastWindowClosed, [&] { + node_model.requestShutdown(); + }); + + PeerListModel peer_model{*node, nullptr}; + PeerListSortProxy peer_model_sort_proxy{nullptr}; + peer_model_sort_proxy.setSourceModel(&peer_model); + + BanListModel ban_list_model{*node, nullptr}; + QObject::connect(&node_model, &NodeModel::bannedListChanged, + &ban_list_model, &BanListModel::refresh); + QObject::connect(&node_model, &NodeModel::nodeInitialized, + &ban_list_model, &BanListModel::refresh); + + LoadFontResource(":/fonts/bitcoincoresans/regular"); + LoadFontResource(":/fonts/bitcoincoresans/semibold"); + LoadFontResource(":/fonts/robotomono/regular"); + + QQmlApplicationEngine engine; + + QScopedPointer network_style{NetworkStyle::instantiate(Params().GetChainType())}; + assert(!network_style.isNull()); + engine.addImageProvider(QStringLiteral("images"), new ImageProvider{network_style.data()}); + engine.addImageProvider(QStringLiteral("qr"), new QRImageProvider); + + engine.rootContext()->setContextProperty("networkTrafficTower", &network_traffic_tower); + engine.rootContext()->setContextProperty("networkStatusModel", &network_status_model); + engine.rootContext()->setContextProperty("nodeModel", &node_model); + engine.rootContext()->setContextProperty("chainModel", &chain_model); + engine.rootContext()->setContextProperty("peerTableModel", &peer_model); + engine.rootContext()->setContextProperty("peerListModelProxy", &peer_model_sort_proxy); + engine.rootContext()->setContextProperty("banListModel", &ban_list_model); + + DebugLogModel debug_log_model{gArgs.GetDataDirNet() / "debug.log"}; + engine.rootContext()->setContextProperty("debugLogModel", &debug_log_model); + +#ifdef ENABLE_WALLET + std::unique_ptr wallet_list_model; + if (wallet_enabled) { + wallet_list_model = std::make_unique(*node, nullptr); + QObject::connect(wallet_controller.get(), &WalletQmlController::walletLoadStateChanged, + wallet_list_model.get(), &WalletListModel::setWalletLoadState); + QObject::connect(wallet_controller.get(), &WalletQmlController::walletInfoChanged, + wallet_list_model.get(), &WalletListModel::setWalletInfo); + QObject::connect(wallet_controller.get(), &WalletQmlController::walletDisplayNamesChanged, + wallet_list_model.get(), &WalletListModel::refreshDisplayNames); + QObject::connect(wallet_list_model.get(), &WalletListModel::walletListChanged, + wallet_controller.get(), [controller = wallet_controller.get()](bool has_wallets) { + controller->setNoWalletsFound(!has_wallets); + }); + // listWalletDir() rebuilds m_items from scratch — any per-row info + // pushed earlier (e.g. at controller initialize() time, before the + // picker was ever opened) has nowhere to land. Re-publish open wallets' + // info every time the model resets so newly created rows pick it up. + QObject::connect(wallet_list_model.get(), &QAbstractItemModel::modelReset, + wallet_controller.get(), &WalletQmlController::publishOpenWalletsInfo); + QObject::connect(wallet_controller.get(), &WalletQmlController::initializedChanged, + wallet_list_model.get(), [controller = wallet_controller.get(), list_model = wallet_list_model.get()]() { + if (controller->initialized()) { + list_model->listWalletDir(); + } + }); + engine.rootContext()->setContextProperty("walletController", wallet_controller.get()); + engine.rootContext()->setContextProperty("walletListModel", wallet_list_model.get()); + } +#endif + + OptionsQmlModel options_model(*node, !need_onboarding.toBool()); + engine.rootContext()->setContextProperty("optionsModel", &options_model); + engine.rootContext()->setContextProperty("needOnboarding", need_onboarding); +#ifdef ENABLE_TEST_AUTOMATION + engine.rootContext()->setContextProperty("testAutomationEnabled", true); +#else + engine.rootContext()->setContextProperty("testAutomationEnabled", false); +#endif + + // -lang CLI flag overrides the persisted setting (bitcoin-qt compatibility). + // Must be after gArgs.ParseParameters() and after setupChainQSettings() so + // QSettings targets the correct chain-specific file. + // Unlike bitcoin-qt which treats -lang as session-only, this persists the + // selection so subsequent launches continue using the CLI-specified language. + // To reset to system default, use the Settings UI or pass -lang= (empty). + const QString cli_lang = QString::fromStdString(gArgs.GetArg("-lang", "")); + if (!cli_lang.isEmpty()) { + options_model.setLanguage(cli_lang); + } + + // Install language before QML engine loads so that all qsTr() calls in QML + // pick up the correct locale from the start. + install_language(options_model.language()); + + // Retranslate the QML UI immediately when the user picks a new language. + QObject::connect(&options_model, &OptionsQmlModel::languageChanged, [&]() { + install_language(options_model.language()); + engine.retranslate(); + }); + + BuildInfo build_info; + Clipboard clipboard; + BitcoinUriModel bitcoin_uri_model; + + qmlRegisterSingletonInstance("org.bitcoincore.qt", 1, 0, "AppMode", &app_mode); + qmlRegisterSingletonInstance("org.bitcoincore.qt", 1, 0, "BuildInfo", &build_info); + qmlRegisterSingletonInstance("org.bitcoincore.qt", 1, 0, "Clipboard", &clipboard); + qmlRegisterSingletonInstance("org.bitcoincore.qt", 1, 0, "BitcoinUri", &bitcoin_uri_model); + qmlRegisterType("org.bitcoincore.qt", 1, 0, "BlockClockDial"); + qmlRegisterType("org.bitcoincore.qt", 1, 0, "LineGraph"); + qmlRegisterUncreatableType("org.bitcoincore.qt", 1, 0, "PeerDetailsModel", ""); + qmlRegisterUncreatableType("org.bitcoincore.qt", 1, 0, "DebugLogModel", ""); + qmlRegisterType("org.bitcoincore.qt", 1, 0, "BitcoinAmount"); + qmlRegisterType("org.bitcoincore.qt", 1, 0, "BitcoinAddress"); + qmlRegisterType("org.bitcoincore.qt", 1, 0, "ActivityFilterProxyModel"); + qmlRegisterUncreatableType("org.bitcoincore.qt", 1, 0, "AddressListModel", ""); + qmlRegisterType("org.bitcoincore.qt", 1, 0, "PaymentRequest"); + qmlRegisterUncreatableType("org.bitcoincore.qt", 1, 0, "Transaction", ""); + qmlRegisterUncreatableType("org.bitcoincore.qt", 1, 0, "SendRecipient", ""); + +#ifdef ENABLE_WALLET + qmlRegisterUncreatableType("org.bitcoincore.qt", 1, 0, "BumpTransactionModel", + "BumpTransactionModel cannot be instantiated from QML"); + qmlRegisterUncreatableType("org.bitcoincore.qt", 1, 0, "WalletQmlModel", + "WalletQmlModel cannot be instantiated from QML"); + qmlRegisterUncreatableType("org.bitcoincore.qt", 1, 0, "WalletQmlModelTransaction", + "WalletQmlModelTransaction cannot be instantiated from QML"); + qmlRegisterUncreatableType("org.bitcoincore.qt", 1, 0, "WalletListModel", + "WalletListModel cannot be instantiated from QML"); +#endif + + engine.load(QUrl(QStringLiteral("qrc:///qml/pages/main.qml"))); + if (engine.rootObjects().isEmpty()) { + return EXIT_FAILURE; + } + + auto window = qobject_cast(engine.rootObjects().first()); + if (!window) { + return EXIT_FAILURE; + } + +#ifdef ENABLE_TEST_AUTOMATION + std::unique_ptr test_bridge; + if (gArgs.IsArgSet("-test-automation")) { + QString socket_path = QString::fromStdString(gArgs.GetArg("-test-automation", "")); + if (socket_path.isEmpty()) { + // Default to a socket in the data directory. + socket_path = QString::fromStdString( + (gArgs.GetDataDirNet() / "test_bridge.sock").utf8string()); + } + test_bridge = std::make_unique(&engine, socket_path); + } +#endif + + // Install qDebug() message handler to route to debug.log + qInstallMessageHandler(DebugMessageHandler); + + qInfo() << "Graphics API in use:" << QmlUtil::GraphicsApi(window); + + node_model.startShutdownPolling(); + return qGuiApp->exec(); +} diff --git a/src/qml/bitcoin.h b/src/qml/bitcoin.h new file mode 100644 index 0000000000..77e7102e6d --- /dev/null +++ b/src/qml/bitcoin.h @@ -0,0 +1,10 @@ +// Copyright (c) 2021 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QML_BITCOIN_H +#define BITCOIN_QML_BITCOIN_H + +int QmlGuiMain(int argc, char* argv[]); + +#endif // BITCOIN_QML_BITCOIN_H diff --git a/src/qml/bitcoin_qml.qrc b/src/qml/bitcoin_qml.qrc new file mode 100644 index 0000000000..0c2d5ec6fc --- /dev/null +++ b/src/qml/bitcoin_qml.qrc @@ -0,0 +1,228 @@ + + + components/AboutOptions.qml + components/AddressDetailRow.qml + components/AlertAction.qml + components/AlertPopup.qml + components/BitcoinAddressDisplayField.qml + components/BitcoinAddressInputField.qml + components/BitcoinAmountDisplayField.qml + components/BitcoinAmountInputField.qml + components/BlockClock.qml + components/BlockClockDisplayMode.qml + components/BlockCounter.qml + components/ConnectionOptions.qml + components/ConnectionSettings.qml + components/MempoolInformationRows.qml + components/DeveloperOptions.qml + components/ExternalSignerReviewActions.qml + components/ExternalPopup.qml + components/FeeSelection.qml + components/MiniBlockClock.qml + components/MonospaceOutputView.qml + components/InfoBanner.qml + components/NetworkTrafficGraph.qml + components/NetworkIndicator.qml + components/NodeFatalErrorPopup.qml + components/NodeInformationPopup.qml + components/NodeRuntimeDialog.qml + components/NodeStatusActions.qml + components/NodeWarningsPopup.qml + components/OptionPopup.qml + components/PeersIndicator.qml + components/ProxySettings.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 + components/DetailEditRow.qml + components/PaymentDetailOptionsPopup.qml + components/QRCodePopup.qml + components/ReceiveOptionsPopup.qml + components/Tooltip.qml + components/LabeledValueField.qml + components/WalletSettings.qml + components/WalletMigrationPopup.qml + components/WalletPassphrasePopup.qml + controls/AddWalletButton.qml + controls/CaretRightIcon.qml + controls/ContinueButton.qml + controls/CoreCheckBox.qml + controls/CoreText.qml + controls/CoreTextField.qml + controls/EditableKeyValueRow.qml + controls/ExternalLink.qml + controls/FocusBorder.qml + controls/Header.qml + controls/Icon.qml + controls/IconButton.qml + controls/InformationPage.qml + controls/IPAddressValueInput.qml + controls/KeyValueRow.qml + controls/LabeledTextInput.qml + controls/LabeledCoinControlButton.qml + controls/EllipsisMenuButtonItem.qml + controls/EllipsisMenuToggleItem.qml + controls/NavButton.qml + controls/NavigationBar.qml + controls/NavigationBar2.qml + controls/NavigationTab.qml + controls/OptionButton.qml + controls/OptionSwitch.qml + controls/OutlineButton.qml + controls/PageIndicator.qml + controls/PageStack.qml + controls/ProgressIndicator.qml + controls/QRImage.qml + controls/qmldir + controls/ReceiveOptionsPopup.qml + controls/SendOptionsPopup.qml + controls/SegmentedPicker.qml + controls/Setting.qml + controls/Skeleton.qml + controls/SpinningIndicator.qml + controls/TextButton.qml + controls/Theme.qml + controls/ToggleButton.qml + controls/utils.js + controls/ValueInput.qml + controls/WalletTypeListItem.qml + pages/initerrormessage.qml + pages/main.qml + pages/node/BannedPeers.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 + pages/onboarding/OnboardingBlockclock.qml + pages/onboarding/OnboardingConnection.qml + pages/onboarding/OnboardingCover.qml + pages/onboarding/OnboardingStorageAmount.qml + pages/onboarding/OnboardingStorageLocation.qml + pages/onboarding/OnboardingStrengthen.qml + pages/onboarding/OnboardingWizard.qml + pages/settings/SettingsAbout.qml + pages/settings/SettingsDisplayUnit.qml + pages/settings/SettingsLanguage.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/wallet/Activity.qml + pages/wallet/ActivityDetails.qml + pages/wallet/ActivityTransactionVisuals.qml + pages/wallet/AddressList.qml + pages/wallet/AddressDetails.qml + pages/wallet/AddressRow.qml + pages/wallet/CoinSelection.qml + pages/wallet/CreateBackup.qml + pages/wallet/CreateConfirm.qml + pages/wallet/CreateExternalWallet.qml + pages/wallet/CreateIntro.qml + pages/wallet/CreateName.qml + pages/wallet/CreatePassword.qml + pages/wallet/ImportWalletOptions.qml + pages/wallet/ImportWalletSuccess.qml + pages/wallet/CreateTypeSelector.qml + pages/wallet/CreateWalletWizard.qml + pages/wallet/WatchOnlyIntro.qml + pages/wallet/WatchOnlyXpub.qml + pages/wallet/DesktopWallets.qml + pages/wallet/MultipleSendReview.qml + pages/wallet/PaymentRequestDetail.qml + pages/wallet/RequestPayment.qml + pages/wallet/Send.qml + pages/wallet/SendResult.qml + pages/wallet/SendReview.qml + pages/wallet/SpeedUpOverlay.qml + pages/wallet/SignVerifyMessage.qml + pages/wallet/WalletBadge.qml + pages/wallet/WalletPasswordSettings.qml + pages/wallet/WalletSettings.qml + pages/wallet/WalletSelect.qml + + + res/icons/add-wallet-dark.png + res/icons/alert-filled.png + res/icons/arrow-down.png + res/icons/arrow-up.png + res/icons/bitcoin-circle.png + res/icons/blockclock-icon.png + res/icons/blockclock-size-compact.png + res/icons/blockclock-size-showcase.png + res/icons/blocktime-dark.png + res/icons/blocktime-light.png + res/icons/bitcoin.png + res/icons/caret-down-medium-filled.png + res/icons/caret-left.png + res/icons/caret-right.png + res/icons/check.png + res/icons/copy.png + res/icons/circle-file.png + res/icons/circle-green-check.png + res/icons/circle-red-cross.png + res/icons/coinbase.png + res/icons/cross.png + res/icons/cross-filled.png + res/icons/devices-filled.png + res/icons/edit.png + res/icons/ellipsis.png + res/icons/error.png + res/icons/export.png + res/icons/file.png + res/icons/flip-vertical.png + res/icons/gear.png + res/icons/gear-outline.png + res/icons/green-check.png + res/icons/hidden.png + res/icons/info.png + res/icons/info-filled.png + res/icons/key.png + res/icons/key-filled.png + res/icons/lock.png + res/icons/minus.png + res/icons/network-dark.png + res/icons/network-light.png + res/icons/pending-dark.png + res/icons/pending-light.png + res/icons/plus.png + res/icons/plus-filled.png + res/icons/refresh.png + res/icons/qr-code.png + res/icons/plus-big-filled.png + res/icons/pending.png + res/icons/search.png + res/icons/share.png + res/icons/shutdown.png + res/icons/singlesig-wallet.png + res/icons/storage-dark.png + res/icons/storage-light.png + res/icons/tooltip-arrow-dark.png + res/icons/tooltip-arrow-light.png + res/icons/triangle-down.png + res/icons/triangle-up.png + res/icons/two-keys.png + res/icons/two-keys-filled.png + res/icons/wallet.png + res/icons/visible.png + res/icons/visible-filled.png + + + res/fonts/BitcoinCoreSans-Regular.otf + res/fonts/BitcoinCoreSans-SemiBold.otf + res/fonts/RobotoMono-Regular.ttf + + diff --git a/src/qml/bitcoinamount.cpp b/src/qml/bitcoinamount.cpp new file mode 100644 index 0000000000..1c5091d81e --- /dev/null +++ b/src/qml/bitcoinamount.cpp @@ -0,0 +1,186 @@ +// Copyright (c) 2024-2025 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include + +#include +#include + +BitcoinAmount::BitcoinAmount(QObject* parent) + : QObject(parent) +{ +} + +QString BitcoinAmount::sanitize(const QString &text) +{ + QString result = text; + + // Remove any invalid characters + result.remove(QRegularExpression("[^0-9.]")); + + // Ensure only one decimal point + QStringList parts = result.split('.'); + if (parts.size() > 2) { + result = parts[0] + "." + parts[1]; + } + + // Limit decimal places to 8 + if (parts.size() == 2 && parts[1].length() > 8) { + result = parts[0] + "." + parts[1].left(8); + } + + return result; +} + +qint64 BitcoinAmount::satoshi() const +{ + return m_satoshi; +} + +void BitcoinAmount::setSatoshi(qint64 new_amount) +{ + if (m_satoshi != new_amount || !m_isSet) { + const bool label_changes = (m_unit == Unit::SAT) && + ((qAbs(m_satoshi) == 1) != (qAbs(new_amount) == 1)); + m_isSet = true; + m_satoshi = new_amount; + if (label_changes) Q_EMIT unitChanged(); + Q_EMIT amountChanged(); + Q_EMIT displayChanged(); + Q_EMIT displayWithUnitChanged(); + } +} + +void BitcoinAmount::clear() +{ + if (!m_isSet && m_satoshi == 0) { + return; + } + const bool label_changes = (m_unit == Unit::SAT) && (qAbs(m_satoshi) == 1); + m_satoshi = 0; + m_isSet = false; + if (label_changes) Q_EMIT unitChanged(); + Q_EMIT amountChanged(); + Q_EMIT displayChanged(); + Q_EMIT displayWithUnitChanged(); +} + +BitcoinAmount::Unit BitcoinAmount::unit() const +{ + return m_unit; +} + +void BitcoinAmount::setUnit(const Unit unit) +{ + m_unit = unit; + Q_EMIT unitChanged(); + Q_EMIT displayChanged(); + Q_EMIT displayWithUnitChanged(); +} + +QString BitcoinAmount::unitLabel() const +{ + switch (m_unit) { + case Unit::BTC: return QStringLiteral("₿"); + case Unit::SAT: + return (qAbs(m_satoshi) == 1) + ? tr("sat", "unit label, singular") + : tr("sats", "unit label, plural"); + } + assert(false); +} + +void BitcoinAmount::flipUnit() +{ + if (m_unit == Unit::BTC) { + m_unit = Unit::SAT; + } else { + m_unit = Unit::BTC; + } + Q_EMIT unitChanged(); + Q_EMIT displayChanged(); + Q_EMIT displayWithUnitChanged(); +} + +QString BitcoinAmount::satsToBtcString(qint64 sat) +{ + const bool negative = sat < 0; + qint64 absSat = negative ? -sat : sat; + + const qint64 wholePart = absSat / COIN; + const qint64 fracInt = absSat % COIN; + QString fracPart = QString("%1").arg(fracInt, 8, 10, QLatin1Char('0')); + + QString result = QString::number(wholePart) + '.' + fracPart; + if (negative) { + result.prepend('-'); + } + return result; +} + +QString BitcoinAmount::toDisplay() const +{ + if (!m_isSet) { + return ""; + } + if (m_unit == Unit::SAT) { + return QString::number(m_satoshi); + } else { + return satsToBtcString(m_satoshi); + } +} + +QString BitcoinAmount::displayWithUnit() const +{ + const QString display{toDisplay()}; + return display.isEmpty() ? QString{} : display + QStringLiteral(" ") + unitLabel(); +} + +qint64 BitcoinAmount::btcToSats(const QString& btcSanitized) +{ + if (btcSanitized.isEmpty() || btcSanitized == ".") return 0; + + QString cleaned = btcSanitized; + if (cleaned.startsWith('.')) cleaned.prepend('0'); + + QStringList parts = cleaned.split('.'); + const qint64 whole = parts[0].isEmpty() ? 0 : parts[0].toLongLong(); + qint64 frac = 0; + if (parts.size() == 2) { + frac = parts[1].leftJustified(8, '0').toLongLong(); + } + + if (whole > std::numeric_limits::max() / COIN) { + return std::numeric_limits::max(); + } + + return whole * COIN + frac; +} + +void BitcoinAmount::fromDisplay(const QString& text) +{ + if (text.trimmed().isEmpty()) { + clear(); + return; + } + + qint64 newSat = 0; + if (m_unit == Unit::BTC) { + QString sanitized = sanitize(text); + newSat = btcToSats(sanitized); + } else { + QString digitsOnly = text; + digitsOnly.remove(QRegularExpression("[^0-9]")); + newSat = digitsOnly.trimmed().isEmpty() ? 0 : digitsOnly.toLongLong(); + } + setSatoshi(newSat); +} + +void BitcoinAmount::format() +{ + Q_EMIT displayChanged(); + Q_EMIT displayWithUnitChanged(); +} diff --git a/src/qml/bitcoinamount.h b/src/qml/bitcoinamount.h new file mode 100644 index 0000000000..9cc1f5c483 --- /dev/null +++ b/src/qml/bitcoinamount.h @@ -0,0 +1,66 @@ +// Copyright (c) 2024-2025 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QML_BITCOINAMOUNT_H +#define BITCOIN_QML_BITCOINAMOUNT_H + +#include + +#include +#include + +class BitcoinAmount : public QObject +{ + Q_OBJECT + Q_PROPERTY(Unit unit READ unit WRITE setUnit NOTIFY unitChanged) + Q_PROPERTY(QString unitLabel READ unitLabel NOTIFY unitChanged) + Q_PROPERTY(QString display READ toDisplay WRITE fromDisplay NOTIFY displayChanged) + Q_PROPERTY(QString displayWithUnit READ displayWithUnit NOTIFY displayWithUnitChanged) + Q_PROPERTY(qint64 satoshi READ satoshi WRITE setSatoshi NOTIFY amountChanged) + +public: + enum class Unit { + BTC, + SAT + }; + Q_ENUM(Unit) + + explicit BitcoinAmount(QObject *parent = nullptr); + + Unit unit() const; + void setUnit(Unit unit); + QString unitLabel() const; + + QString toDisplay() const; + QString displayWithUnit() const; + void fromDisplay(const QString& new_amount); + qint64 satoshi() const; + void setSatoshi(qint64 new_amount); + + bool isSet() const { return m_isSet; } + + Q_INVOKABLE void format(); + + static QString satsToBtcString(qint64 sat); + +public Q_SLOTS: + void flipUnit(); + void clear(); + +Q_SIGNALS: + void unitChanged(); + void amountChanged(); + void displayChanged(); + void displayWithUnitChanged(); + +private: + QString sanitize(const QString& text); + static qint64 btcToSats(const QString& btc); + + qint64 m_satoshi{0}; + bool m_isSet{false}; + Unit m_unit{Unit::BTC}; +}; + +#endif // BITCOIN_QML_BITCOINAMOUNT_H diff --git a/src/qml/bitcoinunits.cpp b/src/qml/bitcoinunits.cpp new file mode 100644 index 0000000000..e04f8ff504 --- /dev/null +++ b/src/qml/bitcoinunits.cpp @@ -0,0 +1,65 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include + +namespace { +constexpr int THIN_SP_CP = 0x2009; +} + +qint64 QmlBitcoinUnits::factor(Unit unit) +{ + switch (unit) { + case Unit::BTC: return 100'000'000; + case Unit::mBTC: return 100'000; + case Unit::uBTC: return 100; + case Unit::SAT: return 1; + } + assert(false); +} + +int QmlBitcoinUnits::decimals(Unit unit) +{ + switch (unit) { + case Unit::BTC: return 8; + case Unit::mBTC: return 5; + case Unit::uBTC: return 2; + case Unit::SAT: return 0; + } + assert(false); +} + +QString QmlBitcoinUnits::format(Unit unit, CAmount amount, bool plussign, SeparatorStyle separators) +{ + const qint64 n = static_cast(amount); + const qint64 coin = factor(unit); + const int num_decimals = decimals(unit); + const qint64 n_abs = (n > 0 ? n : -n); + const qint64 quotient = n_abs / coin; + + QString quotient_str = QString::number(quotient); + QChar thin_sp(THIN_SP_CP); + const int q_size = quotient_str.size(); + if (separators == SeparatorStyle::ALWAYS || (separators == SeparatorStyle::STANDARD && q_size > 4)) { + for (int i = 3; i < q_size; i += 3) { + quotient_str.insert(q_size - i, thin_sp); + } + } + + if (n < 0) { + quotient_str.insert(0, '-'); + } else if (plussign && n > 0) { + quotient_str.insert(0, '+'); + } + + if (num_decimals == 0) { + return quotient_str; + } + + const qint64 remainder = n_abs % coin; + const QString remainder_str = QString::number(remainder).rightJustified(num_decimals, '0'); + return quotient_str + "." + remainder_str; +} diff --git a/src/qml/bitcoinunits.h b/src/qml/bitcoinunits.h new file mode 100644 index 0000000000..a27fe16de8 --- /dev/null +++ b/src/qml/bitcoinunits.h @@ -0,0 +1,36 @@ +// 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. + +#ifndef BITCOIN_QML_BITCOINUNITS_H +#define BITCOIN_QML_BITCOINUNITS_H + +#include + +#include + +class QmlBitcoinUnits +{ +public: + enum class Unit { + BTC, + mBTC, + uBTC, + SAT, + }; + + enum class SeparatorStyle { + NEVER, + STANDARD, + ALWAYS, + }; + + static QString format(Unit unit, CAmount amount, bool plussign = false, + SeparatorStyle separators = SeparatorStyle::STANDARD); + +private: + static qint64 factor(Unit unit); + static int decimals(Unit unit); +}; + +#endif // BITCOIN_QML_BITCOINUNITS_H diff --git a/src/qml/buildinfo.h b/src/qml/buildinfo.h new file mode 100644 index 0000000000..d06f359b98 --- /dev/null +++ b/src/qml/buildinfo.h @@ -0,0 +1,28 @@ +// 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. + +#ifndef BITCOIN_QML_BUILDINFO_H +#define BITCOIN_QML_BUILDINFO_H + +#include + +class BuildInfo : public QObject +{ + Q_OBJECT + Q_PROPERTY(bool isDebug READ isDebug CONSTANT) + +public: + explicit BuildInfo(QObject* parent = nullptr) : QObject(parent) {} + + bool isDebug() const + { +#ifndef NDEBUG + return true; +#else + return false; +#endif + } +}; + +#endif // BITCOIN_QML_BUILDINFO_H diff --git a/src/qml/clipboard.h b/src/qml/clipboard.h new file mode 100644 index 0000000000..afe83bee37 --- /dev/null +++ b/src/qml/clipboard.h @@ -0,0 +1,34 @@ +// Copyright (c) 2024 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QML_CLIPBOARD_H +#define BITCOIN_QML_CLIPBOARD_H + +#include +#include +#include + +class Clipboard : public QObject +{ + Q_OBJECT +public: + explicit Clipboard(QObject *parent = nullptr) : QObject(parent) { + connect(QGuiApplication::clipboard(), &QClipboard::dataChanged, + this, &Clipboard::dataChanged); + } + +Q_SIGNALS: + void dataChanged(); + +public: + Q_INVOKABLE void setText(const QString &text) { + QGuiApplication::clipboard()->setText(text); + } + + Q_INVOKABLE QString text() const { + return QGuiApplication::clipboard()->text(); + } +}; + +#endif // BITCOIN_QML_CLIPBOARD_H diff --git a/src/qml/components/AboutOptions.qml b/src/qml/components/AboutOptions.qml new file mode 100644 index 0000000000..9a11d493b2 --- /dev/null +++ b/src/qml/components/AboutOptions.qml @@ -0,0 +1,88 @@ +// Copyright (c) 2022 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 + signal next + spacing: 4 + Setting { + id: websiteLink + Layout.fillWidth: true + header: qsTr("Website") + actionItem: ExternalLink { + parentState: websiteLink.state + description: "bitcoincore.org" + link: "https://bitcoincore.org" + } + onClicked: openPopup(loadedItem.link) + } + Separator { Layout.fillWidth: true } + Setting { + id: sourceLink + Layout.fillWidth: true + header: qsTr("Source code") + actionItem: ExternalLink { + parentState: sourceLink.state + description: "github.com/bitcoin/bitcoin" + link: "https://github.com/bitcoin/bitcoin" + } + onClicked: openPopup(loadedItem.link) + } + Separator { Layout.fillWidth: true } + Setting { + id: licenseLink + Layout.fillWidth: true + header: qsTr("License") + actionItem: ExternalLink { + parentState: licenseLink.state + description: "MIT" + link: "https://opensource.org/licenses/MIT" + } + onClicked: openPopup(loadedItem.link) + } + Separator { Layout.fillWidth: true } + Setting { + id: versionLink + Layout.fillWidth: true + header: qsTr("Version") + actionItem: ExternalLink { + parentState: versionLink.state + description: nodeModel.fullClientVersion + link: "https://bitcoin.org/en/download" + iconSource: "image://images/caret-right" + iconWidth: 18 + iconHeight: 18 + } + onClicked: openPopup(loadedItem.link) + } + Separator { Layout.fillWidth: true } + Setting { + id: gotoDeveloper + objectName: "gotoDeveloperSetting" + Layout.fillWidth: true + header: qsTr("Developer options") + description: qsTr("Only use these if you have development experience") + actionItem: CaretRightIcon { + color: gotoDeveloper.stateColor + } + onClicked: { + root.next() + } + } + ExternalPopup { + id: confirmPopup + anchors.centerIn: Overlay.overlay + width: parent.width + } + + function openPopup(link) { + confirmPopup.link = link + confirmPopup.open() + } +} diff --git a/src/qml/components/AddressDetailRow.qml b/src/qml/components/AddressDetailRow.qml new file mode 100644 index 0000000000..daeb2cf439 --- /dev/null +++ b/src/qml/components/AddressDetailRow.qml @@ -0,0 +1,88 @@ +// 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 org.bitcoincore.qt 1.0 + +import "../controls" + +Item { + id: root + + property string label: qsTr("Address") + property string address: "" + + signal copied() + + Layout.fillWidth: true + implicitHeight: addressCol.implicitHeight + 20 + + function formatAddressRichText(addr) { + if (!addr) return "" + var primaryColor = Theme.color.neutral9 + var secondaryColor = Theme.color.neutral7 + var html = "" + for (var i = 0; i < addr.length; i += 4) { + var chunk = addr.substring(i, Math.min(i + 4, addr.length)) + var color = (Math.floor(i / 4) % 2 === 0) ? primaryColor : secondaryColor + if (i > 0) html += " " + html += "" + chunk + "" + } + return html + } + + ColumnLayout { + id: addressCol + anchors.left: parent.left + anchors.right: copyIcon.left + anchors.rightMargin: 10 + anchors.top: parent.top + anchors.topMargin: 10 + spacing: 4 + + CoreText { + Layout.fillWidth: true + horizontalAlignment: Text.AlignLeft + color: Theme.color.neutral7 + text: root.label + font: Theme.text.description.font + lineHeight: Theme.text.description.lineHeight + lineHeightMode: Text.FixedHeight + } + + CoreText { + Layout.fillWidth: true + horizontalAlignment: Text.AlignLeft + color: Theme.color.neutral9 + font: Theme.text.monoBody.font + lineHeight: Theme.text.monoBody.lineHeight + lineHeightMode: Text.FixedHeight + textFormat: Text.RichText + text: root.formatAddressRichText(root.address) + wrapMode: Text.WordWrap + } + } + + Icon { + id: copyIcon + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + source: "qrc:/icons/copy" + color: Theme.color.neutral9 + size: 24 + } + + MouseArea { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 44 + height: 44 + cursorShape: Qt.PointingHandCursor + onClicked: { + Clipboard.setText(root.address) + root.copied() + } + } +} diff --git a/src/qml/components/AlertAction.qml b/src/qml/components/AlertAction.qml new file mode 100644 index 0000000000..34bd50f5c7 --- /dev/null +++ b/src/qml/components/AlertAction.qml @@ -0,0 +1,20 @@ +// 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 QtQml 2.15 + +QtObject { + enum Role { + Normal, + Cancel, + Destructive + } + + property string text: "" + property int role: AlertAction.Normal + property string buttonObjectName: "" + property bool closesPopup: true + + signal triggered() +} diff --git a/src/qml/components/AlertPopup.qml b/src/qml/components/AlertPopup.qml new file mode 100644 index 0000000000..c0beb6e0b8 --- /dev/null +++ b/src/qml/components/AlertPopup.qml @@ -0,0 +1,125 @@ +// 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 QtQml 2.15 +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import "../controls" + +Popup { + id: root + + property string title: "" + property string message: "" + property string messageObjectName: "alertMessage" + default property alias actions: actionStore.data + + property var visibleActions: [defaultAction] + + modal: true + padding: 0 + anchors.centerIn: parent + width: parent ? Math.min(parent.width - 40, 360) : 360 + implicitHeight: columnLayout.implicitHeight + + property Item actionStoreItem: Item { + id: actionStore + visible: false + } + property AlertAction defaultAction: AlertAction { text: qsTr("OK") } + + function refreshActions() { + visibleActions = actionStore.data.length > 0 ? actionStore.data : [defaultAction] + } + + Component.onCompleted: refreshActions() + onOpened: refreshActions() + + background: Rectangle { + color: Theme.color.background + radius: 8 + border.color: Theme.color.neutral4 + border.width: 1 + } + + contentItem: ColumnLayout { + id: columnLayout + spacing: 0 + + CoreText { + objectName: "alertTitle" + Layout.fillWidth: true + Layout.preferredHeight: 64 + text: root.title + font: Theme.text.subtitle.font + lineHeight: Theme.text.subtitle.lineHeight + lineHeightMode: Text.FixedHeight + color: Theme.color.neutral9 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + Separator { + Layout.fillWidth: true + } + + CoreText { + objectName: root.messageObjectName + Layout.fillWidth: true + Layout.margins: 20 + text: root.message + color: Theme.color.neutral8 + font: Theme.text.description.font + lineHeight: Theme.text.description.lineHeight + lineHeightMode: Text.FixedHeight + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + } + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 20 + Layout.rightMargin: 20 + Layout.bottomMargin: 20 + spacing: 10 + + Repeater { + model: root.visibleActions.length + + ContinueButton { + id: alertButton + readonly property AlertAction alertAction: root.visibleActions[index] + + objectName: alertAction.buttonObjectName + Layout.fillWidth: true + Layout.preferredWidth: 0 + text: alertAction.text + textColor: alertAction.role === AlertAction.Cancel ? Theme.color.neutral9 : Theme.color.white + textHoverColor: textColor + textPressedColor: textColor + backgroundColor: alertAction.role === AlertAction.Cancel + ? Theme.color.background + : alertAction.role === AlertAction.Destructive ? Theme.color.red : Theme.color.orange + backgroundHoverColor: alertAction.role === AlertAction.Cancel + ? Theme.color.background + : alertAction.role === AlertAction.Destructive ? Qt.lighter(Theme.color.red, 1.1) : Theme.color.orangeLight1 + backgroundPressedColor: alertAction.role === AlertAction.Cancel + ? Theme.color.neutral2 + : alertAction.role === AlertAction.Destructive ? Qt.darker(Theme.color.red, 1.1) : Theme.color.orangeLight2 + borderColor: alertAction.role === AlertAction.Cancel ? Theme.color.neutral6 : "transparent" + borderHoverColor: alertAction.role === AlertAction.Cancel ? Theme.color.neutral9 : "transparent" + borderPressedColor: alertAction.role === AlertAction.Cancel ? Theme.color.neutral2 : "transparent" + onClicked: { + if (alertAction.closesPopup) { + root.close() + } + alertAction.triggered() + } + } + } + } + } +} diff --git a/src/qml/components/BitcoinAddressDisplayField.qml b/src/qml/components/BitcoinAddressDisplayField.qml new file mode 100644 index 0000000000..15de6f2ba6 --- /dev/null +++ b/src/qml/components/BitcoinAddressDisplayField.qml @@ -0,0 +1,109 @@ +// 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" + +Item { + id: root + + property string labelText: qsTr("Send to") + property string text: "" + property string fullText: "" + property string expandedObjectName: "" + property bool expanded: false + property int labelPixelSize: 18 + property color labelColor: Theme.color.neutral9 + readonly property bool showLabel: labelText.length > 0 + readonly property bool expandable: fullText.length > 0 + + Layout.fillWidth: true + implicitHeight: Math.max(showLabel ? label.implicitHeight + 6 : 0, addressContainer.implicitHeight) + + onExpandableChanged: { + if (!expandable) { + expanded = false + } + } + + function click() { + if (expandable && !expanded) { + expanded = true + } + } + + CoreText { + id: label + visible: root.showLabel + width: 110 + anchors.left: parent.left + anchors.top: parent.top + anchors.topMargin: 6 + horizontalAlignment: Text.AlignLeft + text: root.labelText + font.pixelSize: root.labelPixelSize + color: root.labelColor + } + + Item { + id: addressContainer + anchors.left: root.showLabel ? label.right : parent.left + anchors.right: parent.right + anchors.top: parent.top + implicitHeight: addressColumn.implicitHeight + height: implicitHeight + + Column { + id: addressColumn + width: parent.width + spacing: root.expandable && root.expanded ? 6 : 0 + + CoreText { + id: addressText + width: parent.width + text: root.text + wrap: true + wrapMode: Text.WordWrap + horizontalAlignment: root.expandable ? Text.AlignRight : Text.AlignLeft + verticalAlignment: Text.AlignTop + height: Math.max(implicitHeight, 32) + font.pixelSize: 18 + color: Theme.color.neutral9 + + HoverHandler { + enabled: root.expandable + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + enabled: root.expandable + onTapped: root.expanded = !root.expanded + } + } + + TextArea { + id: fullAddressText + objectName: root.expandedObjectName + width: parent.width + visible: root.expandable && root.expanded + readOnly: true + text: root.fullText + wrapMode: Text.WordWrap + leftPadding: 0 + topPadding: 0 + rightPadding: 0 + bottomPadding: 0 + height: visible ? Math.max(contentHeight, 32) : 0 + font.family: "Inter" + font.styleName: "Regular" + font.pixelSize: 18 + color: Theme.color.neutral7 + background: Item {} + } + } + } +} diff --git a/src/qml/components/BitcoinAddressInputField.qml b/src/qml/components/BitcoinAddressInputField.qml new file mode 100644 index 0000000000..a2c93fa752 --- /dev/null +++ b/src/qml/components/BitcoinAddressInputField.qml @@ -0,0 +1,110 @@ +// Copyright (c) 2025-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" + +ColumnLayout { + id: root + + property var address + property string errorText: "" + property string labelText: qsTr("Send to") + property bool enabled: true + property alias inputObjectName: addressInput.objectName + + signal textChanged() + signal editingFinished() + + Layout.fillWidth: true + spacing: 4 + + Item { + id: inputRow + Layout.fillWidth: true + implicitHeight: Math.max(label.implicitHeight + 6, addressInput.height) + + CoreText { + id: label + width: 110 + anchors.left: parent.left + anchors.top: parent.top + anchors.topMargin: 6 + horizontalAlignment: Text.AlignLeft + text: root.labelText + font.pixelSize: 18 + } + + TextArea { + id: addressInput + objectName: root.inputObjectName + anchors.left: label.right + anchors.right: parent.right + anchors.top: parent.top + enabled: root.enabled + placeholderText: qsTr("Enter address...") + text: root.address ? root.address.formattedAddress : "" + wrapMode: Text.WrapAnywhere + leftPadding: 0 + topPadding: 0 + rightPadding: 0 + bottomPadding: 0 + height: Math.max(contentHeight, 32) + font.family: "Roboto Mono" + font.styleName: "Regular" + font.pixelSize: 18 + color: Theme.color.neutral9 + placeholderTextColor: enabled ? Theme.color.neutral7 : Theme.color.neutral4 + background: Item {} + selectByMouse: true + + onTextChanged: { + if (root.address) { + cursorPosition = root.address.setAddress(text, cursorPosition) + } + root.textChanged() + } + + onEditingFinished: { + if (root.address) { + root.address.setAddress(text, cursorPosition) + root.editingFinished() + } + } + + onActiveFocusChanged: { + if (!activeFocus && root.address) { + root.address.setAddress(text) + } + } + } + } + + + RowLayout { + id: addressIssue + Layout.fillWidth: true + visible: root.errorText.length > 0 + spacing: 8 + Layout.topMargin: 4 + + Icon { + source: "image://images/alert-filled" + size: 22 + color: Theme.color.red + } + + CoreText { + text: root.errorText + font.pixelSize: 15 + color: Theme.color.red + horizontalAlignment: Text.AlignLeft + Layout.fillWidth: true + } + } +} diff --git a/src/qml/components/BitcoinAmountDisplayField.qml b/src/qml/components/BitcoinAmountDisplayField.qml new file mode 100644 index 0000000000..6407c2e0c0 --- /dev/null +++ b/src/qml/components/BitcoinAmountDisplayField.qml @@ -0,0 +1,60 @@ +// 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" + +Item { + id: root + + property string labelText: qsTr("Amount") + property string amountText: "" + property string unitText: "" + property string text: unitText.length > 0 ? amountText + " " + unitText : amountText + property int labelWidth: 110 + property int labelPixelSize: 18 + property color labelColor: Theme.color.neutral9 + + Layout.fillWidth: true + implicitHeight: Math.max(label.implicitHeight, amountValue.implicitHeight, unitLabel.implicitHeight) + + CoreText { + id: label + width: root.labelWidth + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + horizontalAlignment: Text.AlignLeft + wrap: false + text: root.labelText + font.pixelSize: root.labelPixelSize + color: root.labelColor + } + + CoreText { + id: amountValue + anchors.left: label.right + anchors.right: unitLabel.left + anchors.rightMargin: unitLabel.visible ? 12 : 0 + anchors.verticalCenter: parent.verticalCenter + horizontalAlignment: Text.AlignRight + wrap: false + elide: Text.ElideRight + text: root.amountText + font.pixelSize: 18 + color: Theme.color.neutral9 + } + + CoreText { + id: unitLabel + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + visible: root.unitText.length > 0 + text: root.unitText + font.pixelSize: 18 + color: Theme.color.neutral7 + } +} diff --git a/src/qml/components/BitcoinAmountInputField.qml b/src/qml/components/BitcoinAmountInputField.qml new file mode 100644 index 0000000000..7778665407 --- /dev/null +++ b/src/qml/components/BitcoinAmountInputField.qml @@ -0,0 +1,211 @@ +// Copyright (c) 2025-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" + +ColumnLayout { + id: root + + property var amount + property string errorText: "" + property string labelText: qsTr("Amount") + property string accessibleName: labelText + property alias inputObjectName: amountInput.objectName + property alias unitToggleObjectName: unitToggle.objectName + property alias unitLabelObjectName: unitLabel.objectName + property alias errorTextObjectName: errorTextLabel.objectName + property bool enabled: true + + signal inputTextChanged + signal textEdited + signal editingFinished(string value) + + function syncFromAmount(force) { + amountInput.syncFromAmount(force) + } + + Layout.fillWidth: true + spacing: 4 + + Item { + id: inputRow + height: amountInput.height + Layout.fillWidth: true + + CoreText { + id: lbl + width: 110 + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + horizontalAlignment: Text.AlignLeft + text: root.labelText + font.pixelSize: 18 + } + + TextField { + id: amountInput + property bool syncingFromAmount: false + + function amountDisplay() { + return root.amount ? root.amount.display : "" + } + + // Keep user draft text while focused, then pull from BitcoinAmount + // after format() so the field shows the clean canonical display. + function syncFromAmount(force) { + if (!force && activeFocus) return + const display = amountDisplay() + if (text === display) return + syncingFromAmount = true + text = display + syncingFromAmount = false + } + + function commitAmountText() { + if (root.amount && root.amount.display !== text) { + root.amount.display = text + } + } + + function finishAmountEditing() { + commitAmountText() + if (root.amount) { + root.amount.format() + root.syncFromAmount(true) + } + root.editingFinished(text) + } + + Accessible.name: root.accessibleName + anchors.left: lbl.right + anchors.right: unitToggle.left + anchors.verticalCenter: parent.verticalCenter + leftPadding: 0 + enabled: root.enabled + font.family: "BitcoinCoreSans" + font.styleName: "Regular" + font.pixelSize: 18 + color: Theme.color.neutral9 + placeholderTextColor: enabled ? Theme.color.neutral7 : Theme.color.neutral4 + background: Item {} + placeholderText: root.amount && root.amount.unit === BitcoinAmount.SAT ? "0" : "0.00000000" + selectByMouse: true + + text: "" + Component.onCompleted: root.syncFromAmount(true) + + onTextChanged: { + if (!syncingFromAmount) { + root.inputTextChanged() + } + } + + onTextEdited: { + commitAmountText() + root.textEdited() + } + + onEditingFinished: finishAmountEditing() + + onActiveFocusChanged: { + if (!activeFocus) { + finishAmountEditing() + } + } + + // Keep draft input representable as satoshis; leading zeroes are + // still allowed until the field syncs from the formatted amount. + validator: RegularExpressionValidator { + regularExpression: !root.amount || root.amount.unit === BitcoinAmount.BTC + ? /^0*\d{0,8}(\.\d{0,8})?$/ + : /^0*\d{0,16}$/ + } + maximumLength: 32 + + Connections { + target: root + function onAmountChanged() { + root.syncFromAmount(true) + } + } + + Connections { + target: root.amount ? root.amount : null + function onDisplayChanged() { + amountInput.syncFromAmount(false) + } + function onUnitChanged() { + root.syncFromAmount(true) + } + } + } + + Item { + id: unitToggle + width: unitLabel.width + flipIcon.width + height: Math.max(unitLabel.height, flipIcon.height) + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + opacity: root.enabled ? 1.0 : 0.5 + + function click() { + if (!root.enabled || !root.amount) return + amountInput.commitAmountText() + root.amount.flipUnit() + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: root.enabled && root.amount + onClicked: unitToggle.click() + } + + CoreText { + id: unitLabel + anchors.right: flipIcon.left + anchors.verticalCenter: parent.verticalCenter + text: root.amount ? root.amount.unitLabel : "" + font.pixelSize: 18 + color: root.enabled ? Theme.color.neutral7 : Theme.color.neutral4 + } + + Icon { + id: flipIcon + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + source: "image://images/flip-vertical" + color: enabled ? Theme.color.neutral8 : Theme.color.neutral4 + size: 30 + } + } + } + + RowLayout { + id: errorRow + Layout.fillWidth: true + visible: root.errorText.length > 0 + + Icon { + source: "image://images/alert-filled" + size: 22 + color: Theme.color.red + } + + CoreText { + id: errorTextLabel + text: root.errorText + font.pixelSize: 15 + color: Theme.color.red + horizontalAlignment: Text.AlignLeft + Layout.fillWidth: true + } + } +} diff --git a/src/qml/components/BlockClock.qml b/src/qml/components/BlockClock.qml new file mode 100644 index 0000000000..2c35979c46 --- /dev/null +++ b/src/qml/components/BlockClock.qml @@ -0,0 +1,292 @@ +// 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 Qt.labs.settings 1.0 + +import org.bitcoincore.qt 1.0 + +import "../controls" +import "../controls/utils.js" as Utils + +Item { + id: root + objectName: "blockClock" + property real parentWidth: 600 + property real parentHeight: 600 + property bool showNetworkIndicator: true + property var nodeModelRef: typeof nodeModel !== "undefined" ? nodeModel : null + property var chainModelRef: typeof chainModel !== "undefined" ? chainModel : null + property var networkStatusModelRef: typeof networkStatusModel !== "undefined" ? networkStatusModel : null + + width: dial.width + height: dial.height + (networkIndicator.visible ? networkIndicator.height + networkIndicator.anchors.topMargin : 0) + + property alias header: mainText.text + property alias headerSize: mainText.font.pixelSize + property alias subText: subText.text + property bool connected: root.nodeModelRef !== null && root.nodeModelRef.numPeers > 0 + property bool synced: root.nodeModelRef !== null && root.nodeModelRef.verificationProgress > 0.999 + property bool headerSyncActive: root.nodeModelRef !== null && root.nodeModelRef.headerSyncActive + property string syncProgress: formatProgressPercentage((root.headerSyncActive ? root.nodeModelRef.headerSyncProgress : (root.nodeModelRef !== null ? root.nodeModelRef.verificationProgress : 0)) * 100) + property bool paused: root.nodeModelRef !== null && root.nodeModelRef.pause + property var syncState: Utils.formatRemainingSyncTime(root.nodeModelRef !== null ? root.nodeModelRef.remainingSyncTime : 0) + property string syncTime: syncState.text + property bool estimating: syncState.estimating + property bool faulted: root.nodeModelRef !== null && root.nodeModelRef.faulted + property bool offline: root.networkStatusModelRef !== null && root.networkStatusModelRef.networkOffline + + activeFocusOnTab: true + + Settings { + id: settings + property alias blockclocksize: dial.scale + } + + BlockClockDial { + id: dial + anchors.horizontalCenter: root.horizontalCenter + scale: Theme.blockclocksize + width: {Math.max(Math.min(200, Math.min(root.parentWidth - 30, root.parentHeight - 30)), + Math.min((root.parentWidth * dial.scale), (root.parentHeight * dial.scale)))} + height: dial.width + penWidth: dial.width / 50 + timeRatioList: root.chainModelRef !== null ? root.chainModelRef.timeRatioList : [] + verificationProgress: root.nodeModelRef !== null ? root.nodeModelRef.verificationProgress : 0 + paused: root.paused || root.faulted || root.offline + connected: root.connected && !root.offline + synced: root.synced + backgroundColor: Theme.color.neutral2 + timeTickColor: Theme.color.neutral5 + confirmationColors: Theme.color.confirmationColors + + Behavior on backgroundColor { + ColorAnimation { duration: 150 } + } + + Behavior on timeTickColor { + ColorAnimation { duration: 150 } + } + + Behavior on confirmationColors { + ColorAnimation { duration: 150 } + } + } + + Icon { + id: bitcoinIcon + source: "image://images/bitcoin-circle" + color: Theme.color.neutral9 + size: Math.max(dial.width / 5, 1) + anchors.bottom: mainText.top + anchors.horizontalCenter: root.horizontalCenter + } + + Label { + id: mainText + anchors.centerIn: dial + font.family: "BitcoinCoreSans" + font.styleName: "Semi Bold" + font.pixelSize: dial.width * (4/25) + color: Theme.color.neutral9 + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + + Label { + id: subText + anchors.top: mainText.bottom + property bool estimating: root.estimating + anchors.horizontalCenter: root.horizontalCenter + font.family: "BitcoinCoreSans" + font.styleName: "Semi Bold" + font.pixelSize: dial.width * (9/100) + color: Theme.color.neutral4 + + Component.onCompleted: { + colorChanged.connect(function() { + if (!subText.estimating) { + themeChange.restart(); + } + }); + + estimatingChanged.connect(function() { + if (subText.estimating) { + estimatingTime.start(); + } else { + estimatingTime.stop(); + } + }); + + subText.estimatingChanged(); + } + + ColorAnimation on color{ + id: themeChange + target: subText + duration: 150 + } + + SequentialAnimation { + id: estimatingTime + loops: Animation.Infinite + ColorAnimation { target: subText; property: "color"; from: subText.color; to: Theme.color.neutral6; duration: 1000 } + ColorAnimation { target: subText; property: "color"; from: Theme.color.neutral6; to: subText.color; duration: 1000 } + } + + } + + PeersIndicator { + anchors.top: subText.bottom + anchors.topMargin: dial.width / 10 + anchors.horizontalCenter: root.horizontalCenter + numOutboundPeers: root.nodeModelRef !== null ? root.nodeModelRef.numOutboundPeers : 0 + maxNumOutboundPeers: root.nodeModelRef !== null ? Math.max(root.nodeModelRef.maxNumOutboundPeers, 1) : 1 + indicatorDimensions: dial.width * (3/200) + indicatorSpacing: dial.width / 40 + paused: root.paused || root.faulted + } + + NetworkIndicator { + id: networkIndicator + objectName: "blockClockNetworkIndicator" + show: root.showNetworkIndicator + chainModelRef: root.chainModelRef + anchors.top: dial.bottom + anchors.topMargin: networkIndicator.visible ? 30 : 0 + anchors.horizontalCenter: root.horizontalCenter + } + + MouseArea { + objectName: "blockClockToggleArea" + anchors.fill: dial + cursorShape: root.faulted ? Qt.ArrowCursor : Qt.PointingHandCursor + enabled: !root.faulted + function click() { + root.togglePause() + } + onClicked: click() + FocusBorder { + visible: root.activeFocus + } + } + + states: [ + State { + name: "IBD"; when: !faulted && !offline && !synced && !paused && connected + PropertyChanges { + target: root + header: root.syncProgress + subText: root.headerSyncActive + ? (root.nodeModelRef.headerPresync ? qsTr("Pre-syncing headers") : qsTr("Syncing headers")) + : root.syncTime + } + }, + + State { + name: "BLOCKCLOCK"; when: !faulted && !offline && synced && !paused && connected + PropertyChanges { + target: root + header: Number(root.nodeModelRef !== null ? root.nodeModelRef.blockTipHeight : 0).toLocaleString(Qt.locale(), 'f', 0) + subText: "Blocktime" + estimating: false + } + }, + + State { + name: "PAUSE"; when: paused && !faulted && !offline + PropertyChanges { + target: root + header: "Paused" + headerSize: dial.width * (3/25) + subText: "Tap to resume" + estimating: false + } + PropertyChanges { + target: bitcoinIcon + anchors.bottomMargin: dial.width / 40 + } + PropertyChanges { + target: subText + anchors.topMargin: dial.width / 50 + } + }, + + State { + name: "ERROR"; when: faulted + PropertyChanges { + target: root + header: "Error" + headerSize: dial.width * (3/25) + } + PropertyChanges { + target: bitcoinIcon + anchors.bottomMargin: dial.width / 40 + icon.source: "image://images/error" + } + }, + + State { + name: "OFFLINE"; when: !faulted && offline + PropertyChanges { + target: root + header: qsTr("Offline") + headerSize: dial.width * (3/25) + subText: qsTr("Check network") + estimating: false + } + PropertyChanges { + target: bitcoinIcon + anchors.bottomMargin: dial.width / 40 + icon.source: "image://images/network-light" + } + PropertyChanges { + target: subText + anchors.topMargin: dial.width / 50 + } + }, + + State { + name: "CONNECTING"; when: !faulted && !paused && !connected + PropertyChanges { + target: root + header: qsTr("Connecting") + headerSize: dial.width * (3/25) + subText: qsTr("Please wait") + estimating: false + } + PropertyChanges { + target: bitcoinIcon + anchors.bottomMargin: dial.width / 40 + } + PropertyChanges { + target: subText + anchors.topMargin: dial.width / 50 + } + } + ] + + + function formatProgressPercentage(progress) { + if (progress >= 1) { + return Math.round(progress) + "%" + } else if (progress >= 0.1) { + return progress.toFixed(1) + "%" + } else if (progress >= 0.01) { + return progress.toFixed(2) + "%" + } else { + return "0%" + } + } + + function togglePause() { + if (!root.faulted && root.nodeModelRef !== null) { + root.nodeModelRef.pause = !root.paused + } + } +} diff --git a/src/qml/components/BlockClockDisplayMode.qml b/src/qml/components/BlockClockDisplayMode.qml new file mode 100644 index 0000000000..87b2e65e3c --- /dev/null +++ b/src/qml/components/BlockClockDisplayMode.qml @@ -0,0 +1,42 @@ +// 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 Qt.labs.settings 1.0 +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/src/qml/components/BlockCounter.qml b/src/qml/components/BlockCounter.qml new file mode 100644 index 0000000000..5dee1606a0 --- /dev/null +++ b/src/qml/components/BlockCounter.qml @@ -0,0 +1,24 @@ +// Copyright (c) 2021 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +// The BlockCounter component. + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import "../controls" + +Label { + property int blockHeight: 0 + background: Rectangle { + color: Theme.color.background + } + color: Theme.color.orange + padding: 16 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font.family: "BitcoinCoreSans" + font.styleName: "Semi Bold" + font.pixelSize: 20 + text: blockHeight +} diff --git a/src/qml/components/ConnectionOptions.qml b/src/qml/components/ConnectionOptions.qml new file mode 100644 index 0000000000..e0c4079d7e --- /dev/null +++ b/src/qml/components/ConnectionOptions.qml @@ -0,0 +1,35 @@ +// Copyright (c) 2021 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 { + ButtonGroup { + id: group + } + spacing: 15 + OptionButton { + Layout.fillWidth: true + ButtonGroup.group: group + text: qsTr("Fast always on") + description: qsTr("Loads quickly at all times and uses as much cellular data as needed.") + recommended: true + } + OptionButton { + Layout.fillWidth: true + ButtonGroup.group: group + checked: true + text: qsTr("Slow always on") + description: qsTr("Loads at all times with reduced cellular data usage.") + } + OptionButton { + Layout.fillWidth: true + ButtonGroup.group: group + text: qsTr("Only when on Wi-Fi") + description: qsTr("Loads quickly when on wi-fi and pauses when on cellular data.") + } +} diff --git a/src/qml/components/ConnectionSettings.qml b/src/qml/components/ConnectionSettings.qml new file mode 100644 index 0000000000..4a39574819 --- /dev/null +++ b/src/qml/components/ConnectionSettings.qml @@ -0,0 +1,64 @@ +// Copyright (c) 2022 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 + signal next + spacing: 4 + Setting { + Layout.fillWidth: true + header: qsTr("Enable listening") + description: qsTr("Allows incoming connections") + actionItem: OptionSwitch { + checked: optionsModel.listen + onToggled: optionsModel.listen = checked + } + onClicked: { + loadedItem.toggle() + loadedItem.toggled() + } + } + Separator { Layout.fillWidth: true } + Setting { + Layout.fillWidth: true + header: qsTr("Map port using NAT-PMP") + actionItem: OptionSwitch { + checked: optionsModel.natpmp + onToggled: optionsModel.natpmp = checked + } + onClicked: { + loadedItem.toggle() + loadedItem.toggled() + } + } + Separator { Layout.fillWidth: true } + Setting { + Layout.fillWidth: true + header: qsTr("Enable RPC server") + actionItem: OptionSwitch { + checked: optionsModel.server + onToggled: optionsModel.server = checked + } + onClicked: { + loadedItem.toggle() + loadedItem.toggled() + } + } + Separator { Layout.fillWidth: true } + Setting { + id: gotoProxy + objectName: "gotoProxy" + Layout.fillWidth: true + header: qsTr("Proxy settings") + actionItem: CaretRightIcon { + color: gotoProxy.stateColor + } + onClicked: root.next() + } +} diff --git a/src/qml/components/DetailEditRow.qml b/src/qml/components/DetailEditRow.qml new file mode 100644 index 0000000000..7c1556fa14 --- /dev/null +++ b/src/qml/components/DetailEditRow.qml @@ -0,0 +1,64 @@ +// 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" + +Item { + id: root + + property string label: "" + property string value: "" + + signal editClicked() + + Layout.fillWidth: true + implicitHeight: col.implicitHeight + 20 + + ColumnLayout { + id: col + anchors.left: parent.left + anchors.right: editIcon.left + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + + CoreText { + Layout.fillWidth: true + horizontalAlignment: Text.AlignLeft + text: root.label + font.pixelSize: 13 + color: Theme.color.neutral7 + } + CoreText { + Layout.fillWidth: true + horizontalAlignment: Text.AlignLeft + text: root.value + font.pixelSize: 18 + color: Theme.color.neutral9 + wrapMode: Text.WordWrap + } + } + + Icon { + id: editIcon + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + source: "qrc:/icons/edit" + color: Theme.color.neutral9 + size: 24 + } + + MouseArea { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 44 + height: 44 + cursorShape: Qt.PointingHandCursor + onClicked: root.editClicked() + } +} diff --git a/src/qml/components/DeveloperOptions.qml b/src/qml/components/DeveloperOptions.qml new file mode 100644 index 0000000000..ecc8bc396f --- /dev/null +++ b/src/qml/components/DeveloperOptions.qml @@ -0,0 +1,72 @@ +// Copyright (c) 2022 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: 4 + Setting { + id: dbcacheSetting + Layout.fillWidth: true + header: qsTr("Database cache size (MiB)") + errorText: qsTr("This is not a valid cache size. Please choose a value between %1 and %2 MiB.").arg(optionsModel.minDbcacheSizeMiB).arg(optionsModel.maxDbcacheSizeMiB) + showErrorText: false + actionItem: ValueInput { + parentState: dbcacheSetting.state + description: optionsModel.dbcacheSizeMiB + onEditingFinished: { + if (checkValidity(optionsModel.minDbcacheSizeMiB, optionsModel.maxDbcacheSizeMiB, parseInt(text))) { + optionsModel.dbcacheSizeMiB = parseInt(text) + dbcacheSetting.forceActiveFocus() + dbcacheSetting.showErrorText = false + } else { + dbcacheSetting.showErrorText = true + } + } + } + onClicked: { + loadedItem.filled = true + loadedItem.forceActiveFocus() + } + } + Separator { Layout.fillWidth: true } + Setting { + id: parSetting + Layout.fillWidth: true + header: qsTr("Script verification threads") + errorText: qsTr("This is not a valid thread count. Please choose a value between %1 and %2 threads.").arg(optionsModel.minScriptThreads).arg(optionsModel.maxScriptThreads) + showErrorText: !loadedItem.acceptableInput && loadedItem.length > 0 + actionItem: ValueInput { + parentState: parSetting.state + description: optionsModel.scriptThreads + onEditingFinished: { + if (checkValidity(optionsModel.minScriptThreads, optionsModel.maxScriptThreads, parseInt(text))) { + optionsModel.scriptThreads = parseInt(text) + parSetting.forceActiveFocus() + parSetting.showErrorText = false + } else { + parSetting.showErrorText = true + } + } + } + onClicked: { + loadedItem.filled = true + loadedItem.forceActiveFocus() + } + } + Separator { Layout.fillWidth: true } + Setting { + Layout.fillWidth: true + header: qsTr("Dark Mode") + actionItem: OptionSwitch { + checked: Theme.dark + onToggled: Theme.toggleDark() + } + onClicked: loadedItem.toggled() + } +} diff --git a/src/qml/components/ExternalPopup.qml b/src/qml/components/ExternalPopup.qml new file mode 100644 index 0000000000..9e4f27b460 --- /dev/null +++ b/src/qml/components/ExternalPopup.qml @@ -0,0 +1,79 @@ +// 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 Qt.labs.settings 1.0 +import org.bitcoincore.qt 1.0 +import "../controls" + +Popup { + id: externalConfirmPopup + property string link: "" + modal: true + padding: 0 + anchors.centerIn: parent + + background: Rectangle { + color: Theme.color.background + radius: 10 + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + CoreText { + Layout.fillWidth: true + Layout.preferredHeight: 55 + text: qsTr("External Link") + bold: true + font.pixelSize: 24 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + Separator { + Layout.fillWidth: true + } + + Header { + Layout.fillWidth: true + Layout.margins: 20 + Layout.topMargin: 20 + header: qsTr("Do you want to open the following website in your browser?") + headerBold: false + headerSize: 16 + description: ("\"" + externalConfirmPopup.link + "\"") + descriptionMargin: 8 + } + + GridLayout { + Layout.fillWidth: true + Layout.margins: 20 + Layout.topMargin: 0 + columns: AppMode.isDesktop ? 2 : 1 + columnSpacing: 15 + rowSpacing: 10 + + OutlineButton { + text: qsTr("Cancel") + Layout.fillWidth: true + Layout.minimumWidth: 120 + onClicked: externalConfirmPopup.close() + } + + ContinueButton { + text: qsTr("Ok") + Layout.fillWidth: true + Layout.minimumWidth: 120 + onClicked: { + Qt.openUrlExternally(externalConfirmPopup.link) + externalConfirmPopup.close() + } + } + } + } +} diff --git a/src/qml/components/ExternalSignerReviewActions.qml b/src/qml/components/ExternalSignerReviewActions.qml new file mode 100644 index 0000000000..6dc65d2baa --- /dev/null +++ b/src/qml/components/ExternalSignerReviewActions.qml @@ -0,0 +1,121 @@ +// 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" + +Item { + id: root + + property var wallet + property string buttonObjectName: "" + property string statusObjectName: "" + property string reviewState: "initial" + property string errorMessage: "" + + readonly property string statusText: { + if (reviewState === "signed") return qsTr("Signed on external signer. Ready to send.") + if (reviewState === "waiting") return qsTr("Waiting for approval on external signer.") + if (reviewState === "error") return errorMessage + return qsTr("Approve on external signer to broadcast this transaction.") + } + readonly property color statusColor: { + if (reviewState === "signed") return Theme.color.green + if (reviewState === "error") return Theme.color.red + return Theme.color.neutral7 + } + readonly property string buttonText: { + if (reviewState === "signed") return qsTr("Send") + if (reviewState === "waiting") return qsTr("Waiting for approval...") + if (reviewState === "error") return qsTr("Retry external signer") + return qsTr("Approve on external signer") + } + + signal sendRequested() + + Layout.fillWidth: true + implicitHeight: actionsColumn.implicitHeight + + function reset() { + approveTimer.stop() + reviewState = "initial" + errorMessage = "" + } + + function beginApproval() { + if (reviewState === "waiting") { + return + } + approveTimer.stop() + reviewState = "waiting" + errorMessage = "" + approveTimer.start() + } + + Component.onDestruction: reset() + onVisibleChanged: { + if (!visible) { + reset() + } + } + + Timer { + id: approveTimer + interval: 1 + onTriggered: { + if (root.wallet) { + root.wallet.approveExternalSignerTransaction() + } + } + } + + Connections { + target: root.wallet + + function onExternalSignerApprovalSucceeded() { + root.reviewState = "signed" + root.errorMessage = "" + } + + function onExternalSignerApprovalFailed(message, signerNotFound) { + root.reviewState = "error" + root.errorMessage = message + } + } + + ColumnLayout { + id: actionsColumn + anchors.left: parent.left + anchors.right: parent.right + spacing: 20 + + CoreText { + objectName: root.statusObjectName + Layout.fillWidth: true + visible: root.statusText.length > 0 + horizontalAlignment: Text.AlignLeft + wrap: true + text: root.statusText + font.pixelSize: 18 + color: root.statusColor + } + + ContinueButton { + objectName: root.buttonObjectName + Layout.fillWidth: true + text: root.buttonText + enabled: root.reviewState !== "waiting" + onClicked: { + if (root.reviewState === "signed") { + root.sendRequested() + } else { + root.beginApproval() + } + } + } + } +} diff --git a/src/qml/components/FeeSelection.qml b/src/qml/components/FeeSelection.qml new file mode 100644 index 0000000000..0872677ab6 --- /dev/null +++ b/src/qml/components/FeeSelection.qml @@ -0,0 +1,393 @@ +// Copyright (c) 2025 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" + +ColumnLayout { + id: root + objectName: "feeSelectionControl" + + property var walletModel: null + property bool includeFeeInAmount: false + property int currentTarget: 2 + property bool customSelected: walletModel ? walletModel.customFeeEnabled : false + property int selectedIndex: customSelected + ? feeModel.count - 1 + : (walletModel ? walletModel.feeTargetIndex(currentTarget) : 1) + property string selectedLabel: customSelected + ? qsTr("sats/vbyte") + : feeModel.get(root.selectedIndex).feeLabel + property string selectedDuration: customSelected + ? "" + : feeModel.get(root.selectedIndex).feeDuration + property int selectedTarget: feeModel.get(root.selectedIndex).target + property string selectedEstimate: customSelected + ? "" + : (walletModel ? (walletModel.feeEstimateRevision, walletModel.estimatedFeeForTarget(selectedTarget)) : "") + property bool customFeeRateValid: walletModel ? walletModel.customFeeRateValid : false + readonly property int optionSpacing: 8 + readonly property int detailsSpacing: 4 + readonly property int selectionColumnWidth: 20 + readonly property int customFeeRateMaximumLength: 12 + readonly property int estimateColumnWidth: Math.ceil(estimateFontMetrics.advanceWidth("0.00000000 ₿")) + + signal feeChanged(int target) + signal includeFeeInAmountToggled(bool checked) + + spacing: 12 + + FontMetrics { + id: estimateFontMetrics + font.family: "Inter" + font.styleName: "Regular" + font.pixelSize: 18 + } + + RowLayout { + Layout.fillWidth: true + spacing: 16 + + CoreText { + Layout.preferredWidth: 110 + horizontalAlignment: Text.AlignLeft + font.pixelSize: 18 + text: root.customSelected ? qsTr("Fee Rate") : qsTr("Fee") + } + + CoreText { + id: estimateLabel + objectName: "feeSelectionEstimateLabel" + Layout.fillWidth: true + horizontalAlignment: Text.AlignLeft + font.pixelSize: 18 + color: Theme.color.neutral7 + text: root.selectedEstimate + visible: !root.customSelected + } + + TextField { + id: customFeeRateInput + objectName: "feeSelectionCustomRateInput" + Layout.fillWidth: true + visible: root.customSelected + leftPadding: 0 + font.family: "Inter" + font.styleName: "Regular" + font.pixelSize: 18 + color: Theme.color.neutral9 + placeholderTextColor: enabled ? Theme.color.neutral7 : Theme.color.neutral4 + background: Item {} + placeholderText: "0.000" + selectByMouse: true + text: root.walletModel ? root.walletModel.customFeeRate : "" + maximumLength: root.customFeeRateMaximumLength + validator: RegularExpressionValidator { + regularExpression: /^(|[0-9]+(\.[0-9]{0,3})?)$/ + } + onTextChanged: { + if (root.walletModel && root.walletModel.customFeeRate !== text) { + root.walletModel.customFeeRate = text + } + } + } + + Button { + id: dropDownButton + objectName: "feeSelectionDropdownButton" + hoverEnabled: true + leftPadding: 10 + rightPadding: 8 + topPadding: 4 + bottomPadding: 4 + implicitHeight: 32 + + HoverHandler { + cursorShape: Qt.PointingHandCursor + } + + onClicked: feePopup.open() + + contentItem: RowLayout { + spacing: 0 + + CoreText { + text: root.selectedLabel + font.pixelSize: 18 + color: dropDownButton.enabled ? Theme.color.neutral9 : Theme.color.neutral4 + } + + Item { width: 5 } + + CoreText { + text: root.selectedDuration + font.pixelSize: 18 + color: dropDownButton.enabled ? Theme.color.neutral7 : Theme.color.neutral4 + } + + Icon { + source: "image://images/caret-down-medium-filled" + Layout.preferredWidth: 30 + size: 30 + color: dropDownButton.enabled ? Theme.color.orange : Theme.color.neutral4 + } + } + + background: Rectangle { + id: dropDownButtonBg + color: dropDownButton.hovered ? Theme.color.neutral2 : Theme.color.background + radius: 6 + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 16 + visible: root.customSelected + + CoreText { + Layout.preferredWidth: 110 + horizontalAlignment: Text.AlignLeft + font.pixelSize: 18 + text: qsTr("Fee") + } + + CoreText { + id: customEstimateLabel + objectName: "feeSelectionCustomEstimateLabel" + Layout.fillWidth: true + horizontalAlignment: Text.AlignLeft + font.pixelSize: 18 + color: Theme.color.neutral7 + text: root.walletModel ? (root.walletModel.feeEstimateRevision, root.walletModel.estimatedFee) : "" + } + } + + Popup { + id: feePopup + objectName: "feeSelectionPopup" + modal: true + dim: false + width: Math.ceil(Math.max(dropDownButton.implicitWidth + 24, feeList.maxItemImplicitWidth + leftPadding + rightPadding)) + height: Math.min(feeList.implicitHeight + topPadding + bottomPadding, 300) + x: dropDownButton.x + dropDownButton.width - width + y: dropDownButton.height + 6 + padding: 6 + + background: Rectangle { + color: Theme.color.background + radius: 6 + border.color: Theme.color.neutral4 + } + + contentItem: Column { + id: feeList + objectName: "feeSelectionList" + readonly property int maxItemImplicitWidth: { + const feeEstimateRevision = root.walletModel ? root.walletModel.feeEstimateRevision : 0 + let maxWidth = 0 + for (let i = 0; i < feeOptionsRepeater.count; ++i) { + const item = feeOptionsRepeater.itemAt(i) + if (item) { + maxWidth = Math.max(maxWidth, item.implicitWidth) + } + } + maxWidth = Math.max(maxWidth, includeFeeToggleItem.implicitWidth) + return Math.ceil(maxWidth) + } + width: feePopup.availableWidth + spacing: 0 + + function itemAtIndex(index) { + return feeOptionsRepeater.itemAt(index) + } + + Repeater { + id: feeOptionsRepeater + model: feeModel + + delegate: ItemDelegate { + id: delegate + objectName: "feeSelectionOption" + index + required property string feeLabel + required property string feeDuration + required property int index + required property int target + + readonly property bool isCustomOption: target < 0 + implicitWidth: delegateContent.implicitWidth + leftPadding + rightPadding + width: feeList.width + height: 44 + leftPadding: 12 + rightPadding: 12 + topPadding: 0 + bottomPadding: 0 + + background: Item { + Rectangle { + anchors.fill: parent + radius: 6 + color: Theme.color.neutral2 + visible: delegate.hovered + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: delegate.index === feeModel.count - 1 ? 0 : 1 + color: Theme.color.neutral4 + } + } + + contentItem: Item { + id: delegateContent + implicitWidth: selectionSlot.width + + root.optionSpacing + + feeDetails.implicitWidth + + (estimateText.visible ? root.optionSpacing + root.estimateColumnWidth : 0) + implicitHeight: Math.max(selectionSlot.height, feeDetails.implicitHeight, estimateText.implicitHeight) + + Item { + id: selectionSlot + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: root.selectionColumnWidth + height: 20 + + CoreText { + anchors.centerIn: parent + visible: delegate.index === root.selectedIndex + text: "\u2713" + font.pixelSize: 18 + color: Theme.color.orange + } + } + + Row { + id: feeDetails + anchors.left: selectionSlot.right + anchors.leftMargin: root.optionSpacing + anchors.right: estimateText.visible ? estimateText.left : parent.right + anchors.rightMargin: estimateText.visible ? root.optionSpacing : 0 + anchors.verticalCenter: parent.verticalCenter + spacing: root.detailsSpacing + + CoreText { + text: feeLabel + font.pixelSize: 18 + color: Theme.color.neutral9 + } + + CoreText { + text: feeDuration + font.pixelSize: 18 + color: Theme.color.neutral7 + } + } + + CoreText { + id: estimateText + objectName: "feeSelectionOptionEstimate" + delegate.index + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + visible: !delegate.isCustomOption && text.length > 0 + width: visible ? root.estimateColumnWidth : 0 + horizontalAlignment: Text.AlignRight + text: delegate.isCustomOption || !root.walletModel + ? "" + : (root.walletModel.feeEstimateRevision, root.walletModel.estimatedFeeForTarget(target)) + font.pixelSize: 18 + color: Theme.color.neutral7 + } + } + + HoverHandler { + cursorShape: Qt.PointingHandCursor + } + + onClicked: { + if (root.walletModel) { + root.walletModel.customFeeEnabled = delegate.isCustomOption + } + if (!delegate.isCustomOption) { + root.feeChanged(target) + } + feePopup.close() + } + } + } + + Rectangle { + width: parent.width + height: 1 + color: Theme.color.neutral4 + } + + ItemDelegate { + id: includeFeeToggleItem + objectName: "feeSelectionIncludeFeeToggle" + implicitWidth: includeFeeToggleContent.implicitWidth + leftPadding + rightPadding + width: feeList.width + height: 44 + leftPadding: 12 + rightPadding: 12 + topPadding: 0 + bottomPadding: 0 + + background: Item { + Rectangle { + anchors.fill: parent + radius: 6 + color: Theme.color.neutral2 + visible: includeFeeToggleItem.hovered + } + } + + contentItem: RowLayout { + id: includeFeeToggleContent + spacing: 12 + + CoreText { + Layout.fillWidth: true + text: qsTr("Include fee in amount") + font.pixelSize: 18 + color: Theme.color.neutral9 + } + + OptionSwitch { + enabled: false + checked: root.includeFeeInAmount + } + } + + HoverHandler { + cursorShape: Qt.PointingHandCursor + } + + onClicked: { + root.includeFeeInAmountToggled(!root.includeFeeInAmount) + feePopup.close() + } + } + } + } + + ListModel { + id: feeModel + ListElement { feeLabel: qsTr("High"); feeDuration: qsTr("(~10 mins)"); target: 1 } + ListElement { feeLabel: qsTr("Default"); feeDuration: qsTr("(~20 mins)"); target: 2 } + ListElement { feeLabel: qsTr("Low"); feeDuration: qsTr("(~60 mins)"); target: 6 } + ListElement { feeLabel: qsTr("Custom"); feeDuration: qsTr("sats/vbyte"); target: -1 } + } +} diff --git a/src/qml/components/InfoBanner.qml b/src/qml/components/InfoBanner.qml new file mode 100644 index 0000000000..eecc7e4e26 --- /dev/null +++ b/src/qml/components/InfoBanner.qml @@ -0,0 +1,161 @@ +// 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" + +Rectangle { + id: root + + enum Layout { + Horizontal, + Vertical + } + + property url iconSource: "" + property string title: "" + property string message: "" + property string primaryButtonText: "" + property string dismissButtonText: "" + property int bannerLayout: InfoBanner.Layout.Horizontal + property int contentMargin: 30 + property int contentSpacing: 15 + + signal primaryClicked() + signal dismissClicked() + + radius: 10 + color: Qt.rgba(Theme.color.blue.r, Theme.color.blue.g, Theme.color.blue.b, 0.2) + implicitHeight: contentLoader.item ? contentLoader.item.height + 2 * contentMargin : 2 * contentMargin + + Loader { + id: contentLoader + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: root.contentMargin + sourceComponent: root.bannerLayout === InfoBanner.Layout.Vertical + ? verticalContent : horizontalContent + } + + Component { + id: horizontalContent + RowLayout { + spacing: root.contentSpacing + + Icon { + visible: root.iconSource != "" + source: root.iconSource + color: Theme.color.neutral7 + size: 24 + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + CoreText { + visible: root.title !== "" + text: root.title + font.pixelSize: 15 + bold: true + color: Theme.color.neutral9 + horizontalAlignment: Text.AlignLeft + Layout.fillWidth: true + } + + CoreText { + visible: root.message !== "" + text: root.message + font.pixelSize: 13 + color: Theme.color.neutral7 + horizontalAlignment: Text.AlignLeft + Layout.fillWidth: true + wrap: true + } + } + + OutlineButton { + objectName: root.objectName !== "" ? root.objectName + "DismissButton" : "" + visible: root.dismissButtonText !== "" + text: root.dismissButtonText + leftPadding: 20 + rightPadding: 20 + onClicked: root.dismissClicked() + } + + ContinueButton { + objectName: root.objectName !== "" ? root.objectName + "PrimaryButton" : "" + visible: root.primaryButtonText !== "" + text: root.primaryButtonText + leftPadding: 20 + rightPadding: 20 + onClicked: root.primaryClicked() + } + } + } + + Component { + id: verticalContent + ColumnLayout { + spacing: root.contentSpacing + + Icon { + visible: root.iconSource != "" + source: root.iconSource + color: Theme.color.neutral7 + size: 24 + Layout.alignment: Qt.AlignHCenter + } + + CoreText { + visible: root.title !== "" + text: root.title + font.pixelSize: 15 + bold: true + color: Theme.color.neutral9 + horizontalAlignment: Text.AlignHCenter + Layout.fillWidth: true + } + + CoreText { + visible: root.message !== "" + text: root.message + font.pixelSize: 13 + color: Theme.color.neutral7 + horizontalAlignment: Text.AlignHCenter + Layout.fillWidth: true + Layout.bottomMargin: 10 + wrap: true + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 15 + + OutlineButton { + objectName: root.objectName !== "" ? root.objectName + "DismissButton" : "" + visible: root.dismissButtonText !== "" + text: root.dismissButtonText + Layout.maximumWidth: 200 + onClicked: root.dismissClicked() + } + + ContinueButton { + objectName: root.objectName !== "" ? root.objectName + "PrimaryButton" : "" + visible: root.primaryButtonText !== "" + text: root.primaryButtonText + Layout.maximumWidth: 200 + leftPadding: 30 + rightPadding: 30 + onClicked: root.primaryClicked() + } + } + } + } +} diff --git a/src/qml/components/LabeledValueField.qml b/src/qml/components/LabeledValueField.qml new file mode 100644 index 0000000000..17fd859b50 --- /dev/null +++ b/src/qml/components/LabeledValueField.qml @@ -0,0 +1,46 @@ +// 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" + +Item { + id: root + + property string labelText: "" + property alias text: valueText.text + property bool wrap: true + property color valueColor: Theme.color.neutral9 + property int labelPixelSize: 18 + property color labelColor: Theme.color.neutral9 + property int valueHorizontalAlignment: Text.AlignLeft + + Layout.fillWidth: true + implicitHeight: Math.max(label.implicitHeight, valueText.implicitHeight) + + CoreText { + id: label + width: 110 + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + horizontalAlignment: Text.AlignLeft + text: root.labelText + font.pixelSize: root.labelPixelSize + color: root.labelColor + } + + CoreText { + id: valueText + anchors.left: label.right + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + horizontalAlignment: root.valueHorizontalAlignment + wrap: root.wrap + font.pixelSize: 18 + color: root.valueColor + } +} diff --git a/src/qml/components/MempoolInformationRows.qml b/src/qml/components/MempoolInformationRows.qml new file mode 100644 index 0000000000..5bbacb70d1 --- /dev/null +++ b/src/qml/components/MempoolInformationRows.qml @@ -0,0 +1,96 @@ +// 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 2.15 as QtQuickBase + +import "../controls" + +ColumnLayout { + id: root + spacing: 4 + + 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" + } + + Setting { + objectName: "mempoolTransactionsRow" + Layout.fillWidth: true + state: "DISABLED" + disabledStateColor: Theme.color.neutral9 + header: qsTr("Transactions") + actionItem: CoreText { + text: Number(nodeModel.mempoolTransactionCount).toLocaleString(Qt.locale(), 'f', 0) + color: Theme.color.neutral7 + font.pixelSize: 18 + fontStyleName: "Regular" + horizontalAlignment: Text.AlignRight + verticalAlignment: Text.AlignVCenter + wrapMode: Text.NoWrap + } + } + + Separator { Layout.fillWidth: true } + + Setting { + objectName: "mempoolMemoryUsedRow" + Layout.fillWidth: true + state: "DISABLED" + disabledStateColor: Theme.color.neutral9 + header: qsTr("Memory used") + actionItem: CoreText { + text: qsTr("%1 / %2") + .arg(root.formatMegabytes(nodeModel.mempoolUsageMB)) + .arg(root.formatMegabytes(nodeModel.mempoolMaxUsageMB)) + color: Theme.color.neutral7 + font.pixelSize: 18 + fontStyleName: "Regular" + horizontalAlignment: Text.AlignRight + verticalAlignment: Text.AlignVCenter + wrapMode: Text.NoWrap + } + } + + Separator { Layout.fillWidth: true } + + Setting { + id: mempoolLimitSetting + objectName: "mempoolSizeLimitRow" + Layout.fillWidth: true + header: qsTr("Mempool size limit") + description: qsTr("Applies after restart") + descriptionColor: Theme.color.neutral7 + errorText: qsTr("This is not a valid mempool size. Please choose a value between %1 and %2 MB.") + .arg(optionsModel.minMaxMempoolSizeMB) + .arg(optionsModel.maxMaxMempoolSizeMB) + showErrorText: false + actionItem: ValueInput { + parentState: mempoolLimitSetting.state + description: optionsModel.maxMempoolSizeMB + validator: QtQuickBase.IntValidator { + bottom: optionsModel.minMaxMempoolSizeMB + top: optionsModel.maxMaxMempoolSizeMB + } + onEditingFinished: { + const mempoolSize = parseInt(text, 10) + if (acceptableInput && checkValidity(optionsModel.minMaxMempoolSizeMB, optionsModel.maxMaxMempoolSizeMB, mempoolSize)) { + optionsModel.maxMempoolSizeMB = mempoolSize + mempoolLimitSetting.forceActiveFocus() + mempoolLimitSetting.showErrorText = false + } else { + mempoolLimitSetting.showErrorText = true + } + } + } + onClicked: { + loadedItem.filled = true + loadedItem.forceActiveFocus() + } + } +} diff --git a/src/qml/components/MiniBlockClock.qml b/src/qml/components/MiniBlockClock.qml new file mode 100644 index 0000000000..9e42877d41 --- /dev/null +++ b/src/qml/components/MiniBlockClock.qml @@ -0,0 +1,187 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import QtQuick 2.15 + +import org.bitcoincore.qt 1.0 + +import "../controls" + +Item { + id: root + objectName: "miniBlockClock" + + property real iconSize: 18 + property bool pageSelected: false + property bool connected: nodeModel.numPeers > 0 + property bool synced: nodeModel.verificationProgress > 0.999 + property bool paused: nodeModel.pause + property bool faulted: nodeModel.faulted + property var networkStatusModelRef: typeof networkStatusModel !== "undefined" ? networkStatusModel : null + property bool offline: networkStatusModelRef !== null && networkStatusModelRef.networkOffline + + readonly property bool showOfflineState: !root.faulted && root.offline + readonly property bool showPausedState: root.paused && !root.faulted && !root.offline + readonly property bool showConnectingState: !root.paused && !root.faulted && !root.offline && !root.connected + readonly property bool showIbdState: !root.paused && !root.faulted && !root.offline && root.connected && !root.synced + readonly property bool showClockState: !root.paused && !root.faulted && !root.offline && root.connected && root.synced + readonly property bool showDialState: dial.visible + readonly property real strokeWidth: Math.max(1, root.width / 12) + readonly property real pausedStrokeWidth: Math.max(1, Math.round(root.strokeWidth)) + readonly property real pausedBarWidth: Math.max(root.pausedStrokeWidth, Math.round(root.width * 0.11)) + readonly property real pausedBarHeight: Math.max(root.pausedStrokeWidth, Math.round(root.height * 0.42)) + readonly property real pausedBarSpacing: Math.max(root.pausedStrokeWidth, Math.round(root.width * 0.12)) + readonly property color dialGlyphColor: root.pageSelected ? Theme.color.confirmationColors[5] : Theme.color.neutral9 + readonly property color pausedGlyphColor: root.pageSelected ? Theme.color.confirmationColors[5] : Theme.color.neutral6 + + implicitWidth: iconSize + implicitHeight: iconSize + width: implicitWidth + height: implicitHeight + + readonly property var fullDialTimeRatioList: [1.0, 0.0] + + BlockClockDial { + id: dial + visible: root.showConnectingState || root.showIbdState || root.showClockState + anchors.fill: parent + penWidth: root.strokeWidth + connectingAnimationDelayMs: 0 + timeRatioList: root.pageSelected ? root.fullDialTimeRatioList : chainModel.timeRatioList + verificationProgress: root.pageSelected ? 1.0 : nodeModel.verificationProgress + connected: root.pageSelected || root.connected + synced: root.pageSelected || root.synced + paused: false + animateDial: root.showConnectingState && !root.pageSelected + showTimeTicks: false + showBlockSegments: false + useGradientArcWhenSynced: !root.pageSelected + backgroundColor: Theme.color.neutral3 + timeTickColor: "transparent" + confirmationColors: Theme.color.confirmationColors + } + + Item { + visible: root.showDialState + anchors.centerIn: parent + width: parent.width * (8 / 18) + height: width + + Rectangle { + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + width: Math.max(1, parent.width * 0.25) + height: width + radius: width / 2 + color: root.dialGlyphColor + } + + Rectangle { + anchors.top: parent.top + anchors.topMargin: parent.height * (3.5 / 8) + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width + height: Math.max(1, parent.height * (1.5 / 8)) + radius: height / 2 + color: root.dialGlyphColor + } + + Rectangle { + anchors.top: parent.top + anchors.topMargin: parent.height * (6.5 / 8) + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width * 0.5 + height: Math.max(1, parent.height * (1.5 / 8)) + radius: height / 2 + color: root.dialGlyphColor + } + } + + Icon { + id: offlineIcon + objectName: "miniBlockClockOfflineIcon" + visible: root.showOfflineState + anchors.centerIn: parent + source: "image://images/network-light" + color: root.pageSelected ? Theme.color.confirmationColors[5] : Theme.color.red + size: root.width + } + + Item { + id: pausedIcon + objectName: "miniBlockClockPausedIcon" + visible: root.showPausedState + width: parent.width + height: parent.height + anchors.centerIn: parent + + Rectangle { + id: pausedRing + objectName: "miniBlockClockPausedRing" + anchors.centerIn: parent + width: Math.round(parent.width - root.pausedStrokeWidth) + height: width + radius: width / 2 + color: "transparent" + border.width: root.pausedStrokeWidth + border.color: root.pausedGlyphColor + } + + Row { + id: pausedBars + objectName: "miniBlockClockPausedBars" + anchors.centerIn: pausedRing + width: implicitWidth + height: implicitHeight + spacing: root.pausedBarSpacing + + Rectangle { + width: root.pausedBarWidth + height: root.pausedBarHeight + radius: width / 2 + color: root.pausedGlyphColor + } + + Rectangle { + width: root.pausedBarWidth + height: root.pausedBarHeight + radius: width / 2 + color: root.pausedGlyphColor + } + } + } + + Item { + visible: root.faulted + anchors.fill: parent + + Rectangle { + anchors.centerIn: parent + width: parent.width - root.strokeWidth + height: width + radius: width / 2 + color: "transparent" + border.width: root.strokeWidth + border.color: Theme.color.red + } + + Rectangle { + anchors.centerIn: parent + anchors.verticalCenterOffset: -parent.height * 0.08 + width: Math.max(root.strokeWidth, parent.width * 0.12) + height: parent.height * 0.34 + radius: width / 2 + color: Theme.color.red + } + + Rectangle { + anchors.centerIn: parent + anchors.verticalCenterOffset: parent.height * 0.22 + width: Math.max(root.strokeWidth, parent.width * 0.12) + height: width + radius: width / 2 + color: Theme.color.red + } + } +} diff --git a/src/qml/components/MonospaceOutputView.qml b/src/qml/components/MonospaceOutputView.qml new file mode 100644 index 0000000000..38ccc7cf6a --- /dev/null +++ b/src/qml/components/MonospaceOutputView.qml @@ -0,0 +1,224 @@ +// 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" + +// MonospaceOutputView — a scrollable monospace text display backed by a +// list model. +// +// Architecture: Flickable + Column + Repeater. Column sums child heights +// exactly (no ListView estimation) so the scrollbar stays stable on long +// rows. Trade-off: no virtualization — callers must bound the model size. +// +// The public model property is named "listModel" (not "model") to avoid +// shadowing the Repeater delegate's implicit "model" context property. +// +// Supports two optional side columns around the main content column: +// Console: [timestamp] | [content] +// Debug log: [line#] | [content] | [relative time] + +Item { + id: root + + Accessible.role: Accessible.List + Accessible.name: accessibleName + + // ── Model ──────────────────────────────────────────────────────────── + + property var listModel: null + property string contentRole: "content" + property string leftColumnRole: "" + property string rightColumnRole: "" + + // ── Style ──────────────────────────────────────────────────────────── + + property int fontPixelSize: 12 + property int contentTextFormat: Text.PlainText + property color contentColor: Theme.color.neutral9 + property color leftColumnColor: Theme.color.neutral5 + property color rightColumnColor: Theme.color.neutral5 + property color selectionColor: Theme.color.orange + property color selectedTextColor: Theme.color.white + + // ── Accessibility ──────────────────────────────────────────────────── + + property string accessibleName: "" + + // ── Scroll behaviour ───────────────────────────────────────────────── + + property bool autoScrollToBottom: true + + // ── Header ─────────────────────────────────────────────────────────── + + property Component header: null + + // ── Column-width samples ───────────────────────────────────────────── + + property string leftColumnSample: "[00:00:00]" + property string rightColumnSample: "99 hr ago" + + // ── Read-only scroll state ──────────────────────────────────────────── + + readonly property bool atBottom: flick.contentHeight <= flick.height || + flick.contentY + flick.height >= flick.contentHeight - 1 + readonly property bool atTop: flick.contentY <= 0 + readonly property real contentY: flick.contentY + readonly property int count: rowRepeater.count + + // ── Methods ────────────────────────────────────────────────────────── + + function scrollToBottom() { + if (flick.contentHeight > flick.height) { + flick.contentY = flick.contentHeight - flick.height + } else { + flick.contentY = 0 + } + flick.returnToBounds() + } + function scrollToTop() { + flick.contentY = 0 + flick.returnToBounds() + } + + // ── Signal ─────────────────────────────────────────────────────────── + + signal scrolled(real y) + + // ── Font metrics (shared across all delegates) ─────────────────────── + + TextMetrics { + id: leftMetrics + font.family: "monospace" + font.pixelSize: root.fontPixelSize + text: root.leftColumnSample + } + TextMetrics { + id: rightMetrics + font.family: "monospace" + font.pixelSize: root.fontPixelSize + text: root.rightColumnSample + } + + // ── Layout ─────────────────────────────────────────────────────────── + + Flickable { + id: flick + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: contentColumn.height + boundsBehavior: Flickable.StopAtBounds + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + minimumSize: 0.05 + } + + onContentYChanged: root.scrolled(contentY) + + Column { + id: contentColumn + x: 16 + width: flick.width - 32 + topPadding: 16 + bottomPadding: 16 + spacing: 2 + + Loader { + width: contentColumn.width + sourceComponent: root.header + } + + Repeater { + id: rowRepeater + model: root.listModel + + delegate: RowLayout { + id: rowRoot + + // `model` is the Repeater delegate's implicit context + // property; marking it required pins the reference so + // bracket lookup (model[roleName]) resolves reliably. + required property var model + + width: contentColumn.width + spacing: 8 + + Accessible.role: Accessible.ListItem + Accessible.name: rowRoot.model[root.contentRole] ?? "" + + // Left column (optional) + Text { + visible: root.leftColumnRole !== "" + text: root.leftColumnRole !== "" + ? (rowRoot.model[root.leftColumnRole] ?? "") : "" + font.family: "monospace" + font.pixelSize: root.fontPixelSize + color: root.leftColumnColor + Layout.preferredWidth: leftMetrics.width + Layout.alignment: Qt.AlignTop + wrapMode: Text.NoWrap + } + + // Main content column: TextEdit for per-row select + copy. + TextEdit { + text: rowRoot.model[root.contentRole] ?? "" + readOnly: true + selectByMouse: true + persistentSelection: false + textFormat: root.contentTextFormat + wrapMode: Text.WrapAnywhere + font.family: "monospace" + font.pixelSize: root.fontPixelSize + color: root.contentColor + selectionColor: root.selectionColor + selectedTextColor: root.selectedTextColor + activeFocusOnPress: true + + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + } + + // Right column (optional) + Text { + visible: root.rightColumnRole !== "" + text: root.rightColumnRole !== "" + ? (rowRoot.model[root.rightColumnRole] ?? "") : "" + font.family: "monospace" + font.pixelSize: root.fontPixelSize + color: root.rightColumnColor + Layout.preferredWidth: rightMetrics.width + Layout.alignment: Qt.AlignTop + horizontalAlignment: Text.AlignRight + wrapMode: Text.NoWrap + } + } + } + } + } + + // Auto-scroll to bottom when rows are added. A bulk append of N rows + // fires onItemAdded N times; _scrollQueued coalesces that to one call. + property bool _scrollQueued: false + function _queueScrollToBottom() { + if (_scrollQueued) return + _scrollQueued = true + Qt.callLater(function() { + _scrollQueued = false + root.scrollToBottom() + }) + } + + Connections { + target: rowRepeater + enabled: root.autoScrollToBottom + function onItemAdded(index, item) { + root._queueScrollToBottom() + } + } +} diff --git a/src/qml/components/NetworkIndicator.qml b/src/qml/components/NetworkIndicator.qml new file mode 100644 index 0000000000..23fe45d498 --- /dev/null +++ b/src/qml/components/NetworkIndicator.qml @@ -0,0 +1,76 @@ +// 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 "../controls" +import "../components" + +import org.bitcoincore.qt 1.0 + +Button { + id: root + property color bgColor + property bool shorten: false + property bool show: true + property var chainModelRef: typeof chainModel !== "undefined" ? chainModel : null + property int textSize: 15 + topPadding: 2 + bottomPadding: 2 + leftPadding: 7 + rightPadding: 7 + state: show && root.chainModelRef !== null ? root.chainModelRef.currentNetworkName : "MAIN" + contentItem: CoreText { + text: root.text + font.pixelSize: root.textSize + color: Theme.color.white + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + id: bg + color: root.bgColor + radius: 2 + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + states: [ + State { + name: "MAIN" + PropertyChanges { + target: root + visible: false + } + }, + State { + name: "TEST" + PropertyChanges { + target: root + visible: true + text: shorten ? qsTr("Testnet") : qsTr("Test Network") + bgColor: Theme.color.green + } + }, + State { + name: "SIGNET" + PropertyChanges { + target: root + visible: true + text: shorten ? qsTr("Signet") : qsTr("Signet Network") + bgColor: Theme.color.amber + } + }, + State { + name: "REGTEST" + PropertyChanges { + target: root + visible: true + text: shorten ? qsTr("Regtest") : qsTr("Regtest Mode") + bgColor: Theme.color.blue + } + } + ] +} diff --git a/src/qml/components/NetworkTrafficGraph.qml b/src/qml/components/NetworkTrafficGraph.qml new file mode 100644 index 0000000000..3d4ded4581 --- /dev/null +++ b/src/qml/components/NetworkTrafficGraph.qml @@ -0,0 +1,96 @@ +// 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" + +import org.bitcoincore.qt 1.0 + +Item { + id: root + property alias backgroundColor: trafficGraph.backgroundColor + property alias borderColor: trafficGraph.borderColor + property alias fillColor: trafficGraph.fillColor + property alias lineColor: trafficGraph.lineColor + property alias markerLineColor: trafficGraph.markerLineColor + property alias maxSamples: trafficGraph.maxSamples + property alias maxValue: trafficGraph.maxValue + property alias valueList: trafficGraph.valueList + property double maxRateBps + property color unitLabelColor: Theme.color.neutral9 + + height: 250 + + LineGraph { + id: trafficGraph + height: root.height + width: root.width + backgroundColor: root.backgroundColor + borderColor: root.borderColor + fillColor: root.fillColor + lineColor: root.lineColor + markerLineColor: root.markerLineColor + maxSamples: root.maxSamples + maxValue: root.maxValue + valueList: root.valueList + + Behavior on backgroundColor { + ColorAnimation { duration: 150 } + } + + Behavior on borderColor { + ColorAnimation { duration: 150 } + } + + Behavior on fillColor { + ColorAnimation { duration: 150 } + } + + Behavior on lineColor { + ColorAnimation { duration: 150 } + } + + Behavior on markerLineColor { + ColorAnimation { duration: 150 } + } + } + + ColumnLayout { + anchors.fill: parent + CoreText { + text: formatBytes(formatMagnitude(root.maxRateBps)) + "/s" + color: root.unitLabelColor + font.pixelSize: 13 + Layout.alignment: Qt.AlignLeft | Qt.AlignBottom + Layout.leftMargin: 5 + Layout.bottomMargin: 6 + } + CoreText { + text: formatBytes(formatMagnitude(root.maxRateBps / 10)) + "/s" + color: root.unitLabelColor + font.pixelSize: 13 + Layout.alignment: Qt.AlignLeft | Qt.AlignBottom + Layout.leftMargin: 5 + Layout.bottomMargin: 5 + } + } + + function formatMagnitude(max_rate) { + var base = Math.floor(Math.log10(max_rate)); + return Math.pow(10.0, base); + } + + function formatBytes(bytes) { + var suffixes = ["Bytes", "KB", "MB", "GB", "TB", "PB"]; + var index = 0; + while (bytes >= 1000 && index < suffixes.length - 1) { + bytes /= 1000; + index++; + } + return bytes.toFixed(0) + " " + suffixes[index]; + } +} diff --git a/src/qml/components/NodeFatalErrorPopup.qml b/src/qml/components/NodeFatalErrorPopup.qml new file mode 100644 index 0000000000..ae65b9740d --- /dev/null +++ b/src/qml/components/NodeFatalErrorPopup.qml @@ -0,0 +1,92 @@ +// 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" + +Popup { + id: root + objectName: "nodeFatalErrorPopup" + readonly property int contentMargin: 28 + + function syncOpenState() { + if (nodeModel.startupError.length > 0 && !opened) { + open() + } + } + + Connections { + target: nodeModel + function onStartupErrorChanged() { + root.syncOpenState() + } + } + + Component.onCompleted: syncOpenState() + + modal: true + closePolicy: Popup.NoAutoClose + padding: 0 + anchors.centerIn: parent + width: parent ? Math.min(parent.width - (2 * contentMargin), 640) : 640 + height: Math.min(implicitHeight, parent ? parent.height - 80 : 520) + implicitHeight: columnLayout.implicitHeight + + background: Rectangle { + color: Theme.color.background + radius: 8 + border.color: Theme.color.neutral4 + border.width: 1 + } + + ColumnLayout { + id: columnLayout + anchors.fill: parent + spacing: 0 + + CoreText { + Layout.fillWidth: true + Layout.preferredHeight: 56 + text: qsTr("Fatal node error") + bold: true + font.pixelSize: 20 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + Separator { Layout.fillWidth: true } + + ScrollView { + id: fatalErrorScroll + Layout.fillWidth: true + Layout.fillHeight: true + Layout.margins: root.contentMargin + contentWidth: availableWidth + clip: true + + CoreText { + objectName: "nodeFatalErrorText" + width: fatalErrorScroll.availableWidth + text: nodeModel.startupError + color: Theme.color.neutral8 + font.pixelSize: 15 + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignLeft + } + } + + ContinueButton { + objectName: "nodeFatalShutdownButton" + Layout.fillWidth: true + Layout.leftMargin: root.contentMargin + Layout.rightMargin: root.contentMargin + Layout.bottomMargin: root.contentMargin + text: qsTr("Shut down") + onClicked: nodeModel.requestShutdown() + } + } +} diff --git a/src/qml/components/NodeInformationPopup.qml b/src/qml/components/NodeInformationPopup.qml new file mode 100644 index 0000000000..4471f4cb32 --- /dev/null +++ b/src/qml/components/NodeInformationPopup.qml @@ -0,0 +1,121 @@ +// 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" + +Popup { + id: root + objectName: "nodeInformationPopup" + + property var rows: [] + readonly property int informationRowCount: informationRepeater.count + readonly property string firstInformationValue: rows.length > 0 ? rows[0].value : "" + readonly property int lastInformationValueLineCount: informationRepeater.count > 0 ? informationRepeater.itemAt(informationRepeater.count - 1).valueLineCount : 0 + readonly property int lastInformationValueWrapMode: informationRepeater.count > 0 ? informationRepeater.itemAt(informationRepeater.count - 1).valueWrapMode : Text.NoWrap + readonly property int contentMargin: 28 + + onAboutToShow: rows = nodeModel.nodeInformationRows() + + modal: true + padding: 0 + anchors.centerIn: parent + width: parent ? Math.min(parent.width - (2 * contentMargin), 760) : 760 + height: Math.min(implicitHeight, parent ? parent.height - 80 : 560) + implicitHeight: Math.min(columnLayout.implicitHeight, 560) + + background: Rectangle { + color: Theme.color.background + radius: 8 + border.color: Theme.color.neutral4 + border.width: 1 + } + + ColumnLayout { + id: columnLayout + anchors.fill: parent + spacing: 0 + + CoreText { + Layout.fillWidth: true + Layout.preferredHeight: 56 + text: qsTr("Node information") + bold: true + font.pixelSize: 20 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + Separator { Layout.fillWidth: true } + + ScrollView { + id: informationScroll + Layout.fillWidth: true + Layout.fillHeight: true + Layout.margins: root.contentMargin + contentWidth: availableWidth + clip: true + + ColumnLayout { + width: informationScroll.availableWidth + spacing: 0 + + Repeater { + id: informationRepeater + model: root.rows + delegate: ColumnLayout { + readonly property int valueLineCount: informationValue.lineCount + readonly property int valueWrapMode: informationValue.wrapMode + + width: parent.width + spacing: 0 + + KeyValueRow { + Layout.fillWidth: true + keyWidth: 150 + key: CoreText { + text: modelData.label + color: Theme.color.neutral7 + font.pixelSize: 13 + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignTop + wrap: false + } + value: CoreText { + id: informationValue + objectName: "nodeInformationValue_" + index + text: modelData.value + color: Theme.color.neutral9 + font.pixelSize: 13 + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignTop + } + } + + Separator { + Layout.fillWidth: true + Layout.topMargin: 10 + Layout.bottomMargin: 10 + visible: index < root.rows.length - 1 + } + } + } + } + } + + ContinueButton { + objectName: "nodeInformationCloseButton" + Layout.fillWidth: true + Layout.leftMargin: root.contentMargin + Layout.rightMargin: root.contentMargin + Layout.bottomMargin: root.contentMargin + text: qsTr("OK") + onClicked: root.close() + } + } +} diff --git a/src/qml/components/NodeRuntimeDialog.qml b/src/qml/components/NodeRuntimeDialog.qml new file mode 100644 index 0000000000..988d157c08 --- /dev/null +++ b/src/qml/components/NodeRuntimeDialog.qml @@ -0,0 +1,236 @@ +// 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" + +Popup { + id: root + objectName: "nodeRuntimeDialog" + readonly property int contentMargin: 28 + + function runtimeButtonSpecs() { + return [ + { id: DialogButtonBox.Ok, name: "Ok" }, + { id: DialogButtonBox.Yes, name: "Yes" }, + { id: DialogButtonBox.No, name: "No" }, + { id: DialogButtonBox.Abort, name: "Abort" }, + { id: DialogButtonBox.Retry, name: "Retry" }, + { id: DialogButtonBox.Ignore, name: "Ignore" }, + { id: DialogButtonBox.Close, name: "Close" }, + { id: DialogButtonBox.Cancel, name: "Cancel" }, + { id: DialogButtonBox.Discard, name: "Discard" }, + { id: DialogButtonBox.Help, name: "Help" }, + { id: DialogButtonBox.Apply, name: "Apply" }, + { id: DialogButtonBox.Reset, name: "Reset" } + ] + } + + function syncOpenState() { + if (nodeModel.runtimeDialogVisible && !opened) { + open() + } else if (!nodeModel.runtimeDialogVisible && opened) { + close() + } + } + + function standardButtonId(button) { + const specs = runtimeButtonSpecs() + for (let i = 0; i < specs.length; ++i) { + if (runtimeButtonBox.standardButton(specs[i].id) === button) { + return specs[i].id + } + } + return DialogButtonBox.NoButton + } + + function tagRuntimeButtons() { + const specs = runtimeButtonSpecs() + for (let i = 0; i < specs.length; ++i) { + const button = runtimeButtonBox.standardButton(specs[i].id) + if (button !== null) { + button.objectName = "nodeRuntimeDialogButton" + specs[i].name + } + } + } + + Connections { + target: nodeModel + function onRuntimeDialogChanged() { + root.syncOpenState() + Qt.callLater(function() { root.tagRuntimeButtons() }) + } + } + + Component.onCompleted: syncOpenState() + onOpened: Qt.callLater(function() { root.tagRuntimeButtons() }) + + modal: true + closePolicy: Popup.NoAutoClose + padding: 0 + anchors.centerIn: parent + width: parent ? Math.min(parent.width - (2 * contentMargin), 640) : 640 + height: Math.min(implicitHeight, parent ? parent.height - 80 : 520) + implicitHeight: columnLayout.implicitHeight + + background: Rectangle { + color: Theme.color.background + radius: 8 + border.color: Theme.color.neutral4 + border.width: 1 + } + + ColumnLayout { + id: columnLayout + anchors.fill: parent + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 64 + Layout.leftMargin: root.contentMargin + Layout.rightMargin: root.contentMargin + spacing: 12 + + Icon { + Layout.preferredWidth: 24 + Layout.preferredHeight: 24 + source: nodeModel.runtimeDialogIcon + color: Theme.color.orange + size: 24 + } + + CoreText { + objectName: "nodeRuntimeDialogTitle" + Layout.fillWidth: true + text: nodeModel.runtimeDialogTitle + bold: true + font.pixelSize: 20 + horizontalAlignment: Text.AlignLeft + } + } + + Separator { Layout.fillWidth: true } + + ScrollView { + id: runtimeMessageScroll + Layout.fillWidth: true + Layout.fillHeight: true + Layout.margins: root.contentMargin + contentWidth: availableWidth + clip: true + + CoreText { + objectName: "nodeRuntimeDialogMessage" + width: runtimeMessageScroll.availableWidth + text: nodeModel.runtimeDialogMessage + color: Theme.color.neutral8 + font.pixelSize: 15 + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignLeft + } + } + + DialogButtonBox { + id: runtimeButtonBox + objectName: "nodeRuntimeDialogButtonBox" + + Layout.fillWidth: true + Layout.margins: root.contentMargin + Layout.topMargin: 0 + padding: 0 + spacing: 12 + standardButtons: nodeModel.runtimeDialogButtons + background: Item {} + + onCountChanged: Qt.callLater(function() { root.tagRuntimeButtons() }) + onStandardButtonsChanged: Qt.callLater(function() { root.tagRuntimeButtons() }) + Component.onCompleted: Qt.callLater(function() { root.tagRuntimeButtons() }) + onClicked: function(button) { + nodeModel.answerRuntimeDialog(root.standardButtonId(button)) + } + + delegate: Button { + id: runtimeDialogButton + + readonly property bool primaryRole: DialogButtonBox.buttonRole === DialogButtonBox.AcceptRole || + DialogButtonBox.buttonRole === DialogButtonBox.YesRole || + DialogButtonBox.buttonRole === DialogButtonBox.ApplyRole + + hoverEnabled: AppMode.isDesktop + leftPadding: 20 + rightPadding: 20 + + contentItem: CoreText { + id: runtimeDialogButtonText + + text: runtimeDialogButton.text + bold: false + fontStyleName: Theme.text.button.styleName + font.pixelSize: Theme.text.button.pixelSize + color: runtimeDialogButton.primaryRole ? Theme.color.white : Theme.color.neutral9 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + + states: [ + State { + name: "OUTLINE_PRESSED"; when: !runtimeDialogButton.primaryRole && runtimeDialogButton.pressed + PropertyChanges { target: runtimeDialogButtonText; color: Theme.color.neutral9 } + }, + State { + name: "OUTLINE_HOVER"; when: !runtimeDialogButton.primaryRole && runtimeDialogButton.hovered + PropertyChanges { target: runtimeDialogButtonText; color: Theme.color.neutral9 } + } + ] + } + + background: Rectangle { + id: runtimeDialogButtonBackground + + implicitHeight: 46 + color: runtimeDialogButton.primaryRole ? Theme.color.orange : Theme.color.background + radius: 5 + border.width: runtimeDialogButton.primaryRole ? 0 : 1 + border.color: runtimeDialogButton.primaryRole ? "transparent" : Theme.color.neutral6 + + states: [ + State { + name: "PRIMARY_PRESSED"; when: runtimeDialogButton.primaryRole && runtimeDialogButton.pressed + PropertyChanges { target: runtimeDialogButtonBackground; color: Theme.color.orangeLight2 } + }, + State { + name: "PRIMARY_HOVER"; when: runtimeDialogButton.primaryRole && runtimeDialogButton.hovered + PropertyChanges { target: runtimeDialogButtonBackground; color: Theme.color.orangeLight1 } + }, + State { + name: "OUTLINE_PRESSED"; when: !runtimeDialogButton.primaryRole && runtimeDialogButton.pressed + PropertyChanges { target: runtimeDialogButtonBackground; border.color: Theme.color.orangeLight2 } + }, + State { + name: "OUTLINE_HOVER"; when: !runtimeDialogButton.primaryRole && runtimeDialogButton.hovered + PropertyChanges { target: runtimeDialogButtonBackground; border.color: Theme.color.neutral9 } + }, + State { + name: "DISABLED"; when: !runtimeDialogButton.enabled + PropertyChanges { target: runtimeDialogButtonBackground; color: Theme.color.neutral2 } + PropertyChanges { target: runtimeDialogButtonBackground; border.color: Theme.color.neutral2 } + } + ] + + Behavior on color { + ColorAnimation { duration: 150 } + } + + FocusBorder { + visible: runtimeDialogButton.visualFocus + } + } + } + } + } +} diff --git a/src/qml/components/NodeStatusActions.qml b/src/qml/components/NodeStatusActions.qml new file mode 100644 index 0000000000..50a5ff60b3 --- /dev/null +++ b/src/qml/components/NodeStatusActions.qml @@ -0,0 +1,45 @@ +// 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" + +RowLayout { + id: root + spacing: 4 + + IconButton { + objectName: "nodeWarningsButton" + visible: nodeModel.hasWarnings + iconSource: "image://images/alert-filled" + iconColor: Theme.color.orange + hoverColor: Theme.color.orangeLight1 + activeColor: Theme.color.orange + size: 34 + onClicked: warningsPopup.open() + } + + IconButton { + objectName: "nodeInformationButton" + iconSource: "image://images/info" + iconColor: Theme.color.neutral7 + hoverColor: Theme.color.neutral9 + activeColor: Theme.color.orange + size: 34 + onClicked: informationPopup.open() + } + + NodeWarningsPopup { + id: warningsPopup + parent: Overlay.overlay + } + + NodeInformationPopup { + id: informationPopup + parent: Overlay.overlay + } +} diff --git a/src/qml/components/NodeWarningsPopup.qml b/src/qml/components/NodeWarningsPopup.qml new file mode 100644 index 0000000000..d7f29be550 --- /dev/null +++ b/src/qml/components/NodeWarningsPopup.qml @@ -0,0 +1,117 @@ +// 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" + +Popup { + id: root + objectName: "nodeWarningsPopup" + + readonly property int warningCount: warningRepeater.count + readonly property string firstWarningText: nodeModel.warningList.length > 0 ? nodeModel.warningList[0] : "" + readonly property int firstWarningLineCount: warningRepeater.count > 0 ? warningRepeater.itemAt(0).warningLineCount : 0 + readonly property int firstWarningWrapMode: warningRepeater.count > 0 ? warningRepeater.itemAt(0).warningWrapMode : Text.NoWrap + readonly property int contentMargin: 28 + + modal: true + padding: 0 + anchors.centerIn: parent + width: parent ? Math.min(parent.width - (2 * contentMargin), 720) : 720 + height: Math.min(implicitHeight, parent ? parent.height - 80 : 520) + implicitHeight: Math.min(columnLayout.implicitHeight, 520) + + background: Rectangle { + color: Theme.color.background + radius: 8 + border.color: Theme.color.neutral4 + border.width: 1 + } + + ColumnLayout { + id: columnLayout + anchors.fill: parent + spacing: 0 + + CoreText { + Layout.fillWidth: true + Layout.preferredHeight: 56 + text: qsTr("Node warnings") + bold: true + font.pixelSize: 20 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + Separator { Layout.fillWidth: true } + + ScrollView { + id: warningsScroll + Layout.fillWidth: true + Layout.fillHeight: true + Layout.margins: root.contentMargin + contentWidth: availableWidth + clip: true + + ColumnLayout { + width: warningsScroll.availableWidth + spacing: 12 + + Repeater { + id: warningRepeater + model: nodeModel.warningList + delegate: RowLayout { + readonly property int warningLineCount: warningText.lineCount + readonly property int warningWrapMode: warningText.wrapMode + + width: parent.width + spacing: 10 + + Icon { + Layout.preferredWidth: 18 + Layout.preferredHeight: 18 + Layout.alignment: Qt.AlignTop + source: "image://images/alert-filled" + color: Theme.color.orange + size: 18 + } + + CoreText { + id: warningText + objectName: "nodeWarningText_" + index + Layout.fillWidth: true + text: modelData + color: Theme.color.neutral8 + font.pixelSize: 15 + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignLeft + } + } + } + + CoreText { + objectName: "nodeNoWarningsText" + Layout.fillWidth: true + visible: nodeModel.warningList.length === 0 + text: qsTr("No current warnings.") + color: Theme.color.neutral7 + font.pixelSize: 15 + } + } + } + + ContinueButton { + objectName: "nodeWarningsCloseButton" + Layout.fillWidth: true + Layout.leftMargin: root.contentMargin + Layout.rightMargin: root.contentMargin + Layout.bottomMargin: root.contentMargin + text: qsTr("OK") + onClicked: root.close() + } + } +} diff --git a/src/qml/components/OptionPopup.qml b/src/qml/components/OptionPopup.qml new file mode 100644 index 0000000000..3742cf2c54 --- /dev/null +++ b/src/qml/components/OptionPopup.qml @@ -0,0 +1,24 @@ +// Copyright (c) 2025 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" + +Popup { + id: root + + background: Item { + anchors.fill: parent + Rectangle { + color: Theme.color.neutral0 + border.color: Theme.color.neutral4 + radius: 5 + border.width: 1 + anchors.fill: parent + } + } +} diff --git a/src/qml/components/PaymentDetailOptionsPopup.qml b/src/qml/components/PaymentDetailOptionsPopup.qml new file mode 100644 index 0000000000..b57df88d5a --- /dev/null +++ b/src/qml/components/PaymentDetailOptionsPopup.qml @@ -0,0 +1,97 @@ +// 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" + +OptionPopup { + id: root + + property bool hasLabel: false + property bool hasMessage: false + property bool hasNoteSelf: false + + signal addName() + signal addMessage() + signal addNoteSelf() + signal useAsTemplate() + signal deleteFromHistory() + + implicitWidth: 300 + implicitHeight: column.implicitHeight + 2 * column.anchors.topMargin + + padding: 0 + clip: true + modal: true + dim: false + + ColumnLayout { + id: column + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 5 + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 0 + + EllipsisMenuButtonItem { + Layout.fillWidth: true + text: qsTr("Add name") + visible: !root.hasLabel + onClicked: { + root.addName() + root.close() + } + } + + EllipsisMenuButtonItem { + Layout.fillWidth: true + text: qsTr("Add message") + visible: !root.hasMessage + onClicked: { + root.addMessage() + root.close() + } + } + + EllipsisMenuButtonItem { + Layout.fillWidth: true + text: qsTr("Add note to self") + visible: !root.hasNoteSelf + onClicked: { + root.addNoteSelf() + root.close() + } + } + + EllipsisMenuButtonItem { + Layout.fillWidth: true + text: qsTr("Save as file") + enabled: false + onClicked: root.close() + } + + EllipsisMenuButtonItem { + Layout.fillWidth: true + text: qsTr("Use as template") + onClicked: { + root.close() + root.useAsTemplate() + } + } + + EllipsisMenuButtonItem { + Layout.fillWidth: true + text: qsTr("Delete from history") + onClicked: { + root.close() + root.deleteFromHistory() + } + } + } +} diff --git a/src/qml/components/PeersIndicator.qml b/src/qml/components/PeersIndicator.qml new file mode 100644 index 0000000000..7faedad947 --- /dev/null +++ b/src/qml/components/PeersIndicator.qml @@ -0,0 +1,46 @@ +// 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 "../controls" + +Row { + id: root + required property int numOutboundPeers + required property int maxNumOutboundPeers + required property bool paused + property int size: 5 + property real indicatorDimensions: 3 + property real indicatorSpacing: 5 + + height: root.indicatorDimensions + + spacing: root.indicatorSpacing + Repeater { + model: 5 + Rectangle { + width: root.indicatorDimensions + height: root.indicatorDimensions + radius: width / 2 + color: Theme.color.neutral9 + opacity: (index === 0 && root.numOutboundPeers > 0) || (index + 1 <= root.size * root.numOutboundPeers / root.maxNumOutboundPeers) ? 0.95 : 0.45 + Behavior on opacity { OpacityAnimator { duration: 150 } } + SequentialAnimation on opacity { + loops: Animation.Infinite + running: numOutboundPeers === 0 && index === 0 && !root.paused + SmoothedAnimation { to: 0; velocity: 2.2 } + SmoothedAnimation { to: 1; velocity: 2.2 } + } + PropertyAnimation on opacity { + running: root.paused + to: .45; + duration: 150 + } + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + } +} diff --git a/src/qml/components/ProxySettings.qml b/src/qml/components/ProxySettings.qml new file mode 100644 index 0000000000..edd3ab7453 --- /dev/null +++ b/src/qml/components/ProxySettings.qml @@ -0,0 +1,148 @@ +// 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 org.bitcoincore.qt 1.0 + +ColumnLayout { + property string ipAndPortHeader: qsTr("IP and Port") + property string invalidIpError: qsTr("Invalid IP address or port format. Use '255.255.255.255:65535' or '[ffff::]:65535'") + + spacing: 4 + Header { + headerBold: true + center: false + header: qsTr("Default Proxy") + headerSize: 24 + description: qsTr("Run peer connections through a proxy (SOCKS5) for improved privacy. The default proxy supports connections via IPv4, IPv6 and Tor.") + descriptionSize: 15 + Layout.bottomMargin: 10 + } + Separator { Layout.fillWidth: true } + Setting { + id: defaultProxyEnable + Layout.fillWidth: true + header: qsTr("Enable") + actionItem: OptionSwitch { + objectName: "proxyEnableSwitch" + checked: optionsModel.proxyEnabled + onCheckedChanged: { + // Default to the standard SOCKS5 proxy address (port 9050). + // The Tor daemon also defaults to port 9050; the user can + // edit the address after enabling. + if (checked && optionsModel.proxyAddress === "") { + optionsModel.proxyAddress = nodeModel.defaultProxyAddress() + } + optionsModel.proxyEnabled = checked + defaultProxy.state = checked ? "FILLED" : "DISABLED" + } + } + onClicked: { + loadedItem.toggle() + loadedItem.toggled() + } + } + Separator { Layout.fillWidth: true } + Setting { + id: defaultProxy + Layout.fillWidth: true + header: ipAndPortHeader + errorText: invalidIpError + state: !defaultProxyEnable.loadedItem.checked ? "DISABLED" : "FILLED" + showErrorText: !defaultProxy.loadedItem.validInput && defaultProxyEnable.loadedItem.checked + actionItem: IPAddressValueInput { + objectName: "proxyAddressInput" + parentState: defaultProxy.state + description: optionsModel.proxyAddress.length > 0 + ? optionsModel.proxyAddress + : nodeModel.defaultProxyAddress() + activeFocusOnTab: true + // IPAddressValueInput.text is initially bound to `description`. + // If a stored address was loaded, text is non-empty here and we + // mark the field as filled so it renders in the active style. + Component.onCompleted: { + if (text !== "") filled = true + } + onTextChanged: { + validInput = nodeModel.validateProxyAddress(text) + if (validInput) optionsModel.proxyAddress = text + } + } + onClicked: { + loadedItem.filled = true + loadedItem.forceActiveFocus() + } + } + Separator { Layout.fillWidth: true } + Header { + headerBold: true + center: false + header: qsTr("Tor Proxy") + headerSize: 24 + description: qsTr("Run Tor connections through a dedicated proxy.") + descriptionSize: 15 + Layout.topMargin: 35 + Layout.bottomMargin: 10 + } + Separator { Layout.fillWidth: true } + Setting { + id: torProxyEnable + Layout.fillWidth: true + header: qsTr("Enable") + actionItem: OptionSwitch { + objectName: "torEnableSwitch" + checked: optionsModel.torEnabled + onCheckedChanged: { + // Default to the standard SOCKS5 proxy address (port 9050). + // The Tor daemon also defaults to port 9050; the user can + // edit the address after enabling. + if (checked && optionsModel.torAddress === "") { + optionsModel.torAddress = nodeModel.defaultProxyAddress() + } + optionsModel.torEnabled = checked + torProxy.state = checked ? "FILLED" : "DISABLED" + } + } + onClicked: { + loadedItem.toggle() + loadedItem.toggled() + } + } + Separator { Layout.fillWidth: true } + Setting { + id: torProxy + Layout.fillWidth: true + header: ipAndPortHeader + errorText: invalidIpError + state: !torProxyEnable.loadedItem.checked ? "DISABLED" : "FILLED" + showErrorText: !torProxy.loadedItem.validInput && torProxyEnable.loadedItem.checked + actionItem: IPAddressValueInput { + objectName: "torAddressInput" + parentState: torProxy.state + description: optionsModel.torAddress.length > 0 + ? optionsModel.torAddress + : nodeModel.defaultProxyAddress() + activeFocusOnTab: true + // IPAddressValueInput.text is initially bound to `description`. + // If a stored address was loaded, text is non-empty here and we + // mark the field as filled so it renders in the active style. + Component.onCompleted: { + if (text !== "") filled = true + } + onTextChanged: { + validInput = nodeModel.validateProxyAddress(text) + if (validInput) optionsModel.torAddress = text + } + } + onClicked: { + loadedItem.filled = true + loadedItem.forceActiveFocus() + } + } + Separator { Layout.fillWidth: true } +} diff --git a/src/qml/components/QRCodePopup.qml b/src/qml/components/QRCodePopup.qml new file mode 100644 index 0000000000..d8d059e515 --- /dev/null +++ b/src/qml/components/QRCodePopup.qml @@ -0,0 +1,112 @@ +// 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" + +Popup { + id: root + + property string code: "" + property string label: "" + readonly property string objectNamePrefix: root.objectName.length > 0 ? root.objectName : "qrCodePopup" + signal copyRequested() + + anchors.centerIn: Overlay.overlay + width: 320 + modal: true + dim: true + padding: 20 + + Overlay.modal: Rectangle { + color: Qt.rgba(0, 0, 0, 0.6) + } + + background: Rectangle { + color: Theme.color.neutral0 + border.color: Theme.color.neutral4 + border.width: 1 + radius: 10 + } + + QRImage { + id: qrSaveImage + visible: false + width: 280 + height: 280 + backgroundColor: "white" + foregroundColor: "black" + code: root.code + } + + FileDialog { + id: saveDialog + fileMode: FileDialog.SaveFile + nameFilters: [qsTr("PNG files (*.png)")] + currentFile: "file:///" + (root.label !== "" ? root.label : qsTr("payment-request")) + ".png" + onAccepted: { + qrSaveImage.grabToImage(function(result) { + var path = saveDialog.selectedFile.toString().replace("file://", "") + if (!path.endsWith(".png")) path += ".png" + result.saveToFile(path) + }) + } + } + + contentItem: ColumnLayout { + spacing: 15 + + Item { + Layout.fillWidth: true + implicitHeight: closeButton.height + + IconButton { + id: closeButton + objectName: root.objectNamePrefix + "CloseButton" + anchors.right: parent.right + iconSource: "image://images/cross" + Accessible.name: qsTr("Close") + size: 30 + onClicked: root.close() + } + } + + QRImage { + id: qrImage + objectName: root.objectNamePrefix + "Image" + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: 280 + Layout.preferredHeight: 280 + backgroundColor: "transparent" + foregroundColor: Theme.color.neutral9 + code: root.code + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + OutlineButton { + objectName: root.objectNamePrefix + "DownloadButton" + Layout.fillWidth: true + Layout.preferredWidth: 0 + text: qsTr("Download") + onClicked: saveDialog.open() + } + + OutlineButton { + objectName: root.objectNamePrefix + "CopyButton" + Layout.fillWidth: true + Layout.preferredWidth: 0 + text: qsTr("Copy") + onClicked: root.copyRequested() + } + } + } +} diff --git a/src/qml/components/ReceiveOptionsPopup.qml b/src/qml/components/ReceiveOptionsPopup.qml new file mode 100644 index 0000000000..aba4807d87 --- /dev/null +++ b/src/qml/components/ReceiveOptionsPopup.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.Controls 2.15 +import QtQuick.Layouts 1.15 + +import "../controls" + +OptionPopup { + id: root + objectName: "receiveOptionsPopup" + + property alias showName: nameToggle.checked + property alias showMessage: messageToggle.checked + property alias showNoteSelf: noteSelfToggle.checked + property alias showAddressType: addressTypeToggle.checked + property bool showRequestActions: false + + signal useAsTemplate() + signal deleteFromHistory() + signal viewAddressHistory() + + implicitWidth: 300 + implicitHeight: columnLayout.implicitHeight + 20 + + clip: true + modal: true + dim: false + + ColumnLayout { + id: columnLayout + anchors.centerIn: parent + anchors.margins: 10 + spacing: 5 + + EllipsisMenuToggleItem { + id: nameToggle + objectName: "receiveOptionsNameToggle" + Layout.fillWidth: true + text: qsTr("Name") + checked: true + } + + EllipsisMenuToggleItem { + id: messageToggle + objectName: "receiveOptionsMessageToggle" + Layout.fillWidth: true + text: qsTr("Message") + checked: true + } + + EllipsisMenuToggleItem { + id: noteSelfToggle + objectName: "receiveOptionsNoteSelfToggle" + Layout.fillWidth: true + text: qsTr("Note to self") + checked: true + } + + EllipsisMenuToggleItem { + id: addressTypeToggle + objectName: "receiveOptionsAddressTypeToggle" + Layout.fillWidth: true + text: qsTr("Address type") + } + + EllipsisMenuButtonItem { + objectName: "receiveOptionsViewAddressHistoryButton" + Layout.fillWidth: true + text: qsTr("View address history") + onClicked: { + root.close() + root.viewAddressHistory() + } + } + + EllipsisMenuButtonItem { + objectName: "receiveOptionsUseAsTemplateButton" + Layout.fillWidth: true + visible: root.showRequestActions + text: qsTr("Use as template") + onClicked: { + root.close() + root.useAsTemplate() + } + } + + EllipsisMenuButtonItem { + objectName: "receiveOptionsDeleteFromHistoryButton" + Layout.fillWidth: true + visible: root.showRequestActions + text: qsTr("Delete from history") + onClicked: { + root.close() + root.deleteFromHistory() + } + } + } +} diff --git a/src/qml/components/Separator.qml b/src/qml/components/Separator.qml new file mode 100644 index 0000000000..8e14bb9ff3 --- /dev/null +++ b/src/qml/components/Separator.qml @@ -0,0 +1,16 @@ +// 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 "../controls" + +Rectangle { + height: 1 + color: enabled ? Theme.color.neutral5 : Theme.color.neutral4 + + Behavior on color { + ColorAnimation { duration: 150 } + } +} diff --git a/src/qml/components/StorageLocations.qml b/src/qml/components/StorageLocations.qml new file mode 100644 index 0000000000..7e5832158b --- /dev/null +++ b/src/qml/components/StorageLocations.qml @@ -0,0 +1,66 @@ +// Copyright (c) 2022 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" + +ColumnLayout { + ButtonGroup { + id: group + } + spacing: 15 + OptionButton { + id: defaultDirOption + Layout.fillWidth: true + ButtonGroup.group: group + text: qsTr("Default") + description: qsTr("Your application directory.") + customDir: optionsModel.getDefaultDataDirString + checked: optionsModel.dataDir === optionsModel.getDefaultDataDirString + onClicked: { + defaultDirOption.checked = true + optionsModel.dataDir = optionsModel.getDefaultDataDirString + } + } + OptionButton { + id: customDirOption + Layout.fillWidth: true + ButtonGroup.group: group + text: qsTr("Custom") + description: qsTr("Choose the directory and storage device.") + customDir: customDirOption.checked ? fileDialog.currentFolder.toString() : "" + checked: optionsModel.dataDir !== optionsModel.getDefaultDataDirString + onClicked: fileDialog.open() + } + FileDialog { + id: fileDialog + onAccepted: { + optionsModel.setCustomDataDirString(fileDialog.selectedFile.toString()) + var customDataDir = fileDialog.selectedFile.toString(); + if (customDataDir !== "") { + optionsModel.setCustomDataDirArgs(customDataDir) + customDirOption.customDir = optionsModel.getCustomDataDirString() + if (optionsModel.dataDir !== optionsModel.getDefaultDataDirString) { + customDirOption.checked = true + defaultDirOption.checked = false + } + } + } + onRejected: { + console.log("Custom datadir selection canceled") + if (optionsModel.dataDir !== optionsModel.getDefaultDataDirString) { + customDirOption.checked = true + defaultDirOption.checked = false + } else { + defaultDirOption.checked = true + } + } + } +} diff --git a/src/qml/components/StorageOptions.qml b/src/qml/components/StorageOptions.qml new file mode 100644 index 0000000000..eed962b951 --- /dev/null +++ b/src/qml/components/StorageOptions.qml @@ -0,0 +1,63 @@ +// Copyright (c) 2022 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 + property bool customStorage: false + property int customStorageAmount + ButtonGroup { + id: group + } + spacing: 15 + OptionButton { + Layout.fillWidth: true + ButtonGroup.group: group + text: qsTr("Reduce storage") + description: qsTr("Uses about %1GB. For simple wallet use.").arg(chainModel.assumedChainstateSize + 2) + recommended: true + checked: !root.customStorage && optionsModel.prune + onClicked: { + optionsModel.prune = true + optionsModel.pruneSizeGB = 2 + } + Component.onCompleted: { + optionsModel.prune = true + optionsModel.pruneSizeGB = 2 + } + } + OptionButton { + Layout.fillWidth: true + ButtonGroup.group: group + text: qsTr("Store all data") + checked: !optionsModel.prune + description: qsTr("Uses about %1GB. Support the network.").arg( + chainModel.assumedBlockchainSize + chainModel.assumedChainstateSize) + onClicked: { + optionsModel.prune = false + } + } + Loader { + Layout.fillWidth: true + active: root.customStorage + visible: active + sourceComponent: OptionButton { + ButtonGroup.group: group + checked: root.customStorage && optionsModel.prune + text: qsTr("Custom") + description: qsTr("Storing about %1GB of data.").arg(root.customStorageAmount + chainModel.assumedChainstateSize) + onClicked: { + optionsModel.prune = true + optionsModel.pruneSizeGB = root.customStorageAmount + } + } + } +} diff --git a/src/qml/components/StorageSettings.qml b/src/qml/components/StorageSettings.qml new file mode 100644 index 0000000000..5916536934 --- /dev/null +++ b/src/qml/components/StorageSettings.qml @@ -0,0 +1,74 @@ +// Copyright (c) 2022 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 + property bool customStorage: false + property int customStorageAmount + spacing: 4 + Setting { + Layout.fillWidth: true + header: qsTr("Store recent blocks only") + actionItem: OptionSwitch { + checked: optionsModel.prune + onToggled: optionsModel.prune = checked + onCheckedChanged: { + if (checked == false) { + pruneTargetSetting.state = "DISABLED" + } else { + pruneTargetSetting.state = "FILLED" + } + } + } + onClicked: { + loadedItem.toggle() + loadedItem.toggled() + } + } + Separator { Layout.fillWidth: true } + Setting { + id: pruneTargetSetting + Layout.fillWidth: true + header: qsTr("Block Storage limit (GB)") + errorText: qsTr("This is not a valid prune target. Please choose a value that is equal to or larger than 1GB") + showErrorText: false + actionItem: ValueInput { + parentState: pruneTargetSetting.state + description: optionsModel.pruneSizeGB + onEditingFinished: { + if (parseInt(text) < 1) { + pruneTargetSetting.showErrorText = true + } else { + root.customStorage = true + root.customStorageAmount = parseInt(text) + optionsModel.pruneSizeGB = parseInt(text) + pruneTargetSetting.forceActiveFocus() + pruneTargetSetting.showErrorText = false + } + } + } + onClicked: { + loadedItem.filled = true + loadedItem.forceActiveFocus() + } + } + Separator { Layout.fillWidth: true } + Setting { + id: customDataDirSetting + Layout.fillWidth: true + header: qsTr("Data Directory") + } + CoreText { + Layout.fillWidth: true + text: optionsModel.dataDir + color: Theme.color.neutral7 + font.pixelSize: 15 + horizontalAlignment: Text.AlignLeft + } +} diff --git a/src/qml/components/ThemeSettings.qml b/src/qml/components/ThemeSettings.qml new file mode 100644 index 0000000000..0ecb8c0e5e --- /dev/null +++ b/src/qml/components/ThemeSettings.qml @@ -0,0 +1,76 @@ +// 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 Qt.labs.settings 1.0 +import org.bitcoincore.qt 1.0 +import "../controls" + +ColumnLayout { + id: root + spacing: 4 + + signal designSystemRequested + + Settings { + 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/src/qml/components/ToastBanner.qml b/src/qml/components/ToastBanner.qml new file mode 100644 index 0000000000..461c8b910f --- /dev/null +++ b/src/qml/components/ToastBanner.qml @@ -0,0 +1,98 @@ +// 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" + +Rectangle { + id: root + + property url iconSource: "" + property color iconColor: Theme.color.white + property string text: "" + property string textObjectName: "" + property color textColor: Theme.color.white + property color backgroundColor: Theme.color.neutral2 + property bool showsCloseButton: false + property string actionText: "" + + signal actionTriggered() + signal dismissed() + + color: backgroundColor + radius: 5 + implicitHeight: Math.max(50, contentRow.implicitHeight + 20) + + RowLayout { + id: contentRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 20 + anchors.rightMargin: 12 + spacing: 12 + + Icon { + visible: root.iconSource != "" + source: root.iconSource + color: root.iconColor + size: 18 + Layout.alignment: Qt.AlignVCenter + } + + CoreText { + objectName: root.textObjectName + Layout.fillWidth: true + text: root.text + color: root.textColor + font: Theme.text.description.font + horizontalAlignment: root.iconSource != "" ? Text.AlignLeft : Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + wrapMode: Text.WordWrap + } + + Button { + id: actionButton + visible: root.actionText !== "" + text: root.actionText + padding: 6 + background: null + contentItem: CoreText { + text: actionButton.text + color: root.textColor + font: Theme.text.description.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + opacity: actionButton.hovered ? 0.75 : 1.0 + } + onClicked: root.actionTriggered() + HoverHandler { + cursorShape: Qt.PointingHandCursor + } + } + + Rectangle { + visible: root.actionText !== "" && root.showsCloseButton + Layout.preferredWidth: 1 + Layout.preferredHeight: 20 + color: Qt.rgba(root.textColor.r, root.textColor.g, root.textColor.b, 0.4) + } + + Icon { + visible: root.showsCloseButton + source: "image://images/cross" + color: root.textColor + size: 14 + enabled: true + padding: 6 + onClicked: root.dismissed() + HoverHandler { + cursorShape: Qt.PointingHandCursor + } + } + } +} diff --git a/src/qml/components/ToastPopup.qml b/src/qml/components/ToastPopup.qml new file mode 100644 index 0000000000..ca38c7d52e --- /dev/null +++ b/src/qml/components/ToastPopup.qml @@ -0,0 +1,116 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import QtQuick 2.15 +import QtQuick.Controls 2.15 + +import "../controls" + +Item { + id: root + + property Item popupAnchor: null + property int popupAnchorHorizontalOffset: 0 + property int popupOffset: 8 + property int popupMargin: 8 + property int visibleDurationMs: 1500 + property string text: "" + property string textObjectName: "" + property url iconSource: "" + property color iconColor: Theme.color.neutral9 + property int iconSize: 14 + property color textColor: Theme.color.neutral9 + property color backgroundColor: Theme.color.neutral0 + property color borderColor: Theme.color.neutral4 + property int horizontalPadding: 10 + property int verticalPadding: 4 + property int spacing: 4 + property var textStyle: Theme.text.caption + + visible: popupToast.opened + implicitWidth: 0 + implicitHeight: 0 + + function show(anchor, horizontalOffset) { + if (anchor !== undefined && anchor !== null) { + root.popupAnchor = anchor + } + root.popupAnchorHorizontalOffset = horizontalOffset !== undefined ? horizontalOffset : 0 + + popupToast.open() + Qt.callLater(function() { + root.updatePopupPosition() + }) + hideTimer.restart() + } + + function hide() { + hideTimer.stop() + popupToast.close() + } + + function updatePopupPosition() { + if (!root.popupAnchor || !Overlay.overlay) { + return + } + var anchorPos = root.popupAnchor.mapToItem(Overlay.overlay, 0, 0) + var preferredX = anchorPos.x + (root.popupAnchor.width - popupToast.width) / 2 + root.popupAnchorHorizontalOffset + var maxX = Math.max(root.popupMargin, Overlay.overlay.width - popupToast.width - root.popupMargin) + popupToast.x = Math.min(Math.max(root.popupMargin, preferredX), maxX) + popupToast.y = Math.max(root.popupMargin, anchorPos.y - popupToast.height - root.popupOffset) + } + + Timer { + id: hideTimer + interval: root.visibleDurationMs + onTriggered: root.hide() + } + + Popup { + id: popupToast + + parent: Overlay.overlay + padding: 0 + modal: false + dim: false + closePolicy: Popup.NoAutoClose + width: popupContent.implicitWidth + height: popupContent.implicitHeight + background: null + + onOpened: Qt.callLater(function() { + root.updatePopupPosition() + }) + onWidthChanged: root.updatePopupPosition() + onHeightChanged: root.updatePopupPosition() + + enter: Transition { + NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 150 } + } + + exit: Transition { + NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 150 } + } + + contentItem: Tooltip { + id: popupContent + width: implicitWidth + height: implicitHeight + text: root.text + textObjectName: root.textObjectName + iconSource: root.iconSource + iconColor: root.iconColor + iconSize: root.iconSize + textColor: root.textColor + backgroundColor: root.backgroundColor + borderColor: root.borderColor + arrowAtBottom: true + centerBubbleOnArrow: true + horizontalPadding: root.horizontalPadding + verticalPadding: root.verticalPadding + contentSpacing: root.spacing + textStyle: root.textStyle + } + } +} diff --git a/src/qml/components/Tooltip.qml b/src/qml/components/Tooltip.qml new file mode 100644 index 0000000000..a9eb19a05d --- /dev/null +++ b/src/qml/components/Tooltip.qml @@ -0,0 +1,103 @@ +// Copyright (c) 2024 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" + +Item { + id: root + + property alias text: tooltipText.text + property string textObjectName: "" + property url iconSource: "" + property color iconColor: Theme.color.neutral9 + property int iconSize: 14 + property color textColor: Theme.color.neutral9 + property color backgroundColor: Theme.color.neutral0 + property color borderColor: Theme.color.neutral4 + property bool arrowAtBottom: false + property bool centerBubbleOnArrow: false + property int horizontalPadding: 15 + property int verticalPadding: 10 + property int contentSpacing: 6 + property int arrowHorizontalInset: 10 + property int arrowWidth: 22 + property int arrowHeight: 10 + property var textStyle: Theme.text.description + + implicitWidth: tooltipBg.width + implicitHeight: tooltipBg.height + arrow.height - 1 + + Rectangle { + id: tooltipBg + color: root.backgroundColor + border.color: root.borderColor + radius: 5 + border.width: 1 + width: contentRow.implicitWidth + 2 * root.horizontalPadding + height: contentRow.implicitHeight + 2 * root.verticalPadding + x: root.centerBubbleOnArrow + ? (root.width - width) / 2 + : arrow.x + arrow.width + root.arrowHorizontalInset - width + y: root.arrowAtBottom ? 0 : arrow.height - 1 + } + + Canvas { + id: arrow + width: root.arrowWidth + height: root.arrowHeight + rotation: root.arrowAtBottom ? 180 : 0 + anchors.horizontalCenter: root.horizontalCenter + y: root.arrowAtBottom ? tooltipBg.y + tooltipBg.height - 1 : 0 + + onPaint: { + var ctx = getContext("2d") + ctx.clearRect(0, 0, width, height) + ctx.beginPath() + ctx.moveTo(width / 2, 1) + ctx.lineTo(width - 1, height) + ctx.lineTo(1, height) + ctx.closePath() + ctx.fillStyle = root.backgroundColor + ctx.fill() + ctx.strokeStyle = root.borderColor + ctx.lineWidth = 1 + ctx.stroke() + } + } + + Connections { + target: root + function onBackgroundColorChanged() { arrow.requestPaint() } + function onBorderColorChanged() { arrow.requestPaint() } + function onArrowAtBottomChanged() { arrow.requestPaint() } + } + + RowLayout { + id: contentRow + anchors.centerIn: tooltipBg + spacing: root.contentSpacing + + Icon { + visible: root.iconSource != "" + source: root.iconSource + color: root.iconColor + size: root.iconSize + Layout.alignment: Qt.AlignVCenter + } + + CoreText { + id: tooltipText + objectName: root.textObjectName + text: "" + color: root.textColor + font: root.textStyle.font + wrapMode: Text.NoWrap + Layout.alignment: Qt.AlignVCenter + } + } +} diff --git a/src/qml/components/TotalBytesIndicator.qml b/src/qml/components/TotalBytesIndicator.qml new file mode 100644 index 0000000000..2c1c55b6da --- /dev/null +++ b/src/qml/components/TotalBytesIndicator.qml @@ -0,0 +1,28 @@ +// 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" + +RowLayout { + id: root + property string indicatorText + property color indicatorColor + + Rectangle { + Layout.alignment: Qt.AlignVCenter + height: 10 + width: 10 + radius: 5 + color: root.indicatorColor + } + CoreText { + Layout.alignment: Qt.AlignHCenter + color: Theme.color.neutral9 + text: root.indicatorText + font.pixelSize: 18 + } +} diff --git a/src/qml/components/WalletMigrationPopup.qml b/src/qml/components/WalletMigrationPopup.qml new file mode 100644 index 0000000000..e31c317aa0 --- /dev/null +++ b/src/qml/components/WalletMigrationPopup.qml @@ -0,0 +1,107 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import "../controls" + +Popup { + id: root + + property string popupObjectName: "" + property string titleText: qsTr("Wallet update required") + property string descriptionText: "" + property string confirmText: qsTr("Update wallet") + property string busyConfirmText: qsTr("Updating...") + property string errorText: "" + property string errorTextObjectName: "" + property string cancelButtonObjectName: "" + property string confirmButtonObjectName: "" + property bool busy: false + + signal confirmed() + + objectName: popupObjectName + modal: true + padding: 0 + implicitWidth: 420 + implicitHeight: columnLayout.implicitHeight + anchors.centerIn: parent + + background: Rectangle { + color: Theme.color.background + radius: 10 + border.color: Theme.color.neutral4 + border.width: 1 + } + + ColumnLayout { + id: columnLayout + anchors.fill: parent + spacing: 0 + + CoreText { + Layout.fillWidth: true + Layout.preferredHeight: 56 + text: root.titleText + bold: true + font.pixelSize: 24 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + Separator { + Layout.fillWidth: true + } + + Header { + Layout.fillWidth: true + Layout.margins: 20 + Layout.topMargin: 20 + header: root.descriptionText + headerBold: false + headerSize: 16 + } + + CoreText { + objectName: root.errorTextObjectName + Layout.fillWidth: true + Layout.leftMargin: 20 + Layout.rightMargin: 20 + Layout.topMargin: 4 + visible: text.length > 0 + text: root.errorText + color: Theme.color.red + font.pixelSize: 15 + wrapMode: Text.WordWrap + } + + RowLayout { + Layout.fillWidth: true + Layout.margins: 20 + Layout.topMargin: 20 + spacing: 15 + + OutlineButton { + objectName: root.cancelButtonObjectName + Layout.fillWidth: true + Layout.minimumWidth: 120 + enabled: !root.busy + text: qsTr("Cancel") + onClicked: root.close() + } + + ContinueButton { + objectName: root.confirmButtonObjectName + Layout.fillWidth: true + Layout.minimumWidth: 120 + enabled: !root.busy + text: root.busy ? root.busyConfirmText : root.confirmText + onClicked: root.confirmed() + } + } + } +} diff --git a/src/qml/components/WalletPassphrasePopup.qml b/src/qml/components/WalletPassphrasePopup.qml new file mode 100644 index 0000000000..f66dc6304d --- /dev/null +++ b/src/qml/components/WalletPassphrasePopup.qml @@ -0,0 +1,138 @@ +// 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" + +Popup { + id: root + + property string popupObjectName: "" + property string titleText: qsTr("Enter wallet password") + property string descriptionText: "" + property string confirmText: qsTr("Continue") + property string busyConfirmText: qsTr("Working...") + property string errorText: "" + property string passphraseFieldObjectName: "" + property string errorTextObjectName: "" + property string cancelButtonObjectName: "" + property string confirmButtonObjectName: "" + property bool busy: false + + signal submitted(string passphrase) + + objectName: popupObjectName + modal: true + padding: 0 + implicitWidth: 420 + implicitHeight: columnLayout.implicitHeight + anchors.centerIn: parent + + function clearPassphraseField() { + if (passphraseField.text.length > 0) { + passphraseField.text = Array(passphraseField.text.length + 1).join(" ") + passphraseField.text = "" + } + } + + function submitPassphrase() { + const passphrase = passphraseField.text + clearPassphraseField() + root.submitted(passphrase) + } + + onOpened: { + clearPassphraseField() + passphraseField.forceActiveFocus() + } + onClosed: clearPassphraseField() + Component.onDestruction: clearPassphraseField() + + background: Rectangle { + color: Theme.color.background + radius: 10 + border.color: Theme.color.neutral4 + border.width: 1 + } + + ColumnLayout { + id: columnLayout + anchors.fill: parent + spacing: 0 + + CoreText { + Layout.fillWidth: true + Layout.preferredHeight: 56 + text: root.titleText + bold: true + font.pixelSize: 24 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + Separator { + Layout.fillWidth: true + } + + Header { + Layout.fillWidth: true + Layout.margins: 20 + Layout.topMargin: 20 + header: root.descriptionText + headerBold: false + headerSize: 16 + } + + CoreTextField { + id: passphraseField + objectName: root.passphraseFieldObjectName + Layout.fillWidth: true + Layout.leftMargin: 20 + Layout.rightMargin: 20 + hideText: true + placeholderText: qsTr("Enter password...") + } + + CoreText { + objectName: root.errorTextObjectName + Layout.fillWidth: true + Layout.leftMargin: 20 + Layout.rightMargin: 20 + Layout.topMargin: 12 + visible: text.length > 0 + text: root.errorText + color: Theme.color.red + font.pixelSize: 15 + wrapMode: Text.WordWrap + } + + RowLayout { + Layout.fillWidth: true + Layout.margins: 20 + Layout.topMargin: 20 + spacing: 15 + + OutlineButton { + objectName: root.cancelButtonObjectName + Layout.fillWidth: true + Layout.minimumWidth: 120 + enabled: !root.busy + text: qsTr("Cancel") + onClicked: root.close() + } + + ContinueButton { + objectName: root.confirmButtonObjectName + Layout.fillWidth: true + Layout.minimumWidth: 120 + enabled: !root.busy && passphraseField.text.length > 0 + text: root.busy ? root.busyConfirmText : root.confirmText + onClicked: root.submitPassphrase() + } + } + } +} diff --git a/src/qml/components/WalletSettings.qml b/src/qml/components/WalletSettings.qml new file mode 100644 index 0000000000..a55da1cd9e --- /dev/null +++ b/src/qml/components/WalletSettings.qml @@ -0,0 +1,138 @@ +// 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 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 + onEditingFinished: { + if (root.commitSignerPath()) { + walletController.refreshExternalSignerStatus() + } + } + } + + CoreText { + visible: root.signerPathError.length > 0 + Layout.topMargin: 10 + Layout.fillWidth: true + wrapMode: Text.WordWrap + color: Theme.color.red + text: root.signerPathError + } + + 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.AlignVerticalCenter + } + + 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 + onClicked: { + if (root.commitSignerPath()) { + walletController.refreshExternalSignerStatus() + } + } + } +} diff --git a/src/qml/components/blockclockdial.cpp b/src/qml/components/blockclockdial.cpp new file mode 100644 index 0000000000..7b5e7b6442 --- /dev/null +++ b/src/qml/components/blockclockdial.cpp @@ -0,0 +1,504 @@ +// 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. + +#include + +#include +#include +#include +#include +#include +#include +#include + +BlockClockDial::BlockClockDial(QQuickItem *parent) + : QQuickPaintedItem(parent) +{ + m_animation_timer.setTimerType(Qt::PreciseTimer); + m_animation_timer.setInterval(16); + m_delay_timer.setSingleShot(true); + m_delay_timer.setInterval(m_connecting_animation_delay_ms); + connect(&m_delay_timer, &QTimer::timeout, + this, [this]() { this->m_animation_timer.start(); }); + connect(&m_animation_timer, &QTimer::timeout, + this, [=, this]() { + if (m_is_connected + && getTargetAnimationAngle() - m_animating_max_angle < 1) { + m_animation_timer.stop(); + } + this->update(); + }); + if (m_connecting_animation_delay_ms == 0) { + m_animation_timer.start(); + } else { + m_delay_timer.start(); + } +} + +void BlockClockDial::setupConnectingGradient(const QPen & pen) +{ + m_connecting_gradient.setCenter(getBoundsForPen(pen).center()); + m_connecting_gradient.setAngle(m_connecting_start_angle); + m_connecting_gradient.setColorAt(0, m_confirmation_colors[5]); + m_connecting_gradient.setColorAt(0.5, m_confirmation_colors[5]); + m_connecting_gradient.setColorAt(0.6, m_confirmation_colors[4]); + m_connecting_gradient.setColorAt(0.7, m_confirmation_colors[3]); + m_connecting_gradient.setColorAt(1, "transparent"); +} + +void BlockClockDial::setupSyncedGradient(const QRectF& bounds) +{ + if (!m_synced_gradient_needs_update && m_synced_gradient_bounds == bounds) { + return; + } + + m_synced_gradient_bounds = bounds; + m_synced_gradient.setCenter(bounds.center()); + m_synced_gradient.setAngle(90); + m_synced_gradient.setColorAt(0, m_confirmation_colors[5]); + m_synced_gradient.setColorAt(0.16, m_confirmation_colors[5]); + m_synced_gradient.setColorAt(0.32, m_confirmation_colors[4]); + m_synced_gradient.setColorAt(0.48, m_confirmation_colors[3]); + m_synced_gradient.setColorAt(0.64, m_confirmation_colors[2]); + m_synced_gradient.setColorAt(0.8, m_confirmation_colors[1]); + m_synced_gradient.setColorAt(1, m_confirmation_colors[0]); + m_synced_gradient_needs_update = false; +} + +void BlockClockDial::invalidateSyncedGradient() +{ + m_synced_gradient_needs_update = true; +} + +qreal BlockClockDial::decrementGradientAngle(qreal angle) +{ + if (angle == -360) { + return 0; + } else { + return angle -= 4; + } +} + +qreal BlockClockDial::getTargetAnimationAngle() +{ + if (m_time_ratio_list.isEmpty()) { + return connected() ? verificationProgress() * 360 : 360; + } + + if (connected() && synced()) { + return m_time_ratio_list[0].toDouble() * 360; + } else if (connected()) { + return verificationProgress() * 360; + } else { + return 360; + } +} + +qreal BlockClockDial::incrementAnimatingMaxAngle(qreal angle) +{ + if (connected()) { + return angle += (getTargetAnimationAngle() - angle) * 0.05; + } else { + // Use linear growth for the "Connecting" animation + if (angle >= 360) { + return 360; + } else { + return angle += 4; + } + } +} + +void BlockClockDial::setTimeRatioList(QVariantList new_list) +{ + m_time_ratio_list = new_list; + if (!m_animate_dial) { + m_animating_max_angle = getTargetAnimationAngle(); + } + update(); +} + +void BlockClockDial::setVerificationProgress(double progress) +{ + m_verification_progress = progress; + if (!m_animate_dial) { + m_animating_max_angle = getTargetAnimationAngle(); + } + update(); +} + +void BlockClockDial::setConnected(bool connected) +{ + if (m_is_connected != connected) { + m_is_connected = connected; + m_animating_max_angle = 0; + if (!m_animate_dial) { + m_animation_timer.stop(); + m_delay_timer.stop(); + m_animating_max_angle = getTargetAnimationAngle(); + } else if (m_is_connected) { + m_delay_timer.stop(); + m_animation_timer.start(); + } else { + m_animation_timer.stop(); + if (m_connecting_animation_delay_ms == 0) { + m_delay_timer.stop(); + m_animation_timer.start(); + } else { + m_delay_timer.start(); + } + } + update(); + } +} + +void BlockClockDial::setSynced(bool is_synced) +{ + if (m_is_synced != is_synced) { + m_is_synced = is_synced; + m_animating_max_angle = 0; + if (!m_animate_dial) { + m_animation_timer.stop(); + m_delay_timer.stop(); + m_animating_max_angle = getTargetAnimationAngle(); + } else if (m_is_synced && connected()) { + m_animation_timer.start(); + } + update(); + } +} + +void BlockClockDial::setPaused(bool paused) +{ + if (m_is_paused != paused) { + m_is_paused = paused; + if (m_is_paused) { + m_animation_timer.stop(); + } + m_animating_max_angle = 0; + update(); + } +} + +void BlockClockDial::setAnimateDial(bool animate_dial) +{ + if (m_animate_dial != animate_dial) { + m_animate_dial = animate_dial; + if (m_animate_dial) { + m_animating_max_angle = 0; + if (m_is_connected) { + m_animation_timer.start(); + } else { + m_animation_timer.stop(); + m_delay_timer.start(); + } + } else { + m_animation_timer.stop(); + m_delay_timer.stop(); + m_animating_max_angle = getTargetAnimationAngle(); + } + update(); + } +} + +void BlockClockDial::setConnectingAnimationDelayMs(int connecting_animation_delay_ms) +{ + connecting_animation_delay_ms = qMax(connecting_animation_delay_ms, 0); + if (m_connecting_animation_delay_ms != connecting_animation_delay_ms) { + m_connecting_animation_delay_ms = connecting_animation_delay_ms; + m_delay_timer.setInterval(m_connecting_animation_delay_ms); + if (!m_is_connected && m_animate_dial) { + if (m_connecting_animation_delay_ms == 0) { + m_delay_timer.stop(); + m_animation_timer.start(); + } else if (!m_animation_timer.isActive()) { + m_delay_timer.start(); + } + } + } +} + +void BlockClockDial::setShowTimeTicks(bool show_time_ticks) +{ + if (m_show_time_ticks != show_time_ticks) { + m_show_time_ticks = show_time_ticks; + update(); + } +} + +void BlockClockDial::setShowBlockSegments(bool show_block_segments) +{ + if (m_show_block_segments != show_block_segments) { + m_show_block_segments = show_block_segments; + update(); + } +} + +void BlockClockDial::setUseGradientArcWhenSynced(bool use_gradient_arc_when_synced) +{ + if (m_use_gradient_arc_when_synced != use_gradient_arc_when_synced) { + m_use_gradient_arc_when_synced = use_gradient_arc_when_synced; + update(); + } +} + +void BlockClockDial::setPenWidth(qreal width) +{ + if (m_pen_width != width) { + m_pen_width = width; + invalidateSyncedGradient(); + update(); + } +} + +void BlockClockDial::setScale(qreal scale) +{ + if (m_scale != scale) { + m_scale = scale; + invalidateSyncedGradient(); + update(); + + Q_EMIT scaleChanged(); + } +} + +void BlockClockDial::setBackgroundColor(QColor color) +{ + m_background_color = color; + update(); +} + +void BlockClockDial::setConfirmationColors(QList colorList) +{ + if (m_confirmation_colors != colorList) { + m_confirmation_colors = colorList; + invalidateSyncedGradient(); + update(); + } +} + +void BlockClockDial::setTimeTickColor(QColor color) +{ + m_time_tick_color = color; + update(); +} + +QRectF BlockClockDial::getBoundsForPen(const QPen & pen) +{ + const QRectF bounds = boundingRect(); + const qreal smallest = qMin(bounds.width(), bounds.height()); + QRectF rect = QRectF( + pen.widthF() / 2.0 + 1, + pen.widthF() / 2.0 + 1, + smallest - pen.widthF() - 2, + smallest - pen.widthF() - 2 + ); + rect.moveCenter(bounds.center()); + + // Make sure the arc is aligned to whole pixels. + if (rect.x() - int(rect.x()) > 0) + rect.setX(qCeil(rect.x())); + if (rect.y() - int(rect.y()) > 0) + rect.setY(qCeil(rect.y())); + if (rect.width() - int(rect.width()) > 0) + rect.setWidth(qFloor(rect.width())); + if (rect.height() - int(rect.height()) > 0) + rect.setHeight(qFloor(rect.height())); + + return rect; +} + +void BlockClockDial::paintBlocks(QPainter * painter) +{ + int numberOfBlocks = m_time_ratio_list.length(); + if (numberOfBlocks < 2) { + return; + } + + QPen pen(m_confirmation_colors[5]); + pen.setWidthF(m_pen_width); + pen.setCapStyle(Qt::FlatCap); + const QRectF bounds = getBoundsForPen(pen); + painter->setPen(pen); + + // The gap is calculated here and is used to create a + // one pixel spacing between each block + double gap = degreesPerPixel(); + + // Paint blocks + for (int i = 1; i < numberOfBlocks; i++) { + if (numberOfBlocks - i <= 6) { + QPen pen(m_confirmation_colors[numberOfBlocks - i - 1]); + pen.setWidthF(m_pen_width); + pen.setCapStyle(Qt::FlatCap); + painter->setPen(pen); + } + + const qreal startAngle = 90 + (-360 * m_time_ratio_list[i].toDouble()); + qreal nextAngle; + if (i == numberOfBlocks - 1) { + nextAngle = 90 + (-360 * m_time_ratio_list[0].toDouble()); + } else { + nextAngle = 90 + (-360 * m_time_ratio_list[i+1].toDouble()); + } + + QPainterPath path; + path.arcMoveTo(bounds, startAngle); + + if (-1 * nextAngle + 90 > m_animating_max_angle) { + nextAngle = -1 * m_animating_max_angle + 90; + // end the loop early + i = numberOfBlocks; + } + + const qreal spanAngle = -1 * (startAngle - nextAngle) + gap; + path.arcTo(bounds, startAngle, spanAngle); + painter->drawPath(path); + } +} + +void BlockClockDial::paintProgress(QPainter * painter) +{ + QPen pen(m_confirmation_colors[5]); + pen.setWidthF(m_pen_width); + pen.setCapStyle(Qt::RoundCap); + const QRectF bounds = getBoundsForPen(pen); + painter->setPen(pen); + + // QPainter::drawArc uses positive values for counter clockwise - the opposite of our API - + // so we must reverse the angles with * -1. Also, our angle origin is at 12 o'clock, whereas + // QPainter's is 3 o'clock, hence - 90. + const qreal startAngle = 90; + qreal spanAngle; + if (verificationProgress() * 360 > m_animating_max_angle) { + spanAngle = m_animating_max_angle * -1; + } else { + spanAngle = verificationProgress() * -360; + } + + // QPainter::drawArc parameters are 1/16 of a degree + painter->drawArc(bounds, startAngle * 16, spanAngle * 16); +} + +void BlockClockDial::paintCurrentTimeArc(QPainter* painter) +{ + if (m_time_ratio_list.isEmpty()) { + return; + } + + QPen pen(m_confirmation_colors[5]); + pen.setWidthF(m_pen_width); + pen.setCapStyle(Qt::RoundCap); + const QRectF bounds = getBoundsForPen(pen); + painter->setPen(pen); + + const qreal start_angle = 90; + const qreal time_angle = m_time_ratio_list[0].toDouble() * 360; + const qreal span_angle = -1 * qMin(time_angle, m_animating_max_angle); + painter->drawArc(bounds, start_angle * 16, span_angle * 16); +} + +void BlockClockDial::paintSyncedGradientArc(QPainter* painter) +{ + if (m_confirmation_colors.size() < 6 || m_time_ratio_list.isEmpty()) { + paintCurrentTimeArc(painter); + return; + } + + QPen pen; + pen.setWidthF(m_pen_width); + pen.setCapStyle(Qt::RoundCap); + const QRectF bounds = getBoundsForPen(pen); + setupSyncedGradient(bounds); + pen.setBrush(QBrush(m_synced_gradient)); + painter->setPen(pen); + + const qreal start_angle = 90; + const qreal time_angle = m_time_ratio_list[0].toDouble() * 360; + const qreal span_angle = -1 * qMin(time_angle, m_animating_max_angle); + painter->drawArc(bounds, start_angle * 16, span_angle * 16); +} + +void BlockClockDial::paintConnectingAnimation(QPainter * painter) +{ + QPen pen; + pen.setWidthF(m_pen_width); + setupConnectingGradient(pen); + pen.setBrush(QBrush(m_connecting_gradient)); + pen.setCapStyle(Qt::RoundCap); + const QRectF bounds = getBoundsForPen(pen); + painter->setPen(pen); + if (m_animating_max_angle < m_connecting_end_angle * -1) { + painter->drawArc(bounds, m_connecting_start_angle * 16, m_animating_max_angle * -16); + } else { + painter->drawArc(bounds, m_connecting_start_angle * 16, m_connecting_end_angle * 16); + m_connecting_start_angle = decrementGradientAngle(m_connecting_start_angle); + } +} + +void BlockClockDial::paintBackground(QPainter * painter) +{ + QPen pen(m_background_color); + pen.setWidthF(m_pen_width); + const QRectF bounds = getBoundsForPen(pen); + painter->setPen(pen); + + painter->drawEllipse(bounds); +} + +double BlockClockDial::degreesPerPixel() +{ + double circumference = width() * 3.1415926; + return 360 / circumference; +} + +void BlockClockDial::paintTimeTicks(QPainter * painter) +{ + QPen pen(m_time_tick_color); + pen.setWidthF(m_pen_width); + // Calculate bound based on width of default pen + const QRectF bounds = getBoundsForPen(pen); + + QPen time_tick_pen = QPen(m_time_tick_color); + time_tick_pen.setWidthF(m_pen_width / 2); + time_tick_pen.setCapStyle(Qt::RoundCap); + painter->setPen(time_tick_pen); + for (double angle = 0; angle < 360; angle += 30) { + QPainterPath path; + path.arcMoveTo(bounds, angle); + path.arcTo(bounds, angle, degreesPerPixel()); + painter->drawPath(path); + } +} + +void BlockClockDial::paint(QPainter * painter) +{ + if (width() <= 0 || height() <= 0) { + return; + } + painter->setRenderHint(QPainter::Antialiasing); + + paintBackground(painter); + if (showTimeTicks()) { + paintTimeTicks(painter); + } + + if (paused()) return; + + if (connected() && synced()) { + if (showBlockSegments()) { + paintBlocks(painter); + } else if (useGradientArcWhenSynced()) { + paintSyncedGradientArc(painter); + } else { + paintCurrentTimeArc(painter); + } + } else if (connected()) { + paintProgress(painter); + } else if (m_animation_timer.isActive()) { + paintConnectingAnimation(painter); + } + if (m_animate_dial) { + m_animating_max_angle = incrementAnimatingMaxAngle(m_animating_max_angle); + } +} diff --git a/src/qml/components/blockclockdial.h b/src/qml/components/blockclockdial.h new file mode 100644 index 0000000000..23727fde2f --- /dev/null +++ b/src/qml/components/blockclockdial.h @@ -0,0 +1,116 @@ +// Copyright (c) 2022 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QML_COMPONENTS_BLOCKCLOCKDIAL_H +#define BITCOIN_QML_COMPONENTS_BLOCKCLOCKDIAL_H + +#include +#include +#include +#include +#include + +class BlockClockDial : public QQuickPaintedItem +{ + Q_OBJECT + Q_PROPERTY(QVariantList timeRatioList READ timeRatioList WRITE setTimeRatioList) + Q_PROPERTY(double verificationProgress READ verificationProgress WRITE setVerificationProgress) + Q_PROPERTY(bool connected READ connected WRITE setConnected) + Q_PROPERTY(bool synced READ synced WRITE setSynced) + Q_PROPERTY(bool paused READ paused WRITE setPaused) + Q_PROPERTY(bool animateDial READ animateDial WRITE setAnimateDial) + Q_PROPERTY(int connectingAnimationDelayMs READ connectingAnimationDelayMs WRITE setConnectingAnimationDelayMs) + Q_PROPERTY(bool showTimeTicks READ showTimeTicks WRITE setShowTimeTicks) + Q_PROPERTY(bool showBlockSegments READ showBlockSegments WRITE setShowBlockSegments) + Q_PROPERTY(bool useGradientArcWhenSynced READ useGradientArcWhenSynced WRITE setUseGradientArcWhenSynced) + Q_PROPERTY(qreal penWidth READ penWidth WRITE setPenWidth) + Q_PROPERTY(qreal scale READ scale WRITE setScale NOTIFY scaleChanged) + Q_PROPERTY(QColor backgroundColor READ backgroundColor WRITE setBackgroundColor) + Q_PROPERTY(QList confirmationColors READ confirmationColors WRITE setConfirmationColors ) + Q_PROPERTY(QColor timeTickColor READ timeTickColor WRITE setTimeTickColor) + +public: + explicit BlockClockDial(QQuickItem * parent = nullptr); + void paint(QPainter * painter) override; + + QVariantList timeRatioList() const { return m_time_ratio_list; }; + double verificationProgress() const { return m_verification_progress; }; + bool connected() const { return m_is_connected; }; + bool synced() const { return m_is_synced; }; + bool paused() const { return m_is_paused; }; + bool animateDial() const { return m_animate_dial; }; + int connectingAnimationDelayMs() const { return m_connecting_animation_delay_ms; }; + bool showTimeTicks() const { return m_show_time_ticks; }; + bool showBlockSegments() const { return m_show_block_segments; }; + bool useGradientArcWhenSynced() const { return m_use_gradient_arc_when_synced; }; + qreal penWidth() const { return m_pen_width; }; + qreal scale() const { return m_scale; }; + QColor backgroundColor() const { return m_background_color; }; + QList confirmationColors() const { return m_confirmation_colors; }; + QColor timeTickColor() const { return m_time_tick_color; }; + +public Q_SLOTS: + void setTimeRatioList(QVariantList new_time); + void setVerificationProgress(double progress); + void setConnected(bool connected); + void setSynced(bool synced); + void setPaused(bool paused); + void setAnimateDial(bool animate_dial); + void setConnectingAnimationDelayMs(int connecting_animation_delay_ms); + void setShowTimeTicks(bool show_time_ticks); + void setShowBlockSegments(bool show_block_segments); + void setUseGradientArcWhenSynced(bool use_gradient_arc_when_synced); + void setPenWidth(qreal width); + void setScale(qreal scale); + void setBackgroundColor(QColor color); + void setConfirmationColors(QList colorList); + void setTimeTickColor(QColor color); + +Q_SIGNALS: + void scaleChanged(); + +private: + void paintConnectingAnimation(QPainter * painter); + void paintProgress(QPainter * painter); + void paintCurrentTimeArc(QPainter* painter); + void paintSyncedGradientArc(QPainter* painter); + void paintBlocks(QPainter * painter); + void paintBackground(QPainter * painter); + void paintTimeTicks(QPainter * painter); + QRectF getBoundsForPen(const QPen & pen); + double degreesPerPixel(); + void setupConnectingGradient(const QPen & pen); + void setupSyncedGradient(const QRectF& bounds); + void invalidateSyncedGradient(); + qreal decrementGradientAngle(qreal angle); + qreal incrementAnimatingMaxAngle(qreal angle); + qreal getTargetAnimationAngle(); + + QVariantList m_time_ratio_list{0.0}; + double m_verification_progress{0.0}; + bool m_is_connected{false}; + bool m_is_synced{false}; + bool m_is_paused{false}; + bool m_animate_dial{true}; + int m_connecting_animation_delay_ms{5000}; + bool m_show_time_ticks{true}; + bool m_show_block_segments{true}; + bool m_use_gradient_arc_when_synced{false}; + qreal m_pen_width{4}; + qreal m_scale{5/12}; + QColor m_background_color{"#2D2D2D"}; + QConicalGradient m_connecting_gradient; + QConicalGradient m_synced_gradient; + QRectF m_synced_gradient_bounds; + bool m_synced_gradient_needs_update{true}; + qreal m_connecting_start_angle = 90; + const qreal m_connecting_end_angle = -180; + QList m_confirmation_colors{}; + QColor m_time_tick_color{"#000000"}; + QTimer m_animation_timer{this}; + QTimer m_delay_timer; + qreal m_animating_max_angle = 0; +}; + +#endif // BITCOIN_QML_COMPONENTS_BLOCKCLOCKDIAL_H diff --git a/src/qml/controls/AddWalletButton.qml b/src/qml/controls/AddWalletButton.qml new file mode 100644 index 0000000000..8618768827 --- /dev/null +++ b/src/qml/controls/AddWalletButton.qml @@ -0,0 +1,84 @@ +// Copyright (c) 2024 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 + +Button { + id: root + + property color bgActiveColor: Theme.color.neutral2 + property color textColor: Theme.color.neutral7 + property color textHoverColor: Theme.color.orange + property color textActiveColor: Theme.color.orange + + hoverEnabled: AppMode.isDesktop + implicitHeight: 40 + implicitWidth: 220 + + MouseArea { + anchors.fill: parent + enabled: false + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + } + + contentItem: Item { + anchors.fill: parent + RowLayout { + anchors.centerIn: parent + spacing: 3 + Icon { + id: addIcon + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter + source: "image://images/plus-filled" + color: Theme.color.neutral7 + size: 24 + Layout.minimumWidth: 24 + Layout.preferredWidth: 24 + Layout.maximumWidth: 24 + Layout.preferredHeight: 24 + } + CoreText { + id: addText + Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter + text: qsTr("Add Wallet") + color: Theme.color.neutral7 + font.pixelSize: 15 + bold: true + } + } + } + + background: Rectangle { + id: bg + height: 30 + width: 220 + radius: 5 + color: Theme.color.background + + FocusBorder { + visible: root.visualFocus + } + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + + states: [ + State { + name: "CHECKED"; when: root.checked + }, + State { + name: "HOVER"; when: root.hovered + PropertyChanges { target: bg; color: bgActiveColor } + PropertyChanges { target: addText; color: textHoverColor } + PropertyChanges { target: addIcon; color: textHoverColor } + } + ] +} diff --git a/src/qml/controls/CaretRightIcon.qml b/src/qml/controls/CaretRightIcon.qml new file mode 100644 index 0000000000..fe39d43c11 --- /dev/null +++ b/src/qml/controls/CaretRightIcon.qml @@ -0,0 +1,11 @@ +// 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 + +Icon { + source: "image://images/caret-right" + size: 18 +} diff --git a/src/qml/controls/ContinueButton.qml b/src/qml/controls/ContinueButton.qml new file mode 100644 index 0000000000..e50f6f22c7 --- /dev/null +++ b/src/qml/controls/ContinueButton.qml @@ -0,0 +1,90 @@ +// Copyright (c) 2022 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 + +Button { + id: root + hoverEnabled: AppMode.isDesktop + + property color textColor: Theme.color.white + property color textHoverColor: textColor + property color textPressedColor: textColor + property color backgroundColor: Theme.color.orange + property color backgroundHoverColor: Theme.color.orangeLight1 + property color backgroundPressedColor: Theme.color.orangeLight2 + property color borderColor: "transparent" + property color borderHoverColor: "transparent" + property color borderPressedColor: "transparent" + property bool bold: true + property bool busy: false + property var textStyle: bold ? Theme.text.buttonStrong : Theme.text.button + property int textFontPixelSize: textStyle.pixelSize + property string textFontStyleName: textStyle.styleName + + contentItem: Item { + implicitHeight: contentRow.implicitHeight + + RowLayout { + id: contentRow + anchors.centerIn: parent + spacing: 8 + + SpinningIndicator { + Layout.alignment: Qt.AlignVCenter + visible: root.busy + running: root.busy + color: root.textColor + } + + CoreText { + Layout.alignment: Qt.AlignVCenter + text: root.text + color: root.textColor + bold: root.bold + fontStyleName: root.textFontStyleName + font.pixelSize: root.textFontPixelSize + } + } + } + background: Rectangle { + id: bg + implicitHeight: 46 + color: backgroundColor + border.color: borderColor + radius: 5 + + states: [ + State { + name: "DISABLED"; when: !root.enabled + PropertyChanges { target: bg; color: Theme.color.neutral2 } + PropertyChanges { target: bg; border.color: Theme.color.neutral2 } + PropertyChanges { target: root; textColor: Theme.color.neutral5 } + }, + State { + name: "PRESSED"; when: root.pressed + PropertyChanges { target: bg; color: backgroundPressedColor } + PropertyChanges { target: bg; border.color: borderPressedColor } + PropertyChanges { target: root; textColor: textPressedColor } + }, + State { + name: "HOVER"; when: root.hovered + PropertyChanges { target: bg; color: backgroundHoverColor } + PropertyChanges { target: bg; border.color: borderHoverColor } + PropertyChanges { target: root; textColor: textHoverColor } + } + ] + + Behavior on color { + ColorAnimation { duration: 150 } + } + + FocusBorder { + visible: root.visualFocus + } + } +} diff --git a/src/qml/controls/CoreCheckBox.qml b/src/qml/controls/CoreCheckBox.qml new file mode 100644 index 0000000000..c7fe6af925 --- /dev/null +++ b/src/qml/controls/CoreCheckBox.qml @@ -0,0 +1,27 @@ +// Copyright (c) 2025 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 + +AbstractButton { + id: root + implicitWidth: 20 + implicitHeight: 20 + + property color borderColor: Theme.color.neutral9 + property color fillColor: Theme.color.neutral9 + + background: null + + checkable: true + hoverEnabled: AppMode.isDesktop + + contentItem: Rectangle { + radius: 3 + border.color: root.checked ? root.fillColor : root.borderColor + border.width: 1 + color: root.checked ? root.fillColor : "transparent" + } +} diff --git a/src/qml/controls/CoreText.qml b/src/qml/controls/CoreText.qml new file mode 100644 index 0000000000..4568fcda53 --- /dev/null +++ b/src/qml/controls/CoreText.qml @@ -0,0 +1,23 @@ +// 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 + +Text { + property bool bold: false + property bool wrap: true + property string fontStyleName: bold ? "Semi Bold" : "Regular" + color: enabled ? Theme.color.neutral9 : Theme.color.neutral4 + font.family: "BitcoinCoreSans" + font.styleName: fontStyleName + font.pixelSize: 13 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + wrapMode: wrap ? Text.WordWrap : Text.NoWrap + + Behavior on color { + ColorAnimation { duration: 150 } + } +} diff --git a/src/qml/controls/CoreTextField.qml b/src/qml/controls/CoreTextField.qml new file mode 100644 index 0000000000..50a2e9071d --- /dev/null +++ b/src/qml/controls/CoreTextField.qml @@ -0,0 +1,53 @@ +// Copyright (c) 2022 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 + +TextField { + id: root + + property bool hideText: false + + implicitHeight: 56 + implicitWidth: 450 + font.family: "BitcoinCoreSans" + font.styleName: "Regular" + font.pixelSize: 18 + color: Theme.color.neutral9 + placeholderTextColor: Theme.color.neutral5 + echoMode: hideText ? TextInput.Password : TextInput.Normal + verticalAlignment: TextInput.AlignVCenter + rightPadding: hideText ? 46 : 20 + leftPadding: 20 + background: Rectangle { + border.color: Theme.color.neutral5 + border.width: 1 + color: "transparent" + radius: 5 + } + + Icon { + id: visibleIcon + visible: root.hideText + enabled: root.hideText + z: 1 + size: 24 + anchors.right: root.right + anchors.rightMargin: 12 + anchors.verticalCenter: root.verticalCenter + source: root.echoMode === TextInput.Normal ? "qrc:/icons/hidden" : "qrc:/icons/visible" + color: Theme.color.neutral5 + + TapHandler { + onTapped: root.echoMode = (root.echoMode === TextInput.Password) + ? TextInput.Normal + : TextInput.Password + } + + HoverHandler { + cursorShape: Qt.PointingHandCursor + } + } +} diff --git a/src/qml/controls/EditableKeyValueRow.qml b/src/qml/controls/EditableKeyValueRow.qml new file mode 100644 index 0000000000..5e0ce47feb --- /dev/null +++ b/src/qml/controls/EditableKeyValueRow.qml @@ -0,0 +1,217 @@ +// 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 + +Item { + id: root + + property string label: "" + property string displayValue: "" + property string editValue: "" + property bool editing: false + property bool confirmEnabled: root.editValue.trim().length > 0 + property int keyWidth: 150 + property int fieldWidth: 152 + property string keyObjectName: "" + property string valueObjectName: "" + property string editFieldObjectName: "" + property string editButtonObjectName: "" + property string cancelButtonObjectName: "" + property string confirmButtonObjectName: "" + + signal editRequested() + signal cancelRequested() + signal confirmRequested() + signal editValueEdited(string value) + + implicitHeight: 36 + + RowLayout { + id: row + anchors.fill: parent + spacing: 10 + + CoreText { + objectName: root.keyObjectName + Layout.preferredWidth: root.keyWidth + Layout.alignment: Qt.AlignVCenter + text: root.label + color: Theme.color.neutral7 + font.pixelSize: 18 + fontStyleName: "Regular" + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter + wrapMode: Text.NoWrap + } + + Loader { + id: editLoader + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + active: root.editing + visible: active + sourceComponent: RowLayout { + width: editLoader.width + spacing: 8 + + TextField { + objectName: root.editFieldObjectName + Layout.fillWidth: true + Layout.preferredWidth: root.fieldWidth + implicitHeight: 36 + text: root.editValue + color: Theme.color.neutral9 + font.family: Theme.text.family + font.styleName: "Regular" + font.pixelSize: 18 + leftPadding: 12 + rightPadding: 12 + selectByMouse: true + background: Rectangle { + border.color: Theme.color.neutral5 + border.width: 1 + color: "transparent" + radius: 5 + } + onTextChanged: root.editValueEdited(text) + Component.onCompleted: forceActiveFocus() + Keys.onReturnPressed: root.confirmRequested() + Keys.onEnterPressed: root.confirmRequested() + Keys.onEscapePressed: root.cancelRequested() + } + + ActionButton { + objectName: root.cancelButtonObjectName + tintIcon: false + imageSource: "qrc:/icons/circle-red-cross" + imageSize: 30 + onClicked: root.cancelRequested() + } + + ActionButton { + objectName: root.confirmButtonObjectName + enabled: root.confirmEnabled + tintIcon: false + imageSource: "qrc:/icons/circle-green-check" + imageSize: 30 + onClicked: root.confirmRequested() + } + } + } + + Loader { + id: displayLoader + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + active: !root.editing + visible: active + sourceComponent: RowLayout { + width: displayLoader.width + spacing: 6 + + CoreText { + objectName: root.valueObjectName + Layout.fillWidth: true + text: root.displayValue + color: Theme.color.neutral9 + font.pixelSize: 18 + fontStyleName: "Regular" + horizontalAlignment: Text.AlignRight + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + wrapMode: Text.NoWrap + } + + ActionButton { + objectName: root.editButtonObjectName + iconSource: "image://images/edit" + iconColor: Theme.color.neutral9 + hoverColor: Theme.color.orange + activeColor: Theme.color.orange + onClicked: root.editRequested() + } + } + } + } + + component ActionButton: Button { + id: actionButton + + property bool tintIcon: true + property url iconSource: "" + property color iconColor: Theme.color.neutral9 + property color hoverColor: Theme.color.orange + property color activeColor: hoverColor + property int iconSize: 20 + property url imageSource: "" + property int imageSize: 20 + + implicitWidth: 30 + implicitHeight: 30 + hoverEnabled: AppMode.isDesktop + padding: 0 + + HoverHandler { + enabled: actionButton.enabled && AppMode.isDesktop + cursorShape: Qt.PointingHandCursor + } + + background: Rectangle { + anchors.fill: parent + radius: 5 + color: !actionButton.enabled + ? "transparent" + : actionButton.pressed + ? Theme.color.neutral3 + : actionButton.hovered + ? Theme.color.neutral2 + : "transparent" + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + + contentItem: Item { + Icon { + visible: actionButton.tintIcon + anchors.centerIn: parent + size: actionButton.iconSize + source: actionButton.iconSource + color: !actionButton.enabled + ? Theme.color.neutral4 + : actionButton.pressed + ? actionButton.activeColor + : actionButton.hovered + ? actionButton.hoverColor + : actionButton.iconColor + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + + Image { + visible: !actionButton.tintIcon + anchors.centerIn: parent + width: actionButton.imageSize + height: actionButton.imageSize + source: actionButton.imageSource + sourceSize.width: actionButton.imageSize + sourceSize.height: actionButton.imageSize + fillMode: Image.PreserveAspectFit + opacity: actionButton.enabled ? 1 : 0.45 + + Behavior on opacity { + NumberAnimation { duration: 150 } + } + } + } + } +} diff --git a/src/qml/controls/EllipsisMenuButtonItem.qml b/src/qml/controls/EllipsisMenuButtonItem.qml new file mode 100644 index 0000000000..051414089c --- /dev/null +++ b/src/qml/controls/EllipsisMenuButtonItem.qml @@ -0,0 +1,51 @@ +// 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 + +Button { + id: root + + Accessible.role: Accessible.MenuItem + hoverEnabled: AppMode.isDesktop + + implicitWidth: 280 + implicitHeight: 44 + + HoverHandler { cursorShape: Qt.PointingHandCursor } + + contentItem: RowLayout { + spacing: 7 + anchors.fill: parent + anchors.margins: 10 + CoreText { + id: buttonText + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + horizontalAlignment: Text.AlignLeft + font.pixelSize: 15 + text: root.text + } + } + + background: Rectangle { + id: bg + color: "transparent" + radius: 5 + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + + states: [ + State { + name: "HOVER"; when: root.hovered && root.enabled + PropertyChanges { target: buttonText; color: Theme.color.orange } + } + ] +} diff --git a/src/qml/controls/EllipsisMenuToggleItem.qml b/src/qml/controls/EllipsisMenuToggleItem.qml new file mode 100644 index 0000000000..f45f0176be --- /dev/null +++ b/src/qml/controls/EllipsisMenuToggleItem.qml @@ -0,0 +1,92 @@ +// Copyright (c) 2025 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 + +Button { + id: root + + property int bgRadius: 5 + property color bgDefaultColor: "transparent" + property color bgHoverColor: Theme.color.neutral2 + property color textColor: Theme.color.neutral7 + property color textHoverColor: Theme.color.neutral9 + property color textActiveColor: Theme.color.neutral7 + property int contentHorizontalPadding: 10 + property int contentTopPadding: 10 + property int contentBottomPadding: 10 + + checkable: true + Accessible.role: Accessible.MenuItem + hoverEnabled: AppMode.isDesktop + padding: 10 + + implicitWidth: 280 + implicitHeight: optionSwitch.implicitHeight + root.contentTopPadding + root.contentBottomPadding + + HoverHandler { cursorShape: Qt.PointingHandCursor } + + contentItem: RowLayout { + spacing: 7 + anchors.fill: parent + anchors.centerIn: parent + anchors.leftMargin: root.contentHorizontalPadding + anchors.rightMargin: root.contentHorizontalPadding + anchors.topMargin: root.contentTopPadding + anchors.bottomMargin: root.contentBottomPadding + + CoreText { + id: buttonText + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + horizontalAlignment: Text.AlignLeft + font.pixelSize: 15 + text: root.text + color: root.checked ? root.textActiveColor : root.textColor + } + + OptionSwitch { + id: optionSwitch + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: 40 + Layout.preferredHeight: 24 + checked: root.checked + + MouseArea { + anchors.fill: parent + onClicked: root.toggle() + } + } + } + + background: Rectangle { + id: bg + color: root.bgDefaultColor + radius: root.bgRadius + + Behavior on color { + ColorAnimation { + duration: 150 + } + } + } + + states: [ + State { + name: "HOVER" + when: root.hovered + PropertyChanges { + target: bg + color: root.bgHoverColor + } + PropertyChanges { + target: buttonText + color: root.textHoverColor + } + } + ] +} diff --git a/src/qml/controls/ExternalLink.qml b/src/qml/controls/ExternalLink.qml new file mode 100644 index 0000000000..7cf80f3113 --- /dev/null +++ b/src/qml/controls/ExternalLink.qml @@ -0,0 +1,77 @@ +// Copyright (c) 2022 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 + +AbstractButton { + id: root + required property string parentState + required property string link + property string description: "" + property int descriptionSize: 18 + property url iconSource: "image://images/export" + property int iconWidth: 22 + property int iconHeight: 22 + property color iconColor: Theme.color.neutral9 + property color textColor: Theme.color.neutral7 + state: root.parentState + + states: [ + State { + name: "ACTIVE" + PropertyChanges { + target: root + iconColor: Theme.color.orange + textColor: Theme.color.orange + } + }, + State { + name: "HOVER" + PropertyChanges { + target: root + iconColor: Theme.color.orangeLight1 + textColor: Theme.color.orangeLight1 + } + } + ] + + contentItem: RowLayout { + spacing: 0 + width: parent.width + Loader { + Layout.fillWidth: true + active: root.description.length > 0 + visible: active + sourceComponent: CoreText { + font.pixelSize: root.descriptionSize + color: root.textColor + textFormat: Text.RichText + text: root.description + wrap: false + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + } + Button { + leftPadding: 0 + topPadding: 0 + bottomPadding: 0 + icon.source: root.iconSource + icon.color: root.iconColor + icon.height: root.iconHeight + icon.width: root.iconWidth + background: null + onClicked: root.clicked() + + Behavior on icon.color { + ColorAnimation { duration: 150 } + } + } + } + onClicked: Qt.openUrlExternally(link) +} diff --git a/src/qml/controls/FocusBorder.qml b/src/qml/controls/FocusBorder.qml new file mode 100644 index 0000000000..043b74c841 --- /dev/null +++ b/src/qml/controls/FocusBorder.qml @@ -0,0 +1,35 @@ +// 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 "../controls" + + + Rectangle { + id: root + property int topMargin: -4 + property int bottomMargin: -4 + property int leftMargin: -4 + property int rightMargin: -4 + property int borderRadius: 9 + + anchors.fill: parent + anchors.topMargin: root.topMargin + anchors.bottomMargin: root.bottomMargin + anchors.leftMargin: root.leftMargin + anchors.rightMargin: root.rightMargin + border.width: 2 + border.color: Theme.color.purple + radius: root.borderRadius + color: "transparent" + + Behavior on border.color { + ColorAnimation { duration: 150 } + } + + Behavior on visible { + NumberAnimation { duration: 150 } + } +} diff --git a/src/qml/controls/Header.qml b/src/qml/controls/Header.qml new file mode 100644 index 0000000000..b90378e2ac --- /dev/null +++ b/src/qml/controls/Header.qml @@ -0,0 +1,88 @@ +// Copyright (c) 2022 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 + +ColumnLayout { + id: root + property bool center: true + required property string header + property bool showHeader: true + property int headerMargin + property int headerSize: 28 + property bool headerBold: false + property color headerColor: Theme.color.neutral9 + property string description: "" + property int descriptionMargin: 10 + property int descriptionSize: 18 + property string descriptionColor: Theme.color.neutral8 + property bool descriptionBold: false + property string subtext: "" + property int subtextMargin + property int subtextSize: 15 + property color subtextColor: Theme.color.neutral9 + property bool wrap: true + + spacing: 0 + Loader { + Layout.fillWidth: true + active: root.showHeader && root.header.length > 0 + visible: active + sourceComponent: Label { + Layout.fillWidth: true + topPadding: root.headerMargin + font.family: "BitcoinCoreSans" + font.styleName: root.headerBold ? "Semi Bold" : "Regular" + font.pixelSize: root.headerSize + color: root.headerColor + text: root.header + horizontalAlignment: center ? Text.AlignHCenter : Text.AlignLeft + wrapMode: wrap ? Text.WordWrap : Text.NoWrap + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + } + Loader { + Layout.fillWidth: true + active: root.description.length > 0 + visible: active + sourceComponent: Label { + topPadding: showHeader ? root.descriptionMargin : root.headerMargin + font.family: "BitcoinCoreSans" + font.styleName: root.descriptionBold ? "Semi Bold" : "Regular" + font.pixelSize: root.descriptionSize + color: root.descriptionColor + text: root.description + horizontalAlignment: root.center ? Text.AlignHCenter : Text.AlignLeft + wrapMode: wrap ? Text.WordWrap : Text.NoWrap + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + } + Loader { + Layout.fillWidth: true + active: root.subtext.length > 0 + visible: active + sourceComponent: Label { + topPadding: root.subtextMargin + font.family: "BitcoinCoreSans" + font.styleName: "Regular" + font.pixelSize: root.subtextSize + color: root.subtextColor + text: root.subtext + horizontalAlignment: root.center ? Text.AlignHCenter : Text.AlignLeft + wrapMode: wrap ? Text.WordWrap : Text.NoWrap + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + } +} diff --git a/src/qml/controls/IPAddressValueInput.qml b/src/qml/controls/IPAddressValueInput.qml new file mode 100644 index 0000000000..9a95feac17 --- /dev/null +++ b/src/qml/controls/IPAddressValueInput.qml @@ -0,0 +1,56 @@ +// Copyright (c) 2024 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 + +TextInput { + id: root + required property string parentState + property string description: "" + property bool filled: false + property int descriptionSize: 18 + property color textColor: root.filled ? Theme.color.neutral9 : Theme.color.neutral5 + // Expose a property to indicate validity, initial value will be true (no error message displayed) + property bool validInput: true + enabled: true + state: root.parentState + validator: RegularExpressionValidator { regularExpression: /^[\][0-9a-f.:]+$/i } // Allow only IPv4/ IPv6 chars + + maximumLength: 47 + + states: [ + State { + name: "ACTIVE" + PropertyChanges { target: root; textColor: Theme.color.orange } + }, + State { + name: "HOVER" + PropertyChanges { + target: root + textColor: root.filled ? Theme.color.orangeLight1 : Theme.color.neutral5 + } + }, + State { + name: "DISABLED" + PropertyChanges { + target: root + enabled: false + textColor: Theme.color.neutral4 + } + } + ] + + font.family: "BitcoinCoreSans" + font.styleName: "Regular" + font.pixelSize: root.descriptionSize + color: root.textColor + text: root.description + horizontalAlignment: Text.AlignRight + wrapMode: Text.WordWrap + + Behavior on color { + ColorAnimation { duration: 150 } + } +} diff --git a/src/qml/controls/Icon.qml b/src/qml/controls/Icon.qml new file mode 100644 index 0000000000..599728ff7a --- /dev/null +++ b/src/qml/controls/Icon.qml @@ -0,0 +1,29 @@ +// 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 + +Button { + id: root + width: icon.width + height: icon.height + required property color color + required property url source + property int size: 32 + focusPolicy: Qt.NoFocus + padding: 0 + icon.source: root.source + icon.color: root.color + icon.height: root.size + icon.width: root.size + enabled: false + background: null + + Behavior on icon.color { + ColorAnimation { + duration: 150 + } + } +} diff --git a/src/qml/controls/IconButton.qml b/src/qml/controls/IconButton.qml new file mode 100644 index 0000000000..238312f4d3 --- /dev/null +++ b/src/qml/controls/IconButton.qml @@ -0,0 +1,74 @@ +// Copyright (c) 2025-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 + +Button { + id: root + + implicitHeight: root.height + implicitWidth: root.width + property color iconColor: Theme.color.neutral5 + property color hoverColor: activeColor + property color activeColor: Theme.color.orange + property int size: 35 + property int iconSize: size + property alias iconSource: icon.source + + hoverEnabled: AppMode.isDesktop + height: root.size + width: root.size + padding: 0 + + HoverHandler { + cursorShape: Qt.PointingHandCursor + } + + background: Rectangle { + id: bg + anchors.fill: parent + radius: 5 + color: root.hovered || root.pressed ? Theme.color.neutral2 : Theme.color.background + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + + contentItem: Icon { + id: icon + anchors.fill: parent + source: "" + size: root.iconSize + color: root.iconColor + hoverEnabled: false + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + + states: [ + State { + name: "HOVER"; when: root.hovered + PropertyChanges { target: icon; color: root.hoverColor } + }, + State { + name: "CHECKED"; when: root.checked + PropertyChanges { target: icon; color: root.activeColor } + }, + State { + name: "PRESSED"; when: root.pressed + PropertyChanges { target: icon; color: root.activeColor } + }, + State { + name: "DISABLED"; when: !root.enabled + PropertyChanges { target: icon; color: Theme.color.neutral4 } + } + ] +} diff --git a/src/qml/controls/InformationPage.qml b/src/qml/controls/InformationPage.qml new file mode 100644 index 0000000000..dbd9133cbf --- /dev/null +++ b/src/qml/controls/InformationPage.qml @@ -0,0 +1,154 @@ +// Copyright (c) 2022 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 + +Page { + id: root + signal back + signal next + implicitHeight: information.height + continueButton.height + buttonMargin + property alias bannerItem: banner_loader.sourceComponent + property alias detailItem: detail_loader.sourceComponent + property alias loadedDetailItem: detail_loader.item + property alias navLeftDetail: navbar.leftDetail + property alias navMiddleDetail: navbar.middleDetail + property alias navRightDetail: navbar.rightDetail + property string buttonText: "" + property int buttonMargin: 40 + property bool bannerActive: true + property bool detailActive: false + property int detailTopMargin: 30 + property bool lastPage: false + property bool bold: false + property bool center: true + property int bannerMargin: 20 + required property string headerText + property bool showHeader: true + property int headerMargin: 30 + property int headerSize: 28 + property string description: "" + property int descriptionMargin: 20 + property int descriptionSize: 18 + property string subtext: "" + property int subtextMargin: 30 + property int subtextSize: 15 + property string buttonObjectName: "continueButton" + property real maximumWidth: 600 + property real detailMaximumWidth: 450 + + background: null + clip: true + + header: NavigationBar { + id: navbar + } + + ScrollView { + id: scrollView + width: parent.width + height: parent.height + clip: true + contentWidth: width + + ColumnLayout { + id: information + width: Math.min(parent.width, 600) + anchors.horizontalCenter: parent.horizontalCenter + spacing: 0 + Loader { + id: banner_loader + active: root.bannerActive + visible: active + Layout.alignment: Qt.AlignCenter + Layout.topMargin: root.bannerMargin + sourceComponent: root.bannerItem + } + Header { + Layout.fillWidth: true + Layout.leftMargin: 20 + Layout.rightMargin: 20 + headerBold: root.bold + center: root.center + header: root.headerText + showHeader: root.showHeader + headerMargin: root.headerMargin + headerSize: root.headerSize + description: root.description + descriptionMargin: root.descriptionMargin + descriptionSize: root.descriptionSize + subtext: root.subtext + subtextMargin: root.subtextMargin + subtextSize: root.subtextSize + } + Loader { + id: detail_loader + active: root.detailActive + visible: active + Layout.fillWidth: true + Layout.alignment: Qt.AlignCenter + Layout.topMargin: root.detailTopMargin + Layout.leftMargin: 20 + Layout.rightMargin: 20 + Layout.maximumWidth: detailMaximumWidth + sourceComponent: root.detailItem + } + } + ContinueButton { + id: continueButton + objectName: root.buttonObjectName + visible: root.buttonText.length > 0 + enabled: visible + width: Math.min(300, parent.width - 2 * anchors.leftMargin) + anchors.topMargin: root.buttonMargin + anchors.bottomMargin: 60 + anchors.leftMargin: 20 + anchors.rightMargin: 20 + anchors.horizontalCenter: parent.horizontalCenter + text: root.buttonText + onClicked: root.next() + } + } + + state: AppMode.state + + states: [ + State { + name: "MOBILE" + AnchorChanges { + target: continueButton + anchors.top: undefined + anchors.bottom: continueButton.parent.bottom + } + PropertyChanges { + target: scrollView + contentHeight: { + var combinedHeight = information.height + continueButton.height + + continueButton.anchors.topMargin + continueButton.anchors.bottomMargin + if (scrollView.height < combinedHeight) { + return combinedHeight + } else { + return scrollView.height + } + } + } + }, + State { + name: "DESKTOP" + AnchorChanges { + target: continueButton + anchors.top: information.bottom + anchors.bottom: undefined + } + PropertyChanges { + target: scrollView + contentHeight: information.height + continueButton.height + + continueButton.anchors.topMargin + continueButton.anchors.bottomMargin + } + } + ] +} diff --git a/src/qml/controls/KeyValueRow.qml b/src/qml/controls/KeyValueRow.qml new file mode 100644 index 0000000000..48296e93a2 --- /dev/null +++ b/src/qml/controls/KeyValueRow.qml @@ -0,0 +1,34 @@ +// Copyright (c) 2024 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 + +RowLayout { + id: root + property int keyWidth: 125 + property alias key: keyField.contentItem + property alias value: valueField.contentItem + width: parent.width + + spacing: 10 + Pane { + id: keyField + implicitWidth: root.keyWidth + Layout.alignment: Qt.AlignLeft + background: null + padding: 0 + } + Pane { + id: valueField + Layout.fillWidth: true + Layout.minimumWidth: 0 + Layout.alignment: Qt.AlignLeft + implicitHeight: Math.max(valueField.contentHeight, 21) + padding: 0 + background: null + } +} diff --git a/src/qml/controls/LabeledCoinControlButton.qml b/src/qml/controls/LabeledCoinControlButton.qml new file mode 100644 index 0000000000..070832cf4e --- /dev/null +++ b/src/qml/controls/LabeledCoinControlButton.qml @@ -0,0 +1,60 @@ +// Copyright (c) 2025 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 + +Item { + property int coinsSelected: 0 + property int coinCount: 0 + property string valueObjectName: "" + + signal openCoinControl + + id: root + implicitHeight: label.height + + function click() { + if (coinCount > 0) { + root.openCoinControl() + } + } + + CoreText { + id: label + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + horizontalAlignment: Text.AlignLeft + width: 110 + font.pixelSize: 18 + text: qsTr("Inputs") + } + + CoreText { + objectName: root.valueObjectName + anchors.left: label.right + anchors.verticalCenter: parent.verticalCenter + horizontalAlignment: Text.AlignLeft + color: enabled ? Theme.color.orangeLight1 : Theme.color.neutral2 + font.pixelSize: 18 + text: { + if (coinCount === 0) { + qsTr("No coins available") + } else if (coinsSelected === 0) { + qsTr("Select") + } else { + qsTr("%1 input%2 selected") + .arg(coinsSelected) + .arg(coinsSelected === 1 ? "" : "s") + } + } + + MouseArea { + anchors.fill: parent + onClicked: root.click() + cursorShape: Qt.PointingHandCursor + } + } +} diff --git a/src/qml/controls/LabeledTextInput.qml b/src/qml/controls/LabeledTextInput.qml new file mode 100644 index 0000000000..4479a3d13c --- /dev/null +++ b/src/qml/controls/LabeledTextInput.qml @@ -0,0 +1,71 @@ +// Copyright (c) 2024-2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +Item { + property alias labelText: label.text + property alias text: input.text + property alias inputObjectName: input.objectName + property alias placeholderText: input.placeholderText + property alias iconSource: icon.source + property alias customIcon: iconContainer.data + property alias enabled: input.enabled + property alias validator: input.validator + property alias maximumLength: input.maximumLength + property alias cursorPosition: input.cursorPosition + property alias inputActiveFocus: input.activeFocus + + signal iconClicked + signal textEdited + signal editingFinished + signal inputFocusChanged + + id: root + implicitHeight: input.height + + CoreText { + id: label + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + horizontalAlignment: Text.AlignLeft + width: 110 + font.pixelSize: 18 + } + + TextField { + id: input + anchors.left: label.right + anchors.right: iconContainer.left + anchors.verticalCenter: parent.verticalCenter + leftPadding: 0 + font.family: "BitcoinCoreSans" + font.styleName: "Regular" + font.pixelSize: 18 + color: Theme.color.neutral9 + placeholderTextColor: enabled ? Theme.color.neutral7 : Theme.color.neutral4 + background: Item {} + selectByMouse: true + onTextEdited: root.textEdited() + onEditingFinished: root.editingFinished() + onActiveFocusChanged: root.inputFocusChanged() + } + + Item { + id: iconContainer + anchors.right: parent.right + anchors.verticalCenter: input.verticalCenter + + Icon { + id: icon + source: "" + color: enabled ? Theme.color.neutral8 : Theme.color.neutral4 + size: 30 + enabled: source != "" + onClicked: root.iconClicked() + } + } +} diff --git a/src/qml/controls/NavButton.qml b/src/qml/controls/NavButton.qml new file mode 100644 index 0000000000..e2105b8907 --- /dev/null +++ b/src/qml/controls/NavButton.qml @@ -0,0 +1,132 @@ +// Copyright (c) 2022 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 + +AbstractButton { + id: root + property int iconHeight: 30 + property int iconWidth: 30 + property bool bold: true + property bool busy: false + property var textStyle: bold ? Theme.text.buttonStrong : Theme.text.button + property int textSize: textStyle.pixelSize + property int textFontPixelSize: textSize + property string textFontStyleName: textStyle.styleName + property url iconSource: "" + property Rectangle iconBackground: null + property color iconColor: enabled ? Theme.color.neutral9 : Theme.color.neutral2 + hoverEnabled: AppMode.isDesktop + topPadding: text_background.active ? 7 : 14 + bottomPadding: text_background.active ? 7 : 14 + rightPadding: text_background.active ? 22 : 14 + leftPadding: text_background.active ? 2 : 14 + background: Rectangle { + id: bg + height: root.height + width: root.width + radius: 5 + state:"DEFAULT" + + states: [ + State { + name: "DEFAULT" + PropertyChanges { target: bg; color: Theme.color.background } + }, + State { + name: "HOVER" + PropertyChanges { target: bg; color: Theme.color.neutral2 } + }, + State { + name: "PRESSED" + PropertyChanges { target: bg; color: Theme.color.neutral3 } + } + ] + + FocusBorder { + visible: root.visualFocus + } + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + contentItem: RowLayout { + spacing: 0 + Item { + Layout.fillWidth: !text_background.active + } + Loader { + id: button_background + active: root.iconSource.toString().length > 0 + visible: active + sourceComponent: Button { + id: icon_button + padding: 0 + display: AbstractButton.IconOnly + height: root.iconHeight + width: root.iconWidth + icon.source: root.iconSource + icon.color: root.iconColor + icon.height: root.iconHeight + icon.width: root.iconWidth + background: root.iconBackground + + Behavior on icon.color { + ColorAnimation { duration: 150 } + } + } + } + Item { + Layout.fillWidth: !text_background.active + } + SpinningIndicator { + Layout.alignment: Qt.AlignVCenter + visible: root.busy + running: root.busy + color: root.iconColor + } + Loader { + id: text_background + active: root.text.length > 0 && !root.busy + visible: active + sourceComponent: AbstractButton { + id: container + topPadding: button_background.active ? 0 : 4 + bottomPadding: button_background.active ? 0 : 4 + rightPadding: 0 + leftPadding: button_background.active ? 0 : 20 + contentItem: CoreText { + anchors.verticalCenter: parent.verticalCenter + fontStyleName: root.textFontStyleName + font.pixelSize: root.textFontPixelSize + text: root.text + } + } + } + } + MouseArea { + anchors.fill: parent + hoverEnabled: AppMode.isDesktop && !root.busy + enabled: !root.busy + cursorShape: root.enabled && root.hoverEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onEntered: { + root.background.state = "HOVER" + } + onExited: { + root.background.state = "DEFAULT" + } + onPressed: { + root.background.state = "PRESSED" + } + onReleased: { + root.background.state = "DEFAULT" + root.clicked() + } + } +} diff --git a/src/qml/controls/NavigationBar.qml b/src/qml/controls/NavigationBar.qml new file mode 100644 index 0000000000..4ddbf95295 --- /dev/null +++ b/src/qml/controls/NavigationBar.qml @@ -0,0 +1,53 @@ +// Copyright (c) 2022 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 + +RowLayout { + id: root + property alias leftDetail: left_detail.sourceComponent + property alias middleDetail: middle_detail.sourceComponent + property alias rightDetail: right_detail.sourceComponent + + height: 46 + spacing: 0 + Layout.fillWidth: true + RowLayout { + Layout.preferredWidth: parent.width / 3 + Loader { + Layout.alignment: Qt.AlignLeft + Layout.topMargin: 4 + Layout.leftMargin: 4 + id: left_detail + active: true + visible: active + sourceComponent: root.leftDetail + } + } + RowLayout { + Layout.preferredWidth: parent.width / 3 + Loader { + Layout.alignment: Qt.AlignHCenter + id: middle_detail + Layout.topMargin: 4 + active: true + visible: active + sourceComponent: root.middleDetail + } + } + RowLayout { + Layout.preferredWidth: parent.width / 3 + Loader { + id: right_detail + Layout.alignment: Qt.AlignRight + Layout.topMargin: 4 + Layout.rightMargin: 4 + active: true + visible: active + sourceComponent: root.rightDetail + } + } +} diff --git a/src/qml/controls/NavigationBar2.qml b/src/qml/controls/NavigationBar2.qml new file mode 100644 index 0000000000..699f524b59 --- /dev/null +++ b/src/qml/controls/NavigationBar2.qml @@ -0,0 +1,82 @@ +// 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 + +Pane { + property alias leftItem: left_section.contentItem + property alias centerItem: center_section.contentItem + property alias rightItem: right_section.contentItem + property var navigationStack: null + property bool showBackButton: navigationStack ? navigationStack.canGoBack : false + property string backButtonObjectName: "" + property string backButtonText: qsTr("Back") + + signal backClicked + + background: null + padding: 4 + contentItem: RowLayout { + Div { + id: left_div + Layout.preferredWidth: Math.floor(Math.max(left_div.implicitWidth, right_div.implicitWidth)) + contentItem: RowLayout { + NavButton { + objectName: backButtonObjectName + visible: showBackButton + iconSource: "image://images/caret-left" + text: backButtonText + onClicked: { + if (navigationStack) { + navigationStack.goBack() + } + backClicked() + } + } + Section { + id: left_section + } + Spacer { + } + } + } + Section { + id: center_section + } + Div { + id: right_div + Layout.preferredWidth: Math.floor(Math.max(left_div.implicitWidth, right_div.implicitWidth)) + contentItem: RowLayout { + Spacer { + } + Section { + id: right_section + } + } + } + } + + component Div: Pane { + Layout.alignment: Qt.AlignCenter + Layout.fillWidth: true + Layout.minimumWidth: implicitWidth + background: null + padding: 0 + } + + component Section: Pane { + Layout.alignment: Qt.AlignCenter + Layout.minimumWidth: implicitWidth + background: null + padding: 0 + } + + component Spacer: Item { + Layout.alignment: Qt.AlignCenter + Layout.fillWidth: true + height: 1 + } +} diff --git a/src/qml/controls/NavigationTab.qml b/src/qml/controls/NavigationTab.qml new file mode 100644 index 0000000000..7edbdd9dc5 --- /dev/null +++ b/src/qml/controls/NavigationTab.qml @@ -0,0 +1,96 @@ +// Copyright (c) 2024 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 + +Button { + property color bgActiveColor: Theme.color.orange + property color textColor: Theme.color.neutral7 + property color textHoverColor: Theme.color.neutral9 + property color textActiveColor: Theme.color.orange + property color iconColor: "transparent" + property string iconSource: "" + property Component customContent: null + + id: root + checkable: true + hoverEnabled: AppMode.isDesktop + implicitHeight: 60 + implicitWidth: 80 + bottomPadding: 0 + topPadding: 0 + + HoverHandler { + cursorShape: root.enabled && root.hoverEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor + } + + contentItem: Item { + width: parent.width + height: parent.height + Loader { + id: customContentLoader + active: root.customContent !== null + visible: active + sourceComponent: root.customContent + anchors.centerIn: parent + } + CoreText { + id: buttonText + font.pixelSize: 15 + text: root.text + color: root.textColor + bold: true + visible: !customContentLoader.active && root.text !== "" + anchors.centerIn: parent + } + Icon { + id: icon + source: root.iconSource + color: iconColor + visible: !customContentLoader.active && root.iconSource !== "" + anchors.centerIn: parent + } + } + + background: Item { + Rectangle { + id: bg + height: parent.height - 5 + width: parent.width + radius: 5 + color: Theme.color.neutral3 + visible: root.hovered + + FocusBorder { + visible: root.visualFocus + } + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 3 + visible: root.checked + color: root.bgActiveColor + } + } + + states: [ + State { + name: "CHECKED"; when: root.checked + PropertyChanges { target: buttonText; color: root.textActiveColor } + PropertyChanges { target: icon; color: root.textActiveColor } + }, + State { + name: "HOVER"; when: root.hovered + PropertyChanges { target: buttonText; color: root.textHoverColor } + PropertyChanges { target: icon; color: root.textHoverColor } + } + ] +} diff --git a/src/qml/controls/OptionButton.qml b/src/qml/controls/OptionButton.qml new file mode 100644 index 0000000000..f6dfd9ea1d --- /dev/null +++ b/src/qml/controls/OptionButton.qml @@ -0,0 +1,125 @@ +// Copyright (c) 2021 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 + +Button { + id: button + property string description + property bool recommended: false + property string image: "" + property string customDir: "" + padding: 15 + checkable: true + implicitWidth: 450 + background: Rectangle { + border.width: 1 + border.color: button.checked ? Theme.color.orange : button.hovered ? Theme.color.neutral9 : Theme.color.neutral5 + radius: 10 + color: "transparent" + FocusBorder { + visible: button.visualFocus + borderRadius: 14 + } + } + + MouseArea { + anchors.fill: parent + onClicked: button.clicked() + } + + contentItem: RowLayout { + spacing: 3 + Loader { + active: button.image !== "" + visible: active + Layout.rightMargin: 7 + sourceComponent: Button { + icon.source: button.image + icon.height: 40 + icon.width: 40 + icon.color: Theme.color.neutral9 + padding: 0 + background: null + } + } + ColumnLayout { + spacing: 3 + Layout.fillWidth: true + Header { + Layout.fillWidth: true + Layout.preferredWidth: 0 + center: false + header: button.text + headerSize: 18 + headerMargin: 0 + description: button.description + descriptionSize: 15 + descriptionMargin: 0 + } + Loader { + Layout.topMargin: 2 + active: button.recommended + visible: active + sourceComponent: Label { + background: Rectangle { + color: Theme.color.neutral9 + radius: 3 + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + font.styleName: "Regular" + font.pixelSize: 13 + topPadding: 3 + rightPadding: 7 + bottomPadding: 4 + leftPadding: 7 + color: Theme.color.neutral0 + text: qsTr("Recommended") + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + } + Loader { + Layout.topMargin: 12 + Layout.fillWidth: true + active: button.customDir.length > 0 + visible: active + sourceComponent: Button { + id: container + background: Rectangle { + color: Theme.color.neutral2 + radius: 5 + } + font.family: "BitcoinCoreSans" + font.styleName: "Semi Bold" + font.pixelSize: 13 + contentItem: Text { + font: container.font + color: Theme.color.neutral9 + text: button.customDir + wrapMode: Text.WordWrap + } + } + } + } + Item { + height: parent.height + width: 40 + Icon { + anchors.centerIn: parent + visible: button.checked + source: "image://images/check" + color: Theme.color.neutral9 + size: 24 + } + } + } +} diff --git a/src/qml/controls/OptionSwitch.qml b/src/qml/controls/OptionSwitch.qml new file mode 100644 index 0000000000..9a9ec32442 --- /dev/null +++ b/src/qml/controls/OptionSwitch.qml @@ -0,0 +1,37 @@ +// Copyright (c) 2022 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 + +Switch { + id: root + implicitWidth: 45 + implicitHeight: 28 + background: Rectangle { + radius: Math.floor(height / 2) + color: root.checked ? Theme.color.orange : Theme.color.neutral4 + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + indicator: Rectangle { + property real _margin: Math.round((parent.height - height) / 2) + y: Math.round(parent.height - height) / 2 + x: root.checked ? parent.width - width - _margin : _margin + radius: 10 + width: 20 + height: 20 + color: Theme.color.white + Behavior on x { + SmoothedAnimation { + } + } + + Behavior on color { + ColorAnimation { duration: 150 } + } + } +} diff --git a/src/qml/controls/OutlineButton.qml b/src/qml/controls/OutlineButton.qml new file mode 100644 index 0000000000..6d1d6d5ceb --- /dev/null +++ b/src/qml/controls/OutlineButton.qml @@ -0,0 +1,75 @@ +// Copyright (c) 2022 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 + +Button { + id: root + hoverEnabled: AppMode.isDesktop + + property bool bold: false + property url iconSource: "" + property var textStyle: bold ? Theme.text.buttonStrong : Theme.text.button + property int textFontPixelSize: textStyle.pixelSize + property string textFontStyleName: textStyle.styleName + + leftPadding: 20 + rightPadding: 20 + + HoverHandler { cursorShape: Qt.PointingHandCursor } + + contentItem: Item { + implicitWidth: row.implicitWidth + implicitHeight: row.implicitHeight + Row { + id: row + anchors.centerIn: parent + spacing: 4 + Icon { + id: icon + anchors.verticalCenter: parent.verticalCenter + visible: root.iconSource.toString().length > 0 + source: root.iconSource + color: Theme.color.neutral9 + size: 20 + } + CoreText { + id: label + anchors.verticalCenter: parent.verticalCenter + text: root.text + bold: root.bold + fontStyleName: root.textFontStyleName + font.pixelSize: root.textFontPixelSize + color: Theme.color.neutral9 + } + } + } + background: Rectangle { + id: bg + implicitHeight: 46 + color: Theme.color.background + radius: 5 + border { + width: 1 + color: Theme.color.neutral6 + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + } + + states: [ + State { + name: "PRESSED"; when: root.pressed + PropertyChanges { target: bg; border.color: Theme.color.orangeLight2 } + }, + State { + name: "HOVER"; when: root.hovered + PropertyChanges { target: bg; border.color: Theme.color.neutral9 } + } + ] +} diff --git a/src/qml/controls/PageIndicator.qml b/src/qml/controls/PageIndicator.qml new file mode 100644 index 0000000000..751d251d94 --- /dev/null +++ b/src/qml/controls/PageIndicator.qml @@ -0,0 +1,22 @@ +// Copyright (c) 2022 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 + +PageIndicator { + id: root + delegate: Rectangle { + implicitWidth: 10 + implicitHeight: 10 + radius: Math.round(width / 2) + color: Theme.color.neutral9 + opacity: index === root.currentIndex ? 0.95 : pressed ? 0.7 : 0.45 + Behavior on opacity { + OpacityAnimator { + duration: 100 + } + } + } +} diff --git a/src/qml/controls/PageStack.qml b/src/qml/controls/PageStack.qml new file mode 100644 index 0000000000..7411fa4a63 --- /dev/null +++ b/src/qml/controls/PageStack.qml @@ -0,0 +1,54 @@ +// Copyright (c) 2024 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 + +StackView { + property bool vertical: false + readonly property bool canGoBack: depth > 1 && currentItem && currentItem.navigationBackEnabled !== false + + function goBack() { + if (canGoBack) { + pop() + } + } + + pushEnter: Transition { + NumberAnimation { + property: vertical ? "y" : "x" + from: vertical ? parent.height : parent.width + to: 0 + duration: 500 + easing.type: Easing.InOutCubic + } + } + pushExit: Transition { + NumberAnimation { + property: vertical ? "y" : "x" + from: 0 + to: vertical ? -parent.height : -parent.width + duration: 500 + easing.type: Easing.InOutCubic + } + } + popEnter: Transition { + NumberAnimation { + property: vertical ? "y" : "x" + from: vertical ? -parent.height : -parent.width + to: 0 + duration: 500 + easing.type: Easing.InOutCubic + } + } + popExit: Transition { + NumberAnimation { + property: vertical ? "y" : "x" + from: 0 + to: vertical ? parent.height : parent.width + duration: 500 + easing.type: Easing.InOutCubic + } + } +} diff --git a/src/qml/controls/ProgressIndicator.qml b/src/qml/controls/ProgressIndicator.qml new file mode 100644 index 0000000000..117a4baebb --- /dev/null +++ b/src/qml/controls/ProgressIndicator.qml @@ -0,0 +1,33 @@ +// Copyright (c) 2021 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 { + property real progress: 0 + Behavior on progress { + NumberAnimation { + easing.type: Easing.Bezier + easing.bezierCurve: [0.5, 0.0, 0.2, 1, 1, 1] + duration: 250 + } + } + contentItem: Rectangle { + implicitHeight: 6 + radius: Math.floor(height / 2) + color: Theme.color.neutral3 + Item { + width: Math.round(progress * contentItem.width) + height: parent.height + clip: true + Rectangle { + width: contentItem.width + height: contentItem.height + radius: contentItem.radius + color: Theme.color.orange + } + } + } +} diff --git a/src/qml/controls/QRImage.qml b/src/qml/controls/QRImage.qml new file mode 100644 index 0000000000..9eda57f54c --- /dev/null +++ b/src/qml/controls/QRImage.qml @@ -0,0 +1,17 @@ +// Copyright (c) 2025 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 + +Image { + id: root + + property string code: "" + property color backgroundColor: Qt.black + property color foregroundColor: Qt.white + + fillMode: Image.PreserveAspectFit + smooth: false + source: `image://qr/${encodeURIComponent(root.code)}?&fg=${encodeURIComponent(root.foregroundColor)}&bg=${encodeURIComponent(root.backgroundColor)}` +} diff --git a/src/qml/controls/ReceiveOptionsPopup.qml b/src/qml/controls/ReceiveOptionsPopup.qml new file mode 100644 index 0000000000..857438a97e --- /dev/null +++ b/src/qml/controls/ReceiveOptionsPopup.qml @@ -0,0 +1,35 @@ +// 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 "../components" +import "../controls" + +OptionPopup { + id: root + + property alias addressFormatEnabled: addressFormatToggle.checked + + implicitWidth: 300 + implicitHeight: 58 + + clip: true + modal: true + dim: false + + ColumnLayout { + anchors.centerIn: parent + anchors.margins: 10 + spacing: 0 + + EllipsisMenuToggleItem { + id: addressFormatToggle + Layout.fillWidth: true + text: qsTr("Choose address type") + } + } +} diff --git a/src/qml/controls/SegmentedPicker.qml b/src/qml/controls/SegmentedPicker.qml new file mode 100644 index 0000000000..a74b9fba88 --- /dev/null +++ b/src/qml/controls/SegmentedPicker.qml @@ -0,0 +1,70 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +Control { + id: root + + property var model: [] + property int currentIndex: 0 + readonly property string currentText: model.length > currentIndex ? optionText(model[currentIndex]) : "" + + signal selected(int index, var option) + + function optionText(option) { + if (typeof option === "object" && option !== null && option.text !== undefined) { + return option.text + } + return option + } + + implicitHeight: 45 + implicitWidth: 360 + padding: 5 + + background: Rectangle { + color: Theme.color.neutral3 + radius: 8 + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + + contentItem: RowLayout { + spacing: 5 + + Repeater { + model: root.model + + ToggleButton { + required property int index + required property var modelData + + Layout.fillWidth: true + Layout.fillHeight: true + Layout.preferredWidth: 1 + Layout.minimumWidth: 0 + autoExclusive: true + checked: index === root.currentIndex + text: root.optionText(modelData) + bgRadius: 5 + textColor: Theme.color.white + textHoverColor: Theme.color.orangeLight1 + textActiveColor: Theme.color.white + textActiveBold: true + bgHoverColor: checked ? Theme.color.neutral6 : Theme.color.neutral4 + bgActiveColor: Theme.color.neutral6 + bgDefaultColor: Theme.color.neutral3 + + onClicked: { + root.selected(index, modelData) + } + } + } + } +} diff --git a/src/qml/controls/SendOptionsPopup.qml b/src/qml/controls/SendOptionsPopup.qml new file mode 100644 index 0000000000..de9f572820 --- /dev/null +++ b/src/qml/controls/SendOptionsPopup.qml @@ -0,0 +1,66 @@ +// Copyright (c) 2025-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 "../components" +import "../controls" + +OptionPopup { + id: root + objectName: "sendOptionsPopup" + + property alias coinControlEnabled: coinControlToggle.checked + property alias multipleRecipientsEnabled: multipleRecipientsToggle.checked + + signal openPaymentRequest() + + implicitWidth: 300 + implicitHeight: columnLayout.implicitHeight + 20 + + clip: true + modal: true + dim: false + + ColumnLayout { + id: columnLayout + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 5 + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 0 + + EllipsisMenuButtonItem { + objectName: "sendOptionsOpenPaymentRequestButton" + Layout.fillWidth: true + text: qsTr("Open payment request") + onClicked: { + root.close() + root.openPaymentRequest() + } + } + + Separator { + Layout.fillWidth: true + } + + EllipsisMenuToggleItem { + id: coinControlToggle + objectName: "sendOptionsCoinControlToggle" + Layout.fillWidth: true + text: qsTr("Enable Coin control") + } + + EllipsisMenuToggleItem { + id: multipleRecipientsToggle + objectName: "sendOptionsMultipleRecipientsToggle" + Layout.fillWidth: true + text: qsTr("Multiple Recipients") + } + } +} diff --git a/src/qml/controls/Setting.qml b/src/qml/controls/Setting.qml new file mode 100644 index 0000000000..43e442e641 --- /dev/null +++ b/src/qml/controls/Setting.qml @@ -0,0 +1,112 @@ +// Copyright (c) 2022 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 + +AbstractButton { + id: root + property string header + property alias actionItem: action_loader.sourceComponent + property alias loadedItem: action_loader.item + property string description + property color descriptionColor: Theme.color.neutral8 + property int descriptionSize: 15 + property string errorText: "" + property bool showErrorText: false + property color stateColor + property color filledStateColor: Theme.color.neutral9 + property color hoverStateColor: Theme.color.orangeLight1 + property color activeStateColor: Theme.color.orange + property color disabledStateColor: Theme.color.neutral4 + hoverEnabled: AppMode.isDesktop + state: "FILLED" + + states: [ + State { + name: "FILLED" + PropertyChanges { + target: root + enabled: true + stateColor: root.filledStateColor + } + }, + State { + name: "HOVER" + PropertyChanges { target: root; stateColor: root.hoverStateColor } + }, + State { + name: "ACTIVE" + PropertyChanges { target: root; stateColor: root.activeStateColor } + }, + State { + name: "DISABLED" + PropertyChanges { + target: root + enabled: false + stateColor: root.disabledStateColor + } + } + ] + + background: FocusBorder { + visible: root.visualFocus + topMargin: -4 + bottomMargin: -4 + leftMargin: -6 + rightMargin: -6 + } + + MouseArea { + id: mouseArea + anchors.fill: root + enabled: root.enabled + hoverEnabled: AppMode.isDesktop + cursorShape: AppMode.isDesktop && root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onEntered: { + if (root.state !== "DISABLED") root.state = "HOVER" + } + onExited: { + if (root.state !== "DISABLED") root.state = "FILLED" + } + onPressed: { + root.state = "ACTIVE" + } + onReleased: { + if (mouseArea.containsMouse) { + root.state = "HOVER" + root.clicked() + } else { + root.state = "FILLED" + } + } + } + + contentItem: RowLayout { + Header { + Layout.topMargin: 14 + Layout.bottomMargin: 14 + Layout.fillWidth: true + center: false + header: root.header + headerSize: 18 + headerColor: root.stateColor + description: root.description + descriptionSize: root.descriptionSize + descriptionColor: root.descriptionColor + descriptionMargin: 0 + subtext: root.showErrorText ? root.errorText : "" + subtextColor: Theme.color.blue + } + Loader { + id: action_loader + active: true + visible: active + sourceComponent: root.actionItem + } + } +} diff --git a/src/qml/controls/Skeleton.qml b/src/qml/controls/Skeleton.qml new file mode 100644 index 0000000000..36277c1cc0 --- /dev/null +++ b/src/qml/controls/Skeleton.qml @@ -0,0 +1,63 @@ +// Copyright (c) 2025 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 + +Rectangle { + id: root + property color baseColor: Theme.color.neutral1 + property color highlightColor: Theme.color.neutral2 + property int shimmerDuration: 2500 + property bool loading: true + + radius: 3 + + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { + id: stop1 + position: 0.0 + color: root.baseColor + } + GradientStop { + id: stop2 + position: 0.5 + color: root.highlightColor + } + GradientStop { + id: stop3; + position: 1 + color: root.baseColor + } + } + + ParallelAnimation { + running: loading + loops: Animation.Infinite + NumberAnimation { + target: stop1 + property: "position" + from: -1.0 + to: 1.0 + duration: root.shimmerDuration + easing.type: Easing.Linear + } + NumberAnimation { + target: stop2 + property: "position" + from: -0.5 + to: 1.5 + duration: root.shimmerDuration; + easing.type: Easing.Linear + } + NumberAnimation { + target: stop3 + property: "position" + from: 0.0 + to: 2.0 + duration: root.shimmerDuration + easing.type: Easing.Linear + } + } +} diff --git a/src/qml/controls/SpinningIndicator.qml b/src/qml/controls/SpinningIndicator.qml new file mode 100644 index 0000000000..4c89666c21 --- /dev/null +++ b/src/qml/controls/SpinningIndicator.qml @@ -0,0 +1,45 @@ +// 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 + +Item { + id: root + + property color color: Theme.color.neutral9 + property bool running: false + property int strokeWidth: 2 + + implicitWidth: 18 + implicitHeight: 18 + + Canvas { + id: arc + anchors.fill: parent + onPaint: { + const ctx = getContext("2d") + ctx.reset() + ctx.strokeStyle = root.color + ctx.lineWidth = root.strokeWidth + ctx.lineCap = "round" + ctx.beginPath() + ctx.arc(width / 2, height / 2, width / 2 - root.strokeWidth, 0, Math.PI * 1.5) + ctx.stroke() + } + Connections { + target: root + function onColorChanged() { arc.requestPaint() } + } + } + + RotationAnimator { + target: root + from: 0 + to: 360 + duration: 800 + loops: Animation.Infinite + running: root.running + } +} diff --git a/src/qml/controls/TextButton.qml b/src/qml/controls/TextButton.qml new file mode 100644 index 0000000000..0bfafecde0 --- /dev/null +++ b/src/qml/controls/TextButton.qml @@ -0,0 +1,59 @@ +// Copyright (c) 2022 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 + +Button { + id: root + property int textSize: 18 + property color textColor: Theme.color.orange + property color bgColor: Theme.color.background + property bool bold: true + property bool rightalign: false + padding: 15 + hoverEnabled: AppMode.isDesktop + contentItem: CoreText { + text: root.text + bold: root.bold + font.pixelSize: root.textSize + color: root.textColor + horizontalAlignment: rightalign ? Text.AlignRight : Text.AlignHCenter + Behavior on color { + ColorAnimation { duration: 150 } + } + } + background: Rectangle { + id: bg + color: root.bgColor + radius: 5 + Behavior on color { + ColorAnimation { duration: 150 } + } + + FocusBorder { + visible: root.visualFocus + } + } + states: [ + State { + name: "PRESSED"; when: root.pressed + PropertyChanges { + target: root + textColor: Theme.color.orangeLight2 + bgColor: Theme.color.neutral3 + } + }, + State { + name: "HOVER"; when: root.hovered + PropertyChanges { + target: root + textColor: Theme.color.orangeLight1 + bgColor: Theme.color.neutral2 + } + } + ] +} diff --git a/src/qml/controls/Theme.qml b/src/qml/controls/Theme.qml new file mode 100644 index 0000000000..b02143e73c --- /dev/null +++ b/src/qml/controls/Theme.qml @@ -0,0 +1,266 @@ +pragma Singleton +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import Qt.labs.settings 1.0 + +Control { + id: root + property bool dark: true + property real blockclocksize: (5 / 12) + readonly property ColorSet color: dark ? darkColorSet : lightColorSet + readonly property ImageSet image: dark ? darkImageSet : lightImageSet + readonly property TextSet text: TextSet {} + + Settings { + id: settings + property alias dark: root.dark + property alias blockclocksize: root.blockclocksize + } + + component ColorSet: QtObject { + required property color white + required property color background + required property color orange + required property color orangeLight1 + required property color orangeLight2 + required property color red + required property color green + required property color blue + required property color amber + required property color purple + required property color neutral0 + required property color neutral1 + required property color neutral2 + required property color neutral3 + required property color neutral4 + required property color neutral5 + required property color neutral6 + required property color neutral7 + required property color neutral8 + required property color neutral9 + required property var confirmationColors + } + + component ImageSet: QtObject { + required property url blocktime + required property url network + required property url storage + required property url tooltipArrow + } + + component TextStyle: QtObject { + required property string family + required property string styleName + required property int pixelSize + + // Default line height is 140% of pixelSize; each role overrides with the spec value. + // Use with `lineHeightMode: Text.FixedHeight` on the consumer Text element. + property int lineHeight: Math.round(pixelSize * 1.4) + + readonly property font font: Qt.font({ + family: family, + styleName: styleName, + pixelSize: pixelSize + }) + } + + ColorSet { + id: darkColorSet + white: "#FFFFFF" + background: "black" + orange: "#F89B2A" + orangeLight1: "#FFAD4A" + orangeLight2: "#FFBF72" + red: "#EC6363" + green: "#36B46B" + blue: "#3CA3DE" + amber: "#C9B500" + purple: "#C075DC" + neutral0: "#000000" + neutral1: "#1A1A1A" + neutral2: "#2D2D2D" + neutral3: "#444444" + neutral4: "#5C5C5C" + neutral5: "#787878" + neutral6: "#949494" + neutral7: "#B0B0B0" + neutral8: "#CCCCCC" + neutral9: "#FFFFFF" + confirmationColors: [ + "#FF1C1C", // red + "#ED6E46", + "#EE8847", + "#EFA148", + "#F0BB49", + "#F1D54A", // yellow + ] + } + + ColorSet { + id: lightColorSet + white: "#FFFFFF" + background: "white" + orange: "#F7931A" + orangeLight1: "#FFAD4A" + orangeLight2: "#FFBF72" + red: "#EB5757" + green: "#27AE60" + blue: "#2D9CDB" + amber: "#C9B500" + purple: "#BB6BD9" + neutral0: "#FFFFFF" + neutral1: "#F8F8F8" + neutral2: "#F4F4F4" + neutral3: "#EDEDED" + neutral4: "#DEDEDE" + neutral5: "#BBBBBB" + neutral6: "#999999" + neutral7: "#777777" + neutral8: "#404040" + neutral9: "#000000" + confirmationColors: [ + "#FF1C1C", // red + "#ED6E46", + "#EE8847", + "#EFA148", + "#F0BB49", + "#F1D54A", // yellow + ] + } + + ImageSet { + id: darkImageSet + blocktime: "image://images/blocktime-dark" + network: "image://images/network-dark" + storage: "image://images/storage-dark" + tooltipArrow: "qrc:/icons/tooltip-arrow-dark" + } + + ImageSet { + id: lightImageSet + blocktime: "image://images/blocktime-light" + network: "image://images/network-light" + storage: "image://images/storage-light" + tooltipArrow: "qrc:/icons/tooltip-arrow-light" + } + + component TextSet: QtObject { + id: textSetRoot + readonly property string family: "BitcoinCoreSans" + readonly property string monoFamily: "Roboto Mono" + + // Headers — Semi Bold + readonly property TextStyle display: TextStyle { + family: textSetRoot.family + styleName: "Semi Bold" + pixelSize: 36 + lineHeight: 44 + } + readonly property TextStyle headline: TextStyle { + family: textSetRoot.family + styleName: "Semi Bold" + pixelSize: 28 + lineHeight: 34 + } + readonly property TextStyle title: TextStyle { + family: textSetRoot.family + styleName: "Semi Bold" + pixelSize: 24 + lineHeight: 28 + } + readonly property TextStyle subtitle: TextStyle { + family: textSetRoot.family + styleName: "Semi Bold" + pixelSize: 21 + lineHeight: 25 + } + readonly property TextStyle heading: TextStyle { + family: textSetRoot.family + styleName: "Semi Bold" + pixelSize: 18 + lineHeight: 21 + } + readonly property TextStyle subheading: TextStyle { + family: textSetRoot.family + styleName: "Semi Bold" + pixelSize: 15 + lineHeight: 18 + } + + // Body — Regular + readonly property TextStyle lead: TextStyle { + family: textSetRoot.family + styleName: "Regular" + pixelSize: 24 + lineHeight: 36 + } + readonly property TextStyle bodyLarge: TextStyle { + family: textSetRoot.family + styleName: "Regular" + pixelSize: 21 + lineHeight: 31 + } + readonly property TextStyle body: TextStyle { + family: textSetRoot.family + styleName: "Regular" + pixelSize: 18 + lineHeight: 27 + } + readonly property TextStyle description: TextStyle { + family: textSetRoot.family + styleName: "Regular" + pixelSize: 15 + lineHeight: 22 + } + readonly property TextStyle caption: TextStyle { + family: textSetRoot.family + styleName: "Regular" + pixelSize: 13 + lineHeight: 19 + } + + // Controls + readonly property TextStyle button: TextStyle { + family: textSetRoot.family + styleName: "Regular" + pixelSize: 18 + lineHeight: 22 + } + readonly property TextStyle buttonStrong: TextStyle { + family: textSetRoot.family + styleName: "Semi Bold" + pixelSize: 18 + lineHeight: 22 + } + + // Mono — Roboto Mono Regular + readonly property TextStyle monoLead: TextStyle { + family: textSetRoot.monoFamily + styleName: "Regular" + pixelSize: 24 + lineHeight: 36 + } + readonly property TextStyle monoBody: TextStyle { + family: textSetRoot.monoFamily + styleName: "Regular" + pixelSize: 18 + lineHeight: 27 + } + readonly property TextStyle monoDescription: TextStyle { + family: textSetRoot.monoFamily + styleName: "Regular" + pixelSize: 15 + lineHeight: 22 + } + readonly property TextStyle monoCaption: TextStyle { + family: textSetRoot.monoFamily + styleName: "Regular" + pixelSize: 13 + lineHeight: 19 + } + } + + function toggleDark() { + dark = !dark + } +} diff --git a/src/qml/controls/ToggleButton.qml b/src/qml/controls/ToggleButton.qml new file mode 100644 index 0000000000..6e3aa89cb4 --- /dev/null +++ b/src/qml/controls/ToggleButton.qml @@ -0,0 +1,63 @@ +// 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 org.bitcoincore.qt 1.0 + +Button { + property int bgRadius: 5 + property color bgDefaultColor: Theme.color.neutral2 + property color bgHoverColor: Theme.color.neutral2 + property color bgActiveColor: Theme.color.neutral5 + property color textColor: Theme.color.neutral7 + property color textHoverColor: Theme.color.orangeLight1 + property color textActiveColor: Theme.color.neutral9 + property bool textBold: false + property bool textActiveBold: textBold + + id: root + checkable: true + hoverEnabled: AppMode.isDesktop + leftPadding: 12 + rightPadding: 12 + topPadding: 5 + bottomPadding: 5 + + contentItem: CoreText { + id: buttonText + text: parent.text + font.pixelSize: Theme.text.caption.pixelSize + bold: root.textBold + color: root.textColor + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + + background: Rectangle { + id: bg + color: root.bgDefaultColor + radius: root.bgRadius + + Behavior on color { + ColorAnimation { duration: 150 } + } + } + + states: [ + State { + name: "CHECKED"; when: root.checked + PropertyChanges { target: bg; color: root.bgActiveColor } + PropertyChanges { target: buttonText; color: root.textActiveColor } + PropertyChanges { target: buttonText; bold: root.textActiveBold } + }, + State { + name: "HOVER"; when: root.hovered + PropertyChanges { target: bg; color: root.bgHoverColor } + PropertyChanges { target: buttonText; color: root.textHoverColor } + } + ] +} diff --git a/src/qml/controls/ValueInput.qml b/src/qml/controls/ValueInput.qml new file mode 100644 index 0000000000..585d0b3d33 --- /dev/null +++ b/src/qml/controls/ValueInput.qml @@ -0,0 +1,61 @@ +// Copyright (c) 2022 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 + +TextInput { + id: root + required property string parentState + property string description: "" + property bool filled: false + property int descriptionSize: 18 + property color textColor: root.filled ? Theme.color.neutral9 : Theme.color.neutral5 + enabled: true + state: root.parentState + validator: IntValidator{} + maximumLength: 5 + + states: [ + State { + name: "ACTIVE" + PropertyChanges { target: root; textColor: Theme.color.orange } + }, + State { + name: "HOVER" + PropertyChanges { + target: root + textColor: root.filled ? Theme.color.orangeLight1 : Theme.color.neutral5 + } + }, + State { + name: "DISABLED" + PropertyChanges { + target: root + enabled: false + textColor: Theme.color.neutral4 + } + } + ] + + font.family: "BitcoinCoreSans" + font.styleName: "Regular" + font.pixelSize: root.descriptionSize + color: root.textColor + text: root.description + horizontalAlignment: Text.AlignRight + wrapMode: Text.WordWrap + + Behavior on color { + ColorAnimation { duration: 150 } + } + + function checkValidity(minVal, maxVal, input) { + if (isNaN(input) || input < minVal || input > maxVal) { + return false + } else { + return true + } + } +} diff --git a/src/qml/controls/WalletTypeListItem.qml b/src/qml/controls/WalletTypeListItem.qml new file mode 100644 index 0000000000..f7b988367e --- /dev/null +++ b/src/qml/controls/WalletTypeListItem.qml @@ -0,0 +1,63 @@ +// Copyright (c) 2025 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 + +AbstractButton { + id: root + + property string title: "" + property string description: "" + property string iconSource: "" + property color iconColor: Theme.color.neutral9 + + padding: 15 + implicitWidth: 450 + opacity: enabled ? 1.0 : 0.4 + + background: Rectangle { + border.width: 1 + border.color: root.hovered && root.enabled ? Theme.color.neutral9 : Theme.color.neutral5 + radius: 10 + color: "transparent" + FocusBorder { + visible: root.visualFocus + borderRadius: 14 + } + } + + contentItem: RowLayout { + spacing: 10 + Icon { + source: root.iconSource + color: root.iconColor + size: 24 + } + ColumnLayout { + spacing: 2 + Layout.fillWidth: true + CoreText { + Layout.fillWidth: true + text: root.title + font.pixelSize: 18 + bold: true + color: Theme.color.neutral9 + horizontalAlignment: Text.AlignLeft + } + CoreText { + Layout.fillWidth: true + text: root.description + font.pixelSize: 15 + color: Theme.color.neutral7 + horizontalAlignment: Text.AlignLeft + wrapMode: Text.WordWrap + } + } + CaretRightIcon { + Layout.alignment: Qt.AlignVCenter + } + } +} diff --git a/src/qml/controls/linegraph.cpp b/src/qml/controls/linegraph.cpp new file mode 100644 index 0000000000..6c59d0d919 --- /dev/null +++ b/src/qml/controls/linegraph.cpp @@ -0,0 +1,148 @@ +// 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. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +LineGraph::LineGraph(QQuickItem *parent) + : QQuickPaintedItem(parent) +{ + setFillColor(m_background_color); +} + +void LineGraph::setBackgroundColor(QColor color) +{ + m_background_color = color; + setFillColor(color); +} + +void LineGraph::setBorderColor(QColor color) +{ + m_border_color = color; + update(); +} + +void LineGraph::setFillColor(QColor color) +{ + m_fill_color = color; + update(); +} + +void LineGraph::setLineColor(QColor color) +{ + m_line_color = color; + update(); +} + +void LineGraph::setMarkerLineColor(QColor color) +{ + m_marker_line_color = color; + update(); +} + +void LineGraph::setMaxSamples(int max_samples) +{ + m_max_samples = max_samples; + update(); +} + +void LineGraph::setMaxValue(float max_value) +{ + m_max_value = max_value; +} + +void LineGraph::setValueList(QQueue value_list) +{ + m_value_list = value_list; + update(); +} + +void LineGraph::paintGraphBorder(QPainter * painter) +{ + painter->setPen(QPen(m_border_color, 2)); + QRectF rect(0, 0, width(), height()); + painter->drawRect(rect); +} + +void LineGraph::paintMarkerLines(QPainter * painter) +{ + painter->setPen(QPen(m_marker_line_color, 1)); + qreal line_spacing = height() / 8; + + for (int i = 0; i < 9; i++) { + qreal y = i * line_spacing; + + if (i == 4) { + painter->setPen(QPen(m_border_color, 1)); + painter->drawLine(0, y, width(), y); + painter->setPen(QPen(m_marker_line_color, 1)); + } else { + painter->drawLine(0, y, width(), y); + } + } +} + +void LineGraph::paintPath(QPainterPath * painter_path) +{ + int item_count = std::min(m_max_samples, static_cast(m_value_list.size())); + qreal h = height(); + qreal w = width(); + + if(item_count > 0) { + int y_pos = h; + int x_pos = w; + painter_path->moveTo(x_pos, y_pos); + + for(int i = 0; i < item_count; ++i) { + x_pos = w - w * i / m_max_samples; + y_pos = h - ((h - 10) * m_value_list.at(i) / m_max_value); + painter_path->lineTo(x_pos, y_pos); + } + + painter_path->lineTo(x_pos - 2, h); + painter_path->lineTo(0, h); + } +} + +void LineGraph::paintTraffic(QPainter * painter) +{ + painter->setRenderHint(QPainter::Antialiasing); + + if (m_max_value == 0.0f) { + return; + } + + if(!m_value_list.empty()) { + QPainterPath p; + paintPath(&p); + setupGradient(&p); + painter->setPen(QPen(m_line_color, 1)); + painter->drawPath(p); + painter->fillPath(p, QBrush(m_fill_gradient)); + } +} + +void LineGraph::setupGradient(QPainterPath * painter_path) +{ + QLinearGradient gradient(painter_path->boundingRect().topLeft(), painter_path->boundingRect().bottomLeft()); + gradient.setColorAt(0, QColor(m_fill_color.red(), m_fill_color.green(), m_fill_color.blue(), 191)); + gradient.setColorAt(1, QColor("transparent")); + m_fill_gradient = gradient; +} + +void LineGraph::paint(QPainter *painter) +{ + paintMarkerLines(painter); + paintTraffic(painter); + paintGraphBorder(painter); +} diff --git a/src/qml/controls/linegraph.h b/src/qml/controls/linegraph.h new file mode 100644 index 0000000000..356a7b9701 --- /dev/null +++ b/src/qml/controls/linegraph.h @@ -0,0 +1,67 @@ +// 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. + +#ifndef BITCOIN_QML_CONTROLS_LINEGRAPH_H +#define BITCOIN_QML_CONTROLS_LINEGRAPH_H + +#include +#include +#include +#include +#include + +class LineGraph : public QQuickPaintedItem +{ + Q_OBJECT + Q_PROPERTY(QColor backgroundColor READ backgroundColor WRITE setBackgroundColor) + Q_PROPERTY(QColor borderColor READ borderColor WRITE setBorderColor) + Q_PROPERTY(QColor fillColor READ fillColor WRITE setFillColor) + Q_PROPERTY(QColor lineColor READ lineColor WRITE setLineColor) + Q_PROPERTY(QColor markerLineColor READ markerLineColor WRITE setMarkerLineColor) + Q_PROPERTY(int maxSamples READ maxSamples WRITE setMaxSamples) + Q_PROPERTY(float maxValue READ maxValue WRITE setMaxValue) + Q_PROPERTY(QQueue valueList READ valueList WRITE setValueList) + + public: + explicit LineGraph(QQuickItem * parent = nullptr); + void paint(QPainter * painter) override; + + QColor backgroundColor() const { return m_background_color; }; + QColor borderColor() const { return m_border_color; }; + QColor fillColor() const { return m_fill_color; }; + QColor lineColor() const { return m_line_color; }; + QColor markerLineColor() const { return m_marker_line_color; }; + int maxSamples() const { return m_max_samples; }; + float maxValue() const { return m_max_value; }; + QQueue valueList() const { return m_value_list; }; + + public Q_SLOTS: + void setBackgroundColor(QColor color); + void setBorderColor(QColor color); + void setFillColor(QColor color); + void setLineColor(QColor color); + void setMarkerLineColor(QColor color); + void setMaxSamples(int max_samples); + void setMaxValue(float max_value); + void setValueList(QQueue value_list); + + private: + void paintGraphBorder(QPainter * painter); + void paintMarkerLines(QPainter * painter); + void paintPath(QPainterPath * painter_path); + void paintTraffic(QPainter * painter); + void setupGradient(QPainterPath * painter); + + QColor m_background_color{"#2D2D2D"}; + QColor m_border_color{"#000000"}; + QColor m_fill_color{"#000000"}; + QLinearGradient m_fill_gradient{0, 0, 0, 0}; + QColor m_line_color{"#000000"}; + QColor m_marker_line_color{"#000000"}; + int m_max_samples{0}; + float m_max_value{0}; + QQueue m_value_list; +}; + +#endif // BITCOIN_QML_CONTROLS_LINEGRAPH_H diff --git a/src/qml/controls/qmldir b/src/qml/controls/qmldir new file mode 100644 index 0000000000..d57fb8673e --- /dev/null +++ b/src/qml/controls/qmldir @@ -0,0 +1 @@ +singleton Theme 1.0 Theme.qml diff --git a/src/qml/controls/utils.js b/src/qml/controls/utils.js new file mode 100644 index 0000000000..e3ca477009 --- /dev/null +++ b/src/qml/controls/utils.js @@ -0,0 +1,80 @@ +// Copyright (c) 2024 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +// utils.js + +function formatRelativeTime(isoString) { + if (!isoString) return "" + var then = new Date(isoString) + var now = new Date() + var diffSec = Math.floor((now - then) / 1000) + if (diffSec < 60) return qsTr("just now") + var m = Math.floor(diffSec / 60) + if (diffSec < 3600) return m === 1 ? qsTr("1 minute ago") : qsTr("%1 minutes ago").arg(m) + var h = Math.floor(diffSec / 3600) + if (diffSec < 86400) return h === 1 ? qsTr("1 hour ago") : qsTr("%1 hours ago").arg(h) + var d = Math.floor(diffSec / 86400) + return d === 1 ? qsTr("1 day ago") : qsTr("%1 days ago").arg(d) +} + +function formatRemainingSyncTime(milliseconds) { + var minutes = Math.floor(milliseconds / 60000); + var seconds = Math.floor((milliseconds % 60000) / 1000); + var weeks = Math.floor(minutes / 10080); + minutes %= 10080; + var days = Math.floor(minutes / 1440); + minutes %= 1440; + var hours = Math.floor(minutes / 60); + minutes %= 60; + var result = ""; + var estimatingStatus = false; + + if (weeks > 0) { + return { + text: "~" + weeks + (weeks === 1 ? " week" : " weeks") + " left", + estimating: false + }; + } + if (days > 0) { + return { + text: "~" + days + (days === 1 ? " day" : " days") + " left", + estimating: false + }; + } + if (hours >= 5) { + return { + text: "~" + hours + (hours === 1 ? " hour" : " hours") + " left", + estimating: false + }; + } + if (hours > 0) { + return { + text: "~" + hours + "h " + minutes + "m" + " left", + estimating: false + }; + } + if (minutes >= 5) { + return { + text: "~" + minutes + (minutes === 1 ? " minute" : " minutes") + " left", + estimating: false + }; + } + if (minutes > 0) { + return { + text: "~" + minutes + "m " + seconds + "s" + " left", + estimating: false + }; + } + if (seconds > 0) { + return { + text: "~" + seconds + (seconds === 1 ? " second" : " seconds") + " left", + estimating: false + }; + } else { + return { + text: "Estimating", + estimating: true + }; + } +} diff --git a/src/qml/guiconstants.h b/src/qml/guiconstants.h new file mode 100644 index 0000000000..8e840b9ab5 --- /dev/null +++ b/src/qml/guiconstants.h @@ -0,0 +1,24 @@ +// Copyright (c) 2011-2024 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QML_GUICONSTANTS_H +#define BITCOIN_QML_GUICONSTANTS_H + +#include + +#define QAPP_ORG_NAME "BitcoinCore" +#define QAPP_ORG_DOMAIN "bitcoincore.org" +#define QAPP_APP_NAME_DEFAULT "BitcoinCore-App" +#define QAPP_APP_NAME_TESTNET "BitcoinCore-App-testnet" +#define QAPP_APP_NAME_TESTNET4 "BitcoinCore-App-testnet4" +#define QAPP_APP_NAME_SIGNET "BitcoinCore-App-signet" +#define QAPP_APP_NAME_REGTEST "BitcoinCore-App-regtest" + +// One gigabyte (GB) in bytes. +static constexpr uint64_t GB_BYTES{1000000000}; + +// Default prune target displayed in QML settings (GB). +static constexpr int DEFAULT_PRUNE_TARGET_GB{2}; + +#endif // BITCOIN_QML_GUICONSTANTS_H diff --git a/src/qml/imageprovider.cpp b/src/qml/imageprovider.cpp new file mode 100644 index 0000000000..eafacefb9e --- /dev/null +++ b/src/qml/imageprovider.cpp @@ -0,0 +1,37 @@ +// Copyright (c) 2021 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include + +#include +#include +#include +#include +#include +#include + +ImageProvider::ImageProvider(const NetworkStyle* network_style) + : QQuickImageProvider{QQuickImageProvider::Pixmap}, + m_network_style{network_style} {} + +QPixmap ImageProvider::requestPixmap(const QString& id, QSize* size, const QSize& requested_size) +{ + if (!size || !requested_size.isValid()) { + return {}; + } + + if (id == "app") { + *size = requested_size; + return m_network_style->getAppIcon().pixmap(requested_size); + } + + if (QFile::exists(":/icons/" + id)) { + *size = requested_size; + return QIcon(":/icons/" + id).pixmap(requested_size); + } + + return {}; +} diff --git a/src/qml/imageprovider.h b/src/qml/imageprovider.h new file mode 100644 index 0000000000..ba602d7184 --- /dev/null +++ b/src/qml/imageprovider.h @@ -0,0 +1,29 @@ +// Copyright (c) 2021 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QML_IMAGEPROVIDER_H +#define BITCOIN_QML_IMAGEPROVIDER_H + +#include +#include + +class NetworkStyle; + +QT_BEGIN_NAMESPACE +class QSize; +class QString; +QT_END_NAMESPACE + +class ImageProvider : public QQuickImageProvider +{ +public: + explicit ImageProvider(const NetworkStyle* network_style); + + QPixmap requestPixmap(const QString& id, QSize* size, const QSize& requested_size) override; + +private: + const NetworkStyle* m_network_style; +}; + +#endif // BITCOIN_QML_IMAGEPROVIDER_H diff --git a/src/qml/initexecutor.cpp b/src/qml/initexecutor.cpp new file mode 100644 index 0000000000..09b08137b5 --- /dev/null +++ b/src/qml/initexecutor.cpp @@ -0,0 +1,69 @@ +// Copyright (c) 2014-2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +QmlInitExecutor::QmlInitExecutor(interfaces::Node& node) + : QObject(), m_node(node) +{ + m_context.moveToThread(&m_thread); + m_thread.start(); +} + +QmlInitExecutor::~QmlInitExecutor() +{ + qDebug() << __func__ << ": Stopping thread"; + m_thread.quit(); + m_thread.wait(); + qDebug() << __func__ << ": Stopped thread"; +} + +void QmlInitExecutor::handleRunawayException(const std::exception* e) +{ + PrintExceptionContinue(e, "Runaway exception"); + Q_EMIT runawayException(e ? QString::fromUtf8(e->what()) : tr("Unknown exception")); +} + +void QmlInitExecutor::initialize() +{ + QMetaObject::invokeMethod(&m_context, [this] { + try { + util::ThreadRename("qml-init"); + qDebug() << "Running initialization in thread"; + interfaces::BlockAndHeaderTipInfo tip_info; + bool rv = m_node.appInitMain(&tip_info); + Q_EMIT initializeResult(rv, tip_info); + } catch (const std::exception& e) { + handleRunawayException(&e); + } catch (...) { + handleRunawayException(nullptr); + } + }); +} + +void QmlInitExecutor::shutdown() +{ + QMetaObject::invokeMethod(&m_context, [this] { + try { + qDebug() << "Running shutdown in thread"; + m_node.appShutdown(); + qDebug() << "Shutdown finished"; + Q_EMIT shutdownResult(); + } catch (const std::exception& e) { + handleRunawayException(&e); + } catch (...) { + handleRunawayException(nullptr); + } + }); +} diff --git a/src/qml/initexecutor.h b/src/qml/initexecutor.h new file mode 100644 index 0000000000..4198d9afc5 --- /dev/null +++ b/src/qml/initexecutor.h @@ -0,0 +1,44 @@ +// Copyright (c) 2014-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. + +#ifndef BITCOIN_QML_INITEXECUTOR_H +#define BITCOIN_QML_INITEXECUTOR_H + +#include + +#include + +#include +#include + +QT_BEGIN_NAMESPACE +class QString; +QT_END_NAMESPACE + +/** Runs app initialization and shutdown work off the GUI thread. */ +class QmlInitExecutor : public QObject +{ + Q_OBJECT +public: + explicit QmlInitExecutor(interfaces::Node& node); + ~QmlInitExecutor(); + +public Q_SLOTS: + void initialize(); + void shutdown(); + +Q_SIGNALS: + void initializeResult(bool success, interfaces::BlockAndHeaderTipInfo tip_info); + void shutdownResult(); + void runawayException(const QString& message); + +private: + void handleRunawayException(const std::exception* e); + + interfaces::Node& m_node; + QObject m_context; + QThread m_thread; +}; + +#endif // BITCOIN_QML_INITEXECUTOR_H diff --git a/src/qml/locale/bitcoin_qml.ts b/src/qml/locale/bitcoin_qml.ts new file mode 100644 index 0000000000..57a9474dc1 --- /dev/null +++ b/src/qml/locale/bitcoin_qml.ts @@ -0,0 +1,2754 @@ + + + + + AboutOptions + + + Website + + + + + Source code + + + + + License + + + + + Version + + + + + Developer options + + + + + Only use these if you have development experience + + + + + Activity + + + Activity + + + + + ActivityDetails + + + Back + + + + + Transaction + + + + + %1 confirmations + + + + + Label + + + + + Message + + + + + Address + + + + + This transaction is still unconfirmed. You can speed it up by increasing the fee. + + + + + Speed up + + + + + This transaction was updated with a faster one + + + + + You increased the fee while this transaction was still unconfirmed. Only the new one will confirm on-chain. + + + + + View updated transaction + + + + + AddWalletButton + + + Add Wallet + + + + + BannedPeers + + + Back + + + + + Banned peers + + + + + You banned these peers from connecting to your node. + + + + + Until %1 + + + + + Unban + + + + + No banned peers. + + + + + BitcoinAddressDisplayField + + + Send to + + + + + BitcoinAddressInputField + + + Send to + + + + + Enter address... + + + + + BitcoinAmountDisplayField + + + Amount + + + + + BitcoinAmountInputField + + + Amount + + + + + BlockClock + + + Connecting + + + + + Please wait + + + + + BlockClockDisplayMode + + + Compact + + + + + For personal use on a computer or smartphone. + + + + + Showcase + + + + + A larger block clock for public display on a tablet or other large screen. + + + + + BumpTransactionModel + + + No wallet available. + + + + + Invalid transaction ID. + + + + + Failed to create bump transaction. + + + + + Transaction can no longer be bumped. + + + + + Failed to sign transaction. + + + + + Failed to commit transaction. + + + + + CoinSelection + + + Coin Selection + + + + + Done + + + + + Total selected + + + + + Over required amount + + + + + Remaining to select + + + + + ConnectionOptions + + + Fast always on + + + + + Loads quickly at all times and uses as much cellular data as needed. + + + + + Slow always on + + + + + Loads at all times with reduced cellular data usage. + + + + + Only when on Wi-Fi + + + + + Loads quickly when on wi-fi and pauses when on cellular data. + + + + + ConnectionSettings + + + Enable listening + + + + + Allows incoming connections + + + + + Map port using NAT-PMP + + + + + Enable RPC server + + + + + Proxy settings + + + + + CreateBackup + + + Back up your wallet + + + + + Your wallet is a file stored on your hard disk. +To prevent accidental loss, it is recommended you keep a copy of your wallet file in a secure place, like a dedicated USB Drive. + + + + + View file + + + + + Done + + + + + CreateConfirm + + + Your wallet has been created + + + + + It is good practice to make a small test transaction before you actively use this wallet for larger amounts. + + + + + Next + + + + + CreateExternalWallet + + + Back + + + + + Create an external wallet + + + + + Connected signer: %1 + + + + + Connect one external signer to continue. + + + + + This wallet stores public descriptors and uses your external signer device for signing. + + + + + Eg. hardware_wallet + + + + + Creating wallet with the connected signer... + + + + + Creating wallet... + + + + + Create wallet + + + + + CreateIntro + + + You are about to create +a single-key bitcoin wallet + + + + + You can fully control it in this application. + + + + + Wallet data will be stored locally on your hard drive. + + + + + You can optionally protect it with a password. + + + + + Start + + + + + CreateName + + + Choose a wallet name + + + + + This wallet will use the connected external signer. + + + + + Eg. Hardware wallet... + + + + + Eg. My bitcoin wallet... + + + + + Continue + + + + + Create wallet + + + + + CreatePassword + + + Skip + + + + + + Choose a password + + + + + It is recommended to set a password to protect your wallet file from unwanted access from others. + + + + + Enter password... + + + + + Confirm password + + + + + Enter password again... + + + + + I understand that if I lose or forget this password I might lose access to the bitcoin stored in this wallet. + + + + + Continue + + + + + CreateWalletWizard + + + Back + + + + + Cancel + + + + + Skip + + + + + Add a wallet + + + + + Supported wallet types are external signer, view-only, single-key, +and multi-key. + + + + + Supported wallet types are view-only, single-key, +and multi-key. + + + + + Create wallet + + + + + Import wallet + + + + + Create external wallet + + + + + Create hardware wallet + + + + + No external signer is currently detected. Open Wallet settings to verify the signer path and rescan. + + + + + Your external wallet has been created + + + + + This wallet uses the connected external signer for addresses and signing. + + + + + Done + + + + + DebugLogModel + + + Debug log file not found: %1 + + + + + Could not open debug log file. No application is associated with this file type. + + + + + DesktopWallets + + + Activity + + + + + Send + + + + + Receive + + + + + Paused + + + + + Connecting + + + + + DeveloperOptions + + + Database cache size (MiB) + + + + + This is not a valid cache size. Please choose a value between %1 and %2 MiB. + + + + + Script verification threads + + + + + This is not a valid thread count. Please choose a value between %1 and %2 threads. + + + + + Dark Mode + + + + + ExternalPopup + + + External Link + + + + + Do you want to open the following website in your browser? + + + + + Cancel + + + + + Ok + + + + + ExternalSignerReviewActions + + + Signed on external signer. Ready to send. + + + + + Waiting for approval on external signer. + + + + + Approve on external signer to broadcast this transaction. + + + + + Send + + + + + Waiting for approval... + + + + + Retry external signer + + + + + Approve on external signer + + + + + FeeSelection + + + + sats/vbyte + + + + + + Fee + + + + + Fee Rate + + + + + Include fee in amount + + + + + High + + + + + (~10 mins) + + + + + Default + + + + + (~20 mins) + + + + + (~60 mins) + + + + + Low + + + + + Custom + + + + + ImportWalletOptions + + + Back + + + + + Done + + + + + Wallet backup files (*.bak *.dat) + + + + + All files (*) + + + + + Import wallet + + + + + Importing... + + + + + Choose a wallet file + + + + + Choose another file + + + + + ImportWalletSuccess + + + Back + + + + + Import complete + + + + + Your wallet was added successfully. We recommend running a health check to make sure that everything works as expected. + + + + + Wallet name + + + + + + Unknown + + + + + Key scheme + + + + + Go to wallet overview + + + + + View in settings + + + + + LabeledCoinControlButton + + + Inputs + + + + + No coins available + + + + + Select + + + + + %1 input%2 selected + + + + + MultipleSendReview + + + Back + + + + + Total amount + + + + + Fee + + + + + There is 1 recipient. + + + + + There are %1 recipients. + + + + + Edit + + + + + Review transaction + + + + + Send + + + + + NavigationBar2 + + + Back + + + + + NetworkIndicator + + + Testnet + + + + + Test Network + + + + + Signet + + + + + Signet Network + + + + + Regtest + + + + + Regtest Mode + + + + + NetworkTraffic + + + Back + + + + + Network traffic + + + + + Network Traffic + + + + + How much data you have sent to and received from your peers. + + + + + 5 min + + + + + 1 hour + + + + + 12 hours + + + + + 1 day + + + + + Received: %1 + + + + + Sent: %1 + + + + + NodeSettings + + + Settings + + + + + Done + + + + + About + + + + + Display + + + + + Storage + + + + + External Signer + + + + + Connection + + + + + Peers + + + + + Network Traffic + + + + + Debug Log + + + + + OnboardingBlockclock + + + Back + + + + + The block clock + + + + + The Bitcoin network targets a new block every 10 minutes. Sometimes it's faster and sometimes slower. + +The block clock indicates each block on a dial that represents the current day. + + + + + Next + + + + + OnboardingConnection + + + Back + + + + + Starting initial download + + + + + The application will connect to the Bitcoin network and start downloading and verifying transactions. + +This may take several hours, or even days, based on your connection. + + + + + Connection settings + + + + + Next + + + + + OnboardingCover + + + Bitcoin Core App + + + + + Be part of the Bitcoin network. + + + + + 100% open-source & open-design + + + + + Start + + + + + OnboardingStorageAmount + + + Back + + + + + Storage + + + + + Data retrieved from the Bitcoin network is stored on your device. +You have 500GB of storage available. + + + + + Detailed settings + + + + + Next + + + + + OnboardingStorageLocation + + + Back + + + + + Storage location + + + + + Where do you want to store the downloaded block data? +You need a minimum of %1GB of storage. + + + + + Next + + + + + OnboardingStrengthen + + + Back + + + + + Strengthen bitcoin + + + + + Bitcoin Core runs a full Bitcoin node which verifies the rules of the network are being followed. + +Users running nodes is what makes bitcoin so resilient and trustworthy. + + + + + Next + + + + + OptionButton + + + Recommended + + + + + OptionsQmlModel + + + The configured signer path does not exist. + + + + + The configured signer path is not a file. + + + + + The configured signer path is not executable. + + + + + PeerDetails + + + Back + + + + + Peer %1 + + + + + Information + + + + + Address + + + + + VIA + + + + + Type + + + + + Permissions + + + + + Version + + + + + User agent + + + + + Services + + + + + Transaction relay + + + + + Address relay + + + + + Mapped AS + + + + + Block data + + + + + Starting block + + + + + Synced headers + + + + + Synced blocks + + + + + Network traffic + + + + + Direction + + + + + Connection time + + + + + Last send + + + + + + ago + + + + + Last receive + + + + + Sent + + + + + + total + + + + + Received + + + + + Ping time + + + + + Ping wait + + + + + Min ping + + + + + Time offset + + + + + Disconnect + + + + + + Ban + + + + + 1 hour + + + + + 1 day + + + + + 1 week + + + + + 1 year + + + + + Ban this peer + + + + + Cancel + + + + + PeerDetailsModel + + + + N/A + + + + + PeerListModel + + + Inbound + + + + + Outbound + + + + + Peers + + + Back + + + + + Peers + + + + + Peers are nodes you exchange data with. + + + + + ID + + + + + Direction + + + + + User Agent + + + + + Type + + + + + Ip + + + + + Network + + + + + Looking for %1 more peer(s) + + + + + View %1 banned %2 + + + + + peer + + + + + peers + + + + + ProxySettings + + + IP and Port + + + + + Invalid IP address or port format. Use '255.255.255.255:65535' or '[ffff::]:65535' + + + + + Default Proxy + + + + + Run peer connections through a proxy (SOCKS5) for improved privacy. The default proxy supports connections via IPv4, IPv6 and Tor. + + + + + + Enable + + + + + Tor Proxy + + + + + Run Tor connections through a dedicated proxy. + + + + + QObject + + + System default + + + + + Inbound + + + + + Outbound + + + + + Full Relay + + + + + Block Relay + + + + + Manual + + + + + Feeler + + + + + Address Fetch + + + + + Unroutable + + + + + IPv4 + network name + + + + + IPv6 + network name + + + + + Onion + network name + + + + + I2P + network name + + + + + CJDNS + network name + + + + + + %1 d + + + + + + %1 h + + + + + + %1 m + + + + + + + %1 s + + + + + None + + + + + N/A + + + + + %1 ms + + + + + %1 B + + + + + %1 kB + + + + + %1 MB + + + + + %1 GB + + + + + Could not open debug log file: %1 + + + + + Debug log file not found: %1 + + + + + just now + + + + + %1 min ago + + + + + %1 hr ago + + + + + %1 d ago + + + + + RequestPayment + + + Request a payment + + + + + Amount + + + + + Label + + + + + Enter label... + + + + + Message + + + + + Enter message... + + + + + Address + + + + + copy + + + + + Create bitcoin address + + + + + Copy payment request + + + + + Payment request # + + + + + Clear + + + + + Send + + + Send bitcoin + + + + + Make sure you have your external signer at hand to approve this transaction. + + + + + Recipient %1 of %2 + + + + + Amount + + + + + Note to self + + + + + Enter ... + + + + + Fee is included in the amount + + + + + Review + + + + + Review transaction + + + + + Amount plus fee exceeds available balance + + + + + SendOptionsPopup + + + Enable Coin control + + + + + Multiple Recipients + + + + + SendRecipient + + + Address is valid for mainnet, not the current network + + + + + Address is valid for testnet, not the current network + + + + + Invalid address format + + + + + Amount must be greater than zero + + + + + Amount exceeds maximum limit of 21,000,000 BTC + + + + + Amount exceeds available balance + + + + + SendResult + + + Transaction updated + + + + + Transaction sent + + + + + Based on your selected fee, it should be confirmed within the next 10 minutes. + + + + + View new transaction + + + + + View transaction + + + + + Done + + + + + SendReview + + + Back + + + + + Edit + + + + + Review transaction + + + + + Note + + + + + Amount + + + + + Fee + + + + + Total amount + + + + + Send + + + + + SettingsAbout + + + + About + + + + + Bitcoin Core is an open source project. +If you find it useful, please contribute. + + This is experimental software. + + + + + Back + + + + + SettingsBlockClockDisplayMode + + + Back + + + + + Block clock display mode + + + + + SettingsConnection + + + + Connection settings + + + + + Back + + + + + Done + + + + + SettingsDebugLog + + + Back + + + + + Search... + + + + + Search debug log + + + + + 1 new entry + + + + + %1 new entries + + + + + Debug log entries + + + + + Load more + + + + + SettingsDesignSystem + + + Back + + + + + Design system + + + + + Typography + + + + + Colors + + + + + Palette tokens for the active theme. Toggle Theme to compare. + + + + + SettingsDeveloper + + + Back + + + + + Developer options + + + + + Developer settings + + + + + SettingsDisplay + + + Back + + + + + Display + + + + + Theme + + + + + Block status size + + + + + System default + + + + + Display unit + + + + + Language + + + + + SettingsDisplayUnit + + + Back + + + + + Display unit + + + + + BTC + + + + + 8 decimal places (0.00000001 BTC = 1 sat) + + + + + sat + + + + + Satoshi, the smallest unit (1 sat = 0.00000001 BTC) + + + + + SettingsLanguage + + + Back + + + + + Choose language + + + + + Search... + + + + + SettingsProxy + + + Back + + + + + Proxy Settings + + + + + Restart the application for these changes to take effect. + + + + + SettingsStorage + + + Storage settings + + + + + Back + + + + + Storage Settings + + + + + Done + + + + + SettingsTheme + + + Back + + + + + Theme + + + + + SettingsWallet + + + Back + + + + + External Signer + + + + + Shutdown + + + Saving... + + + + + Do not shut down the computer until this is done. + + + + + SpeedUpOverlay + + + Speed up transaction + + + + + Set a higher transaction fee if you want your transaction to be confirmed faster. + + + + + Original fee + + + + + New fee + + + + + Cancel + + + + + Update transaction + + + + + StorageLocations + + + Default + + + + + Your application directory. + + + + + Custom + + + + + Choose the directory and storage device. + + + + + StorageOptions + + + Reduce storage + + + + + Uses about %1GB. For simple wallet use. + + + + + Store all data + + + + + Uses about %1GB. Support the network. + + + + + Custom + + + + + Storing about %1GB of data. + + + + + StorageSettings + + + Store recent blocks only + + + + + Block Storage limit (GB) + + + + + This is not a valid prune target. Please choose a value that is equal to or larger than 1GB + + + + + Data Directory + + + + + ThemeSettings + + + Light + + + + + Dark + + + + + Developer + + + + + Design system + + + + + WalletBadge + + + Add Wallet + + + + + Select Wallet + + + + + sat + + + + + sats + + + + + WalletQmlController + + + + Wallet creation failed. + + + + + Choose a wallet name. + + + + + Set an external signer path in Wallet settings first. + + + + + Connect an external signer and try again. + + + + + More than one external signer was found. Connect only one device and try again. + + + + + More than one external signer was found. Connect only one device. + + + + + The signer command did not return valid output. Check that the path is correct. + + + + + This wallet needs to be migrated + + + + + We couldn't find that file + + + + + A wallet with this name already exists + + + + + This wallet type is not supported + + + + + This wallet couldn't be imported + + + + + The selected backup appears to come from a legacy wallet format. It needs to be migrated before it can be used here. + + + + + The selected file is no longer available at that location. Choose the backup file again and retry. + + + + + Importing this backup would create a wallet that already exists in your wallet directory. Remove or rename the existing wallet first, then try again. + + + + + The file looks like a wallet backup, but this application could not verify or open it in a supported format. + + + + + The selected file could not be restored as a wallet. Check that it is a valid wallet backup, then try another file. + + + + + If this is a legacy wallet backup, migrate it with the wallet migration tool before importing it here. + + + + + Choose a wallet backup file. + + + + + The selected wallet path does not exist. + + + + + Wallet import failed. + + + + + Choose a wallet to open. + + + + + + The selected wallet is not available in the wallet directory. + + + + + Wallet could not be opened. + + + + + Choose a wallet to update. + + + + + Wallet update failed. + + + + + + Single-key + + + + + External signer + + + + + Watch-only + + + + + Multi-key + + + + + WalletQmlModel + + + External signer not available. + + + + + + Couldn't prepare transaction for external signing. + + + + + External signer not found. Connect one device and try again. + + + + + External signer failed to sign. Try again. + + + + + WalletSelect + + + Wallets + + + + + WalletSettings + + + Signer path + + + + + Enter external signer path + + + + + The add wallet flow can offer external wallets when exactly one supported signer is connected. + + + + + Detected external signer: %1 + + + + + Path updated. Press Check device to rescan with the current signer command. + + + + + No external signer is currently detected. + + + + + Set the command path for HWI or another external signer tool. + + + + + Check device + + + + + initerrormessage + + + There was an issue starting up. + + + + + Close + + + + + main + + + Bitcoin Core App + + + + + + Approved on external signer. It should be confirmed within the next 10 minutes. + + + + + + Based on your selected fee, it should be confirmed within the next 10 minutes. + + + + + + Done + + + + + + Close window + + + + diff --git a/src/qml/locale/bitcoin_qml_es.ts b/src/qml/locale/bitcoin_qml_es.ts new file mode 100644 index 0000000000..d973bf2b50 --- /dev/null +++ b/src/qml/locale/bitcoin_qml_es.ts @@ -0,0 +1,2769 @@ + + + + + AboutOptions + + + Website + Sitio web + + + + Source code + Código fuente + + + + License + Licencia + + + + Version + Versión + + + + Developer options + Opciones de desarrollador + + + + Only use these if you have development experience + Úsalas solo si tienes experiencia en desarrollo + + + + Activity + + + Activity + Actividad + + + + ActivityDetails + + + Back + Atrás + + + + Transaction + Transacción + + + + %1 confirmations + %1 confirmaciones + + + + Label + Etiqueta + + + + Message + Mensaje + + + + Address + Dirección + + + + This transaction is still unconfirmed. You can speed it up by increasing the fee. + Esta transacción aún no está confirmada. Puedes acelerarla aumentando la comisión. + + + + Speed up + Acelerar + + + + This transaction was updated with a faster one + Esta transacción fue reemplazada por una más rápida + + + + You increased the fee while this transaction was still unconfirmed. Only the new one will confirm on-chain. + Aumentaste la comisión mientras esta transacción aún no estaba confirmada. Solo la nueva se confirmará en la cadena. + + + + View updated transaction + Ver transacción actualizada + + + + AddWalletButton + + + Add Wallet + Añadir cartera + + + + BannedPeers + + + Back + Atrás + + + + Banned peers + Pares bloqueados + + + + You banned these peers from connecting to your node. + Has bloqueado estos pares para que no se conecten a tu nodo. + + + + Until %1 + Hasta %1 + + + + Unban + Desbloquear + + + + No banned peers. + No hay pares bloqueados. + + + + BitcoinAddressDisplayField + + + Send to + Enviar a + + + + BitcoinAddressInputField + + + Send to + Enviar a + + + + Enter address... + Introducir dirección... + + + + BitcoinAmountDisplayField + + + Amount + Importe + + + + BitcoinAmountInputField + + + Amount + Importe + + + + BlockClock + + + Connecting + Conectando + + + + Please wait + Por favor espere + + + + BlockClockDisplayMode + + + Compact + Compacto + + + + For personal use on a computer or smartphone. + Para uso personal en ordenador o teléfono inteligente. + + + + Showcase + Presentación + + + + A larger block clock for public display on a tablet or other large screen. + Un reloj de bloques más grande para visualización pública en tableta u otra pantalla grande. + + + + BumpTransactionModel + + + No wallet available. + No hay billetera disponible. + + + + Invalid transaction ID. + ID de transacción no válido. + + + + Failed to create bump transaction. + No se pudo crear la transacción de aumento de comisión. + + + + Transaction can no longer be bumped. + La transacción ya no se puede acelerar. + + + + Failed to sign transaction. + No se pudo firmar la transacción. + + + + Failed to commit transaction. + No se pudo confirmar la transacción. + + + + CoinSelection + + + Coin Selection + Selección de monedas + + + + Done + Listo + + + + Total selected + Total seleccionado + + + + Over required amount + Por encima del importe requerido + + + + Remaining to select + Pendiente de seleccionar + + + + ConnectionOptions + + + Fast always on + Rápido, siempre activo + + + + Loads quickly at all times and uses as much cellular data as needed. + Se carga rápidamente siempre y usa los datos móviles necesarios. + + + + Slow always on + Lento, siempre activo + + + + Loads at all times with reduced cellular data usage. + Se carga siempre con uso reducido de datos móviles. + + + + Only when on Wi-Fi + Solo con Wi-Fi + + + + Loads quickly when on wi-fi and pauses when on cellular data. + Se carga rápidamente con Wi-Fi y se pausa con datos móviles. + + + + ConnectionSettings + + + Enable listening + Activar escucha + + + + Allows incoming connections + Permite conexiones entrantes + + + + Map port using NAT-PMP + Mapear puerto usando NAT-PMP + + + + Enable RPC server + Activar servidor RPC + + + + Proxy settings + Ajustes de proxy + + + + CreateBackup + + + Back up your wallet + Haz una copia de seguridad de tu cartera + + + + Your wallet is a file stored on your hard disk. +To prevent accidental loss, it is recommended you keep a copy of your wallet file in a secure place, like a dedicated USB Drive. + Tu cartera es un archivo almacenado en tu disco duro. +Para evitar pérdidas accidentales, se recomienda guardar una copia en un lugar seguro, como una memoria USB dedicada. + + + + View file + Ver archivo + + + + Done + Listo + + + + CreateConfirm + + + Your wallet has been created + Tu cartera ha sido creada + + + + It is good practice to make a small test transaction before you actively use this wallet for larger amounts. + Es buena práctica realizar una pequeña transacción de prueba antes de usar esta cartera para importes mayores. + + + + Next + Siguiente + + + + CreateExternalWallet + + + Back + Atrás + + + + Create an external wallet + Crear una billetera externa + + + + Connected signer: %1 + Firmante conectado: %1 + + + + Connect one external signer to continue. + Conecta un firmante externo para continuar. + + + + This wallet stores public descriptors and uses your external signer device for signing. + Esta billetera almacena descriptores públicos y usa tu dispositivo firmante externo para firmar. + + + + Eg. hardware_wallet + Ej. billetera_hardware + + + + Creating wallet with the connected signer... + Creando billetera con el firmante conectado... + + + + Creating wallet... + Creando billetera... + + + + Create wallet + Crear cartera + + + + CreateIntro + + + You are about to create +a single-key bitcoin wallet + Estás a punto de crear +una cartera Bitcoin de clave única + + + + You can fully control it in this application. + Puedes controlarlo completamente en esta aplicación. + + + + Wallet data will be stored locally on your hard drive. + Los datos de la cartera se almacenarán localmente en tu disco duro. + + + + You can optionally protect it with a password. + Opcionalmente puedes protegerlo con una contraseña. + + + + Start + Iniciar + + + + CreateName + + + Choose a wallet name + Elige un nombre para la cartera + + + + This wallet will use the connected external signer. + Esta billetera usará el firmante externo conectado. + + + + Eg. Hardware wallet... + Ej. Billetera hardware... + + + + Eg. My bitcoin wallet... + Ej. Mi cartera Bitcoin... + + + + Continue + Continuar + + + + Create wallet + Crear cartera + + + + CreatePassword + + + Skip + Omitir + + + + + Choose a password + Elige una contraseña + + + + It is recommended to set a password to protect your wallet file from unwanted access from others. + Se recomienda establecer una contraseña para proteger tu archivo de cartera del acceso no deseado. + + + + Enter password... + Introducir contraseña... + + + + Confirm password + Confirmar contraseña + + + + Enter password again... + Introducir contraseña de nuevo... + + + + I understand that if I lose or forget this password I might lose access to the bitcoin stored in this wallet. + Entiendo que si pierdo u olvido esta contraseña podría perder el acceso al bitcoin de esta cartera. + + + + Continue + Continuar + + + + CreateWalletWizard + + + Back + Atrás + + + + Cancel + Cancelar + + + + Skip + Omitir + + + + Add a wallet + Añadir una cartera + + + + Supported wallet types are external signer, view-only, single-key, +and multi-key. + Los tipos de billetera compatibles son firmante externo, solo lectura, clave única +y multiclave. + + + + Supported wallet types are view-only, single-key, +and multi-key. + Los tipos de billetera compatibles son solo lectura, clave única +y multiclave. + + + + Create external wallet + Crear billetera externa + + + + Create hardware wallet + Crear billetera hardware + + + + No external signer is currently detected. Open Wallet settings to verify the signer path and rescan. + No se detecta ningún firmante externo. Abre los ajustes de billetera para verificar la ruta del firmante y volver a escanear. + + + + Your external wallet has been created + Tu billetera externa ha sido creada + + + + This wallet uses the connected external signer for addresses and signing. + Esta billetera usa el firmante externo conectado para direcciones y firmas. + + + + Done + Listo + + + + Create wallet + Crear cartera + + + + Import wallet + Importar cartera + + + + DebugLogModel + + + Debug log file not found: %1 + Archivo de registro de depuración no encontrado: %1 + + + + Could not open debug log file. No application is associated with this file type. + No se pudo abrir el archivo de registro de depuración. Ninguna aplicación está asociada con este tipo de archivo. + + + + DesktopWallets + + + Activity + Actividad + + + + Send + Enviar + + + + Receive + Recibir + + + + Paused + En pausa + + + + Connecting + Conectando + + + + DeveloperOptions + + + Database cache size (MiB) + Tamaño de caché de base de datos (MiB) + + + + This is not a valid cache size. Please choose a value between %1 and %2 MiB. + No es un tamaño de caché válido. Elige un valor entre %1 y %2 MiB. + + + + Script verification threads + Hilos de verificación de scripts + + + + This is not a valid thread count. Please choose a value between %1 and %2 threads. + No es un número de hilos válido. Elige un valor entre %1 y %2 hilos. + + + + Dark Mode + Modo oscuro + + + + ExternalPopup + + + External Link + Enlace externo + + + + Do you want to open the following website in your browser? + ¿Quieres abrir el siguiente sitio web en tu navegador? + + + + Cancel + Cancelar + + + + Ok + Aceptar + + + + ExternalSignerReviewActions + + + Signed on external signer. Ready to send. + Firmado en el firmante externo. Listo para enviar. + + + + Waiting for approval on external signer. + Esperando aprobación en el firmante externo. + + + + Approve on external signer to broadcast this transaction. + Aprueba en el firmante externo para transmitir esta transacción. + + + + Send + Enviar + + + + Waiting for approval... + Esperando aprobación... + + + + Retry external signer + Reintentar firmante externo + + + + Approve on external signer + Aprobar en el firmante externo + + + + FeeSelection + + + + sats/vbyte + sats/vbyte + + + + + Fee + Tarifa + + + + Fee Rate + Tasa de comisión + + + + Include fee in amount + Incluir comisión en el monto + + + + High + Alto + + + + (~10 mins) + (~10 min) + + + + Default + Predeterminado + + + + (~20 mins) + (~20 min) + + + + (~60 mins) + (~60 min) + + + + Low + Bajo + + + + Custom + Personalizado + + + + ImportWalletOptions + + + Back + Atrás + + + + Done + Listo + + + + Wallet backup files (*.bak *.dat) + Archivos de respaldo de billetera (*.bak *.dat) + + + + All files (*) + Todos los archivos (*) + + + + Import wallet + Importar cartera + + + + Importing... + Importando... + + + + Choose a wallet file + Elige un archivo de billetera + + + + Choose another file + Elige otro archivo + + + + ImportWalletSuccess + + + Back + Atrás + + + + Import complete + Importación completada + + + + Your wallet was added successfully. We recommend running a health check to make sure that everything works as expected. + Tu billetera se añadió correctamente. Recomendamos ejecutar una verificación de estado para asegurar que todo funciona como se espera. + + + + Wallet name + Nombre de billetera + + + + + Unknown + Desconocido + + + + Key scheme + Esquema de claves + + + + Go to wallet overview + Ir al resumen de la billetera + + + + View in settings + Ver en ajustes + + + + LabeledCoinControlButton + + + Inputs + Entradas + + + + No coins available + No hay monedas disponibles + + + + Select + Seleccionar + + + + %1 input%2 selected + %1 entrada%2 seleccionada + + + + MultipleSendReview + + + Back + Atrás + + + + Total amount + Importe total + + + + Fee + Tarifa + + + + There is 1 recipient. + Hay 1 destinatario. + + + + There are %1 recipients. + Hay %1 destinatarios. + + + + Edit + Editar + + + + Review transaction + Revisar transacción + + + + Send + Enviar + + + + NavigationBar2 + + + Back + Atrás + + + + NetworkIndicator + + + Testnet + Testnet + + + + Test Network + Red de pruebas + + + + Signet + Signet + + + + Signet Network + Red Signet + + + + Regtest + Regtest + + + + Regtest Mode + Modo Regtest + + + + NetworkTraffic + + + Back + Atrás + + + + Network traffic + Tráfico de red + + + + Network Traffic + Tráfico de red + + + + How much data you have sent to and received from your peers. + Cuántos datos has enviado a y recibido de tus pares. + + + + 5 min + 5 min + + + + 1 hour + 1 hora + + + + 12 hours + 12 horas + + + + 1 day + 1 día + + + + Received: %1 + Recibido: %1 + + + + Sent: %1 + Enviado: %1 + + + + NodeSettings + + + Settings + Configuración + + + + Done + Listo + + + + About + Acerca de + + + + Display + Pantalla + + + + Storage + Almacenamiento + + + + External Signer + Firmante externo + + + + Connection + Conexión + + + + Peers + Pares + + + + Network Traffic + Tráfico de red + + + + Debug Log + Registro de depuración + + + + OnboardingBlockclock + + + Back + Atrás + + + + The block clock + El reloj de bloques + + + + The Bitcoin network targets a new block every 10 minutes. Sometimes it's faster and sometimes slower. + +The block clock indicates each block on a dial that represents the current day. + La red Bitcoin apunta a un nuevo bloque cada 10 minutos. A veces es más rápido y a veces más lento. + +El reloj de bloques indica cada bloque en un dial que representa el día actual. + + + + Next + Siguiente + + + + OnboardingConnection + + + Back + Atrás + + + + Starting initial download + Iniciando descarga inicial + + + + The application will connect to the Bitcoin network and start downloading and verifying transactions. + +This may take several hours, or even days, based on your connection. + La aplicación se conectará a la red Bitcoin y comenzará a descargar y verificar transacciones. + +Esto puede llevar varias horas, o incluso días, según tu conexión. + + + + Connection settings + Ajustes de conexión + + + + Next + Siguiente + + + + OnboardingCover + + + Bitcoin Core App + Aplicación Bitcoin Core + + + + Be part of the Bitcoin network. + Forma parte de la red Bitcoin. + + + + 100% open-source & open-design + 100% código abierto y diseño abierto + + + + Start + Iniciar + + + + OnboardingStorageAmount + + + Back + Atrás + + + + Storage + Almacenamiento + + + + Data retrieved from the Bitcoin network is stored on your device. +You have 500GB of storage available. + Los datos de la red Bitcoin se almacenan en tu dispositivo. +Tienes 500 GB de almacenamiento disponible. + + + + Detailed settings + Ajustes detallados + + + + Next + Siguiente + + + + OnboardingStorageLocation + + + Back + Atrás + + + + Storage location + Ubicación de almacenamiento + + + + Where do you want to store the downloaded block data? +You need a minimum of %1GB of storage. + ¿Dónde quieres almacenar los datos de bloques descargados? +Necesitas un mínimo de %1 GB de almacenamiento. + + + + Next + Siguiente + + + + OnboardingStrengthen + + + Back + Atrás + + + + Strengthen bitcoin + Fortalecer bitcoin + + + + Bitcoin Core runs a full Bitcoin node which verifies the rules of the network are being followed. + +Users running nodes is what makes bitcoin so resilient and trustworthy. + Bitcoin Core ejecuta un nodo Bitcoin completo que verifica que se siguen las reglas de la red. + +Que los usuarios ejecuten nodos es lo que hace que bitcoin sea tan resistente y fiable. + + + + Next + Siguiente + + + + OptionButton + + + Recommended + Recomendado + + + + OptionsQmlModel + + + The configured signer path does not exist. + La ruta configurada del firmante no existe. + + + + The configured signer path is not a file. + La ruta configurada del firmante no es un archivo. + + + + The configured signer path is not executable. + La ruta configurada del firmante no es ejecutable. + + + + PeerDetails + + + Back + Atrás + + + + Peer %1 + Par %1 + + + + Information + Información + + + + Address + Dirección + + + + VIA + VÍA + + + + Type + Tipo + + + + Permissions + Permisos + + + + Version + Versión + + + + User agent + Agente de usuario + + + + Services + Servicios + + + + Transaction relay + Retransmisión de transacciones + + + + Address relay + Retransmisión de direcciones + + + + Mapped AS + Sistema autónomo asignado + + + + Block data + Datos de bloques + + + + Starting block + Bloque inicial + + + + Synced headers + Cabeceras sincronizadas + + + + Synced blocks + Bloques sincronizados + + + + Network traffic + Tráfico de red + + + + Direction + Sentido + + + + Connection time + Tiempo de conexión + + + + Last send + Último envío + + + + + ago + atrás + + + + Last receive + Última recepción + + + + Sent + Enviado + + + + + total + total + + + + Received + Recibido + + + + Ping time + Tiempo de ping + + + + Ping wait + Espera de ping + + + + Min ping + Ping mínimo + + + + Time offset + Desfase de tiempo + + + + Disconnect + Desconectar + + + + + Ban + Bloquear + + + + 1 hour + 1 hora + + + + 1 day + 1 día + + + + 1 week + 1 semana + + + + 1 year + 1 año + + + + Ban this peer + Bloquear este par + + + + Cancel + Cancelar + + + + PeerDetailsModel + + + + N/A + N/D + + + + PeerListModel + + + Inbound + Entrante + + + + Outbound + Saliente + + + + Peers + + + Back + Atrás + + + + Peers + Pares + + + + Peers are nodes you exchange data with. + Los pares son nodos con los que intercambias datos. + + + + ID + ID + + + + Direction + Sentido + + + + User Agent + Agente de usuario + + + + Type + Tipo + + + + Ip + Ip + + + + Network + Red + + + + Looking for %1 more peer(s) + Buscando %1 par(es) más + + + + View %1 banned %2 + Ver %1 bloqueado(s) %2 + + + + peer + par + + + + peers + pares + + + + ProxySettings + + + IP and Port + IP y Puerto + + + + Invalid IP address or port format. Use '255.255.255.255:65535' or '[ffff::]:65535' + Formato de dirección IP o puerto no válido. Use '255.255.255.255:65535' o '[ffff::]:65535' + + + + Default Proxy + Proxy predeterminado + + + + Run peer connections through a proxy (SOCKS5) for improved privacy. The default proxy supports connections via IPv4, IPv6 and Tor. + Ejecutar conexiones con pares mediante un proxy (SOCKS5) para mayor privacidad. El proxy predeterminado admite conexiones IPv4, IPv6 y Tor. + + + + + Enable + Activar + + + + Tor Proxy + Proxy Tor + + + + Run Tor connections through a dedicated proxy. + Ejecutar conexiones Tor a través de un proxy dedicado. + + + + QObject + + + System default + Predeterminado del sistema + + + + Inbound + Entrante + + + + Outbound + Saliente + + + + Full Relay + Retransmisión completa + + + + Block Relay + Retransmisión de bloques + + + + Manual + Manual + + + + Feeler + Sensor + + + + Address Fetch + Recuperación de direcciones + + + + Unroutable + No enrutable + + + + IPv4 + network name + IPv4 + + + + IPv6 + network name + IPv6 + + + + Onion + network name + Onion + + + + I2P + network name + I2P + + + + CJDNS + network name + CJDNS + + + + + %1 d + %1 d + + + + + %1 h + %1 h + + + + + %1 m + %1 m + + + + + + %1 s + %1 s + + + + None + Ninguno + + + + N/A + N/D + + + + %1 ms + %1 ms + + + + %1 B + %1 B + + + + %1 kB + %1 kB + + + + %1 MB + %1 MB + + + + %1 GB + %1 GB + + + + Could not open debug log file: %1 + No se pudo abrir el archivo de registro de depuración: %1 + + + + Debug log file not found: %1 + Archivo de registro de depuración no encontrado: %1 + + + + just now + ahora mismo + + + + %1 min ago + hace %1 min + + + + %1 hr ago + hace %1 h + + + + %1 d ago + hace %1 d + + + + RequestPayment + + + Request a payment + Solicitar un pago + + + + Amount + Importe + + + + Label + Etiqueta + + + + Enter label... + Introducir etiqueta... + + + + Message + Mensaje + + + + Enter message... + Introducir mensaje... + + + + Address + Dirección + + + + copy + copiar + + + + Create bitcoin address + Crear dirección Bitcoin + + + + Copy payment request + Copiar solicitud de pago + + + + Payment request # + Solicitud de pago # + + + + Clear + Borrar + + + + Send + + + Send bitcoin + Enviar bitcoin + + + + Make sure you have your external signer at hand to approve this transaction. + Asegúrate de tener tu firmante externo a mano para aprobar esta transacción. + + + + Recipient %1 of %2 + Destinatario %1 de %2 + + + + Amount + Importe + + + + Note to self + Nota personal + + + + Enter ... + Introducir... + + + + Fee is included in the amount + La comisión está incluida en el monto + + + + Review + Revisar + + + + Review transaction + Revisar transacción + + + + Amount plus fee exceeds available balance + El monto más la comisión excede el saldo disponible + + + + SendOptionsPopup + + + Enable Coin control + Activar control de monedas + + + + Multiple Recipients + Varios destinatarios + + + + SendRecipient + + + Address is valid for mainnet, not the current network + La dirección es válida para la red principal, no para la red actual + + + + Address is valid for testnet, not the current network + La dirección es válida para la red de pruebas, no para la red actual + + + + Invalid address format + Formato de dirección no válido + + + + Amount must be greater than zero + El importe debe ser mayor que cero + + + + Amount exceeds maximum limit of 21,000,000 BTC + El importe supera el límite máximo de 21.000.000 BTC + + + + Amount exceeds available balance + El importe supera el saldo disponible + + + + SendResult + + + Transaction updated + Transacción actualizada + + + + Transaction sent + Transacción enviada + + + + Based on your selected fee, it should be confirmed within the next 10 minutes. + Con la tarifa seleccionada, debería confirmarse en los próximos 10 minutos. + + + + View new transaction + Ver nueva transacción + + + + View transaction + Ver transacción + + + + Done + Listo + + + + SendReview + + + Back + Atrás + + + + Edit + Editar + + + + Review transaction + Revisar transacción + + + + Note + Nota + + + + Amount + Importe + + + + Fee + Tarifa + + + + Total amount + Importe total + + + + Send + Enviar + + + + SettingsAbout + + + + About + Acerca de + + + + Bitcoin Core is an open source project. +If you find it useful, please contribute. + + This is experimental software. + Bitcoin Core es un proyecto de código abierto. +Si lo encuentras útil, por favor contribuye. + + Este es software experimental. + + + + Back + Atrás + + + + SettingsBlockClockDisplayMode + + + Back + Atrás + + + + Block clock display mode + Modo del reloj de bloques + + + + SettingsConnection + + + + Connection settings + Ajustes de conexión + + + + Back + Atrás + + + + Done + Listo + + + + SettingsDebugLog + + + Back + Atrás + + + + Search... + Buscar... + + + + Search debug log + Buscar en el registro de depuración + + + + 1 new entry + 1 entrada nueva + + + + %1 new entries + %1 entradas nuevas + + + + Debug log entries + Entradas del registro de depuración + + + + Load more + Cargar más + + + + SettingsDesignSystem + + + Back + Atrás + + + + Design system + Sistema de diseño + + + + Typography + Tipografía + + + + Colors + Colores + + + + Palette tokens for the active theme. Toggle Theme to compare. + Tokens de la paleta del tema activo. Cambia el tema para comparar. + + + + SettingsDeveloper + + + Back + Atrás + + + + Developer options + Opciones de desarrollador + + + + Developer settings + Ajustes de desarrollador + + + + SettingsDisplay + + + Back + Atrás + + + + Display + Pantalla + + + + Theme + Tema + + + + Block status size + Tamaño del estado de bloques + + + + System default + Predeterminado del sistema + + + + Display unit + Unidad de visualización + + + + Language + Idioma + + + + SettingsDisplayUnit + + + Back + Atrás + + + + Display unit + Unidad de visualización + + + + BTC + BTC + + + + 8 decimal places (0.00000001 BTC = 1 sat) + 8 decimales (0.00000001 BTC = 1 sat) + + + + sat + sat + + + + Satoshi, the smallest unit (1 sat = 0.00000001 BTC) + Satoshi, la unidad más pequeña (1 sat = 0.00000001 BTC) + + + + SettingsLanguage + + + Back + Atrás + + + + Choose language + Elegir idioma + + + + Search... + Buscar... + + + + SettingsProxy + + + Back + Atrás + + + + Proxy Settings + Ajustes de proxy + + + + Restart the application for these changes to take effect. + Reinicia la aplicación para que los cambios surtan efecto. + + + + SettingsStorage + + + Storage settings + Ajustes de almacenamiento + + + + Back + Atrás + + + + Storage Settings + Ajustes de almacenamiento + + + + Done + Listo + + + + SettingsTheme + + + Back + Atrás + + + + Theme + Tema + + + + SettingsWallet + + + Back + Atrás + + + + External Signer + Firmante externo + + + + Shutdown + + + Saving... + Guardando... + + + + Do not shut down the computer until this is done. + No apagues el ordenador hasta que esto esté listo. + + + + SpeedUpOverlay + + + Speed up transaction + Acelerar transacción + + + + Set a higher transaction fee if you want your transaction to be confirmed faster. + Establece una comisión más alta si quieres que tu transacción se confirme más rápido. + + + + Original fee + Comisión original + + + + New fee + Nueva comisión + + + + Cancel + Cancelar + + + + Update transaction + Actualizar transacción + + + + StorageLocations + + + Default + Predeterminado + + + + Your application directory. + Tu directorio de aplicación. + + + + Custom + Personalizado + + + + Choose the directory and storage device. + Elige el directorio y dispositivo de almacenamiento. + + + + StorageOptions + + + Reduce storage + Reducir almacenamiento + + + + Uses about %1GB. For simple wallet use. + Usa aproximadamente %1 GB. Para uso básico de cartera. + + + + Store all data + Almacenar todos los datos + + + + Uses about %1GB. Support the network. + Usa aproximadamente %1 GB. Apoya la red. + + + + Custom + Personalizado + + + + Storing about %1GB of data. + Almacenando aproximadamente %1 GB de datos. + + + + StorageSettings + + + Store recent blocks only + Almacenar solo bloques recientes + + + + Block Storage limit (GB) + Límite de almacenamiento de bloques (GB) + + + + This is not a valid prune target. Please choose a value that is equal to or larger than 1GB + No es un objetivo de poda válido. Elige un valor igual o mayor a 1 GB. + + + + Data Directory + Directorio de datos + + + + ThemeSettings + + + Light + Claro + + + + Dark + Oscuro + + + + Developer + Desarrollador + + + + Design system + Sistema de diseño + + + + WalletBadge + + + Add Wallet + Añadir cartera + + + + Select Wallet + Seleccionar cartera + + + + sat + sat + + + + sats + sats + + + + WalletQmlController + + + + Wallet creation failed. + No se pudo crear la billetera. + + + + Choose a wallet name. + Elige un nombre de billetera. + + + + Set an external signer path in Wallet settings first. + Primero establece la ruta del firmante externo en los ajustes de billetera. + + + + Connect an external signer and try again. + Conecta un firmante externo e inténtalo de nuevo. + + + + More than one external signer was found. Connect only one device and try again. + Se encontró más de un firmante externo. Conecta solo un dispositivo e inténtalo de nuevo. + + + + More than one external signer was found. Connect only one device. + Se encontró más de un firmante externo. Conecta solo un dispositivo. + + + + The signer command did not return valid output. Check that the path is correct. + El comando del firmante no devolvió una salida válida. Verifica que la ruta sea correcta. + + + + This wallet needs to be migrated + Esta billetera necesita ser migrada + + + + We couldn't find that file + No pudimos encontrar ese archivo + + + + A wallet with this name already exists + Ya existe una billetera con este nombre + + + + This wallet type is not supported + Este tipo de billetera no es compatible + + + + This wallet couldn't be imported + No se pudo importar esta billetera + + + + The selected backup appears to come from a legacy wallet format. It needs to be migrated before it can be used here. + El respaldo seleccionado parece provenir de un formato de billetera antiguo. Necesita ser migrado antes de poder usarse aquí. + + + + The selected file is no longer available at that location. Choose the backup file again and retry. + El archivo seleccionado ya no está disponible en esa ubicación. Elige el archivo de respaldo de nuevo e inténtalo otra vez. + + + + Importing this backup would create a wallet that already exists in your wallet directory. Remove or rename the existing wallet first, then try again. + Importar este respaldo crearía una billetera que ya existe en tu directorio de billeteras. Elimina o cambia el nombre de la billetera existente primero, luego inténtalo de nuevo. + + + + The file looks like a wallet backup, but this application could not verify or open it in a supported format. + El archivo parece ser un respaldo de billetera, pero esta aplicación no pudo verificarlo o abrirlo en un formato compatible. + + + + The selected file could not be restored as a wallet. Check that it is a valid wallet backup, then try another file. + El archivo seleccionado no se pudo restaurar como billetera. Verifica que sea un respaldo de billetera válido, luego intenta con otro archivo. + + + + If this is a legacy wallet backup, migrate it with the wallet migration tool before importing it here. + Si este es un respaldo de billetera antiguo, mígralo con la herramienta de migración de billeteras antes de importarlo aquí. + + + + Choose a wallet backup file. + Elige un archivo de respaldo de billetera. + + + + The selected wallet path does not exist. + La ruta de billetera seleccionada no existe. + + + + Wallet import failed. + No se pudo importar la billetera. + + + + Choose a wallet to open. + Elige una billetera para abrir. + + + + + The selected wallet is not available in the wallet directory. + La billetera seleccionada no está disponible en el directorio de billeteras. + + + + Wallet could not be opened. + No se pudo abrir la billetera. + + + + Choose a wallet to update. + Elige una billetera para actualizar. + + + + Wallet update failed. + No se pudo actualizar la billetera. + + + + + Single-key + Clave única + + + + External signer + Firmante externo + + + + Watch-only + Solo lectura + + + + Multi-key + Multiclave + + + + WalletQmlModel + + + External signer not available. + Firmante externo no disponible. + + + + + Couldn't prepare transaction for external signing. + No se pudo preparar la transacción para firma externa. + + + + External signer not found. Connect one device and try again. + Firmante externo no encontrado. Conecta un dispositivo e inténtalo de nuevo. + + + + External signer failed to sign. Try again. + El firmante externo no pudo firmar. Inténtalo de nuevo. + + + + WalletSelect + + + Wallets + Carteras + + + + WalletSettings + + + Signer path + Ruta del firmante + + + + Enter external signer path + Introduce la ruta del firmante externo + + + + The add wallet flow can offer external wallets when exactly one supported signer is connected. + El proceso de añadir billetera puede ofrecer billeteras externas cuando hay exactamente un firmante compatible conectado. + + + + Detected external signer: %1 + Firmante externo detectado: %1 + + + + Path updated. Press Check device to rescan with the current signer command. + Ruta actualizada. Pulsa Verificar dispositivo para volver a escanear con el comando de firmante actual. + + + + No external signer is currently detected. + No se detecta ningún firmante externo actualmente. + + + + Set the command path for HWI or another external signer tool. + Establece la ruta del comando para HWI u otra herramienta de firmante externo. + + + + Check device + Verificar dispositivo + + + + initerrormessage + + + There was an issue starting up. + Hubo un problema al iniciar. + + + + Close + Cerrar + + + + main + + + Bitcoin Core App + Aplicación Bitcoin Core + + + + + Approved on external signer. It should be confirmed within the next 10 minutes. + Aprobado en el firmante externo. Debería confirmarse en los próximos 10 minutos. + + + + + Based on your selected fee, it should be confirmed within the next 10 minutes. + Con la tarifa seleccionada, debería confirmarse en los próximos 10 minutos. + + + + + Done + Listo + + + + + Close window + Cerrar ventana + + + diff --git a/src/qml/main.cpp b/src/qml/main.cpp new file mode 100644 index 0000000000..ed502a9f1b --- /dev/null +++ b/src/qml/main.cpp @@ -0,0 +1,25 @@ +// Copyright (c) 2018-2022 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include + +#include + +#include +#include + +/** Translate string to current locale using Qt. */ +extern const TranslateFn G_TRANSLATION_FUN = [](const char* psz) { + return QCoreApplication::translate("bitcoin-core", psz).toStdString(); +}; + +const std::function G_TEST_GET_FULL_NAME{}; + +MAIN_FUNCTION +{ + return QmlGuiMain(argc, argv); +} diff --git a/src/qml/models/activityfilterproxymodel.cpp b/src/qml/models/activityfilterproxymodel.cpp new file mode 100644 index 0000000000..d3b7d20a25 --- /dev/null +++ b/src/qml/models/activityfilterproxymodel.cpp @@ -0,0 +1,305 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { +void WriteCsvValue(QTextStream& stream, QString value) +{ + value.replace('"', "\"\""); + stream << '"' << value << '"'; +} + +void WriteCsvRow(QTextStream& stream, const QStringList& values) +{ + for (int i = 0; i < values.size(); ++i) { + if (i > 0) stream << ','; + WriteCsvValue(stream, values.at(i)); + } + stream << '\n'; +} + +QmlBitcoinUnits::Unit ExportDisplayUnit(int display_unit) +{ + return display_unit == 1 ? QmlBitcoinUnits::Unit::SAT : QmlBitcoinUnits::Unit::BTC; +} + +QString ExportDisplayUnitLabel(int display_unit) +{ + return display_unit == 1 ? QStringLiteral("sat") : QStringLiteral("BTC"); +} +} // namespace + +ActivityFilterProxyModel::ActivityFilterProxyModel(QObject* parent) + : QSortFilterProxyModel(parent) +{ + setDynamicSortFilter(true); + setSortCaseSensitivity(Qt::CaseInsensitive); + sort(0, Qt::DescendingOrder); + + connect(this, &QAbstractItemModel::rowsInserted, this, &ActivityFilterProxyModel::countChanged); + connect(this, &QAbstractItemModel::rowsRemoved, this, &ActivityFilterProxyModel::countChanged); + connect(this, &QAbstractItemModel::modelReset, this, &ActivityFilterProxyModel::countChanged); + connect(this, &QAbstractItemModel::layoutChanged, this, &ActivityFilterProxyModel::countChanged); +} + +QHash ActivityFilterProxyModel::roleNames() const +{ + return sourceModel() ? sourceModel()->roleNames() : QHash{}; +} + +void ActivityFilterProxyModel::setSourceModel(QAbstractItemModel* source_model) +{ + if (sourceModel() == source_model) return; + + QSortFilterProxyModel::setSourceModel(source_model); + sort(0, Qt::DescendingOrder); + Q_EMIT countChanged(); +} + +QString ActivityFilterProxyModel::searchText() const +{ + return m_search_text; +} + +void ActivityFilterProxyModel::setSearchText(const QString& search_text) +{ + if (m_search_text == search_text) return; + + m_search_text = search_text; + invalidateFilter(); + Q_EMIT searchTextChanged(); + Q_EMIT countChanged(); +} + +ActivityFilterProxyModel::DateFilter ActivityFilterProxyModel::dateFilter() const +{ + return m_date_filter; +} + +void ActivityFilterProxyModel::setDateFilter(DateFilter date_filter) +{ + if (m_date_filter == date_filter) return; + + m_date_filter = date_filter; + invalidateFilter(); + Q_EMIT dateFilterChanged(); + Q_EMIT countChanged(); +} + +ActivityFilterProxyModel::TypeFilter ActivityFilterProxyModel::typeFilter() const +{ + return m_type_filter; +} + +void ActivityFilterProxyModel::setTypeFilter(TypeFilter type_filter) +{ + if (m_type_filter == type_filter) return; + + m_type_filter = type_filter; + invalidateFilter(); + Q_EMIT typeFilterChanged(); + Q_EMIT countChanged(); +} + +int ActivityFilterProxyModel::displayUnit() const +{ + return m_display_unit; +} + +void ActivityFilterProxyModel::setDisplayUnit(int display_unit) +{ + if (m_display_unit == display_unit) return; + + m_display_unit = display_unit; + Q_EMIT displayUnitChanged(); +} + +int ActivityFilterProxyModel::count() const +{ + return rowCount(); +} + +bool ActivityFilterProxyModel::filterAcceptsRow(int source_row, const QModelIndex& source_parent) const +{ + if (!sourceModel()) return false; + + const QModelIndex source_index = sourceModel()->index(source_row, 0, source_parent); + if (!source_index.isValid()) return false; + + if (!dateMatches(source_index.data(ActivityListModel::TimestampRole).toLongLong())) { + return false; + } + + const TypeFilter row_type = filterTypeForIndex(source_index); + if (m_type_filter != TypeAll && row_type != m_type_filter) { + return false; + } + + const QString search = m_search_text.trimmed(); + if (!search.isEmpty()) { + const QString address = source_index.data(ActivityListModel::AddressRole).toString(); + const QString label = source_index.data(ActivityListModel::LabelRole).toString(); + const QString txid = source_index.data(ActivityListModel::TxIdRole).toString(); + if (!address.contains(search, Qt::CaseInsensitive) && + !label.contains(search, Qt::CaseInsensitive) && + !txid.contains(search, Qt::CaseInsensitive)) { + return false; + } + } + + return true; +} + +bool ActivityFilterProxyModel::lessThan(const QModelIndex& left_index, const QModelIndex& right_index) const +{ + const qint64 left_timestamp = sourceModel()->data(left_index, ActivityListModel::TimestampRole).toLongLong(); + const qint64 right_timestamp = sourceModel()->data(right_index, ActivityListModel::TimestampRole).toLongLong(); + return left_timestamp < right_timestamp; +} + +ActivityFilterProxyModel::TypeFilter ActivityFilterProxyModel::filterTypeForIndex(const QModelIndex& source_index) const +{ + if (source_index.data(ActivityListModel::IsPendingRequestRole).toBool()) { + return PaymentRequest; + } + + switch (source_index.data(ActivityListModel::TypeRole).toInt()) { + case Transaction::RecvWithAddress: + case Transaction::RecvFromOther: + return Received; + case Transaction::SendToSelf: + return SentToSelf; + case Transaction::Generated: + return Mined; + case Transaction::SendToAddress: + case Transaction::SendToOther: + case Transaction::Other: + default: + return Sent; + } +} + +QString ActivityFilterProxyModel::exportTypeLabelForIndex(const QModelIndex& proxy_index) const +{ + if (proxy_index.data(ActivityListModel::IsPendingRequestRole).toBool()) { + return tr("Payment request"); + } + + switch (proxy_index.data(ActivityListModel::TypeRole).toInt()) { + case Transaction::RecvWithAddress: + case Transaction::RecvFromOther: + return tr("Received"); + case Transaction::SendToAddress: + case Transaction::SendToOther: + return tr("Sent"); + case Transaction::SendToSelf: + return tr("Sent to yourself"); + case Transaction::Generated: + return tr("Mined"); + case Transaction::Other: + default: + return tr("Other"); + } +} + +bool ActivityFilterProxyModel::dateMatches(qint64 timestamp) const +{ + if (m_date_filter == DateAll) return true; + if (timestamp <= 0) return false; + + const QDate current_date = QDate::currentDate(); + QDate start_date; + QDate end_date; + switch (m_date_filter) { + case Today: + start_date = current_date; + end_date = current_date.addDays(1); + break; + case ThisWeek: + start_date = current_date.addDays(-(current_date.dayOfWeek() - 1)); + end_date = start_date.addDays(7); + break; + case ThisMonth: + start_date = QDate(current_date.year(), current_date.month(), 1); + end_date = start_date.addMonths(1); + break; + case ThisYear: + start_date = QDate(current_date.year(), 1, 1); + end_date = start_date.addYears(1); + break; + case DateAll: + return true; + } + + const QDateTime start_of_range{start_date, QTime(0, 0)}; + const QDateTime end_of_range{end_date, QTime(0, 0)}; + const QDateTime row_time = QDateTime::fromSecsSinceEpoch(timestamp); + return row_time >= start_of_range && row_time < end_of_range; +} + +QString ActivityFilterProxyModel::normalizedExportPath(const QString& path) const +{ + const QUrl url{path}; + if (url.isLocalFile()) { + return url.toLocalFile(); + } + return path; +} + +bool ActivityFilterProxyModel::exportCsv(const QString& path) const +{ + const QString filename = normalizedExportPath(path); + if (filename.isEmpty()) return false; + + QFile file(filename); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + return false; + } + + QTextStream stream(&file); + WriteCsvRow(stream, { + tr("Confirmed"), + tr("Date"), + tr("Type"), + tr("Label"), + tr("Address"), + tr("Amount") + QStringLiteral(" (%1)").arg(ExportDisplayUnitLabel(m_display_unit)), + tr("ID"), + }); + + for (int row = 0; row < rowCount(); ++row) { + const QModelIndex proxy_index = index(row, 0); + const qint64 timestamp = proxy_index.data(ActivityListModel::TimestampRole).toLongLong(); + const auto status = static_cast(proxy_index.data(ActivityListModel::StatusRole).toInt()); + const bool confirmed = status == Transaction::Confirming || status == Transaction::Confirmed; + const CAmount amount = proxy_index.data(ActivityListModel::NetAmountSatRole).toLongLong(); + const bool pending_request = proxy_index.data(ActivityListModel::IsPendingRequestRole).toBool(); + + WriteCsvRow(stream, { + confirmed ? QStringLiteral("true") : QStringLiteral("false"), + QDateTime::fromSecsSinceEpoch(timestamp).toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")), + exportTypeLabelForIndex(proxy_index), + proxy_index.data(ActivityListModel::LabelRole).toString(), + proxy_index.data(ActivityListModel::AddressRole).toString(), + QmlBitcoinUnits::format(ExportDisplayUnit(m_display_unit), amount, false, QmlBitcoinUnits::SeparatorStyle::NEVER), + pending_request ? QString{} : proxy_index.data(ActivityListModel::TxIdRole).toString(), + }); + } + + file.close(); + return file.error() == QFile::NoError; +} diff --git a/src/qml/models/activityfilterproxymodel.h b/src/qml/models/activityfilterproxymodel.h new file mode 100644 index 0000000000..e3009ef86f --- /dev/null +++ b/src/qml/models/activityfilterproxymodel.h @@ -0,0 +1,87 @@ +// 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. + +#ifndef BITCOIN_QML_MODELS_ACTIVITYFILTERPROXYMODEL_H +#define BITCOIN_QML_MODELS_ACTIVITYFILTERPROXYMODEL_H + +#include +#include +#include +#include +#include + +class ActivityFilterProxyModel : public QSortFilterProxyModel +{ + Q_OBJECT + Q_PROPERTY(QString searchText READ searchText WRITE setSearchText NOTIFY searchTextChanged) + Q_PROPERTY(DateFilter dateFilter READ dateFilter WRITE setDateFilter NOTIFY dateFilterChanged) + Q_PROPERTY(TypeFilter typeFilter READ typeFilter WRITE setTypeFilter NOTIFY typeFilterChanged) + Q_PROPERTY(int displayUnit READ displayUnit WRITE setDisplayUnit NOTIFY displayUnitChanged) + Q_PROPERTY(int count READ count NOTIFY countChanged) + +public: + enum DateFilter { + DateAll, + Today, + ThisWeek, + ThisMonth, + ThisYear + }; + Q_ENUM(DateFilter) + + enum TypeFilter { + TypeAll, + Received, + Sent, + SentToSelf, + Mined, + PaymentRequest + }; + Q_ENUM(TypeFilter) + + explicit ActivityFilterProxyModel(QObject* parent = nullptr); + + QHash roleNames() const override; + void setSourceModel(QAbstractItemModel* source_model) override; + + QString searchText() const; + void setSearchText(const QString& search_text); + + DateFilter dateFilter() const; + void setDateFilter(DateFilter date_filter); + + TypeFilter typeFilter() const; + void setTypeFilter(TypeFilter type_filter); + + int displayUnit() const; + void setDisplayUnit(int display_unit); + + int count() const; + + Q_INVOKABLE bool exportCsv(const QString& path) const; + +Q_SIGNALS: + void searchTextChanged(); + void dateFilterChanged(); + void typeFilterChanged(); + void displayUnitChanged(); + void countChanged(); + +protected: + bool filterAcceptsRow(int source_row, const QModelIndex& source_parent) const override; + bool lessThan(const QModelIndex& left_index, const QModelIndex& right_index) const override; + +private: + TypeFilter filterTypeForIndex(const QModelIndex& source_index) const; + QString exportTypeLabelForIndex(const QModelIndex& proxy_index) const; + bool dateMatches(qint64 timestamp) const; + QString normalizedExportPath(const QString& path) const; + + QString m_search_text; + DateFilter m_date_filter{DateAll}; + TypeFilter m_type_filter{TypeAll}; + int m_display_unit{0}; +}; + +#endif // BITCOIN_QML_MODELS_ACTIVITYFILTERPROXYMODEL_H diff --git a/src/qml/models/activitylistmodel.cpp b/src/qml/models/activitylistmodel.cpp new file mode 100644 index 0000000000..725e014c90 --- /dev/null +++ b/src/qml/models/activitylistmodel.cpp @@ -0,0 +1,372 @@ +// Copyright (c) 2024-2025 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include + +#include +#include + +#include + +ActivityListModel::ActivityListModel(WalletQmlModel *parent) + : QAbstractListModel(parent) + , m_wallet_model(parent) +{ + if (m_wallet_model != nullptr) { + refreshWallet(); + subscribeToCoreSignals(); + } +} + +ActivityListModel::~ActivityListModel() +{ + unsubscribeFromCoreSignals(); +} + +int ActivityListModel::rowCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent) + return m_transactions.size(); +} + +void ActivityListModel::updateTransactionStatus(QSharedPointer tx) const +{ + if (m_wallet_model == nullptr || tx->isPendingRequest) { + return; + } + interfaces::WalletTxStatus wtx; + int num_blocks; + int64_t block_time; + if (m_wallet_model->tryGetTxStatus(tx->hash, wtx, num_blocks, block_time)) { + tx->updateStatus(wtx, num_blocks, block_time); + } else { + tx->status = Transaction::Status::NotAccepted; + } +} + +void ActivityListModel::updateTransactionLabel(QSharedPointer tx) const +{ + if (m_wallet_model == nullptr) { + return; + } + + tx->label = m_wallet_model->getAddressLabel(tx->address); +} + +QVariant ActivityListModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_transactions.size()) + return QVariant(); + + QSharedPointer tx = m_transactions.at(index.row()); + updateTransactionStatus(tx); + + switch (role) { + case AddressRole: + return tx->address; + case AmountRole: + return tx->prettyAmount(m_display_unit); + case DateTimeRole: + return tx->dateTimeString(); + case DepthRole: + return tx->depth; + case LabelRole: + updateTransactionLabel(tx); + return tx->label; + case StatusRole: + return tx->status; + case TypeRole: + return tx->type; + case TxidRole: + return tx->isPendingRequest ? QString{} : tx->txid; + case CanBumpRole: + return m_wallet_model ? m_wallet_model->canBumpTransaction(tx->hash) : false; + case ReplacesTxidRole: + return tx->replacesTxid; + case ReplacedByTxidRole: + return tx->replacedByTxid; + case TimestampRole: + return tx->time; + case IsPendingRequestRole: + return tx->isPendingRequest; + case RequestIdRole: + return tx->requestId; + case NetAmountSatRole: + return QVariant::fromValue(tx->netAmount()); + default: + return QVariant(); + } +} + +QHash ActivityListModel::roleNames() const +{ + QHash roles; + roles[AddressRole] = "address"; + roles[AmountRole] = "amount"; + roles[DateTimeRole] = "date"; + roles[DepthRole] = "depth"; + roles[LabelRole] = "label"; + roles[StatusRole] = "status"; + roles[TypeRole] = "type"; + roles[TxidRole] = "txid"; + roles[CanBumpRole] = "canBump"; + roles[ReplacesTxidRole] = "replacesTxid"; + roles[ReplacedByTxidRole] = "replacedByTxid"; + roles[TimestampRole] = "timestamp"; + roles[IsPendingRequestRole] = "isPendingRequest"; + roles[RequestIdRole] = "requestId"; + roles[NetAmountSatRole] = "netAmountSat"; + return roles; +} + +void ActivityListModel::reload() +{ + beginResetModel(); + m_transactions.clear(); + refreshWallet(); + endResetModel(); +} + +QVariantMap ActivityListModel::transactionDetails(const QString& txid) const +{ + for (const auto& tx : m_transactions) { + if (tx->txid == txid) { + updateTransactionStatus(tx); + updateTransactionLabel(tx); + const QVariantList payment_requests = m_wallet_model && m_wallet_model->receiveRequests() + ? m_wallet_model->receiveRequests()->matchingEntriesForAddress(tx->address) + : QVariantList{}; + return { + {"txid", tx->txid}, + {"canBump", m_wallet_model ? m_wallet_model->canBumpTransaction(tx->hash) : false}, + {"replacedByTxid", tx->replacedByTxid}, + {"amount", tx->prettyAmount()}, + {"date", tx->dateTimeString()}, + {"depth", tx->depth}, + {"type", tx->type}, + {"status", tx->status}, + {"address", tx->address}, + {"label", tx->label}, + {"paymentRequests", payment_requests} + }; + } + } + return {}; +} + +void ActivityListModel::setDisplayUnit(int unit) +{ + if (unit != m_display_unit) { + m_display_unit = unit; + if (!m_transactions.isEmpty()) { + Q_EMIT dataChanged(index(0), index(m_transactions.size() - 1), {AmountRole}); + } + } +} + +void ActivityListModel::refreshWallet() +{ + if (m_wallet_model == nullptr) { + return; + } + for (const auto &tx : m_wallet_model->getWalletTxs()) { + auto transactions = Transaction::fromWalletTx(tx); + m_transactions.append(transactions); + for (const auto &transaction : transactions) { + updateTransactionStatus(transaction); + } + } + std::sort(m_transactions.begin(), m_transactions.end(), + [](const QSharedPointer &a, const QSharedPointer &b) { + return a->depth < b->depth; + }); + + addPendingReceiveRequests(); +} + +void ActivityListModel::addPendingReceiveRequests() +{ + if (m_wallet_model == nullptr) return; + + ReceiveRequestHistoryModel* history = m_wallet_model->receiveRequests(); + if (!history) return; + + QSet existing_addresses; + for (const auto& tx : m_transactions) { + if (!tx->address.isEmpty()) { + existing_addresses.insert(tx->address); + } + } + + for (int i = 0; i < history->rowCount(); ++i) { + QModelIndex idx = history->index(i); + QString address = history->data(idx, ReceiveRequestHistoryModel::AddressRole).toString(); + if (address.isEmpty() || existing_addresses.contains(address)) continue; + + QString label = history->data(idx, ReceiveRequestHistoryModel::LabelRole).toString(); + CAmount amount = history->data(idx, ReceiveRequestHistoryModel::AmountSatRole).toLongLong(); + QString dateIso = history->data(idx, ReceiveRequestHistoryModel::DateIsoRole).toString(); + qint64 timestamp = QDateTime::fromString(dateIso, Qt::ISODate).toSecsSinceEpoch(); + QString reqId = history->data(idx, ReceiveRequestHistoryModel::IdRole).toString(); + + addReceiveRequest(address, label, amount, timestamp, reqId); + } +} + +void ActivityListModel::addReceiveRequest(const QString& address, const QString& label, + CAmount amount, qint64 timestamp, const QString& requestId) +{ + uint256 zero_hash; + auto tx = QSharedPointer::create(zero_hash, timestamp, + Transaction::RecvWithAddress, address, CAmount{0}, amount); + tx->label = label.isEmpty() ? tr("Payment request") : label; + tx->status = Transaction::Unconfirmed; + tx->isPendingRequest = true; + tx->requestId = requestId; + + beginInsertRows(QModelIndex(), 0, 0); + m_transactions.push_front(tx); + m_pending_request_addresses.insert(address); + endInsertRows(); + Q_EMIT countChanged(); +} + +void ActivityListModel::updateReceiveRequest(const QString& requestId, const QString& label, CAmount amount) +{ + for (int i = 0; i < m_transactions.size(); ++i) { + if (m_transactions[i]->isPendingRequest && m_transactions[i]->requestId == requestId) { + m_transactions[i]->label = label.isEmpty() ? tr("Payment request") : label; + m_transactions[i]->credit = amount; + Q_EMIT dataChanged(index(i), index(i)); + return; + } + } +} + +void ActivityListModel::removePendingReceiveRequest(const QString& requestId) +{ + for (int i = 0; i < m_transactions.size(); ++i) { + if (!m_transactions[i]->isPendingRequest || m_transactions[i]->requestId != requestId) { + continue; + } + + const QString address = m_transactions[i]->address; + beginRemoveRows(QModelIndex(), i, i); + m_transactions.removeAt(i); + endRemoveRows(); + + const bool address_still_pending = std::any_of(m_transactions.cbegin(), m_transactions.cend(), + [&address](const QSharedPointer& tx) { + return tx->isPendingRequest && tx->address == address; + }); + if (!address_still_pending) { + m_pending_request_addresses.remove(address); + } + + Q_EMIT countChanged(); + return; + } +} + +void ActivityListModel::updateTransaction(const uint256& hash, const interfaces::WalletTxStatus& tx_status, int num_blocks, int64_t block_time) +{ + int index = findTransactionIndex(hash); + + if (index != -1) { + QSharedPointer tx = m_transactions.at(index); + tx->updateStatus(tx_status, num_blocks, block_time); + Q_EMIT dataChanged(this->index(index), this->index(index)); + } else { + // new transaction + interfaces::WalletTx wtx = m_wallet_model->getWalletTx(hash); + auto transactions = Transaction::fromWalletTx(wtx); + if (transactions.isEmpty()) { + return; + } + for (const auto& tx : transactions) { + tx->updateStatus(tx_status, num_blocks, block_time); + int pendingIdx = findPendingRequestIndex(tx->address); + if (pendingIdx != -1) { + fulfillPendingRequest(pendingIdx, tx); + } else { + beginInsertRows(QModelIndex(), 0, 0); + m_transactions.push_front(tx); + endInsertRows(); + } + } + } +} + +int ActivityListModel::findTransactionIndex(const uint256& hash) const +{ + auto it = std::find_if(m_transactions.begin(), m_transactions.end(), + [&hash](const QSharedPointer& tx) { + return tx->hash == hash; + }); + if (it != m_transactions.end()) { + return std::distance(m_transactions.begin(), it); + } + return -1; +} + +int ActivityListModel::findPendingRequestIndex(const QString& address) const +{ + if (!m_pending_request_addresses.contains(address)) return -1; + + for (int i = 0; i < m_transactions.size(); ++i) { + if (m_transactions[i]->isPendingRequest && m_transactions[i]->address == address) { + return i; + } + } + return -1; +} + +void ActivityListModel::fulfillPendingRequest(int index, const QSharedPointer& real_tx) +{ + QSharedPointer pending = m_transactions.at(index); + + pending->hash = real_tx->hash; + pending->status = real_tx->status; + pending->depth = real_tx->depth; + pending->time = real_tx->time; + pending->credit = real_tx->credit; + pending->debit = real_tx->debit; + pending->type = real_tx->type; + pending->idx = real_tx->idx; + pending->txid = real_tx->txid; + pending->countsForBalance = real_tx->countsForBalance; + pending->involvesWatchAddress = real_tx->involvesWatchAddress; + pending->isPendingRequest = false; + if (!real_tx->label.isEmpty()) { + pending->label = real_tx->label; + } + + m_pending_request_addresses.remove(pending->address); + + Q_EMIT dataChanged(this->index(index), this->index(index)); +} + +void ActivityListModel::subscribeToCoreSignals() +{ + // Connect signals to wallet + m_handler_transaction_changed = m_wallet_model->handleTransactionChanged([this](const uint256& hash, ChangeType status) { + interfaces::WalletTxStatus wtx; + int num_blocks; + int64_t block_time; + if (m_wallet_model->tryGetTxStatus(hash, wtx, num_blocks, block_time)) { + updateTransaction(hash, wtx, num_blocks, block_time); + } + }); +} + +void ActivityListModel::unsubscribeFromCoreSignals() +{ + // Disconnect signals from wallet + if (m_handler_transaction_changed) { + m_handler_transaction_changed->disconnect(); + } +} diff --git a/src/qml/models/activitylistmodel.h b/src/qml/models/activitylistmodel.h new file mode 100644 index 0000000000..70e379885d --- /dev/null +++ b/src/qml/models/activitylistmodel.h @@ -0,0 +1,86 @@ +// Copyright (c) 2024-2025 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QML_MODELS_ACTIVITYLISTMODEL_H +#define BITCOIN_QML_MODELS_ACTIVITYLISTMODEL_H + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +class WalletQmlModel; + +class ActivityListModel : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY(int count READ count NOTIFY countChanged) + +public: + explicit ActivityListModel(WalletQmlModel * parent = nullptr); + ~ActivityListModel(); + + enum TransactionRoles { + AddressRole = Qt::UserRole + 1, + AmountRole, + DateTimeRole, + DepthRole, + LabelRole, + StatusRole, + TypeRole, + TxidRole, + TxIdRole = TxidRole, + CanBumpRole, + ReplacesTxidRole, + ReplacedByTxidRole, + TimestampRole, + IsPendingRequestRole, + RequestIdRole, + NetAmountSatRole + }; + + Q_INVOKABLE void reload(); + Q_INVOKABLE QVariantMap transactionDetails(const QString& txid) const; + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + int count() const { return rowCount(); } + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + void setDisplayUnit(int unit); + void addReceiveRequest(const QString& address, const QString& label, + CAmount amount, qint64 timestamp, const QString& requestId); + void updateReceiveRequest(const QString& requestId, const QString& label, CAmount amount); + void removePendingReceiveRequest(const QString& requestId); + +Q_SIGNALS: + void countChanged(); + +private: + void refreshWallet(); + void addPendingReceiveRequests(); + void updateTransactionStatus(QSharedPointer tx) const; + void updateTransactionLabel(QSharedPointer tx) const; + void subscribeToCoreSignals(); + void unsubscribeFromCoreSignals(); + void updateTransaction(const uint256& hash, const interfaces::WalletTxStatus& wtx, + int num_blocks, int64_t block_time); + int findTransactionIndex(const uint256& hash) const; + int findPendingRequestIndex(const QString& address) const; + void fulfillPendingRequest(int index, const QSharedPointer& real_tx); + + int m_display_unit{0}; + QList> m_transactions; + QSet m_pending_request_addresses; + WalletQmlModel* m_wallet_model; + std::unique_ptr m_handler_transaction_changed; + std::unique_ptr m_handler_show_progress; +}; + +#endif // BITCOIN_QML_MODELS_ACTIVITYLISTMODEL_H diff --git a/src/qml/models/addresslistmodel.cpp b/src/qml/models/addresslistmodel.cpp new file mode 100644 index 0000000000..948f5214d1 --- /dev/null +++ b/src/qml/models/addresslistmodel.cpp @@ -0,0 +1,241 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +using wallet::AddressPurpose; +using wallet::ISMINE_NO; + +namespace { +QString CategoryName(AddressListModel::Category category) +{ + switch (category) { + case AddressListModel::SingleUse: return QStringLiteral("single-use"); + case AddressListModel::Change: return QStringLiteral("change"); + } + return {}; +} + +QString ScriptTypeName(const CTxDestination& destination) +{ + if (std::get_if(&destination)) return QStringLiteral("P2PKH"); + if (std::get_if(&destination)) return QStringLiteral("P2SH"); + if (std::get_if(&destination)) return QStringLiteral("P2WPKH"); + if (std::get_if(&destination)) return QStringLiteral("P2WSH"); + if (std::get_if(&destination)) return QStringLiteral("P2TR"); + if (std::get_if(&destination)) return QStringLiteral("P2A"); + if (std::get_if(&destination)) return QStringLiteral("Witness"); + if (std::get_if(&destination)) return QStringLiteral("P2PK"); + return {}; +} + +QString DisplayAmount(CAmount amount) +{ + if (amount == 0) return QStringLiteral("₿ 0.0"); + return QStringLiteral("₿ ") + QmlBitcoinUnits::format(QmlBitcoinUnits::Unit::BTC, amount); +} +} // namespace + +AddressListModel::AddressListModel(WalletQmlModel* parent) + : QAbstractListModel(parent) + , m_wallet_model(parent) +{ + if (m_wallet_model) { + connect(m_wallet_model, &WalletQmlModel::addressListChanged, this, &AddressListModel::refresh); + connect(m_wallet_model, &WalletQmlModel::balanceChanged, this, &AddressListModel::refresh); + } + refresh(); +} + +int AddressListModel::rowCount(const QModelIndex& parent) const +{ + if (parent.isValid()) return 0; + return static_cast(m_entries.size()); +} + +int AddressListModel::count() const +{ + return static_cast(m_entries.size()); +} + +QVariant AddressListModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= static_cast(m_entries.size())) { + return {}; + } + + const AddressEntry& entry{m_entries.at(index.row())}; + switch (role) { + case AddressRole: + return entry.address; + case FormattedAddressRole: + return BitcoinAddress::formattedAddress(entry.address); + case EllipsesAddressRole: + return BitcoinAddress::ellipsesAddress(entry.address); + case LabelRole: + return entry.label; + case CategoryRole: + return CategoryName(entry.category); + case UsedRole: + return entry.used; + case CurrentBalanceRole: + return QmlBitcoinUnits::format(QmlBitcoinUnits::Unit::BTC, entry.current_balance); + case DisplayAmountRole: + return DisplayAmount(entry.current_balance); + case HasAmountRole: + return entry.current_balance != 0; + case ScriptTypeRole: + return entry.script_type; + case CanEditLabelRole: + return entry.can_edit_label; + case CanCreatePaymentRequestRole: + return entry.can_create_payment_request; + } + return {}; +} + +QHash AddressListModel::roleNames() const +{ + QHash roles; + roles[AddressRole] = "address"; + roles[FormattedAddressRole] = "formattedAddress"; + roles[EllipsesAddressRole] = "ellipsesAddress"; + roles[LabelRole] = "label"; + roles[CategoryRole] = "category"; + roles[UsedRole] = "isUsed"; + roles[CurrentBalanceRole] = "currentBalance"; + roles[DisplayAmountRole] = "displayAmount"; + roles[HasAmountRole] = "hasAmount"; + roles[ScriptTypeRole] = "scriptType"; + roles[CanEditLabelRole] = "canEditLabel"; + roles[CanCreatePaymentRequestRole] = "canCreatePaymentRequest"; + return roles; +} + +AddressListModel::Category AddressListModel::category() const +{ + return m_category; +} + +void AddressListModel::setCategory(Category category) +{ + if (m_category == category) return; + m_category = category; + Q_EMIT categoryChanged(); + rebuild(); +} + +QVariantList AddressListModel::categoryOptions() const +{ + return { + QVariantMap{{QStringLiteral("value"), static_cast(SingleUse)}, {QStringLiteral("text"), tr("Single-use")}}, + QVariantMap{{QStringLiteral("value"), static_cast(Change)}, {QStringLiteral("text"), tr("Change")}}, + }; +} + +bool AddressListModel::showUsed() const +{ + return m_show_used; +} + +void AddressListModel::setShowUsed(bool show_used) +{ + if (m_show_used == show_used) return; + m_show_used = show_used; + Q_EMIT showUsedChanged(); + rebuild(); +} + +void AddressListModel::refresh() +{ + rebuild(); +} + +bool AddressListModel::setAddressLabel(const QString& address, const QString& label) +{ + if (!m_wallet_model || address.isEmpty()) return false; + if (!m_wallet_model->setAddressLabel(address, label)) return false; + refresh(); + return true; +} + +QString AddressListModel::addressAt(int row) const +{ + if (row < 0 || row >= static_cast(m_entries.size())) return {}; + return m_entries.at(row).address; +} + +void AddressListModel::rebuild() +{ + beginResetModel(); + m_entries = collectEntries(); + endResetModel(); + Q_EMIT countChanged(); +} + +std::vector AddressListModel::collectEntries() const +{ + std::vector entries; + if (!m_wallet_model) return entries; + + const auto balances{m_wallet_model->addressBalances()}; + const auto used_addresses{m_wallet_model->usedAddresses()}; + + if (m_category == Change) { + const auto change_addresses{m_wallet_model->changeAddresses()}; + entries.reserve(change_addresses.size()); + for (const QString& address : change_addresses) { + AddressEntry entry; + entry.address = address; + entry.label = m_wallet_model->getAddressLabel(address); + entry.category = Change; + entry.used = used_addresses.count(address) > 0; + entry.script_type = ScriptTypeName(DecodeDestination(address.toStdString())); + const auto balance_it{balances.find(address)}; + entry.current_balance = balance_it == balances.end() ? 0 : balance_it->second; + entry.can_edit_label = false; + entry.can_create_payment_request = false; + entries.push_back(entry); + } + return entries; + } + + for (const interfaces::WalletAddress& wallet_address : m_wallet_model->getAddresses()) { + if (wallet_address.purpose != AddressPurpose::RECEIVE || wallet_address.is_mine == ISMINE_NO) { + continue; + } + + const QString address{QString::fromStdString(EncodeDestination(wallet_address.dest))}; + if (address.isEmpty()) continue; + + const bool used{used_addresses.count(address) > 0}; + if (used && !m_show_used) { + continue; + } + + AddressEntry entry; + entry.address = address; + entry.label = QString::fromStdString(wallet_address.name); + entry.category = SingleUse; + entry.used = used; + entry.script_type = ScriptTypeName(wallet_address.dest); + const auto balance_it{balances.find(address)}; + entry.current_balance = balance_it == balances.end() ? 0 : balance_it->second; + entry.can_edit_label = true; + entry.can_create_payment_request = true; + entries.push_back(entry); + } + + return entries; +} diff --git a/src/qml/models/addresslistmodel.h b/src/qml/models/addresslistmodel.h new file mode 100644 index 0000000000..3b77451894 --- /dev/null +++ b/src/qml/models/addresslistmodel.h @@ -0,0 +1,91 @@ +// 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. + +#ifndef BITCOIN_QML_MODELS_ADDRESSLISTMODEL_H +#define BITCOIN_QML_MODELS_ADDRESSLISTMODEL_H + +#include + +#include +#include +#include + +#include + +class WalletQmlModel; + +class AddressListModel : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY(Category category READ category WRITE setCategory NOTIFY categoryChanged) + Q_PROPERTY(QVariantList categoryOptions READ categoryOptions CONSTANT) + Q_PROPERTY(bool showUsed READ showUsed WRITE setShowUsed NOTIFY showUsedChanged) + Q_PROPERTY(int count READ count NOTIFY countChanged) + +public: + enum Category { + SingleUse, + Change + }; + Q_ENUM(Category) + + enum AddressRoles { + AddressRole = Qt::UserRole + 1, + FormattedAddressRole, + EllipsesAddressRole, + LabelRole, + CategoryRole, + UsedRole, + CurrentBalanceRole, + DisplayAmountRole, + HasAmountRole, + ScriptTypeRole, + CanEditLabelRole, + CanCreatePaymentRequestRole + }; + + explicit AddressListModel(WalletQmlModel* parent = nullptr); + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + int count() const; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + Category category() const; + void setCategory(Category category); + QVariantList categoryOptions() const; + bool showUsed() const; + void setShowUsed(bool show_used); + + Q_INVOKABLE void refresh(); + Q_INVOKABLE bool setAddressLabel(const QString& address, const QString& label); + Q_INVOKABLE QString addressAt(int row) const; + +Q_SIGNALS: + void categoryChanged(); + void showUsedChanged(); + void countChanged(); + +private: + struct AddressEntry { + QString address; + QString label; + Category category{SingleUse}; + bool used{false}; + CAmount current_balance{0}; + QString script_type; + bool can_edit_label{false}; + bool can_create_payment_request{false}; + }; + + void rebuild(); + std::vector collectEntries() const; + + WalletQmlModel* m_wallet_model{nullptr}; + std::vector m_entries; + Category m_category{SingleUse}; + bool m_show_used{false}; +}; + +#endif // BITCOIN_QML_MODELS_ADDRESSLISTMODEL_H diff --git a/src/qml/models/banlistmodel.cpp b/src/qml/models/banlistmodel.cpp new file mode 100644 index 0000000000..158830ff11 --- /dev/null +++ b/src/qml/models/banlistmodel.cpp @@ -0,0 +1,65 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include + +#include +#include + +BanListModel::BanListModel(interfaces::Node& node, QObject* parent) + : QAbstractListModel(parent), m_node(node) +{ +} + +int BanListModel::rowCount(const QModelIndex& parent) const +{ + if (parent.isValid()) return 0; + return m_ban_list.size(); +} + +QVariant BanListModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() >= m_ban_list.size()) return {}; + const BanListEntry& entry = m_ban_list.at(index.row()); + switch (static_cast(role)) { + case BanRoles::AddressRole: + return QString::fromStdString(entry.subnet.ToString()); + case BanRoles::BanUntilRole: { + QDateTime dt = QDateTime::fromSecsSinceEpoch(entry.ban_entry.nBanUntil); + return QLocale::system().toString(dt, QStringLiteral("MMMM d, yyyy h:mm AP")); + } + } + return {}; +} + +QHash BanListModel::roleNames() const +{ + return { + {static_cast(BanRoles::AddressRole), "address"}, + {static_cast(BanRoles::BanUntilRole), "banUntil"}, + }; +} + +bool BanListModel::unbanAt(int row) +{ + if (row < 0 || row >= m_ban_list.size()) return false; + return m_node.unban(m_ban_list.at(row).subnet); +} + +void BanListModel::refresh() +{ + beginResetModel(); + banmap_t banMap; + m_node.getBanned(banMap); + m_ban_list.clear(); + m_ban_list.reserve(static_cast(banMap.size())); + for (const auto& [subnet, entry] : banMap) { + m_ban_list.append({subnet, entry}); + } + endResetModel(); + Q_EMIT countChanged(); +} diff --git a/src/qml/models/banlistmodel.h b/src/qml/models/banlistmodel.h new file mode 100644 index 0000000000..6bc4989486 --- /dev/null +++ b/src/qml/models/banlistmodel.h @@ -0,0 +1,54 @@ +// 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. + +#ifndef BITCOIN_QML_MODELS_BANLISTMODEL_H +#define BITCOIN_QML_MODELS_BANLISTMODEL_H + +#include +#include + +#include +#include + +namespace interfaces { class Node; } + +struct BanListEntry +{ + CSubNet subnet; + CBanEntry ban_entry; +}; + +class BanListModel : public QAbstractListModel +{ + Q_OBJECT + +public: + enum class BanRoles { + AddressRole = Qt::UserRole, + BanUntilRole + }; + + explicit BanListModel(interfaces::Node& node, QObject* parent = nullptr); + + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + Q_PROPERTY(int count READ count NOTIFY countChanged) + int count() const { return m_ban_list.size(); } + + Q_INVOKABLE bool unbanAt(int row); + +public Q_SLOTS: + void refresh(); + +Q_SIGNALS: + void countChanged(); + +private: + interfaces::Node& m_node; + QList m_ban_list; +}; + +#endif // BITCOIN_QML_MODELS_BANLISTMODEL_H diff --git a/src/qml/models/bitcoinaddress.cpp b/src/qml/models/bitcoinaddress.cpp new file mode 100644 index 0000000000..3835a1fbd6 --- /dev/null +++ b/src/qml/models/bitcoinaddress.cpp @@ -0,0 +1,122 @@ +// Copyright (c) 2025-2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +namespace { +constexpr int MAX_BITCOIN_ADDRESS_LENGTH = 90; + +bool IsBitcoinAddressChar(const QChar c) +{ + const ushort ch = c.unicode(); + return (ch >= '0' && ch <= '9') || (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z'); +} +} // namespace + +BitcoinAddress::BitcoinAddress(QObject* parent) + : QObject(parent) +{ + // Initialize with empty address + setAddress("", 0); +} + +BitcoinAddress::BitcoinAddress(const QString& address, QObject* parent) + : QObject(parent) +{ + setAddress(address, 0); +} + +QString BitcoinAddress::address() const +{ + return m_address; +} + +bool BitcoinAddress::isEmpty() const +{ + return m_address.isEmpty(); +} + +QString BitcoinAddress::formattedAddress() const +{ + return m_formattedAddress; +} + +QString BitcoinAddress::ellipsesAddress() const +{ + return ellipsesAddress(m_address); +} + +int BitcoinAddress::setAddress(const QString& input, int cursorPosition) +{ + // 1) Count how many valid chars were before the old cursor. + int posInClean = 0; + const int lenIn = qMin(qMax(cursorPosition, 0), input.length()); + for (int i = 0; i < lenIn; ++i) { + if (IsBitcoinAddressChar(input[i])) { + ++posInClean; + } + } + + // 2) Build the raw (no-space) address, filtering and capping at 90 chars. + QString raw; + raw.reserve(qMin(input.length(), MAX_BITCOIN_ADDRESS_LENGTH)); + for (QChar c : input) { + if (IsBitcoinAddressChar(c)) { + raw += c; + if (raw.length() >= MAX_BITCOIN_ADDRESS_LENGTH) { + break; + } + } + } + + // 3) Format into groups of 4 chars separated by spaces. + QString fmt = BitcoinAddress::formattedAddress(raw); + + // 4) Map back to a cursor position in the new formatted string. + int newCursor = 0, seen = 0; + while (newCursor < fmt.length() && seen < posInClean) { + if (fmt[newCursor] != QChar(' ')) { + ++seen; + } + ++newCursor; + } + + if (raw == m_address && fmt == m_formattedAddress) { + return newCursor; + } + + m_address = raw; + m_formattedAddress = fmt; + Q_EMIT addressChanged(); + Q_EMIT formattedAddressChanged(); + Q_EMIT ellipsesAddressChanged(); + + return newCursor; +} + +QString BitcoinAddress::formattedAddress(const QString& address) +{ + QString fmt; + fmt.reserve(address.length() + address.length() / 4); + for (int i = 0; i < address.length(); ++i) { + if (i > 0 && (i % 4) == 0) { + fmt += QChar(' '); + } + fmt += address[i]; + } + return fmt; +} + +QString BitcoinAddress::ellipsesAddress(const QString& address) +{ + if (address.length() > 8) { + QString left = address.left(4) + ' ' + address.mid(4, 4); + + QString right = address.mid(address.length() - 8, 4) + ' ' + address.right(4); + + return left + " ... " + right; + } else { + return address; + } +} diff --git a/src/qml/models/bitcoinaddress.h b/src/qml/models/bitcoinaddress.h new file mode 100644 index 0000000000..a8e3e45259 --- /dev/null +++ b/src/qml/models/bitcoinaddress.h @@ -0,0 +1,41 @@ +// Copyright (c) 2025-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. + +#ifndef BITCOIN_QML_MODELS_BITCOINADDRESS_H +#define BITCOIN_QML_MODELS_BITCOINADDRESS_H + +#include +#include + +class BitcoinAddress : public QObject +{ + Q_OBJECT + Q_PROPERTY(QString address READ address NOTIFY addressChanged) + Q_PROPERTY(QString formattedAddress READ formattedAddress NOTIFY formattedAddressChanged) + Q_PROPERTY(QString ellipsesAddress READ ellipsesAddress NOTIFY ellipsesAddressChanged) + +public: + explicit BitcoinAddress(QObject* parent = nullptr); + BitcoinAddress(const QString& address, QObject* parent = nullptr); + + static QString ellipsesAddress(const QString& address); + static QString formattedAddress(const QString& address); + + QString address() const; + bool isEmpty() const; + QString formattedAddress() const; + QString ellipsesAddress() const; + Q_INVOKABLE int setAddress(const QString& address, int cursorPosition = 0); + +Q_SIGNALS: + void addressChanged(); + void formattedAddressChanged(); + void ellipsesAddressChanged(); + +private: + QString m_address; + QString m_formattedAddress; +}; + +#endif // BITCOIN_QML_MODELS_BITCOINADDRESS_H diff --git a/src/qml/models/bitcoinuri.cpp b/src/qml/models/bitcoinuri.cpp new file mode 100644 index 0000000000..9c5febb32d --- /dev/null +++ b/src/qml/models/bitcoinuri.cpp @@ -0,0 +1,97 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include + +#include +#include +#include + +namespace { +BitcoinUriParseResult BuildError(const QString& message) +{ + BitcoinUriParseResult result; + result.success = false; + result.error = message; + return result; +} + +struct Tr { + static QString tr(const char* s) { return QCoreApplication::translate("BitcoinUri", s); } +}; +} // namespace + +BitcoinUriParseResult BitcoinUri::Parse(const QString& uri_text) +{ + const QString raw = uri_text.trimmed(); + if (raw.isEmpty()) { + return BuildError(Tr::tr("Enter a Bitcoin payment URI.")); + } + + if (raw.startsWith(QStringLiteral("bitcoin://"), Qt::CaseInsensitive)) { + return BuildError(Tr::tr("'bitcoin://' is not a valid URI. Use 'bitcoin:' instead.")); + } + + const QUrl uri(raw); + // QUrl normalises the scheme to lowercase (RFC 3986 §3.1), so a plain + // equality check is sufficient and correctly accepts BITCOIN:, Bitcoin:, etc. + if (!uri.isValid() || uri.scheme() != QStringLiteral("bitcoin")) { + return BuildError(Tr::tr("Not a valid Bitcoin payment URI.")); + } + + BitcoinUriParseResult result; + result.address = uri.path(); + if (result.address.endsWith('/')) { + result.address.chop(1); + } + + const CTxDestination destination = DecodeDestination(result.address.toStdString()); + if (!IsValidDestination(destination)) { + return BuildError(Tr::tr("Not a valid Bitcoin address.")); + } + + const QUrlQuery query(uri); + const auto items = query.queryItems(QUrl::FullyDecoded); + for (const auto& item : items) { + QString key = item.first; + const QString value = item.second; + + bool required = false; + if (key.startsWith(QStringLiteral("req-"))) { + required = true; + key.remove(0, 4); + } + + bool handled = false; + if (key == QLatin1String("label")) { + result.label = value; + result.has_label = true; + handled = true; + } else if (key == QLatin1String("message")) { + result.message = value; + result.has_message = true; + handled = true; + } else if (key == QLatin1String("amount")) { + if (!value.trimmed().isEmpty()) { + const auto amount_sats = ParseMoney(value.toStdString()); + if (!amount_sats.has_value()) { + return BuildError(Tr::tr("Invalid Bitcoin amount.")); + } + result.amount_sats = *amount_sats; + result.has_amount = true; + } + handled = true; + } + + if (required && !handled) { + return BuildError(Tr::tr("Unsupported required parameter: %1").arg(key)); + } + } + + result.success = true; + return result; +} diff --git a/src/qml/models/bitcoinuri.h b/src/qml/models/bitcoinuri.h new file mode 100644 index 0000000000..49ee6be655 --- /dev/null +++ b/src/qml/models/bitcoinuri.h @@ -0,0 +1,31 @@ +// 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. + +#ifndef BITCOIN_QML_MODELS_BITCOINURI_H +#define BITCOIN_QML_MODELS_BITCOINURI_H + +#include + +#include + +struct BitcoinUriParseResult +{ + bool success{false}; + QString error; + QString address; + CAmount amount_sats{0}; + bool has_amount{false}; + QString label; + bool has_label{false}; + QString message; + bool has_message{false}; +}; + +class BitcoinUri +{ +public: + static BitcoinUriParseResult Parse(const QString& uri_text); +}; + +#endif // BITCOIN_QML_MODELS_BITCOINURI_H diff --git a/src/qml/models/bitcoinurimodel.cpp b/src/qml/models/bitcoinurimodel.cpp new file mode 100644 index 0000000000..df38b345cf --- /dev/null +++ b/src/qml/models/bitcoinurimodel.cpp @@ -0,0 +1,72 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include + +#include +#include +#include + +namespace { +QVariantMap BuildBitcoinUriResultMap(const BitcoinUriParseResult& r) +{ + return { + {QStringLiteral("success"), r.success}, + {QStringLiteral("error"), r.error}, + {QStringLiteral("address"), r.address}, + {QStringLiteral("amountSats"), static_cast(r.amount_sats)}, + {QStringLiteral("hasAmount"), r.has_amount}, + {QStringLiteral("label"), r.label}, + {QStringLiteral("hasLabel"), r.has_label}, + // Key is "uriMessage" (not "message") to avoid shadowing the JavaScript + // built-in Error.message property when this map is used in QML. + {QStringLiteral("uriMessage"), r.message}, + {QStringLiteral("hasMessage"), r.has_message}, + }; +} +} // namespace + +BitcoinUriModel::BitcoinUriModel(QObject* parent) + : QObject(parent) +{ +} + +QVariantMap BitcoinUriModel::parseBitcoinUri(const QString& uri_text) +{ + return BuildBitcoinUriResultMap(BitcoinUri::Parse(uri_text)); +} + +QVariantMap BitcoinUriModel::parseBitcoinUriFromFile(const QString& source_path) +{ + constexpr qint64 MAX_FILE_SIZE = 1024 * 1024; // 1 MiB + + // Accept either a local file path or a file:// URL (e.g. from a DropArea). + // QUrl::toLocalFile() handles the platform-specific conversion correctly, + // including the extra leading slash in file:///C:/path on Windows. + QString local_path = source_path; + const QUrl url(source_path); + if (url.isLocalFile()) { + local_path = url.toLocalFile(); + } + + // File is read synchronously on the GUI thread. For local storage the + // 1 MiB guard makes this acceptable. For network-mounted paths (NFS, SMB) + // this could stall the UI — see issue #541 for the async fix using + // QtConcurrent::run. + QFile file(local_path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + BitcoinUriParseResult err; + err.error = tr("Cannot open file: %1").arg(file.errorString()); + return BuildBitcoinUriResultMap(err); + } + if (file.size() > MAX_FILE_SIZE) { + BitcoinUriParseResult err; + err.error = tr("File is too large to be a payment URI."); + return BuildBitcoinUriResultMap(err); + } + const QString content = QString::fromUtf8(file.readAll()).trimmed(); + return BuildBitcoinUriResultMap(BitcoinUri::Parse(content)); +} diff --git a/src/qml/models/bitcoinurimodel.h b/src/qml/models/bitcoinurimodel.h new file mode 100644 index 0000000000..9be8c83c61 --- /dev/null +++ b/src/qml/models/bitcoinurimodel.h @@ -0,0 +1,35 @@ +// 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. + +#ifndef BITCOIN_QML_MODELS_BITCOINURIMODEL_H +#define BITCOIN_QML_MODELS_BITCOINURIMODEL_H + +#include +#include + +/** + * QML-accessible singleton that wraps the BitcoinUri static parser. + * Registered as "BitcoinUri" in the org.bitcoincore.qt 1.0 module. + * + * URI parsing is wallet-agnostic; this class deliberately does not hold + * a wallet reference so callers do not need a loaded wallet to parse URIs. + */ +class BitcoinUriModel : public QObject +{ + Q_OBJECT +public: + explicit BitcoinUriModel(QObject* parent = nullptr); + + /** Parse a bitcoin: URI string. Returns a QVariantMap with keys: + * success, error, address, amountSats, hasAmount, + * label, hasLabel, uriMessage, hasMessage. */ + Q_INVOKABLE QVariantMap parseBitcoinUri(const QString& uri_text); + + /** Read a local file and parse its contents as a bitcoin: URI. + * Accepts plain paths and file:// URLs (QUrl::toLocalFile is used + * for platform-correct conversion). Capped at 1 MiB. */ + Q_INVOKABLE QVariantMap parseBitcoinUriFromFile(const QString& source_path); +}; + +#endif // BITCOIN_QML_MODELS_BITCOINURIMODEL_H diff --git a/src/qml/models/bumptransactionmodel.cpp b/src/qml/models/bumptransactionmodel.cpp new file mode 100644 index 0000000000..a5c18cdd70 --- /dev/null +++ b/src/qml/models/bumptransactionmodel.cpp @@ -0,0 +1,245 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include +#include +#include + +namespace { +QString FormatFee(CAmount amount) +{ + BitcoinAmount bitcoin_amount; + bitcoin_amount.setSatoshi(amount); + return bitcoin_amount.displayWithUnit(); +} +} // namespace + +BumpTransactionModel::BumpTransactionModel(interfaces::Wallet* wallet, QObject* parent) + : QObject(parent) + , m_wallet(wallet) +{ +} + +QString BumpTransactionModel::oldFee() const +{ + return FormatFee(m_old_fee); +} + +QString BumpTransactionModel::newFee() const +{ + return FormatFee(m_new_fee); +} + +QString BumpTransactionModel::feeIncrease() const +{ + return FormatFee(m_new_fee - m_old_fee); +} + +QString BumpTransactionModel::oldTxid() const +{ + return QString::fromStdString(m_original_txid.GetHex()); +} + +void BumpTransactionModel::setState(State state) +{ + if (m_state != state) { + m_state = state; + Q_EMIT stateChanged(); + } +} + +void BumpTransactionModel::setActionType(ActionType type) +{ + if (m_action_type != type) { + m_action_type = type; + Q_EMIT actionTypeChanged(); + } +} + +void BumpTransactionModel::setNeedsUnlock(bool needs_unlock) +{ + if (m_needs_unlock != needs_unlock) { + m_needs_unlock = needs_unlock; + Q_EMIT needsUnlockChanged(); + } +} + +void BumpTransactionModel::setError(const QString& error) +{ + m_error = error; + setNeedsUnlock(false); + setState(Failed); + Q_EMIT resultChanged(); +} + +void BumpTransactionModel::setUnlockRequired(const QString& error) +{ + m_error = error; + setNeedsUnlock(true); + Q_EMIT resultChanged(); +} + +void BumpTransactionModel::setSecurityStateChangedFn(std::function fn) +{ + m_security_state_changed_fn = std::move(fn); +} + +void BumpTransactionModel::prepareFeeBump(const QString& txid, unsigned int targetBlocks) +{ + if (!m_wallet) { + setError(tr("No wallet available.")); + return; + } + + auto parsed = uint256::FromHex(txid.toStdString()); + if (!parsed) { + setError(tr("Invalid transaction ID.")); + return; + } + + setActionType(SpeedUp); + setNeedsUnlock(false); + setState(Preparing); + + wallet::CCoinControl coin_control; + coin_control.m_signal_bip125_rbf = true; + coin_control.m_confirm_target = targetBlocks; + + std::vector errors; + CAmount old_fee{0}; + CAmount new_fee{0}; + CMutableTransaction mtx; + + if (!m_wallet->createBumpTransaction(Txid::FromUint256(*parsed), coin_control, errors, old_fee, new_fee, mtx)) { + setError(errors.empty() ? tr("Failed to create bump transaction.") : QString::fromStdString(errors[0].translated)); + return; + } + + m_original_txid = Txid::FromUint256(*parsed); + m_old_fee = old_fee; + m_new_fee = new_fee; + m_bump_mtx = std::move(mtx); + + setState(NeedsConfirmation); + Q_EMIT resultChanged(); +} + +bool BumpTransactionModel::confirmFeeBump() +{ + return confirmFeeBumpInternal(std::nullopt); +} + +bool BumpTransactionModel::confirmFeeBumpWithPassphrase(const QString& passphrase) +{ + return confirmFeeBumpInternal(std::optional{QmlUtil::SecureStringFromQString(passphrase)}); +} + +bool BumpTransactionModel::confirmFeeBumpInternal(std::optional passphrase) +{ + if (!m_wallet || m_state != NeedsConfirmation) { + if (passphrase.has_value()) { + QmlUtil::ClearSecureString(*passphrase); + passphrase.reset(); + } + return false; + } + + if (!m_wallet->privateKeysDisabled() && m_wallet->isCrypted() && m_wallet->isLocked() && !passphrase.has_value()) { + setUnlockRequired(tr("Enter your wallet password to update this transaction.")); + return false; + } + + bool relock{false}; + if (!unlockForCommit(passphrase, relock)) { + return false; + } + WalletRelockGuard relock_guard{*m_wallet, [this] { + if (m_security_state_changed_fn) { + m_security_state_changed_fn(); + } + }, relock}; + + setNeedsUnlock(false); + setState(Committing); + + if (!m_wallet->transactionCanBeBumped(m_original_txid)) { + relock_guard.relock(); + setError(tr("Transaction can no longer be bumped.")); + return false; + } + + if (!m_wallet->signBumpTransaction(m_bump_mtx)) { + relock_guard.relock(); + setError(tr("Failed to sign transaction.")); + return false; + } + + std::vector errors; + Txid bumped_txid; + if (!m_wallet->commitBumpTransaction(m_original_txid, std::move(m_bump_mtx), errors, bumped_txid)) { + relock_guard.relock(); + setError(errors.empty() ? tr("Failed to commit transaction.") : QString::fromStdString(errors[0].translated)); + return false; + } + + m_new_txid = QString::fromStdString(bumped_txid.GetHex()); + relock_guard.relock(); + setState(Succeeded); + Q_EMIT resultChanged(); + return true; +} + +bool BumpTransactionModel::unlockForCommit(std::optional& passphrase, bool& relock) +{ + relock = false; + if (!m_wallet) { + if (passphrase.has_value()) { + QmlUtil::ClearSecureString(*passphrase); + passphrase.reset(); + } + return false; + } + if (!passphrase.has_value()) { + return true; + } + + const auto result{TryUnlockWithPassphrase(*m_wallet, *passphrase)}; + passphrase.reset(); + switch (result) { + case WalletUnlockResult::IncorrectPassphrase: + setUnlockRequired(tr("The wallet password you entered was incorrect.")); + return false; + case WalletUnlockResult::AlreadyUnlocked: + return true; + case WalletUnlockResult::UnlockedNowRelockRequired: + relock = true; + if (m_security_state_changed_fn) { + m_security_state_changed_fn(); + } + return true; + } + return false; +} + +void BumpTransactionModel::reset() +{ + m_state = Idle; + m_action_type = SpeedUp; + m_old_fee = 0; + m_new_fee = 0; + m_bump_mtx = CMutableTransaction{}; + m_original_txid = Txid{}; + m_new_txid.clear(); + m_error.clear(); + m_needs_unlock = false; + + Q_EMIT stateChanged(); + Q_EMIT actionTypeChanged(); + Q_EMIT resultChanged(); + Q_EMIT needsUnlockChanged(); +} diff --git a/src/qml/models/bumptransactionmodel.h b/src/qml/models/bumptransactionmodel.h new file mode 100644 index 0000000000..cb6e36a1aa --- /dev/null +++ b/src/qml/models/bumptransactionmodel.h @@ -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. + +#ifndef BITCOIN_QML_MODELS_BUMPTRANSACTIONMODEL_H +#define BITCOIN_QML_MODELS_BUMPTRANSACTIONMODEL_H + +#include +#include +#include +#include + +#include +#include +#include + +#include +#include + +namespace interfaces { +class Wallet; +} // namespace interfaces + +struct bilingual_str; + +class BumpTransactionModel : public QObject +{ + Q_OBJECT + +public: + enum State { Idle, Preparing, NeedsConfirmation, Committing, Succeeded, Failed }; + Q_ENUM(State) + + enum ActionType { SpeedUp /*, Cancel */ }; + Q_ENUM(ActionType) + + Q_PROPERTY(State state READ state NOTIFY stateChanged) + Q_PROPERTY(ActionType actionType READ actionType NOTIFY actionTypeChanged) + Q_PROPERTY(QString oldFee READ oldFee NOTIFY resultChanged) + Q_PROPERTY(QString newFee READ newFee NOTIFY resultChanged) + Q_PROPERTY(QString feeIncrease READ feeIncrease NOTIFY resultChanged) + Q_PROPERTY(QString oldTxid READ oldTxid NOTIFY resultChanged) + Q_PROPERTY(QString newTxid READ newTxid NOTIFY resultChanged) + Q_PROPERTY(QString errorText READ errorText NOTIFY resultChanged) + Q_PROPERTY(bool needsUnlock READ needsUnlock NOTIFY needsUnlockChanged) + + explicit BumpTransactionModel(interfaces::Wallet* wallet, QObject* parent = nullptr); + + State state() const { return m_state; } + ActionType actionType() const { return m_action_type; } + QString oldFee() const; + QString newFee() const; + QString feeIncrease() const; + QString oldTxid() const; + QString newTxid() const { return m_new_txid; } + QString errorText() const { return m_error; } + bool needsUnlock() const { return m_needs_unlock; } + + Q_INVOKABLE void prepareFeeBump(const QString& txid, unsigned int targetBlocks); + Q_INVOKABLE bool confirmFeeBump(); + Q_INVOKABLE bool confirmFeeBumpWithPassphrase(const QString& passphrase); + Q_INVOKABLE void reset(); + void setSecurityStateChangedFn(std::function fn); + +Q_SIGNALS: + void stateChanged(); + void actionTypeChanged(); + void resultChanged(); + void needsUnlockChanged(); + +private: + void setState(State state); + void setActionType(ActionType type); + void setError(const QString& error); + void setNeedsUnlock(bool needs_unlock); + void setUnlockRequired(const QString& error); + bool confirmFeeBumpInternal(std::optional passphrase); + bool unlockForCommit(std::optional& passphrase, bool& relock); + + interfaces::Wallet* m_wallet{nullptr}; + std::function m_security_state_changed_fn; + State m_state{Idle}; + ActionType m_action_type{SpeedUp}; + CAmount m_old_fee{0}; + CAmount m_new_fee{0}; + CMutableTransaction m_bump_mtx; + Txid m_original_txid; + QString m_new_txid; + QString m_error; + bool m_needs_unlock{false}; +}; + +#endif // BITCOIN_QML_MODELS_BUMPTRANSACTIONMODEL_H diff --git a/src/qml/models/chainmodel.cpp b/src/qml/models/chainmodel.cpp new file mode 100644 index 0000000000..ce3a6b12ae --- /dev/null +++ b/src/qml/models/chainmodel.cpp @@ -0,0 +1,108 @@ +// Copyright (c) 2022 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include +#include +#include + +using interfaces::FoundBlock; + +ChainModel::ChainModel(interfaces::Chain& chain) + : m_chain{chain} +{ + QTimer* timer = new QTimer(); + connect(timer, &QTimer::timeout, this, &ChainModel::setCurrentTimeRatio); + timer->start(1000); + + QThread* timer_thread = new QThread; + timer->moveToThread(timer_thread); + timer_thread->start(); +} + +void ChainModel::setCurrentNetworkName(QString network_name) +{ + m_current_network_name = network_name.toUpper(); + Q_EMIT currentNetworkNameChanged(); +} + +void ChainModel::setTimeRatioList(int new_time) +{ + if (m_time_ratio_list.isEmpty()) { + setTimeRatioListInitial(); + } + int time_at_meridian = timestampAtMeridian(); + + if (new_time < time_at_meridian) { + return; + } + m_time_ratio_list.push_back(double(new_time - time_at_meridian) / SECS_IN_12_HOURS); + + Q_EMIT timeRatioListChanged(); +} + +int ChainModel::timestampAtMeridian() +{ + int secs_since_meridian = (QTime::currentTime().msecsSinceStartOfDay() / 1000) % SECS_IN_12_HOURS; + int current_timestamp = QDateTime::currentSecsSinceEpoch(); + + return current_timestamp - secs_since_meridian; +} + +void ChainModel::setTimeRatioListInitial() +{ + int time_at_meridian = timestampAtMeridian(); + m_time_ratio_list.clear(); + /* m_time_ratio_list[0] = current_time_ratio + * m_time_ratio_list[1] = 0 + * These two positions remain fixed for these + * values in m_time_ratio_list */ + m_time_ratio_list.push_back(double(QDateTime::currentSecsSinceEpoch() - time_at_meridian) / SECS_IN_12_HOURS); + m_time_ratio_list.push_back(0); + + if (!m_chain.getHeight()) { + Q_EMIT timeRatioListChanged(); + return; + } + + int first_block_height; + int active_chain_height = m_chain.getHeight().value(); + bool success = m_chain.findFirstBlockWithTimeAndHeight(/*min_time=*/time_at_meridian, /*min_height=*/0, interfaces::FoundBlock().height(first_block_height)); + + if (!success) { + Q_EMIT timeRatioListChanged(); + return; + } + + for (int height = first_block_height; height < active_chain_height + 1; height++) { + uint256 block_hash{m_chain.getBlockHash(height)}; + int64_t block_time; + m_chain.findBlock(block_hash, FoundBlock().time(block_time)); + m_time_ratio_list.push_back(double(block_time - time_at_meridian) / SECS_IN_12_HOURS); + } + + Q_EMIT timeRatioListChanged(); +} + +void ChainModel::setCurrentTimeRatio() +{ + int secs_since_meridian = (QTime::currentTime().msecsSinceStartOfDay() / 1000) % SECS_IN_12_HOURS; + double current_time_ratio = double(secs_since_meridian) / SECS_IN_12_HOURS; + + if (current_time_ratio < m_time_ratio_list[0].toDouble()) { // That means time has crossed a meridian + m_time_ratio_list.clear(); + } + + if (m_time_ratio_list.isEmpty()) { + m_time_ratio_list.push_back(current_time_ratio); + m_time_ratio_list.push_back(0); + } else { + m_time_ratio_list[0] = current_time_ratio; + } + + Q_EMIT timeRatioListChanged(); +} diff --git a/src/qml/models/chainmodel.h b/src/qml/models/chainmodel.h new file mode 100644 index 0000000000..9318510eda --- /dev/null +++ b/src/qml/models/chainmodel.h @@ -0,0 +1,67 @@ +// Copyright (c) 2022 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QML_MODELS_CHAINMODEL_H +#define BITCOIN_QML_MODELS_CHAINMODEL_H + +#include +#include + +#include +#include +#include +#include + +namespace interfaces { +class FoundBlock; +class Chain; +} // namespace interfaces + +static const int SECS_IN_12_HOURS = 43200; + +class ChainModel : public QObject +{ + Q_OBJECT + Q_PROPERTY(QString currentNetworkName READ currentNetworkName WRITE setCurrentNetworkName NOTIFY currentNetworkNameChanged) + Q_PROPERTY(quint64 assumedBlockchainSize READ assumedBlockchainSize CONSTANT) + Q_PROPERTY(quint64 assumedChainstateSize READ assumedChainstateSize CONSTANT) + Q_PROPERTY(QVariantList timeRatioList READ timeRatioList NOTIFY timeRatioListChanged) + +public: + explicit ChainModel(interfaces::Chain& chain); + + QString currentNetworkName() const { return m_current_network_name; }; + void setCurrentNetworkName(QString network_name); + quint64 assumedBlockchainSize() const { return m_assumed_blockchain_size; }; + quint64 assumedChainstateSize() const { return m_assumed_chainstate_size; }; + QVariantList timeRatioList() const { return m_time_ratio_list; }; + + int timestampAtMeridian(); + + void setCurrentTimeRatio(); + +public Q_SLOTS: + void setTimeRatioList(int new_time); + void setTimeRatioListInitial(); + +Q_SIGNALS: + void timeRatioListChanged(); + void currentNetworkNameChanged(); + +private: + QString m_current_network_name; + quint64 m_assumed_blockchain_size{ Params().AssumedBlockchainSize() }; + quint64 m_assumed_chainstate_size{ Params().AssumedChainStateSize() }; + /* time_ratio: Ratio between the time at which an event + * happened and 12 hours. So, for example, if a block is + * found at 4 am or pm, the time_ratio would be 0.3. + * The m_time_ratio_list stores the time ratio value for + * the current_time and the time at which the blocks in + * the last 12 hours were mined. */ + QVariantList m_time_ratio_list{0.0}; + + interfaces::Chain& m_chain; +}; + +#endif // BITCOIN_QML_MODELS_CHAINMODEL_H diff --git a/src/qml/models/coinslistmodel.cpp b/src/qml/models/coinslistmodel.cpp new file mode 100644 index 0000000000..7f3d9e1769 --- /dev/null +++ b/src/qml/models/coinslistmodel.cpp @@ -0,0 +1,155 @@ +// Copyright (c) 2025 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include +#include +#include +#include +#include + +CoinsListModel::CoinsListModel(WalletQmlModel* parent) + : QAbstractListModel(parent), m_wallet_model(parent), m_sort_by("amount"), m_total_amount(0) +{ + update(); +} + +CoinsListModel::~CoinsListModel() = default; + +int CoinsListModel::rowCount(const QModelIndex& parent) const +{ + return m_coins.size(); +} + +QVariant CoinsListModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= static_cast(m_coins.size())) + return QVariant(); + + const auto& [destination, outpoint, coin] = m_coins.at(index.row()); + switch (role) { + case AddressRole: + return QString::fromStdString(EncodeDestination(destination)); + case AmountRole: + return QmlBitcoinUnits::format(QmlBitcoinUnits::Unit::BTC, coin.txout.nValue); + case LabelRole: + return QString::fromStdString(""); + case LockedRole: + return m_wallet_model->isLockedCoin(outpoint); + case SelectedRole: + return m_wallet_model->isSelectedCoin(outpoint); + default: + return QVariant(); + } +} + +QHash CoinsListModel::roleNames() const +{ + QHash roles; + roles[AmountRole] = "amount"; + roles[AddressRole] = "address"; + roles[DateTimeRole] = "date"; + roles[LabelRole] = "label"; + roles[LockedRole] = "locked"; + roles[SelectedRole] = "selected"; + return roles; +} + +void CoinsListModel::update() +{ + if (m_wallet_model == nullptr) { + return; + } + auto coins_map = m_wallet_model->listCoins(); + beginResetModel(); + m_coins.clear(); + for (const auto& [destination, vec] : coins_map) { + for (const auto& [outpoint, tx_out] : vec) { + auto tuple = std::make_tuple(destination, outpoint, tx_out); + m_coins.push_back(tuple); + } + } + endResetModel(); + Q_EMIT coinCountChanged(); +} + +void CoinsListModel::setSortBy(const QString& roleName) +{ + if (m_sort_by != roleName) { + m_sort_by = roleName; + // sort(RoleNameToIndex(roleName)); + Q_EMIT sortByChanged(roleName); + } +} + +void CoinsListModel::toggleCoinSelection(const int index) +{ + if (index < 0 || index >= static_cast(m_coins.size())) { + return; + } + const auto& [destination, outpoint, coin] = m_coins.at(index); + + if (m_wallet_model->isSelectedCoin(outpoint)) { + m_wallet_model->unselectCoin(outpoint); + m_total_amount -= coin.txout.nValue; + } else { + m_wallet_model->selectCoin(outpoint); + m_total_amount += coin.txout.nValue; + } + const QModelIndex model_index = this->index(index, 0); + Q_EMIT dataChanged(model_index, model_index, {SelectedRole}); + Q_EMIT selectedCoinsCountChanged(); +} + +void CoinsListModel::refreshSelection() +{ + CAmount total_amount{0}; + for (const auto& [destination, outpoint, coin] : m_coins) { + if (m_wallet_model->isSelectedCoin(outpoint)) { + total_amount += coin.txout.nValue; + } + } + m_total_amount = total_amount; + if (!m_coins.empty()) { + Q_EMIT dataChanged(index(0, 0), index(static_cast(m_coins.size()) - 1, 0), {SelectedRole}); + } + Q_EMIT selectedCoinsCountChanged(); +} + +unsigned int CoinsListModel::lockedCoinsCount() const +{ + std::vector lockedCoins; + m_wallet_model->listLockedCoins(lockedCoins); + return lockedCoins.size(); +} + +unsigned int CoinsListModel::selectedCoinsCount() const +{ + return m_wallet_model->listSelectedCoins().size(); +} + +QString CoinsListModel::totalSelected() const +{ + return QmlBitcoinUnits::format(QmlBitcoinUnits::Unit::BTC, m_total_amount); +} + +QString CoinsListModel::changeAmount() const +{ + CAmount change = m_total_amount - m_wallet_model->sendRecipientList()->totalAmountSatoshi(); + change = std::abs(change); + return QmlBitcoinUnits::format(QmlBitcoinUnits::Unit::BTC, change); +} + +bool CoinsListModel::overRequiredAmount() const +{ + return m_total_amount > m_wallet_model->sendRecipientList()->totalAmountSatoshi(); +} + +int CoinsListModel::coinCount() const +{ + return m_coins.size(); +} diff --git a/src/qml/models/coinslistmodel.h b/src/qml/models/coinslistmodel.h new file mode 100644 index 0000000000..2fa2829b2b --- /dev/null +++ b/src/qml/models/coinslistmodel.h @@ -0,0 +1,71 @@ +// Copyright (c) 2025 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QML_MODELS_COINSLISTMODEL_H +#define BITCOIN_QML_MODELS_COINSLISTMODEL_H + +#include +#include +#include +#include + +#include +#include + +class WalletQmlModel; + +class CoinsListModel : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY(int lockedCoinsCount READ lockedCoinsCount NOTIFY lockedCoinsCountChanged) + Q_PROPERTY(int selectedCoinsCount READ selectedCoinsCount NOTIFY selectedCoinsCountChanged) + Q_PROPERTY(int coinCount READ coinCount NOTIFY coinCountChanged) + Q_PROPERTY(QString totalSelected READ totalSelected NOTIFY selectedCoinsCountChanged) + Q_PROPERTY(QString changeAmount READ changeAmount NOTIFY selectedCoinsCountChanged) + Q_PROPERTY(bool overRequiredAmount READ overRequiredAmount NOTIFY selectedCoinsCountChanged) + +public: + explicit CoinsListModel(WalletQmlModel* parent = nullptr); + ~CoinsListModel(); + + enum CoinsRoles { + AddressRole = Qt::UserRole + 1, + AmountRole, + DateTimeRole, + LabelRole, + LockedRole, + SelectedRole + }; + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + +public Q_SLOTS: + void update(); + void refreshSelection(); + void setSortBy(const QString& roleName); + void toggleCoinSelection(const int index); + unsigned int lockedCoinsCount() const; + unsigned int selectedCoinsCount() const; + QString totalSelected() const; + QString changeAmount() const; + bool overRequiredAmount() const; + int coinCount() const; + +Q_SIGNALS: + void sortByChanged(const QString& roleName); + void lockedCoinsCountChanged(); + void selectedCoinsCountChanged(); + void coinCountChanged(); + +private: + WalletQmlModel* m_wallet_model; + std::unique_ptr m_handler_transaction_changed; + std::vector> m_coins; + QString m_sort_by; + CAmount m_total_amount; +}; + +#endif // BITCOIN_QML_MODELS_COINSLISTMODEL_H diff --git a/src/qml/models/debuglogmodel.cpp b/src/qml/models/debuglogmodel.cpp new file mode 100644 index 0000000000..44856167e6 --- /dev/null +++ b/src/qml/models/debuglogmodel.cpp @@ -0,0 +1,395 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +static const QRegularExpression TIMESTAMP_RX( + QStringLiteral(R"(^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)\s*(.*)$)")); + +DebugLogModel::DebugLogModel(const fs::path& log_path, QObject* parent) + : QAbstractListModel(parent) + , m_log_path(log_path) +{ + m_debounce.setSingleShot(true); + m_debounce.setInterval(500); + connect(&m_debounce, &QTimer::timeout, this, [this]() { refresh(); }); + + connectFileWatcher(); +} + +int DebugLogModel::rowCount(const QModelIndex& parent) const +{ + if (parent.isValid()) return 0; + return m_display_lines.size(); +} + +QVariant DebugLogModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_display_lines.size()) + return {}; + + const LogLine& line = m_display_lines.at(index.row()); + switch (role) { + case LineNumberRole: return line.lineNumber; + case ContentRole: return line.content; + case RelativeTimeRole: return line.relativeTime; + } + return {}; +} + +QHash DebugLogModel::roleNames() const +{ + return { + {LineNumberRole, "lineNumber"}, + {ContentRole, "content"}, + {RelativeTimeRole, "relativeTime"}, + }; +} + +void DebugLogModel::setLoadLimit(int limit) +{ + if (m_load_limit == limit) return; + m_load_limit = limit; + Q_EMIT loadLimitChanged(); +} + +void DebugLogModel::setFilter(const QString& filter) +{ + if (m_filter == filter) return; + m_filter = filter; + Q_EMIT filterChanged(); + buildDisplayLines(); +} + +void DebugLogModel::refresh(bool full_load) +{ + // Single-read-in-flight guard. If a read is already running, fold this + // request into a trailing re-run rather than piling another job onto the + // thread pool. A burst of watcher events on a noisy node therefore + // collapses to at most two reads: the one in flight, plus one trailer + // that sees the final file state. + if (m_read_in_flight) { + m_refresh_pending = true; + // Upgrade a pending partial refresh to a full load if any caller + // requested full; otherwise leave the flag alone. + if (full_load) m_pending_full_load = true; + return; + } + m_read_in_flight = true; + + const QString prev_top_content = m_all_lines.isEmpty() + ? QString{} + : m_all_lines.first().content; + + const fs::path path = m_log_path; + const int load_limit = m_load_limit; + + auto* watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, this, + [this, watcher, prev_top_content, full_load]() { + onReadCompleted(watcher->result(), prev_top_content, full_load); + watcher->deleteLater(); + }); + watcher->setFuture(QtConcurrent::run(&DebugLogModel::ReadAndFilter, + path, load_limit, full_load)); +} + +void DebugLogModel::loadMore() +{ + if (m_load_limit >= kMaxLoadLimit) { + if (m_has_more_lines) { + m_has_more_lines = false; + Q_EMIT hasMoreLinesChanged(); + } + return; + } + m_load_limit = std::min(m_load_limit + 1000, kMaxLoadLimit); + Q_EMIT loadLimitChanged(); + refresh(true); +} + +bool DebugLogModel::openLogFile() +{ + const QString path_str = QString::fromStdString(m_log_path.utf8string()); + if (!fs::exists(m_log_path)) { + m_open_error = tr("Debug log file not found: %1").arg(path_str); + Q_EMIT openErrorChanged(); + return false; + } + const bool ok = QDesktopServices::openUrl(QUrl::fromLocalFile(path_str)); + if (!ok) { + m_open_error = tr("Could not open debug log file. " + "No application is associated with this file type."); + Q_EMIT openErrorChanged(); + return false; + } + if (!m_open_error.isEmpty()) { + m_open_error.clear(); + Q_EMIT openErrorChanged(); + } + return true; +} + +void DebugLogModel::updateRelativeTimes() +{ + if (m_display_lines.isEmpty()) return; + const qint64 now_ms = QDateTime::currentMSecsSinceEpoch(); + + // Update cached relative time strings in all_lines. + for (LogLine& line : m_all_lines) { + if (line.timestamp_ms >= 0) + line.relativeTime = RelativeTimeLabelStatic(line.timestamp_ms, now_ms); + } + + // Rebuild display so delegates see the new strings. + buildDisplayLines(); +} + +// ── Private ────────────────────────────────────────────────────────────────── + +DebugLogModel::ReadResult DebugLogModel::ReadAndFilter(const fs::path& log_path, + int load_limit, + bool full_load) +{ + ReadResult result; + const QString path_str = QString::fromStdString(log_path.utf8string()); + QFile probe(path_str); + if (!probe.open(QIODevice::ReadOnly | QIODevice::Text)) { + result.error_message = fs::exists(log_path) + ? QObject::tr("Could not open debug log file: %1").arg(path_str) + : QObject::tr("Debug log file not found: %1").arg(path_str); + return result; + } + probe.close(); + result.file_opened = true; + + // Doubling loop for a full load: ensure load_limit non-blank lines even + // when the tail of the file is dominated by header/banner blanks. For an + // incremental refresh we just take the last load_limit raw lines. + QList filtered; + int fetch_size = load_limit; + while (true) { + const QList raw = ReadRawLines(log_path, fetch_size); + filtered.clear(); + for (const LogLine& l : raw) { + if (!l.content.trimmed().isEmpty() || l.timestamp_ms >= 0) + filtered.append(l); + } + if (!full_load) break; + if (filtered.size() >= load_limit || raw.size() < fetch_size) break; + fetch_size *= 2; + } + result.filtered = std::move(filtered); + return result; +} + +QList DebugLogModel::ReadRawLines(const fs::path& log_path, + int max_lines) +{ + const QString path_str = QString::fromStdString(log_path.utf8string()); + QFile file(path_str); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) return {}; + + // Seek near the end to avoid reading the entire file. debug.log on an + // active mainnet node can exceed 100 MB; estimate 500 bytes per line. + const qint64 seek_pos = std::max(qint64{0}, + file.size() - static_cast(max_lines) * 500); + if (seek_pos > 0) file.seek(seek_pos); + + QTextStream in(&file); + if (seek_pos > 0) in.readLine(); // discard potentially partial first line + + QStringList raw; + raw.reserve(max_lines); + while (!in.atEnd()) raw.append(in.readLine()); + if (raw.size() > max_lines) + raw = raw.mid(raw.size() - max_lines); + + const qint64 now_ms = QDateTime::currentMSecsSinceEpoch(); + QList result; + result.reserve(raw.size()); + for (const QString& line : raw) { + LogLine entry; + const QRegularExpressionMatch m = TIMESTAMP_RX.match(line); + if (m.hasMatch()) { + const QDateTime dt = QDateTime::fromString(m.captured(1), Qt::ISODateWithMs); + entry.timestamp_ms = dt.isValid() ? dt.toMSecsSinceEpoch() : -1; + entry.content = m.captured(2).toHtmlEscaped(); + } else { + entry.timestamp_ms = -1; + entry.content = line.toHtmlEscaped(); + } + entry.relativeTime = entry.timestamp_ms >= 0 + ? RelativeTimeLabelStatic(entry.timestamp_ms, now_ms) + : QString{}; + result.append(entry); + } + return result; +} + +void DebugLogModel::onReadCompleted(const ReadResult& result, + const QString& prev_top_content, + bool full_load) +{ + // Propagate open-error state from the background read. + if (!result.file_opened) { + if (m_open_error != result.error_message) { + m_open_error = result.error_message; + Q_EMIT openErrorChanged(); + } + m_read_in_flight = false; + // Trailing refresh re-arm still honoured so a recovered file reloads. + if (m_refresh_pending) { + m_refresh_pending = false; + const bool do_full = m_pending_full_load; + m_pending_full_load = false; + refresh(do_full); + } + return; + } + if (!m_open_error.isEmpty()) { + m_open_error.clear(); + Q_EMIT openErrorChanged(); + } + // The watcher may have failed to register the path at construction time + // if the log file did not exist yet. Re-add after a successful read so + // auto-refresh works from here on. + const QString path_str = QString::fromStdString(m_log_path.utf8string()); + if (!m_watcher.files().contains(path_str)) { + m_watcher.addPath(path_str); + } + + const QList& filtered = result.filtered; + + if (full_load || m_all_lines.isEmpty()) { + const bool new_has_more = filtered.size() > m_load_limit + || m_load_limit >= kMaxLoadLimit; + const int start = filtered.size() > m_load_limit + ? filtered.size() - m_load_limit : 0; + + QList new_lines; + new_lines.reserve(std::min(m_load_limit, filtered.size())); + for (int i = filtered.size() - 1; i >= start; i--) + new_lines.append(filtered[i]); + + m_all_lines = std::move(new_lines); + + // At the hard ceiling, stop advertising "has more". + const bool announced_has_more = new_has_more && m_load_limit < kMaxLoadLimit; + if (m_has_more_lines != announced_has_more) { + m_has_more_lines = announced_has_more; + Q_EMIT hasMoreLinesChanged(); + } + } else { + // Incremental refresh: prepend lines newer than the previous top entry. + if (!prev_top_content.isEmpty()) { + QList new_entries; + bool found_prev = false; + for (int j = filtered.size() - 1; j >= 0; j--) { + if (filtered[j].content == prev_top_content) { + found_prev = true; + break; + } + new_entries.append(filtered[j]); + } + if (found_prev && !new_entries.isEmpty()) { + m_all_lines = new_entries + m_all_lines; + } else if (!found_prev) { + // Log rotated or missed window — full reset from the fresh read. + const bool new_has_more = filtered.size() > m_load_limit; + const int start = new_has_more ? filtered.size() - m_load_limit : 0; + m_all_lines.clear(); + m_all_lines.reserve(m_load_limit); + for (int i = filtered.size() - 1; i >= start; i--) + m_all_lines.append(filtered[i]); + const bool announced_has_more = new_has_more && m_load_limit < kMaxLoadLimit; + if (m_has_more_lines != announced_has_more) { + m_has_more_lines = announced_has_more; + Q_EMIT hasMoreLinesChanged(); + } + } + } + } + + // Emit newLinesAdded for the "new entries" pill. + if (!prev_top_content.isEmpty()) { + for (int k = 0; k < m_all_lines.size(); k++) { + if (m_all_lines[k].content == prev_top_content) { + if (k > 0) Q_EMIT newLinesAdded(k); + break; + } + } + } + + buildDisplayLines(); + + m_read_in_flight = false; + // If changes arrived while we were reading, run one trailing refresh. + if (m_refresh_pending) { + m_refresh_pending = false; + const bool do_full = m_pending_full_load; + m_pending_full_load = false; + refresh(do_full); + } +} + +void DebugLogModel::connectFileWatcher() +{ + const QString path_str = QString::fromStdString(m_log_path.utf8string()); + if (path_str.isEmpty()) return; + m_watcher.addPath(path_str); + connect(&m_watcher, &QFileSystemWatcher::fileChanged, + this, [this](const QString& path) { + m_watcher.addPath(path); // re-add in case of log rotation + m_debounce.start(); + }); +} + +void DebugLogModel::buildDisplayLines() +{ + // Apply search filter. + QList filtered; + if (m_filter.isEmpty()) { + filtered = m_all_lines; + } else { + const QString f = m_filter.toLower(); + for (const LogLine& line : m_all_lines) { + if (line.content.toLower().contains(f)) + filtered.append(line); + } + } + + // Assign display line numbers (1-based, in display order = newest-first). + for (int i = 0; i < filtered.size(); i++) + filtered[i].lineNumber = QString::number(i + 1); + + beginResetModel(); + m_display_lines = filtered; + endResetModel(); +} + +QString DebugLogModel::relativeTimeLabel(qint64 timestamp_ms, qint64 now_ms) const +{ + return RelativeTimeLabelStatic(timestamp_ms, now_ms); +} + +QString DebugLogModel::RelativeTimeLabelStatic(qint64 timestamp_ms, qint64 now_ms) +{ + const qint64 diff = (now_ms - timestamp_ms) / 1000; + if (diff < 60) return QObject::tr("just now"); + if (diff < 3600) return QObject::tr("%1 min ago").arg(diff / 60); + if (diff < 86400) return QObject::tr("%1 hr ago").arg(diff / 3600); + return QObject::tr("%1 d ago").arg(diff / 86400); +} diff --git a/src/qml/models/debuglogmodel.h b/src/qml/models/debuglogmodel.h new file mode 100644 index 0000000000..364ff9d0b1 --- /dev/null +++ b/src/qml/models/debuglogmodel.h @@ -0,0 +1,143 @@ +// 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. + +#ifndef BITCOIN_QML_MODELS_DEBUGLOGMODEL_H +#define BITCOIN_QML_MODELS_DEBUGLOGMODEL_H + +#include + +#include +#include +#include +#include +#include + +//! List model for the in-app debug.log viewer. +//! +//! Exposes log lines as list items with three roles: +//! - LineNumberRole — 1-based line number as a display string ("1", "2", …) +//! - ContentRole — HTML-escaped message text (no inline style; colours +//! are applied by the QML delegate) +//! - RelativeTimeRole — human-readable age string ("just now", "3 min ago", +//! …) updated by updateRelativeTimes() +//! +//! Pagination: only the most recent `loadLimit` lines are kept in memory. +//! Call loadMore() to increase the limit by 1000, up to kMaxLoadLimit. +//! +//! File watching: the model connects a QFileSystemWatcher to the log file and +//! coalesces rapid writes with a 500 ms debounce timer before calling refresh(). +//! Reads themselves run on the Qt thread pool so a busy, noisy node cannot +//! stall the UI on every debug-log burst. +class DebugLogModel : public QAbstractListModel +{ + Q_OBJECT + + Q_PROPERTY(bool hasMoreLines READ hasMoreLines NOTIFY hasMoreLinesChanged) + Q_PROPERTY(int loadLimit READ loadLimit WRITE setLoadLimit NOTIFY loadLimitChanged) + Q_PROPERTY(QString filter READ filter WRITE setFilter NOTIFY filterChanged) + Q_PROPERTY(QString openError READ openError NOTIFY openErrorChanged) + +public: + enum Role { + LineNumberRole = Qt::UserRole + 1, + ContentRole, + RelativeTimeRole, + }; + Q_ENUM(Role) + + //! Hard ceiling on loadLimit to protect against unbounded memory growth. + static constexpr int kMaxLoadLimit = 50'000; + + explicit DebugLogModel(const fs::path& log_path, QObject* parent = nullptr); + + // QAbstractListModel interface + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + bool hasMoreLines() const { return m_has_more_lines; } + + int loadLimit() const { return m_load_limit; } + void setLoadLimit(int limit); + + QString filter() const { return m_filter; } + void setFilter(const QString& filter); + + QString openError() const { return m_open_error; } + + Q_INVOKABLE void refresh(bool full_load = false); + Q_INVOKABLE void loadMore(); + Q_INVOKABLE bool openLogFile(); + Q_INVOKABLE void updateRelativeTimes(); + +Q_SIGNALS: + void hasMoreLinesChanged(); + void loadLimitChanged(); + void filterChanged(); + void openErrorChanged(); + //! Emitted when new lines are prepended at the top during an auto-refresh. + void newLinesAdded(int count); + +private: + struct LogLine { + QString lineNumber; + QString content; // HTML-escaped message text + qint64 timestamp_ms; // epoch ms, -1 if not parseable + QString relativeTime; // cached human-readable age + }; + + //! Result of a file read performed off the GUI thread. The worker must + //! not touch any QObject state on the model — everything it discovers + //! (including open errors) is returned here for the main thread to apply. + struct ReadResult { + bool file_opened{false}; + QString error_message; + QList filtered; // oldest-first, already filtered of blank lines + }; + + //! File-reading worker. Pure function — no QObject / signal access — + //! so it can safely run on the thread pool. + static ReadResult ReadAndFilter(const fs::path& log_path, + int load_limit, + bool full_load); + + //! Raw file read (one pass). Called from ReadAndFilter. + static QList ReadRawLines(const fs::path& log_path, int max_lines); + + //! Completion handler invoked on the GUI thread after ReadAndFilter + //! returns. Applies the result to m_all_lines and rebuilds the display. + void onReadCompleted(const ReadResult& result, + const QString& prev_top_content, + bool full_load); + + void connectFileWatcher(); + void buildDisplayLines(); + QString relativeTimeLabel(qint64 timestamp_ms, qint64 now_ms) const; + + static QString RelativeTimeLabelStatic(qint64 timestamp_ms, qint64 now_ms); + + fs::path m_log_path; + + //! All loaded lines stored newest-first (index 0 = newest). + QList m_all_lines; + + //! Filtered subset of m_all_lines, also newest-first. + QList m_display_lines; + + QString m_filter; + int m_load_limit{1000}; + bool m_has_more_lines{false}; + QString m_open_error; + + QFileSystemWatcher m_watcher; + QTimer m_debounce; + + //! Single-read-in-flight guard so concurrent refresh() calls fold into + //! one trailing read instead of piling up on the thread pool. + bool m_read_in_flight{false}; + bool m_refresh_pending{false}; + bool m_pending_full_load{false}; +}; + +#endif // BITCOIN_QML_MODELS_DEBUGLOGMODEL_H diff --git a/src/qml/models/networkstatusmodel.cpp b/src/qml/models/networkstatusmodel.cpp new file mode 100644 index 0000000000..11f09f8843 --- /dev/null +++ b/src/qml/models/networkstatusmodel.cpp @@ -0,0 +1,72 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include + +NetworkStatusModel::NetworkStatusModel(QObject* parent) + : NetworkStatusModel{true, parent} +{ +} + +NetworkStatusModel::NetworkStatusModel(bool monitor, QObject* parent) + : QObject{parent}, + m_monitor{monitor} +{ + if (m_monitor) { + initializeMonitoring(); + } +} + +QString NetworkStatusModel::reachability() const +{ + switch (m_reachability) { + case QNetworkInformation::Reachability::Disconnected: + return QStringLiteral("Disconnected"); + case QNetworkInformation::Reachability::Local: + return QStringLiteral("Local"); + case QNetworkInformation::Reachability::Site: + return QStringLiteral("Site"); + case QNetworkInformation::Reachability::Online: + return QStringLiteral("Online"); + case QNetworkInformation::Reachability::Unknown: + return QStringLiteral("Unknown"); + } + return QStringLiteral("Unknown"); +} + +void NetworkStatusModel::setReachability(QNetworkInformation::Reachability reachability) +{ + const bool old_available{m_reachability_available}; + const bool old_offline{networkOffline()}; + const QString old_name{this->reachability()}; + + m_reachability_available = true; + m_reachability = reachability; + + if (old_available != m_reachability_available || old_offline != networkOffline() || old_name != this->reachability()) { + Q_EMIT reachabilityChanged(); + } +} + +void NetworkStatusModel::initializeMonitoring() +{ + if (!QNetworkInformation::instance()) { + QNetworkInformation::loadBackendByFeatures(QNetworkInformation::Feature::Reachability); + } + + QNetworkInformation* info{QNetworkInformation::instance()}; + if (!info) { + return; + } + + m_reachability_available = info->supports(QNetworkInformation::Feature::Reachability); + if (!m_reachability_available) { + return; + } + + setReachability(info->reachability()); + connect(info, &QNetworkInformation::reachabilityChanged, this, &NetworkStatusModel::setReachability); +} diff --git a/src/qml/models/networkstatusmodel.h b/src/qml/models/networkstatusmodel.h new file mode 100644 index 0000000000..08836e5b46 --- /dev/null +++ b/src/qml/models/networkstatusmodel.h @@ -0,0 +1,45 @@ +// 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. + +#ifndef BITCOIN_QML_MODELS_NETWORKSTATUSMODEL_H +#define BITCOIN_QML_MODELS_NETWORKSTATUSMODEL_H + +#include +#include + +#include + +class NetworkStatusModel : public QObject +{ + Q_OBJECT + Q_PROPERTY(bool reachabilityAvailable READ reachabilityAvailable NOTIFY reachabilityChanged) + Q_PROPERTY(bool networkOffline READ networkOffline NOTIFY reachabilityChanged) + Q_PROPERTY(QString reachability READ reachability NOTIFY reachabilityChanged) + +public: + explicit NetworkStatusModel(QObject* parent = nullptr); + explicit NetworkStatusModel(bool monitor, QObject* parent = nullptr); + + bool reachabilityAvailable() const { return m_reachability_available; } + bool networkOffline() const + { + return m_reachability_available && + m_reachability == QNetworkInformation::Reachability::Disconnected; + } + QString reachability() const; + + void setReachability(QNetworkInformation::Reachability reachability); + +Q_SIGNALS: + void reachabilityChanged(); + +private: + void initializeMonitoring(); + + bool m_monitor{true}; + bool m_reachability_available{false}; + QNetworkInformation::Reachability m_reachability{QNetworkInformation::Reachability::Unknown}; +}; + +#endif // BITCOIN_QML_MODELS_NETWORKSTATUSMODEL_H diff --git a/src/qml/models/networktraffictower.cpp b/src/qml/models/networktraffictower.cpp new file mode 100644 index 0000000000..9a88bff89d --- /dev/null +++ b/src/qml/models/networktraffictower.cpp @@ -0,0 +1,148 @@ +// 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. + +#include + +#include +#include +#include + +#define MAX_SAMPLES 86400 + +NetworkTrafficTower::NetworkTrafficTower(NodeModel& node) + : m_node{node} +{ + QTimer* timer = new QTimer(); + connect(timer, &QTimer::timeout, this, &NetworkTrafficTower::updateTrafficStats); + timer->start(1000); + + QThread* timer_thread = new QThread; + timer->moveToThread(timer_thread); + timer_thread->start(); + + m_filter_window_size = 30; +} + +void NetworkTrafficTower::setTotalBytesReceived(float new_total) +{ + m_total_bytes_received = new_total; + Q_EMIT totalBytesReceivedChanged(); +} + +void NetworkTrafficTower::setTotalBytesSent(float new_total) +{ + m_total_bytes_sent = new_total; + Q_EMIT totalBytesSentChanged(); +} + +void NetworkTrafficTower::setMaxReceivedRateBps(float new_max) +{ + m_max_received_rate_bps = new_max; + Q_EMIT maxReceivedRateBpsChanged(); +} + +void NetworkTrafficTower::setMaxSentRateBps(float new_max) +{ + m_max_sent_rate_bps = new_max; + Q_EMIT maxSentRateBpsChanged(); +} + +void NetworkTrafficTower::updateFilterWindowSize(int new_size) +{ + if (!(m_filter_window_size == new_size)) { + m_filter_window_size = new_size; + + if (!m_received_rate_list.isEmpty()) { + recalculateSmoothedRateList(&m_received_rate_list, &m_smoothed_received_rate_list); + float new_max_received_rate = calculateMaxRateBps(&m_smoothed_received_rate_list); + + setMaxReceivedRateBps(new_max_received_rate); + Q_EMIT receivedRateListChanged(); + } + if (!m_sent_rate_list.isEmpty()) { + recalculateSmoothedRateList(&m_sent_rate_list, &m_smoothed_sent_rate_list); + float new_max_sent_rate = calculateMaxRateBps(&m_smoothed_sent_rate_list); + + setMaxSentRateBps(new_max_sent_rate); + Q_EMIT sentRateListChanged(); + } + } + Q_EMIT sentRateListChanged(); +} + +float NetworkTrafficTower::applyMovingAverageFilter(QQueue * rate_list) +{ + int filter_window_size = std::min(static_cast(rate_list->size()), m_filter_window_size); + float sum = 0.0f; + for (int i = 0; i < filter_window_size; ++i) { + sum += rate_list->at(i); + } + + return sum / filter_window_size; +} + +float NetworkTrafficTower::calculateMaxRateBps(QQueue * smoothed_rate_list) +{ + float max_rate_bps = 0.0f; + int lookback = std::min(static_cast(smoothed_rate_list->size()) - 1, m_filter_window_size * 10); + for (int i = lookback; i > 0; --i) { + if (smoothed_rate_list->at(i) > max_rate_bps) { + max_rate_bps = smoothed_rate_list->at(i); + } + } + return max_rate_bps; +} + +void NetworkTrafficTower::recalculateSmoothedRateList(QQueue * rate_list, QQueue * smoothed_rate_list) +{ + smoothed_rate_list->clear(); + QQueue temp_list; + for (int i = rate_list->size() - 1; i > 0; --i) { + temp_list.push_front(rate_list->at(i)); + float smoothed_rate = applyMovingAverageFilter(&temp_list); + smoothed_rate_list->push_front(smoothed_rate); + } +} + +void NetworkTrafficTower::updateTrafficStats() +{ + float new_total_bytes_received = m_node.getTotalBytesReceived(); + float new_total_bytes_sent = m_node.getTotalBytesSent(); + + float rate_received_bps = (new_total_bytes_received - m_total_bytes_received); + float rate_sent_bps = (new_total_bytes_sent - m_total_bytes_sent); + + setTotalBytesSent(new_total_bytes_sent); + setTotalBytesReceived(new_total_bytes_received); + + m_received_rate_list.push_front(rate_received_bps); + m_sent_rate_list.push_front(rate_sent_bps); + + float smoothed_received_rate_bps = applyMovingAverageFilter(&m_received_rate_list); + float smoothed_sent_rate_bps = applyMovingAverageFilter(&m_sent_rate_list); + + m_smoothed_received_rate_list.push_front(smoothed_received_rate_bps); + m_smoothed_sent_rate_list.push_front(smoothed_sent_rate_bps); + + while (m_received_rate_list.size() > MAX_SAMPLES) { + m_received_rate_list.pop_back(); + m_smoothed_received_rate_list.pop_back(); + } + + while (m_sent_rate_list.size() > MAX_SAMPLES) { + m_sent_rate_list.pop_back(); + m_smoothed_sent_rate_list.pop_back(); + } + + float new_max_received_rate_bps = calculateMaxRateBps(&m_smoothed_received_rate_list); + float new_max_sent_rate_bps = calculateMaxRateBps(&m_smoothed_sent_rate_list); + + setTotalBytesSent(new_total_bytes_sent); + setTotalBytesReceived(new_total_bytes_received); + setMaxReceivedRateBps(new_max_received_rate_bps); + setMaxSentRateBps(new_max_sent_rate_bps); + + Q_EMIT receivedRateListChanged(); + Q_EMIT sentRateListChanged(); +} diff --git a/src/qml/models/networktraffictower.h b/src/qml/models/networktraffictower.h new file mode 100644 index 0000000000..624a3d42a8 --- /dev/null +++ b/src/qml/models/networktraffictower.h @@ -0,0 +1,67 @@ +// 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. + +#ifndef BITCOIN_QML_MODELS_NETWORKTRAFFICTOWER_H +#define BITCOIN_QML_MODELS_NETWORKTRAFFICTOWER_H + +#include + +#include +#include + +class NetworkTrafficTower : public QObject +{ + Q_OBJECT + Q_PROPERTY(float totalBytesReceived READ totalBytesReceived NOTIFY totalBytesReceivedChanged) + Q_PROPERTY(float totalBytesSent READ totalBytesSent NOTIFY totalBytesSentChanged) + Q_PROPERTY(float maxReceivedRateBps READ maxReceivedRateBps NOTIFY maxReceivedRateBpsChanged) + Q_PROPERTY(float maxSentRateBps READ maxSentRateBps NOTIFY maxSentRateBpsChanged) + Q_PROPERTY(QQueue receivedRateList READ receivedRateList NOTIFY receivedRateListChanged) + Q_PROPERTY(QQueue sentRateList READ sentRateList NOTIFY sentRateListChanged) + +public: + explicit NetworkTrafficTower(NodeModel& node); + + float totalBytesReceived() const { return m_total_bytes_received; } + float totalBytesSent() const { return m_total_bytes_sent; } + float maxReceivedRateBps() const { return m_max_received_rate_bps; } + float maxSentRateBps() const { return m_max_sent_rate_bps; } + QQueue receivedRateList() { return m_smoothed_received_rate_list; } + QQueue sentRateList() { return m_smoothed_sent_rate_list; } + +public Q_SLOTS: + void setTotalBytesReceived(float new_total); + void setTotalBytesSent(float new_total); + void setMaxReceivedRateBps(float new_max); + void setMaxSentRateBps(float new_max); + + Q_INVOKABLE void updateFilterWindowSize(int new_size); + +Q_SIGNALS: + void totalBytesReceivedChanged(); + void totalBytesSentChanged(); + void maxReceivedRateBpsChanged(); + void maxSentRateBpsChanged(); + void receivedRateListChanged(); + void sentRateListChanged(); + +private: + float applyMovingAverageFilter(QQueue * rate_list); + float calculateMaxRateBps(QQueue * smoothed_rate_list); + void recalculateSmoothedRateList(QQueue * raw_rate_list, QQueue * smoothed_rate_list); + void updateTrafficStats(); + + NodeModel& m_node; + int m_filter_window_size; + float m_total_bytes_received{0.0f}; + float m_total_bytes_sent{0.0f}; + float m_max_received_rate_bps{0.0f}; + float m_max_sent_rate_bps{0.0f}; + QQueue m_received_rate_list; + QQueue m_smoothed_received_rate_list; + QQueue m_sent_rate_list; + QQueue m_smoothed_sent_rate_list; +}; + +#endif // BITCOIN_QML_MODELS_NETWORKTRAFFICTOWER_H diff --git a/src/qml/models/nodemodel.cpp b/src/qml/models/nodemodel.cpp new file mode 100644 index 0000000000..71309758cc --- /dev/null +++ b/src/qml/models/nodemodel.cpp @@ -0,0 +1,832 @@ +// Copyright (c) 2021 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace std::chrono_literals; + +static constexpr int MEMPOOL_INFO_POLLING_INTERVAL_MS{3000}; +static constexpr int HEADER_HEIGHT_DELTA_SYNC{24}; + +namespace { +QStringList SplitWarnings(const QString& warnings) +{ + QString normalized{warnings}; + normalized.replace(QStringLiteral("
"), QStringLiteral("
"), Qt::CaseInsensitive); + normalized.replace(QStringLiteral("
"), QStringLiteral("
"), Qt::CaseInsensitive); + + QStringList result; + for (const QString& warning : normalized.split(QStringLiteral("
"), Qt::SkipEmptyParts)) { + const QString trimmed{warning.trimmed()}; + if (!trimmed.isEmpty()) { + result.push_back(trimmed); + } + } + return result; +} + +QString RuntimeDialogTitle(const QString& caption, unsigned int style) +{ + if (!caption.isEmpty()) { + return caption; + } + if (style & CClientUIInterface::ICON_ERROR) { + return QObject::tr("Error"); + } + if (style & CClientUIInterface::ICON_WARNING) { + return QObject::tr("Warning"); + } + return QObject::tr("Information"); +} + +QString RuntimeDialogIcon(unsigned int style) +{ + if (style & CClientUIInterface::ICON_ERROR) { + return QStringLiteral("image://images/error"); + } + if (style & CClientUIInterface::ICON_WARNING) { + return QStringLiteral("image://images/alert-filled"); + } + return QStringLiteral("image://images/info-filled"); +} + +unsigned int RuntimeDialogButtons(unsigned int style) +{ + const unsigned int buttons{style & CClientUIInterface::BTN_MASK}; + return buttons ? buttons : CClientUIInterface::BTN_OK; +} + +bool HasMultipleRuntimeDialogButtons(unsigned int buttons) +{ + return (buttons & (buttons - 1)) != 0; +} + +QVariant InformationRow(const QString& label, const QString& value) +{ + QVariantMap row; + row.insert(QStringLiteral("label"), label); + row.insert(QStringLiteral("value"), value); + return QVariant::fromValue(row); +} +} // namespace + +NodeModel::NodeModel(interfaces::Node& node) + : m_node{node} +{ + m_mempool_information_available = !gArgs.GetBoolArg("-blocksonly", DEFAULT_BLOCKSONLY); + initializeMempoolInfoPolling(); + refreshPeerCounts(); + refreshWarnings(); + ConnectToBlockTipSignal(); + ConnectToHeaderTipSignal(); + ConnectToNumConnectionsChangedSignal(); + ConnectToNetworkActiveChangedSignal(); + ConnectToAlertChangedSignal(); + ConnectToRuntimeDialogSignals(); + ConnectToBannedListChangedSignal(); + refreshMempoolInfo(); +} + +NodeModel::~NodeModel() +{ + unsubscribeFromCoreSignals(); + setMempoolInfoPollingActive(false); + if (m_mempool_info_thread) { + m_mempool_info_thread->quit(); + m_mempool_info_thread->wait(); + } +} + +void NodeModel::setBlockTipHeight(int new_height) +{ + if (new_height != m_block_tip_height) { + m_block_tip_height = new_height; + Q_EMIT blockTipHeightChanged(); + } +} + +void NodeModel::setNumOutboundPeers(int new_num) +{ + if (new_num != m_num_outbound_peers) { + m_num_outbound_peers = new_num; + Q_EMIT numOutboundPeersChanged(); + } +} + +void NodeModel::setNumPeers(int new_num) +{ + if (new_num != m_num_peers) { + m_num_peers = new_num; + Q_EMIT numPeersChanged(); + } +} + +void NodeModel::setNumInboundPeers(int new_num) +{ + if (new_num != m_num_inbound_peers) { + m_num_inbound_peers = new_num; + Q_EMIT numInboundPeersChanged(); + } +} + +void NodeModel::refreshPeerCounts() +{ + setNumPeers(static_cast(m_node.getNodeCount(ConnectionDirection::Both))); + setNumInboundPeers(static_cast(m_node.getNodeCount(ConnectionDirection::In))); + setNumOutboundPeers(static_cast(m_node.getNodeCount(ConnectionDirection::Out))); +} + +void NodeModel::refreshMempoolInfo() +{ + requestMempoolInfoRefresh(); +} + +void NodeModel::setMempoolInfoPollingActive(bool active) +{ + if (m_mempool_info_polling_active == active) { + return; + } + m_mempool_info_polling_active = active; + Q_EMIT mempoolInfoPollingActiveChanged(active); + + QTimer* timer = m_mempool_info_timer; + if (!timer) { + return; + } + + QMetaObject::invokeMethod(timer, [timer, active] { + if (active) { + timer->start(); + } else { + timer->stop(); + } + }, Qt::QueuedConnection); + + if (active) { + refreshMempoolInfo(); + } +} + +void NodeModel::initializeMempoolInfoPolling() +{ + m_mempool_info_worker = new QObject; + m_mempool_info_thread = new QThread(this); + m_mempool_info_timer = new QTimer; + m_mempool_info_timer->setInterval(MEMPOOL_INFO_POLLING_INTERVAL_MS); + + m_mempool_info_worker->moveToThread(m_mempool_info_thread); + m_mempool_info_timer->moveToThread(m_mempool_info_thread); + + connect(m_mempool_info_timer, &QTimer::timeout, m_mempool_info_worker, [this] { + fetchMempoolInfo(); + }); + connect(m_mempool_info_thread, &QThread::finished, m_mempool_info_timer, &QObject::deleteLater); + connect(m_mempool_info_thread, &QThread::finished, m_mempool_info_worker, &QObject::deleteLater); + + m_mempool_info_thread->start(); + QTimer::singleShot(0, m_mempool_info_worker, [] { + util::ThreadRename("qml-mempool"); + }); +} + +void NodeModel::requestMempoolInfoRefresh() +{ + if (!m_mempool_info_worker) { + return; + } + + QTimer::singleShot(0, m_mempool_info_worker, [this] { + fetchMempoolInfo(); + }); +} + +void NodeModel::fetchMempoolInfo() +{ + const MempoolInfo info{ + static_cast(m_node.getMempoolSize()), + m_node.getMempoolDynamicUsage() / 1'000'000.0, + m_node.getMempoolMaxUsage() / 1'000'000.0, + }; + + QMetaObject::invokeMethod(this, [this, info] { + applyMempoolInfo(info); + }, Qt::QueuedConnection); +} + +void NodeModel::applyMempoolInfo(const MempoolInfo& info) +{ + if (info.transaction_count != m_mempool_transaction_count || + info.usage_mb != m_mempool_usage_mb || + info.max_usage_mb != m_mempool_max_usage_mb) { + m_mempool_transaction_count = info.transaction_count; + m_mempool_usage_mb = info.usage_mb; + m_mempool_max_usage_mb = info.max_usage_mb; + Q_EMIT mempoolInfoChanged(); + } +} + +void NodeModel::setRemainingSyncTime(double new_progress) +{ + int currentTime = QDateTime::currentDateTime().toMSecsSinceEpoch(); + + // keep a vector of samples of verification progress at height + m_block_process_time.push_front(qMakePair(currentTime, new_progress)); + + // show progress speed if we have more than one sample + if (m_block_process_time.size() >= 2) { + double progressDelta = 0; + int timeDelta = 0; + int remainingMSecs = 0; + double remainingProgress = 1.0 - new_progress; + for (int i = 1; i < m_block_process_time.size(); i++) { + QPair sample = m_block_process_time[i]; + + // take first sample after 500 seconds or last available one + if (sample.first < (currentTime - 500 * 1000) || i == m_block_process_time.size() - 1) { + progressDelta = m_block_process_time[0].second - sample.second; + timeDelta = m_block_process_time[0].first - sample.first; + remainingMSecs = (progressDelta > 0) ? remainingProgress / progressDelta * timeDelta : -1; + break; + } + } + if (remainingMSecs > 0 && m_block_process_time.count() % 1000 == 0) { + m_remaining_sync_time = remainingMSecs; + + Q_EMIT remainingSyncTimeChanged(); + } + static const int MAX_SAMPLES = 5000; + if (m_block_process_time.count() > MAX_SAMPLES) { + m_block_process_time.remove(1, m_block_process_time.count() - 1); + } + } +} +void NodeModel::setVerificationProgress(double new_progress) +{ + if (new_progress != m_verification_progress) { + setRemainingSyncTime(new_progress); + + m_verification_progress = new_progress; + Q_EMIT verificationProgressChanged(); + } +} + +void NodeModel::setBlockSyncActive(bool active) +{ + if (m_block_sync_active == active) { + return; + } + + m_block_sync_active = active; + Q_EMIT blockSyncActiveChanged(); +} + +void NodeModel::setHeaderSyncState(int height, int64_t block_time, bool presync) +{ + m_header_tip_height = height; + m_header_tip_time = block_time; + + const int64_t estimate_headers_left{(GetTime() - block_time) / Params().GetConsensus().nPowTargetSpacing}; + const bool active{height > 0 && estimate_headers_left > HEADER_HEIGHT_DELTA_SYNC}; + const double progress{active ? 1.0 * height / (height + estimate_headers_left) : 0.0}; + + if (m_header_sync_active == active && + m_header_presync == presync && + m_header_sync_progress == progress) { + return; + } + + m_header_sync_active = active; + m_header_presync = presync; + m_header_sync_progress = progress; + Q_EMIT headerSyncChanged(); +} + +void NodeModel::setPause(bool new_pause) +{ + if(m_pause != new_pause) { + m_pause = new_pause; + m_node.setNetworkActive(!new_pause); + Q_EMIT pauseChanged(new_pause); + } +} + +void NodeModel::setErrorState(bool faulted) +{ + if (m_faulted != faulted) { + m_faulted = faulted; + Q_EMIT errorStateChanged(faulted); + } +} + +void NodeModel::setStartupError(const QString& error) +{ + if (m_startup_error != error) { + m_startup_error = error; + Q_EMIT startupErrorChanged(); + } +} + +void NodeModel::addStartupWarnings(const QStringList& warnings) +{ + for (const QString& warning : warnings) { + recordStartupWarningMessage(warning); + } +} + +void NodeModel::setWarnings(const QString& warnings) +{ + const QStringList warning_list{SplitWarnings(warnings)}; + if (m_warnings == warnings && m_warning_list == warning_list) { + return; + } + m_warnings = warnings; + m_warning_list = warning_list; + Q_EMIT warningsChanged(); +} + +void NodeModel::refreshWarnings() +{ + // Keep "current warnings" tied to Core's active warning set. + setWarnings(QString::fromStdString(m_node.getWarnings().translated)); +} + +void NodeModel::showStartupWarnings() +{ + if (m_startup_warning_messages.isEmpty()) { + return; + } + + const QString warnings{m_startup_warning_messages.join(QStringLiteral("\n\n"))}; + m_startup_warning_messages.clear(); + // MSG_WARNING is modal; startup notices should be shown once without blocking initialization. + showRuntimeDialogOnGuiThread(warnings, QString{}, CClientUIInterface::ICON_WARNING, /*question=*/false); +} + +void NodeModel::recordStartupErrorMessage(const QString& message) +{ + const QString error{message.trimmed()}; + if (error.isEmpty() || m_startup_error_messages.contains(error)) { + return; + } + m_startup_error_messages.push_back(error); +} + +void NodeModel::recordStartupWarningMessage(const QString& message) +{ + const QString warning{message.trimmed()}; + if (warning.isEmpty() || m_startup_warning_messages.contains(warning)) { + return; + } + m_startup_warning_messages.push_back(warning); +} + +void NodeModel::startNodeInitializionThread() +{ + if (m_initialization_requested) { + return; + } + m_initialization_requested = true; + Q_EMIT requestedInitialize(); +} + +void NodeModel::requestShutdown() +{ + if (m_shutdown_requested) { + return; + } + m_shutdown_requested = true; + stopShutdownPolling(); + Q_EMIT requestedShutdown(); +} + +void NodeModel::initializeResult(bool success, interfaces::BlockAndHeaderTipInfo tip_info) +{ + if (!success) { + if (m_startup_failure_dialog_shown) { + requestShutdown(); + Q_EMIT nodeInitialized(); + return; + } + setErrorState(true); + refreshWarnings(); + QString startup_error{m_startup_error_messages.isEmpty() ? tr("Node initialization failed.") : m_startup_error_messages.join(QStringLiteral("\n\n"))}; + if (!m_startup_warning_messages.isEmpty()) { + startup_error = tr("Startup warnings:") + QStringLiteral("\n") + m_startup_warning_messages.join(QStringLiteral("\n\n")) + QStringLiteral("\n\n") + startup_error; + } + m_startup_warning_messages.clear(); + setStartupError(startup_error); + if (m_node.shutdownRequested()) { + requestShutdown(); + } + } else { + m_startup_error_messages.clear(); + m_node_ready = true; + m_runtime_dialogs_enabled = true; + refreshWarnings(); + showStartupWarnings(); + setBlockTipHeight(tip_info.block_height); + setVerificationProgress(tip_info.verification_progress); + setBlockSyncActive(tip_info.block_height > 0 && m_node.isInitialBlockDownload()); + setHeaderSyncState(tip_info.header_height, tip_info.header_time, /*presync=*/false); + refreshMempoolInfo(); + Q_EMIT setTimeRatioListInitial(); + } + Q_EMIT nodeInitialized(); +} + +void NodeModel::handleRunawayException(const QString& message) +{ + setErrorState(true); + setStartupError(message.isEmpty() ? tr("A fatal node error occurred.") : message); +} + +void NodeModel::startShutdownPolling() +{ + if (m_shutdown_polling_timer_id != 0) { + return; + } + m_shutdown_polling_timer_id = startTimer(200ms); +} + +void NodeModel::stopShutdownPolling() +{ + if (m_shutdown_polling_timer_id == 0) { + return; + } + killTimer(m_shutdown_polling_timer_id); + m_shutdown_polling_timer_id = 0; +} + +void NodeModel::timerEvent(QTimerEvent* event) +{ + if (event->timerId() != m_shutdown_polling_timer_id) { + return; + } + if (m_node.shutdownRequested()) { + requestShutdown(); + } +} + +void NodeModel::ConnectToBlockTipSignal() +{ + assert(!m_handler_notify_block_tip); + + m_handler_notify_block_tip = m_node.handleNotifyBlockTip( + [this]([[maybe_unused]] SynchronizationState state, interfaces::BlockTip tip, double verification_progress) { + QMetaObject::invokeMethod(this, [this, state, block_height = tip.block_height, block_time = tip.block_time, verification_progress] { + setBlockTipHeight(block_height); + setVerificationProgress(verification_progress); + setBlockSyncActive(block_height > 0 && state != SynchronizationState::POST_INIT); + + Q_EMIT setTimeRatioList(block_time); + }, Qt::QueuedConnection); + }); +} + +void NodeModel::ConnectToHeaderTipSignal() +{ + assert(!m_handler_notify_header_tip); + + m_handler_notify_header_tip = m_node.handleNotifyHeaderTip( + [this]([[maybe_unused]] SynchronizationState state, interfaces::BlockTip tip, bool presync) { + QMetaObject::invokeMethod(this, [this, block_height = tip.block_height, block_time = tip.block_time, presync] { + setHeaderSyncState(block_height, block_time, presync); + }, Qt::QueuedConnection); + }); +} + +void NodeModel::ConnectToNumConnectionsChangedSignal() +{ + assert(!m_handler_notify_num_peers_changed); + + m_handler_notify_num_peers_changed = m_node.handleNotifyNumConnectionsChanged( + [this]([[maybe_unused]] int new_num_connections) { + QMetaObject::invokeMethod(this, [this] { + refreshPeerCounts(); + }, Qt::QueuedConnection); + }); +} + +void NodeModel::ConnectToNetworkActiveChangedSignal() +{ + assert(!m_handler_notify_network_active_changed); + + m_handler_notify_network_active_changed = m_node.handleNotifyNetworkActiveChanged( + [this](bool network_active) { + QMetaObject::invokeMethod(this, [this, network_active] { + if (m_pause != !network_active) { + m_pause = !network_active; + Q_EMIT pauseChanged(m_pause); + } + }, Qt::QueuedConnection); + }); +} + +void NodeModel::ConnectToAlertChangedSignal() +{ + assert(!m_handler_notify_alert_changed); + + m_handler_notify_alert_changed = m_node.handleNotifyAlertChanged([this]() { + QMetaObject::invokeMethod(this, [this] { + refreshWarnings(); + }, Qt::QueuedConnection); + }); +} + +void NodeModel::ConnectToRuntimeDialogSignals() +{ + assert(!m_handler_message_box); + assert(!m_handler_question); + + m_handler_message_box = m_node.handleMessageBox( + [this](const bilingual_str& message, const std::string& caption, unsigned int style) { + return showRuntimeDialog( + QString::fromStdString(message.translated), + QString::fromStdString(caption), + style, + /*question=*/false); + }); + m_handler_question = m_node.handleQuestion( + [this](const bilingual_str& message, [[maybe_unused]] const std::string& non_interactive_message, const std::string& caption, unsigned int style) { + return showRuntimeDialog( + QString::fromStdString(message.translated), + QString::fromStdString(caption), + style, + /*question=*/true); + }); +} + +bool NodeModel::validateProxyAddress(QString address_port) +{ + uint16_t port{0}; + std::string addr_port{address_port.toStdString()}; + std::string hostname; + // First, attempt to split the input address into hostname and port components. + // We call SplitHostPort to validate that a port is provided in addr_port. + // If either splitting fails or port is zero (not specified), return false. + if (!SplitHostPort(addr_port, port, hostname) || !port) return false; + + // Create a service endpoint (CService) from the address and port. + // If port is missing in addr_port, DEFAULT_PROXY_PORT is used as the fallback. + CService serv(LookupNumeric(addr_port, DEFAULT_PROXY_PORT)); + + // Construct the Proxy with the service endpoint and return if it's valid + Proxy addrProxy = Proxy(serv, true); + return addrProxy.IsValid(); +} + +QString NodeModel::defaultProxyAddress() +{ + return QString::fromStdString(std::string(DEFAULT_PROXY_HOST) + ":" + util::ToString(DEFAULT_PROXY_PORT)); +} + +bool NodeModel::disconnectPeer(int nodeId) +{ + return m_node.disconnectById(nodeId); +} + +bool NodeModel::banPeer(const QString& rawAddress, int64_t banDuration) +{ + if (banDuration <= 0) return false; + auto addr = LookupHost(rawAddress.toStdString(), /*fAllowLookup=*/false); + if (!addr) return false; + bool result = m_node.ban(*addr, banDuration); + if (result) { + m_node.disconnectByAddress(*addr); + } + return result; +} + +QVariantList NodeModel::nodeInformationRows() +{ + int header_height{m_header_tip_height}; + int64_t header_time{m_header_tip_time}; + if (m_node_ready && header_height == 0) { + m_node.getHeaderTip(header_height, header_time); + } + + QString local_addresses; + if (m_node_ready) { + for (const auto& [addr, info] : m_node.getNetLocalAddresses()) { + local_addresses += QString::fromStdString(addr.ToStringAddr()); + if (!addr.IsI2P()) { + local_addresses += QStringLiteral(":") + QString::number(info.nPort); + } + local_addresses += QStringLiteral(", "); + } + } + if (!local_addresses.isEmpty()) { + local_addresses.chop(2); + } else { + local_addresses = tr("None"); + } + + const int block_height{m_node_ready ? std::max(m_block_tip_height, m_node.getNumBlocks()) : m_block_tip_height}; + const int64_t last_block_time{m_node_ready ? m_node.getLastBlockTime() : 0}; + const QString warning_text{m_warning_list.empty() ? tr("None") : m_warning_list.join(QStringLiteral("\n"))}; + + QVariantList rows; + rows.push_back(InformationRow(tr("Client version"), fullClientVersion())); + rows.push_back(InformationRow(tr("User agent"), QString::fromStdString(strSubVersion))); + rows.push_back(InformationRow(tr("Datadir"), QString::fromStdString(fs::PathToString(gArgs.GetDataDirNet())))); + rows.push_back(InformationRow(tr("Blocks dir"), QString::fromStdString(fs::PathToString(gArgs.GetBlocksDirPath())))); + rows.push_back(InformationRow(tr("Startup time"), QDateTime::fromSecsSinceEpoch(GetStartupTime()).toString())); + rows.push_back(InformationRow(tr("Network"), QString::fromStdString(Params().GetChainTypeString()))); + rows.push_back(InformationRow(tr("Block height"), QString::number(block_height))); + rows.push_back(InformationRow(tr("Header height"), QString::number(header_height))); + rows.push_back(InformationRow(tr("Header time"), header_time > 0 ? QDateTime::fromSecsSinceEpoch(header_time).toString() : tr("Unknown"))); + rows.push_back(InformationRow(tr("Last block time"), last_block_time > 0 ? QDateTime::fromSecsSinceEpoch(last_block_time).toString() : tr("Unknown"))); + rows.push_back(InformationRow(tr("Verification progress"), QStringLiteral("%1%").arg(QString::number(m_verification_progress * 100.0, 'f', 2)))); + rows.push_back(InformationRow(tr("Peers"), tr("%1 total (%2 inbound, %3 outbound)").arg(m_num_peers).arg(m_num_inbound_peers).arg(m_num_outbound_peers))); + rows.push_back(InformationRow(tr("Network active"), m_node_ready ? (m_node.getNetworkActive() ? tr("Yes") : tr("No")) : tr("Unknown"))); + rows.push_back(InformationRow(tr("Local addresses"), local_addresses)); + rows.push_back(InformationRow(tr("Warnings"), warning_text)); + return rows; +} + +bool NodeModel::showRuntimeDialog(const QString& message, const QString& caption, unsigned int style, bool question) +{ + if (QThread::currentThread() == thread()) { + return showRuntimeDialogOnGuiThread(message, caption, style, question); + } + + if (!(style & CClientUIInterface::MODAL) && !question) { + QMetaObject::invokeMethod(this, [this, message, caption, style, question] { + showRuntimeDialogOnGuiThread(message, caption, style, question); + }, Qt::QueuedConnection); + return false; + } + + bool result{false}; + QMetaObject::invokeMethod(this, [this, &result, message, caption, style, question] { + result = showRuntimeDialogOnGuiThread(message, caption, style, question); + }, Qt::BlockingQueuedConnection); + return result; +} + +bool NodeModel::showRuntimeDialogOnGuiThread(const QString& message, const QString& caption, unsigned int style, bool question) +{ + if (!m_runtime_dialogs_enabled && !question) { + if (style & CClientUIInterface::ICON_WARNING) { + recordStartupWarningMessage(message); + return false; + } + if (!(style & CClientUIInterface::MODAL)) { + if (style & CClientUIInterface::ICON_ERROR) { + recordStartupErrorMessage(message); + } + return false; + } + } + + const bool blocking{(style & CClientUIInterface::MODAL) || question}; + auto request{std::make_shared()}; + request->message = message; + request->caption = caption; + request->style = style; + request->question = question; + if (!m_runtime_dialogs_enabled && (question || (style & CClientUIInterface::ICON_ERROR))) { + m_startup_failure_dialog_shown = true; + } + + QEventLoop loop; + if (blocking) { + request->loop = &loop; + } + + if (m_runtime_dialog_active) { + m_runtime_dialog_queue.push_back(request); + } else { + showRuntimeDialogRequest(request); + } + + if (!blocking) { + return false; + } + + if (!request->answered) { + loop.exec(); + } + request->loop = nullptr; + + return request->answer; +} + +void NodeModel::showRuntimeDialogRequest(const std::shared_ptr& request) +{ + m_runtime_dialog_active = request; + m_runtime_dialog_title = RuntimeDialogTitle(request->caption, request->style); + m_runtime_dialog_message = request->message; + m_runtime_dialog_icon = RuntimeDialogIcon(request->style); + m_runtime_dialog_buttons = RuntimeDialogButtons(request->style); + m_runtime_dialog_question = request->question || HasMultipleRuntimeDialogButtons(m_runtime_dialog_buttons); + m_runtime_dialog_visible = true; + Q_EMIT runtimeDialogChanged(); +} + +void NodeModel::answerRuntimeDialog(unsigned int button) +{ + if (!m_runtime_dialog_active) { + return; + } + + auto answered_dialog{std::move(m_runtime_dialog_active)}; + answered_dialog->button = button; + answered_dialog->answer = button == CClientUIInterface::BTN_OK; + answered_dialog->answered = true; + if (answered_dialog->loop) { + answered_dialog->loop->quit(); + } + + if (!m_runtime_dialog_queue.empty()) { + auto next_dialog{m_runtime_dialog_queue.front()}; + m_runtime_dialog_queue.pop_front(); + showRuntimeDialogRequest(next_dialog); + return; + } + + m_runtime_dialog_visible = false; + Q_EMIT runtimeDialogChanged(); +} + +#ifdef ENABLE_TEST_AUTOMATION +void NodeModel::showRuntimeDialogForTest(const QString& message, const QString& caption, unsigned int style, bool question) +{ + auto request{std::make_shared()}; + request->message = message; + request->caption = caption; + request->style = style; + request->question = question; + + if (m_runtime_dialog_active) { + m_runtime_dialog_queue.push_back(request); + } else { + showRuntimeDialogRequest(request); + } +} +#endif + +void NodeModel::ConnectToBannedListChangedSignal() +{ + assert(!m_handler_notify_banned_list_changed); + m_handler_notify_banned_list_changed = m_node.handleBannedListChanged([this]() { + QMetaObject::invokeMethod(this, [this] { + Q_EMIT bannedListChanged(); + }); + }); +} + +void NodeModel::unsubscribeFromCoreSignals() +{ + if (m_handler_notify_block_tip) { + m_handler_notify_block_tip->disconnect(); + } + if (m_handler_notify_header_tip) { + m_handler_notify_header_tip->disconnect(); + } + if (m_handler_notify_num_peers_changed) { + m_handler_notify_num_peers_changed->disconnect(); + } + if (m_handler_notify_network_active_changed) { + m_handler_notify_network_active_changed->disconnect(); + } + if (m_handler_notify_alert_changed) { + m_handler_notify_alert_changed->disconnect(); + } + if (m_handler_message_box) { + m_handler_message_box->disconnect(); + } + if (m_handler_question) { + m_handler_question->disconnect(); + } + if (m_handler_notify_banned_list_changed) { + m_handler_notify_banned_list_changed->disconnect(); + } +} diff --git a/src/qml/models/nodemodel.h b/src/qml/models/nodemodel.h new file mode 100644 index 0000000000..fc0c94b94b --- /dev/null +++ b/src/qml/models/nodemodel.h @@ -0,0 +1,264 @@ +// Copyright (c) 2021 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QML_MODELS_NODEMODEL_H +#define BITCOIN_QML_MODELS_NODEMODEL_H + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +const char DEFAULT_PROXY_HOST[] = "127.0.0.1"; +constexpr uint16_t DEFAULT_PROXY_PORT = 9050; + +QT_BEGIN_NAMESPACE +class QThread; +class QTimer; +class QTimerEvent; +class QEventLoop; +QT_END_NAMESPACE + +namespace interfaces { +class Node; +} + +/** Model for Bitcoin network client. */ +class NodeModel : public QObject +{ + Q_OBJECT + Q_PROPERTY(int blockTipHeight READ blockTipHeight NOTIFY blockTipHeightChanged) + Q_PROPERTY(QString fullClientVersion READ fullClientVersion CONSTANT) + Q_PROPERTY(int numPeers READ numPeers NOTIFY numPeersChanged) + Q_PROPERTY(int numInboundPeers READ numInboundPeers NOTIFY numInboundPeersChanged) + Q_PROPERTY(int numOutboundPeers READ numOutboundPeers NOTIFY numOutboundPeersChanged) + Q_PROPERTY(int maxNumOutboundPeers READ maxNumOutboundPeers CONSTANT) + Q_PROPERTY(int mempoolTransactionCount READ mempoolTransactionCount NOTIFY mempoolInfoChanged) + Q_PROPERTY(double mempoolUsageMB READ mempoolUsageMB NOTIFY mempoolInfoChanged) + Q_PROPERTY(double mempoolMaxUsageMB READ mempoolMaxUsageMB NOTIFY mempoolInfoChanged) + Q_PROPERTY(bool mempoolInfoPollingActive READ mempoolInfoPollingActive WRITE setMempoolInfoPollingActive NOTIFY mempoolInfoPollingActiveChanged) + Q_PROPERTY(bool mempoolInformationAvailable READ mempoolInformationAvailable CONSTANT) + Q_PROPERTY(int remainingSyncTime READ remainingSyncTime NOTIFY remainingSyncTimeChanged) + Q_PROPERTY(double verificationProgress READ verificationProgress NOTIFY verificationProgressChanged) + Q_PROPERTY(bool blockSyncActive READ blockSyncActive NOTIFY blockSyncActiveChanged) + Q_PROPERTY(bool headerSyncActive READ headerSyncActive NOTIFY headerSyncChanged) + Q_PROPERTY(bool headerPresync READ headerPresync NOTIFY headerSyncChanged) + Q_PROPERTY(double headerSyncProgress READ headerSyncProgress NOTIFY headerSyncChanged) + Q_PROPERTY(bool pause READ pause WRITE setPause NOTIFY pauseChanged) + Q_PROPERTY(bool faulted READ errorState WRITE setErrorState NOTIFY errorStateChanged) + Q_PROPERTY(QString startupError READ startupError NOTIFY startupErrorChanged) + Q_PROPERTY(QString warnings READ warnings NOTIFY warningsChanged) + Q_PROPERTY(QStringList warningList READ warningList NOTIFY warningsChanged) + Q_PROPERTY(bool hasWarnings READ hasWarnings NOTIFY warningsChanged) + Q_PROPERTY(bool runtimeDialogVisible READ runtimeDialogVisible NOTIFY runtimeDialogChanged) + Q_PROPERTY(QString runtimeDialogTitle READ runtimeDialogTitle NOTIFY runtimeDialogChanged) + Q_PROPERTY(QString runtimeDialogMessage READ runtimeDialogMessage NOTIFY runtimeDialogChanged) + Q_PROPERTY(QString runtimeDialogIcon READ runtimeDialogIcon NOTIFY runtimeDialogChanged) + Q_PROPERTY(unsigned int runtimeDialogButtons READ runtimeDialogButtons NOTIFY runtimeDialogChanged) + Q_PROPERTY(bool runtimeDialogQuestion READ runtimeDialogQuestion NOTIFY runtimeDialogChanged) + +public: + explicit NodeModel(interfaces::Node& node); + ~NodeModel() override; + + int blockTipHeight() const { return m_block_tip_height; } + void setBlockTipHeight(int new_height); + QString fullClientVersion() const { return QString::fromStdString(FormatFullVersion()); } + int numPeers() const { return m_num_peers; } + void setNumPeers(int new_num); + int numInboundPeers() const { return m_num_inbound_peers; } + void setNumInboundPeers(int new_num); + int numOutboundPeers() const { return m_num_outbound_peers; } + void setNumOutboundPeers(int new_num); + int maxNumOutboundPeers() const { return m_max_num_outbound_peers; } + int mempoolTransactionCount() const { return m_mempool_transaction_count; } + double mempoolUsageMB() const { return m_mempool_usage_mb; } + double mempoolMaxUsageMB() const { return m_mempool_max_usage_mb; } + bool mempoolInfoPollingActive() const { return m_mempool_info_polling_active; } + void setMempoolInfoPollingActive(bool active); + bool mempoolInformationAvailable() const { return m_mempool_information_available; } + int remainingSyncTime() const { return m_remaining_sync_time; } + void setRemainingSyncTime(double new_progress); + double verificationProgress() const { return m_verification_progress; } + void setVerificationProgress(double new_progress); + bool blockSyncActive() const { return m_block_sync_active; } + bool headerSyncActive() const { return m_header_sync_active; } + bool headerPresync() const { return m_header_presync; } + double headerSyncProgress() const { return m_header_sync_progress; } + bool pause() const { return m_pause; } + void setPause(bool new_pause); + bool errorState() const { return m_faulted; } + void setErrorState(bool new_error); + QString startupError() const { return m_startup_error; } + void setStartupError(const QString& error); + void addStartupWarnings(const QStringList& warnings); + QString warnings() const { return m_warnings; } + QStringList warningList() const { return m_warning_list; } + bool hasWarnings() const { return !m_warning_list.empty(); } + bool runtimeDialogVisible() const { return m_runtime_dialog_visible; } + QString runtimeDialogTitle() const { return m_runtime_dialog_title; } + QString runtimeDialogMessage() const { return m_runtime_dialog_message; } + QString runtimeDialogIcon() const { return m_runtime_dialog_icon; } + unsigned int runtimeDialogButtons() const { return m_runtime_dialog_buttons; } + bool runtimeDialogQuestion() const { return m_runtime_dialog_question; } + + Q_INVOKABLE float getTotalBytesReceived() const { return (float)m_node.getTotalBytesRecv(); } + Q_INVOKABLE float getTotalBytesSent() const { return (float)m_node.getTotalBytesSent(); } + Q_INVOKABLE void refreshMempoolInfo(); + + Q_INVOKABLE void startNodeInitializionThread(); + Q_INVOKABLE void requestShutdown(); + + void startShutdownPolling(); + void stopShutdownPolling(); + + Q_INVOKABLE bool validateProxyAddress(QString addr_port); + Q_INVOKABLE QString defaultProxyAddress(); + Q_INVOKABLE bool disconnectPeer(int nodeId); + Q_INVOKABLE bool banPeer(const QString& rawAddress, int64_t banDuration); + Q_INVOKABLE QVariantList nodeInformationRows(); + Q_INVOKABLE void answerRuntimeDialog(unsigned int button); +#ifdef ENABLE_TEST_AUTOMATION + Q_INVOKABLE void showRuntimeDialogForTest(const QString& message, const QString& caption, unsigned int style, bool question); +#endif + +public Q_SLOTS: + void initializeResult(bool success, interfaces::BlockAndHeaderTipInfo tip_info); + void handleRunawayException(const QString& message); + +Q_SIGNALS: + void blockTipHeightChanged(); + void mempoolInfoChanged(); + void mempoolInfoPollingActiveChanged(bool active); + void numPeersChanged(); + void numInboundPeersChanged(); + void numOutboundPeersChanged(); + void remainingSyncTimeChanged(); + void requestedInitialize(); + void requestedShutdown(); + void verificationProgressChanged(); + void blockSyncActiveChanged(); + void headerSyncChanged(); + void pauseChanged(bool new_pause); + void errorStateChanged(bool new_error_state); + void startupErrorChanged(); + void warningsChanged(); + void runtimeDialogChanged(); + + void setTimeRatioList(int new_time); + void setTimeRatioListInitial(); + void nodeInitialized(); + void bannedListChanged(); + +protected: + void timerEvent(QTimerEvent* event) override; + +private: + struct MempoolInfo { + int transaction_count{0}; + double usage_mb{0.0}; + double max_usage_mb{0.0}; + }; + + struct RuntimeDialogRequest { + QString message; + QString caption; + unsigned int style{0}; + bool question{false}; + bool answer{false}; + bool answered{false}; + unsigned int button{0}; + QEventLoop* loop{nullptr}; + }; + + // Properties that are exposed to QML. + int m_block_tip_height{0}; + int m_num_peers{0}; + int m_num_inbound_peers{0}; + int m_num_outbound_peers{0}; + static constexpr int m_max_num_outbound_peers{MAX_OUTBOUND_FULL_RELAY_CONNECTIONS + MAX_BLOCK_RELAY_ONLY_CONNECTIONS}; + int m_mempool_transaction_count{0}; + double m_mempool_usage_mb{0.0}; + double m_mempool_max_usage_mb{0.0}; + bool m_mempool_info_polling_active{false}; + bool m_mempool_information_available{true}; + int m_remaining_sync_time{0}; + double m_verification_progress{0.0}; + bool m_block_sync_active{false}; + bool m_pause{false}; + bool m_faulted{false}; + QString m_startup_error; + QStringList m_startup_error_messages; + QStringList m_startup_warning_messages; + QString m_warnings; + QStringList m_warning_list; + bool m_header_sync_active{false}; + bool m_header_presync{false}; + double m_header_sync_progress{0.0}; + int m_header_tip_height{0}; + int64_t m_header_tip_time{0}; + bool m_node_ready{false}; + bool m_initialization_requested{false}; + bool m_shutdown_requested{false}; + bool m_runtime_dialogs_enabled{false}; + bool m_startup_failure_dialog_shown{false}; + bool m_runtime_dialog_visible{false}; + bool m_runtime_dialog_question{false}; + QString m_runtime_dialog_title; + QString m_runtime_dialog_message; + QString m_runtime_dialog_icon; + unsigned int m_runtime_dialog_buttons{0}; + std::shared_ptr m_runtime_dialog_active; + std::deque> m_runtime_dialog_queue; + + int m_shutdown_polling_timer_id{0}; + + QVector> m_block_process_time; + + interfaces::Node& m_node; + QObject* m_mempool_info_worker{nullptr}; + QThread* m_mempool_info_thread{nullptr}; + QTimer* m_mempool_info_timer{nullptr}; + std::unique_ptr m_handler_notify_block_tip; + std::unique_ptr m_handler_notify_header_tip; + std::unique_ptr m_handler_notify_num_peers_changed; + std::unique_ptr m_handler_notify_network_active_changed; + std::unique_ptr m_handler_notify_alert_changed; + std::unique_ptr m_handler_message_box; + std::unique_ptr m_handler_question; + std::unique_ptr m_handler_notify_banned_list_changed; + + void ConnectToBlockTipSignal(); + void ConnectToHeaderTipSignal(); + void ConnectToNumConnectionsChangedSignal(); + void ConnectToNetworkActiveChangedSignal(); + void ConnectToAlertChangedSignal(); + void ConnectToRuntimeDialogSignals(); + void ConnectToBannedListChangedSignal(); + void unsubscribeFromCoreSignals(); + void initializeMempoolInfoPolling(); + void refreshPeerCounts(); + void refreshWarnings(); + void recordStartupErrorMessage(const QString& message); + void recordStartupWarningMessage(const QString& message); + void showStartupWarnings(); + void setWarnings(const QString& warnings); + void setBlockSyncActive(bool active); + void setHeaderSyncState(int height, int64_t block_time, bool presync); + bool showRuntimeDialog(const QString& message, const QString& caption, unsigned int style, bool question); + bool showRuntimeDialogOnGuiThread(const QString& message, const QString& caption, unsigned int style, bool question); + void showRuntimeDialogRequest(const std::shared_ptr& request); + void requestMempoolInfoRefresh(); + void fetchMempoolInfo(); + void applyMempoolInfo(const MempoolInfo& info); +}; + +#endif // BITCOIN_QML_MODELS_NODEMODEL_H diff --git a/src/qml/models/options_model.cpp b/src/qml/models/options_model.cpp new file mode 100644 index 0000000000..ec99b0470d --- /dev/null +++ b/src/qml/models/options_model.cpp @@ -0,0 +1,534 @@ +// Copyright (c) 2022 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { +int PruneMiBtoGB(int64_t mib) +{ + return (mib * 1024 * 1024 + GB_BYTES - 1) / GB_BYTES; +} + +int64_t PruneGBtoMiB(int gb) +{ + return gb * GB_BYTES / 1024 / 1024; +} + +QString NormalizeCommandPath(const QString& path) +{ + const QString trimmed = path.trimmed(); + if (trimmed.isEmpty()) { + return {}; + } + + const QUrl url(trimmed); + return url.isLocalFile() ? url.toLocalFile() : trimmed; +} + +QString FirstCommandToken(const QString& command) +{ + static const QRegularExpression TOKEN_RE(QStringLiteral(R"re(^\s*(?:"([^"]+)"|'([^']+)'|(\S+)))re")); + const auto match = TOKEN_RE.match(command); + if (!match.hasMatch()) { + return {}; + } + for (int i = 1; i <= 3; ++i) { + const QString captured = match.captured(i); + if (!captured.isEmpty()) { + return captured; + } + } + return {}; +} + +QString ExpandUserPath(const QString& path) +{ + if (path == QStringLiteral("~")) { + return QDir::homePath(); + } + if (path.startsWith(QStringLiteral("~/"))) { + return QDir::homePath() + path.mid(1); + } + return path; +} + +bool TokenLooksLikePath(const QString& token) +{ + return token.startsWith(QStringLiteral("/")) || + token.startsWith(QStringLiteral("./")) || + token.startsWith(QStringLiteral("../")) || + token.startsWith(QStringLiteral("~/")) || + token.contains(QLatin1Char('/')) || + token.contains(QLatin1Char('\\')) || + QDir::isAbsolutePath(token); +} +} // namespace + +OptionsQmlModel::OptionsQmlModel(interfaces::Node& node, bool is_onboarded) + : m_node{node} + , m_onboarded{is_onboarded} +{ + m_dbcache_size_mib = SettingToInt(m_node.getPersistentSetting("dbcache"), DEFAULT_DB_CACHE >> 20); + + m_listen = SettingToBool(m_node.getPersistentSetting("listen"), DEFAULT_LISTEN); + + m_max_mempool_size_mb = SettingToInt(m_node.getPersistentSetting("maxmempool"), DEFAULT_MAX_MEMPOOL_SIZE_MB); + + m_natpmp = SettingToBool(m_node.getPersistentSetting("natpmp"), DEFAULT_NATPMP); + + int64_t prune_value{SettingToInt(m_node.getPersistentSetting("prune"), 0)}; + m_prune = (prune_value > 1); + m_prune_size_gb = m_prune ? PruneMiBtoGB(prune_value) : DEFAULT_PRUNE_TARGET_GB; + + m_script_threads = SettingToInt(m_node.getPersistentSetting("par"), DEFAULT_SCRIPTCHECK_THREADS); + + m_server = SettingToBool(m_node.getPersistentSetting("server"), false); + + m_dataDir = getDefaultDataDirString(); + + QString proxy_setting = QString::fromStdString(SettingToString(m_node.getPersistentSetting("proxy"), "")); + if (proxy_setting == "0") proxy_setting.clear(); + m_proxy_enabled = !proxy_setting.isEmpty(); + m_proxy_address = proxy_setting; + + QString onion_setting = QString::fromStdString(SettingToString(m_node.getPersistentSetting("onion"), "")); + if (onion_setting == "0") onion_setting.clear(); + m_tor_enabled = !onion_setting.isEmpty(); + m_tor_address = onion_setting; + + m_external_signer_path = QString::fromStdString(SettingToString(m_node.getPersistentSetting("signer"), "")); + + m_initial_proxy_enabled = m_proxy_enabled; + m_initial_proxy_address = m_proxy_address; + m_initial_tor_enabled = m_tor_enabled; + m_initial_tor_address = m_tor_address; + m_initial_external_signer_path = m_external_signer_path; + + QSettings settings; + m_language = settings.value(SettingsKeys::LANGUAGE, "").toString(); + m_display_unit = settings.value(SettingsKeys::DISPLAY_UNIT, 0).toInt(); + + buildAvailableLanguages(); +} + +void OptionsQmlModel::setDbcacheSizeMiB(int new_dbcache_size_mib) +{ + if (new_dbcache_size_mib != m_dbcache_size_mib) { + m_dbcache_size_mib = new_dbcache_size_mib; + if (m_onboarded) { + m_node.updateRwSetting("dbcache", new_dbcache_size_mib); + } + Q_EMIT dbcacheSizeMiBChanged(new_dbcache_size_mib); + } +} + +void OptionsQmlModel::setListen(bool new_listen) +{ + if (new_listen != m_listen) { + m_listen = new_listen; + if (m_onboarded) { + m_node.updateRwSetting("listen", new_listen); + } + Q_EMIT listenChanged(new_listen); + } +} + +void OptionsQmlModel::setMaxMempoolSizeMB(int new_max_mempool_size_mb) +{ + if (new_max_mempool_size_mb != m_max_mempool_size_mb) { + m_max_mempool_size_mb = new_max_mempool_size_mb; + if (m_onboarded) { + m_node.updateRwSetting("maxmempool", new_max_mempool_size_mb); + } + Q_EMIT maxMempoolSizeMBChanged(new_max_mempool_size_mb); + } +} + +void OptionsQmlModel::setNatpmp(bool new_natpmp) +{ + if (new_natpmp != m_natpmp) { + m_natpmp = new_natpmp; + if (m_onboarded) { + m_node.updateRwSetting("natpmp", new_natpmp); + } + Q_EMIT natpmpChanged(new_natpmp); + } +} + +void OptionsQmlModel::setPrune(bool new_prune) +{ + if (new_prune != m_prune) { + m_prune = new_prune; + if (m_onboarded) { + m_node.updateRwSetting("prune", pruneSetting()); + } + Q_EMIT pruneChanged(new_prune); + } +} + +void OptionsQmlModel::setPruneSizeGB(int new_prune_size_gb) +{ + if (new_prune_size_gb != m_prune_size_gb) { + m_prune_size_gb = new_prune_size_gb; + if (m_onboarded) { + m_node.updateRwSetting("prune", pruneSetting()); + } + Q_EMIT pruneSizeGBChanged(new_prune_size_gb); + } +} + +void OptionsQmlModel::setScriptThreads(int new_script_threads) +{ + if (new_script_threads != m_script_threads) { + m_script_threads = new_script_threads; + if (m_onboarded) { + m_node.updateRwSetting("par", new_script_threads); + } + Q_EMIT scriptThreadsChanged(new_script_threads); + } +} + +void OptionsQmlModel::setServer(bool new_server) +{ + if (new_server != m_server) { + m_server = new_server; + if (m_onboarded) { + m_node.updateRwSetting("server", new_server); + } + Q_EMIT serverChanged(new_server); + } +} + +void OptionsQmlModel::setProxyEnabled(bool enabled) +{ + if (enabled != m_proxy_enabled) { + bool was_dirty = proxySettingsDirty(); + m_proxy_enabled = enabled; + if (m_onboarded) { + if (enabled && !m_proxy_address.isEmpty()) { + m_node.updateRwSetting("proxy", m_proxy_address.toStdString()); + } else { + m_node.updateRwSetting("proxy", common::SettingsValue{}); + } + } + if (proxySettingsDirty() != was_dirty) { + Q_EMIT proxySettingsDirtyChanged(); + } + Q_EMIT proxyEnabledChanged(enabled); + } +} + +void OptionsQmlModel::setProxyAddress(const QString& address) +{ + if (address != m_proxy_address) { + bool was_dirty = proxySettingsDirty(); + m_proxy_address = address; + if (m_onboarded && m_proxy_enabled) { + m_node.updateRwSetting("proxy", address.toStdString()); + } + if (proxySettingsDirty() != was_dirty) { + Q_EMIT proxySettingsDirtyChanged(); + } + Q_EMIT proxyAddressChanged(address); + } +} + +void OptionsQmlModel::setTorEnabled(bool enabled) +{ + if (enabled != m_tor_enabled) { + bool was_dirty = proxySettingsDirty(); + m_tor_enabled = enabled; + if (m_onboarded) { + if (enabled && !m_tor_address.isEmpty()) { + m_node.updateRwSetting("onion", m_tor_address.toStdString()); + } else { + m_node.updateRwSetting("onion", common::SettingsValue{}); + } + } + if (proxySettingsDirty() != was_dirty) { + Q_EMIT proxySettingsDirtyChanged(); + } + Q_EMIT torEnabledChanged(enabled); + } +} + +void OptionsQmlModel::setTorAddress(const QString& address) +{ + if (address != m_tor_address) { + bool was_dirty = proxySettingsDirty(); + m_tor_address = address; + if (m_onboarded && m_tor_enabled) { + m_node.updateRwSetting("onion", address.toStdString()); + } + if (proxySettingsDirty() != was_dirty) { + Q_EMIT proxySettingsDirtyChanged(); + } + Q_EMIT torAddressChanged(address); + } +} + +void OptionsQmlModel::setExternalSignerPath(const QString& path) +{ + const QString normalized_path = NormalizeCommandPath(path); + if (normalized_path != m_external_signer_path) { + bool was_dirty = walletSettingsDirty(); + m_external_signer_path = normalized_path; + if (m_external_signer_path.isEmpty()) { + m_node.forceSetting("signer", common::SettingsValue{}); + } else { + m_node.forceSetting("signer", m_external_signer_path.toStdString()); + } + if (m_onboarded) { + if (m_external_signer_path.isEmpty()) { + m_node.updateRwSetting("signer", common::SettingsValue{}); + } else { + m_node.updateRwSetting("signer", m_external_signer_path.toStdString()); + } + } + if (walletSettingsDirty() != was_dirty) { + Q_EMIT walletSettingsDirtyChanged(); + } + Q_EMIT externalSignerPathChanged(m_external_signer_path); + } +} + +QString OptionsQmlModel::externalSignerPathValidationError(const QString& path) const +{ + const QString normalized_path = NormalizeCommandPath(path); + if (normalized_path.isEmpty()) { + return {}; + } + + const QString token = FirstCommandToken(normalized_path); + if (token.isEmpty() || !TokenLooksLikePath(token)) { + return {}; + } + + const QFileInfo info(ExpandUserPath(token)); + if (!info.exists()) { + return tr("The configured signer path does not exist."); + } + if (!info.isFile()) { + return tr("The configured signer path is not a file."); + } + if (!info.isExecutable()) { + return tr("The configured signer path is not executable."); + } + return {}; +} + +common::SettingsValue OptionsQmlModel::pruneSetting() const +{ + assert(!m_prune || m_prune_size_gb >= 1); + return m_prune ? PruneGBtoMiB(m_prune_size_gb) : 0; +} + +QString PathToQString(const fs::path &path) +{ + return QString::fromStdString(path.utf8string()); +} + +QString OptionsQmlModel::getDefaultDataDirString() +{ + return PathToQString(GetDefaultDataDir()); +} + + +QUrl OptionsQmlModel::getDefaultDataDirectory() +{ + QString path = getDefaultDataDirString(); + return QUrl::fromLocalFile(path); +} + +bool OptionsQmlModel::setCustomDataDirArgs(QString path) +{ + if (!path.isEmpty()) { + // TODO: add actual custom data wiring +#ifdef __ANDROID__ + QString uri = path; + QString originalPrefix = "content://com.android.externalstorage.documents/tree/primary%3A"; + QString newPrefix = "/storage/self/primary/"; + QString path = uri.replace(originalPrefix, newPrefix); +#else + path = QUrl(path).toLocalFile(); +#endif // __ANDROID__ + qDebug() << "PlaceHolder: Created data directory: " << path; + + m_custom_datadir_string = path; + Q_EMIT customDataDirStringChanged(path); + setDataDir(path); + return true; + } + return false; +} + +QString OptionsQmlModel::getCustomDataDirString() +{ +#ifdef __ANDROID__ + m_custom_datadir_string = m_custom_datadir_string.replace("content://com.android.externalstorage.documents/tree/primary%3A", "/storage/self/primary/"); +#endif // __ANDROID__ + return m_custom_datadir_string; +} + +void OptionsQmlModel::setDataDir(QString new_data_dir) +{ + if (new_data_dir != m_dataDir) { + m_dataDir = new_data_dir; + if (!getCustomDataDirString().isEmpty() && (new_data_dir != getDefaultDataDirString())) { + m_dataDir = getCustomDataDirString(); + } else { + m_dataDir = getDefaultDataDirString(); + } + Q_EMIT dataDirChanged(new_data_dir); + } +} + +void OptionsQmlModel::buildAvailableLanguages() +{ + m_available_languages.clear(); + m_available_languages << ""; // empty = system default + + QDir translations_dir(":/translations"); + QStringList files = translations_dir.entryList({"bitcoin_*.qm"}, QDir::Files); + QStringList tags; + for (const QString& file : files) { + // Strip "bitcoin_" prefix and ".qm" suffix to get locale tag. + QString tag = file; + tag.remove(0, 8); // remove "bitcoin_" + tag.chop(3); // remove ".qm" + // Skip QML-app-specific translation resources (e.g. "qml_es"). + if (tag.startsWith(QStringLiteral("qml_"))) continue; + tags << tag; + } + tags.sort(Qt::CaseInsensitive); + m_available_languages << tags; +} + +void OptionsQmlModel::setLanguage(const QString& new_language) +{ + if (new_language != m_language) { + m_language = new_language; + QSettings settings; + settings.setValue(SettingsKeys::LANGUAGE, m_language); + Q_EMIT languageChanged(); + } +} + +QString OptionsQmlModel::languageSummary() const +{ + return languageLabel(m_language); +} + +QString OptionsQmlModel::languageLabel(const QString& locale_tag) const +{ + if (locale_tag.isEmpty()) { + return QObject::tr("System default"); + } + QLocale locale(locale_tag); + QString native = locale.nativeLanguageName(); + if (native.isEmpty()) { + return locale_tag; + } + // Capitalize first letter of native name. + native[0] = native[0].toUpper(); + QString english = QLocale::languageToString(locale.language()); + // Append territory disambiguation when the tag includes a territory code. + if (locale_tag.contains('_')) { + QString native_territory = locale.nativeTerritoryName(); + if (!native_territory.isEmpty()) { + native += QStringLiteral(" (%1)").arg(native_territory); + } + english += QStringLiteral(" (%1)").arg(QLocale::territoryToString(locale.territory())); + } + return QStringLiteral("%1 \u2014 %2").arg(native, english); +} + +void OptionsQmlModel::setDisplayUnit(int new_display_unit) +{ + if (new_display_unit != m_display_unit) { + m_display_unit = new_display_unit; + QSettings settings; + settings.setValue(SettingsKeys::DISPLAY_UNIT, m_display_unit); + Q_EMIT displayUnitChanged(m_display_unit); + } +} + +QString OptionsQmlModel::displayUnitLabel() const +{ + return (m_display_unit == 1) ? QStringLiteral("sat") : QStringLiteral("BTC"); +} + +QString OptionsQmlModel::displayUnitLabelForAmount(qint64 satoshi) const +{ + if (m_display_unit != 1) return QStringLiteral("₿"); + return (qAbs(satoshi) == 1) ? QStringLiteral("sat") : QStringLiteral("sats"); +} + + +void OptionsQmlModel::onboard() +{ + m_node.resetSettings(); + if (m_external_signer_path.isEmpty()) { + m_node.forceSetting("signer", common::SettingsValue{}); + } else { + m_node.forceSetting("signer", m_external_signer_path.toStdString()); + } + if (m_dbcache_size_mib != DEFAULT_DB_CACHE >> 20) { + m_node.updateRwSetting("dbcache", m_dbcache_size_mib); + } + if (m_listen) { + m_node.updateRwSetting("listen", m_listen); + } + if (m_natpmp) { + m_node.updateRwSetting("natpmp", m_natpmp); + } + if (m_prune) { + m_node.updateRwSetting("prune", pruneSetting()); + } + if (m_script_threads != DEFAULT_SCRIPTCHECK_THREADS) { + m_node.updateRwSetting("par", m_script_threads); + } + if (m_server) { + m_node.updateRwSetting("server", m_server); + } + if (m_proxy_enabled && !m_proxy_address.isEmpty()) { + m_node.updateRwSetting("proxy", m_proxy_address.toStdString()); + } + if (m_tor_enabled && !m_tor_address.isEmpty()) { + m_node.updateRwSetting("onion", m_tor_address.toStdString()); + } + if (!m_external_signer_path.isEmpty()) { + m_node.updateRwSetting("signer", m_external_signer_path.toStdString()); + } + m_onboarded = true; + m_initial_proxy_enabled = m_proxy_enabled; + m_initial_proxy_address = m_proxy_address; + m_initial_tor_enabled = m_tor_enabled; + m_initial_tor_address = m_tor_address; + m_initial_external_signer_path = m_external_signer_path; +} diff --git a/src/qml/models/options_model.h b/src/qml/models/options_model.h new file mode 100644 index 0000000000..402c436593 --- /dev/null +++ b/src/qml/models/options_model.h @@ -0,0 +1,196 @@ +// Copyright (c) 2022 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QML_MODELS_OPTIONS_MODEL_H +#define BITCOIN_QML_MODELS_OPTIONS_MODEL_H + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace interfaces { +class Node; +} + +/** Model for Bitcoin client options. */ +class OptionsQmlModel : public QObject +{ + Q_OBJECT + Q_PROPERTY(int dbcacheSizeMiB READ dbcacheSizeMiB WRITE setDbcacheSizeMiB NOTIFY dbcacheSizeMiBChanged) + Q_PROPERTY(bool listen READ listen WRITE setListen NOTIFY listenChanged) + Q_PROPERTY(int maxMempoolSizeMB READ maxMempoolSizeMB WRITE setMaxMempoolSizeMB NOTIFY maxMempoolSizeMBChanged) + Q_PROPERTY(int maxMaxMempoolSizeMB READ maxMaxMempoolSizeMB CONSTANT) + Q_PROPERTY(int minMaxMempoolSizeMB READ minMaxMempoolSizeMB CONSTANT) + Q_PROPERTY(int maxDbcacheSizeMiB READ maxDbcacheSizeMiB CONSTANT) + Q_PROPERTY(int minDbcacheSizeMiB READ minDbcacheSizeMiB CONSTANT) + Q_PROPERTY(int maxScriptThreads READ maxScriptThreads CONSTANT) + Q_PROPERTY(int minScriptThreads READ minScriptThreads CONSTANT) + Q_PROPERTY(bool natpmp READ natpmp WRITE setNatpmp NOTIFY natpmpChanged) + Q_PROPERTY(bool prune READ prune WRITE setPrune NOTIFY pruneChanged) + Q_PROPERTY(int pruneSizeGB READ pruneSizeGB WRITE setPruneSizeGB NOTIFY pruneSizeGBChanged) + Q_PROPERTY(int scriptThreads READ scriptThreads WRITE setScriptThreads NOTIFY scriptThreadsChanged) + Q_PROPERTY(bool server READ server WRITE setServer NOTIFY serverChanged) + Q_PROPERTY(QString dataDir READ dataDir WRITE setDataDir NOTIFY dataDirChanged) + Q_PROPERTY(QString getDefaultDataDirString READ getDefaultDataDirString CONSTANT) + Q_PROPERTY(QUrl getDefaultDataDirectory READ getDefaultDataDirectory CONSTANT) + Q_PROPERTY(bool proxyEnabled READ proxyEnabled WRITE setProxyEnabled NOTIFY proxyEnabledChanged) + Q_PROPERTY(QString proxyAddress READ proxyAddress WRITE setProxyAddress NOTIFY proxyAddressChanged) + Q_PROPERTY(bool torEnabled READ torEnabled WRITE setTorEnabled NOTIFY torEnabledChanged) + Q_PROPERTY(QString torAddress READ torAddress WRITE setTorAddress NOTIFY torAddressChanged) + Q_PROPERTY(QString externalSignerPath READ externalSignerPath WRITE setExternalSignerPath NOTIFY externalSignerPathChanged) + Q_PROPERTY(bool proxySettingsDirty READ proxySettingsDirty NOTIFY proxySettingsDirtyChanged) + Q_PROPERTY(bool walletSettingsDirty READ walletSettingsDirty NOTIFY walletSettingsDirtyChanged) + Q_PROPERTY(QString language READ language WRITE setLanguage NOTIFY languageChanged) + Q_PROPERTY(QString languageSummary READ languageSummary NOTIFY languageChanged) + Q_PROPERTY(QStringList availableLanguages READ availableLanguages CONSTANT) + Q_PROPERTY(int displayUnit READ displayUnit WRITE setDisplayUnit NOTIFY displayUnitChanged) + Q_PROPERTY(QString displayUnitLabel READ displayUnitLabel NOTIFY displayUnitChanged) + +public: + explicit OptionsQmlModel(interfaces::Node& node, bool is_onboarded); + + int dbcacheSizeMiB() const { return m_dbcache_size_mib; } + void setDbcacheSizeMiB(int new_dbcache_size_mib); + bool listen() const { return m_listen; } + void setListen(bool new_listen); + int maxMempoolSizeMB() const { return m_max_mempool_size_mb; } + void setMaxMempoolSizeMB(int new_max_mempool_size_mb); + int maxMaxMempoolSizeMB() const { return m_max_max_mempool_size_mb; } + int minMaxMempoolSizeMB() const { return m_min_max_mempool_size_mb; } + int maxDbcacheSizeMiB() const { return m_max_dbcache_size_mib; } + int minDbcacheSizeMiB() const { return m_min_dbcache_size_mib; } + int maxScriptThreads() const { return m_max_script_threads; } + int minScriptThreads() const { return m_min_script_threads; } + bool natpmp() const { return m_natpmp; } + void setNatpmp(bool new_natpmp); + bool prune() const { return m_prune; } + void setPrune(bool new_prune); + int pruneSizeGB() const { return m_prune_size_gb; } + void setPruneSizeGB(int new_prune_size); + int scriptThreads() const { return m_script_threads; } + void setScriptThreads(int new_script_threads); + bool server() const { return m_server; } + void setServer(bool new_server); + QString dataDir() const { return m_dataDir; } + void setDataDir(QString new_data_dir); + QString getDefaultDataDirString(); + QUrl getDefaultDataDirectory(); + Q_INVOKABLE bool setCustomDataDirArgs(QString path); + Q_INVOKABLE QString getCustomDataDirString(); + Q_INVOKABLE QString externalSignerPathValidationError(const QString& path) const; + bool proxyEnabled() const { return m_proxy_enabled; } + void setProxyEnabled(bool enabled); + QString proxyAddress() const { return m_proxy_address; } + void setProxyAddress(const QString& address); + bool torEnabled() const { return m_tor_enabled; } + void setTorEnabled(bool enabled); + QString torAddress() const { return m_tor_address; } + void setTorAddress(const QString& address); + QString externalSignerPath() const { return m_external_signer_path; } + void setExternalSignerPath(const QString& path); + bool proxySettingsDirty() const { + if (!m_onboarded) return false; + if (m_proxy_enabled != m_initial_proxy_enabled) return true; + if (m_proxy_enabled && m_proxy_address != m_initial_proxy_address) return true; + if (m_tor_enabled != m_initial_tor_enabled) return true; + if (m_tor_enabled && m_tor_address != m_initial_tor_address) return true; + return false; + } + bool walletSettingsDirty() const { + if (!m_onboarded) return false; + return m_external_signer_path != m_initial_external_signer_path; + } + QString language() const { return m_language; } + void setLanguage(const QString& new_language); + QString languageSummary() const; + QStringList availableLanguages() const { return m_available_languages; } + Q_INVOKABLE QString languageLabel(const QString& locale_tag) const; + int displayUnit() const { return m_display_unit; } + void setDisplayUnit(int new_display_unit); + QString displayUnitLabel() const; + Q_INVOKABLE QString displayUnitLabelForAmount(qint64 satoshi) const; + +public Q_SLOTS: + void setCustomDataDirString(const QString &new_custom_datadir_string) { + m_custom_datadir_string = new_custom_datadir_string; + } + Q_INVOKABLE void onboard(); + +Q_SIGNALS: + void dbcacheSizeMiBChanged(int new_dbcache_size_mib); + void listenChanged(bool new_listen); + void maxMempoolSizeMBChanged(int new_max_mempool_size_mb); + void natpmpChanged(bool new_natpmp); + void pruneChanged(bool new_prune); + void pruneSizeGBChanged(int new_prune_size_gb); + void scriptThreadsChanged(int new_script_threads); + void serverChanged(bool new_server); + void customDataDirStringChanged(QString new_custom_datadir_string); + void dataDirChanged(QString new_data_dir); + void proxyEnabledChanged(bool enabled); + void proxyAddressChanged(QString address); + void torEnabledChanged(bool enabled); + void torAddressChanged(QString address); + void externalSignerPathChanged(QString path); + void proxySettingsDirtyChanged(); + void walletSettingsDirtyChanged(); + void languageChanged(); + void displayUnitChanged(int new_display_unit); + +private: + interfaces::Node& m_node; + bool m_onboarded; + + // Properties that are exposed to QML. + int m_dbcache_size_mib; + const int m_min_dbcache_size_mib{MIN_DB_CACHE >> 20}; + const int m_max_dbcache_size_mib{MAX_COINS_DB_CACHE >> 20}; + bool m_listen; + int m_max_mempool_size_mb; + const int m_min_max_mempool_size_mb{ + static_cast((DEFAULT_DESCENDANT_SIZE_LIMIT_KVB * 1000 * 40 + 999999) / 1000000) + }; + const int m_max_max_mempool_size_mb{ + sizeof(void*) <= 4 ? 500 : 99999 + }; + const int m_max_script_threads{MAX_SCRIPTCHECK_THREADS}; + const int m_min_script_threads{-GetNumCores()}; + bool m_natpmp; + bool m_prune; + int m_prune_size_gb; + int m_script_threads; + bool m_server; + QString m_custom_datadir_string; + QString m_dataDir; + bool m_proxy_enabled; + QString m_proxy_address; + bool m_tor_enabled; + QString m_tor_address; + QString m_external_signer_path; + bool m_initial_proxy_enabled; + QString m_initial_proxy_address; + bool m_initial_tor_enabled; + QString m_initial_tor_address; + QString m_initial_external_signer_path; + QString m_language; + QStringList m_available_languages; + int m_display_unit{0}; + + common::SettingsValue pruneSetting() const; + void buildAvailableLanguages(); +}; + +#endif // BITCOIN_QML_MODELS_OPTIONS_MODEL_H diff --git a/src/qml/models/paymentrequest.cpp b/src/qml/models/paymentrequest.cpp new file mode 100644 index 0000000000..c9e56f7413 --- /dev/null +++ b/src/qml/models/paymentrequest.cpp @@ -0,0 +1,261 @@ +// Copyright (c) 2024 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include + +#include + +#include + +PaymentRequest::PaymentRequest(QObject* parent) + : QObject(parent) +{ + m_amount = new BitcoinAmount(this); + connect(m_amount, &BitcoinAmount::amountChanged, this, &PaymentRequest::qrPayloadChanged); + connect(this, &PaymentRequest::addressChanged, this, &PaymentRequest::qrPayloadChanged); + connect(this, &PaymentRequest::labelChanged, this, &PaymentRequest::qrPayloadChanged); + connect(this, &PaymentRequest::messageChanged, this, &PaymentRequest::qrPayloadChanged); +} + +QString PaymentRequest::address() const +{ + return QString::fromStdString(EncodeDestination(m_destination)); +} + +QString PaymentRequest::addressFormatted() const +{ + return FormatAddress(address()); +} + +QString PaymentRequest::addressType() const +{ + const QString derived_type{std::visit([](const auto& dest) -> QString { + using T = std::decay_t; + if constexpr (std::is_same_v) { + return QStringLiteral("Legacy"); + } else if constexpr (std::is_same_v) { + return QStringLiteral("P2SH-SegWit"); + } else if constexpr (std::is_same_v || std::is_same_v) { + return QStringLiteral("Bech32"); + } else if constexpr (std::is_same_v) { + return QStringLiteral("Bech32m"); + } else { + return {}; + } + }, m_destination)}; + return derived_type.isEmpty() ? m_address_type : derived_type; +} + +QString PaymentRequest::label() const +{ + return m_label; +} + +void PaymentRequest::setLabel(const QString& label) +{ + if (m_label == label) { + return; + } + m_label = label; + Q_EMIT labelChanged(); +} + +QString PaymentRequest::message() const +{ + return m_message; +} + +void PaymentRequest::setMessage(const QString& message) +{ + if (m_message == message) { + return; + } + m_message = message; + Q_EMIT messageChanged(); +} + +void PaymentRequest::setAddressType(const QString& address_type) +{ + if (m_address_type == address_type) { + return; + } + m_address_type = address_type; + Q_EMIT addressTypeChanged(); +} + +QString PaymentRequest::noteSelf() const +{ + return m_noteSelf; +} + +void PaymentRequest::setNoteSelf(const QString& note) +{ + if (m_noteSelf == note) { + return; + } + m_noteSelf = note; + Q_EMIT noteSelfChanged(); +} + +BitcoinAmount* PaymentRequest::amount() const +{ + return m_amount; +} + +QString PaymentRequest::amountError() const +{ + return m_amountError; +} + +void PaymentRequest::setAmountError(const QString& error) +{ + if (m_amountError == error) { + return; + } + m_amountError = error; + Q_EMIT amountErrorChanged(); +} + +QString PaymentRequest::id() const +{ + return m_id; +} + +void PaymentRequest::setId(unsigned int id) +{ + const QString new_id = QString::number(id); + if (m_id == new_id) { + return; + } + m_id = new_id; + Q_EMIT idChanged(); +} + +bool PaymentRequest::needsUnlock() const +{ + return m_needs_unlock; +} + +void PaymentRequest::setNeedsUnlock(bool needs_unlock) +{ + if (m_needs_unlock == needs_unlock) { + return; + } + m_needs_unlock = needs_unlock; + Q_EMIT needsUnlockChanged(); +} + +QString PaymentRequest::unlockError() const +{ + return m_unlock_error; +} + +void PaymentRequest::setUnlockError(const QString& error) +{ + if (m_unlock_error == error) { + return; + } + m_unlock_error = error; + Q_EMIT unlockErrorChanged(); +} + +void PaymentRequest::setDestination(const CTxDestination& destination) +{ + m_destination = destination; + Q_EMIT addressChanged(); + Q_EMIT addressTypeChanged(); +} + +CTxDestination PaymentRequest::destination() const +{ + return m_destination; +} + +QString PaymentRequest::qrPayload() const +{ + const QString addr = address(); + if (addr.isEmpty()) return {}; + return ReceiveRequestHistoryModel::BuildBitcoinUri(addr, m_amount->satoshi(), m_label, m_message); +} + +QString PaymentRequest::createdIso() const +{ + return m_created.isValid() ? m_created.toString(Qt::ISODate) : QString(); +} + +QDateTime PaymentRequest::created() const +{ + return m_created; +} + +void PaymentRequest::setCreated(const QDateTime& dt) +{ + if (m_created == dt) return; + m_created = dt; + Q_EMIT createdIsoChanged(); +} + +bool PaymentRequest::hasPaymentInfo() const +{ + return !m_label.isEmpty() || !m_message.isEmpty() || m_amount->satoshi() > 0; +} + +bool PaymentRequest::isEditing() const +{ + return m_is_editing; +} + +void PaymentRequest::setIsEditing(bool editing) +{ + if (m_is_editing == editing) return; + m_is_editing = editing; + Q_EMIT isEditingChanged(); +} + +void PaymentRequest::clear() +{ + m_destination = CNoDestination(); + m_label.clear(); + m_message.clear(); + m_address_type.clear(); + m_noteSelf.clear(); + m_amount->clear(); + m_amountError.clear(); + m_id.clear(); + m_needs_unlock = false; + m_unlock_error.clear(); + m_created = QDateTime(); + m_is_editing = true; + Q_EMIT addressChanged(); + Q_EMIT labelChanged(); + Q_EMIT messageChanged(); + Q_EMIT addressTypeChanged(); + Q_EMIT noteSelfChanged(); + Q_EMIT amountErrorChanged(); + Q_EMIT idChanged(); + Q_EMIT needsUnlockChanged(); + Q_EMIT unlockErrorChanged(); + Q_EMIT createdIsoChanged(); + Q_EMIT isEditingChanged(); +} + +void PaymentRequest::edit() +{ + setIsEditing(true); +} + +QString PaymentRequest::FormatAddress(const QString& address) +{ + QString formatted; + formatted.reserve(address.length() + address.length() / 4); + for (int i = 0; i < address.length(); ++i) { + if (i > 0 && (i % 4) == 0) { + formatted += QChar(' '); + } + formatted += address[i]; + } + return formatted; +} diff --git a/src/qml/models/paymentrequest.h b/src/qml/models/paymentrequest.h new file mode 100644 index 0000000000..dc374980f9 --- /dev/null +++ b/src/qml/models/paymentrequest.h @@ -0,0 +1,113 @@ +// Copyright (c) 2024 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QML_MODELS_PAYMENTREQUEST_H +#define BITCOIN_QML_MODELS_PAYMENTREQUEST_H + +#include + +#include + +#include +#include +#include + +class PaymentRequest : public QObject +{ + Q_OBJECT + Q_PROPERTY(QString address READ address NOTIFY addressChanged) + Q_PROPERTY(QString addressFormatted READ addressFormatted NOTIFY addressChanged) + Q_PROPERTY(QString label READ label WRITE setLabel NOTIFY labelChanged) + Q_PROPERTY(QString message READ message WRITE setMessage NOTIFY messageChanged) + Q_PROPERTY(QString addressType READ addressType WRITE setAddressType NOTIFY addressTypeChanged) + Q_PROPERTY(QString noteSelf READ noteSelf WRITE setNoteSelf NOTIFY noteSelfChanged) + Q_PROPERTY(BitcoinAmount* amount READ amount CONSTANT) + Q_PROPERTY(QString amountError READ amountError NOTIFY amountErrorChanged) + Q_PROPERTY(QString id READ id NOTIFY idChanged) + Q_PROPERTY(bool needsUnlock READ needsUnlock NOTIFY needsUnlockChanged) + Q_PROPERTY(QString unlockError READ unlockError NOTIFY unlockErrorChanged) + Q_PROPERTY(QString qrPayload READ qrPayload NOTIFY qrPayloadChanged) + Q_PROPERTY(QString createdIso READ createdIso NOTIFY createdIsoChanged) + Q_PROPERTY(bool hasPaymentInfo READ hasPaymentInfo NOTIFY qrPayloadChanged) + Q_PROPERTY(bool isEditing READ isEditing WRITE setIsEditing NOTIFY isEditingChanged) + +public: + explicit PaymentRequest(QObject* parent = nullptr); + + QString address() const; + QString addressFormatted() const; + + QString label() const; + void setLabel(const QString& label); + + QString message() const; + void setMessage(const QString& message); + + QString addressType() const; + void setAddressType(const QString& address_type); + + QString noteSelf() const; + void setNoteSelf(const QString& note); + + BitcoinAmount* amount() const; + QString amountError() const; + void setAmountError(const QString& error); + + QString id() const; + void setId(unsigned int id); + + bool needsUnlock() const; + void setNeedsUnlock(bool needs_unlock); + + QString unlockError() const; + void setUnlockError(const QString& error); + + void setDestination(const CTxDestination& destination); + CTxDestination destination() const; + + QString qrPayload() const; + + QString createdIso() const; + QDateTime created() const; + void setCreated(const QDateTime& dt); + bool hasPaymentInfo() const; + + bool isEditing() const; + void setIsEditing(bool editing); + + Q_INVOKABLE void clear(); + Q_INVOKABLE void edit(); + +Q_SIGNALS: + void addressChanged(); + void labelChanged(); + void messageChanged(); + void addressTypeChanged(); + void noteSelfChanged(); + void amountErrorChanged(); + void idChanged(); + void needsUnlockChanged(); + void unlockErrorChanged(); + void qrPayloadChanged(); + void createdIsoChanged(); + void isEditingChanged(); + +private: + static QString FormatAddress(const QString& address); + + CTxDestination m_destination; + QString m_label; + QString m_message; + QString m_address_type; + QString m_noteSelf; + QString m_amountError; + BitcoinAmount* m_amount; + QString m_id; + bool m_needs_unlock{false}; + QString m_unlock_error; + QDateTime m_created; + bool m_is_editing{true}; +}; + +#endif // BITCOIN_QML_MODELS_PAYMENTREQUEST_H diff --git a/src/qml/models/peerdetailsmodel.cpp b/src/qml/models/peerdetailsmodel.cpp new file mode 100644 index 0000000000..4a5340ea8b --- /dev/null +++ b/src/qml/models/peerdetailsmodel.cpp @@ -0,0 +1,80 @@ +// Copyright (c) 2024-2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +PeerDetailsModel::PeerDetailsModel(const CNodeCombinedStats* nodeStats, PeerListModel* parent) +: m_node_id{static_cast(nodeStats->nodeStats.nodeid)} +, m_addr{nodeStats->nodeStats.addr} +, m_combinedStats{nodeStats} +, m_model{parent} +{ + for (int row = 0; row < m_model->rowCount(); ++row) { + QModelIndex index = m_model->index(row, 0); + int nodeIdInRow = m_model->data(index, PeerListModel::NetNodeId).toInt(); + if (nodeIdInRow == m_node_id) { + m_row = row; + break; + } + } + connect(parent, &PeerListModel::rowsRemoved, this, &PeerDetailsModel::onModelRowsRemoved); + connect(parent, &PeerListModel::dataChanged, this, &PeerDetailsModel::onModelDataChanged); + connect(parent, &PeerListModel::modelReset, this, &PeerDetailsModel::onModelReset); +} + +void PeerDetailsModel::onModelRowsRemoved(const QModelIndex& parent, int first, int last) +{ + for (int row = first; row <= last; ++row) { + QModelIndex index = m_model->index(row, 0, parent); + int nodeIdInRow = m_model->data(index, PeerListModel::NetNodeId).toInt(); + if (nodeIdInRow == this->nodeId()) { + if (!m_disconnected) { + m_disconnected = true; + Q_EMIT disconnected(); + } + break; + } + } +} + +void PeerDetailsModel::onModelDataChanged(const QModelIndex& /* top_left */, const QModelIndex& /* bottom_right */) +{ + if (m_model->data(m_model->index(m_row, 0), PeerListModel::NetNodeId).isNull() || + m_model->data(m_model->index(m_row, 0), PeerListModel::NetNodeId).toInt() != nodeId()) { + if (!m_disconnected) { + m_disconnected = true; + Q_EMIT disconnected(); + } + return; + } + + m_combinedStats = m_model->data(m_model->index(m_row, 0), PeerListModel::StatsRole).value(); + + // Only update when all information is available + if (m_combinedStats && m_combinedStats->fNodeStateStatsAvailable) { + Q_EMIT dataChanged(); + } +} + +void PeerDetailsModel::onModelReset() +{ + if (m_disconnected) return; + + const int tracked_node_id = m_node_id; + for (int row = 0; row < m_model->rowCount(); ++row) { + const QModelIndex index = m_model->index(row, 0); + const int node_id_in_row = m_model->data(index, PeerListModel::NetNodeId).toInt(); + if (node_id_in_row == tracked_node_id) { + m_row = row; + m_combinedStats = m_model->data(index, PeerListModel::StatsRole).value(); + if (m_combinedStats && m_combinedStats->fNodeStateStatsAvailable) { + Q_EMIT dataChanged(); + } + return; + } + } + + m_disconnected = true; + Q_EMIT disconnected(); +} diff --git a/src/qml/models/peerdetailsmodel.h b/src/qml/models/peerdetailsmodel.h new file mode 100644 index 0000000000..42a66db420 --- /dev/null +++ b/src/qml/models/peerdetailsmodel.h @@ -0,0 +1,100 @@ +// Copyright (c) 2024-2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QML_MODELS_PEERDETAILSMODEL_H +#define BITCOIN_QML_MODELS_PEERDETAILSMODEL_H + +#include +#include + +#include +#include +#include + +class PeerDetailsModel : public QObject +{ + Q_OBJECT + Q_PROPERTY(int nodeId READ nodeId NOTIFY dataChanged) + Q_PROPERTY(QString rawAddress READ rawAddress CONSTANT) + Q_PROPERTY(QString address READ address NOTIFY dataChanged) + Q_PROPERTY(QString addressLocal READ addressLocal NOTIFY dataChanged) + Q_PROPERTY(QString type READ type NOTIFY dataChanged) + Q_PROPERTY(QString version READ version NOTIFY dataChanged) + Q_PROPERTY(QString userAgent READ userAgent NOTIFY dataChanged) + Q_PROPERTY(QString services READ services NOTIFY dataChanged) + Q_PROPERTY(bool transactionRelay READ transactionRelay NOTIFY dataChanged) + Q_PROPERTY(bool addressRelay READ addressRelay NOTIFY dataChanged) + Q_PROPERTY(QString startingHeight READ startingHeight NOTIFY dataChanged) + Q_PROPERTY(QString syncedHeaders READ syncedHeaders NOTIFY dataChanged) + Q_PROPERTY(QString syncedBlocks READ syncedBlocks NOTIFY dataChanged) + Q_PROPERTY(QString direction READ direction NOTIFY dataChanged) + Q_PROPERTY(QString connectionDuration READ connectionDuration NOTIFY dataChanged) + Q_PROPERTY(QString lastSend READ lastSend NOTIFY dataChanged) + Q_PROPERTY(QString lastReceived READ lastReceived NOTIFY dataChanged) + Q_PROPERTY(QString bytesSent READ bytesSent NOTIFY dataChanged) + Q_PROPERTY(QString bytesReceived READ bytesReceived NOTIFY dataChanged) + Q_PROPERTY(QString pingTime READ pingTime NOTIFY dataChanged) + Q_PROPERTY(QString pingWait READ pingWait NOTIFY dataChanged) + Q_PROPERTY(QString pingMin READ pingMin NOTIFY dataChanged) + Q_PROPERTY(QString timeOffset READ timeOffset NOTIFY dataChanged) + Q_PROPERTY(QString mappedAS READ mappedAS NOTIFY dataChanged) + Q_PROPERTY(QString permission READ permission NOTIFY dataChanged) + +public: + explicit PeerDetailsModel(const CNodeCombinedStats* nodeStats, PeerListModel* model); + + int nodeId() const { return m_node_id; } + QString rawAddress() const { return QString::fromStdString(m_addr.ToStringAddr()); } + QString address() const { return QString::fromStdString(m_combinedStats->nodeStats.m_addr_name); } + QString addressLocal() const { return QString::fromStdString(m_combinedStats->nodeStats.addrLocal); } + QString type() const { return PeerStatsUtil::ConnectionTypeToQString(m_combinedStats->nodeStats.m_conn_type, /*prepend_direction=*/true); } + QString version() const { return QString::number(m_combinedStats->nodeStats.nVersion); } + QString userAgent() const { return QString::fromStdString(m_combinedStats->nodeStats.cleanSubVer); } + QString services() const { return PeerStatsUtil::FormatServicesStr(m_combinedStats->nodeStateStats.their_services); } + bool transactionRelay() const { return m_combinedStats->nodeStateStats.m_relay_txs; } + bool addressRelay() const { return m_combinedStats->nodeStateStats.m_addr_relay_enabled; } + QString startingHeight() const { return QString::number(m_combinedStats->nodeStateStats.m_starting_height); } + QString syncedHeaders() const { return QString::number(m_combinedStats->nodeStateStats.nSyncHeight); } + QString syncedBlocks() const { return QString::number(m_combinedStats->nodeStateStats.nCommonHeight); } + QString direction() const { return QString::fromStdString(m_combinedStats->nodeStats.fInbound ? "Inbound" : "Outbound"); } + QString connectionDuration() const { return PeerStatsUtil::FormatDurationStr(GetTime() - m_combinedStats->nodeStats.m_connected); } + QString lastSend() const { return PeerStatsUtil::FormatDurationStr(GetTime() - m_combinedStats->nodeStats.m_last_send); } + QString lastReceived() const { return PeerStatsUtil::FormatDurationStr(GetTime() - m_combinedStats->nodeStats.m_last_recv); } + QString bytesSent() const { return PeerStatsUtil::FormatBytes(m_combinedStats->nodeStats.nSendBytes); } + QString bytesReceived() const { return PeerStatsUtil::FormatBytes(m_combinedStats->nodeStats.nRecvBytes); } + QString pingTime() const { return PeerStatsUtil::FormatPingTime(m_combinedStats->nodeStats.m_last_ping_time); } + QString pingMin() const { return PeerStatsUtil::FormatPingTime(m_combinedStats->nodeStats.m_min_ping_time); } + QString pingWait() const { return PeerStatsUtil::FormatPingTime(m_combinedStats->nodeStateStats.m_ping_wait); } + QString timeOffset() const { return PeerStatsUtil::FormatTimeOffset(Ticks(m_combinedStats->nodeStateStats.time_offset)); } + QString mappedAS() const { return m_combinedStats->nodeStats.m_mapped_as != 0 ? QString::number(m_combinedStats->nodeStats.m_mapped_as) : tr("N/A"); } + QString permission() const { + if (m_combinedStats->nodeStats.m_permission_flags == NetPermissionFlags::None) { + return tr("N/A"); + } + QStringList permissions; + for (const auto& permission : NetPermissions::ToStrings(m_combinedStats->nodeStats.m_permission_flags)) { + permissions.append(QString::fromStdString(permission)); + } + return permissions.join(" & "); + } + +Q_SIGNALS: + void dataChanged(); + void disconnected(); + +private Q_SLOTS: + void onModelRowsRemoved(const QModelIndex& parent, int first, int last); + void onModelDataChanged(const QModelIndex& top_left, const QModelIndex& bottom_right); + void onModelReset(); + +private: + int m_row{-1}; + int m_node_id{-1}; + CNetAddr m_addr; + const CNodeCombinedStats* m_combinedStats{nullptr}; + PeerListModel* m_model{nullptr}; + bool m_disconnected{false}; +}; + +#endif // BITCOIN_QML_MODELS_PEERDETAILSMODEL_H diff --git a/src/qml/models/peerlistmodel.cpp b/src/qml/models/peerlistmodel.cpp new file mode 100644 index 0000000000..390bb2d956 --- /dev/null +++ b/src/qml/models/peerlistmodel.cpp @@ -0,0 +1,142 @@ +// Copyright (c) 2011-2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include + +#include +#include + +#include +#include +#include + +namespace { +constexpr auto MODEL_UPDATE_DELAY{std::chrono::milliseconds{250}}; +} + +PeerListModel::PeerListModel(interfaces::Node& node, QObject* parent) + : QAbstractListModel(parent), m_node(node) +{ + m_timer = new QTimer(this); + connect(m_timer, &QTimer::timeout, this, &PeerListModel::refresh); + m_timer->setInterval(MODEL_UPDATE_DELAY); + + refresh(); +} + +PeerListModel::~PeerListModel() = default; + +void PeerListModel::startAutoRefresh() +{ + m_timer->start(); +} + +void PeerListModel::stopAutoRefresh() +{ + m_timer->stop(); +} + +int PeerListModel::rowCount(const QModelIndex& parent) const +{ + if (parent.isValid()) return 0; + return m_peers_data.size(); +} + +QVariant PeerListModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_peers_data.size()) return {}; + const CNodeCombinedStats& rec = m_peers_data.at(index.row()); + + switch (role) { + case NetNodeId: + return static_cast(rec.nodeStats.nodeid); + case Age: + return PeerStatsUtil::FormatPeerAge(rec.nodeStats.m_connected); + case Address: + return QString::fromStdString(rec.nodeStats.m_addr_name); + case Direction: + return QString(rec.nodeStats.fInbound ? tr("Inbound") : tr("Outbound")); + case ConnectionType: + return PeerStatsUtil::ConnectionTypeToQString(rec.nodeStats.m_conn_type, /*prepend_direction=*/false); + case Network: + return PeerStatsUtil::NetworkToQString(rec.nodeStats.m_network); + case Ping: + return PeerStatsUtil::FormatPingTime(rec.nodeStats.m_min_ping_time); + case Sent: + return PeerStatsUtil::FormatBytes(rec.nodeStats.nSendBytes); + case Received: + return PeerStatsUtil::FormatBytes(rec.nodeStats.nRecvBytes); + case Subversion: + return QString::fromStdString(rec.nodeStats.cleanSubVer); + case StatsRole: + return QVariant::fromValue(&rec); + } + + return {}; +} + +QHash PeerListModel::roleNames() const +{ + QHash roles; + roles[NetNodeId] = "nodeId"; + roles[Age] = "age"; + roles[Address] = "address"; + roles[Direction] = "direction"; + roles[ConnectionType] = "connectionType"; + roles[Network] = "network"; + roles[Ping] = "ping"; + roles[Sent] = "sent"; + roles[Received] = "received"; + roles[Subversion] = "subversion"; + roles[StatsRole] = "stats"; + return roles; +} + +Qt::ItemFlags PeerListModel::flags(const QModelIndex& index) const +{ + if (!index.isValid()) return Qt::NoItemFlags; + return Qt::ItemIsSelectable | Qt::ItemIsEnabled; +} + +void PeerListModel::refresh() +{ + interfaces::Node::NodesStats nodes_stats; + if (!m_node.getNodesStats(nodes_stats)) return; + + decltype(m_peers_data) new_peers_data; + new_peers_data.reserve(nodes_stats.size()); + for (const auto& node_stats : nodes_stats) { + const CNodeCombinedStats stats{std::get<0>(node_stats), std::get<2>(node_stats), std::get<1>(node_stats)}; + new_peers_data.append(stats); + } + + const bool count_changed = m_peers_data.size() != new_peers_data.size(); + bool order_changed{false}; + if (!count_changed) { + for (int i = 0; i < m_peers_data.size(); ++i) { + if (m_peers_data.at(i).nodeStats.nodeid != new_peers_data.at(i).nodeStats.nodeid) { + order_changed = true; + break; + } + } + } + + if (count_changed || order_changed) { + beginResetModel(); + m_peers_data = std::move(new_peers_data); + endResetModel(); + return; + } + + m_peers_data = std::move(new_peers_data); + + if (rowCount() > 0) { + const auto top_left = index(0, 0); + const auto bottom_right = index(rowCount() - 1, 0); + Q_EMIT dataChanged(top_left, bottom_right); + } +} diff --git a/src/qml/models/peerlistmodel.h b/src/qml/models/peerlistmodel.h new file mode 100644 index 0000000000..54c9ac231a --- /dev/null +++ b/src/qml/models/peerlistmodel.h @@ -0,0 +1,73 @@ +// Copyright (c) 2011-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. + +#ifndef BITCOIN_QML_MODELS_PEERLISTMODEL_H +#define BITCOIN_QML_MODELS_PEERLISTMODEL_H + +#include +#include + +#include +#include +#include +#include +#include + +namespace interfaces { +class Node; +} + +QT_BEGIN_NAMESPACE +class QTimer; +QT_END_NAMESPACE + +struct CNodeCombinedStats { + CNodeStats nodeStats; + CNodeStateStats nodeStateStats; + bool fNodeStateStatsAvailable; +}; +Q_DECLARE_METATYPE(const CNodeCombinedStats*) + +class PeerListModel : public QAbstractListModel +{ + Q_OBJECT + +public: + explicit PeerListModel(interfaces::Node& node, QObject* parent); + ~PeerListModel(); + + Q_INVOKABLE + void startAutoRefresh(); + Q_INVOKABLE + void stopAutoRefresh(); + + enum Role { + StatsRole = Qt::UserRole, + NetNodeId = Qt::UserRole + 1, + Age, + Address, + Direction, + ConnectionType, + Network, + Ping, + Sent, + Received, + Subversion, + }; + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + Qt::ItemFlags flags(const QModelIndex& index) const override; + +public Q_SLOTS: + void refresh(); + +private: + QList m_peers_data{}; + interfaces::Node& m_node; + QTimer* m_timer{nullptr}; +}; + +#endif // BITCOIN_QML_MODELS_PEERLISTMODEL_H diff --git a/src/qml/models/peerlistsortproxy.cpp b/src/qml/models/peerlistsortproxy.cpp new file mode 100644 index 0000000000..c876fc97be --- /dev/null +++ b/src/qml/models/peerlistsortproxy.cpp @@ -0,0 +1,92 @@ +// Copyright (c) 2023-2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include +#include +#include +#include + +PeerListSortProxy::PeerListSortProxy(QObject* parent) + : QSortFilterProxyModel(parent) +{ + m_sort_role = PeerListModel::NetNodeId; + setSortRole(m_sort_role); + setDynamicSortFilter(true); +} + +QHash PeerListSortProxy::roleNames() const +{ + if (sourceModel()) { + return sourceModel()->roleNames(); + } + return {}; +} + +int PeerListSortProxy::RoleNameToRole(const QString & name) const +{ + auto role_names = roleNames(); + auto keys = role_names.keys(name.toUtf8()); + if (!keys.empty()) { + return keys.first(); + } else { + return PeerListModel::NetNodeId; + } +} + +QVariant PeerListSortProxy::data(const QModelIndex& index, int role) const +{ + if (role == PeerListModel::StatsRole) { + auto stats = QSortFilterProxyModel::data(index, role); + auto details = new PeerDetailsModel(stats.value(), qobject_cast(sourceModel())); + return QVariant::fromValue(details); + } + + return QSortFilterProxyModel::data(index, role); +} + +QString PeerListSortProxy::sortBy() const +{ + return m_sort_by; +} + +void PeerListSortProxy::setSortBy(const QString & roleName) +{ + if (m_sort_by == roleName) return; + + m_sort_by = roleName; + m_sort_role = RoleNameToRole(roleName); + setSortRole(m_sort_role); + sort(0); + Q_EMIT sortByChanged(roleName); +} + +bool PeerListSortProxy::lessThan(const QModelIndex& left_index, const QModelIndex& right_index) const +{ + const CNodeStats left_stats = Assert(sourceModel()->data(left_index, PeerListModel::StatsRole).value())->nodeStats; + const CNodeStats right_stats = Assert(sourceModel()->data(right_index, PeerListModel::StatsRole).value())->nodeStats; + + switch (m_sort_role) { + case PeerListModel::NetNodeId: + return left_stats.nodeid < right_stats.nodeid; + case PeerListModel::Age: + return left_stats.m_connected > right_stats.m_connected; + case PeerListModel::Address: + return left_stats.m_addr_name.compare(right_stats.m_addr_name) < 0; + case PeerListModel::Direction: + return left_stats.fInbound > right_stats.fInbound; + case PeerListModel::ConnectionType: + return left_stats.m_conn_type < right_stats.m_conn_type; + case PeerListModel::Network: + return left_stats.m_network < right_stats.m_network; + case PeerListModel::Ping: + return left_stats.m_min_ping_time < right_stats.m_min_ping_time; + case PeerListModel::Sent: + return left_stats.nSendBytes < right_stats.nSendBytes; + case PeerListModel::Received: + return left_stats.nRecvBytes < right_stats.nRecvBytes; + case PeerListModel::Subversion: + return left_stats.cleanSubVer.compare(right_stats.cleanSubVer) < 0; + } + return false; +} diff --git a/src/qml/models/peerlistsortproxy.h b/src/qml/models/peerlistsortproxy.h new file mode 100644 index 0000000000..4b01e94f07 --- /dev/null +++ b/src/qml/models/peerlistsortproxy.h @@ -0,0 +1,40 @@ +// Copyright (c) 2023-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. + +#ifndef BITCOIN_QML_MODELS_PEERLISTSORTPROXY_H +#define BITCOIN_QML_MODELS_PEERLISTSORTPROXY_H + +#include +#include +#include +#include +#include + +class PeerListSortProxy : public QSortFilterProxyModel +{ + Q_OBJECT + Q_PROPERTY(QString sortBy READ sortBy WRITE setSortBy NOTIFY sortByChanged) + +public: + explicit PeerListSortProxy(QObject* parent); + ~PeerListSortProxy() = default; + + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + QString sortBy() const; + +public Q_SLOTS: + void setSortBy(const QString & roleName); + +Q_SIGNALS: + void sortByChanged(const QString & roleName); + +private: + bool lessThan(const QModelIndex& left_index, const QModelIndex& right_index) const override; + int RoleNameToRole(const QString & name) const; + int m_sort_role{0}; + QString m_sort_by; +}; + +#endif // BITCOIN_QML_MODELS_PEERLISTSORTPROXY_H diff --git a/src/qml/models/receiverequestentry.h b/src/qml/models/receiverequestentry.h new file mode 100644 index 0000000000..f225201755 --- /dev/null +++ b/src/qml/models/receiverequestentry.h @@ -0,0 +1,51 @@ +// 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. + +#ifndef BITCOIN_QML_MODELS_RECEIVEREQUESTENTRY_H +#define BITCOIN_QML_MODELS_RECEIVEREQUESTENTRY_H + +#include +#include + +#include +#include + +#include + +struct QmlReceiveRequestRecipient +{ + static constexpr int CURRENT_VERSION{1}; + int nVersion{CURRENT_VERSION}; + std::string address; + std::string label; + CAmount amount{0}; + std::string message; + std::string sPaymentRequest; + std::string authenticatedMerchant; + std::string noteSelf; + + SERIALIZE_METHODS(QmlReceiveRequestRecipient, obj) + { + READWRITE(obj.nVersion, obj.address, obj.label, obj.amount, obj.message, obj.sPaymentRequest, obj.authenticatedMerchant); + } +}; + +struct QmlRecentRequestEntry +{ + static constexpr int CURRENT_VERSION{1}; + int nVersion{CURRENT_VERSION}; + int64_t id{0}; + QDateTime date; + QmlReceiveRequestRecipient recipient; + + SERIALIZE_METHODS(QmlRecentRequestEntry, obj) + { + unsigned int date_timet; + SER_WRITE(obj, date_timet = static_cast(obj.date.toSecsSinceEpoch())); + READWRITE(obj.nVersion, obj.id, date_timet, obj.recipient); + SER_READ(obj, obj.date = QDateTime::fromSecsSinceEpoch(date_timet)); + } +}; + +#endif // BITCOIN_QML_MODELS_RECEIVEREQUESTENTRY_H diff --git a/src/qml/models/receiverequesthistorymodel.cpp b/src/qml/models/receiverequesthistorymodel.cpp new file mode 100644 index 0000000000..2f438e82fb --- /dev/null +++ b/src/qml/models/receiverequesthistorymodel.cpp @@ -0,0 +1,260 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include + +#include +#include + +#include +#include + +ReceiveRequestHistoryModel::ReceiveRequestHistoryModel(QObject* parent) + : QAbstractListModel(parent) +{ +} + +int ReceiveRequestHistoryModel::rowCount(const QModelIndex& parent) const +{ + if (parent.isValid()) return 0; + return static_cast(m_entries.size()); +} + +QVariant ReceiveRequestHistoryModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= static_cast(m_entries.size())) { + return {}; + } + return dataForEntry(m_entries[index.row()], role); +} + +QVariant ReceiveRequestHistoryModel::dataForEntry(const QmlRecentRequestEntry& entry, int role) const +{ + switch (role) { + case IdRole: + return QString::number(entry.id); + case DateRole: + return entry.date.toLocalTime().toString(Qt::TextDate); + case DateIsoRole: + return entry.date.toUTC().toString(Qt::ISODate); + case AddressRole: + return QString::fromStdString(entry.recipient.address); + case AddressShortRole: { + const QString addr = QString::fromStdString(entry.recipient.address); + if (addr.size() <= 16) return addr; + return QString(addr.left(8) + QStringLiteral("…") + addr.right(6)); + } + case LabelRole: + return QString::fromStdString(entry.recipient.label); + case MessageRole: + return QString::fromStdString(entry.recipient.message); + case NoteSelfRole: + return QString::fromStdString(entry.recipient.noteSelf); + case AmountSatRole: + return QVariant::fromValue(entry.recipient.amount); + case AmountDisplayRole: + return FormatAmountBtc(entry.recipient.amount); + case UriRole: + return BuildBitcoinUri(QString::fromStdString(entry.recipient.address), + entry.recipient.amount, + QString::fromStdString(entry.recipient.label), + QString::fromStdString(entry.recipient.message)); + } + return {}; +} + +QVariantMap ReceiveRequestHistoryModel::entryMap(const QmlRecentRequestEntry& entry) const +{ + return { + {QStringLiteral("requestId"), dataForEntry(entry, IdRole)}, + {QStringLiteral("date"), dataForEntry(entry, DateRole)}, + {QStringLiteral("dateIso"), dataForEntry(entry, DateIsoRole)}, + {QStringLiteral("address"), dataForEntry(entry, AddressRole)}, + {QStringLiteral("addressShort"), dataForEntry(entry, AddressShortRole)}, + {QStringLiteral("label"), dataForEntry(entry, LabelRole)}, + {QStringLiteral("message"), dataForEntry(entry, MessageRole)}, + {QStringLiteral("noteSelf"), dataForEntry(entry, NoteSelfRole)}, + {QStringLiteral("amountSat"), dataForEntry(entry, AmountSatRole)}, + {QStringLiteral("amountDisplay"), dataForEntry(entry, AmountDisplayRole)}, + {QStringLiteral("uri"), dataForEntry(entry, UriRole)}, + }; +} + +QHash ReceiveRequestHistoryModel::roleNames() const +{ + return { + {IdRole, "requestId"}, + {DateRole, "date"}, + {DateIsoRole, "dateIso"}, + {AddressRole, "address"}, + {AddressShortRole, "addressShort"}, + {LabelRole, "label"}, + {MessageRole, "message"}, + {NoteSelfRole, "noteSelf"}, + {AmountSatRole, "amountSat"}, + {AmountDisplayRole, "amountDisplay"}, + {UriRole, "uri"}, + }; +} + +int ReceiveRequestHistoryModel::indexOfId(int64_t id) const +{ + for (size_t i = 0; i < m_entries.size(); ++i) { + if (m_entries[i].id == id) return static_cast(i); + } + return -1; +} + +void ReceiveRequestHistoryModel::setEntries(std::vector&& entries) +{ + beginResetModel(); + m_entries = std::move(entries); + std::sort(m_entries.begin(), m_entries.end(), + [](const QmlRecentRequestEntry& a, const QmlRecentRequestEntry& b) { + return a.date > b.date; + }); + endResetModel(); + Q_EMIT countChanged(); +} + +void ReceiveRequestHistoryModel::prependOrReplace(const QmlRecentRequestEntry& entry) +{ + const int existing = indexOfId(entry.id); + if (existing >= 0) { + m_entries[existing] = entry; + const QModelIndex idx = index(existing); + Q_EMIT dataChanged(idx, idx); + return; + } + beginInsertRows(QModelIndex(), 0, 0); + m_entries.insert(m_entries.begin(), entry); + endInsertRows(); + Q_EMIT countChanged(); +} + +bool ReceiveRequestHistoryModel::removeByRequestId(const QString& request_id) +{ + bool ok{false}; + const int64_t id = request_id.toLongLong(&ok); + if (!ok) return false; + const int row = indexOfId(id); + if (row < 0) return false; + beginRemoveRows(QModelIndex(), row, row); + m_entries.erase(m_entries.begin() + row); + endRemoveRows(); + Q_EMIT countChanged(); + return true; +} + +QVariantList ReceiveRequestHistoryModel::matchingEntriesForAddress(const QString& address) const +{ + QVariantList matches; + if (address.isEmpty()) return matches; + for (const auto& entry : m_entries) { + if (QString::fromStdString(entry.recipient.address) == address) { + matches.append(entryMap(entry)); + } + } + return matches; +} + +std::optional ReceiveRequestHistoryModel::entryById(const QString& request_id) const +{ + bool ok{false}; + const int64_t id = request_id.toLongLong(&ok); + if (!ok) return std::nullopt; + const int row = indexOfId(id); + if (row < 0) return std::nullopt; + return m_entries[row]; +} + +int64_t ReceiveRequestHistoryModel::maxId() const +{ + int64_t max_id = 0; + for (const auto& entry : m_entries) { + if (entry.id > max_id) max_id = entry.id; + } + return max_id; +} + +// Wallet receive requests are stored in address destdata "rr##" values that +// bitcoin-qt reads as Qt Widgets RecentRequestEntry records. Keep the serialized +// prefix byte-compatible with bitcoin/src/qt/recentrequeststablemodel.h and +// bitcoin/src/qt/sendcoinsrecipient.h: version 1, Qt's unsigned timestamp, and +// no QML-only fields inside the prefix. +// +// QML currently persists noteSelf as an optional trailing string. Released +// bitcoin-qt builds ignore trailing bytes after RecentRequestEntry, while QML +// reads the extension when present. If Qt Widgets gains noteSelf support, move +// this to an explicit shared contract instead of changing the prefix ad hoc. +std::vector ReceiveRequestHistoryModel::DeserializeEntries(const std::vector& blobs) +{ + std::vector out; + out.reserve(blobs.size()); + for (const std::string& blob : blobs) { + std::vector data(blob.begin(), blob.end()); + DataStream ss{data}; + QmlRecentRequestEntry entry; + try { + ss >> entry; + if (!ss.empty()) { + ss >> entry.recipient.noteSelf; + } + } catch (const std::ios_base::failure& e) { + qWarning() << "ReceiveRequestHistoryModel: skipping malformed receive request entry:" << e.what(); + continue; + } catch (const std::exception& e) { + qWarning() << "ReceiveRequestHistoryModel: skipping receive request entry (error):" << e.what(); + continue; + } + out.push_back(std::move(entry)); + } + return out; +} + +std::string ReceiveRequestHistoryModel::SerializeEntry(const QmlRecentRequestEntry& entry) +{ + DataStream ss{}; + ss << entry; + if (!entry.recipient.noteSelf.empty()) { + ss << entry.recipient.noteSelf; + } + return ss.str(); +} + +QString ReceiveRequestHistoryModel::FormatAmountBtc(CAmount amount_sats) +{ + if (amount_sats <= 0) return {}; + return QmlBitcoinUnits::format(QmlBitcoinUnits::Unit::BTC, amount_sats, false, + QmlBitcoinUnits::SeparatorStyle::NEVER); +} + +QString ReceiveRequestHistoryModel::BuildBitcoinUri(const QString& address, CAmount amount_sats, + const QString& label, const QString& message) +{ + if (address.isEmpty()) return {}; + + QString result = QStringLiteral("bitcoin:") + address; + + QStringList params; + if (amount_sats > 0) { + const QString amount_str = FormatAmountBtc(amount_sats); + if (!amount_str.isEmpty()) { + params << QStringLiteral("amount=") + amount_str; + } + } + if (!label.isEmpty()) { + params << QStringLiteral("label=") + QString::fromUtf8(QUrl::toPercentEncoding(label)); + } + if (!message.isEmpty()) { + params << QStringLiteral("message=") + QString::fromUtf8(QUrl::toPercentEncoding(message)); + } + if (!params.isEmpty()) { + result += QStringLiteral("?") + params.join(QLatin1Char('&')); + } + return result; +} diff --git a/src/qml/models/receiverequesthistorymodel.h b/src/qml/models/receiverequesthistorymodel.h new file mode 100644 index 0000000000..2a049e3e2a --- /dev/null +++ b/src/qml/models/receiverequesthistorymodel.h @@ -0,0 +1,73 @@ +// 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. + +#ifndef BITCOIN_QML_MODELS_RECEIVEREQUESTHISTORYMODEL_H +#define BITCOIN_QML_MODELS_RECEIVEREQUESTHISTORYMODEL_H + +#include + +#include + +#include +#include +#include + +#include +#include +#include +#include + +class ReceiveRequestHistoryModel : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY(int count READ count NOTIFY countChanged) + +public: + enum Role { + IdRole = Qt::UserRole + 1, + DateRole, + DateIsoRole, + AddressRole, + AddressShortRole, + LabelRole, + MessageRole, + NoteSelfRole, + AmountSatRole, + AmountDisplayRole, + UriRole, + }; + + explicit ReceiveRequestHistoryModel(QObject* parent = nullptr); + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + int count() const { return static_cast(m_entries.size()); } + + void setEntries(std::vector&& entries); + void prependOrReplace(const QmlRecentRequestEntry& entry); + bool removeByRequestId(const QString& request_id); + Q_INVOKABLE QVariantList matchingEntriesForAddress(const QString& address) const; + + std::optional entryById(const QString& request_id) const; + int64_t maxId() const; + + static std::vector DeserializeEntries(const std::vector& blobs); + static std::string SerializeEntry(const QmlRecentRequestEntry& entry); + static QString BuildBitcoinUri(const QString& address, CAmount amount_sats, const QString& label, const QString& message); + static QString FormatAmountBtc(CAmount amount_sats); + +Q_SIGNALS: + void countChanged(); + +private: + int indexOfId(int64_t id) const; + QVariant dataForEntry(const QmlRecentRequestEntry& entry, int role) const; + QVariantMap entryMap(const QmlRecentRequestEntry& entry) const; + + std::vector m_entries; +}; + +#endif // BITCOIN_QML_MODELS_RECEIVEREQUESTHISTORYMODEL_H diff --git a/src/qml/models/sendrecipient.cpp b/src/qml/models/sendrecipient.cpp new file mode 100644 index 0000000000..177cb84c46 --- /dev/null +++ b/src/qml/models/sendrecipient.cpp @@ -0,0 +1,167 @@ +// Copyright (c) 2025-2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include + +#include +#include +#include +#include