From f1e9abf04e33db5a34f6038eb95027f819db1d26 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 5 Mar 2025 09:45:11 +0100 Subject: [PATCH] qt,qml: review rework, refactor spinner, add tor probe active indicator --- .../qml/components/controls/ProxyConfig.qml | 24 +++++++++-- electrum/gui/qt/network_dialog.py | 38 ++++++++++------ electrum/gui/qt/send_tab.py | 25 +++-------- electrum/gui/qt/util.py | 18 +++++++- electrum/network.py | 43 +++++++++++-------- electrum/simple_config.py | 2 +- 6 files changed, 93 insertions(+), 57 deletions(-) diff --git a/electrum/gui/qml/components/controls/ProxyConfig.qml b/electrum/gui/qml/components/controls/ProxyConfig.qml index dcea1d7d5..05bc05b2b 100644 --- a/electrum/gui/qml/components/controls/ProxyConfig.qml +++ b/electrum/gui/qml/components/controls/ProxyConfig.qml @@ -19,6 +19,8 @@ Item { { text: qsTr('SOCKS4'), value: 'socks4' } ] + property bool _probing: false + function toProxyDict() { var p = {} p['enabled'] = pc.proxy_enabled @@ -104,19 +106,33 @@ Item { Layout.topMargin: constants.paddingLarge padding: 0 background: Rectangle { - color: Material.dialogColor + color: constants.darkerDialogBackground } FlatButton { - enabled: proxy_enabled_cb.checked - text: qsTr('Detect TOR proxy') - onClicked: Network.probeTor() + enabled: proxy_enabled_cb.checked && !_probing + text: qsTr('Detect Tor proxy') + onClicked: { + _probing = true + Network.probeTor() + } } } + + BusyIndicator { + id: spinner + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: constants.paddingSmall + Layout.preferredWidth: constants.iconSizeXLarge + Layout.preferredHeight: constants.iconSizeXLarge + running: visible + visible: _probing + } } Connections { target: Network function onTorProbeFinished(host, port) { + _probing = false if (host && port) { proxytype.currentIndex = 0 proxy_port = ""+port diff --git a/electrum/gui/qt/network_dialog.py b/electrum/gui/qt/network_dialog.py index a3b829f42..43ce7a716 100644 --- a/electrum/gui/qt/network_dialog.py +++ b/electrum/gui/qt/network_dialog.py @@ -25,21 +25,23 @@ from enum import IntEnum -from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtCore import Qt, pyqtSignal, pyqtSlot from PyQt6.QtWidgets import ( QTreeWidget, QTreeWidgetItem, QMenu, QGridLayout, QComboBox, QLineEdit, QDialog, QVBoxLayout, QHeaderView, - QCheckBox, QTabWidget, QWidget, QLabel, QPushButton + QCheckBox, QTabWidget, QWidget, QLabel, QPushButton, QHBoxLayout ) from PyQt6.QtGui import QIntValidator from electrum.i18n import _ from electrum import blockchain from electrum.interface import ServerAddr, PREFERRED_NETWORK_PROTOCOL -from electrum.network import Network, ProxySettings +from electrum.network import Network, ProxySettings, is_valid_host, is_valid_port from electrum.logging import get_logger -from .util import (Buttons, CloseButton, HelpButton, read_QIcon, char_width_in_lineedit, - PasswordLineEdit, QtEventListener, qt_event_listener) +from .util import ( + Buttons, CloseButton, HelpButton, read_QIcon, char_width_in_lineedit, PasswordLineEdit, QtEventListener, + qt_event_listener, Spinner +) _logger = get_logger(__name__) @@ -260,16 +262,18 @@ class ProxyWidget(QWidget): grid.addWidget(self.proxy_user, 2, 1, 1, 2) grid.addWidget(self.proxy_password, 2, 3, 1, 2) - spacer = QVBoxLayout() - spacer.addStretch(1) - grid.addLayout(spacer, 3, 0, 1, 5) + detect_l = QHBoxLayout() + self.detect_button = QPushButton(_('Detect Tor proxy')) + self.spinner = Spinner() + self.spinner.setMargin(5) + detect_l.addWidget(self.detect_button) + detect_l.addWidget(self.spinner) - self.detect_button = QPushButton(_('Detect TOR proxy')) - grid.addWidget(self.detect_button, 4, 0, 1, 5, alignment=Qt.AlignmentFlag.AlignHCenter) + grid.addLayout(detect_l, 3, 0, 1, 5, alignment=Qt.AlignmentFlag.AlignLeft) spacer = QVBoxLayout() spacer.addStretch(1) - grid.addLayout(spacer, 5, 0, 1, 5) + grid.addLayout(spacer, 4, 0, 1, 5) self.update_from_config() self.update() @@ -293,7 +297,10 @@ class ProxyWidget(QWidget): ]: item.setEnabled(enabled) - if not self.proxy_port.hasAcceptableInput(): + if not self.proxy_port.hasAcceptableInput() and not is_valid_port(self.proxy_port.text()): + return + + if not is_valid_host(self.proxy_host.text()): return net_params = self.network.get_parameters() @@ -337,10 +344,15 @@ class ProxyWidget(QWidget): return proxy def detect_tor(self): + self.detect_button.setEnabled(False) + self.spinner.setVisible(True) ProxySettings.probe_tor(self.torProbeFinished.emit) # via signal + @pyqtSlot(str, int) def on_tor_probe_finished(self, host: str, port: int): - if host is not None: + self.detect_button.setEnabled(True) + self.spinner.setVisible(False) + if host: self.proxy_mode.setCurrentIndex(1) self.proxy_host.setText(host) self.proxy_port.setText(str(port)) diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index ab2cd7541..093b76b12 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -25,7 +25,7 @@ from electrum.submarine_swaps import SwapServerError from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit from .paytoedit import InvalidPaymentIdentifier from .util import (WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton, char_width_in_lineedit, - get_iconname_camera, read_QIcon, ColorScheme, icon_path, IconLabel) + get_iconname_camera, read_QIcon, ColorScheme, icon_path, IconLabel, Spinner) from .invoice_list import InvoiceList if TYPE_CHECKING: @@ -142,14 +142,8 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.paste_button.setToolTip(_('Paste invoice from clipboard')) self.paste_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) - self.spinner = QMovie(icon_path('spinner.gif')) - self.spinner.setScaledSize(QSize(24, 24)) - self.spinner.setBackgroundColor(QColor('black')) - self.spinner_l = QLabel() - self.spinner_l.setMargin(5) - self.spinner_l.setVisible(False) - self.spinner_l.setMovie(self.spinner) - grid.addWidget(self.spinner_l, 0, 1, 1, 4, Qt.AlignmentFlag.AlignRight) + self.spinner = Spinner() + grid.addWidget(self.spinner, 0, 1, 1, 4, Qt.AlignmentFlag.AlignRight) self.save_button = EnterButton(_("Save"), self.do_save_invoice) self.save_button.setEnabled(False) @@ -216,13 +210,6 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.setTabOrder(self.send_button, self.invoice_list) - def showSpinner(self, b): - self.spinner_l.setVisible(b) - if b: - self.spinner.start() - else: - self.spinner.stop() - def on_amount_changed(self, text): # FIXME: implement full valid amount check to enable/disable Pay button pi = self.payto_e.payment_identifier @@ -388,7 +375,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): def prepare_for_send_tab_network_lookup(self): for btn in [self.save_button, self.send_button, self.clear_button]: btn.setEnabled(False) - self.showSpinner(True) + self.spinner.setVisible(True) def payment_request_error(self, error): self.show_message(error) @@ -499,7 +486,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): # TODO: resolve can happen while typing, we don't want message dialogs to pop up # currently we don't set error for emaillike recipients to avoid just that self.logger.debug('payment identifier resolve done') - self.showSpinner(False) + self.spinner.setVisible(False) if pi.error: self.show_error(pi.error) self.do_clear() @@ -555,7 +542,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger): return self.amount_e.get_amount() or 0 def on_finalize_done(self, pi: PaymentIdentifier): - self.showSpinner(False) + self.spinner.setVisible(False) self.update_fields() if pi.error: self.show_error(pi.error) diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py index 1e9fc6fb5..273f73805 100644 --- a/electrum/gui/qt/util.py +++ b/electrum/gui/qt/util.py @@ -11,7 +11,7 @@ from typing import (NamedTuple, Callable, Optional, TYPE_CHECKING, List, Any, Se from PyQt6 import QtCore from PyQt6.QtGui import (QFont, QColor, QCursor, QPixmap, QImage, - QPalette, QIcon, QFontMetrics, QPainter, QContextMenuEvent) + QPalette, QIcon, QFontMetrics, QPainter, QContextMenuEvent, QMovie) from PyQt6.QtCore import (Qt, pyqtSignal, QCoreApplication, QThread, QSize, QRect, QPoint, QObject) from PyQt6.QtWidgets import (QPushButton, QLabel, QMessageBox, QHBoxLayout, QVBoxLayout, QLineEdit, QStyle, QDialog, QGroupBox, QButtonGroup, QRadioButton, @@ -117,6 +117,22 @@ class AmountLabel(QLabel): self.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) +class Spinner(QLabel): + def __init__(self, *args, **kwargs): + QLabel.__init__(self, *args, **kwargs) + self.spinner = QMovie(icon_path('spinner.gif')) + self.spinner.setScaledSize(QSize(20, 20)) + self.spinner.frameChanged.connect(lambda: self.setPixmap(self.spinner.currentPixmap())) + self.setVisible(False) + + def setVisible(self, visible): + if visible: + self.spinner.start() + else: + self.spinner.stop() + super().setVisible(visible) + + class HelpMixin: def __init__(self, help_text: str, *, help_title: str = None): assert isinstance(self, QWidget), "HelpMixin must be a QWidget instance!" diff --git a/electrum/network.py b/electrum/network.py index 508b34ebf..2c3d91a1b 100644 --- a/electrum/network.py +++ b/electrum/network.py @@ -159,6 +159,21 @@ def pick_random_server(hostmap=None, *, allowed_protocols: Iterable[str], return random.choice(eligible) if eligible else None +def is_valid_port(ps: str): + try: + return 0 < int(ps) < 65535 + except ValueError: + return False + + +def is_valid_host(ph: str): + try: + NetAddress(ph, '1') + except ValueError: + return False + return True + + class ProxySettings: MODES = ['socks4', 'socks5'] @@ -178,31 +193,21 @@ class ProxySettings: def serialize_proxy_cfgstr(self): return ':'.join([self.mode, self.host, self.port]) - def deserialize_proxy_cfgstr(self, s: Optional[str], user: str = None, password: str = None) -> Optional[dict]: - if s is None or not isinstance(s, str) or s.lower() == 'none': + def deserialize_proxy_cfgstr(self, s: Optional[str], user: str = None, password: str = None) -> None: + if s is None or (isinstance(s, str) and s.lower() == 'none'): self.set_defaults() self.user = user self.password = password return + if not isinstance(s, str): + raise ValueError('proxy config not a string') + args = s.split(':') if args[0] in ProxySettings.MODES: self.mode = args[0] args = args[1:] - def is_valid_port(ps: str): - try: - return 0 < int(ps) < 65535 - except ValueError: - return False - - def is_valid_host(ph: str): - try: - NetAddress(ph, '1') - except ValueError: - return False - return True - # detect migrate from old settings if len(args) == 4 and is_valid_host(args[0]) and is_valid_port(args[1]): # host:port:user:pass, self.host = args[0] @@ -234,7 +239,7 @@ class ProxySettings: proxy.deserialize_proxy_cfgstr( config.NETWORK_PROXY, config.NETWORK_PROXY_USER, config.NETWORK_PROXY_PASSWORD ) - proxy.enabled = config.NETWORK_ENABLE_PROXY + proxy.enabled = config.NETWORK_PROXY_ENABLED return proxy @classmethod @@ -253,7 +258,7 @@ class ProxySettings: def detect_task(finished: Callable[[str | None, int | None], None]): net_addr = detect_tor_socks_proxy() if net_addr is None: - finished(None, None) + finished('', -1) else: host = net_addr[0] port = net_addr[1] @@ -750,14 +755,14 @@ class Network(Logger, NetworkRetryManager[ServerAddr]): # return self.config.NETWORK_AUTO_CONNECT = net_params.auto_connect self.config.NETWORK_ONESERVER = net_params.oneserver - self.config.NETWORK_ENABLE_PROXY = proxy_enabled + self.config.NETWORK_PROXY_ENABLED = proxy_enabled self.config.NETWORK_PROXY = proxy_str self.config.NETWORK_PROXY_USER = proxy_user self.config.NETWORK_PROXY_PASSWORD = proxy_pass self.config.NETWORK_SERVER = str(server) # abort if changes were not allowed by config if self.config.NETWORK_SERVER != str(server) \ - or self.config.NETWORK_ENABLE_PROXY != proxy_enabled \ + or self.config.NETWORK_PROXY_ENABLED != proxy_enabled \ or self.config.NETWORK_PROXY != proxy_str \ or self.config.NETWORK_PROXY_USER != proxy_user \ or self.config.NETWORK_PROXY_PASSWORD != proxy_pass \ diff --git a/electrum/simple_config.py b/electrum/simple_config.py index 25dd5b9a7..e5528b941 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -950,7 +950,7 @@ class SimpleConfig(Logger): NETWORK_PROXY = ConfigVar('proxy', default=None, type_=str, convert_getter=lambda v: "none" if v is None else v) NETWORK_PROXY_USER = ConfigVar('proxy_user', default=None, type_=str) NETWORK_PROXY_PASSWORD = ConfigVar('proxy_password', default=None, type_=str) - NETWORK_ENABLE_PROXY = ConfigVar('enable_proxy', default=lambda config: config.NETWORK_PROXY not in [None, "none"], type_=bool) + NETWORK_PROXY_ENABLED = ConfigVar('enable_proxy', default=lambda config: config.NETWORK_PROXY not in [None, "none"], type_=bool) NETWORK_SERVER = ConfigVar('server', default=None, type_=str) NETWORK_NOONION = ConfigVar('noonion', default=False, type_=bool) NETWORK_OFFLINE = ConfigVar('offline', default=False, type_=bool)