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