diff --git a/securedrop_client/gui/__init__.py b/securedrop_client/gui/__init__.py index 54b1aac2d..a29659160 100644 --- a/securedrop_client/gui/__init__.py +++ b/securedrop_client/gui/__init__.py @@ -17,9 +17,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. """ from typing import Union +import math +import html +import sys from PyQt5.QtWidgets import QLabel, QHBoxLayout, QPushButton, QWidget +from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEngineSettings from PyQt5.QtCore import QSize, Qt +from PyQt5.QtGui import QFocusEvent, QFont, QMouseEvent from securedrop_client.resources import load_svg, load_icon @@ -195,3 +200,94 @@ def get_elided_text(self, full_text: str) -> str: def is_elided(self) -> bool: return self.elided + + +class SecureQPlainTextEdit(QWebEngineView): + ''' + This is a resizeable widget used to display read-only plain text content. + + Like `SecureQLabel`, this widget wraps lines of text. Unlike `SecureQLabel`, this also supports + wrapping text which contains no spaces or dashes and is longer than the width of the widget. + + Like `SecureQLabel`, this automatically resizes the widget height to fit its content. Since this + is not a supported Qt feature for QPlainTextEdit (`self.size().height()` returns a fixed + default pixel height instead of a content-fitted height no matter what our sizePolicy is, and + `self.document().size().height()` returns the number of blocks in the document), we calculate + and set the height when `setPlainText` is called. + ''' + HEIGHT_BASE = 60 + LINE_HEIGHT = 20 + FONT_NAME = 'Source Sans Pro' + FONT_SIZE = 15 + TEMPLATE_START = """<html> + <head> + <style> + .container { + width: 515px; + background-color: white; + white-space: normal; + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + -ms-word-break: break-all; + -ms-hyphens: auto; + -moz-hyphens: auto; + -webkit-hyphens: auto; + hyphens: auto; + white-space: pre-line; + } + </style> + + </head> + <body> + <div class="container"> +""" + TEMPLATE_END = """ + </div> + </body> +</html>""" + + def __init__(self, text: str = '') -> None: + super().__init__() + #self.setReadOnly(True) # Do not allow SecureQPlainTextEdit to be editable + self.setContextMenuPolicy(Qt.NoContextMenu) # Disable copy/paste context menu + #self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setFont(QFont(self.FONT_NAME, self.FONT_SIZE)) + #self.height = self.HEIGHT_BASE + self.iText = "" + self.resize(580, 100) + self.setPlainText(text) + + def setText(self, text): + self.setPlainText(text) + + def toPlainText(self): + return self.iText + + def setPlainText(self, text: str) -> None: + self.iText = text + settings = self.settings() + settings.setAttribute(QWebEngineSettings.JavascriptEnabled, False) + settings.setAttribute(QWebEngineSettings.AutoLoadImages, False) + + escaped = html.escape(text) + # replace tab char with 8 spaces because HTML + escaped = escaped.replace("\t", " " * 8) + super().setHtml(self.TEMPLATE_START + escaped + self.TEMPLATE_END) + page = self.page() + size = page.contentsSize() + page.contentsSizeChanged.connect(self.calculate) + + + def calculate(self, size): + self.setFixedHeight(size.height()) + + def mouseReleaseEvent(self, event: QMouseEvent) -> None: + self.setFocus() + super().mouseReleaseEvent(event) + + def focusOutEvent(self, event: QFocusEvent) -> None: + clear_text_cursor = self.textCursor() + clear_text_cursor.clearSelection() + self.setTextCursor(clear_text_cursor) + super().focusOutEvent(event) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 2612bda8f..9ad4f9124 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -38,7 +38,8 @@ from securedrop_client.db import DraftReply, Source, Message, File, Reply, User from securedrop_client.storage import source_exists from securedrop_client.export import ExportStatus, ExportError -from securedrop_client.gui import SecureQLabel, SvgLabel, SvgPushButton, SvgToggleButton +from securedrop_client.gui import SecureQLabel, SecureQPlainTextEdit, SvgLabel, SvgPushButton, \ + SvgToggleButton from securedrop_client.logic import Controller from securedrop_client.resources import load_icon, load_image, load_movie from securedrop_client.utils import humanize_filesize @@ -1898,7 +1899,8 @@ class SpeechBubble(QWidget): font-weight: 400; font-size: 15px; background-color: #fff; - padding: 16px; + padding: 24px; + border: none; } #color_bar { min-height: 5px; @@ -1918,7 +1920,7 @@ def __init__(self, message_uuid: str, text: str, update_signal, index: int) -> N # Set styles self.setStyleSheet(self.CSS) - self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) # Set layout layout = QVBoxLayout() @@ -1929,7 +1931,7 @@ def __init__(self, message_uuid: str, text: str, update_signal, index: int) -> N layout.setSpacing(0) # Message box - self.message = SecureQLabel(text) + self.message = SecureQPlainTextEdit(text) self.message.setObjectName('message') # Color bar @@ -1937,14 +1939,16 @@ def __init__(self, message_uuid: str, text: str, update_signal, index: int) -> N self.color_bar.setObjectName('color_bar') # Speech bubble - speech_bubble = QWidget() - speech_bubble.setObjectName('speech_bubble') + self.speech_bubble = QWidget() + self.speech_bubble.setObjectName('speech_bubble') speech_bubble_layout = QVBoxLayout() - speech_bubble.setLayout(speech_bubble_layout) + self.speech_bubble.setLayout(speech_bubble_layout) speech_bubble_layout.addWidget(self.message) speech_bubble_layout.addWidget(self.color_bar) speech_bubble_layout.setContentsMargins(0, 0, 0, 0) speech_bubble_layout.setSpacing(0) + # self.speech_bubble.setFixedHeight(self.message.height) + self.speech_bubble.adjustSize() # Bubble area includes speech bubble plus error message if there is an error bubble_area = QWidget() @@ -1952,7 +1956,7 @@ def __init__(self, message_uuid: str, text: str, update_signal, index: int) -> N self.bubble_area_layout = QHBoxLayout() self.bubble_area_layout.setContentsMargins(0, self.TOP_MARGIN, 0, self.BOTTOM_MARGIN) bubble_area.setLayout(self.bubble_area_layout) - self.bubble_area_layout.addWidget(speech_bubble) + self.bubble_area_layout.addWidget(self.speech_bubble) # Add widget to layout layout.addWidget(bubble_area) @@ -1967,7 +1971,9 @@ def _update_text(self, source_id: str, message_uuid: str, text: str) -> None: signal matches the uuid of this speech bubble. """ if message_uuid == self.uuid: - self.message.setText(text) + self.message.setPlainText(text) + # self.speech_bubble.setFixedHeight(self.message.height) + self.speech_bubble.adjustSize() class MessageWidget(SpeechBubble): @@ -1990,7 +1996,7 @@ class ReplyWidget(SpeechBubble): font-size: 15px; background-color: #fff; color: #3b3b3b; - padding: 16px; + padding: 24px; ''' CSS_COLOR_BAR_REPLY_FAILED = ''' @@ -2013,7 +2019,7 @@ class ReplyWidget(SpeechBubble): font-size: 15px; background-color: #fff; color: #3b3b3b; - padding: 16px; + padding: 24px; ''' CSS_COLOR_BAR_REPLY_SUCCEEDED = ''' @@ -2029,7 +2035,7 @@ class ReplyWidget(SpeechBubble): font-size: 15px; color: #A9AAAD; background-color: #F7F8FC; - padding: 16px; + padding: 24px; ''' CSS_COLOR_BAR_REPLY_PENDING = ''' @@ -3135,7 +3141,7 @@ def update_conversation(self, collection: list) -> None: # Check if text in item has changed, then update the # widget to reflect this change. if not isinstance(item_widget, FileWidget): - if (item_widget.message.text() != conversation_item.content) and \ + if (item_widget.message.toPlainText() != conversation_item.content) and \ conversation_item.content: item_widget.message.setText(conversation_item.content) else: diff --git a/tests/functional/test_download_file.py b/tests/functional/test_download_file.py index 9a5e02d98..1e4593656 100644 --- a/tests/functional/test_download_file.py +++ b/tests/functional/test_download_file.py @@ -44,7 +44,7 @@ def check_for_sources(): # We see the source's message. last_msg_id = list(conversation.conversation_view.current_messages.keys())[-2] last_msg = conversation.conversation_view.current_messages[last_msg_id] - assert last_msg.message.text() == message + assert last_msg.message.toPlainText() == message # Let us download the file qtbot.mouseClick(file_msg.download_button, Qt.LeftButton) diff --git a/tests/functional/test_export_dialog.py b/tests/functional/test_export_dialog.py index fb76d3e97..8eff0e074 100644 --- a/tests/functional/test_export_dialog.py +++ b/tests/functional/test_export_dialog.py @@ -44,7 +44,7 @@ def check_for_sources(): # We see the source's message. last_msg_id = list(conversation.conversation_view.current_messages.keys())[-2] last_msg = conversation.conversation_view.current_messages[last_msg_id] - assert last_msg.message.text() == message + assert last_msg.message.toPlainText() == message # Let us download the file qtbot.mouseClick(file_msg.download_button, Qt.LeftButton) diff --git a/tests/functional/test_receive_message.py b/tests/functional/test_receive_message.py index 8014f2287..637dc0b43 100644 --- a/tests/functional/test_receive_message.py +++ b/tests/functional/test_receive_message.py @@ -44,4 +44,4 @@ def check_for_sources(): # We see the source's message. last_msg_id = list(conversation.conversation_view.current_messages.keys())[-2] last_msg = conversation.conversation_view.current_messages[last_msg_id] - assert last_msg.message.text() == message + assert last_msg.message.toPlainText() == message diff --git a/tests/functional/test_send_reply.py b/tests/functional/test_send_reply.py index f236fa505..ce1cafa56 100644 --- a/tests/functional/test_send_reply.py +++ b/tests/functional/test_send_reply.py @@ -47,4 +47,4 @@ def check_for_sources(): # just typed. last_msg_id = list(conversation.conversation_view.current_messages.keys())[-1] last_msg = conversation.conversation_view.current_messages[last_msg_id] - assert last_msg.message.text() == message + assert last_msg.message.toPlainText() == message diff --git a/tests/gui/test_init.py b/tests/gui/test_init.py index a16096379..853f849a9 100644 --- a/tests/gui/test_init.py +++ b/tests/gui/test_init.py @@ -1,10 +1,12 @@ """ Tests for the gui helper functions in __init__.py """ -from PyQt5.QtCore import QSize, Qt +from PyQt5.QtCore import QSize, Qt, QEvent, QPoint +from PyQt5.QtGui import QFocusEvent, QMouseEvent from PyQt5.QtWidgets import QApplication -from securedrop_client.gui import SecureQLabel, SvgPushButton, SvgLabel, SvgToggleButton +from securedrop_client.gui import (SecureQLabel, SecureQPlainTextEdit, + SvgPushButton, SvgLabel, SvgToggleButton) app = QApplication([]) @@ -185,3 +187,47 @@ def test_SecureQLabel_get_elided_text_only_returns_oneline_elided(mocker): def test_SecureQLabel_quotes_not_escaped_for_readability(): sl = SecureQLabel("'hello'") assert sl.text() == "'hello'" + + +def test_SecureQPlainTextEdit_init(): + label_text = '<script>alert("hi!");</script>' + sl = SecureQPlainTextEdit(label_text) + assert sl.toPlainText() == label_text + + +def test_SecureQPlainTextEdit_quotes_not_escaped_for_readability(): + label_text = "'hello'" + sl = SecureQPlainTextEdit(label_text) + assert sl.toPlainText() == label_text + + +def test_SecureQPlainTextEdit_init_wordwrap(): + ''' + Regression test to make sure we don't remove newlines. + ''' + long_string = ('1234567890123456789012345678901234567890123456789012345678901234567890\n' + '12345678901') + sl = SecureQPlainTextEdit(long_string) + assert sl.toPlainText() == long_string + + +def test_SecureQPlainTextEdit_mouse_release_event_sets_focus(mocker): + dummy_number = 1 + test_event = QMouseEvent(QEvent.MouseButtonRelease, QPoint(dummy_number, dummy_number), + Qt.LeftButton, Qt.LeftButton, Qt.NoModifier) + sl = SecureQPlainTextEdit("foo") + sl.setFocus = mocker.MagicMock() + + sl.mouseReleaseEvent(test_event) + + assert sl.setFocus.call_count == 1 + + +def test_SecureQPlainTextEdit_focus_out_removes_text_selection(mocker): + focus_out_event = QFocusEvent(QEvent.FocusOut) + sl = SecureQPlainTextEdit("foo") + sl.setTextCursor = mocker.MagicMock() + + sl.focusOutEvent(focus_out_event) + + assert sl.setTextCursor.call_count == 1 diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index 5fcd0d2f1..f63424314 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -2031,7 +2031,7 @@ def test_SpeechBubble_init(mocker): sb = SpeechBubble('mock id', 'hello', mock_signal, 0) ss = sb.styleSheet() - sb.message.text() == 'hello' + sb.message.toPlainText() == 'hello' assert mock_connect.called assert 'background-color' in ss @@ -2047,11 +2047,11 @@ def test_SpeechBubble_update_text(mocker): new_msg = 'new message' sb._update_text('mock_source_uuid', msg_id, new_msg) - assert sb.message.text() == new_msg + assert sb.message.toPlainText() == new_msg newer_msg = 'an even newer message' sb._update_text('mock_source_uuid', msg_id + 'xxxxx', newer_msg) - assert sb.message.text() == new_msg + assert sb.message.toPlainText() == new_msg def test_SpeechBubble_html_init(mocker): @@ -2062,7 +2062,7 @@ def test_SpeechBubble_html_init(mocker): mock_signal = mocker.MagicMock() bubble = SpeechBubble('mock id', '<b>hello</b>', mock_signal, 0) - assert bubble.message.text() == '<b>hello</b>' + assert bubble.message.toPlainText() == '<b>hello</b>' def test_SpeechBubble_with_apostrophe_in_text(mocker): @@ -2071,7 +2071,7 @@ def test_SpeechBubble_with_apostrophe_in_text(mocker): message = "I'm sure, you are reading my message." bubble = SpeechBubble('mock id', message, mock_signal, 0) - assert bubble.message.text() == message + assert bubble.message.toPlainText() == message def test_MessageWidget_init(mocker):