There was no ability to read qr codes contained in image files. This could lead to confusion in some contexts, as `on_file_input()` of ScanQRTextEdit will read the whole content of the file (instead of looking for qr codes). The revealer plugin for example generates png files containing qr codes and uses the `ScanQRTextEdit` to get user input, for the user it would seem logical to click on 'Read from file' to load the generated file, however this will result in the wrong data being loaded. Having the option to explicitly load a QR from file makes this clear. Also it seems useful, especially considering reading QR from screenshots doesn't work on wayland.
302 lines
10 KiB
Python
302 lines
10 KiB
Python
#!/usr/bin/env python
|
|
#
|
|
# Electrum - lightweight Bitcoin client
|
|
# Copyright (C) 2012 thomasv@gitorious
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person
|
|
# obtaining a copy of this software and associated documentation files
|
|
# (the "Software"), to deal in the Software without restriction,
|
|
# including without limitation the rights to use, copy, modify, merge,
|
|
# publish, distribute, sublicense, and/or sell copies of the Software,
|
|
# and to permit persons to whom the Software is furnished to do so,
|
|
# subject to the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be
|
|
# included in all copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
|
|
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
|
|
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
# SOFTWARE.
|
|
|
|
from functools import partial
|
|
from typing import Optional, TYPE_CHECKING
|
|
|
|
from PyQt6.QtCore import Qt, QTimer, QSize
|
|
from PyQt6.QtCore import pyqtSignal
|
|
from PyQt6.QtGui import QFontMetrics, QFont
|
|
from PyQt6.QtWidgets import QTextEdit, QWidget, QLineEdit, QStackedLayout
|
|
|
|
from electrum.payment_identifier import PaymentIdentifier
|
|
from electrum.logging import Logger
|
|
|
|
from . import util
|
|
from .util import MONOSPACE_FONT, GenericInputHandler, editor_contextMenuEvent, ColorScheme
|
|
|
|
if TYPE_CHECKING:
|
|
from .send_tab import SendTab
|
|
|
|
|
|
frozen_style = "QWidget {border:none;}"
|
|
normal_style = "QPlainTextEdit { }"
|
|
|
|
|
|
class InvalidPaymentIdentifier(Exception):
|
|
pass
|
|
|
|
|
|
class ResizingTextEdit(QTextEdit):
|
|
|
|
textReallyChanged = pyqtSignal()
|
|
resized = pyqtSignal()
|
|
|
|
def __init__(self):
|
|
QTextEdit.__init__(self)
|
|
self._text = ''
|
|
self.setAcceptRichText(False)
|
|
self.textChanged.connect(self.on_text_changed)
|
|
document = self.document()
|
|
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 on_text_changed(self):
|
|
# QTextEdit emits spurious textChanged events
|
|
if self.toPlainText() != self._text:
|
|
self._text = self.toPlainText()
|
|
self.textReallyChanged.emit()
|
|
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.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
|
self.verticalScrollBar().setHidden(docHeight + self.verticalMargins < self.heightMax)
|
|
self.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth)
|
|
self.resized.emit()
|
|
|
|
def sizeHint(self) -> QSize:
|
|
return QSize(0, self.minimumHeight())
|
|
|
|
|
|
class PayToEdit(QWidget, Logger, GenericInputHandler):
|
|
paymentIdentifierChanged = pyqtSignal()
|
|
textChanged = pyqtSignal()
|
|
|
|
def __init__(self, send_tab: 'SendTab'):
|
|
QWidget.__init__(self, parent=send_tab)
|
|
Logger.__init__(self)
|
|
GenericInputHandler.__init__(self)
|
|
|
|
self._text = ''
|
|
self._layout = QStackedLayout()
|
|
self.setLayout(self._layout)
|
|
|
|
def text_edit_changed():
|
|
text = self.text_edit.toPlainText()
|
|
if self._text != text:
|
|
# sync and emit
|
|
self._text = text
|
|
self.line_edit.setText(text)
|
|
self.textChanged.emit()
|
|
|
|
def text_edit_resized():
|
|
self.update_height()
|
|
|
|
def line_edit_changed():
|
|
text = self.line_edit.text()
|
|
if self._text != text:
|
|
# sync and emit
|
|
self._text = text
|
|
self.text_edit.setPlainText(text)
|
|
self.textChanged.emit()
|
|
|
|
self.line_edit = QLineEdit()
|
|
self.line_edit.textChanged.connect(line_edit_changed)
|
|
self.text_edit = ResizingTextEdit()
|
|
self.text_edit.setTabChangesFocus(True)
|
|
self.text_edit.textReallyChanged.connect(text_edit_changed)
|
|
self.text_edit.resized.connect(text_edit_resized)
|
|
|
|
self.textChanged.connect(self._handle_text_change)
|
|
|
|
self._layout.addWidget(self.line_edit)
|
|
self._layout.addWidget(self.text_edit)
|
|
|
|
self.multiline = False
|
|
|
|
self._is_paytomany = False
|
|
self.line_edit.setFont(QFont(MONOSPACE_FONT))
|
|
self.text_edit.setFont(QFont(MONOSPACE_FONT))
|
|
self.send_tab = send_tab
|
|
self.config = send_tab.config
|
|
|
|
# button handlers
|
|
self.on_qr_from_camera_input_btn = partial(
|
|
self.input_qr_from_camera,
|
|
config=self.config,
|
|
allow_multi=False,
|
|
show_error=self.send_tab.show_error,
|
|
setText=self.try_payment_identifier,
|
|
parent=self.send_tab.window,
|
|
)
|
|
self.on_qr_from_screenshot_input_btn = partial(
|
|
self.input_qr_from_screenshot,
|
|
allow_multi=False,
|
|
show_error=self.send_tab.show_error,
|
|
setText=self.try_payment_identifier,
|
|
)
|
|
self.on_qr_from_file_input_btn = partial(
|
|
self.input_qr_from_file,
|
|
allow_multi=False,
|
|
config=self.config,
|
|
show_error=self.send_tab.show_error,
|
|
setText=self.try_payment_identifier,
|
|
)
|
|
self.on_input_file = partial(
|
|
self.input_file,
|
|
config=self.config,
|
|
show_error=self.send_tab.show_error,
|
|
setText=self.try_payment_identifier,
|
|
)
|
|
|
|
self.text_edit.contextMenuEvent = partial(editor_contextMenuEvent, self.text_edit, self)
|
|
self.line_edit.contextMenuEvent = partial(editor_contextMenuEvent, self.line_edit, self)
|
|
|
|
self.edit_timer = QTimer(self)
|
|
self.edit_timer.setSingleShot(True)
|
|
self.edit_timer.setInterval(1000)
|
|
self.edit_timer.timeout.connect(self._on_edit_timer)
|
|
|
|
self.payment_identifier = None # type: Optional[PaymentIdentifier]
|
|
|
|
@property
|
|
def multiline(self):
|
|
return self._multiline
|
|
|
|
@multiline.setter
|
|
def multiline(self, b: bool) -> None:
|
|
if b is None:
|
|
return
|
|
self._multiline = b
|
|
self._layout.setCurrentWidget(self.text_edit if b else self.line_edit)
|
|
self.update_height()
|
|
|
|
def update_height(self) -> None:
|
|
h = self._layout.currentWidget().sizeHint().height()
|
|
self.setMaximumHeight(h)
|
|
|
|
def setText(self, text: str) -> None:
|
|
if self._text != text:
|
|
self.line_edit.setText(text)
|
|
self.text_edit.setText(text)
|
|
|
|
def setFocus(self, reason=Qt.FocusReason.OtherFocusReason) -> None:
|
|
if self.multiline:
|
|
self.text_edit.setFocus(reason)
|
|
else:
|
|
self.line_edit.setFocus(reason)
|
|
|
|
def setToolTip(self, tt: str) -> None:
|
|
self.line_edit.setToolTip(tt)
|
|
self.text_edit.setToolTip(tt)
|
|
|
|
def try_payment_identifier(self, text) -> None:
|
|
'''set payment identifier only if valid, else exception'''
|
|
text = text.strip()
|
|
pi = PaymentIdentifier(self.send_tab.wallet, text)
|
|
if not pi.is_valid():
|
|
raise InvalidPaymentIdentifier('Invalid payment identifier')
|
|
self.set_payment_identifier(text)
|
|
|
|
def set_payment_identifier(self, text) -> None:
|
|
text = text.strip()
|
|
if self.payment_identifier and self.payment_identifier.text == text:
|
|
# no change.
|
|
return
|
|
|
|
self.payment_identifier = PaymentIdentifier(self.send_tab.wallet, text)
|
|
|
|
# toggle to multiline if payment identifier is a multiline
|
|
if self.payment_identifier.is_multiline() and not self._is_paytomany:
|
|
self.set_paytomany(True)
|
|
|
|
# if payment identifier gets set externally, we want to update the edit control
|
|
# Note: this triggers the change handler, but we shortcut if it's the same payment identifier
|
|
self.setText(text)
|
|
|
|
self.paymentIdentifierChanged.emit()
|
|
|
|
def set_paytomany(self, b):
|
|
self._is_paytomany = b
|
|
self.multiline = b
|
|
self.send_tab.paytomany_menu.setChecked(b)
|
|
|
|
def toggle_paytomany(self) -> None:
|
|
self.set_paytomany(not self._is_paytomany)
|
|
|
|
def is_paytomany(self):
|
|
return self._is_paytomany
|
|
|
|
def setReadOnly(self, b: bool) -> None:
|
|
self.line_edit.setReadOnly(b)
|
|
self.text_edit.setReadOnly(b)
|
|
|
|
def isReadOnly(self):
|
|
return self.line_edit.isReadOnly()
|
|
|
|
def setStyleSheet(self, stylesheet: str) -> None:
|
|
self.line_edit.setStyleSheet(stylesheet)
|
|
self.text_edit.setStyleSheet(stylesheet)
|
|
|
|
def setFrozen(self, b) -> None:
|
|
self.setReadOnly(b)
|
|
self.setStyleSheet(ColorScheme.LIGHTBLUE.as_stylesheet(True) if b else '')
|
|
|
|
def isFrozen(self):
|
|
return self.isReadOnly()
|
|
|
|
def do_clear(self) -> None:
|
|
self.set_paytomany(False)
|
|
self.setText('')
|
|
self.setToolTip('')
|
|
self.payment_identifier = None
|
|
|
|
def setGreen(self) -> None:
|
|
self.setStyleSheet(util.ColorScheme.GREEN.as_stylesheet(True))
|
|
|
|
def setExpired(self) -> None:
|
|
self.setStyleSheet(util.ColorScheme.RED.as_stylesheet(True))
|
|
|
|
def _handle_text_change(self) -> None:
|
|
if self.isFrozen():
|
|
# if editor is frozen, we ignore text changes as they might not be a payment identifier
|
|
# but a user friendly representation.
|
|
return
|
|
|
|
# pushback timer if timer active or PI needs resolving
|
|
pi = PaymentIdentifier(self.send_tab.wallet, self._text)
|
|
if not pi.is_valid() or pi.need_resolve() or self.edit_timer.isActive():
|
|
self.edit_timer.start()
|
|
else:
|
|
self.set_payment_identifier(self._text)
|
|
|
|
def _on_edit_timer(self) -> None:
|
|
if not self.isFrozen():
|
|
self.set_payment_identifier(self._text)
|