diff --git a/electrum/address_synchronizer.py b/electrum/address_synchronizer.py index efbe3552c..62339bbdb 100644 --- a/electrum/address_synchronizer.py +++ b/electrum/address_synchronizer.py @@ -242,7 +242,7 @@ class AddressSynchronizer(Logger, EventListener): conflicting_txns -= {tx_hash} return conflicting_txns - def get_transaction(self, txid: str) -> Transaction: + def get_transaction(self, txid: str) -> Optional[Transaction]: tx = self.db.get_transaction(txid) if tx: # add verified tx info diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 1dd40d5cd..8c00648a9 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -2190,30 +2190,44 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): if tx: self.show_transaction(tx) - def do_process_from_txid(self): + def do_process_from_txid(self, *, parent: QWidget = None, txid: str = None): + if parent is None: + parent = self from electrum import transaction - txid, ok = QInputDialog.getText(self, _('Lookup transaction'), _('Transaction ID') + ':') - if ok and txid: - txid = str(txid).strip() - raw_tx = self._fetch_tx_from_network(txid) + if txid is None: + txid, ok = QInputDialog.getText(parent, _('Lookup transaction'), _('Transaction ID') + ':') + if not ok: + txid = None + if not txid: + return + txid = str(txid).strip() + tx = self.wallet.adb.get_transaction(txid) + if tx is None: + raw_tx = self._fetch_tx_from_network(txid, parent=parent) if not raw_tx: return tx = transaction.Transaction(raw_tx) - self.show_transaction(tx) + self.show_transaction(tx) - def _fetch_tx_from_network(self, txid: str) -> Optional[str]: + def _fetch_tx_from_network(self, txid: str, *, parent: QWidget = None) -> Optional[str]: if not self.network: - self.show_message(_("You are offline.")) + self.show_message(_("You are offline."), parent=parent) return try: raw_tx = self.network.run_from_another_thread( self.network.get_transaction(txid, timeout=10)) except UntrustedServerReturnedError as e: self.logger.info(f"Error getting transaction from network: {repr(e)}") - self.show_message(_("Error getting transaction from network") + ":\n" + e.get_message_for_gui()) + self.show_message( + _("Error getting transaction from network") + ":\n" + e.get_message_for_gui(), + parent=parent, + ) return except Exception as e: - self.show_message(_("Error getting transaction from network") + ":\n" + repr(e)) + self.show_message( + _("Error getting transaction from network") + ":\n" + repr(e), + parent=parent, + ) return return raw_tx diff --git a/electrum/gui/qt/transaction_dialog.py b/electrum/gui/qt/transaction_dialog.py index c7381757b..18907b632 100644 --- a/electrum/gui/qt/transaction_dialog.py +++ b/electrum/gui/qt/transaction_dialog.py @@ -28,19 +28,20 @@ import copy import datetime import traceback import time -from typing import TYPE_CHECKING, Callable, Optional, List, Union +from typing import TYPE_CHECKING, Callable, Optional, List, Union, Tuple from functools import partial from decimal import Decimal -from PyQt5.QtCore import QSize, Qt +from PyQt5.QtCore import QSize, Qt, QUrl from PyQt5.QtGui import QTextCharFormat, QBrush, QFont, QPixmap from PyQt5.QtWidgets import (QDialog, QLabel, QPushButton, QHBoxLayout, QVBoxLayout, QWidget, QGridLayout, - QTextEdit, QFrame, QAction, QToolButton, QMenu, QCheckBox) + QTextEdit, QFrame, QAction, QToolButton, QMenu, QCheckBox, QTextBrowser) import qrcode from qrcode import exceptions from electrum.simple_config import SimpleConfig from electrum.util import quantize_feerate +from electrum import bitcoin from electrum.bitcoin import base_encode, NLOCKTIME_BLOCKHEIGHT_MAX from electrum.i18n import _ from electrum.plugin import run_hook @@ -74,7 +75,7 @@ class TxFiatLabel(QLabel): def setAmount(self, fiat_fee): self.setText(('≈ %s' % fiat_fee) if fiat_fee else '') -class QTextEditWithDefaultSize(QTextEdit): +class QTextBrowserWithDefaultSize(QTextBrowser): def sizeHint(self): return QSize(0, 100) @@ -86,7 +87,11 @@ class TxInOutWidget(QWidget): self.wallet = wallet self.main_window = main_window self.inputs_header = QLabel() - self.inputs_textedit = QTextEditWithDefaultSize() + self.inputs_textedit = QTextBrowserWithDefaultSize() + self.inputs_textedit.setOpenLinks(False) # disable automatic link opening + self.inputs_textedit.anchorClicked.connect(self._open_internal_link) # send links to our handler + self.inputs_textedit.setTextInteractionFlags( + self.inputs_textedit.textInteractionFlags() | Qt.LinksAccessibleByMouse | Qt.LinksAccessibleByKeyboard) self.txo_color_recv = TxOutputColoring( legend=_("Receiving Address"), color=ColorScheme.GREEN, tooltip=_("Wallet receive address")) self.txo_color_change = TxOutputColoring( @@ -94,7 +99,11 @@ class TxInOutWidget(QWidget): self.txo_color_2fa = TxOutputColoring( legend=_("TrustedCoin (2FA) batch fee"), color=ColorScheme.BLUE, tooltip=_("TrustedCoin (2FA) fee for the next batch of transactions")) self.outputs_header = QLabel() - self.outputs_textedit = QTextEditWithDefaultSize() + self.outputs_textedit = QTextBrowserWithDefaultSize() + self.outputs_textedit.setOpenLinks(False) # disable automatic link opening + self.outputs_textedit.anchorClicked.connect(self._open_internal_link) # send links to our handler + self.outputs_textedit.setTextInteractionFlags( + self.outputs_textedit.textInteractionFlags() | Qt.LinksAccessibleByMouse | Qt.LinksAccessibleByKeyboard) self.inputs_textedit.setMinimumWidth(950) self.outputs_textedit.setMinimumWidth(950) @@ -125,43 +134,59 @@ class TxInOutWidget(QWidget): self.inputs_header.setText(inputs_header_text) - ext = QTextCharFormat() + ext = QTextCharFormat() # "external" + lnk = QTextCharFormat() + lnk.setToolTip(_('Click to open')) + lnk.setAnchor(True) + lnk.setUnderlineStyle(QTextCharFormat.SingleUnderline) tf_used_recv, tf_used_change, tf_used_2fa = False, False, False - def text_format(addr): + def addr_text_format(addr): nonlocal tf_used_recv, tf_used_change, tf_used_2fa if self.wallet.is_mine(addr): if self.wallet.is_change(addr): tf_used_change = True - return self.txo_color_change.text_char_format + fmt = QTextCharFormat(self.txo_color_change.text_char_format) else: tf_used_recv = True - return self.txo_color_recv.text_char_format + fmt = QTextCharFormat(self.txo_color_recv.text_char_format) + fmt.setAnchorHref(addr) + fmt.setToolTip(_('Click to open')) + fmt.setAnchor(True) + fmt.setUnderlineStyle(QTextCharFormat.SingleUnderline) + return fmt elif self.wallet.is_billing_address(addr): tf_used_2fa = True return self.txo_color_2fa.text_char_format return ext - def insert_tx_io(cursor, is_coinbase, short_id, address, value): - if is_coinbase: - cursor.insertText('coinbase') - else: - address_str = address or '
' - value_str = self.main_window.format_amount(value, whitespaces=True) - cursor.insertText("%-15s\t"%str(short_id), ext) - cursor.insertText("%-62s"%address_str, text_format(address)) - cursor.insertText('\t', ext) - cursor.insertText(value_str, ext) - cursor.insertBlock() - i_text = self.inputs_textedit i_text.clear() i_text.setFont(QFont(MONOSPACE_FONT)) i_text.setReadOnly(True) cursor = i_text.textCursor() - for txin in self.tx.inputs(): + for txin_idx, txin in enumerate(self.tx.inputs()): addr = self.wallet.adb.get_txin_address(txin) txin_value = self.wallet.adb.get_txin_value(txin) - insert_tx_io(cursor, txin.is_coinbase_input(), txin.short_id, addr, txin_value) + if txin.is_coinbase_input(): + cursor.insertText('coinbase') + else: + # short_id + a_name = f"tx input {txin_idx}" + lnk2 = QTextCharFormat(lnk) + lnk2.setAnchorHref(txin.prevout.txid.hex()) + lnk2.setAnchorNames([a_name]) + cursor.insertText(str(txin.short_id), lnk2) + cursor.insertText(" " * max(0, 15 - len(str(txin.short_id))), ext) # padding + cursor.insertText('\t', ext) + # addr + address_str = addr or '' + cursor.insertText(address_str, addr_text_format(addr)) + cursor.insertText(" " * max(0, 62 - len(address_str)), ext) # padding + cursor.insertText('\t', ext) + # value + value_str = self.main_window.format_amount(txin_value, whitespaces=True) + cursor.insertText(value_str, ext) + cursor.insertBlock() self.outputs_header.setText(_("Outputs") + ' (%d)'%len(self.tx.outputs())) o_text = self.outputs_textedit @@ -176,14 +201,39 @@ class TxInOutWidget(QWidget): short_id = ShortID.from_components(tx_height, tx_pos, index) else: short_id = TxOutpoint(tx_hash, index).short_name() - addr, value = o.get_ui_address_str(), o.value - insert_tx_io(cursor, False, short_id, addr, value) + # short id + cursor.insertText(str(short_id), ext) + cursor.insertText(" " * max(0, 15 - len(str(short_id))), ext) # padding + cursor.insertText('\t', ext) + # addr + address_str = addr or '' + cursor.insertText(address_str, addr_text_format(addr)) + cursor.insertText(" " * max(0, 62 - len(address_str)), ext) # padding + cursor.insertText('\t', ext) + # value + value_str = self.main_window.format_amount(value, whitespaces=True) + cursor.insertText(value_str, ext) + cursor.insertBlock() self.txo_color_recv.legend_label.setVisible(tf_used_recv) self.txo_color_change.legend_label.setVisible(tf_used_change) self.txo_color_2fa.legend_label.setVisible(tf_used_2fa) + def _open_internal_link(self, target): + """Accepts either a str txid, str address, or a QUrl which should be + of the bare form "txid" and/or "address" -- used by the clickable + links in the inputs/outputs QTextBrowsers""" + if isinstance(target, QUrl): + target = target.toString(QUrl.None_) + assert target + if bitcoin.is_address(target): + # target was an address, open address dialog + self.main_window.show_address(target, parent=self) + else: + # target was a txid, open new tx dialog + self.main_window.do_process_from_txid(txid=target, parent=self) + _logger = get_logger(__name__) dialogs = [] # Otherwise python randomly garbage collects the dialogs... @@ -652,7 +702,6 @@ class BaseTxDialog(QDialog, MessageBoxMixin): run_hook('transaction_dialog_update', self) - def add_tx_stats(self, vbox): hbox_stats = QHBoxLayout()