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