Files
purple-electrumwallet/electrum/gui/qt/util.py
T

1497 lines
52 KiB
Python
Raw Normal View History

from abc import ABC, ABCMeta
2013-06-01 11:06:51 +02:00
import os.path
2013-08-29 16:07:55 +02:00
import time
2014-06-21 21:38:42 +02:00
import sys
2014-09-19 13:36:30 +02:00
import platform
2017-10-23 13:54:09 +02:00
import queue
import os
import webbrowser
2022-06-16 12:05:05 +02:00
from functools import partial, lru_cache, wraps
from typing import (NamedTuple, Callable, Optional, TYPE_CHECKING, List, Any, Sequence, Tuple)
2024-09-05 16:20:01 +00:00
from PyQt6 import QtCore
from PyQt6.QtGui import (QFont, QColor, QCursor, QPixmap, QImage,
QPalette, QIcon, QFontMetrics, QPainter, QContextMenuEvent)
2024-09-05 16:20:01 +00:00
from PyQt6.QtCore import (Qt, pyqtSignal, QCoreApplication, QThread, QSize, QRect, QPoint, QObject)
from PyQt6.QtWidgets import (QPushButton, QLabel, QMessageBox, QHBoxLayout, QVBoxLayout, QLineEdit,
2019-02-11 20:21:24 +01:00
QStyle, QDialog, QGroupBox, QButtonGroup, QRadioButton,
QFileDialog, QWidget, QToolButton, QPlainTextEdit, QApplication, QToolTip,
QGraphicsEffect, QGraphicsScene, QGraphicsPixmapItem, QLayoutItem, QLayout, QMenu,
QFrame)
2014-09-19 13:36:30 +02:00
from electrum.i18n import _
2025-02-04 11:13:37 +01:00
from electrum.util import (FileImportFailed, FileExportFailed, resource_path, EventListener, event_listener,
get_logger, UserCancelled, UserFacingException)
from electrum.invoices import (PR_UNPAID, PR_PAID, PR_EXPIRED, PR_INFLIGHT, PR_UNKNOWN, PR_FAILED, PR_ROUTING,
PR_UNCONFIRMED, PR_BROADCASTING, PR_BROADCAST)
from electrum.logging import Logger
from electrum.qrreader import MissingQrDetectionLib, QrCodeResult
2018-02-21 18:55:37 +01:00
2018-12-03 19:12:36 +01:00
if TYPE_CHECKING:
from .main_window import ElectrumWindow
2023-08-09 16:00:09 +00:00
from .paytoedit import PayToEdit
from electrum.simple_config import SimpleConfig
from electrum.simple_config import ConfigVarWithConfig
2018-12-03 19:12:36 +01:00
2018-02-21 18:55:37 +01:00
2014-09-19 13:36:30 +02:00
if platform.system() == 'Windows':
MONOSPACE_FONT = 'Lucida Console'
elif platform.system() == 'Darwin':
MONOSPACE_FONT = 'Monaco'
else:
MONOSPACE_FONT = 'monospace'
2015-07-11 18:14:00 +02:00
_logger = get_logger(__name__)
2015-12-23 22:10:11 +09:00
dialogs = []
2013-08-29 16:07:55 +02:00
2016-05-27 09:56:53 +02:00
pr_icons = {
2023-12-08 14:07:32 +01:00
PR_UNKNOWN: "warning.png",
PR_UNPAID: "unpaid.png",
PR_PAID: "confirmed.png",
PR_EXPIRED: "expired.png",
PR_INFLIGHT: "unconfirmed.png",
PR_FAILED: "warning.png",
PR_ROUTING: "unconfirmed.png",
PR_UNCONFIRMED: "unconfirmed.png",
PR_BROADCASTING: "unconfirmed.png",
PR_BROADCAST: "unconfirmed.png",
2016-05-27 09:56:53 +02:00
}
# filter tx files in QFileDialog:
TRANSACTION_FILE_EXTENSION_FILTER_ANY = "Transaction (*.txn *.psbt);;All files (*)"
TRANSACTION_FILE_EXTENSION_FILTER_ONLY_PARTIAL_TX = "Partial Transaction (*.psbt)"
TRANSACTION_FILE_EXTENSION_FILTER_ONLY_COMPLETE_TX = "Complete Transaction (*.txn)"
TRANSACTION_FILE_EXTENSION_FILTER_SEPARATE = (f"{TRANSACTION_FILE_EXTENSION_FILTER_ONLY_PARTIAL_TX};;"
f"{TRANSACTION_FILE_EXTENSION_FILTER_ONLY_COMPLETE_TX};;"
f"All files (*)")
2016-05-27 09:56:53 +02:00
2013-08-29 16:07:55 +02:00
class EnterButton(QPushButton):
def __init__(self, text, func):
QPushButton.__init__(self, text)
self.func = func
self.clicked.connect(func)
2022-05-30 20:49:08 +02:00
self._orig_text = text
2013-08-29 16:07:55 +02:00
def keyPressEvent(self, e):
2024-09-05 16:20:01 +00:00
if e.key() in [Qt.Key.Key_Return, Qt.Key.Key_Enter]:
2017-08-01 05:22:18 +02:00
self.func()
2013-08-29 16:07:55 +02:00
2022-05-30 20:49:08 +02:00
def restore_original_text(self):
self.setText(self._orig_text)
2013-08-29 16:07:55 +02:00
class ThreadedButton(QPushButton):
2016-01-17 16:13:32 +09:00
def __init__(self, text, task, on_success=None, on_error=None):
QPushButton.__init__(self, text)
2016-01-17 16:13:32 +09:00
self.task = task
self.on_success = on_success
2016-01-17 16:13:32 +09:00
self.on_error = on_error
self.clicked.connect(self.run_task)
2016-01-17 16:13:32 +09:00
def run_task(self):
self.setEnabled(False)
self.thread = TaskThread(self)
self.thread.add(self.task, self.on_success, self.done, self.on_error)
2016-01-17 16:13:32 +09:00
def done(self):
self.setEnabled(True)
2016-01-17 16:13:32 +09:00
self.thread.stop()
2013-08-29 16:07:55 +02:00
2016-01-13 19:20:58 +09:00
class WWLabel(QLabel):
def __init__ (self, text="", parent=None):
QLabel.__init__(self, text, parent)
self.setWordWrap(True)
2024-09-05 16:20:01 +00:00
self.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
2016-01-13 19:20:58 +09:00
class AmountLabel(QLabel):
def __init__(self, *args, **kwargs):
QLabel.__init__(self, *args, **kwargs)
self.setFont(QFont(MONOSPACE_FONT))
2024-09-05 16:20:01 +00:00
self.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
class HelpMixin:
def __init__(self, help_text: str, *, help_title: str = None):
assert isinstance(self, QWidget), "HelpMixin must be a QWidget instance!"
self.help_text = help_text
self._help_title = help_title or _('Help')
if isinstance(self, QLabel):
self.setTextInteractionFlags(
2024-09-05 16:20:01 +00:00
(self.textInteractionFlags() | Qt.TextInteractionFlag.TextSelectableByMouse)
& ~Qt.TextInteractionFlag.TextSelectableByKeyboard)
def show_help(self):
custom_message_box(
2024-09-05 16:20:01 +00:00
icon=QMessageBox.Icon.Information,
parent=self,
title=self._help_title,
text=self.help_text,
rich_text=True,
)
class HelpLabel(HelpMixin, QLabel):
def __init__(self, text: str, help_text: str):
QLabel.__init__(self, text)
HelpMixin.__init__(self, help_text)
2015-05-02 13:36:10 +02:00
self.app = QCoreApplication.instance()
self.font = self.font()
@classmethod
def from_configvar(cls, cv: 'ConfigVarWithConfig') -> 'HelpLabel':
return HelpLabel(cv.get_short_desc() + ':', cv.get_long_desc())
def mouseReleaseEvent(self, x):
self.show_help()
2015-05-02 13:36:10 +02:00
def enterEvent(self, event):
2015-05-03 07:06:54 +02:00
self.font.setUnderline(True)
self.setFont(self.font)
2024-09-05 16:20:01 +00:00
self.app.setOverrideCursor(QCursor(Qt.CursorShape.PointingHandCursor))
2015-05-02 13:36:10 +02:00
return QLabel.enterEvent(self, event)
def leaveEvent(self, event):
2015-05-03 07:06:54 +02:00
self.font.setUnderline(False)
self.setFont(self.font)
2024-09-05 16:20:01 +00:00
self.app.setOverrideCursor(QCursor(Qt.CursorShape.ArrowCursor))
2015-05-02 13:36:10 +02:00
return QLabel.leaveEvent(self, event)
class HelpButton(HelpMixin, QToolButton):
def __init__(self, text: str):
QToolButton.__init__(self)
HelpMixin.__init__(self, text)
self.setText('?')
2024-09-05 16:20:01 +00:00
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
2019-06-29 05:27:28 +02:00
self.setFixedWidth(round(2.2 * char_width_in_lineedit()))
self.clicked.connect(self.show_help)
2013-08-29 16:07:55 +02:00
class InfoButton(HelpMixin, QPushButton):
def __init__(self, text: str):
2024-02-28 13:40:01 +00:00
QPushButton.__init__(self, _('Info'))
HelpMixin.__init__(self, text, help_title=_('Info'))
2024-09-05 16:20:01 +00:00
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
2019-06-29 05:27:28 +02:00
self.setFixedWidth(6 * char_width_in_lineedit())
self.clicked.connect(self.show_help)
2015-03-14 12:28:19 +01:00
class Buttons(QHBoxLayout):
def __init__(self, *buttons):
QHBoxLayout.__init__(self)
self.addStretch(1)
for b in buttons:
2020-02-18 15:31:05 +01:00
if b is None:
continue
2015-03-14 12:28:19 +01:00
self.addWidget(b)
2023-12-08 14:07:32 +01:00
2015-03-14 12:28:19 +01:00
class CloseButton(QPushButton):
def __init__(self, dialog):
QPushButton.__init__(self, _("Close"))
self.clicked.connect(dialog.close)
self.setDefault(True)
2023-12-08 14:07:32 +01:00
2015-03-14 12:28:19 +01:00
class CopyButton(QPushButton):
2015-04-20 14:44:59 +02:00
def __init__(self, text_getter, app):
2015-03-14 12:28:19 +01:00
QPushButton.__init__(self, _("Copy"))
2015-04-20 14:44:59 +02:00
self.clicked.connect(lambda: app.clipboard().setText(text_getter()))
2015-03-14 12:28:19 +01:00
2023-12-08 14:07:32 +01:00
2015-07-19 10:54:45 +02:00
class CopyCloseButton(QPushButton):
def __init__(self, text_getter, app, dialog):
QPushButton.__init__(self, _("Copy and Close"))
self.clicked.connect(lambda: app.clipboard().setText(text_getter()))
self.clicked.connect(dialog.close)
self.setDefault(True)
2023-12-08 14:07:32 +01:00
2015-03-14 12:28:19 +01:00
class OkButton(QPushButton):
def __init__(self, dialog, label=None):
QPushButton.__init__(self, label or _("OK"))
self.clicked.connect(dialog.accept)
self.setDefault(True)
2023-12-08 14:07:32 +01:00
2015-03-14 12:28:19 +01:00
class CancelButton(QPushButton):
def __init__(self, dialog, label=None):
QPushButton.__init__(self, label or _("Cancel"))
self.clicked.connect(dialog.reject)
2013-06-01 11:06:51 +02:00
2023-12-08 14:07:32 +01:00
2016-01-03 11:18:20 +09:00
class MessageBoxMixin(object):
2018-04-08 15:13:12 +02:00
def top_level_window_recurse(self, window=None, test_func=None):
2016-01-10 12:56:12 +09:00
window = window or self
2016-01-17 19:40:05 +09:00
classes = (WindowModalDialog, QMessageBox)
2018-04-08 15:13:12 +02:00
if test_func is None:
test_func = lambda x: True
2016-01-10 12:56:12 +09:00
for n, child in enumerate(window.children()):
2018-04-08 15:13:12 +02:00
# Test for visibility as old closed dialogs may not be GC-ed.
# Only accept children that confirm to test_func.
if isinstance(child, classes) and child.isVisible() \
and test_func(child):
return self.top_level_window_recurse(child, test_func=test_func)
2016-01-10 12:56:12 +09:00
return window
2018-04-08 15:13:12 +02:00
def top_level_window(self, test_func=None):
return self.top_level_window_recurse(test_func)
def question(self, msg, parent=None, title=None, icon=None, **kwargs) -> bool:
2024-09-05 16:20:01 +00:00
Yes, No = QMessageBox.StandardButton.Yes, QMessageBox.StandardButton.No
return Yes == self.msg_box(icon=icon or QMessageBox.Icon.Question,
parent=parent,
title=title or '',
text=msg,
buttons=Yes|No,
defaultButton=No,
**kwargs)
2015-12-23 18:31:36 +09:00
def show_warning(self, msg, parent=None, title=None, **kwargs):
2024-09-05 16:20:01 +00:00
return self.msg_box(QMessageBox.Icon.Warning, parent,
title or _('Warning'), msg, **kwargs)
2015-12-23 15:10:15 +09:00
def show_error(self, msg, parent=None, **kwargs):
2024-09-05 16:20:01 +00:00
return self.msg_box(QMessageBox.Icon.Warning, parent,
_('Error'), msg, **kwargs)
2015-12-23 15:10:15 +09:00
def show_critical(self, msg, parent=None, title=None, **kwargs):
2024-09-05 16:20:01 +00:00
return self.msg_box(QMessageBox.Icon.Critical, parent,
title or _('Critical Error'), msg, **kwargs)
2015-12-23 15:10:15 +09:00
def show_message(self, msg, parent=None, title=None, **kwargs):
2024-09-05 16:20:01 +00:00
return self.msg_box(QMessageBox.Icon.Information, parent,
title or _('Information'), msg, **kwargs)
2015-12-23 20:05:09 +09:00
2024-09-05 16:20:01 +00:00
def msg_box(self, icon, parent, title, text, *, buttons=QMessageBox.StandardButton.Ok,
defaultButton=QMessageBox.StandardButton.NoButton, rich_text=False,
2019-04-28 06:31:01 +02:00
checkbox=None):
2016-01-10 12:56:12 +09:00
parent = parent or self.top_level_window()
return custom_message_box(icon=icon,
parent=parent,
title=title,
text=text,
buttons=buttons,
defaultButton=defaultButton,
rich_text=rich_text,
checkbox=checkbox)
def query_choice(self,
msg: Optional[str],
choices: Sequence[Tuple],
title: Optional[str] = None,
default_choice: Optional[Any] = None) -> Optional[Any]:
# Needed by QtHandler for hardware wallets
if title is None:
title = _('Question')
dialog = WindowModalDialog(self.top_level_window(), title=title)
dialog.setMinimumWidth(400)
choice_widget = ChoiceWidget(message=msg, choices=choices, selected=default_choice)
vbox = QVBoxLayout(dialog)
vbox.addWidget(choice_widget)
cancel_button = CancelButton(dialog)
vbox.addLayout(Buttons(cancel_button, OkButton(dialog)))
cancel_button.setFocus()
2024-09-05 16:20:01 +00:00
if not dialog.exec():
return None
return choice_widget.selected_key
2024-09-05 16:20:01 +00:00
def custom_message_box(*, icon, parent, title, text, buttons=QMessageBox.StandardButton.Ok,
defaultButton=QMessageBox.StandardButton.NoButton, rich_text=False,
checkbox=None):
if type(icon) is QPixmap:
2024-09-05 16:20:01 +00:00
d = QMessageBox(QMessageBox.Icon.Information, title, str(text), buttons, parent)
d.setIconPixmap(icon)
else:
d = QMessageBox(icon, title, str(text), buttons, parent)
2024-09-05 16:20:01 +00:00
d.setWindowModality(Qt.WindowModality.WindowModal)
d.setDefaultButton(defaultButton)
if rich_text:
2024-09-05 16:20:01 +00:00
d.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse | Qt.TextInteractionFlag.LinksAccessibleByMouse)
# set AutoText instead of RichText
# AutoText lets Qt figure out whether to render as rich text.
# e.g. if text is actually plain text and uses "\n" newlines;
# and we set RichText here, newlines would be swallowed
2024-09-05 16:20:01 +00:00
d.setTextFormat(Qt.TextFormat.AutoText)
else:
2024-09-05 16:20:01 +00:00
d.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
d.setTextFormat(Qt.TextFormat.PlainText)
if checkbox is not None:
d.setCheckBox(checkbox)
2024-09-05 16:20:01 +00:00
return d.exec()
2015-12-23 15:10:15 +09:00
2015-12-28 00:12:26 +09:00
class WindowModalDialog(QDialog, MessageBoxMixin):
'''Handy wrapper; window modal dialogs are better for our multi-window
daemon model as other wallet windows can still be accessed.'''
def __init__(self, parent, title=None):
QDialog.__init__(self, parent)
2024-09-05 16:20:01 +00:00
self.setWindowModality(Qt.WindowModality.WindowModal)
if title:
self.setWindowTitle(title)
class WaitingDialog(WindowModalDialog):
2018-02-26 22:03:22 +01:00
'''Shows a please wait dialog whilst running a task. It is not
2015-12-23 22:10:11 +09:00
necessary to maintain a reference to this dialog.'''
def __init__(self, parent: QWidget, message: str, task, on_success=None, on_error=None, on_cancel=None):
assert parent
if isinstance(parent, MessageBoxMixin):
parent = parent.top_level_window()
WindowModalDialog.__init__(self, parent, _("Please wait"))
self.message_label = QLabel(message)
vbox = QVBoxLayout(self)
vbox.addWidget(self.message_label)
if on_cancel:
self.cancel_button = CancelButton(self)
self.cancel_button.clicked.connect(on_cancel)
vbox.addLayout(Buttons(self.cancel_button))
self.accepted.connect(self.on_accepted)
self.show()
self.thread = TaskThread(self)
2018-02-28 01:26:05 +01:00
self.thread.finished.connect(self.deleteLater) # see #3956
self.thread.add(task, on_success, self.accept, on_error)
def wait(self):
self.thread.wait()
def on_accepted(self):
self.thread.stop()
2015-12-23 22:10:11 +09:00
def update(self, msg):
print(msg)
self.message_label.setText(msg)
2015-12-23 22:10:11 +09:00
class RunCoroutineDialog(WaitingDialog):
def __init__(self, parent: QWidget, message: str, coroutine):
from electrum import util
import asyncio
import concurrent.futures
loop = util.get_asyncio_loop()
assert util.get_running_loop() != loop, 'must not be called from asyncio thread'
self._exception = None
self._result = None
self._future = asyncio.run_coroutine_threadsafe(coroutine, loop)
def task():
try:
self._result = self._future.result()
except concurrent.futures.CancelledError:
self._exception = UserCancelled
except Exception as e:
self._exception = e
WaitingDialog.__init__(self, parent, message, task, on_cancel=self._future.cancel)
def run(self):
self.exec()
if self._exception:
raise self._exception
else:
return self._result
2019-12-10 03:34:41 +01:00
2014-07-11 19:29:18 +02:00
def line_dialog(parent, title, label, ok_label, default=None):
2015-12-22 07:55:33 +09:00
dialog = WindowModalDialog(parent, title)
2014-07-11 19:29:18 +02:00
dialog.setMinimumWidth(500)
l = QVBoxLayout()
dialog.setLayout(l)
l.addWidget(QLabel(label))
txt = QLineEdit()
if default:
txt.setText(default)
l.addWidget(txt)
2015-03-14 12:28:19 +01:00
l.addLayout(Buttons(CancelButton(dialog), OkButton(dialog, ok_label)))
2024-09-05 16:20:01 +00:00
if dialog.exec():
2017-01-30 12:36:56 +03:00
return txt.text()
2014-07-11 19:29:18 +02:00
2023-12-08 14:07:32 +01:00
def text_dialog(
*,
parent,
title,
header_layout,
ok_label,
default=None,
allow_multi=False,
config: 'SimpleConfig',
):
2017-08-17 10:29:50 +02:00
from .qrtextedit import ScanQRTextEdit
2015-12-22 07:55:33 +09:00
dialog = WindowModalDialog(parent, title)
dialog.setMinimumWidth(600)
l = QVBoxLayout()
dialog.setLayout(l)
if isinstance(header_layout, str):
l.addWidget(QLabel(header_layout))
else:
l.addLayout(header_layout)
txt = ScanQRTextEdit(allow_multi=allow_multi, config=config)
2014-04-03 15:36:31 +02:00
if default:
txt.setText(default)
l.addWidget(txt)
2015-03-14 12:28:19 +01:00
l.addLayout(Buttons(CancelButton(dialog), OkButton(dialog, ok_label)))
2024-09-05 16:20:01 +00:00
if dialog.exec():
2017-01-30 12:36:56 +03:00
return txt.toPlainText()
2023-12-08 14:07:32 +01:00
class ChoiceWidget(QWidget):
"""Renders a list of tuples as a radiobuttons group.
The first element of each tuple is used as a key.
The second element of each tuple is used as user facing string.
The remainder of the tuple can be any additional data.
Callers can pre-select an item by key, through the 'selected' parameter.
The selected item is made available by index (selected_index),
by key (selected_key) and by whole tuple (selected_item).
"""
itemSelected = pyqtSignal([int], arguments=['index'])
def __init__(self, *, message: Optional[str] = None, choices: Sequence[Tuple] = None, selected: Optional[Any] = None):
QWidget.__init__(self)
vbox = QVBoxLayout()
self.setLayout(vbox)
2023-08-11 15:55:54 +02:00
if choices is None:
choices = []
self.selected_index = -1 # int
self.selected_item = None # Optional[Tuple]
self.selected_key = None # Optional[Any]
2023-11-02 12:48:21 +01:00
self.choices = choices
if message and len(message) > 50:
vbox.addWidget(WWLabel(message))
message = ""
gb2 = QGroupBox(message)
vbox.addWidget(gb2)
vbox2 = QVBoxLayout()
gb2.setLayout(vbox2)
self.group = group = QButtonGroup()
assert isinstance(choices, list)
iterator = enumerate(choices)
for i, c in iterator:
button = QRadioButton(gb2)
button.setText(c[1])
vbox2.addWidget(button)
group.addButton(button)
group.setId(button, i)
if (i == 0 and selected is None) or c[0] == selected:
self.selected_index = i
self.selected_item = c
2024-02-08 11:42:14 +01:00
self.selected_key = c[0]
button.setChecked(True)
group.buttonClicked.connect(self.on_selected)
def on_selected(self, button):
self.selected_index = self.group.id(button)
self.selected_item = self.choices[self.selected_index]
2023-11-02 12:48:21 +01:00
self.selected_key = self.choices[self.selected_index][0]
self.itemSelected.emit(self.selected_index)
2023-11-02 12:48:21 +01:00
def select(self, key):
iterator = enumerate(self.choices)
for i, c in iterator:
if key == c[0]:
self.group.button(i).click()
class ResizableStackedWidget(QWidget):
"""Simple alternative to QStackedWidget, as QStackedWidget always resizes to the largest
widget in the stack, leaving ugly scrollbars where they're not needed."""
def __init__(self, parent):
super().__init__(parent)
self.setLayout(QVBoxLayout())
self.widgets = []
self.current_index = -1
def sizeHint(self) -> QSize:
if not self.count() or not self.currentWidget():
return super().sizeHint()
return self.currentWidget().sizeHint()
def addWidget(self, widget: QWidget) -> int:
self.widgets.append(widget)
self.layout().addWidget(widget)
if len(self.widgets) == 1: # first widget?
self.current_index = 0
self.showCurrentWidget()
return len(self.widgets) - 1
def removeWidget(self, widget: QWidget):
i = self.widgets.index(widget)
self.widgets.remove(widget)
self.layout().removeWidget(widget)
if self.current_index >= i:
self.current_index -= 1
if self.current_index == self.count() - 1:
self.showCurrentWidget()
def setCurrentIndex(self, index: int):
assert isinstance(index, int)
assert 0 <= index < len(self.widgets), f'invalid widget index {index}'
self.current_index = index
self.showCurrentWidget()
def currentWidget(self) -> Optional[QWidget]:
if self.current_index < 0:
return None
return self.widgets[self.current_index]
def showCurrentWidget(self):
if not self.widgets:
return
for i, k in enumerate(self.widgets):
if i == self.current_index:
k.show()
else:
k.hide()
def count(self) -> int:
return len(self.widgets)
class VLine(QFrame):
"""Vertical line separator"""
def __init__(self):
super(VLine, self).__init__()
2024-09-05 16:20:01 +00:00
self.setFrameShape(QFrame.Shape.VLine)
self.setFrameShadow(QFrame.Shadow.Sunken)
self.setLineWidth(1)
def address_field(addresses, *, btn_text: str = None):
if btn_text is None:
btn_text = _('Get wallet address')
2014-05-05 09:58:29 +02:00
hbox = QHBoxLayout()
address_e = QLineEdit()
2017-12-09 21:23:10 +01:00
if addresses and len(addresses) > 0:
2014-05-05 09:58:29 +02:00
address_e.setText(addresses[0])
2017-12-09 21:23:10 +01:00
else:
addresses = []
2023-12-08 14:07:32 +01:00
2014-05-05 09:58:29 +02:00
def func():
2017-12-09 21:23:10 +01:00
try:
i = addresses.index(str(address_e.text())) + 1
i = i % len(addresses)
address_e.setText(addresses[i])
except ValueError:
# the user might have changed address_e to an
# address not in the wallet (or to something that isn't an address)
if addresses and len(addresses) > 0:
address_e.setText(addresses[0])
button = QPushButton(btn_text)
2014-05-05 09:58:29 +02:00
button.clicked.connect(func)
hbox.addWidget(button)
hbox.addWidget(address_e)
return hbox, address_e
2014-05-05 11:31:04 +02:00
def filename_field(parent, config, defaultname, select_msg):
vbox = QVBoxLayout()
vbox.addWidget(QLabel(_("Format")))
gb = QGroupBox("format", parent)
b1 = QRadioButton(gb)
b1.setText(_("CSV"))
b1.setChecked(True)
b2 = QRadioButton(gb)
b2.setText(_("json"))
vbox.addWidget(b1)
vbox.addWidget(b2)
2014-05-05 11:31:04 +02:00
hbox = QHBoxLayout()
2023-05-24 17:41:44 +00:00
directory = config.IO_DIRECTORY
2021-03-21 00:34:25 -04:00
path = os.path.join(directory, defaultname)
2014-05-05 11:31:04 +02:00
filename_e = QLineEdit()
filename_e.setText(path)
def func():
2017-01-30 12:36:56 +03:00
text = filename_e.text()
_filter = "*.csv" if defaultname.endswith(".csv") else "*.json" if defaultname.endswith(".json") else None
p = getSaveFileName(
parent=None,
title=select_msg,
filename=text,
filter=_filter,
config=config,
)
2014-05-05 11:31:04 +02:00
if p:
filename_e.setText(p)
button = QPushButton(_('File'))
button.clicked.connect(func)
hbox.addWidget(button)
hbox.addWidget(filename_e)
vbox.addLayout(hbox)
def set_csv(v):
2017-01-30 12:36:56 +03:00
text = filename_e.text()
2014-05-05 11:31:04 +02:00
text = text.replace(".json",".csv") if v else text.replace(".csv",".json")
filename_e.setText(text)
b1.clicked.connect(lambda: set_csv(True))
b2.clicked.connect(lambda: set_csv(False))
return vbox, filename_e, b1
2014-05-05 09:58:29 +02:00
def get_iconname_qrcode() -> str:
return "qrcode_white.png" if ColorScheme.dark_scheme else "qrcode.png"
def get_iconname_camera() -> str:
return "camera_white.png" if ColorScheme.dark_scheme else "camera_dark.png"
2023-08-09 16:00:09 +00:00
def editor_contextMenuEvent(self, p: 'PayToEdit', e: 'QContextMenuEvent') -> None:
2023-03-14 10:57:26 +01:00
m = self.createStandardContextMenu()
m.addSeparator()
m.addAction(read_QIcon(get_iconname_camera()), _("Read QR code with camera"), p.on_qr_from_camera_input_btn)
m.addAction(read_QIcon("picture_in_picture.png"), _("Read QR code from screen"), p.on_qr_from_screenshot_input_btn)
m.addAction(read_QIcon("file.png"), _("Read file"), p.on_input_file)
2024-09-05 16:20:01 +00:00
m.exec(e.globalPos())
2023-03-14 10:57:26 +01:00
def scan_qr_from_screenshot() -> QrCodeResult:
from .qrreader import scan_qr_from_image
screenshots = [screen.grabWindow(0).toImage()
for screen in QApplication.instance().screens()]
if all(screen.allGray() for screen in screenshots):
raise UserFacingException(_("Failed to take screenshot."))
scanned_qr = None
for screenshot in screenshots:
try:
scan_result = scan_qr_from_image(screenshot)
except MissingQrDetectionLib as e:
raise UserFacingException(_("Unable to scan image.") + "\n" + repr(e))
if len(scan_result) > 0:
if (scanned_qr is not None) or len(scan_result) > 1:
raise UserFacingException(_("More than one QR code was found on the screen."))
scanned_qr = scan_result
if scanned_qr is None:
raise UserFacingException(_("No QR code was found on the screen."))
assert len(scanned_qr) == 1, f"{len(scanned_qr)=}, expected 1"
return scanned_qr[0]
2023-03-14 10:57:26 +01:00
class GenericInputHandler:
def input_qr_from_camera(
self,
*,
config: 'SimpleConfig',
allow_multi: bool = False,
show_error: Callable[[str], None],
setText: Callable[[str], None] = None,
2023-04-24 00:58:41 +00:00
parent: QWidget = None,
2023-03-14 10:57:26 +01:00
) -> None:
if setText is None:
setText = self.setText
def cb(success: bool, error: str, data: Optional[str]):
2023-03-14 10:57:26 +01:00
if not success:
if error:
show_error(error)
return
if not data:
data = ''
try:
if allow_multi:
text = self.text()
if data in text:
return
if text and not text.endswith('\n'):
text += '\n'
text += data
text += '\n'
setText(text)
else:
new_text = data
2023-06-20 20:54:31 +02:00
setText(new_text)
except Exception as e:
show_error(_('Invalid payment identifier in QR') + ':\n' + repr(e))
2023-03-14 10:57:26 +01:00
from .qrreader import scan_qrcode
2023-04-24 00:58:41 +00:00
if parent is None:
parent = self if isinstance(self, QWidget) else None
scan_qrcode(parent=parent, config=config, callback=cb)
2023-03-14 10:57:26 +01:00
def input_qr_from_screenshot(
self,
*,
allow_multi: bool = False,
show_error: Callable[[str], None],
setText: Callable[[str], None] = None,
) -> None:
if setText is None:
setText = self.setText
try:
scanned_qr = scan_qr_from_screenshot()
except UserFacingException as e:
show_error(str(e))
2023-03-14 10:57:26 +01:00
return
data = scanned_qr.data
try:
if allow_multi:
text = self.text()
if data in text:
return
if text and not text.endswith('\n'):
text += '\n'
text += data
text += '\n'
setText(text)
else:
new_text = data
2023-06-20 20:54:31 +02:00
setText(new_text)
except Exception as e:
show_error(_('Invalid payment identifier in QR') + ':\n' + repr(e))
2023-03-14 10:57:26 +01:00
def input_file(
self,
*,
config: 'SimpleConfig',
show_error: Callable[[str], None],
setText: Callable[[str], None] = None,
) -> None:
if setText is None:
setText = self.setText
fileName = getOpenFileName(
parent=None,
title='select file',
config=config,
)
if not fileName:
return
try:
try:
with open(fileName, "r") as f:
data = f.read()
except UnicodeError as e:
with open(fileName, "rb") as f:
data = f.read()
data = data.hex()
except BaseException as e:
show_error(_('Error opening file') + ':\n' + repr(e))
else:
2023-06-20 20:54:31 +02:00
try:
setText(data)
except Exception as e:
show_error(_('Invalid payment identifier in file') + ':\n' + repr(e))
2023-03-14 10:57:26 +01:00
def input_paste_from_clipboard(
self,
*,
setText: Callable[[str], None] = None,
) -> None:
if setText is None:
setText = self.setText
app = QApplication.instance()
setText(app.clipboard().text())
class OverlayControlMixin(GenericInputHandler):
STYLE_SHEET_COMMON = '''
QPushButton { border-width: 1px; padding: 0px; margin: 0px; }
'''
STYLE_SHEET_LIGHT = '''
QPushButton { border: 1px solid transparent; }
QPushButton:hover { border: 1px solid #3daee9; }
'''
def __init__(self, middle: bool = False):
2023-03-14 10:57:26 +01:00
GenericInputHandler.__init__(self)
assert isinstance(self, QWidget)
assert isinstance(self, OverlayControlMixin) # only here for type-hints in IDE
self.middle = middle
self.overlay_widget = QWidget(self)
style_sheet = self.STYLE_SHEET_COMMON
if not ColorScheme.dark_scheme:
style_sheet = style_sheet + self.STYLE_SHEET_LIGHT
self.overlay_widget.setStyleSheet(style_sheet)
self.overlay_layout = QHBoxLayout(self.overlay_widget)
self.overlay_layout.setContentsMargins(0, 0, 0, 0)
self.overlay_layout.setSpacing(1)
self._updateOverlayPos()
def resizeEvent(self, e):
super().resizeEvent(e)
self._updateOverlayPos()
def _updateOverlayPos(self):
2024-09-05 16:20:01 +00:00
frame_width = self.style().pixelMetric(QStyle.PixelMetric.PM_DefaultFrameWidth)
overlay_size = self.overlay_widget.sizeHint()
x = self.rect().right() - frame_width - overlay_size.width()
y = self.rect().bottom() - overlay_size.height()
middle = self.middle
if hasattr(self, 'document'):
# Keep the buttons centered if we have less than 2 lines in the editor
line_spacing = QFontMetrics(self.document().defaultFont()).lineSpacing()
if self.rect().height() < (line_spacing * 2):
middle = True
y = (y / 2) + frame_width if middle else y - frame_width
if hasattr(self, 'verticalScrollBar') and self.verticalScrollBar().isVisible():
2024-09-05 16:20:01 +00:00
scrollbar_width = self.style().pixelMetric(QStyle.PixelMetric.PM_ScrollBarExtent)
x -= scrollbar_width
self.overlay_widget.move(int(x), int(y))
def addWidget(self, widget: QWidget):
# The old code positioned the items the other way around, so we just insert at position 0 instead
self.overlay_layout.insertWidget(0, widget)
def addButton(self, icon_name: str, on_click, tooltip: str) -> QPushButton:
button = QPushButton(self.overlay_widget)
button.setToolTip(tooltip)
2019-02-01 19:01:21 +01:00
button.setIcon(read_QIcon(icon_name))
2024-09-05 16:20:01 +00:00
button.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
button.clicked.connect(on_click)
self.addWidget(button)
return button
def addCopyButton(self):
def on_copy():
app = QApplication.instance()
app.clipboard().setText(self.text())
QToolTip.showText(QCursor.pos(), _("Text copied to clipboard"), self)
2017-10-06 16:50:05 +02:00
self.addButton("copy.png", on_copy, _("Copy to clipboard"))
2015-04-20 14:15:18 +02:00
def addPasteButton(
self,
*,
setText: Callable[[str], None] = None,
):
input_paste_from_clipboard = partial(
self.input_paste_from_clipboard,
setText=setText,
)
self.addButton("copy.png", input_paste_from_clipboard, _("Paste from clipboard"))
2020-12-27 12:01:43 +01:00
def add_qr_show_button(self, *, config: 'SimpleConfig', title: Optional[str] = None):
if title is None:
title = _("QR code")
def qr_show():
from .qrcodewidget import QRDialog
try:
s = str(self.text())
2023-04-23 01:33:12 +00:00
except Exception:
s = self.text()
if not s:
return
QRDialog(
data=s,
parent=self,
title=title,
config=config,
2024-09-05 16:20:01 +00:00
).exec()
self.addButton(get_iconname_qrcode(), qr_show, _("Show as QR code"))
# side-effect: we export this method:
self.on_qr_show_btn = qr_show
def add_qr_input_combined_button(
self,
*,
config: 'SimpleConfig',
allow_multi: bool = False,
show_error: Callable[[str], None],
setText: Callable[[str], None] = None,
):
input_qr_from_camera = partial(
self.input_qr_from_camera,
config=config,
allow_multi=allow_multi,
show_error=show_error,
setText=setText,
)
input_qr_from_screenshot = partial(
self.input_qr_from_screenshot,
allow_multi=allow_multi,
show_error=show_error,
setText=setText,
)
self.add_menu_button(
icon=get_iconname_camera(),
tooltip=_("Read QR code"),
options=[
(get_iconname_camera(), _("Read QR code from camera"), input_qr_from_camera),
("picture_in_picture.png", _("Read QR code from screen"), input_qr_from_screenshot),
],
)
# side-effect: we export these methods:
self.on_qr_from_camera_input_btn = input_qr_from_camera
self.on_qr_from_screenshot_input_btn = input_qr_from_screenshot
def add_qr_input_from_camera_button(
self,
*,
config: 'SimpleConfig',
allow_multi: bool = False,
show_error: Callable[[str], None],
setText: Callable[[str], None] = None,
):
input_qr_from_camera = partial(
self.input_qr_from_camera,
config=config,
allow_multi=allow_multi,
show_error=show_error,
setText=setText,
)
self.addButton(get_iconname_camera(), input_qr_from_camera, _("Read QR code from camera"))
# side-effect: we export these methods:
self.on_qr_from_camera_input_btn = input_qr_from_camera
def add_file_input_button(
self,
*,
config: 'SimpleConfig',
show_error: Callable[[str], None],
setText: Callable[[str], None] = None,
) -> None:
input_file = partial(
self.input_file,
config=config,
show_error=show_error,
setText=setText,
)
self.addButton("file.png", input_file, _("Read file"))
def add_menu_button(
self,
*,
options: Sequence[Tuple[Optional[str], str, Callable[[], None]]], # list of (icon, text, cb)
icon: Optional[str] = None,
tooltip: Optional[str] = None,
):
if icon is None:
icon = "menu_vertical_white.png" if ColorScheme.dark_scheme else "menu_vertical.png"
if tooltip is None:
tooltip = _("Other options")
btn = self.addButton(icon, lambda: None, tooltip)
menu = QMenu()
for opt_icon, opt_text, opt_cb in options:
if opt_icon is None:
menu.addAction(opt_text, opt_cb)
else:
menu.addAction(read_QIcon(opt_icon), opt_text, opt_cb)
btn.setMenu(menu)
class ButtonsLineEdit(OverlayControlMixin, QLineEdit):
def __init__(self, text=None):
QLineEdit.__init__(self, text)
OverlayControlMixin.__init__(self, middle=True)
2023-12-08 14:07:32 +01:00
class ShowQRLineEdit(ButtonsLineEdit):
""" read-only line with qr and copy buttons """
def __init__(self, text: str, config, title=None):
ButtonsLineEdit.__init__(self, text)
self.setReadOnly(True)
self.setFont(QFont(MONOSPACE_FONT))
self.add_qr_show_button(config=config, title=title)
self.addCopyButton()
2015-04-20 12:32:48 +02:00
2023-12-08 14:07:32 +01:00
class ButtonsTextEdit(OverlayControlMixin, QPlainTextEdit):
def __init__(self, text=None):
QPlainTextEdit.__init__(self, text)
OverlayControlMixin.__init__(self)
self.setText = self.setPlainText
2015-04-20 14:15:18 +02:00
self.text = self.toPlainText
2020-04-07 16:48:26 +02:00
class PasswordLineEdit(QLineEdit):
def __init__(self, *args, **kwargs):
QLineEdit.__init__(self, *args, **kwargs)
2024-09-05 16:20:01 +00:00
self.setEchoMode(QLineEdit.EchoMode.Password)
2020-04-07 16:48:26 +02:00
def clear(self):
# Try to actually overwrite the memory.
# This is really just a best-effort thing...
self.setText(len(self.text()) * " ")
super().clear()
2020-04-07 16:48:26 +02:00
class TaskThread(QThread, Logger):
'''Thread that runs background tasks. Callbacks are guaranteed
to happen in the context of its parent.'''
2018-10-28 00:28:29 +02:00
class Task(NamedTuple):
task: Callable
cb_success: Optional[Callable]
cb_done: Optional[Callable]
cb_error: Optional[Callable]
cancel: Optional[Callable] = None
2018-10-28 00:28:29 +02:00
doneSig = pyqtSignal(object, object, object)
def __init__(self, parent, on_error=None):
QThread.__init__(self, parent)
Logger.__init__(self)
self.on_error = on_error
2017-01-22 21:25:24 +03:00
self.tasks = queue.Queue()
self._cur_task = None # type: Optional[TaskThread.Task]
self._stopping = False
self.doneSig.connect(self.on_done)
self.start()
def add(self, task, on_success=None, on_done=None, on_error=None, *, cancel=None):
if self._stopping:
self.logger.warning(f"stopping or already stopped but tried to add new task.")
return
on_error = on_error or self.on_error
task_ = TaskThread.Task(task, on_success, on_done, on_error, cancel=cancel)
self.tasks.put(task_)
def run(self):
while True:
if self._stopping:
break
2018-10-28 00:28:29 +02:00
task = self.tasks.get() # type: TaskThread.Task
self._cur_task = task
if not task or self._stopping:
break
try:
result = task.task()
self.doneSig.emit(result, task.cb_done, task.cb_success)
except BaseException:
self.doneSig.emit(sys.exc_info(), task.cb_done, task.cb_error)
2018-02-28 01:26:05 +01:00
def on_done(self, result, cb_done, cb_result):
# This runs in the parent's thread.
if cb_done:
cb_done()
2018-02-28 01:26:05 +01:00
if cb_result:
cb_result(result)
def stop(self):
self._stopping = True
# try to cancel currently running task now.
# if the task does not implement "cancel", we will have to wait until it finishes.
task = self._cur_task
if task and task.cancel:
task.cancel()
# cancel the remaining tasks in the queue
while True:
try:
task = self.tasks.get_nowait()
except queue.Empty:
break
if task and task.cancel:
task.cancel()
self.tasks.put(None) # in case the thread is still waiting on the queue
2021-04-03 01:44:42 +02:00
self.exit()
self.wait()
2017-10-04 15:09:31 +02:00
class ColorSchemeItem:
def __init__(self, fg_color, bg_color):
self.colors = (fg_color, bg_color)
def _get_color(self, background):
return self.colors[(int(background) + int(ColorScheme.dark_scheme)) % 2]
def as_stylesheet(self, background=False):
css_prefix = "background-" if background else ""
color = self._get_color(background)
return "QWidget {{ {}color:{}; }}".format(css_prefix, color)
def as_color(self, background=False):
color = self._get_color(background)
return QColor(color)
class ColorScheme:
dark_scheme = False
GREEN = ColorSchemeItem("#117c11", "#8af296")
2018-04-12 02:41:33 +02:00
YELLOW = ColorSchemeItem("#897b2a", "#ffff00")
2017-10-04 15:09:31 +02:00
RED = ColorSchemeItem("#7c1111", "#f18c8c")
BLUE = ColorSchemeItem("#123b7c", "#8cb3f2")
2023-03-19 13:32:43 +01:00
LIGHTBLUE = ColorSchemeItem("black", "#d0f0ff")
2017-10-04 15:09:31 +02:00
DEFAULT = ColorSchemeItem("black", "white")
GRAY = ColorSchemeItem("gray", "gray")
2017-10-04 15:09:31 +02:00
@staticmethod
def has_dark_background(widget):
2024-09-05 16:20:01 +00:00
brightness = sum(widget.palette().color(QPalette.ColorRole.Window).getRgb()[0:3])
2017-10-04 15:09:31 +02:00
return brightness < (255*3/2)
@staticmethod
2018-06-25 16:45:56 +02:00
def update_from_widget(widget, force_dark=False):
ColorScheme.dark_scheme = bool(force_dark or ColorScheme.has_dark_background(widget))
2017-10-04 15:09:31 +02:00
class AcceptFileDragDrop:
def __init__(self, file_type=""):
assert isinstance(self, QWidget)
self.setAcceptDrops(True)
self.file_type = file_type
def validateEvent(self, event):
if not event.mimeData().hasUrls():
event.ignore()
return False
for url in event.mimeData().urls():
if not url.toLocalFile().endswith(self.file_type):
event.ignore()
return False
event.accept()
return True
def dragEnterEvent(self, event):
self.validateEvent(event)
def dragMoveEvent(self, event):
if self.validateEvent(event):
2024-09-05 16:20:01 +00:00
event.setDropAction(Qt.DropAction.CopyAction)
def dropEvent(self, event):
if self.validateEvent(event):
for url in event.mimeData().urls():
self.onFileAdded(url.toLocalFile())
def onFileAdded(self, fn):
raise NotImplementedError()
2018-02-21 18:55:37 +01:00
def import_meta_gui(electrum_window: 'ElectrumWindow', title, importer, on_success):
2018-02-21 18:55:37 +01:00
filter_ = "JSON (*.json);;All files (*)"
filename = getOpenFileName(
parent=electrum_window,
title=_("Open {} file").format(title),
filter=filter_,
config=electrum_window.config,
)
2018-02-15 18:07:00 +01:00
if not filename:
2018-02-21 18:55:37 +01:00
return
2018-02-15 18:07:00 +01:00
try:
2018-02-21 18:55:37 +01:00
importer(filename)
2018-02-15 18:07:00 +01:00
except FileImportFailed as e:
2018-02-21 18:55:37 +01:00
electrum_window.show_critical(str(e))
2018-02-15 18:07:00 +01:00
else:
2018-02-21 18:55:37 +01:00
electrum_window.show_message(_("Your {} were successfully imported").format(title))
on_success()
2018-02-15 18:07:00 +01:00
def export_meta_gui(electrum_window: 'ElectrumWindow', title, exporter):
2018-02-21 18:55:37 +01:00
filter_ = "JSON (*.json);;All files (*)"
filename = getSaveFileName(
parent=electrum_window,
title=_("Select file to save your {}").format(title),
filename='electrum_{}.json'.format(title),
filter=filter_,
config=electrum_window.config,
)
2018-02-21 18:55:37 +01:00
if not filename:
2018-02-15 18:07:00 +01:00
return
try:
exporter(filename)
except FileExportFailed as e:
electrum_window.show_critical(str(e))
else:
2018-02-21 18:55:37 +01:00
electrum_window.show_message(_("Your {0} were exported to '{1}'")
.format(title, str(filename)))
def getOpenFileName(*, parent, title, filter="", config: 'SimpleConfig') -> Optional[str]:
"""Custom wrapper for getOpenFileName that remembers the path selected by the user."""
2023-05-24 17:41:44 +00:00
directory = config.IO_DIRECTORY
fileName, __ = QFileDialog.getOpenFileName(parent, title, directory, filter)
if fileName and directory != os.path.dirname(fileName):
2023-05-24 17:41:44 +00:00
config.IO_DIRECTORY = os.path.dirname(fileName)
return fileName
def getSaveFileName(
*,
parent,
title,
filename,
filter="",
default_extension: str = None,
default_filter: str = None,
config: 'SimpleConfig',
) -> Optional[str]:
"""Custom wrapper for getSaveFileName that remembers the path selected by the user."""
2023-05-24 17:41:44 +00:00
directory = config.IO_DIRECTORY
path = os.path.join(directory, filename)
file_dialog = QFileDialog(parent, title, path, filter)
2024-09-05 16:20:01 +00:00
file_dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptSave)
if default_extension:
# note: on MacOS, the selected filter's first extension seems to have priority over this...
file_dialog.setDefaultSuffix(default_extension)
if default_filter:
assert default_filter in filter, f"default_filter={default_filter!r} does not appear in filter={filter!r}"
file_dialog.selectNameFilter(default_filter)
2024-09-05 16:20:01 +00:00
if file_dialog.exec() != QDialog.DialogCode.Accepted:
return None
selected_path = file_dialog.selectedFiles()[0]
if selected_path and directory != os.path.dirname(selected_path):
2023-05-24 17:41:44 +00:00
config.IO_DIRECTORY = os.path.dirname(selected_path)
return selected_path
2018-03-01 02:39:48 +01:00
def icon_path(icon_basename: str):
return resource_path('gui', 'icons', icon_basename)
2019-02-01 19:01:21 +01:00
2025-02-04 11:13:37 +01:00
2024-10-17 13:01:41 +02:00
def internal_plugin_icon_path(plugin_name, icon_basename: str):
return resource_path('plugins', plugin_name, icon_basename)
2019-02-01 19:01:21 +01:00
@lru_cache(maxsize=1000)
def read_QIcon(icon_basename: str) -> QIcon:
2019-02-01 19:01:21 +01:00
return QIcon(icon_path(icon_basename))
2025-02-04 11:13:37 +01:00
2024-10-17 13:01:41 +02:00
def read_QPixmap_from_bytes(b: bytes) -> QPixmap:
qp = QPixmap()
qp.loadFromData(b)
2024-10-17 13:01:41 +02:00
return qp
2025-02-04 11:13:37 +01:00
2024-10-17 13:01:41 +02:00
def read_QIcon_from_bytes(b: bytes) -> QIcon:
qp = read_QPixmap_from_bytes(b)
return QIcon(qp)
2023-12-08 14:07:32 +01:00
class IconLabel(QWidget):
HorizontalSpacing = 2
def __init__(self, *, text='', final_stretch=True, reverse=False, hide_if_empty=False):
super(QWidget, self).__init__()
self.hide_if_empty = hide_if_empty
size = max(16, font_height())
self.icon_size = QSize(size, size)
layout = QHBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(layout)
self.icon = QLabel()
self.label = QLabel(text)
2024-09-05 16:20:01 +00:00
self.label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
layout.addWidget(self.icon if reverse else self.label)
layout.addSpacing(self.HorizontalSpacing)
layout.addWidget(self.label if reverse else self.icon)
if final_stretch:
layout.addStretch()
self.setText(text)
def setText(self, text):
self.label.setText(text)
if self.hide_if_empty:
self.setVisible(bool(text))
def setIcon(self, icon):
self.icon.setPixmap(icon.pixmap(self.icon_size))
self.icon.repaint() # macOS hack for #6269
2019-01-01 19:38:33 +01:00
2019-06-29 05:27:28 +02:00
def char_width_in_lineedit() -> int:
char_width = QFontMetrics(QLineEdit().font()).averageCharWidth()
# 'averageCharWidth' seems to underestimate on Windows, hence 'max()'
return max(9, char_width)
def font_height(widget: QWidget = None) -> int:
if widget is None:
widget = QLabel()
return QFontMetrics(widget.font()).height()
def webopen(url: str):
if sys.platform == 'linux' and os.environ.get('APPIMAGE'):
# When on Linux webbrowser.open can fail in AppImage because it can't find the correct libdbus.
# We just fork the process and unset LD_LIBRARY_PATH before opening the URL.
# See #5425
if os.fork() == 0:
del os.environ['LD_LIBRARY_PATH']
webbrowser.open(url)
os._exit(0)
else:
webbrowser.open(url)
class FixedAspectRatioLayout(QLayout):
def __init__(self, parent: QWidget = None, aspect_ratio: float = 1.0):
super().__init__(parent)
self.aspect_ratio = aspect_ratio
self.items: List[QLayoutItem] = []
def set_aspect_ratio(self, aspect_ratio: float = 1.0):
self.aspect_ratio = aspect_ratio
self.update()
def addItem(self, item: QLayoutItem):
self.items.append(item)
def count(self) -> int:
return len(self.items)
def itemAt(self, index: int) -> QLayoutItem:
if index >= len(self.items):
return None
return self.items[index]
def takeAt(self, index: int) -> QLayoutItem:
if index >= len(self.items):
return None
return self.items.pop(index)
def _get_contents_margins_size(self) -> QSize:
margins = self.contentsMargins()
return QSize(margins.left() + margins.right(), margins.top() + margins.bottom())
def setGeometry(self, rect: QRect):
super().setGeometry(rect)
if not self.items:
return
contents = self.contentsRect()
if contents.height() > 0:
c_aratio = contents.width() / contents.height()
else:
c_aratio = 1
s_aratio = self.aspect_ratio
item_rect = QRect(QPoint(0, 0), QSize(
contents.width() if c_aratio < s_aratio else int(contents.height() * s_aratio),
contents.height() if c_aratio > s_aratio else int(contents.width() / s_aratio)
))
content_margins = self.contentsMargins()
free_space = contents.size() - item_rect.size()
for item in self.items:
2024-09-05 16:20:01 +00:00
if free_space.width() > 0 and not item.alignment() & Qt.AlignmentFlag.AlignLeft:
if item.alignment() & Qt.AlignmentFlag.AlignRight:
item_rect.moveRight(contents.width() + content_margins.right())
else:
item_rect.moveLeft(content_margins.left() + (free_space.width() // 2))
else:
item_rect.moveLeft(content_margins.left())
2024-09-05 16:20:01 +00:00
if free_space.height() > 0 and not item.alignment() & Qt.AlignmentFlag.AlignTop:
if item.alignment() & Qt.AlignmentFlag.AlignBottom:
item_rect.moveBottom(contents.height() + content_margins.bottom())
else:
item_rect.moveTop(content_margins.top() + (free_space.height() // 2))
else:
item_rect.moveTop(content_margins.top())
item.widget().setGeometry(item_rect)
def sizeHint(self) -> QSize:
result = QSize()
for item in self.items:
result = result.expandedTo(item.sizeHint())
return self._get_contents_margins_size() + result
def minimumSize(self) -> QSize:
result = QSize()
for item in self.items:
result = result.expandedTo(item.minimumSize())
return self._get_contents_margins_size() + result
2024-09-05 16:20:01 +00:00
def expandingDirections(self) -> Qt.Orientation:
return Qt.Orientation.Horizontal | Qt.Orientation.Vertical
def QColorLerp(a: QColor, b: QColor, t: float):
"""
Blends two QColors. t=0 returns a. t=1 returns b. t=0.5 returns evenly mixed.
"""
t = max(min(t, 1.0), 0.0)
i_t = 1.0 - t
return QColor(
int((a.red() * i_t) + (b.red() * t)),
int((a.green() * i_t) + (b.green() * t)),
int((a.blue() * i_t) + (b.blue() * t)),
int((a.alpha() * i_t) + (b.alpha() * t)),
)
class ImageGraphicsEffect(QObject):
"""
Applies a QGraphicsEffect to a QImage
"""
def __init__(self, parent: QObject, effect: QGraphicsEffect):
super().__init__(parent)
assert effect, 'effect must be set'
self.effect = effect
self.graphics_scene = QGraphicsScene()
self.graphics_item = QGraphicsPixmapItem()
self.graphics_item.setGraphicsEffect(effect)
self.graphics_scene.addItem(self.graphics_item)
def apply(self, image: QImage):
assert image, 'image must be set'
2024-09-05 16:20:01 +00:00
result = QImage(image.size(), QImage.Format.Format_ARGB32)
result.fill(Qt.GlobalColor.transparent)
painter = QPainter(result)
self.graphics_item.setPixmap(QPixmap.fromImage(image))
self.graphics_scene.render(painter)
self.graphics_item.setPixmap(QPixmap())
return result
2022-06-16 12:05:05 +02:00
class QtEventListener(EventListener):
qt_callback_signal = QtCore.pyqtSignal(tuple)
def register_callbacks(self):
self.qt_callback_signal.connect(self.on_qt_callback_signal)
EventListener.register_callbacks(self)
def unregister_callbacks(self):
try:
self.qt_callback_signal.disconnect()
2024-09-05 16:20:01 +00:00
except (RuntimeError, TypeError): # wrapped Qt object might be deleted
# "TypeError: disconnect() failed between 'qt_callback_signal' and all its connections"
pass
2022-06-16 12:05:05 +02:00
EventListener.unregister_callbacks(self)
def on_qt_callback_signal(self, args):
func = args[0]
return func(self, *args[1:])
2022-06-16 12:05:05 +02:00
# decorator for members of the QtEventListener class
def qt_event_listener(func):
func = event_listener(func)
2023-12-08 14:07:32 +01:00
2022-06-16 12:05:05 +02:00
@wraps(func)
def decorator(self, *args):
self.qt_callback_signal.emit( (func,) + args)
return decorator
2025-02-04 11:13:37 +01:00
def insert_spaces(text: str, every_chars: int) -> str:
'''Insert spaces at every Nth character to allow for WordWrap'''
return ' '.join(text[i:i+every_chars] for i in range(0, len(text), every_chars))
2022-06-16 12:05:05 +02:00
2025-02-04 11:13:37 +01:00
class _ABCQObjectMeta(type(QObject), ABCMeta): pass
class _ABCQWidgetMeta(type(QWidget), ABCMeta): pass
class AbstractQObject(QObject, ABC, metaclass=_ABCQObjectMeta): pass
class AbstractQWidget(QWidget, ABC, metaclass=_ABCQWidgetMeta): pass
2014-05-24 22:06:43 +02:00
if __name__ == "__main__":
app = QApplication([])
t = WaitingDialog(None, 'testing ...', lambda: [time.sleep(1)], lambda x: QMessageBox.information(None, 'done', "done"))
2014-05-26 05:40:04 +02:00
t.start()
2024-09-05 16:20:01 +00:00
app.exec()