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 @@ + + + + Fill 1 + Created with Sketch. + + + + + + \ 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 @@ + + + + Fill 1 + Created with Sketch. + + + + + + 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 @@ + + + + Fill 1 + Created with Sketch. + + + + + + \ 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):