Browse Source

Rework PaytoEdit:

- show a QLineEdit by default, a QTextEdit only if paytomany is active.
   paytomany is a rare use case, it should not interfer with regular
   use (e.g. when a user inadvertently types enter).
 - this also fixes the visual appearance if the payto line
 - keep paytomany menu in sync with actual state
master
ThomasV 3 years ago
parent
commit
1f4cedf56a
  1. 180
      electrum/gui/qt/paytoedit.py
  2. 50
      electrum/gui/qt/send_tab.py
  3. 208
      electrum/gui/qt/util.py

180
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

50
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]

208
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):

Loading…
Cancel
Save