From 1f918e1e77d9123cbe00347b629d956309bf6d78 Mon Sep 17 00:00:00 2001 From: Kushal Das Date: Tue, 7 Apr 2020 15:04:36 +0530 Subject: [PATCH 01/17] Fixes #815 adds wordwrap for SecureQLabel --- securedrop_client/gui/__init__.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/securedrop_client/gui/__init__.py b/securedrop_client/gui/__init__.py index 54b1aac2d..e40cdde42 100644 --- a/securedrop_client/gui/__init__.py +++ b/securedrop_client/gui/__init__.py @@ -16,6 +16,7 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . """ +import textwrap from typing import Union from PyQt5.QtWidgets import QLabel, QHBoxLayout, QPushButton, QWidget @@ -170,8 +171,13 @@ def setText(self, text: str) -> None: self.elided = True if elided_text != text else False if self.elided and self.with_tooltip: tooltip_label = SecureQLabel(text) - self.setToolTip(tooltip_label.text()) - super().setText(elided_text) + tooltip_text = tooltip_label.text() + if tooltip_text.find("\n") != -1: + tooltip_text = "".join(tooltip_text.split("\n")) + self.setToolTip(tooltip_text) + + wrapped = "\n".join(textwrap.wrap(elided_text)) + super().setText(wrapped) def get_elided_text(self, full_text: str) -> str: if not self.max_length: From 6aff05f90879d80dad0849367ea05de6e7f2c5a4 Mon Sep 17 00:00:00 2001 From: Kushal Das Date: Thu, 9 Apr 2020 21:31:07 +0530 Subject: [PATCH 02/17] Half done work on the UI to use QPlainTextEdit For the SecureQLabel --- securedrop_client/gui/__init__.py | 25 ++++++++++++++----------- securedrop_client/gui/widgets.py | 2 +- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/securedrop_client/gui/__init__.py b/securedrop_client/gui/__init__.py index e40cdde42..a90f4fdbb 100644 --- a/securedrop_client/gui/__init__.py +++ b/securedrop_client/gui/__init__.py @@ -19,7 +19,7 @@ import textwrap from typing import Union -from PyQt5.QtWidgets import QLabel, QHBoxLayout, QPushButton, QWidget +from PyQt5.QtWidgets import QPlainTextEdit, QHBoxLayout, QPushButton, QWidget, QLabel from PyQt5.QtCore import QSize, Qt from securedrop_client.resources import load_svg, load_icon @@ -147,7 +147,7 @@ def update_image(self, filename: str, svg_size: str = None) -> None: self.layout().addWidget(self.svg) -class SecureQLabel(QLabel): +class SecureQLabel(QPlainTextEdit): def __init__( self, text: str = "", @@ -157,27 +157,27 @@ def __init__( max_length: int = 0, with_tooltip: bool = False, ): - super().__init__(parent, flags) + super().__init__(parent) self.wordwrap = wordwrap self.max_length = max_length - self.setWordWrap(wordwrap) # If True, wraps text at default of 70 characters self.with_tooltip = with_tooltip + self.setReadOnly(True) self.setText(text) - self.elided = True if self.text() != text else False + self.elided = True if self.toPlainText() != text else False + self.setStyleSheet("SecureQLabel { border: none; } ") + policy = self.sizePolicy() + policy.setVerticalStretch(1) + self.setSizePolicy(policy) def setText(self, text: str) -> None: - self.setTextFormat(Qt.PlainText) elided_text = self.get_elided_text(text) self.elided = True if elided_text != text else False if self.elided and self.with_tooltip: tooltip_label = SecureQLabel(text) - tooltip_text = tooltip_label.text() - if tooltip_text.find("\n") != -1: - tooltip_text = "".join(tooltip_text.split("\n")) + tooltip_text = tooltip_label.plainText() self.setToolTip(tooltip_text) - wrapped = "\n".join(textwrap.wrap(elided_text)) - super().setText(wrapped) + super().setPlainText(elided_text) def get_elided_text(self, full_text: str) -> str: if not self.max_length: @@ -201,3 +201,6 @@ def get_elided_text(self, full_text: str) -> str: def is_elided(self) -> bool: return self.elided + + def text(self) -> str: + return self.toPlainText() diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 2612bda8f..942c09ab4 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -1918,7 +1918,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() From 8b6d1bb1fb4a5955f6aa0600f610372fcfffff9a Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Thu, 9 Apr 2020 14:52:02 -0700 Subject: [PATCH 03/17] create textedit speech bubbles --- securedrop_client/gui/__init__.py | 33 ++++++++++++++++--------------- securedrop_client/gui/widgets.py | 8 +++++--- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/securedrop_client/gui/__init__.py b/securedrop_client/gui/__init__.py index a90f4fdbb..5fa076a3a 100644 --- a/securedrop_client/gui/__init__.py +++ b/securedrop_client/gui/__init__.py @@ -16,10 +16,9 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . """ -import textwrap from typing import Union -from PyQt5.QtWidgets import QPlainTextEdit, QHBoxLayout, QPushButton, QWidget, QLabel +from PyQt5.QtWidgets import QLabel, QHBoxLayout, QPlainTextEdit, QPushButton, QWidget from PyQt5.QtCore import QSize, Qt from securedrop_client.resources import load_svg, load_icon @@ -147,7 +146,7 @@ def update_image(self, filename: str, svg_size: str = None) -> None: self.layout().addWidget(self.svg) -class SecureQLabel(QPlainTextEdit): +class SecureQLabel(QLabel): def __init__( self, text: str = "", @@ -157,27 +156,22 @@ def __init__( max_length: int = 0, with_tooltip: bool = False, ): - super().__init__(parent) + super().__init__(parent, flags) self.wordwrap = wordwrap self.max_length = max_length + self.setWordWrap(wordwrap) # If True, wraps text at default of 70 characters self.with_tooltip = with_tooltip - self.setReadOnly(True) self.setText(text) - self.elided = True if self.toPlainText() != text else False - self.setStyleSheet("SecureQLabel { border: none; } ") - policy = self.sizePolicy() - policy.setVerticalStretch(1) - self.setSizePolicy(policy) + self.elided = True if self.text() != text else False def setText(self, text: str) -> None: + self.setTextFormat(Qt.PlainText) elided_text = self.get_elided_text(text) self.elided = True if elided_text != text else False if self.elided and self.with_tooltip: tooltip_label = SecureQLabel(text) - tooltip_text = tooltip_label.plainText() - self.setToolTip(tooltip_text) - - super().setPlainText(elided_text) + self.setToolTip(tooltip_label.text()) + super().setText(elided_text) def get_elided_text(self, full_text: str) -> str: if not self.max_length: @@ -202,5 +196,12 @@ def get_elided_text(self, full_text: str) -> str: def is_elided(self) -> bool: return self.elided - def text(self) -> str: - return self.toPlainText() + +class SecureQPlainTextEdit(QPlainTextEdit): + def __init__(self, text: str = ""): + super().__init__() + self.setReadOnly(True) + self.setPlainText(text) + policy = self.sizePolicy() + policy.setVerticalStretch(1) + self.setSizePolicy(policy) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 942c09ab4..5c614636b 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 @@ -1899,6 +1900,7 @@ class SpeechBubble(QWidget): font-size: 15px; background-color: #fff; padding: 16px; + border: none; } #color_bar { min-height: 5px; @@ -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 @@ -3135,7 +3137,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: From 5392c4681e3c36a7d398ab47ee367121c41627b3 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Mon, 13 Apr 2020 10:36:02 -0700 Subject: [PATCH 04/17] adjust textedit height --- securedrop_client/gui/__init__.py | 28 +++++++++++++++++++++++++--- securedrop_client/gui/widgets.py | 14 +++++++++----- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/securedrop_client/gui/__init__.py b/securedrop_client/gui/__init__.py index 5fa076a3a..076a099e1 100644 --- a/securedrop_client/gui/__init__.py +++ b/securedrop_client/gui/__init__.py @@ -17,6 +17,7 @@ along with this program. If not, see . """ from typing import Union +import math from PyQt5.QtWidgets import QLabel, QHBoxLayout, QPlainTextEdit, QPushButton, QWidget from PyQt5.QtCore import QSize, Qt @@ -198,10 +199,31 @@ def is_elided(self) -> bool: class SecureQPlainTextEdit(QPlainTextEdit): + MAX_TEXT_WIDTH = 75 + MAX_NATURAL_TEXT_WIDTH = 650 + HEIGHT_BASE = 40 + LINE_HEIGHT = 20 + def __init__(self, text: str = ""): super().__init__() self.setReadOnly(True) + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.document().setTextWidth(self.MAX_TEXT_WIDTH) self.setPlainText(text) - policy = self.sizePolicy() - policy.setVerticalStretch(1) - self.setSizePolicy(policy) + + def setPlainText(self, text): + super().setPlainText(text) + total_line_count = 0 + for block_num in range(0, self.blockCount()): + block = self.document().findBlockByNumber(block_num) + line_count = 0 + line_count = line_count + math.ceil(block.length() / self.document().idealWidth()) + text_layout = block.layout() + for line_num in range(0, text_layout.lineCount()): + line = text_layout.lineAt(line_num) + line_wrap_count = math.floor(line.naturalTextWidth() / self.MAX_NATURAL_TEXT_WIDTH) + line_count = line_count + line_wrap_count + total_line_count = total_line_count + line_count + + self.height = self.HEIGHT_BASE + (total_line_count * self.LINE_HEIGHT) + self.setFixedHeight(self.height) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 5c614636b..f88742c18 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -1939,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() @@ -1954,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) @@ -1969,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): From e28cca51ff828ec80e13f887d3fa025930160f15 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Mon, 13 Apr 2020 14:18:06 -0700 Subject: [PATCH 05/17] simplify resize logic for textedit --- securedrop_client/gui/__init__.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/securedrop_client/gui/__init__.py b/securedrop_client/gui/__init__.py index 076a099e1..cfea1bd2c 100644 --- a/securedrop_client/gui/__init__.py +++ b/securedrop_client/gui/__init__.py @@ -201,7 +201,7 @@ def is_elided(self) -> bool: class SecureQPlainTextEdit(QPlainTextEdit): MAX_TEXT_WIDTH = 75 MAX_NATURAL_TEXT_WIDTH = 650 - HEIGHT_BASE = 40 + HEIGHT_BASE = 60 LINE_HEIGHT = 20 def __init__(self, text: str = ""): @@ -209,20 +209,16 @@ def __init__(self, text: str = ""): self.setReadOnly(True) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.document().setTextWidth(self.MAX_TEXT_WIDTH) + self.height = self.HEIGHT_BASE self.setPlainText(text) def setPlainText(self, text): super().setPlainText(text) + total_line_count = 0 for block_num in range(0, self.blockCount()): block = self.document().findBlockByNumber(block_num) - line_count = 0 - line_count = line_count + math.ceil(block.length() / self.document().idealWidth()) - text_layout = block.layout() - for line_num in range(0, text_layout.lineCount()): - line = text_layout.lineAt(line_num) - line_wrap_count = math.floor(line.naturalTextWidth() / self.MAX_NATURAL_TEXT_WIDTH) - line_count = line_count + line_wrap_count + line_count = math.ceil(block.length() / self.document().idealWidth()) total_line_count = total_line_count + line_count self.height = self.HEIGHT_BASE + (total_line_count * self.LINE_HEIGHT) From c1bb6f75ab265be114b4e41ff9e86b8b9ac1f82a Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Mon, 13 Apr 2020 14:20:49 -0700 Subject: [PATCH 06/17] center message text --- securedrop_client/gui/widgets.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index f88742c18..9ad4f9124 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -1899,7 +1899,7 @@ class SpeechBubble(QWidget): font-weight: 400; font-size: 15px; background-color: #fff; - padding: 16px; + padding: 24px; border: none; } #color_bar { @@ -1996,7 +1996,7 @@ class ReplyWidget(SpeechBubble): font-size: 15px; background-color: #fff; color: #3b3b3b; - padding: 16px; + padding: 24px; ''' CSS_COLOR_BAR_REPLY_FAILED = ''' @@ -2019,7 +2019,7 @@ class ReplyWidget(SpeechBubble): font-size: 15px; background-color: #fff; color: #3b3b3b; - padding: 16px; + padding: 24px; ''' CSS_COLOR_BAR_REPLY_SUCCEEDED = ''' @@ -2035,7 +2035,7 @@ class ReplyWidget(SpeechBubble): font-size: 15px; color: #A9AAAD; background-color: #F7F8FC; - padding: 16px; + padding: 24px; ''' CSS_COLOR_BAR_REPLY_PENDING = ''' From 974189d7c9daf4a181a7ba652faa450b6fce0f6a Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Mon, 13 Apr 2020 14:29:09 -0700 Subject: [PATCH 07/17] update tests for updating speech bubble text --- securedrop_client/gui/__init__.py | 4 ++-- tests/gui/test_widgets.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/securedrop_client/gui/__init__.py b/securedrop_client/gui/__init__.py index cfea1bd2c..8411de70c 100644 --- a/securedrop_client/gui/__init__.py +++ b/securedrop_client/gui/__init__.py @@ -204,7 +204,7 @@ class SecureQPlainTextEdit(QPlainTextEdit): HEIGHT_BASE = 60 LINE_HEIGHT = 20 - def __init__(self, text: str = ""): + def __init__(self, text: str = '') -> None: super().__init__() self.setReadOnly(True) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) @@ -212,7 +212,7 @@ def __init__(self, text: str = ""): self.height = self.HEIGHT_BASE self.setPlainText(text) - def setPlainText(self, text): + def setPlainText(self, text: str) -> None: super().setPlainText(text) total_line_count = 0 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', 'hello', mock_signal, 0) - assert bubble.message.text() == 'hello' + assert bubble.message.toPlainText() == 'hello' 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): From d9702a37eecc7cdea3f5c4af5e5fdf2ef3420ac9 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Mon, 13 Apr 2020 15:00:38 -0700 Subject: [PATCH 08/17] update functional tests to get text using toPlainText --- tests/functional/test_download_file.py | 2 +- tests/functional/test_export_dialog.py | 2 +- tests/functional/test_receive_message.py | 2 +- tests/functional/test_send_reply.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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 From e2a4e2690e17f2fba49dbb39d4e75ec15c05d0c1 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Thu, 16 Apr 2020 18:23:21 -0400 Subject: [PATCH 09/17] app: QPlainTextEdit single selection to prevent user confusion --- securedrop_client/gui/__init__.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/securedrop_client/gui/__init__.py b/securedrop_client/gui/__init__.py index 8411de70c..6fe2a5644 100644 --- a/securedrop_client/gui/__init__.py +++ b/securedrop_client/gui/__init__.py @@ -223,3 +223,13 @@ def setPlainText(self, text: str) -> None: self.height = self.HEIGHT_BASE + (total_line_count * self.LINE_HEIGHT) self.setFixedHeight(self.height) + + def mouseReleaseEvent(self, event): + self.setFocus() + super().mouseReleaseEvent(event) + + def focusOutEvent(self, event): + clear_text_cursor = self.textCursor() + clear_text_cursor.clearSelection() + self.setTextCursor(clear_text_cursor) + super().focusOutEvent(event) From f837b3adef5115f903948ecee0a5444a0ff6a657 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Thu, 16 Apr 2020 18:51:38 -0400 Subject: [PATCH 10/17] disable SecureQPlainTextEdit context menu --- securedrop_client/gui/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/securedrop_client/gui/__init__.py b/securedrop_client/gui/__init__.py index 6fe2a5644..32a0a0e45 100644 --- a/securedrop_client/gui/__init__.py +++ b/securedrop_client/gui/__init__.py @@ -212,6 +212,9 @@ def __init__(self, text: str = '') -> None: self.height = self.HEIGHT_BASE self.setPlainText(text) + # Disable copy/paste context menu + self.setContextMenuPolicy(Qt.NoContextMenu) + def setPlainText(self, text: str) -> None: super().setPlainText(text) From c91433c34c6f529135cc8ec2732366dc1a76c915 Mon Sep 17 00:00:00 2001 From: Allie Crevier Date: Mon, 20 Apr 2020 16:10:27 -0700 Subject: [PATCH 11/17] use pixels instead of chars to resize --- securedrop_client/gui/__init__.py | 39 ++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/securedrop_client/gui/__init__.py b/securedrop_client/gui/__init__.py index 32a0a0e45..c672efb51 100644 --- a/securedrop_client/gui/__init__.py +++ b/securedrop_client/gui/__init__.py @@ -21,6 +21,7 @@ from PyQt5.QtWidgets import QLabel, QHBoxLayout, QPlainTextEdit, QPushButton, QWidget from PyQt5.QtCore import QSize, Qt +from PyQt5.QtGui import QFont from securedrop_client.resources import load_svg, load_icon @@ -199,32 +200,48 @@ def is_elided(self) -> bool: class SecureQPlainTextEdit(QPlainTextEdit): - MAX_TEXT_WIDTH = 75 - MAX_NATURAL_TEXT_WIDTH = 650 + ''' + 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 def __init__(self, text: str = '') -> None: super().__init__() - self.setReadOnly(True) + self.setReadOnly(True) # Do not allow SecureQPlainTextEdit to be editable + self.setContextMenuPolicy(Qt.NoContextMenu) # Disable copy/paste context menu self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.document().setTextWidth(self.MAX_TEXT_WIDTH) + self.setFont(QFont(self.FONT_NAME, self.FONT_SIZE)) self.height = self.HEIGHT_BASE self.setPlainText(text) - # Disable copy/paste context menu - self.setContextMenuPolicy(Qt.NoContextMenu) - def setPlainText(self, text: str) -> None: super().setPlainText(text) - total_line_count = 0 + # Resize widget height to fit text + document_lines = 0 + fm = self.fontMetrics() + max_line_width = self.size().width() + 4 for block_num in range(0, self.blockCount()): block = self.document().findBlockByNumber(block_num) - line_count = math.ceil(block.length() / self.document().idealWidth()) - total_line_count = total_line_count + line_count + block_length = fm.horizontalAdvance(block.text()) + # Calculate the number of lines in the block. If the block_length is zero because it is + # a blank line, then the line count should be 1 . + block_lines = max(math.ceil(block_length / max_line_width), 1) + document_lines = document_lines + block_lines - self.height = self.HEIGHT_BASE + (total_line_count * self.LINE_HEIGHT) + self.height = self.HEIGHT_BASE + (document_lines * self.LINE_HEIGHT) self.setFixedHeight(self.height) def mouseReleaseEvent(self, event): From 52248e71a86194ba2e033dd666ad933b573fb54f Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Tue, 21 Apr 2020 11:23:10 -0400 Subject: [PATCH 12/17] app: use document margin pixel width in computing max line width --- securedrop_client/gui/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/securedrop_client/gui/__init__.py b/securedrop_client/gui/__init__.py index c672efb51..08b7cb2f9 100644 --- a/securedrop_client/gui/__init__.py +++ b/securedrop_client/gui/__init__.py @@ -232,7 +232,7 @@ def setPlainText(self, text: str) -> None: # Resize widget height to fit text document_lines = 0 fm = self.fontMetrics() - max_line_width = self.size().width() + 4 + max_line_width = self.size().width() + self.document().documentMargin() for block_num in range(0, self.blockCount()): block = self.document().findBlockByNumber(block_num) block_length = fm.horizontalAdvance(block.text()) From f4202b34525543d000fd1782709d5af3cf693b28 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Tue, 21 Apr 2020 11:29:21 -0400 Subject: [PATCH 13/17] app: reduce padding now that scroll bar does not appear --- securedrop_client/gui/widgets.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 9ad4f9124..f88742c18 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -1899,7 +1899,7 @@ class SpeechBubble(QWidget): font-weight: 400; font-size: 15px; background-color: #fff; - padding: 24px; + padding: 16px; border: none; } #color_bar { @@ -1996,7 +1996,7 @@ class ReplyWidget(SpeechBubble): font-size: 15px; background-color: #fff; color: #3b3b3b; - padding: 24px; + padding: 16px; ''' CSS_COLOR_BAR_REPLY_FAILED = ''' @@ -2019,7 +2019,7 @@ class ReplyWidget(SpeechBubble): font-size: 15px; background-color: #fff; color: #3b3b3b; - padding: 24px; + padding: 16px; ''' CSS_COLOR_BAR_REPLY_SUCCEEDED = ''' @@ -2035,7 +2035,7 @@ class ReplyWidget(SpeechBubble): font-size: 15px; color: #A9AAAD; background-color: #F7F8FC; - padding: 24px; + padding: 16px; ''' CSS_COLOR_BAR_REPLY_PENDING = ''' From 1b5603407080e9606da55dc8c2045f5495636faa Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Tue, 21 Apr 2020 15:56:23 -0400 Subject: [PATCH 14/17] test: add coverage for SecureQPlainTextEdit --- tests/gui/test_init.py | 50 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) 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 = '' + 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 From 3f8db24e74208c852c1f14e5462699f1c4ee2dc2 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Tue, 21 Apr 2020 15:56:50 -0400 Subject: [PATCH 15/17] type annotations: event handlers on SecureQPlainTextEdit --- securedrop_client/gui/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/securedrop_client/gui/__init__.py b/securedrop_client/gui/__init__.py index 08b7cb2f9..7feb22ab0 100644 --- a/securedrop_client/gui/__init__.py +++ b/securedrop_client/gui/__init__.py @@ -21,7 +21,7 @@ from PyQt5.QtWidgets import QLabel, QHBoxLayout, QPlainTextEdit, QPushButton, QWidget from PyQt5.QtCore import QSize, Qt -from PyQt5.QtGui import QFont +from PyQt5.QtGui import QFocusEvent, QFont, QMouseEvent from securedrop_client.resources import load_svg, load_icon @@ -244,11 +244,11 @@ def setPlainText(self, text: str) -> None: self.height = self.HEIGHT_BASE + (document_lines * self.LINE_HEIGHT) self.setFixedHeight(self.height) - def mouseReleaseEvent(self, event): + def mouseReleaseEvent(self, event: QMouseEvent) -> None: self.setFocus() super().mouseReleaseEvent(event) - def focusOutEvent(self, event): + def focusOutEvent(self, event: QFocusEvent) -> None: clear_text_cursor = self.textCursor() clear_text_cursor.clearSelection() self.setTextCursor(clear_text_cursor) From f93944a366adca79534c68f2a1c3c2144b675095 Mon Sep 17 00:00:00 2001 From: redshiftzero Date: Tue, 21 Apr 2020 18:17:48 -0400 Subject: [PATCH 16/17] Revert "app: reduce padding now that scroll bar does not appear" This reverts commit f4202b34525543d000fd1782709d5af3cf693b28. --- securedrop_client/gui/widgets.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index f88742c18..9ad4f9124 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -1899,7 +1899,7 @@ class SpeechBubble(QWidget): font-weight: 400; font-size: 15px; background-color: #fff; - padding: 16px; + padding: 24px; border: none; } #color_bar { @@ -1996,7 +1996,7 @@ class ReplyWidget(SpeechBubble): font-size: 15px; background-color: #fff; color: #3b3b3b; - padding: 16px; + padding: 24px; ''' CSS_COLOR_BAR_REPLY_FAILED = ''' @@ -2019,7 +2019,7 @@ class ReplyWidget(SpeechBubble): font-size: 15px; background-color: #fff; color: #3b3b3b; - padding: 16px; + padding: 24px; ''' CSS_COLOR_BAR_REPLY_SUCCEEDED = ''' @@ -2035,7 +2035,7 @@ class ReplyWidget(SpeechBubble): font-size: 15px; color: #A9AAAD; background-color: #F7F8FC; - padding: 16px; + padding: 24px; ''' CSS_COLOR_BAR_REPLY_PENDING = ''' From 5bf3f0a4097272ee963970d0c7eccc06e7a00cb8 Mon Sep 17 00:00:00 2001 From: Kushal Das Date: Mon, 27 Apr 2020 21:39:47 +0530 Subject: [PATCH 17/17] Working word wrapping, needs more work --- securedrop_client/gui/__init__.py | 80 +++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 21 deletions(-) diff --git a/securedrop_client/gui/__init__.py b/securedrop_client/gui/__init__.py index 7feb22ab0..a29659160 100644 --- a/securedrop_client/gui/__init__.py +++ b/securedrop_client/gui/__init__.py @@ -18,8 +18,11 @@ """ from typing import Union import math +import html +import sys -from PyQt5.QtWidgets import QLabel, QHBoxLayout, QPlainTextEdit, QPushButton, QWidget +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 @@ -199,7 +202,7 @@ def is_elided(self) -> bool: return self.elided -class SecureQPlainTextEdit(QPlainTextEdit): +class SecureQPlainTextEdit(QWebEngineView): ''' This is a resizeable widget used to display read-only plain text content. @@ -216,33 +219,68 @@ class SecureQPlainTextEdit(QPlainTextEdit): LINE_HEIGHT = 20 FONT_NAME = 'Source Sans Pro' FONT_SIZE = 15 + TEMPLATE_START = """ + + + + + +
+""" + TEMPLATE_END = """ +
+ +""" def __init__(self, text: str = '') -> None: super().__init__() - self.setReadOnly(True) # Do not allow SecureQPlainTextEdit to be editable + #self.setReadOnly(True) # Do not allow SecureQPlainTextEdit to be editable self.setContextMenuPolicy(Qt.NoContextMenu) # Disable copy/paste context menu - self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + #self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setFont(QFont(self.FONT_NAME, self.FONT_SIZE)) - self.height = self.HEIGHT_BASE + #self.height = self.HEIGHT_BASE + self.iText = "" + self.resize(580, 100) self.setPlainText(text) - def setPlainText(self, text: str) -> None: - super().setPlainText(text) + def setText(self, text): + self.setPlainText(text) - # Resize widget height to fit text - document_lines = 0 - fm = self.fontMetrics() - max_line_width = self.size().width() + self.document().documentMargin() - for block_num in range(0, self.blockCount()): - block = self.document().findBlockByNumber(block_num) - block_length = fm.horizontalAdvance(block.text()) - # Calculate the number of lines in the block. If the block_length is zero because it is - # a blank line, then the line count should be 1 . - block_lines = max(math.ceil(block_length / max_line_width), 1) - document_lines = document_lines + block_lines - - self.height = self.HEIGHT_BASE + (document_lines * self.LINE_HEIGHT) - self.setFixedHeight(self.height) + 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()