diff --git a/securedrop_client/gui/main.py b/securedrop_client/gui/main.py index 877da095c8..ae08e2c867 100644 --- a/securedrop_client/gui/main.py +++ b/securedrop_client/gui/main.py @@ -20,13 +20,13 @@ along with this program. If not, see . """ import logging -from PyQt5.QtWidgets import QMainWindow, QWidget, QVBoxLayout, QDesktopWidget, QStatusBar +from PyQt5.QtWidgets import QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QDesktopWidget, \ + QStatusBar from typing import List from securedrop_client import __version__ from securedrop_client.db import Source -from securedrop_client.gui.widgets import (ToolBar, MainView, LoginDialog, - SourceConversationWrapper) +from securedrop_client.gui.widgets import ToolBar, MainView, LoginDialog, SourceConversationWrapper from securedrop_client.resources import load_icon logger = logging.getLogger(__name__) @@ -57,18 +57,29 @@ def __init__(self, sdc_home: str): self.setWindowTitle(_("SecureDrop Client {}").format(__version__)) self.setWindowIcon(load_icon(self.icon)) + self.central_widget = QWidget() + central_widget_layout = QVBoxLayout() + central_widget_layout.setContentsMargins(0, 0, 0, 0) + self.central_widget.setLayout(central_widget_layout) + self.setCentralWidget(self.central_widget) + + self.status_bar = QStatusBar(self) + self.status_bar.setStyleSheet('background-color: #fff;') + central_widget_layout.addWidget(self.status_bar) + self.widget = QWidget() - widget_layout = QVBoxLayout() + widget_layout = QHBoxLayout() + widget_layout.setContentsMargins(0, 0, 0, 0) self.widget.setLayout(widget_layout) - self.setCentralWidget(self.widget) self.tool_bar = ToolBar(self.widget) - self.main_view = MainView(self.widget) self.main_view.source_list.itemSelectionChanged.connect(self.on_source_changed) widget_layout.addWidget(self.tool_bar, 1) - widget_layout.addWidget(self.main_view, 6) + widget_layout.addWidget(self.main_view, 8) + + central_widget_layout.addWidget(self.widget) # Cache a dict of source.uuid -> SourceConversationWrapper # We do this to not create/destroy widgets constantly (because it causes UI "flicker") @@ -88,9 +99,7 @@ def setup(self, controller): self.controller = controller # Reference the Client logic instance. self.tool_bar.setup(self, controller) - self.status_bar = QStatusBar(self) - self.setStatusBar(self.status_bar) - self.set_status('Started SecureDrop Client. Please sign in.', 20000) + self.set_status(_('Started SecureDrop Client. Please sign in.'), 20000) self.login_dialog = LoginDialog(self) self.main_view.setup(self.controller) @@ -144,10 +153,9 @@ def show_sync(self, updated_on): Display a message indicating the data-sync state. """ if updated_on: - self.main_view.status.setText('Last refresh: ' + - updated_on.humanize()) + self.set_status(_('Last refresh: ' + updated_on.humanize())) else: - self.main_view.status.setText(_('Waiting to refresh...')) + self.set_status(_('Waiting to refresh...'), 5000) def set_logged_in_as(self, username): """ @@ -188,9 +196,9 @@ def show_conversation_for(self, source: Source, is_authenticated: bool): self.main_view.set_conversation(conversation_container) - def set_status(self, message, duration=5000): + def set_status(self, message, duration=0): """ Display a status message to the user. Optionally, supply a duration - (in milliseconds), the default value being a duration of 5 seconds. + (in milliseconds), the default will continuously show the message. """ self.status_bar.showMessage(message, duration) diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 56833dbb4c..d2327cd649 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -22,8 +22,8 @@ from PyQt5.QtCore import Qt, pyqtSlot from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QListWidget, QLabel, QWidget, QListWidgetItem, QHBoxLayout, \ - QPushButton, QVBoxLayout, QLineEdit, QScrollArea, QDialog, QAction, QMenu, \ - QMessageBox, QToolButton, QSizePolicy, QTextEdit + QPushButton, QVBoxLayout, QLineEdit, QScrollArea, QDialog, QAction, QMenu, QMessageBox, \ + QToolButton, QSizePolicy, QTextEdit from typing import List from uuid import uuid4 @@ -43,29 +43,44 @@ class ToolBar(QWidget): def __init__(self, parent: QWidget): super().__init__(parent) - layout = QHBoxLayout(self) - self.logo = QLabel() - self.logo.setPixmap(load_image('header_logo.png')) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) - self.user_state = QLabel(_("Signed out.")) + self.user_state = QLabel(_('Signed out.')) self.login = QPushButton(_('Sign in')) + self.login.setMaximumSize(80, 30) self.login.clicked.connect(self.on_login_clicked) self.logout = QPushButton(_('Sign out')) self.logout.clicked.connect(self.on_logout_clicked) + self.logout.setMaximumSize(80, 30) self.logout.setVisible(False) - self.refresh = QPushButton(_('Refresh')) + self.refresh = QPushButton() self.refresh.clicked.connect(self.on_refresh_clicked) + self.refresh.setMaximumSize(30, 30) + refresh_pixmap = load_image('refresh.png') + refresh_icon = QIcon(refresh_pixmap) + self.refresh.setIcon(refresh_icon) + self.refresh.setIconSize(refresh_pixmap.rect().size()) self.refresh.setVisible(False) + self.logo = QLabel() + self.logo.setStyleSheet('background-color: #CCC;') + self.logo.setPixmap(load_image('branding.png')) + self.logo.setMinimumSize(200, 200) + + journalist_layout = QHBoxLayout(self) + journalist_layout.addWidget(self.refresh, 1) + journalist_layout.addWidget(self.user_state, 5) + journalist_layout.addWidget(self.login, 5) + journalist_layout.addWidget(self.logout, 5) + journalist_layout.addStretch() + + layout.addLayout(journalist_layout) layout.addWidget(self.logo) layout.addStretch() - layout.addWidget(self.user_state) - layout.addWidget(self.login) - layout.addWidget(self.logout) - layout.addWidget(self.refresh) def setup(self, window, controller): """ @@ -84,7 +99,7 @@ def set_logged_in_as(self, username): """ Update the UI to reflect that the user is logged in as "username". """ - self.user_state.setText(_('Signed in as: ' + html.escape(username))) + self.user_state.setText(_(html.escape(username))) self.login.setVisible(False) self.logout.setVisible(True) self.refresh.setVisible(True) @@ -132,27 +147,29 @@ class MainView(QWidget): def __init__(self, parent): super().__init__(parent) self.layout = QHBoxLayout(self) + self.layout.setContentsMargins(0, 0, 0, 0) self.setLayout(self.layout) left_column = QWidget(parent=self) left_layout = QVBoxLayout() + left_layout.setContentsMargins(0, 0, 0, 0) left_column.setLayout(left_layout) - self.status = QLabel(_('Waiting to refresh...')) self.error_status = QLabel('') self.error_status.setObjectName('error_label') - - left_layout.addWidget(self.status) - left_layout.addWidget(self.error_status) + # TODO: move this to the main window QStatusBar + # left_layout.addWidget(self.error_status) self.source_list = SourceList(left_column) left_layout.addWidget(self.source_list) - self.layout.addWidget(left_column, 2) + self.layout.addWidget(left_column, 4) - self.view_holder = QWidget() self.view_layout = QVBoxLayout() + self.view_layout.setContentsMargins(0, 0, 0, 0) + self.view_holder = QWidget() self.view_holder.setLayout(self.view_layout) + self.layout.addWidget(self.view_holder, 6) def setup(self, controller): @@ -174,8 +191,8 @@ def set_conversation(self, widget): if old_widget: old_widget.widget().setVisible(False) - self.view_layout.addWidget(widget) widget.setVisible(True) + self.view_layout.addWidget(widget) class SourceList(QListWidget): @@ -372,11 +389,13 @@ def setup(self, controller): self.setWindowTitle(_('Sign in to SecureDrop')) main_layout = QHBoxLayout() + main_layout.setContentsMargins(0, 0, 0, 0) main_layout.addStretch() self.setLayout(main_layout) form = QWidget() layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) form.setLayout(layout) main_layout.addWidget(form) @@ -485,7 +504,7 @@ class SpeechBubble(QWidget): and journalist. """ - css = "padding: 10px; min-height:20px;border: 1px solid #999; border-radius: 18px;" + css = "padding:8px; min-height:32px; border:1px solid #999; border-radius:18px;" def __init__(self, message_id: str, text: str, update_signal) -> None: super().__init__() @@ -493,7 +512,6 @@ def __init__(self, message_id: str, text: str, update_signal) -> None: layout = QVBoxLayout() self.setLayout(layout) - self.message = QLabel(html.escape(text, quote=False)) self.message.setWordWrap(True) self.message.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) @@ -507,6 +525,7 @@ def _update_text(self, message_id: str, text: str) -> None: Conditionally update this SpeechBubble's text if and only if the message_id of the emitted signal matches the message_id of this speech bubble. """ + if message_id == self.message_id: self.message.setText(html.escape(text, quote=False)) @@ -528,12 +547,14 @@ def __init__(self, """ super().__init__() layout = QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + label = SpeechBubble(message_id, message, update_signal) if align != "left": # Float right... layout.addStretch(5) - label.setStyleSheet(label.css + 'border-bottom-right-radius: 0px;') + label.setStyleSheet(label.css) layout.addWidget(label, 6) @@ -542,10 +563,7 @@ def __init__(self, layout.addStretch(5) label.setStyleSheet(label.css + 'border-bottom-left-radius: 0px;') - layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(layout) - self.setContentsMargins(0, 0, 0, 0) class MessageWidget(ConversationWidget): @@ -678,6 +696,7 @@ def __init__(self, source_db_object: Source, sdc_home: str, controller: Client, self.container = QWidget() self.conversation_layout = QVBoxLayout() + self.conversation_layout.setContentsMargins(0, 0, 0, 0) self.container.setLayout(self.conversation_layout) self.container.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) @@ -693,9 +712,9 @@ def __init__(self, source_db_object: Source, sdc_home: str, controller: Client, sb.rangeChanged.connect(self.update_conversation_position) main_layout = QVBoxLayout() + main_layout.setContentsMargins(0, 0, 0, 0) main_layout.addWidget(self.scroll) self.setLayout(main_layout) - self.update_conversation(self.source.collection) def clear_conversation(self): @@ -788,14 +807,15 @@ def __init__( self.sdc_home = sdc_home self.layout = QVBoxLayout() + self.layout.setContentsMargins(0, 0, 0, 0) self.setLayout(self.layout) self.conversation = ConversationView(self.source, self.sdc_home, self.controller, parent=self) self.source_profile = SourceProfileShortWidget(self.source, self.controller) - self.layout.addWidget(self.source_profile) - self.layout.addWidget(self.conversation) + self.layout.addWidget(self.source_profile, 1) + self.layout.addWidget(self.conversation, 9) self.controller.authentication_state.connect(self._show_or_hide_replybox) self._show_or_hide_replybox(is_authenticated) @@ -816,7 +836,7 @@ def _show_or_hide_replybox(self, show: bool) -> None: old_widget.widget().deleteLater() self.reply_box = new_widget - self.layout.addWidget(new_widget) + self.layout.addWidget(new_widget, 3) class ReplyBoxWidget(QWidget): @@ -830,12 +850,20 @@ def __init__(self, conversation: SourceConversationWrapper) -> None: self.text_edit = QTextEdit() - self.send_button = QPushButton('Send') + self.send_button = QPushButton() self.send_button.clicked.connect(self.send_reply) + self.send_button.setMaximumSize(40, 40) - layout = QHBoxLayout() + button_pixmap = load_image('send.png') + button_icon = QIcon(button_pixmap) + self.send_button.setIcon(button_icon) + self.send_button.setIconSize(button_pixmap.rect().size()) + + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(self.text_edit) - layout.addWidget(self.send_button) + + layout.addWidget(self.send_button, 0, Qt.AlignRight) self.setLayout(layout) def send_reply(self) -> None: @@ -915,12 +943,19 @@ def __init__(self, source, controller): class TitleLabel(QLabel): - """Centered aligned, HTML heading level 3 label.""" + """The title for a conversation.""" def __init__(self, text): - html_text = "

%s

" % (text,) + html_text = '

{}

'.format(text) + super().__init__(_(html_text)) + + +class LastUpdatedLabel(QLabel): + """Time the conversation was last updated.""" + + def __init__(self, last_updated): + html_text = '

{}

'.format(arrow.get(last_updated).humanize()) super().__init__(_(html_text)) - self.setAlignment(Qt.AlignCenter) class SourceProfileShortWidget(QWidget): @@ -939,10 +974,10 @@ def __init__(self, source, controller): self.layout = QHBoxLayout() self.setLayout(self.layout) - widgets = ( - TitleLabel(self.source.journalist_designation), - SourceMenuButton(self.source, self.controller), - ) + self.title = TitleLabel(self.source.journalist_designation) + self.updated = LastUpdatedLabel(self.source.last_update) + self.menu = SourceMenuButton(self.source, self.controller) - for widget in widgets: - self.layout.addWidget(widget) + self.layout.addWidget(self.title, 10, Qt.AlignLeft) + self.layout.addWidget(self.updated, 1, Qt.AlignRight) + self.layout.addWidget(self.menu, 1, Qt.AlignRight) diff --git a/securedrop_client/resources/images/branding.png b/securedrop_client/resources/images/branding.png new file mode 100644 index 0000000000..5fa7ce482a Binary files /dev/null and b/securedrop_client/resources/images/branding.png differ diff --git a/securedrop_client/resources/images/delete.png b/securedrop_client/resources/images/delete.png new file mode 100644 index 0000000000..6c9656c98a Binary files /dev/null and b/securedrop_client/resources/images/delete.png differ diff --git a/securedrop_client/resources/images/refresh.png b/securedrop_client/resources/images/refresh.png new file mode 100644 index 0000000000..65a66adeea Binary files /dev/null and b/securedrop_client/resources/images/refresh.png differ diff --git a/securedrop_client/resources/images/send.png b/securedrop_client/resources/images/send.png new file mode 100644 index 0000000000..1fe8922a1e Binary files /dev/null and b/securedrop_client/resources/images/send.png differ diff --git a/securedrop_client/resources/images/trash.png b/securedrop_client/resources/images/trash.png new file mode 100644 index 0000000000..3bd54f6e22 Binary files /dev/null and b/securedrop_client/resources/images/trash.png differ diff --git a/tests/gui/test_main.py b/tests/gui/test_main.py index c414afbfba..c6ae35a343 100644 --- a/tests/gui/test_main.py +++ b/tests/gui/test_main.py @@ -1,7 +1,7 @@ """ Check the core Window UI class works as expected. """ -from PyQt5.QtWidgets import QApplication, QVBoxLayout +from PyQt5.QtWidgets import QApplication, QHBoxLayout from securedrop_client.gui.main import Window from securedrop_client.resources import load_icon from securedrop_client.db import Message @@ -16,13 +16,13 @@ def test_init(mocker): Ensure the Window instance is setup in the expected manner. """ mock_li = mocker.MagicMock(return_value=load_icon('icon.png')) - mock_lo = mocker.MagicMock(return_value=QVBoxLayout()) + mock_lo = mocker.MagicMock(return_value=QHBoxLayout()) mock_lo().addWidget = mocker.MagicMock() mocker.patch('securedrop_client.gui.main.load_icon', mock_li) mock_tb = mocker.patch('securedrop_client.gui.main.ToolBar') mock_mv = mocker.patch('securedrop_client.gui.main.MainView') - mocker.patch('securedrop_client.gui.main.QVBoxLayout', mock_lo) + mocker.patch('securedrop_client.gui.main.QHBoxLayout', mock_lo) mocker.patch('securedrop_client.gui.main.QMainWindow') w = Window('mock') @@ -126,13 +126,13 @@ def test_update_error_status(mocker): def test_show_sync(mocker): """ - If there's a value display the result of its "humanize" method. + If there's a value display the result of its "humanize" method.humanize """ w = Window('mock') - w.main_view = mocker.MagicMock() + w.set_status = mocker.MagicMock() updated_on = mocker.MagicMock() w.show_sync(updated_on) - w.main_view.status.setText.assert_called_once_with('Last refresh: ' + updated_on.humanize()) + w.set_status.assert_called_once_with('Last refresh: ' + updated_on.humanize()) def test_show_sync_no_sync(mocker): @@ -140,9 +140,9 @@ def test_show_sync_no_sync(mocker): If there's no value to display, default to a "waiting" message. """ w = Window('mock') - w.main_view = mocker.MagicMock() + w.set_status = mocker.MagicMock() w.show_sync(None) - w.main_view.status.setText.assert_called_once_with('Waiting to refresh...') + w.set_status.assert_called_once_with('Waiting to refresh...', 5000) def test_set_logged_in_as(mocker): @@ -255,7 +255,7 @@ def test_conversation_pending_message(mocker): mock_source.collection = [message] mocked_add_message = mocker.patch('securedrop_client.gui.widgets.ConversationView.add_message') - mocker.patch('securedrop_client.gui.main.QVBoxLayout') + mocker.patch('securedrop_client.gui.main.QHBoxLayout') mocker.patch('securedrop_client.gui.main.QWidget') w.show_conversation_for(mock_source, True) diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index 87ffa1b349..66c23da106 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -2,7 +2,7 @@ Make sure the UI widgets are configured correctly and work as expected. """ from PyQt5.QtWidgets import QWidget, QApplication, QWidgetItem, QSpacerItem, QVBoxLayout, \ - QMessageBox + QMessageBox, QLabel from tests import factory from securedrop_client import db from securedrop_client import logic @@ -52,7 +52,7 @@ def test_ToolBar_set_logged_in_as(mocker): tb.set_logged_in_as('test') - tb.user_state.setText.assert_called_once_with('Signed in as: test') + tb.user_state.setText.assert_called_once_with('test') tb.login.setVisible.assert_called_once_with(False) tb.logout.setVisible.assert_called_once_with(True) tb.refresh.setVisible.assert_called_once_with(True) @@ -1068,6 +1068,7 @@ def test_SourceConversationWrapper_send_reply(mocker): mock_uuid = '456xyz' mocker.patch('securedrop_client.gui.widgets.uuid4', return_value=mock_uuid) mock_controller = mocker.MagicMock() + mocker.patch('securedrop_client.gui.widgets.LastUpdatedLabel', return_value=QLabel('now')) cw = SourceConversationWrapper(mock_source, 'mock home', mock_controller, True) mock_add_reply = mocker.Mock() @@ -1211,6 +1212,8 @@ def test_SourceConversationWrapper_auth_signals(mocker, homedir): mock_is_auth = mocker.MagicMock() mock_sh = mocker.patch.object(SourceConversationWrapper, '_show_or_hide_replybox') + mocker.patch('securedrop_client.gui.widgets.LastUpdatedLabel', return_value=QLabel('now')) + SourceConversationWrapper(mock_source, 'mock home', mock_controller, mock_is_auth) mock_connect.assert_called_once_with(mock_sh) @@ -1224,9 +1227,9 @@ def test_SourceConversationWrapper_set_widgets_via_auth_value(mocker, homedir): mock_source = mocker.Mock(collection=[]) mock_controller = mocker.MagicMock() + mocker.patch('securedrop_client.gui.widgets.LastUpdatedLabel', return_value=QLabel('now')) cw = SourceConversationWrapper(mock_source, 'mock home', mock_controller, True) mocker.patch.object(cw, 'layout') - mock_reply_box = mocker.patch('securedrop_client.gui.widgets.ReplyBoxWidget', return_value=QWidget()) mock_label = mocker.patch('securedrop_client.gui.widgets.QLabel', return_value=QWidget())