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_()