diff --git a/securedrop_client/gui/__init__.py b/securedrop_client/gui/__init__.py
index e69de29bb..92808d1dc 100644
--- a/securedrop_client/gui/__init__.py
+++ b/securedrop_client/gui/__init__.py
@@ -0,0 +1,87 @@
+"""
+Generic custom widgets.
+
+Copyright (C) 2018 The Freedom of the Press Foundation.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published
+by the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+"""
+
+from PyQt5.QtWidgets import QLabel, QHBoxLayout, QPushButton
+from PyQt5.QtCore import QSize
+
+from securedrop_client.resources import load_svg, load_icon
+
+
+class SvgPushButton(QPushButton):
+ """
+ A widget used to display the contents of Scalable Vector Graphics (SVG) files provided for
+ associated user action modes, see https://doc.qt.io/qt-5/qicon.html#Mode-enum.
+
+
+ Parameters
+ ----------
+ normal: str
+ The name of the SVG file to add to the button for QIcon.Normal mode.
+ disabled: str, optional
+ The name of the SVG file to add to the button for QIcon.Disabled mode.
+ active: str, optional
+ The name of the SVG file to add to the button for QIcon.Active mode.
+ selected: str, optional
+ The name of the SVG file to add to the button for QIcon.Selected mode.
+ svg_size: QSize, optional
+ The display size of the SVG, defaults to filling the entire size of the widget.
+ """
+
+ def __init__(self, normal: str, disabled=None, active=None, selected=None, svg_size=None):
+ super().__init__()
+
+ # Set layout
+ layout = QHBoxLayout(self)
+ self.setLayout(layout)
+
+ # Remove margins and spacing
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ # Add SVG icon and set its size
+ self.icon = load_icon(normal=normal, disabled=disabled, active=active, selected=selected)
+ self.setIcon(self.icon)
+ self.setIconSize(svg_size) if svg_size else self.setIconSize(QSize())
+
+
+class SvgLabel(QLabel):
+ """
+ A widget used to display the contents of a Scalable Vector Graphics (SVG) file.
+
+ Parameters
+ ----------
+ filename: str
+ The name of the SVG file to add to the label.
+ svg_size: QSize, optional
+ The display size of the SVG, defaults to filling the entire size of the widget.
+ """
+
+ def __init__(self, filename: str, svg_size=None):
+ super().__init__()
+
+ # Remove margins and spacing
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+ self.setLayout(layout)
+
+ # Add SVG and set its size
+ self.svg = load_svg(filename)
+ self.svg.setFixedSize(svg_size) if svg_size else self.svg.setFixedSize(QSize())
+ layout.addWidget(self.svg)
diff --git a/securedrop_client/gui/main.py b/securedrop_client/gui/main.py
index c09a49fe3..4c1ecf6b9 100644
--- a/securedrop_client/gui/main.py
+++ b/securedrop_client/gui/main.py
@@ -25,7 +25,7 @@
from securedrop_client import __version__
from securedrop_client.db import Source
-from securedrop_client.gui.widgets import ToolBar, MainView, LoginDialog, StatusBar, \
+from securedrop_client.gui.widgets import TopPane, LeftPane, MainView, LoginDialog, \
SourceConversationWrapper
from securedrop_client.resources import load_icon
@@ -60,22 +60,24 @@ def __init__(self, sdc_home: str):
self.central_widget = QWidget()
central_widget_layout = QVBoxLayout()
central_widget_layout.setContentsMargins(0, 0, 0, 0)
+ central_widget_layout.setSpacing(0)
self.central_widget.setLayout(central_widget_layout)
self.setCentralWidget(self.central_widget)
- self.status_bar = StatusBar()
- central_widget_layout.addWidget(self.status_bar)
+ self.top_pane = TopPane()
+ central_widget_layout.addWidget(self.top_pane)
self.widget = QWidget()
widget_layout = QHBoxLayout()
widget_layout.setContentsMargins(0, 0, 0, 0)
+ widget_layout.setSpacing(0)
self.widget.setLayout(widget_layout)
- self.tool_bar = ToolBar(self.widget)
+ self.left_pane = LeftPane()
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.left_pane, 1)
widget_layout.addWidget(self.main_view, 8)
central_widget_layout.addWidget(self.widget)
@@ -96,9 +98,9 @@ def setup(self, controller):
views used in the UI.
"""
self.controller = controller # Reference the Client logic instance.
- self.tool_bar.setup(self, controller)
- self.status_bar.setup(controller)
- self.set_status(_('Started SecureDrop Client. Please sign in.'), 20000)
+ self.left_pane.setup(self, controller)
+ self.top_pane.setup(controller)
+ self.update_activity_status(_('Started SecureDrop Client. Please sign in.'), 20000)
self.login_dialog = LoginDialog(self)
self.main_view.setup(self.controller)
@@ -119,7 +121,6 @@ def show_login(self):
self.login_dialog.setup(self.controller)
self.login_dialog.reset()
self.login_dialog.exec()
- self.status_bar.show_refresh_icon()
def show_login_error(self, error):
"""
@@ -134,13 +135,6 @@ def hide_login(self):
"""
self.login_dialog.accept()
self.login_dialog = None
- self.status_bar.hide_refresh_icon()
-
- def update_error_status(self, error=None):
- """
- Show an error message on the sidebar.
- """
- self.main_view.update_error_status(error)
def show_sources(self, sources: List[Source]):
"""
@@ -154,22 +148,23 @@ def show_sync(self, updated_on):
Display a message indicating the data-sync state.
"""
if updated_on:
- self.set_status(_('Last refresh: {}').format(updated_on.humanize()))
+ self.update_activity_status(_('Last refresh: {}').format(updated_on.humanize()))
else:
- self.set_status(_('Waiting to refresh...'), 5000)
+ self.update_activity_status(_('Waiting to refresh...'), 5000)
def set_logged_in_as(self, username):
"""
Update the UI to show user logged in with username.
"""
- self.tool_bar.set_logged_in_as(username)
+ self.left_pane.set_logged_in_as(username)
+ self.top_pane.enable_refresh()
def logout(self):
"""
Update the UI to show the user is logged out.
"""
- self.tool_bar.set_logged_out()
- self.status_bar.hide_refresh_icon()
+ self.left_pane.set_logged_out()
+ self.top_pane.disable_refresh()
def on_source_changed(self):
"""
@@ -197,9 +192,22 @@ def show_conversation_for(self, source: Source, is_authenticated: bool):
self.main_view.set_conversation(conversation_container)
- def set_status(self, message, duration=0):
+ def update_activity_status(self, message: str, duration=0):
+ """
+ Display an activity status message to the user. Optionally, supply a duration
+ (in milliseconds), the default will continuously show the message.
+ """
+ self.top_pane.update_activity_status(message, duration)
+
+ def update_error_status(self, message: str, duration=10000):
"""
- Display a status message to the user. Optionally, supply a duration
+ Display an error status message to the user. Optionally, supply a duration
(in milliseconds), the default will continuously show the message.
"""
- self.status_bar.show_message(message, duration)
+ self.top_pane.update_error_status(message, duration)
+
+ def clear_error_status(self):
+ """
+ Clear any message currently in the error status bar.
+ """
+ self.top_pane.clear_error_status()
diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py
index be2ab8834..dc3390770 100644
--- a/securedrop_client/gui/widgets.py
+++ b/securedrop_client/gui/widgets.py
@@ -19,181 +19,553 @@
import logging
import arrow
import html
-from PyQt5.QtCore import Qt, pyqtSlot
-from PyQt5.QtGui import QIcon, QPalette, QBrush, QColor, QFont
+from PyQt5.QtCore import Qt, pyqtSlot, QTimer, QSize
+from PyQt5.QtGui import QIcon, QPalette, QBrush, QColor, QFont, QLinearGradient
from PyQt5.QtWidgets import QListWidget, QLabel, QWidget, QListWidgetItem, QHBoxLayout, \
QPushButton, QVBoxLayout, QLineEdit, QScrollArea, QDialog, QAction, QMenu, QMessageBox, \
- QToolButton, QSizePolicy, QTextEdit, QStatusBar
+ QToolButton, QSizePolicy, QTextEdit, QStatusBar, QGraphicsDropShadowEffect
from typing import List
from uuid import uuid4
from securedrop_client.db import Source, Message, File
+from securedrop_client.gui import SvgLabel, SvgPushButton
from securedrop_client.logic import Client
-from securedrop_client.resources import load_svg, load_image
+from securedrop_client.resources import load_svg, load_icon, load_image
from securedrop_client.utils import humanize_filesize
logger = logging.getLogger(__name__)
-class StatusBar(QStatusBar):
+class TopPane(QWidget):
+ """
+ Top pane of the app window.
+ """
+
def __init__(self):
super().__init__()
- self.setStyleSheet('''
- QStatusBar { background-color: #fff; }
- QStatusBar::item { border: none; }
- QPushButton { border: none; }
- ''')
+ # Fill the background with a gradient
+ palette = QPalette()
+ gradient = QLinearGradient(0, 0, 900, 0)
+ gradient.setColorAt(0, QColor('#0565d4'))
+ gradient.setColorAt(1, QColor('#002c55'))
+ palette.setBrush(QPalette.Background, QBrush(gradient))
+ self.setPalette(palette)
+ self.setAutoFillBackground(True)
+
+ # Set layout
+ layout = QHBoxLayout(self)
+ self.setLayout(layout)
+
+ # Remove margins and spacing
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ # Refresh button
+ self.refresh = RefreshButton()
+ self.refresh.disable()
+
+ # Activity status bar
+ self.activity_status_bar = ActivityStatusBar()
- self.refresh = QPushButton()
- self.refresh.clicked.connect(self.on_refresh_clicked)
- self.refresh.setMaximumSize(30, 30)
- refresh_pixmap = load_image('refresh.svg')
- self.refresh.setIcon(QIcon(refresh_pixmap))
- self.addPermanentWidget(self.refresh) # widget may not be obscured by temporary messages
+ # Error status bar
+ self.error_status_bar = ErrorStatusBar()
+
+ # Create space the size of the status bar to keep the error status bar centered
+ spacer = QWidget()
+
+ # Create space ths size of the refresh button to keep the error status bar centered
+ spacer2 = QWidget()
+ spacer2.setFixedWidth(42)
+
+ # Set height of top pane to 42 pixels
+ self.setFixedHeight(42)
+ self.refresh.setFixedHeight(42)
+ self.activity_status_bar.setFixedHeight(42)
+ self.error_status_bar.setFixedHeight(42)
+ spacer.setFixedHeight(42)
+ spacer2.setFixedHeight(42)
+
+ # Add widgets to layout
+ layout.addWidget(self.refresh, 1)
+ layout.addWidget(self.activity_status_bar, 1)
+ layout.addWidget(self.error_status_bar, 5)
+ layout.addWidget(spacer, 1)
+ layout.addWidget(spacer2, 1)
def setup(self, controller):
+ self.refresh.setup(controller)
+
+ def enable_refresh(self):
+ self.refresh.enable()
+
+ def disable_refresh(self):
+ self.refresh.disable()
+
+ def update_activity_status(self, message: str, duration: int):
+ self.activity_status_bar.update_message(message, duration)
+
+ def update_error_status(self, message: str, duration: int):
+ self.error_status_bar.update_message(message, duration)
+
+ def clear_error_status(self):
+ self.error_status_bar.clear_message()
+
+
+class LeftPane(QWidget):
+ """
+ Represents the left side pane that contains user authentication actions and information.
+ """
+
+ def __init__(self):
+ super().__init__()
+
+ # Set layout
+ layout = QVBoxLayout(self)
+ self.setLayout(layout)
+
+ # Remove margins and spacing
+ layout.setContentsMargins(12, 12, 12, 12)
+ layout.setSpacing(0)
+
+ # Use a background gradient
+ palette = QPalette()
+ gradient = QLinearGradient(0, 0, 0, 700)
+ gradient.setColorAt(0, QColor('#0093da'))
+ gradient.setColorAt(1, QColor('#0c3e75'))
+ palette.setBrush(QPalette.Background, QBrush(gradient))
+ self.setPalette(palette)
+ self.setAutoFillBackground(True)
+
+ # User profile
+ self.user_profile = UserProfile()
+
+ # Hide user profile widget until user logs in
+ self.user_profile.hide()
+
+ # Add widgets to layout
+ layout.addWidget(self.user_profile)
+
+ # Align content to the top of pane
+ layout.addStretch()
+
+ def setup(self, window, controller):
+ self.user_profile.setup(window, controller)
+
+ def set_logged_in_as(self, username):
"""
- Assign a controller object (containing the application logic).
+ Update the UI to reflect that the user is logged in as "username".
"""
- self.controller = controller
- self.controller.sync_events.connect(self._on_sync_event)
+ self.user_profile.set_username(username)
+ self.user_profile.show()
- def on_refresh_clicked(self):
+ def set_logged_out(self):
"""
- Called when the refresh button is clicked.
+ Update the UI to a logged out state.
"""
- self.controller.sync_api()
+ self.user_profile.hide()
+
- def _on_sync_event(self, data):
+class RefreshButton(SvgPushButton):
+ """
+ A button that shows an icon for different refresh states.
+ """
+
+ CSS = '''
+ #refresh_button {
+ border: none;
+ color: #fff;
+ }
+ '''
+
+ def __init__(self):
+ # Add svg images to button
+ super().__init__(
+ normal='refresh.svg',
+ disabled='refresh_offline.svg',
+ active='refresh_active.svg',
+ selected='refresh.svg',
+ svg_size=QSize(22, 22))
+
+ # Set css id
+ self.setObjectName('refresh_button')
+
+ # Set styles
+ self.setStyleSheet(self.CSS)
+ self.setFixedSize(QSize(42, 42))
+
+ # Click event handler
+ self.clicked.connect(self._on_clicked)
+
+ def setup(self, controller):
"""
- Called when the refresh call completes
+ Assign a controller object (containing the application logic).
"""
- self.refresh.setEnabled(data != 'syncing')
+ self.controller = controller
+ self.controller.sync_events.connect(self._on_refresh_complete)
- def show_message(self, message, duration=0):
+ def _on_clicked(self):
+ self.controller.sync_api()
+ # This is a temporary solution for showing the icon as active for the entire duration of a
+ # refresh, rather than for just the duration of a click. The icon image will be replaced
+ # when the controller tells us the refresh has finished. A cleaner solution would be to
+ # store and update our own icon mode so we don't have to reload any images.
+ self.setIcon(load_icon(
+ normal='refresh_active.svg',
+ disabled='refresh_offline.svg'))
+
+ def _on_refresh_complete(self, data):
+ if (data == 'synced'):
+ self.setIcon(load_icon(
+ normal='refresh.svg',
+ disabled='refresh_offline.svg',
+ active='refresh_active.svg',
+ selected='refresh.svg'))
+
+ def enable(self):
+ self.setEnabled(True)
+
+ def disable(self):
+ self.setEnabled(False)
+
+
+class ActivityStatusBar(QStatusBar):
+ """
+ A status bar for displaying messages about application activity to the user. Messages will be
+ displayed for a given duration or until the message updated with a new message.
+ """
+
+ CSS = '''
+ #activity_status_bar {
+ color: #fff;
+ }
+ '''
+
+ def __init__(self):
+ super().__init__()
+
+ # Set css id
+ self.setObjectName('activity_status_bar')
+
+ # Set styles
+ self.setStyleSheet(self.CSS)
+
+ # Remove grip image at bottom right-hand corner
+ self.setSizeGripEnabled(False)
+
+ def update_message(self, message: str, duration: int):
"""
- Display a status message to the user. Optionally, supply a duration
- (in milliseconds), the default will continuously show the message.
+ Display a status message to the user.
"""
self.showMessage(message, duration)
- def hide_refresh_icon(self):
+
+class ErrorStatusBar(QWidget):
+ """
+ A pop-up status bar for displaying messages about application errors to the user. Messages will
+ be displayed for a given duration or until the message is cleared or updated with a new message.
+ """
+
+ CSS = '''
+ #error_vertical_bar {
+ background-color: #f22b5d;
+ }
+ #error_icon {
+ background-color: qlineargradient(
+ x1: 0,
+ y1: 0,
+ x2: 0,
+ y2: 1,
+ stop: 0 #fff,
+ stop: 0.2 #fff,
+ stop: 1 #fff
+ );
+ }
+ #error_status_bar {
+ background-color: qlineargradient(
+ x1: 0,
+ y1: 0,
+ x2: 0,
+ y2: 1,
+ stop: 0 #fff,
+ stop: 0.2 #fff,
+ stop: 1 #fff
+ );
+ font-weight: bold;
+ color: #f22b5d;
+ }
+ '''
+
+ def __init__(self):
+ super().__init__()
+
+ # Set styles
+ self.setStyleSheet(self.CSS)
+
+ # Set layout
+ layout = QHBoxLayout(self)
+ self.setLayout(layout)
+
+ # Remove margins and spacing
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ # Error vertical bar
+ self.vertical_bar = QWidget()
+ self.vertical_bar.setObjectName('error_vertical_bar')
+ self.vertical_bar.setFixedWidth(10)
+
+ # Error icon
+ self.label = SvgLabel('error_icon.svg', svg_size=QSize(32, 32))
+ self.label.setObjectName('error_icon')
+ self.label.setFixedWidth(42)
+
+ # Error status bar
+ self.status_bar = QStatusBar()
+ self.status_bar.setObjectName('error_status_bar')
+ self.status_bar.setSizeGripEnabled(False)
+
+ # Add widgets to layout
+ layout.addWidget(self.vertical_bar)
+ layout.addWidget(self.label)
+ layout.addWidget(self.status_bar)
+
+ # Hide until a message needs to be displayed
+ self.vertical_bar.hide()
+ self.label.hide()
+ self.status_bar.hide()
+
+ # Only show errors for a set duration
+ self.status_timer = QTimer()
+ self.status_timer.timeout.connect(self._on_status_timeout)
+
+ def _hide(self):
+ self.vertical_bar.hide()
+ self.label.hide()
+ self.status_bar.hide()
+
+ def _show(self):
+ self.vertical_bar.show()
+ self.label.show()
+ self.status_bar.show()
+
+ def _on_status_timeout(self):
+ self._hide()
+
+ def update_message(self, message: str, duration: int):
"""
- Hide refresh icon.
+ Display a status message to the user for a given duration.
"""
- self.refresh.hide()
+ self.status_bar.showMessage(message, duration)
+ self.status_timer.start(duration)
+ self._show()
- def show_refresh_icon(self):
+ def clear_message(self):
"""
- Show refresh icon.
+ Clear any message currently in the status bar.
"""
- self.refresh.show()
+ self.status_bar.clearMessage()
+ self._hide()
-class ToolBar(QWidget):
+class UserProfile(QWidget):
"""
- Represents the tool bar across the top of the user interface.
+ A widget that contains user profile information and options.
+
+ Displays user profile icon, name, and menu options if the user is logged in. Displays a login
+ button if the user is logged out.
"""
- def __init__(self, parent: QWidget):
- super().__init__(parent)
+ CSS = '''
+ QLabel#user_icon {
+ border: none;
+ padding: 10px;
+ background-color: #b4fffa;
+ font-family: Open Sans;
+ font-size: 16px;
+ font-weight: bold;
+ color: #2a319d;
+ }
+ '''
- layout = QVBoxLayout(self)
- layout.setContentsMargins(20, 10, 20, 10)
+ def __init__(self):
+ super().__init__()
- self.setAutoFillBackground(True)
- palette = QPalette()
- palette.setBrush(QPalette.Background, QBrush(load_image('hexes.svg')))
- self.setPalette(palette)
+ # Set styles
+ self.setStyleSheet(self.CSS)
+ self.setFixedWidth(200)
+
+ # Set layout
+ layout = QHBoxLayout(self)
+ self.setLayout(layout)
+
+ # Remove margins
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ # Login button
+ self.login_button = LoginButton()
+
+ # User icon
self.user_icon = QLabel()
- self.user_icon.setFont(QFont("Helvetica [Cronyx]", 16, QFont.Bold))
- self.user_icon.hide()
- self.user_state = QLabel()
- self.user_state.setFont(QFont("Helvetica [Cronyx]", 12, QFont.Bold))
- self.user_state.hide()
- self.user_menu = JournalistMenuButton(self)
- self.user_menu.hide()
-
- self.login = QPushButton(_('SIGN IN'))
- self.login.setFont(QFont("Helvetica [Cronyx]", 10))
- self.login.setMinimumSize(200, 40)
- button_palette = self.login.palette()
- button_palette.setColor(QPalette.Button, QColor('#eee'))
- button_palette.setColor(QPalette.ButtonText, QColor('#000'))
- self.login.setAutoFillBackground(True)
- self.login.setPalette(button_palette)
- self.login.update()
- self.login.clicked.connect(self.on_login_clicked)
+ self.user_icon.setObjectName('user_icon')
- spacer = QWidget()
- spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
-
- logo = QLabel()
- logo.setMinimumSize(200, 200)
- logo.setPixmap(load_image('logo.png'))
-
- user_layout = QHBoxLayout()
- user_layout.addWidget(self.user_icon, 5, Qt.AlignLeft)
- user_layout.addWidget(self.user_state, 5, Qt.AlignLeft)
- user_layout.addWidget(self.login, 5, Qt.AlignLeft)
- user_layout.addWidget(self.user_menu, 5, Qt.AlignLeft)
- user_layout.addStretch()
-
- layout.addLayout(user_layout)
- layout.addWidget(spacer, 5)
- layout.addWidget(logo, 3, Qt.AlignCenter)
+ # User button
+ self.user_button = UserButton()
+
+ # Add widgets to user auth layout
+ layout.addWidget(self.login_button, 1)
+ layout.addWidget(self.user_icon, 1)
+ layout.addWidget(self.user_button, 4)
+
+ # Align content to the left
layout.addStretch()
def setup(self, window, controller):
- """
- Store a reference to the GUI window object (through which all wider GUI
- state is controlled).
+ self.user_button.setup(controller)
+ self.login_button.setup(window)
+
+ def set_username(self, username):
+ self.user_icon.setText(_('jo'))
+ self.user_button.set_username(username)
+
+ def show(self):
+ self.login_button.hide()
+ self.user_icon.show()
+ self.user_button.show()
+
+ def hide(self):
+ self.user_icon.hide()
+ self.user_button.hide()
+ self.login_button.show()
+
+
+class UserButton(SvgPushButton):
+ """An menu button for the journalist menu
+
+ This button is responsible for launching the journalist menu on click.
+ """
+
+ CSS = '''
+ SvgPushButton#user_button {
+ border: none;
+ padding-left: 6px;
+ background-color: #0093da;
+ font-family: Open Sans;
+ font-size: 14px;
+ font-weight: bold;
+ color: #b4fffa;
+ align: left;
+ text-align: left;
+ }
+ SvgPushButton::menu-indicator {
+ image: none;
+ }
+ '''
+
+ def __init__(self):
+ super().__init__('dropdown_arrow.svg', svg_size=QSize())
+
+ self.setStyleSheet(self.CSS)
+ self.setFixedHeight(40)
+
+ self.setObjectName('user_button')
- Assign a controller object (containing the application logic) to this
- instance of the toolbar.
+ self.setLayoutDirection(Qt.RightToLeft)
+
+ self.menu = UserMenu()
+ self.setMenu(self.menu)
+
+ def setup(self, controller):
+ self.menu.setup(controller)
+
+ def set_username(self, username):
+ self.setText(_('{}').format(html.escape(username)))
+
+
+class UserMenu(QMenu):
+ """A menu next to the journalist username.
+
+ A menu that provides login options.
+ """
+
+ def __init__(self):
+ super().__init__()
+ self.logout = QAction(_('SIGN OUT'))
+ self.logout.setFont(QFont("OpenSans", 10))
+ self.addAction(self.logout)
+ self.logout.triggered.connect(self._on_logout_triggered)
+
+ def setup(self, controller):
+ """
+ Store a reference to the controller (containing the application logic).
"""
- self.window = window
self.controller = controller
- def set_logged_in_as(self, username):
+ def _on_logout_triggered(self):
"""
- Update the UI to reflect that the user is logged in as "username".
+ Called when the logout button is selected from the menu.
"""
- self.login.hide()
+ self.controller.logout()
- self.user_icon.setText(_('jo'))
- self.user_icon.setStyleSheet('''
- QLabel { background-color: #045fb4; color: cyan; padding: 10; border: 1px solid gray; }
- ''')
- self.user_icon.show()
- self.user_state.setText(_('{}').format(html.escape(username)))
- self.user_state.show()
+class LoginButton(QPushButton):
+ """
+ A button that opens a login dialog when clicked.
+ """
- self.user_menu.show()
+ CSS = '''
+ #login {
+ border: none;
+ background-color: qlineargradient(
+ x1: 0,
+ y1: 0,
+ x2: 0,
+ y2: 1,
+ stop: 0 #b4fffa,
+ stop: 1 #05edfe
+ );
+ font-family: Open Sans;
+ font-size: 14px;
+ color: #2a319d;
+ }
+ #login:pressed {
+ background-color: #85f6fe;
+ }
+ '''
- def set_logged_out(self):
+ def __init__(self):
+ super().__init__(_('SIGN IN'))
+
+ # Set css id
+ self.setObjectName('login')
+
+ # Set styles
+ self.setStyleSheet(self.CSS)
+ self.setFixedHeight(40)
+
+ # Set drop shadow effect
+ effect = QGraphicsDropShadowEffect(self)
+ effect.setOffset(0, 1)
+ effect.setBlurRadius(8)
+ effect.setColor(QColor('#aa000000'))
+ self.setGraphicsEffect(effect)
+ self.update()
+
+ # Set click handler
+ self.clicked.connect(self._on_clicked)
+
+ def setup(self, window):
"""
- Update the UI to a logged out state.
+ Store a reference to the GUI window object.
"""
- self.login.show()
- self.user_icon.hide()
- self.user_state.hide()
- self.user_menu.hide()
+ self.window = window
- def on_login_clicked(self):
+ def _on_clicked(self):
"""
Called when the login button is clicked.
"""
self.window.show_login()
- def on_logout_clicked(self):
- """
- Called when the logout button is clicked.
- """
- self.controller.logout()
-
class MainView(QWidget):
"""
@@ -205,6 +577,7 @@ def __init__(self, parent):
super().__init__(parent)
self.layout = QHBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
+ self.layout.setSpacing(0)
self.setLayout(self.layout)
left_column = QWidget(parent=self)
@@ -215,16 +588,12 @@ def __init__(self, parent):
self.source_list = SourceList(left_column)
left_layout.addWidget(self.source_list)
- self.error_status = QLabel('')
- self.error_status.setObjectName('error_label')
- left_layout.addWidget(self.error_status)
- self.error_status.hide()
-
self.layout.addWidget(left_column, 4)
self.view_layout = QVBoxLayout()
self.view_layout.setContentsMargins(0, 0, 0, 0)
self.view_holder = QWidget()
+ self.view_holder.setStyleSheet('background: #fff;')
self.view_holder.setLayout(self.view_layout)
self.layout.addWidget(self.view_holder, 6)
@@ -236,9 +605,6 @@ def setup(self, controller):
self.controller = controller
self.source_list.setup(controller)
- def update_error_status(self, error=None):
- self.error_status.setText(html.escape(error))
-
def set_conversation(self, widget):
"""
Update the view holder to contain the referenced widget.
@@ -259,6 +625,7 @@ class SourceList(QListWidget):
def __init__(self, parent):
super().__init__(parent)
+ self.setStyleSheet('QListWidget::item:selected { background: #efeef7 }')
def setup(self, controller):
"""
@@ -351,14 +718,22 @@ def __init__(self, parent: QWidget, source: Source):
Set up the child widgets.
"""
super().__init__(parent)
+
+ self.setStyleSheet('''
+ QWidget#color_bar { background-color: #9211ff; }
+ ''')
+
self.source = source
self.name = QLabel()
+ self.name.setFont(QFont("Open Sans", 16))
self.updated = QLabel()
+ self.updated.setFont(QFont("Open Sans", 10))
layout = QVBoxLayout()
self.setLayout(layout)
self.summary = QWidget(self)
+ self.summary.setObjectName('summary')
self.summary_layout = QHBoxLayout()
self.summary.setLayout(self.summary_layout)
@@ -483,7 +858,7 @@ def setup(self, controller):
self.error_label = QLabel('')
self.error_label.setObjectName('error_label')
- self.error_label.setStyleSheet('color: red')
+ self.error_label.setStyleSheet('color: #f22b5d')
layout.addStretch()
layout.addWidget(self.title)
@@ -562,7 +937,7 @@ class SpeechBubble(QWidget):
and journalist.
"""
- css = "padding:8px; min-height:32px; border:1px solid #999; border-radius:18px;"
+ CSS = "padding:8px; min-height:32px; border:1px solid #999;"
def __init__(self, message_id: str, text: str, update_signal) -> None:
super().__init__()
@@ -612,14 +987,14 @@ def __init__(self,
if align != "left":
# Float right...
layout.addStretch(5)
- label.setStyleSheet(label.css)
+ label.setStyleSheet(label.CSS)
layout.addWidget(label, 6)
if align == "left":
# Add space on right hand side...
layout.addStretch(5)
- label.setStyleSheet(label.css + 'border-bottom-left-radius: 0px;')
+ label.setStyleSheet(label.CSS)
self.setLayout(layout)
@@ -634,9 +1009,10 @@ def __init__(self, message_id: str, message: str, update_signal) -> None:
message,
update_signal,
align="left")
- self.setStyleSheet("""
- background-color: #EEE;
- """)
+ self.setStyleSheet('''
+ background-color: qlineargradient( x1: 0, y1: 0, x2: 0, y2: 1, \
+ stop: 0 #fff, stop: 0.9 #fff, stop: 1 #9211ff);
+ ''')
class ReplyWidget(ConversationWidget):
@@ -657,9 +1033,10 @@ def __init__(
update_signal,
align="right")
self.message_id = message_id
- self.setStyleSheet("""
- background-color: #2299EE;
- """)
+ self.setStyleSheet('''
+ background-color: qlineargradient( x1: 0, y1: 0, x2: 0, y2: 1, \
+ stop: 0 #fff, stop: 0.9 #fff, stop: 1 #05edfe);
+ ''')
message_succeeded_signal.connect(self._on_reply_success)
message_failed_signal.connect(self._on_reply_failure)
@@ -887,9 +1264,11 @@ def __init__(
self.conversation = ConversationView(self.source, self.sdc_home, self.controller,
parent=self)
self.source_profile = SourceProfileShortWidget(self.source, self.controller)
+ self.reply_box = ReplyBoxWidget(self)
self.layout.addWidget(self.source_profile, 1)
self.layout.addWidget(self.conversation, 9)
+ self.layout.addWidget(self.reply_box, 3)
self.controller.authentication_state.connect(self._show_or_hide_replybox)
self._show_or_hide_replybox(is_authenticated)
@@ -971,43 +1350,6 @@ def trigger(self):
self.messagebox.launch()
-class JournalistMenu(QMenu):
- """A menu next to the journalist username.
-
- A menu that provides login options.
- """
-
- def __init__(self, parent):
- super().__init__()
- self.logout = QAction(_('SIGN OUT'))
- self.logout.setFont(QFont("Helvetica [Cronyx]", 10))
- self.addAction(self.logout)
- self.logout.triggered.connect(parent.on_logout_clicked)
-
-
-class JournalistMenuButton(QToolButton):
- """An menu button for the journalist menu
-
- This button is responsible for launching the journalist menu on click.
- """
-
- def __init__(self, parent):
- super().__init__()
-
- self.setStyleSheet('''
- QToolButton::menu-indicator { image: none; }
- QToolButton { border: none; }
- ''')
- arrow = load_image("dropdown_arrow.svg")
- self.setIcon(QIcon(arrow))
- self.setMinimumSize(20, 20)
-
- self.menu = JournalistMenu(parent)
- self.setMenu(self.menu)
-
- self.setPopupMode(QToolButton.InstantPopup)
-
-
class SourceMenu(QMenu):
"""Renders menu having various operations.
@@ -1044,8 +1386,7 @@ def __init__(self, source, controller):
self.controller = controller
self.source = source
- ellipsis_icon = load_image("ellipsis.svg")
- self.setIcon(QIcon(ellipsis_icon))
+ self.setIcon(load_icon("ellipsis.svg"))
self.menu = SourceMenu(self.source, self.controller)
self.setMenu(self.menu)
diff --git a/securedrop_client/logic.py b/securedrop_client/logic.py
index 21b9b67b9..d4463313c 100644
--- a/securedrop_client/logic.py
+++ b/securedrop_client/logic.py
@@ -355,7 +355,7 @@ def on_authenticate(self, result):
# Clear the sidebar error status bar if a message was shown
# to the user indicating they should log in.
- self.gui.update_error_status("")
+ self.gui.clear_error_status()
self.is_authenticated = True
else:
@@ -415,14 +415,12 @@ def sync_api(self):
"""
Grab data from the remote SecureDrop API in a non-blocking manner.
"""
- logger.debug("In sync_api on thread {}".format(
- self.thread().currentThreadId()))
+ logger.debug("In sync_api on thread {}".format(self.thread().currentThreadId()))
self.sync_events.emit('syncing')
if self.authenticated():
logger.debug("You are authenticated, going to make your call")
- self.call_api(storage.get_remote_data, self.on_synced,
- self.on_sync_timeout, self.api)
+ self.call_api(storage.get_remote_data, self.on_synced, self.on_sync_timeout, self.api)
logger.debug("In sync_api, after call to call_api, on "
"thread {}".format(self.thread().currentThreadId()))
@@ -514,7 +512,7 @@ def on_update_star_complete(self, result):
"""
if isinstance(result, bool) and result: # result may be an exception.
self.sync_api() # Syncing the API also updates the source list UI
- self.gui.update_error_status("")
+ self.gui.clear_error_status()
else:
# Here we need some kind of retry logic.
logging.info("failed to push change to server")
@@ -530,7 +528,7 @@ def update_star(self, source_db_object):
self.on_action_requiring_login()
return
else: # Clear the error status bar
- self.gui.update_error_status("")
+ self.gui.clear_error_status()
source_sdk_object = sdclientapi.Source(uuid=source_db_object.uuid)
@@ -557,7 +555,7 @@ def set_status(self, message, duration=5000):
Set a textual status message to be displayed to the user for a certain
duration.
"""
- self.gui.set_status(message, duration)
+ self.gui.update_activity_status(message, duration)
def on_file_open(self, file_db_object):
"""
@@ -663,7 +661,7 @@ def _on_delete_source_complete(self, result):
"""Trigger this when delete operation on source is completed."""
if result:
self.sync_api()
- self.gui.update_error_status("")
+ self.gui.clear_error_status()
else:
logging.info("failed to delete source at server")
error = _('Failed to delete source at server')
diff --git a/securedrop_client/resources/__init__.py b/securedrop_client/resources/__init__.py
index 5ed50da42..e1b492b4e 100644
--- a/securedrop_client/resources/__init__.py
+++ b/securedrop_client/resources/__init__.py
@@ -22,7 +22,6 @@
from PyQt5.QtSvg import QSvgWidget
from PyQt5.QtCore import QDir
-
# Add the images and CSS directories to the search path.
QDir.addSearchPath('images', resource_filename(__name__, 'images'))
QDir.addSearchPath('css', resource_filename(__name__, 'css'))
@@ -37,11 +36,43 @@ def path(name, resource_dir="images/"):
return resource_filename(__name__, resource_dir + name)
-def load_icon(name):
+def load_icon(normal: str, disabled: str = None, active=None, selected=None) -> QIcon:
"""
- Return a QIcon representation of a file in the resources.
+ Add the contents of Scalable Vector Graphics (SVG) files provided for associated icon modes,
+ see https://doc.qt.io/qt-5/qicon.html#Mode-enum.
+
+ Parameters
+ ----------
+ normal: str
+ The name of the SVG file to add to the icon for QIcon.Normal mode.
+ disabled: str or None, optional
+ The name of the SVG file to add to the icon for QIcon.Disabled mode.
+ active: str, optional
+ The name of the SVG file to add to the icon for QIcon.Active mode.
+ selected: str, optional
+ The name of the SVG file to add to the icon for QIcon.Selected mode.
+
+ Returns
+ -------
+ QIcon
+ The icon that displays the contents of the SVG files.
+
"""
- return QIcon(path(name))
+
+ icon = QIcon()
+
+ icon.addFile(path(normal), mode=QIcon.Normal, state=QIcon.On)
+
+ if disabled:
+ icon.addFile(path(disabled), mode=QIcon.Disabled, state=QIcon.Off)
+
+ if active:
+ icon.addFile(path(active), mode=QIcon.Active, state=QIcon.On)
+
+ if selected:
+ icon.addFile(path(selected), mode=QIcon.Selected, state=QIcon.On)
+
+ return icon
def load_svg(name):
diff --git a/securedrop_client/resources/images/dropdown_arrow.svg b/securedrop_client/resources/images/dropdown_arrow.svg
index d43125b38..9ec0b9369 100644
--- a/securedrop_client/resources/images/dropdown_arrow.svg
+++ b/securedrop_client/resources/images/dropdown_arrow.svg
@@ -4,7 +4,7 @@
Path 2
Created with Sketch.
-
+
diff --git a/securedrop_client/resources/images/error_icon.svg b/securedrop_client/resources/images/error_icon.svg
new file mode 100644
index 000000000..2137553ea
--- /dev/null
+++ b/securedrop_client/resources/images/error_icon.svg
@@ -0,0 +1,11 @@
+
+
\ No newline at end of file
diff --git a/securedrop_client/resources/images/refresh.svg b/securedrop_client/resources/images/refresh.svg
index 6ed88c317..e661714c3 100644
--- a/securedrop_client/resources/images/refresh.svg
+++ b/securedrop_client/resources/images/refresh.svg
@@ -7,13 +7,10 @@
-
-
-
-
-
+
+
-
+
diff --git a/securedrop_client/resources/images/refresh_active.svg b/securedrop_client/resources/images/refresh_active.svg
new file mode 100644
index 000000000..c6e378812
--- /dev/null
+++ b/securedrop_client/resources/images/refresh_active.svg
@@ -0,0 +1,11 @@
+
+
diff --git a/securedrop_client/resources/images/refresh_offline.svg b/securedrop_client/resources/images/refresh_offline.svg
new file mode 100644
index 000000000..722f3590f
--- /dev/null
+++ b/securedrop_client/resources/images/refresh_offline.svg
@@ -0,0 +1,11 @@
+
+
\ No newline at end of file
diff --git a/tests/gui/test_main.py b/tests/gui/test_main.py
index eca64b579..4f8408f4d 100644
--- a/tests/gui/test_main.py
+++ b/tests/gui/test_main.py
@@ -19,14 +19,14 @@ def test_init(mocker):
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_lp = mocker.patch('securedrop_client.gui.main.LeftPane')
mock_mv = mocker.patch('securedrop_client.gui.main.MainView')
mocker.patch('securedrop_client.gui.main.QHBoxLayout', mock_lo)
mocker.patch('securedrop_client.gui.main.QMainWindow')
w = Window('mock')
mock_li.assert_called_once_with(w.icon)
- mock_tb.assert_called_once_with(w.widget)
+ mock_lp.assert_called_once_with()
mock_mv.assert_called_once_with(w.widget)
assert mock_lo().addWidget.call_count == 2
@@ -66,15 +66,13 @@ def test_show_login(mocker):
mock_controller = mocker.MagicMock()
w = Window('mock')
w.setup(mock_controller)
- w.status_bar = mocker.MagicMock()
-
mock_ld = mocker.patch('securedrop_client.gui.main.LoginDialog')
+
w.show_login()
mock_ld.assert_called_once_with(w)
w.login_dialog.reset.assert_called_once_with()
w.login_dialog.exec.assert_called_once_with()
- w.status_bar.show_refresh_icon.assert_called_once_with()
def test_show_login_error(mocker):
@@ -113,16 +111,60 @@ def test_show_sources(mocker):
w.main_view.source_list.update.assert_called_once_with([1, 2, 3])
+def test_update_error_status_default(mocker):
+ """
+ Ensure that the error to be shown in the error status bar will be passed to the top pane with a
+ default duration of 10 seconds.
+ """
+ w = Window('mock')
+ w.top_pane = mocker.MagicMock()
+ w.update_error_status(message='test error message')
+ w.top_pane.update_error_status.assert_called_once_with('test error message', 10000)
+
+
def test_update_error_status(mocker):
"""
- Ensure that the error to be shown in the error status sidebar will
- be passed to the left sidebar for display.
+ Ensure that the error to be shown in the error status bar will be passed to the top pane with
+ the duration of seconds provided.
"""
- error_message = "this is a bad thing!"
w = Window('mock')
- w.main_view = mocker.MagicMock()
- w.update_error_status(error=error_message)
- w.main_view.update_error_status.assert_called_once_with(error_message)
+ w.top_pane = mocker.MagicMock()
+ w.update_error_status(message='test error message', duration=123)
+ w.top_pane.update_error_status.assert_called_once_with('test error message', 123)
+
+
+def test_update_activity_status_default(mocker):
+ """
+ Ensure that the activity to be shown in the activity status bar will be passed to the top pane
+ with a default duration of 0 seconds, i.e. forever.
+ """
+ w = Window('mock')
+ w.top_pane = mocker.MagicMock()
+ w.update_activity_status(message='test message')
+ w.top_pane.update_activity_status.assert_called_once_with('test message', 0)
+
+
+def test_update_activity_status(mocker):
+ """
+ Ensure that the activity to be shown in the activity status bar will be passed to the top pane
+ with the duration of seconds provided.
+ """
+ w = Window('mock')
+ w.top_pane = mocker.MagicMock()
+ w.update_activity_status(message='test message', duration=123)
+ w.top_pane.update_activity_status.assert_called_once_with('test message', 123)
+
+
+def test_clear_error_status(mocker):
+ """
+ Ensure clear_error_status is called.
+ """
+ w = Window('mock')
+ w.top_pane = mocker.MagicMock()
+
+ w.clear_error_status()
+
+ w.top_pane.clear_error_status.assert_called_once_with()
def test_show_sync(mocker):
@@ -130,10 +172,11 @@ def test_show_sync(mocker):
If there's a value display the result of its "humanize" method.humanize
"""
w = Window('mock')
- w.set_status = mocker.MagicMock()
+ w.update_activity_status = mocker.MagicMock()
updated_on = mocker.MagicMock()
w.show_sync(updated_on)
- w.set_status.assert_called_once_with('Last refresh: {}'.format(updated_on.humanize()))
+ w.update_activity_status.assert_called_once_with(
+ 'Last refresh: {}'.format(updated_on.humanize()))
def test_show_sync_no_sync(mocker):
@@ -141,31 +184,33 @@ def test_show_sync_no_sync(mocker):
If there's no value to display, default to a "waiting" message.
"""
w = Window('mock')
- w.set_status = mocker.MagicMock()
+ w.update_activity_status = mocker.MagicMock()
w.show_sync(None)
- w.set_status.assert_called_once_with('Waiting to refresh...', 5000)
+ w.update_activity_status.assert_called_once_with('Waiting to refresh...', 5000)
def test_set_logged_in_as(mocker):
"""
- Given a username, the toolbar is appropriately called to update.
+ Given a username, the left pane is appropriately called to update.
"""
w = Window('mock')
- w.tool_bar = mocker.MagicMock()
+ w.left_pane = mocker.MagicMock()
w.set_logged_in_as('test')
- w.tool_bar.set_logged_in_as.assert_called_once_with('test')
+ w.left_pane.set_logged_in_as.assert_called_once_with('test')
def test_logout(mocker):
"""
- Ensure the toolbar updates to the logged out state.
+ Ensure the left pane updates to the logged out state.
"""
w = Window('mock')
- w.tool_bar = mocker.MagicMock()
- w.status_bar = mocker.MagicMock()
+ w.left_pane = mocker.MagicMock()
+ w.top_pane = mocker.MagicMock()
+
w.logout()
- w.tool_bar.set_logged_out.assert_called_once_with()
- w.status_bar.hide_refresh_icon.assert_called_once_with()
+
+ w.left_pane.set_logged_out.assert_called_once_with()
+ w.top_pane.disable_refresh.assert_called_once_with()
def test_on_source_changed(mocker):
@@ -265,13 +310,3 @@ def test_conversation_pending_message(mocker):
assert mocked_add_message.call_count == 1
assert mocked_add_message.call_args == mocker.call(message)
-
-
-def test_set_status(mocker):
- """
- Ensure the status bar's text is updated.
- """
- w = Window('mock')
- w.status_bar = mocker.MagicMock()
- w.set_status('hello', 100)
- w.status_bar.show_message.assert_called_once_with('hello', 100)
diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py
index dc9234937..4c29cbd11 100644
--- a/tests/gui/test_widgets.py
+++ b/tests/gui/test_widgets.py
@@ -4,165 +4,370 @@
from PyQt5.QtWidgets import QWidget, QApplication, QWidgetItem, QSpacerItem, QVBoxLayout, \
QMessageBox, QLabel
from tests import factory
-from securedrop_client import db
-from securedrop_client import logic
-from securedrop_client.gui.widgets import ToolBar, MainView, SourceList, SourceWidget, \
- LoginDialog, SpeechBubble, ConversationWidget, MessageWidget, ReplyWidget, FileWidget, \
- ConversationView, DeleteSourceMessageBox, DeleteSourceAction, SourceMenu, StatusBar, \
- SourceConversationWrapper, ReplyBoxWidget, JournalistMenu
+from securedrop_client import db, logic
+from securedrop_client.gui.widgets import MainView, SourceList, SourceWidget, LoginDialog, \
+ SpeechBubble, ConversationWidget, MessageWidget, ReplyWidget, FileWidget, ConversationView, \
+ DeleteSourceMessageBox, DeleteSourceAction, SourceMenu, TopPane, LeftPane, RefreshButton, \
+ ErrorStatusBar, ActivityStatusBar, UserProfile, UserButton, UserMenu, LoginButton, \
+ ReplyBoxWidget, SourceConversationWrapper
app = QApplication([])
-def test_ToolBar_init():
+def test_TopPane_init(mocker):
"""
- Ensure the ToolBar instance is correctly set up.
+ Ensure the TopPane instance is correctly set up.
"""
- tb = ToolBar(None)
- assert not tb.login.isHidden()
- assert tb.user_icon.isHidden()
- assert tb.user_state.isHidden()
- assert tb.user_menu.isHidden()
+ tp = TopPane()
+ assert not tp.refresh.isEnabled()
-def test_ToolBar_setup(mocker):
+def test_TopPane_setup(mocker):
"""
- Calling setup with references to a window and controller object results in
- them becoming attributes of self.
+ Calling setup calls setup for RefreshButton.
"""
- tb = ToolBar(None)
- mock_window = mocker.MagicMock()
+ tp = TopPane()
+ tp.refresh = mocker.MagicMock()
mock_controller = mocker.MagicMock()
- tb.setup(mock_window, mock_controller)
+ tp.setup(mock_controller)
- assert tb.window == mock_window
- assert tb.controller == mock_controller
+ tp.refresh.setup.assert_called_once_with(mock_controller)
-def test_ToolBar_set_logged_in_as(mocker):
+def test_TopPane_enable_refresh(mocker):
"""
- When a user is logged in check that buttons and menus are in the correct state.
+ Calling enable_refresh calls enable on RefreshButton.
"""
- tb = ToolBar(None)
- tb.login = mocker.MagicMock()
- tb.user_icon = mocker.MagicMock()
- tb.user_state = mocker.MagicMock()
- tb.user_menu = mocker.MagicMock()
+ tp = TopPane()
+ tp.refresh = mocker.MagicMock()
- tb.set_logged_in_as('test')
+ tp.enable_refresh()
- tb.user_state.setText.assert_called_once_with('test')
- tb.login.hide.assert_called_once_with()
- tb.user_icon.show.assert_called_once_with()
- tb.user_state.show.assert_called_once_with()
- tb.user_menu.show.assert_called_once_with()
+ tp.refresh.enable.assert_called_once_with()
-def test_ToolBar_set_logged_out(mocker):
+def test_TopPane_disable_refresh(mocker):
"""
- When a user is logged out check that buttons and menus are in the correct state.
+ Calling disable_refresh calls disable on RefreshButton.
"""
- tb = ToolBar(None)
- tb.login = mocker.MagicMock()
- tb.user_icon = mocker.MagicMock()
- tb.user_state = mocker.MagicMock()
- tb.user_menu = mocker.MagicMock()
+ tp = TopPane()
+ tp.refresh = mocker.MagicMock()
- tb.set_logged_out()
+ tp.disable_refresh()
- tb.login.show.assert_called_once_with()
- tb.user_icon.hide.assert_called_once_with()
- tb.user_state.hide.assert_called_once_with()
- tb.user_menu.hide.assert_called_once_with()
+ tp.refresh.disable.assert_called_once_with()
-def test_ToolBar_on_login_clicked(mocker):
+def test_TopPane_update_activity_status(mocker):
"""
- When login button is clicked, the window activates the login form.
+ Calling update_activity_status calls update_message on ActivityStatusBar.
"""
- tb = ToolBar(None)
- tb.window = mocker.MagicMock()
- tb.on_login_clicked()
- tb.window.show_login.assert_called_once_with()
+ tp = TopPane()
+ tp.activity_status_bar = mocker.MagicMock()
+
+ tp.update_activity_status(message='test message', duration=5)
+ tp.activity_status_bar.update_message.assert_called_once_with('test message', 5)
-def test_ToolBar_on_logout_clicked(mocker):
+
+def test_TopPane_update_error_status(mocker):
"""
- When logout is clicked, the logout logic from the controller is started.
+ Calling update_error_status calls update_message on ErrorStatusBar.
"""
- tb = ToolBar(None)
- tb.controller = mocker.MagicMock()
- tb.on_logout_clicked()
- tb.controller.logout.assert_called_once_with()
+ tp = TopPane()
+ tp.error_status_bar = mocker.MagicMock()
+
+ tp.update_error_status(message='test message', duration=5)
+
+ tp.error_status_bar.update_message.assert_called_once_with('test message', 5)
-def test_JournalistMenu_on_logout_clicked_action_triggered(mocker):
+def test_TopPane_clear_error_status(mocker):
"""
- When the sign-out option is selected, call on_logout_clicked.
+ Calling clear_error_status calls clear_message on RefreshButton.
"""
- tb = ToolBar(None)
- tb.controller = mocker.MagicMock()
- jm = JournalistMenu(tb)
- jm.actions()[0].trigger()
- tb.controller.logout.assert_called_once_with()
+ tp = TopPane()
+ tp.error_status_bar = mocker.MagicMock()
+ tp.clear_error_status()
-def test_StatusBar_on_refresh_clicked(mocker):
+ tp.error_status_bar.clear_message.assert_called_once_with()
+
+
+def test_LeftPane_init(mocker):
"""
- When refresh is clicked, the refresh logic from the controller is stated.
+ Ensure the LeftPane instance is correctly set up.
"""
- sb = StatusBar()
- sb.controller = mocker.MagicMock()
- sb.on_refresh_clicked()
- sb.controller.sync_api.assert_called_once_with()
+ lp = LeftPane()
+ lp.user_profile = mocker.MagicMock()
+ assert lp.user_profile.isHidden()
-def test_StatusBar_sync_event():
- """Toggles refresh button when syncing
+def test_LeftPane_setup(mocker):
"""
- sb = StatusBar()
- sb._on_sync_event('syncing')
- assert not sb.refresh.isEnabled()
+ Calling setup calls setup for UserProfile.
+ """
+ lp = LeftPane()
+ lp.user_profile = mocker.MagicMock()
+ mock_window = mocker.MagicMock()
+ mock_controller = mocker.MagicMock()
+
+ lp.setup(mock_window, mock_controller)
- sb._on_sync_event('synced')
- assert sb.refresh.isEnabled()
+ lp.user_profile.setup.assert_called_once_with(mock_window, mock_controller)
-def test_StatusBar_init(mocker):
+def test_LeftPane_set_logged_in_as(mocker):
"""
- Ensure the StatusBar instance is correctly set up.
+ When a user is logged in check that buttons and menus are in the correct state.
"""
- tb = ToolBar(None)
- mock_window = mocker.MagicMock()
- mock_controller = mocker.MagicMock()
- tb.setup(mock_window, mock_controller)
+ lp = LeftPane()
+ lp.user_profile = mocker.MagicMock()
+
+ lp.set_logged_in_as('test')
+
+ lp.user_profile.show.assert_called_once_with()
+ lp.user_profile.set_username.assert_called_once_with('test')
+
+
+def test_LeftPane_set_logged_out(mocker):
+ """
+ When a user is logged out check that buttons and menus are in the correct state.
+ """
+ lp = LeftPane()
+ lp.user_profile = mocker.MagicMock()
+
+ lp.set_logged_out()
+
+ lp.user_profile.hide.assert_called_once_with()
+
+
+def test_RefreshButton_setup(mocker):
+ """
+ Calling setup stores reference to controller, which will later be used to update button icon on
+ sync event.
+ """
+ rb = RefreshButton()
+ controller = mocker.MagicMock()
+
+ rb.setup(controller)
+
+ assert rb.controller == controller
+
+
+def test_RefreshButton_on_clicked(mocker):
+ """
+ When refresh button is clicked, sync_api should be called.
+ """
+ rb = RefreshButton()
+ rb.controller = mocker.MagicMock()
- sb = StatusBar()
- sb.setup(mock_controller)
+ rb._on_clicked()
- assert not sb.refresh.isHidden()
+ rb.controller.sync_api.assert_called_once_with()
-def test_StatusBar_show_refresh(mocker):
+def test_RefreshButton_on_refresh_complete(mocker):
"""
- Ensure the StatusBar shows refresh icon.
+ Make sure we are enabled after a refresh completes.
"""
- sb = StatusBar()
- sb.refresh = mocker.MagicMock()
- sb.show_refresh_icon()
- sb.refresh.show.assert_called_once_with()
+ rb = RefreshButton()
+ rb._on_refresh_complete('synced')
+ assert rb.isEnabled()
+
+
+def test_RefreshButton_enable(mocker):
+ rb = RefreshButton()
+ rb.enable()
+ assert rb.isEnabled()
+
+
+def test_RefreshButton_disable(mocker):
+ rb = RefreshButton()
+ rb.disable()
+ assert not rb.isEnabled()
+
+
+def test_ErrorStatusBar_clear_error_status(mocker):
+ """
+ Calling clear_error_status calls clear_message on RefreshButton.
+ """
+ esb = ErrorStatusBar()
+ esb.status_bar = mocker.MagicMock()
+
+ esb.clear_message()
+
+ esb.status_bar.clearMessage.assert_called_once_with()
+
+
+def test_ErrorStatusBar_update_message(mocker):
+ """
+ Calling update_message updates the message of the QStatusBar and starts the a timer for the
+ given duration.
+ """
+ esb = ErrorStatusBar()
+ esb.status_bar = mocker.MagicMock()
+ esb.status_timer = mocker.MagicMock()
+
+ esb.update_message(message='test message', duration=123)
+
+ esb.status_bar.showMessage.assert_called_once_with('test message', 123)
+ esb.status_timer.start.assert_called_once_with(123)
+
+
+def test_ErrorStatusBar_hide(mocker):
+ esb = ErrorStatusBar()
+ esb.vertical_bar = mocker.MagicMock()
+ esb.label = mocker.MagicMock()
+ esb.status_bar = mocker.MagicMock()
+
+ esb._hide()
+ esb.vertical_bar.hide.assert_called_once_with()
+ esb.label.hide.assert_called_once_with()
+ esb.status_bar.hide.assert_called_once_with()
-def test_StatusBar_hide_refresh(mocker):
+
+def test_ErrorStatusBar_show(mocker):
+ esb = ErrorStatusBar()
+ esb.vertical_bar = mocker.MagicMock()
+ esb.label = mocker.MagicMock()
+ esb.status_bar = mocker.MagicMock()
+
+ esb._show()
+
+ esb.vertical_bar.show.assert_called_once_with()
+ esb.label.show.assert_called_once_with()
+ esb.status_bar.show.assert_called_once_with()
+
+
+def test_ErrorStatusBar_on_status_timeout(mocker):
+ esb = ErrorStatusBar()
+ esb._on_status_timeout()
+ assert esb.isHidden()
+
+
+def test_ActivityStatusBar_update_message(mocker):
"""
- Ensure the StatusBar hides refresh icon.
+ Calling update_message updates the message of the QStatusBar.
"""
- sb = StatusBar()
- sb.refresh = mocker.MagicMock()
- sb.hide_refresh_icon()
- sb.refresh.hide.assert_called_once_with()
+ asb = ActivityStatusBar()
+ asb.update_message(message='test message', duration=123)
+ assert asb.currentMessage() == 'test message'
+
+
+def test_UserProfile_setup(mocker):
+ up = UserProfile()
+ up.user_button = mocker.MagicMock()
+ up.login_button = mocker.MagicMock()
+ window = mocker.MagicMock()
+ controller = mocker.MagicMock()
+
+ up.setup(window, controller)
+
+ up.user_button.setup.assert_called_once_with(controller)
+ up.login_button.setup.assert_called_once_with(window)
+
+
+def test_UserProfile_set_username(mocker):
+ up = UserProfile()
+ up.user_icon = mocker.MagicMock()
+ up.user_button = mocker.MagicMock()
+
+ up.set_username('test_username')
+
+ up.user_icon.setText.assert_called_once_with('jo') # testing current behavior as placeholder
+ up.user_button.set_username.assert_called_once_with('test_username')
+
+
+def test_UserProfile_show(mocker):
+ up = UserProfile()
+ up.user_icon = mocker.MagicMock()
+ up.user_button = mocker.MagicMock()
+ up.login_button = mocker.MagicMock()
+
+ up.show()
+
+ up.login_button.hide.assert_called_once_with()
+ up.user_icon.show.assert_called_once_with()
+ up.user_button.show.assert_called_once_with()
+
+
+def test_UserProfile_hide(mocker):
+ up = UserProfile()
+ up.user_icon = mocker.MagicMock()
+ up.user_button = mocker.MagicMock()
+ up.login_button = mocker.MagicMock()
+
+ up.hide()
+
+ up.user_icon.hide.assert_called_once_with()
+ up.user_button.hide.assert_called_once_with()
+ up.login_button.show.assert_called_once_with()
+
+
+def test_UserButton_setup(mocker):
+ ub = UserButton()
+ ub.menu = mocker.MagicMock()
+ controller = mocker.MagicMock()
+
+ ub.setup(controller)
+
+ ub.menu.setup.assert_called_once_with(controller)
+
+
+def test_UserButton_set_username():
+ ub = UserButton()
+ ub.set_username('test_username')
+ ub.text() == 'test_username'
+
+
+def test_UserMenu_setup(mocker):
+ um = UserMenu()
+ controller = mocker.MagicMock()
+
+ um.setup(controller)
+
+ assert um.controller == controller
+
+
+def test_UserMenu_on_logout_triggered(mocker):
+ um = UserMenu()
+ um.controller = mocker.MagicMock()
+
+ um._on_logout_triggered()
+
+ um.controller.logout.assert_called_once_with()
+
+
+def test_UserMenu_on_item_selected(mocker):
+ um = UserMenu()
+ um.controller = mocker.MagicMock()
+
+ um.actions()[0].trigger()
+
+ um.controller.logout.assert_called_once_with()
+
+
+def test_LoginButton_init(mocker):
+ lb = LoginButton()
+ assert lb.text() == 'SIGN IN'
+
+
+def test_LoginButton_setup(mocker):
+ lb = LoginButton()
+ window = mocker.MagicMock()
+ lb.setup(window)
+ lb.window = window
+
+
+def test_Loginbutton_on_clicked(mocker):
+ lb = LoginButton()
+ lb.window = mocker.MagicMock()
+ lb._on_clicked()
+ lb.window.show_login.assert_called_once_with()
def test_MainView_init():
@@ -189,21 +394,6 @@ def test_MainView_show_conversation(mocker):
mv.view_layout.addWidget.assert_called_once_with(mock_widget)
-def test_MainView_update_error_status(mocker):
- """
- Ensure when the update_error_status method is called on the MainView that
- the error status text is set as expected.
- """
- mv = MainView(None)
- expected_message = "this is the message to be displayed"
-
- mv.error_status = mocker.MagicMock()
- mv.error_status.setText = mocker.MagicMock()
-
- mv.update_error_status(error=expected_message)
- mv.error_status.setText.assert_called_once_with(expected_message)
-
-
def test_SourceList_update(mocker):
"""
Check the items in the source list are cleared and a new SourceWidget for
diff --git a/tests/test_logic.py b/tests/test_logic.py
index 57c9987af..e2259d1ad 100644
--- a/tests/test_logic.py
+++ b/tests/test_logic.py
@@ -275,7 +275,7 @@ def test_Client_on_authenticate_ok(homedir, config, mocker):
cl.start_message_thread.assert_called_once_with()
cl.gui.set_logged_in_as.assert_called_once_with('test')
# Error status bar should be cleared
- cl.gui.update_error_status.assert_called_once_with("")
+ cl.gui.clear_error_status.assert_called_once_with()
def test_Client_completed_api_call_without_current_object(homedir, config, mocker):
@@ -760,7 +760,7 @@ def test_Client_unstars_a_source_if_starred(homedir, config, mocker):
cl.call_api.assert_called_once_with(
cl.api.remove_star, cl.on_update_star_complete,
cl.on_sidebar_action_timeout, source_sdk_object)
- mock_gui.update_error_status.assert_called_once_with("")
+ mock_gui.clear_error_status.assert_called_once_with()
def test_Client_unstars_a_source_if_unstarred(homedir, config, mocker):
@@ -786,7 +786,7 @@ def test_Client_unstars_a_source_if_unstarred(homedir, config, mocker):
cl.call_api.assert_called_once_with(
cl.api.add_star, cl.on_update_star_complete,
cl.on_sidebar_action_timeout, source_sdk_object)
- mock_gui.update_error_status.assert_called_once_with("")
+ mock_gui.clear_error_status.assert_called_once_with()
def test_Client_update_star_not_logged_in(homedir, config, mocker):
@@ -832,7 +832,7 @@ def test_Client_on_update_star_success(homedir, config, mocker):
cl.sync_api = mocker.MagicMock()
cl.on_update_star_complete(result)
cl.sync_api.assert_called_once_with()
- mock_gui.update_error_status.assert_called_once_with("")
+ mock_gui.clear_error_status.assert_called_once_with()
def test_Client_on_update_star_failed(homedir, config, mocker):
@@ -874,7 +874,7 @@ def test_Client_logout(homedir, config, mocker):
cl.gui.logout.assert_called_once_with()
-def test_Client_set_status(homedir, config, mocker):
+def test_Client_set_activity_status(homedir, config, mocker):
"""
Ensure the GUI set_status API is called.
Using the `config` fixture to ensure the config is written to disk.
@@ -883,7 +883,7 @@ def test_Client_set_status(homedir, config, mocker):
mock_session = mocker.MagicMock()
cl = Client('http://localhost', mock_gui, mock_session, homedir)
cl.set_status("Hello, World!", 1000)
- mock_gui.set_status.assert_called_once_with("Hello, World!", 1000)
+ mock_gui.update_activity_status.assert_called_once_with("Hello, World!", 1000)
PERMISSIONS_CASES = [
@@ -1162,7 +1162,7 @@ def test_Client_on_delete_source_complete_with_results(homedir, config, mocker):
cl.sync_api = mocker.MagicMock()
cl._on_delete_source_complete(True)
cl.sync_api.assert_called_with()
- cl.gui.update_error_status.assert_called_with("")
+ cl.gui.clear_error_status.assert_called_with()
def test_Client_on_delete_source_complete_without_results(homedir, config, mocker):