electrum

Electrum Bitcoin wallet
git clone https://git.parazyd.org/electrum
Log | Files | Refs | Submodules

util.py (38566B)


      1 import asyncio
      2 import os.path
      3 import time
      4 import sys
      5 import platform
      6 import queue
      7 import traceback
      8 import os
      9 import webbrowser
     10 from decimal import Decimal
     11 from functools import partial, lru_cache
     12 from typing import (NamedTuple, Callable, Optional, TYPE_CHECKING, Union, List, Dict, Any,
     13                     Sequence, Iterable)
     14 
     15 from PyQt5.QtGui import (QFont, QColor, QCursor, QPixmap, QStandardItem,
     16                          QPalette, QIcon, QFontMetrics, QShowEvent)
     17 from PyQt5.QtCore import (Qt, QPersistentModelIndex, QModelIndex, pyqtSignal,
     18                           QCoreApplication, QItemSelectionModel, QThread,
     19                           QSortFilterProxyModel, QSize, QLocale, QAbstractItemModel)
     20 from PyQt5.QtWidgets import (QPushButton, QLabel, QMessageBox, QHBoxLayout,
     21                              QAbstractItemView, QVBoxLayout, QLineEdit,
     22                              QStyle, QDialog, QGroupBox, QButtonGroup, QRadioButton,
     23                              QFileDialog, QWidget, QToolButton, QTreeView, QPlainTextEdit,
     24                              QHeaderView, QApplication, QToolTip, QTreeWidget, QStyledItemDelegate,
     25                              QMenu)
     26 
     27 from electrum.i18n import _, languages
     28 from electrum.util import FileImportFailed, FileExportFailed, make_aiohttp_session, resource_path
     29 from electrum.invoices import PR_UNPAID, PR_PAID, PR_EXPIRED, PR_INFLIGHT, PR_UNKNOWN, PR_FAILED, PR_ROUTING, PR_UNCONFIRMED
     30 
     31 if TYPE_CHECKING:
     32     from .main_window import ElectrumWindow
     33     from .installwizard import InstallWizard
     34     from electrum.simple_config import SimpleConfig
     35 
     36 
     37 if platform.system() == 'Windows':
     38     MONOSPACE_FONT = 'Lucida Console'
     39 elif platform.system() == 'Darwin':
     40     MONOSPACE_FONT = 'Monaco'
     41 else:
     42     MONOSPACE_FONT = 'monospace'
     43 
     44 
     45 dialogs = []
     46 
     47 pr_icons = {
     48     PR_UNKNOWN:"warning.png",
     49     PR_UNPAID:"unpaid.png",
     50     PR_PAID:"confirmed.png",
     51     PR_EXPIRED:"expired.png",
     52     PR_INFLIGHT:"unconfirmed.png",
     53     PR_FAILED:"warning.png",
     54     PR_ROUTING:"unconfirmed.png",
     55     PR_UNCONFIRMED:"unconfirmed.png",
     56 }
     57 
     58 
     59 # filter tx files in QFileDialog:
     60 TRANSACTION_FILE_EXTENSION_FILTER_ANY = "Transaction (*.txn *.psbt);;All files (*)"
     61 TRANSACTION_FILE_EXTENSION_FILTER_ONLY_PARTIAL_TX = "Partial Transaction (*.psbt)"
     62 TRANSACTION_FILE_EXTENSION_FILTER_ONLY_COMPLETE_TX = "Complete Transaction (*.txn)"
     63 TRANSACTION_FILE_EXTENSION_FILTER_SEPARATE = (f"{TRANSACTION_FILE_EXTENSION_FILTER_ONLY_PARTIAL_TX};;"
     64                                               f"{TRANSACTION_FILE_EXTENSION_FILTER_ONLY_COMPLETE_TX};;"
     65                                               f"All files (*)")
     66 
     67 
     68 class EnterButton(QPushButton):
     69     def __init__(self, text, func):
     70         QPushButton.__init__(self, text)
     71         self.func = func
     72         self.clicked.connect(func)
     73 
     74     def keyPressEvent(self, e):
     75         if e.key() in [ Qt.Key_Return, Qt.Key_Enter ]:
     76             self.func()
     77 
     78 
     79 class ThreadedButton(QPushButton):
     80     def __init__(self, text, task, on_success=None, on_error=None):
     81         QPushButton.__init__(self, text)
     82         self.task = task
     83         self.on_success = on_success
     84         self.on_error = on_error
     85         self.clicked.connect(self.run_task)
     86 
     87     def run_task(self):
     88         self.setEnabled(False)
     89         self.thread = TaskThread(self)
     90         self.thread.add(self.task, self.on_success, self.done, self.on_error)
     91 
     92     def done(self):
     93         self.setEnabled(True)
     94         self.thread.stop()
     95 
     96 
     97 class WWLabel(QLabel):
     98     def __init__ (self, text="", parent=None):
     99         QLabel.__init__(self, text, parent)
    100         self.setWordWrap(True)
    101         self.setTextInteractionFlags(Qt.TextSelectableByMouse)
    102 
    103 
    104 class HelpLabel(QLabel):
    105 
    106     def __init__(self, text, help_text):
    107         QLabel.__init__(self, text)
    108         self.help_text = help_text
    109         self.app = QCoreApplication.instance()
    110         self.font = QFont()
    111 
    112     def mouseReleaseEvent(self, x):
    113         custom_message_box(icon=QMessageBox.Information,
    114                            parent=self,
    115                            title=_('Help'),
    116                            text=self.help_text)
    117 
    118     def enterEvent(self, event):
    119         self.font.setUnderline(True)
    120         self.setFont(self.font)
    121         self.app.setOverrideCursor(QCursor(Qt.PointingHandCursor))
    122         return QLabel.enterEvent(self, event)
    123 
    124     def leaveEvent(self, event):
    125         self.font.setUnderline(False)
    126         self.setFont(self.font)
    127         self.app.setOverrideCursor(QCursor(Qt.ArrowCursor))
    128         return QLabel.leaveEvent(self, event)
    129 
    130 
    131 class HelpButton(QToolButton):
    132     def __init__(self, text):
    133         QToolButton.__init__(self)
    134         self.setText('?')
    135         self.help_text = text
    136         self.setFocusPolicy(Qt.NoFocus)
    137         self.setFixedWidth(round(2.2 * char_width_in_lineedit()))
    138         self.clicked.connect(self.onclick)
    139 
    140     def onclick(self):
    141         custom_message_box(icon=QMessageBox.Information,
    142                            parent=self,
    143                            title=_('Help'),
    144                            text=self.help_text,
    145                            rich_text=True)
    146 
    147 
    148 class InfoButton(QPushButton):
    149     def __init__(self, text):
    150         QPushButton.__init__(self, 'Info')
    151         self.help_text = text
    152         self.setFocusPolicy(Qt.NoFocus)
    153         self.setFixedWidth(6 * char_width_in_lineedit())
    154         self.clicked.connect(self.onclick)
    155 
    156     def onclick(self):
    157         custom_message_box(icon=QMessageBox.Information,
    158                            parent=self,
    159                            title=_('Info'),
    160                            text=self.help_text,
    161                            rich_text=True)
    162 
    163 
    164 class Buttons(QHBoxLayout):
    165     def __init__(self, *buttons):
    166         QHBoxLayout.__init__(self)
    167         self.addStretch(1)
    168         for b in buttons:
    169             if b is None:
    170                 continue
    171             self.addWidget(b)
    172 
    173 class CloseButton(QPushButton):
    174     def __init__(self, dialog):
    175         QPushButton.__init__(self, _("Close"))
    176         self.clicked.connect(dialog.close)
    177         self.setDefault(True)
    178 
    179 class CopyButton(QPushButton):
    180     def __init__(self, text_getter, app):
    181         QPushButton.__init__(self, _("Copy"))
    182         self.clicked.connect(lambda: app.clipboard().setText(text_getter()))
    183 
    184 class CopyCloseButton(QPushButton):
    185     def __init__(self, text_getter, app, dialog):
    186         QPushButton.__init__(self, _("Copy and Close"))
    187         self.clicked.connect(lambda: app.clipboard().setText(text_getter()))
    188         self.clicked.connect(dialog.close)
    189         self.setDefault(True)
    190 
    191 class OkButton(QPushButton):
    192     def __init__(self, dialog, label=None):
    193         QPushButton.__init__(self, label or _("OK"))
    194         self.clicked.connect(dialog.accept)
    195         self.setDefault(True)
    196 
    197 class CancelButton(QPushButton):
    198     def __init__(self, dialog, label=None):
    199         QPushButton.__init__(self, label or _("Cancel"))
    200         self.clicked.connect(dialog.reject)
    201 
    202 class MessageBoxMixin(object):
    203     def top_level_window_recurse(self, window=None, test_func=None):
    204         window = window or self
    205         classes = (WindowModalDialog, QMessageBox)
    206         if test_func is None:
    207             test_func = lambda x: True
    208         for n, child in enumerate(window.children()):
    209             # Test for visibility as old closed dialogs may not be GC-ed.
    210             # Only accept children that confirm to test_func.
    211             if isinstance(child, classes) and child.isVisible() \
    212                     and test_func(child):
    213                 return self.top_level_window_recurse(child, test_func=test_func)
    214         return window
    215 
    216     def top_level_window(self, test_func=None):
    217         return self.top_level_window_recurse(test_func)
    218 
    219     def question(self, msg, parent=None, title=None, icon=None, **kwargs) -> bool:
    220         Yes, No = QMessageBox.Yes, QMessageBox.No
    221         return Yes == self.msg_box(icon=icon or QMessageBox.Question,
    222                                    parent=parent,
    223                                    title=title or '',
    224                                    text=msg,
    225                                    buttons=Yes|No,
    226                                    defaultButton=No,
    227                                    **kwargs)
    228 
    229     def show_warning(self, msg, parent=None, title=None, **kwargs):
    230         return self.msg_box(QMessageBox.Warning, parent,
    231                             title or _('Warning'), msg, **kwargs)
    232 
    233     def show_error(self, msg, parent=None, **kwargs):
    234         return self.msg_box(QMessageBox.Warning, parent,
    235                             _('Error'), msg, **kwargs)
    236 
    237     def show_critical(self, msg, parent=None, title=None, **kwargs):
    238         return self.msg_box(QMessageBox.Critical, parent,
    239                             title or _('Critical Error'), msg, **kwargs)
    240 
    241     def show_message(self, msg, parent=None, title=None, **kwargs):
    242         return self.msg_box(QMessageBox.Information, parent,
    243                             title or _('Information'), msg, **kwargs)
    244 
    245     def msg_box(self, icon, parent, title, text, *, buttons=QMessageBox.Ok,
    246                 defaultButton=QMessageBox.NoButton, rich_text=False,
    247                 checkbox=None):
    248         parent = parent or self.top_level_window()
    249         return custom_message_box(icon=icon,
    250                                   parent=parent,
    251                                   title=title,
    252                                   text=text,
    253                                   buttons=buttons,
    254                                   defaultButton=defaultButton,
    255                                   rich_text=rich_text,
    256                                   checkbox=checkbox)
    257 
    258 
    259 def custom_message_box(*, icon, parent, title, text, buttons=QMessageBox.Ok,
    260                        defaultButton=QMessageBox.NoButton, rich_text=False,
    261                        checkbox=None):
    262     if type(icon) is QPixmap:
    263         d = QMessageBox(QMessageBox.Information, title, str(text), buttons, parent)
    264         d.setIconPixmap(icon)
    265     else:
    266         d = QMessageBox(icon, title, str(text), buttons, parent)
    267     d.setWindowModality(Qt.WindowModal)
    268     d.setDefaultButton(defaultButton)
    269     if rich_text:
    270         d.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.LinksAccessibleByMouse)
    271         # set AutoText instead of RichText
    272         # AutoText lets Qt figure out whether to render as rich text.
    273         # e.g. if text is actually plain text and uses "\n" newlines;
    274         #      and we set RichText here, newlines would be swallowed
    275         d.setTextFormat(Qt.AutoText)
    276     else:
    277         d.setTextInteractionFlags(Qt.TextSelectableByMouse)
    278         d.setTextFormat(Qt.PlainText)
    279     if checkbox is not None:
    280         d.setCheckBox(checkbox)
    281     return d.exec_()
    282 
    283 
    284 class WindowModalDialog(QDialog, MessageBoxMixin):
    285     '''Handy wrapper; window modal dialogs are better for our multi-window
    286     daemon model as other wallet windows can still be accessed.'''
    287     def __init__(self, parent, title=None):
    288         QDialog.__init__(self, parent)
    289         self.setWindowModality(Qt.WindowModal)
    290         if title:
    291             self.setWindowTitle(title)
    292 
    293 
    294 class WaitingDialog(WindowModalDialog):
    295     '''Shows a please wait dialog whilst running a task.  It is not
    296     necessary to maintain a reference to this dialog.'''
    297     def __init__(self, parent: QWidget, message: str, task, on_success=None, on_error=None):
    298         assert parent
    299         if isinstance(parent, MessageBoxMixin):
    300             parent = parent.top_level_window()
    301         WindowModalDialog.__init__(self, parent, _("Please wait"))
    302         self.message_label = QLabel(message)
    303         vbox = QVBoxLayout(self)
    304         vbox.addWidget(self.message_label)
    305         self.accepted.connect(self.on_accepted)
    306         self.show()
    307         self.thread = TaskThread(self)
    308         self.thread.finished.connect(self.deleteLater)  # see #3956
    309         self.thread.add(task, on_success, self.accept, on_error)
    310 
    311     def wait(self):
    312         self.thread.wait()
    313 
    314     def on_accepted(self):
    315         self.thread.stop()
    316 
    317     def update(self, msg):
    318         print(msg)
    319         self.message_label.setText(msg)
    320 
    321 
    322 class BlockingWaitingDialog(WindowModalDialog):
    323     """Shows a waiting dialog whilst running a task.
    324     Should be called from the GUI thread. The GUI thread will be blocked while
    325     the task is running; the point of the dialog is to provide feedback
    326     to the user regarding what is going on.
    327     """
    328     def __init__(self, parent: QWidget, message: str, task: Callable[[], Any]):
    329         assert parent
    330         if isinstance(parent, MessageBoxMixin):
    331             parent = parent.top_level_window()
    332         WindowModalDialog.__init__(self, parent, _("Please wait"))
    333         self.message_label = QLabel(message)
    334         vbox = QVBoxLayout(self)
    335         vbox.addWidget(self.message_label)
    336         # show popup
    337         self.show()
    338         # refresh GUI; needed for popup to appear and for message_label to get drawn
    339         QCoreApplication.processEvents()
    340         QCoreApplication.processEvents()
    341         # block and run given task
    342         task()
    343         # close popup
    344         self.accept()
    345 
    346 
    347 def line_dialog(parent, title, label, ok_label, default=None):
    348     dialog = WindowModalDialog(parent, title)
    349     dialog.setMinimumWidth(500)
    350     l = QVBoxLayout()
    351     dialog.setLayout(l)
    352     l.addWidget(QLabel(label))
    353     txt = QLineEdit()
    354     if default:
    355         txt.setText(default)
    356     l.addWidget(txt)
    357     l.addLayout(Buttons(CancelButton(dialog), OkButton(dialog, ok_label)))
    358     if dialog.exec_():
    359         return txt.text()
    360 
    361 def text_dialog(
    362         *,
    363         parent,
    364         title,
    365         header_layout,
    366         ok_label,
    367         default=None,
    368         allow_multi=False,
    369         config: 'SimpleConfig',
    370 ):
    371     from .qrtextedit import ScanQRTextEdit
    372     dialog = WindowModalDialog(parent, title)
    373     dialog.setMinimumWidth(600)
    374     l = QVBoxLayout()
    375     dialog.setLayout(l)
    376     if isinstance(header_layout, str):
    377         l.addWidget(QLabel(header_layout))
    378     else:
    379         l.addLayout(header_layout)
    380     txt = ScanQRTextEdit(allow_multi=allow_multi, config=config)
    381     if default:
    382         txt.setText(default)
    383     l.addWidget(txt)
    384     l.addLayout(Buttons(CancelButton(dialog), OkButton(dialog, ok_label)))
    385     if dialog.exec_():
    386         return txt.toPlainText()
    387 
    388 class ChoicesLayout(object):
    389     def __init__(self, msg, choices, on_clicked=None, checked_index=0):
    390         vbox = QVBoxLayout()
    391         if len(msg) > 50:
    392             vbox.addWidget(WWLabel(msg))
    393             msg = ""
    394         gb2 = QGroupBox(msg)
    395         vbox.addWidget(gb2)
    396 
    397         vbox2 = QVBoxLayout()
    398         gb2.setLayout(vbox2)
    399 
    400         self.group = group = QButtonGroup()
    401         for i,c in enumerate(choices):
    402             button = QRadioButton(gb2)
    403             button.setText(c)
    404             vbox2.addWidget(button)
    405             group.addButton(button)
    406             group.setId(button, i)
    407             if i==checked_index:
    408                 button.setChecked(True)
    409 
    410         if on_clicked:
    411             group.buttonClicked.connect(partial(on_clicked, self))
    412 
    413         self.vbox = vbox
    414 
    415     def layout(self):
    416         return self.vbox
    417 
    418     def selected_index(self):
    419         return self.group.checkedId()
    420 
    421 def address_field(addresses):
    422     hbox = QHBoxLayout()
    423     address_e = QLineEdit()
    424     if addresses and len(addresses) > 0:
    425         address_e.setText(addresses[0])
    426     else:
    427         addresses = []
    428     def func():
    429         try:
    430             i = addresses.index(str(address_e.text())) + 1
    431             i = i % len(addresses)
    432             address_e.setText(addresses[i])
    433         except ValueError:
    434             # the user might have changed address_e to an
    435             # address not in the wallet (or to something that isn't an address)
    436             if addresses and len(addresses) > 0:
    437                 address_e.setText(addresses[0])
    438     button = QPushButton(_('Address'))
    439     button.clicked.connect(func)
    440     hbox.addWidget(button)
    441     hbox.addWidget(address_e)
    442     return hbox, address_e
    443 
    444 
    445 def filename_field(parent, config, defaultname, select_msg):
    446 
    447     vbox = QVBoxLayout()
    448     vbox.addWidget(QLabel(_("Format")))
    449     gb = QGroupBox("format", parent)
    450     b1 = QRadioButton(gb)
    451     b1.setText(_("CSV"))
    452     b1.setChecked(True)
    453     b2 = QRadioButton(gb)
    454     b2.setText(_("json"))
    455     vbox.addWidget(b1)
    456     vbox.addWidget(b2)
    457 
    458     hbox = QHBoxLayout()
    459 
    460     directory = config.get('io_dir', os.path.expanduser('~'))
    461     path = os.path.join( directory, defaultname )
    462     filename_e = QLineEdit()
    463     filename_e.setText(path)
    464 
    465     def func():
    466         text = filename_e.text()
    467         _filter = "*.csv" if defaultname.endswith(".csv") else "*.json" if defaultname.endswith(".json") else None
    468         p = getSaveFileName(
    469             parent=None,
    470             title=select_msg,
    471             filename=text,
    472             filter=_filter,
    473             config=config,
    474         )
    475         if p:
    476             filename_e.setText(p)
    477 
    478     button = QPushButton(_('File'))
    479     button.clicked.connect(func)
    480     hbox.addWidget(button)
    481     hbox.addWidget(filename_e)
    482     vbox.addLayout(hbox)
    483 
    484     def set_csv(v):
    485         text = filename_e.text()
    486         text = text.replace(".json",".csv") if v else text.replace(".csv",".json")
    487         filename_e.setText(text)
    488 
    489     b1.clicked.connect(lambda: set_csv(True))
    490     b2.clicked.connect(lambda: set_csv(False))
    491 
    492     return vbox, filename_e, b1
    493 
    494 
    495 class ElectrumItemDelegate(QStyledItemDelegate):
    496     def __init__(self, tv: 'MyTreeView'):
    497         super().__init__(tv)
    498         self.tv = tv
    499         self.opened = None
    500         def on_closeEditor(editor: QLineEdit, hint):
    501             self.opened = None
    502             self.tv.is_editor_open = False
    503             if self.tv._pending_update:
    504                 self.tv.update()
    505         def on_commitData(editor: QLineEdit):
    506             new_text = editor.text()
    507             idx = QModelIndex(self.opened)
    508             row, col = idx.row(), idx.column()
    509             _prior_text, user_role = self.tv.get_text_and_userrole_from_coordinate(row, col)
    510             # check that we didn't forget to set UserRole on an editable field
    511             assert user_role is not None, (row, col)
    512             self.tv.on_edited(idx, user_role, new_text)
    513         self.closeEditor.connect(on_closeEditor)
    514         self.commitData.connect(on_commitData)
    515 
    516     def createEditor(self, parent, option, idx):
    517         self.opened = QPersistentModelIndex(idx)
    518         self.tv.is_editor_open = True
    519         return super().createEditor(parent, option, idx)
    520 
    521 
    522 class MyTreeView(QTreeView):
    523     ROLE_CLIPBOARD_DATA = Qt.UserRole + 100
    524 
    525     filter_columns: Iterable[int]
    526 
    527     def __init__(self, parent: 'ElectrumWindow', create_menu, *,
    528                  stretch_column=None, editable_columns=None):
    529         super().__init__(parent)
    530         self.parent = parent
    531         self.config = self.parent.config
    532         self.stretch_column = stretch_column
    533         self.setContextMenuPolicy(Qt.CustomContextMenu)
    534         self.customContextMenuRequested.connect(create_menu)
    535         self.setUniformRowHeights(True)
    536 
    537         # Control which columns are editable
    538         if editable_columns is not None:
    539             editable_columns = set(editable_columns)
    540         elif stretch_column is not None:
    541             editable_columns = {stretch_column}
    542         else:
    543             editable_columns = {}
    544         self.editable_columns = editable_columns
    545         self.setItemDelegate(ElectrumItemDelegate(self))
    546         self.current_filter = ""
    547         self.is_editor_open = False
    548 
    549         self.setRootIsDecorated(False)  # remove left margin
    550         self.toolbar_shown = False
    551 
    552         # When figuring out the size of columns, Qt by default looks at
    553         # the first 1000 rows (at least if resize mode is QHeaderView.ResizeToContents).
    554         # This would be REALLY SLOW, and it's not perfect anyway.
    555         # So to speed the UI up considerably, set it to
    556         # only look at as many rows as currently visible.
    557         self.header().setResizeContentsPrecision(0)
    558 
    559         self._pending_update = False
    560         self._forced_update = False
    561 
    562     def set_editability(self, items):
    563         for idx, i in enumerate(items):
    564             i.setEditable(idx in self.editable_columns)
    565 
    566     def selected_in_column(self, column: int):
    567         items = self.selectionModel().selectedIndexes()
    568         return list(x for x in items if x.column() == column)
    569 
    570     def current_item_user_role(self, col) -> Any:
    571         idx = self.selectionModel().currentIndex()
    572         idx = idx.sibling(idx.row(), col)
    573         item = self.item_from_index(idx)
    574         if item:
    575             return item.data(Qt.UserRole)
    576 
    577     def item_from_index(self, idx: QModelIndex) -> Optional[QStandardItem]:
    578         model = self.model()
    579         if isinstance(model, QSortFilterProxyModel):
    580             idx = model.mapToSource(idx)
    581             return model.sourceModel().itemFromIndex(idx)
    582         else:
    583             return model.itemFromIndex(idx)
    584 
    585     def original_model(self) -> QAbstractItemModel:
    586         model = self.model()
    587         if isinstance(model, QSortFilterProxyModel):
    588             return model.sourceModel()
    589         else:
    590             return model
    591 
    592     def set_current_idx(self, set_current: QPersistentModelIndex):
    593         if set_current:
    594             assert isinstance(set_current, QPersistentModelIndex)
    595             assert set_current.isValid()
    596             self.selectionModel().select(QModelIndex(set_current), QItemSelectionModel.SelectCurrent)
    597 
    598     def update_headers(self, headers: Union[List[str], Dict[int, str]]):
    599         # headers is either a list of column names, or a dict: (col_idx->col_name)
    600         if not isinstance(headers, dict):  # convert to dict
    601             headers = dict(enumerate(headers))
    602         col_names = [headers[col_idx] for col_idx in sorted(headers.keys())]
    603         self.original_model().setHorizontalHeaderLabels(col_names)
    604         self.header().setStretchLastSection(False)
    605         for col_idx in headers:
    606             sm = QHeaderView.Stretch if col_idx == self.stretch_column else QHeaderView.ResizeToContents
    607             self.header().setSectionResizeMode(col_idx, sm)
    608 
    609     def keyPressEvent(self, event):
    610         if self.itemDelegate().opened:
    611             return
    612         if event.key() in [ Qt.Key_F2, Qt.Key_Return, Qt.Key_Enter ]:
    613             self.on_activated(self.selectionModel().currentIndex())
    614             return
    615         super().keyPressEvent(event)
    616 
    617     def on_activated(self, idx):
    618         # on 'enter' we show the menu
    619         pt = self.visualRect(idx).bottomLeft()
    620         pt.setX(50)
    621         self.customContextMenuRequested.emit(pt)
    622 
    623     def edit(self, idx, trigger=QAbstractItemView.AllEditTriggers, event=None):
    624         """
    625         this is to prevent:
    626            edit: editing failed
    627         from inside qt
    628         """
    629         return super().edit(idx, trigger, event)
    630 
    631     def on_edited(self, idx: QModelIndex, user_role, text):
    632         self.parent.wallet.set_label(user_role, text)
    633         self.parent.history_model.refresh('on_edited in MyTreeView')
    634         self.parent.utxo_list.update()
    635         self.parent.update_completions()
    636 
    637     def should_hide(self, row):
    638         """
    639         row_num is for self.model(). So if there is a proxy, it is the row number
    640         in that!
    641         """
    642         return False
    643 
    644     def get_text_and_userrole_from_coordinate(self, row_num, column):
    645         idx = self.model().index(row_num, column)
    646         item = self.item_from_index(idx)
    647         user_role = item.data(Qt.UserRole)
    648         return item.text(), user_role
    649 
    650     def hide_row(self, row_num):
    651         """
    652         row_num is for self.model(). So if there is a proxy, it is the row number
    653         in that!
    654         """
    655         should_hide = self.should_hide(row_num)
    656         if not self.current_filter and should_hide is None:
    657             # no filters at all, neither date nor search
    658             self.setRowHidden(row_num, QModelIndex(), False)
    659             return
    660         for column in self.filter_columns:
    661             txt, _ = self.get_text_and_userrole_from_coordinate(row_num, column)
    662             txt = txt.lower()
    663             if self.current_filter in txt:
    664                 # the filter matched, but the date filter might apply
    665                 self.setRowHidden(row_num, QModelIndex(), bool(should_hide))
    666                 break
    667         else:
    668             # we did not find the filter in any columns, hide the item
    669             self.setRowHidden(row_num, QModelIndex(), True)
    670 
    671     def filter(self, p=None):
    672         if p is not None:
    673             p = p.lower()
    674             self.current_filter = p
    675         self.hide_rows()
    676 
    677     def hide_rows(self):
    678         for row in range(self.model().rowCount()):
    679             self.hide_row(row)
    680 
    681     def create_toolbar(self, config=None):
    682         hbox = QHBoxLayout()
    683         buttons = self.get_toolbar_buttons()
    684         for b in buttons:
    685             b.setVisible(False)
    686             hbox.addWidget(b)
    687         hide_button = QPushButton('x')
    688         hide_button.setVisible(False)
    689         hide_button.pressed.connect(lambda: self.show_toolbar(False, config))
    690         self.toolbar_buttons = buttons + (hide_button,)
    691         hbox.addStretch()
    692         hbox.addWidget(hide_button)
    693         return hbox
    694 
    695     def save_toolbar_state(self, state, config):
    696         pass  # implemented in subclasses
    697 
    698     def show_toolbar(self, state, config=None):
    699         if state == self.toolbar_shown:
    700             return
    701         self.toolbar_shown = state
    702         if config:
    703             self.save_toolbar_state(state, config)
    704         for b in self.toolbar_buttons:
    705             b.setVisible(state)
    706         if not state:
    707             self.on_hide_toolbar()
    708 
    709     def toggle_toolbar(self, config=None):
    710         self.show_toolbar(not self.toolbar_shown, config)
    711 
    712     def add_copy_menu(self, menu: QMenu, idx) -> QMenu:
    713         cc = menu.addMenu(_("Copy"))
    714         for column in self.Columns:
    715             column_title = self.original_model().horizontalHeaderItem(column).text()
    716             item_col = self.item_from_index(idx.sibling(idx.row(), column))
    717             clipboard_data = item_col.data(self.ROLE_CLIPBOARD_DATA)
    718             if clipboard_data is None:
    719                 clipboard_data = item_col.text().strip()
    720             cc.addAction(column_title,
    721                          lambda text=clipboard_data, title=column_title:
    722                          self.place_text_on_clipboard(text, title=title))
    723         return cc
    724 
    725     def place_text_on_clipboard(self, text: str, *, title: str = None) -> None:
    726         self.parent.do_copy(text, title=title)
    727 
    728     def showEvent(self, e: 'QShowEvent'):
    729         super().showEvent(e)
    730         if e.isAccepted() and self._pending_update:
    731             self._forced_update = True
    732             self.update()
    733             self._forced_update = False
    734 
    735     def maybe_defer_update(self) -> bool:
    736         """Returns whether we should defer an update/refresh."""
    737         defer = (not self._forced_update
    738                  and (not self.isVisible() or self.is_editor_open))
    739         # side-effect: if we decide to defer update, the state will become stale:
    740         self._pending_update = defer
    741         return defer
    742 
    743 
    744 class MySortModel(QSortFilterProxyModel):
    745     def __init__(self, parent, *, sort_role):
    746         super().__init__(parent)
    747         self._sort_role = sort_role
    748 
    749     def lessThan(self, source_left: QModelIndex, source_right: QModelIndex):
    750         item1 = self.sourceModel().itemFromIndex(source_left)
    751         item2 = self.sourceModel().itemFromIndex(source_right)
    752         data1 = item1.data(self._sort_role)
    753         data2 = item2.data(self._sort_role)
    754         if data1 is not None and data2 is not None:
    755             return data1 < data2
    756         v1 = item1.text()
    757         v2 = item2.text()
    758         try:
    759             return Decimal(v1) < Decimal(v2)
    760         except:
    761             return v1 < v2
    762 
    763 
    764 class ButtonsWidget(QWidget):
    765 
    766     def __init__(self):
    767         super(QWidget, self).__init__()
    768         self.buttons = []  # type: List[QToolButton]
    769 
    770     def resizeButtons(self):
    771         frameWidth = self.style().pixelMetric(QStyle.PM_DefaultFrameWidth)
    772         x = self.rect().right() - frameWidth - 10
    773         y = self.rect().bottom() - frameWidth
    774         for button in self.buttons:
    775             sz = button.sizeHint()
    776             x -= sz.width()
    777             button.move(x, y - sz.height())
    778 
    779     def addButton(self, icon_name, on_click, tooltip):
    780         button = QToolButton(self)
    781         button.setIcon(read_QIcon(icon_name))
    782         button.setIconSize(QSize(25,25))
    783         button.setCursor(QCursor(Qt.PointingHandCursor))
    784         button.setStyleSheet("QToolButton { border: none; hover {border: 1px} pressed {border: 1px} padding: 0px; }")
    785         button.setVisible(True)
    786         button.setToolTip(tooltip)
    787         button.clicked.connect(on_click)
    788         self.buttons.append(button)
    789         return button
    790 
    791     def addCopyButton(self, app):
    792         self.app = app
    793         self.addButton("copy.png", self.on_copy, _("Copy to clipboard"))
    794 
    795     def on_copy(self):
    796         self.app.clipboard().setText(self.text())
    797         QToolTip.showText(QCursor.pos(), _("Text copied to clipboard"), self)
    798 
    799     def addPasteButton(self, app):
    800         self.app = app
    801         self.addButton("copy.png", self.on_paste, _("Paste from clipboard"))
    802 
    803     def on_paste(self):
    804         self.setText(self.app.clipboard().text())
    805 
    806 
    807 class ButtonsLineEdit(QLineEdit, ButtonsWidget):
    808     def __init__(self, text=None):
    809         QLineEdit.__init__(self, text)
    810         self.buttons = []
    811 
    812     def resizeEvent(self, e):
    813         o = QLineEdit.resizeEvent(self, e)
    814         self.resizeButtons()
    815         return o
    816 
    817 class ButtonsTextEdit(QPlainTextEdit, ButtonsWidget):
    818     def __init__(self, text=None):
    819         QPlainTextEdit.__init__(self, text)
    820         self.setText = self.setPlainText
    821         self.text = self.toPlainText
    822         self.buttons = []
    823 
    824     def resizeEvent(self, e):
    825         o = QPlainTextEdit.resizeEvent(self, e)
    826         self.resizeButtons()
    827         return o
    828 
    829 
    830 class PasswordLineEdit(QLineEdit):
    831     def __init__(self, *args, **kwargs):
    832         QLineEdit.__init__(self, *args, **kwargs)
    833         self.setEchoMode(QLineEdit.Password)
    834 
    835     def clear(self):
    836         # Try to actually overwrite the memory.
    837         # This is really just a best-effort thing...
    838         self.setText(len(self.text()) * " ")
    839         super().clear()
    840 
    841 
    842 class TaskThread(QThread):
    843     '''Thread that runs background tasks.  Callbacks are guaranteed
    844     to happen in the context of its parent.'''
    845 
    846     class Task(NamedTuple):
    847         task: Callable
    848         cb_success: Optional[Callable]
    849         cb_done: Optional[Callable]
    850         cb_error: Optional[Callable]
    851 
    852     doneSig = pyqtSignal(object, object, object)
    853 
    854     def __init__(self, parent, on_error=None):
    855         super(TaskThread, self).__init__(parent)
    856         self.on_error = on_error
    857         self.tasks = queue.Queue()
    858         self.doneSig.connect(self.on_done)
    859         self.start()
    860 
    861     def add(self, task, on_success=None, on_done=None, on_error=None):
    862         on_error = on_error or self.on_error
    863         self.tasks.put(TaskThread.Task(task, on_success, on_done, on_error))
    864 
    865     def run(self):
    866         while True:
    867             task = self.tasks.get()  # type: TaskThread.Task
    868             if not task:
    869                 break
    870             try:
    871                 result = task.task()
    872                 self.doneSig.emit(result, task.cb_done, task.cb_success)
    873             except BaseException:
    874                 self.doneSig.emit(sys.exc_info(), task.cb_done, task.cb_error)
    875 
    876     def on_done(self, result, cb_done, cb_result):
    877         # This runs in the parent's thread.
    878         if cb_done:
    879             cb_done()
    880         if cb_result:
    881             cb_result(result)
    882 
    883     def stop(self):
    884         self.tasks.put(None)
    885 
    886 
    887 class ColorSchemeItem:
    888     def __init__(self, fg_color, bg_color):
    889         self.colors = (fg_color, bg_color)
    890 
    891     def _get_color(self, background):
    892         return self.colors[(int(background) + int(ColorScheme.dark_scheme)) % 2]
    893 
    894     def as_stylesheet(self, background=False):
    895         css_prefix = "background-" if background else ""
    896         color = self._get_color(background)
    897         return "QWidget {{ {}color:{}; }}".format(css_prefix, color)
    898 
    899     def as_color(self, background=False):
    900         color = self._get_color(background)
    901         return QColor(color)
    902 
    903 
    904 class ColorScheme:
    905     dark_scheme = False
    906 
    907     GREEN = ColorSchemeItem("#117c11", "#8af296")
    908     YELLOW = ColorSchemeItem("#897b2a", "#ffff00")
    909     RED = ColorSchemeItem("#7c1111", "#f18c8c")
    910     BLUE = ColorSchemeItem("#123b7c", "#8cb3f2")
    911     DEFAULT = ColorSchemeItem("black", "white")
    912     GRAY = ColorSchemeItem("gray", "gray")
    913 
    914     @staticmethod
    915     def has_dark_background(widget):
    916         brightness = sum(widget.palette().color(QPalette.Background).getRgb()[0:3])
    917         return brightness < (255*3/2)
    918 
    919     @staticmethod
    920     def update_from_widget(widget, force_dark=False):
    921         if force_dark or ColorScheme.has_dark_background(widget):
    922             ColorScheme.dark_scheme = True
    923 
    924 
    925 class AcceptFileDragDrop:
    926     def __init__(self, file_type=""):
    927         assert isinstance(self, QWidget)
    928         self.setAcceptDrops(True)
    929         self.file_type = file_type
    930 
    931     def validateEvent(self, event):
    932         if not event.mimeData().hasUrls():
    933             event.ignore()
    934             return False
    935         for url in event.mimeData().urls():
    936             if not url.toLocalFile().endswith(self.file_type):
    937                 event.ignore()
    938                 return False
    939         event.accept()
    940         return True
    941 
    942     def dragEnterEvent(self, event):
    943         self.validateEvent(event)
    944 
    945     def dragMoveEvent(self, event):
    946         if self.validateEvent(event):
    947             event.setDropAction(Qt.CopyAction)
    948 
    949     def dropEvent(self, event):
    950         if self.validateEvent(event):
    951             for url in event.mimeData().urls():
    952                 self.onFileAdded(url.toLocalFile())
    953 
    954     def onFileAdded(self, fn):
    955         raise NotImplementedError()
    956 
    957 
    958 def import_meta_gui(electrum_window: 'ElectrumWindow', title, importer, on_success):
    959     filter_ = "JSON (*.json);;All files (*)"
    960     filename = getOpenFileName(
    961         parent=electrum_window,
    962         title=_("Open {} file").format(title),
    963         filter=filter_,
    964         config=electrum_window.config,
    965     )
    966     if not filename:
    967         return
    968     try:
    969         importer(filename)
    970     except FileImportFailed as e:
    971         electrum_window.show_critical(str(e))
    972     else:
    973         electrum_window.show_message(_("Your {} were successfully imported").format(title))
    974         on_success()
    975 
    976 
    977 def export_meta_gui(electrum_window: 'ElectrumWindow', title, exporter):
    978     filter_ = "JSON (*.json);;All files (*)"
    979     filename = getSaveFileName(
    980         parent=electrum_window,
    981         title=_("Select file to save your {}").format(title),
    982         filename='electrum_{}.json'.format(title),
    983         filter=filter_,
    984         config=electrum_window.config,
    985     )
    986     if not filename:
    987         return
    988     try:
    989         exporter(filename)
    990     except FileExportFailed as e:
    991         electrum_window.show_critical(str(e))
    992     else:
    993         electrum_window.show_message(_("Your {0} were exported to '{1}'")
    994                                      .format(title, str(filename)))
    995 
    996 
    997 def getOpenFileName(*, parent, title, filter="", config: 'SimpleConfig') -> Optional[str]:
    998     """Custom wrapper for getOpenFileName that remembers the path selected by the user."""
    999     directory = config.get('io_dir', os.path.expanduser('~'))
   1000     fileName, __ = QFileDialog.getOpenFileName(parent, title, directory, filter)
   1001     if fileName and directory != os.path.dirname(fileName):
   1002         config.set_key('io_dir', os.path.dirname(fileName), True)
   1003     return fileName
   1004 
   1005 
   1006 def getSaveFileName(
   1007         *,
   1008         parent,
   1009         title,
   1010         filename,
   1011         filter="",
   1012         default_extension: str = None,
   1013         default_filter: str = None,
   1014         config: 'SimpleConfig',
   1015 ) -> Optional[str]:
   1016     """Custom wrapper for getSaveFileName that remembers the path selected by the user."""
   1017     directory = config.get('io_dir', os.path.expanduser('~'))
   1018     path = os.path.join(directory, filename)
   1019 
   1020     file_dialog = QFileDialog(parent, title, path, filter)
   1021     file_dialog.setAcceptMode(QFileDialog.AcceptSave)
   1022     if default_extension:
   1023         # note: on MacOS, the selected filter's first extension seems to have priority over this...
   1024         file_dialog.setDefaultSuffix(default_extension)
   1025     if default_filter:
   1026         assert default_filter in filter, f"default_filter={default_filter!r} does not appear in filter={filter!r}"
   1027         file_dialog.selectNameFilter(default_filter)
   1028     if file_dialog.exec() != QDialog.Accepted:
   1029         return None
   1030 
   1031     selected_path = file_dialog.selectedFiles()[0]
   1032     if selected_path and directory != os.path.dirname(selected_path):
   1033         config.set_key('io_dir', os.path.dirname(selected_path), True)
   1034     return selected_path
   1035 
   1036 
   1037 def icon_path(icon_basename):
   1038     return resource_path('gui', 'icons', icon_basename)
   1039 
   1040 
   1041 @lru_cache(maxsize=1000)
   1042 def read_QIcon(icon_basename):
   1043     return QIcon(icon_path(icon_basename))
   1044 
   1045 class IconLabel(QWidget):
   1046     IconSize = QSize(16, 16)
   1047     HorizontalSpacing = 2
   1048     def __init__(self, *, text='', final_stretch=True):
   1049         super(QWidget, self).__init__()
   1050         layout = QHBoxLayout()
   1051         layout.setContentsMargins(0, 0, 0, 0)
   1052         self.setLayout(layout)
   1053         self.icon = QLabel()
   1054         self.label = QLabel(text)
   1055         layout.addWidget(self.label)
   1056         layout.addSpacing(self.HorizontalSpacing)
   1057         layout.addWidget(self.icon)
   1058         if final_stretch:
   1059             layout.addStretch()
   1060     def setText(self, text):
   1061         self.label.setText(text)
   1062     def setIcon(self, icon):
   1063         self.icon.setPixmap(icon.pixmap(self.IconSize))
   1064         self.icon.repaint()  # macOS hack for #6269
   1065 
   1066 def get_default_language():
   1067     name = QLocale.system().name()
   1068     return name if name in languages else 'en_UK'
   1069 
   1070 
   1071 def char_width_in_lineedit() -> int:
   1072     char_width = QFontMetrics(QLineEdit().font()).averageCharWidth()
   1073     # 'averageCharWidth' seems to underestimate on Windows, hence 'max()'
   1074     return max(9, char_width)
   1075 
   1076 
   1077 def webopen(url: str):
   1078     if sys.platform == 'linux' and os.environ.get('APPIMAGE'):
   1079         # When on Linux webbrowser.open can fail in AppImage because it can't find the correct libdbus.
   1080         # We just fork the process and unset LD_LIBRARY_PATH before opening the URL.
   1081         # See #5425
   1082         if os.fork() == 0:
   1083             del os.environ['LD_LIBRARY_PATH']
   1084             webbrowser.open(url)
   1085             os._exit(0)
   1086     else:
   1087         webbrowser.open(url)
   1088 
   1089 
   1090 if __name__ == "__main__":
   1091     app = QApplication([])
   1092     t = WaitingDialog(None, 'testing ...', lambda: [time.sleep(1)], lambda x: QMessageBox.information(None, 'done', "done"))
   1093     t.start()
   1094     app.exec_()