diff --git a/electrum/gui/qt/paytoedit.py b/electrum/gui/qt/paytoedit.py index 7b40c937d..fc1cd7916 100644 --- a/electrum/gui/qt/paytoedit.py +++ b/electrum/gui/qt/paytoedit.py @@ -25,14 +25,15 @@ import re import decimal +from functools import partial from decimal import Decimal from typing import NamedTuple, Sequence, Optional, List, TYPE_CHECKING from PyQt5.QtGui import QFontMetrics, QFont -from PyQt5.QtWidgets import QApplication +from PyQt5.QtWidgets import QApplication, QWidget, QLineEdit, QTextEdit, QVBoxLayout from electrum import bitcoin -from electrum.util import bfh, parse_max_spend, FailedToParsePaymentIdentifier +from electrum.util import parse_max_spend, FailedToParsePaymentIdentifier from electrum.transaction import PartialTxOutput from electrum.bitcoin import opcodes, construct_script from electrum.logging import Logger @@ -41,7 +42,7 @@ from electrum.lnurl import LNURLError from .qrtextedit import ScanQRTextEdit from .completion_text_edit import CompletionTextEdit from . import util -from .util import MONOSPACE_FONT +from .util import MONOSPACE_FONT, GenericInputHandler, editor_contextMenuEvent if TYPE_CHECKING: from .main_window import ElectrumWindow @@ -61,48 +62,112 @@ class PayToLineError(NamedTuple): is_multiline: bool = False -class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): - def __init__(self, send_tab: 'SendTab'): - CompletionTextEdit.__init__(self) - ScanQRTextEdit.__init__(self, config=send_tab.config, setText=self._on_input_btn, is_payto=True) - Logger.__init__(self) - self.send_tab = send_tab - self.win = send_tab.window - self.app = QApplication.instance() - self.amount_edit = self.send_tab.amount_e - self.setFont(QFont(MONOSPACE_FONT)) +class ResizingTextEdit(QTextEdit): + + def __init__(self): + QTextEdit.__init__(self) document = self.document() document.contentsChanged.connect(self.update_size) - fontMetrics = QFontMetrics(document.defaultFont()) self.fontSpacing = fontMetrics.lineSpacing() - margins = self.contentsMargins() documentMargin = document.documentMargin() self.verticalMargins = margins.top() + margins.bottom() self.verticalMargins += self.frameWidth() * 2 self.verticalMargins += documentMargin * 2 - self.heightMin = self.fontSpacing + self.verticalMargins self.heightMax = (self.fontSpacing * 10) + self.verticalMargins + self.update_size() + + def update_size(self): + docLineCount = self.document().lineCount() + docHeight = max(3, docLineCount) * self.fontSpacing + h = docHeight + self.verticalMargins + h = min(max(h, self.heightMin), self.heightMax) + self.setMinimumHeight(int(h)) + self.setMaximumHeight(int(h)) + self.verticalScrollBar().setHidden(docHeight + self.verticalMargins < self.heightMax) + + +class PayToEdit(Logger, GenericInputHandler): - self.c = None - self.addPasteButton(setText=self._on_input_btn) - self.textChanged.connect(self._on_text_changed) + def __init__(self, send_tab: 'SendTab'): + Logger.__init__(self) + GenericInputHandler.__init__(self) + self.line_edit = QLineEdit() + self.text_edit = ResizingTextEdit() + self.text_edit.hide() + self._is_paytomany = False + for w in [self.line_edit, self.text_edit]: + w.setFont(QFont(MONOSPACE_FONT)) + w.textChanged.connect(self._on_text_changed) + self.send_tab = send_tab + self.config = send_tab.config + self.win = send_tab.window + self.app = QApplication.instance() + self.amount_edit = self.send_tab.amount_e + + self.is_multiline = False self.outputs = [] # type: List[PartialTxOutput] self.errors = [] # type: List[PayToLineError] self.disable_checks = False self.is_alias = False - self.update_size() self.payto_scriptpubkey = None # type: Optional[bytes] self.lightning_invoice = None self.previous_payto = '' + # editor methods + self.setStyleSheet = self.editor.setStyleSheet + self.setText = self.editor.setText + self.setEnabled = self.editor.setEnabled + self.setReadOnly = self.editor.setReadOnly + # button handlers + self.on_qr_from_camera_input_btn = partial( + self.input_qr_from_camera, + config=self.config, + allow_multi=False, + show_error=self.win.show_error, + setText=self._on_input_btn, + ) + self.on_qr_from_screenshot_input_btn = partial( + self.input_qr_from_screenshot, + allow_multi=False, + show_error=self.win.show_error, + setText=self._on_input_btn, + ) + self.on_input_file = partial( + self.input_file, + config=self.config, + show_error=self.win.show_error, + setText=self._on_input_btn, + ) + # + self.line_edit.contextMenuEvent = partial(editor_contextMenuEvent, self.line_edit, self) + self.text_edit.contextMenuEvent = partial(editor_contextMenuEvent, self.text_edit, self) + + @property + def editor(self): + return self.text_edit if self.is_paytomany() else self.line_edit + + def set_paytomany(self, b): + self._is_paytomany = b + self.line_edit.setVisible(not b) + self.text_edit.setVisible(b) + self.send_tab.paytomany_menu.setChecked(b) + + def toggle_paytomany(self): + self.set_paytomany(not self._is_paytomany) + + def toPlainText(self): + return self.text_edit.toPlainText() if self.is_paytomany() else self.line_edit.text() + + def is_paytomany(self): + return self._is_paytomany def setFrozen(self, b): self.setReadOnly(b) - self.setStyleSheet(frozen_style if b else normal_style) - self.overlay_widget.setHidden(b) + if not b: + self.setStyleSheet(normal_style) def setTextNoCheck(self, text: str): """Sets the text, while also ensuring the new value will not be resolved/checked.""" @@ -110,9 +175,11 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): self.setText(text) def do_clear(self): + self.set_paytomany(False) self.disable_checks = False self.is_alias = False - self.setText('') + self.line_edit.setText('') + self.text_edit.setText('') self.setFrozen(False) self.setEnabled(True) @@ -134,12 +201,12 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): def parse_output(self, x) -> bytes: try: address = self.parse_address(x) - return bfh(bitcoin.address_to_script(address)) + return bytes.fromhex(bitcoin.address_to_script(address)) except Exception: pass try: script = self.parse_script(x) - return bfh(script) + return bytes.fromhex(script) except Exception: pass raise Exception("Invalid address or script.") @@ -151,7 +218,7 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): opcode_int = opcodes[word] script += construct_script([opcode_int]) else: - bfh(word) # to test it is hex data + bytes.fromhex(word) # to test it is hex data script += construct_script([word]) return script @@ -176,30 +243,37 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): def _on_input_btn(self, text: str): self.setText(text) - self._check_text(full_check=True) def _on_text_changed(self): - if self.app.clipboard().text() == self.toPlainText(): - # user likely pasted from clipboard - self._check_text(full_check=True) - else: - self._check_text(full_check=False) + text = self.toPlainText() + # False if user pasted from clipboard + full_check = self.app.clipboard().text() != text + self._check_text(text, full_check=full_check) + if self.is_multiline and not self._is_paytomany: + self.set_paytomany(True) + self.text_edit.setText(text) def on_timer_check_text(self): - if self.hasFocus(): + if self.editor.hasFocus(): return - self._check_text(full_check=True) - - def _check_text(self, *, full_check: bool): - if self.previous_payto == str(self.toPlainText()).strip(): + text = self.toPlainText() + self._check_text(text, full_check=True) + + def _check_text(self, text, *, full_check: bool): + """ + side effects: self.is_multiline, self.errors, self.outputs + """ + if self.previous_payto == str(text).strip(): return if full_check: - self.previous_payto = str(self.toPlainText()).strip() + self.previous_payto = str(text).strip() self.errors = [] if self.disable_checks: return # filter out empty lines - lines = [i for i in self.lines() if i] + lines = text.split('\n') + lines = [i for i in lines if i] + self.is_multiline = len(lines)>1 self.payto_scriptpubkey = None self.lightning_invoice = None @@ -242,6 +316,7 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): # there are multiple lines self._parse_as_multiline(lines, raise_errors=False) + def _parse_as_multiline(self, lines, *, raise_errors: bool): outputs = [] # type: List[PartialTxOutput] total = 0 @@ -292,33 +367,6 @@ class PayToEdit(CompletionTextEdit, ScanQRTextEdit, Logger): return self.outputs[:] - def lines(self): - return self.toPlainText().split('\n') - - def is_multiline(self): - return len(self.lines()) > 1 - - def paytomany(self): - self.setTextNoCheck("\n\n\n") - self.update_size() - - def update_size(self): - docLineCount = self.document().lineCount() - if self.cursorRect().right() + 1 >= self.overlay_widget.pos().x(): - # Add a line if we are under the overlay widget - docLineCount += 1 - docHeight = docLineCount * self.fontSpacing - - h = docHeight + self.verticalMargins - h = min(max(h, self.heightMin), self.heightMax) - self.setMinimumHeight(int(h)) - self.setMaximumHeight(int(h)) - - self.verticalScrollBar().setHidden(docHeight + self.verticalMargins < self.heightMax) - - # The scrollbar visibility can have changed so we update the overlay position here - self._updateOverlayPos() - def _resolve_openalias(self, text: str) -> Optional[dict]: key = text key = key.strip() # strip whitespaces diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index 640f1a03a..4b3f9b46e 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -9,7 +9,7 @@ from urllib.parse import urlparse from PyQt5.QtCore import pyqtSignal, QPoint from PyQt5.QtWidgets import (QLabel, QVBoxLayout, QGridLayout, - QHBoxLayout, QCompleter, QWidget, QToolTip) + QHBoxLayout, QCompleter, QWidget, QToolTip, QPushButton) from electrum import util, paymentrequest from electrum import lnutil @@ -85,20 +85,21 @@ class SendTab(QWidget, MessageBoxMixin, Logger): "e.g. set one amount to '2!' and another to '3!' to split your coins 40-60.")) payto_label = HelpLabel(_('Pay to'), msg) grid.addWidget(payto_label, 1, 0) - grid.addWidget(self.payto_e, 1, 1, 1, -1) + grid.addWidget(self.payto_e.line_edit, 1, 1, 1, 4) + grid.addWidget(self.payto_e.text_edit, 1, 1, 1, 4) - completer = QCompleter() - completer.setCaseSensitivity(False) - self.payto_e.set_completer(completer) - completer.setModel(self.window.completions) + #completer = QCompleter() + #completer.setCaseSensitivity(False) + #self.payto_e.set_completer(completer) + #completer.setModel(self.window.completions) msg = _('Description of the transaction (not mandatory).') + '\n\n' \ + _( 'The description is not sent to the recipient of the funds. It is stored in your wallet file, and displayed in the \'History\' tab.') description_label = HelpLabel(_('Description'), msg) grid.addWidget(description_label, 2, 0) - self.message_e = SizedFreezableLineEdit(width=700) - grid.addWidget(self.message_e, 2, 1, 1, -1) + self.message_e = SizedFreezableLineEdit(width=600) + grid.addWidget(self.message_e, 2, 1, 1, 4) msg = (_('The amount to be received by the recipient.') + ' ' + _('Fees are paid by the sender.') + '\n\n' @@ -127,9 +128,16 @@ class SendTab(QWidget, MessageBoxMixin, Logger): self.save_button = EnterButton(_("Save"), self.do_save_invoice) self.send_button = EnterButton(_("Pay") + "...", self.do_pay_or_get_invoice) self.clear_button = EnterButton(_("Clear"), self.do_clear) + self.paste_button = QPushButton() + self.paste_button.clicked.connect(lambda: self.payto_e._on_input_btn(self.window.app.clipboard().text())) + self.paste_button.setIcon(read_QIcon('copy.png')) + self.paste_button.setToolTip(_('Paste invoice from clipboard')) + self.paste_button.setMaximumWidth(35) + grid.addWidget(self.paste_button, 1, 5) buttons = QHBoxLayout() buttons.addStretch(1) + #buttons.addWidget(self.paste_button) buttons.addWidget(self.clear_button) buttons.addWidget(self.save_button) buttons.addWidget(self.send_button) @@ -151,10 +159,12 @@ class SendTab(QWidget, MessageBoxMixin, Logger): from .invoice_list import InvoiceList self.invoice_list = InvoiceList(self) self.toolbar, menu = self.invoice_list.create_toolbar_with_menu('') + + menu.addAction(read_QIcon(get_iconname_camera()), _("Read QR code with camera"), self.payto_e.on_qr_from_camera_input_btn) menu.addAction(read_QIcon("picture_in_picture.png"), _("Read QR code from screen"), self.payto_e.on_qr_from_screenshot_input_btn) menu.addAction(read_QIcon("file.png"), _("Read invoice from file"), self.payto_e.on_input_file) - menu.addToggle(_("&Pay to many"), self.paytomany) + self.paytomany_menu = menu.addToggle(_("&Pay to many"), self.toggle_paytomany) menu.addSeparator() menu.addAction(_("Import invoices"), self.window.import_invoices) menu.addAction(_("Export invoices"), self.window.export_invoices) @@ -755,18 +765,16 @@ class SendTab(QWidget, MessageBoxMixin, Logger): WaitingDialog(self, _('Broadcasting transaction...'), broadcast_thread, broadcast_done, self.window.on_error) - def paytomany(self): - if self.payto_e.is_multiline(): - self.payto_e.do_clear() - return - self.payto_e.paytomany() - message = '\n'.join([ - _('Enter a list of outputs in the \'Pay to\' field.'), - _('One output per line.'), - _('Format: address, amount'), - _('You may load a CSV file using the file icon.') - ]) - self.window.show_tooltip_after_delay(message) + def toggle_paytomany(self): + self.payto_e.toggle_paytomany() + if self.payto_e.is_paytomany(): + message = '\n'.join([ + _('Enter a list of outputs in the \'Pay to\' field.'), + _('One output per line.'), + _('Format: address, amount'), + _('You may load a CSV file using the file icon.') + ]) + self.window.show_tooltip_after_delay(message) def payto_contacts(self, labels): paytos = [self.window.get_contact_payto(label) for label in labels] diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py index b82b060bd..0ed206342 100644 --- a/electrum/gui/qt/util.py +++ b/electrum/gui/qt/util.py @@ -920,7 +920,116 @@ def get_iconname_camera() -> str: return "camera_white.png" if ColorScheme.dark_scheme else "camera_dark.png" -class OverlayControlMixin: +def editor_contextMenuEvent(self, p, e): + 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) + m.exec_(e.globalPos()) + + +class GenericInputHandler: + + def input_qr_from_camera( + self, + *, + config: 'SimpleConfig', + allow_multi: bool = False, + show_error: Callable[[str], None], + setText: Callable[[str], None] = None, + ) -> None: + if setText is None: + setText = self.setText + def cb(success: bool, error: str, data): + if not success: + if error: + show_error(error) + return + if not data: + data = '' + if allow_multi: + new_text = self.text() + data + '\n' + else: + new_text = data + setText(new_text) + + from .qrreader import scan_qrcode + scan_qrcode(parent=self, config=config, callback=cb) + + 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 + from .qrreader import scan_qr_from_image + scanned_qr = None + for screen in QApplication.instance().screens(): + try: + scan_result = scan_qr_from_image(screen.grabWindow(0).toImage()) + except MissingQrDetectionLib as e: + show_error(_("Unable to scan image.") + "\n" + repr(e)) + return + if len(scan_result) > 0: + if (scanned_qr is not None) or len(scan_result) > 1: + show_error(_("More than one QR code was found on the screen.")) + return + scanned_qr = scan_result + if scanned_qr is None: + show_error(_("No QR code was found on the screen.")) + return + data = scanned_qr[0].data + if allow_multi: + new_text = self.text() + data + '\n' + else: + new_text = data + setText(new_text) + + 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: + setText(data) + + 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; } ''' @@ -931,6 +1040,7 @@ class OverlayControlMixin: ''' def __init__(self, middle: bool = False): + GenericInputHandler.__init__(self) assert isinstance(self, QWidget) assert isinstance(self, OverlayControlMixin) # only here for type-hints in IDE self.middle = middle @@ -1107,102 +1217,6 @@ class OverlayControlMixin: menu.addAction(read_QIcon(opt_icon), opt_text, opt_cb) btn.setMenu(menu) - def input_qr_from_camera( - self, - *, - config: 'SimpleConfig', - allow_multi: bool = False, - show_error: Callable[[str], None], - setText: Callable[[str], None] = None, - ) -> None: - if setText is None: - setText = self.setText - def cb(success: bool, error: str, data): - if not success: - if error: - show_error(error) - return - if not data: - data = '' - if allow_multi: - new_text = self.text() + data + '\n' - else: - new_text = data - setText(new_text) - - from .qrreader import scan_qrcode - scan_qrcode(parent=self, config=config, callback=cb) - - 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 - from .qrreader import scan_qr_from_image - scanned_qr = None - for screen in QApplication.instance().screens(): - try: - scan_result = scan_qr_from_image(screen.grabWindow(0).toImage()) - except MissingQrDetectionLib as e: - show_error(_("Unable to scan image.") + "\n" + repr(e)) - return - if len(scan_result) > 0: - if (scanned_qr is not None) or len(scan_result) > 1: - show_error(_("More than one QR code was found on the screen.")) - return - scanned_qr = scan_result - if scanned_qr is None: - show_error(_("No QR code was found on the screen.")) - return - data = scanned_qr[0].data - if allow_multi: - new_text = self.text() + data + '\n' - else: - new_text = data - setText(new_text) - - 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=self, - 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: - setText(data) - - 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 ButtonsLineEdit(OverlayControlMixin, QLineEdit):