Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Animate activestate on frameless dialog boxes. #940

Merged
merged 4 commits into from
Mar 24, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 85 additions & 6 deletions securedrop_client/gui/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal, QEvent, QTimer, QSize, pyqtBoundSignal, \
QObject, QPoint
from PyQt5.QtGui import QIcon, QPalette, QBrush, QColor, QFont, QLinearGradient, QKeySequence, \
QCursor, QKeyEvent, QCloseEvent
QCursor, QKeyEvent, QCloseEvent, QPixmap
from PyQt5.QtWidgets import QApplication, QListWidget, QLabel, QWidget, QListWidgetItem, \
QHBoxLayout, QVBoxLayout, QLineEdit, QScrollArea, QDialog, QAction, QMenu, QMessageBox, \
QToolButton, QSizePolicy, QPlainTextEdit, QStatusBar, QGraphicsDropShadowEffect, QPushButton, \
Expand Down Expand Up @@ -2383,7 +2383,7 @@ class FramelessDialog(QDialog):
font-size: 12px;
color: #2a319d;
}
#header_icon {
#header_icon, #header_spinner {
min-width: 80px;
max-width: 80px;
min-height: 64px;
Expand Down Expand Up @@ -2487,9 +2487,16 @@ def __init__(self):
header_container.setLayout(header_container_layout)
self.header_icon = SvgLabel('blank.svg', svg_size=QSize(64, 64))
self.header_icon.setObjectName('header_icon')
self.header_spinner = QPixmap()
self.header_spinner_label = QLabel()
self.header_spinner_label.setObjectName("header_spinner")
self.header_spinner_label.setMinimumSize(64, 64)
self.header_spinner_label.setVisible(False)
self.header_spinner_label.setPixmap(self.header_spinner)
self.header = QLabel()
self.header.setObjectName('header')
header_container_layout.addWidget(self.header_icon)
header_container_layout.addWidget(self.header_spinner_label)
header_container_layout.addWidget(self.header, alignment=Qt.AlignCenter)
header_container_layout.addStretch()

Expand Down Expand Up @@ -2523,6 +2530,7 @@ def __init__(self):
self.continue_button = QPushButton(_('CONTINUE'))
self.continue_button.setObjectName('primary_button')
self.continue_button.setDefault(True)
self.continue_button.setIconSize(QSize(21, 21))
button_box = QDialogButtonBox(Qt.Horizontal)
button_box.setObjectName('button_box')
button_box.addButton(self.cancel_button, QDialogButtonBox.ActionRole)
Expand All @@ -2541,6 +2549,16 @@ def __init__(self):
layout.addStretch()
layout.addWidget(window_buttons)

# Activestate animation.
self.button_animation = load_movie("activestate-wide.gif")
self.button_animation.setScaledSize(QSize(32, 32))
self.button_animation.frameChanged.connect(self.animate_activestate)

# Header animation.
self.header_animation = load_movie("header_animation.gif")
self.header_animation.setScaledSize(QSize(64, 64))
self.header_animation.frameChanged.connect(self.animate_header)

def closeEvent(self, event: QCloseEvent):
# ignore any close event that doesn't come from our custom close method
if not self.internal_close_event_emitted:
Expand Down Expand Up @@ -2575,6 +2593,46 @@ def center_dialog(self):
y_center = (application_window_size.height() - dialog_size.height()) / 2
self.move(x + x_center, y + y_center)

def animate_activestate(self):
self.continue_button.setIcon(QIcon(self.button_animation.currentPixmap()))

def animate_header(self):
self.header_spinner_label.setPixmap(self.header_animation.currentPixmap())

def start_animate_activestate(self):
self.button_animation.start()
self.continue_button.setText("")
self.continue_button.setMinimumSize(QSize(142, 43))
css = """
background-color: #f1f1f6;
color: #fff;
border: 2px solid #f1f1f6;
margin: 0px 0px 0px 12px;
height: 40px;
padding-left: 20px;
padding-right: 20px;
"""
self.continue_button.setStyleSheet(css)
self.error_details.setStyleSheet("color: #ff66C4")

def start_animate_header(self):
self.header_icon.setVisible(False)
self.header_spinner_label.setVisible(True)
self.header_animation.start()

def stop_animate_activestate(self):
self.continue_button.setIcon(QIcon())
self.button_animation.stop()
self.continue_button.setText(_('CONTINUE'))
css = "background-color: #2a319d; color: #fff; border: 2px solid #2a319d;"
self.continue_button.setStyleSheet(css)
self.error_details.setStyleSheet("color: #ff0064")

def stop_animate_header(self):
self.header_icon.setVisible(True)
self.header_spinner_label.setVisible(False)
self.header_animation.stop()


class PrintDialog(FramelessDialog):

Expand All @@ -2596,13 +2654,15 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str):
# Connect parent signals to slots
self.continue_button.setEnabled(False)

self.header_icon.update_image('printer.svg', svg_size=QSize(64, 64))

# Dialog content
self.starting_header = _(
'Preparing to print:'
'<br />'
'<span style="font-weight:normal">{}</span>'.format(self.file_name))
self.ready_header = _(
'Ready to print:'
'<br />'
'<span style="font-weight:normal">{}</span>'.format(self.file_name))
self.insert_usb_header = _('Connect USB printer')
self.error_header = _('Printing failed')
self.starting_message = _(
Expand All @@ -2622,6 +2682,7 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str):
self.generic_error_message = _('See your administrator for help.')

self._show_starting_instructions()
self.start_animate_header()
self._run_preflight()

def _show_starting_instructions(self):
Expand Down Expand Up @@ -2662,6 +2723,9 @@ def _print_file(self):
@pyqtSlot()
def _on_preflight_success(self):
# If the continue button is disabled then this is the result of a background preflight check
self.stop_animate_header()
self.header_icon.update_image('printer.svg', svg_size=QSize(64, 64))
self.header.setText(self.ready_header)
if not self.continue_button.isEnabled():
self.continue_button.clicked.disconnect()
self.continue_button.clicked.connect(self._print_file)
Expand All @@ -2672,6 +2736,8 @@ def _on_preflight_success(self):

@pyqtSlot(object)
def _on_preflight_failure(self, error: ExportError):
self.stop_animate_header()
self.header_icon.update_image('printer.svg', svg_size=QSize(64, 64))
self.error_status = error.status
# If the continue button is disabled then this is the result of a background preflight check
if not self.continue_button.isEnabled():
Expand Down Expand Up @@ -2730,13 +2796,15 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str):
# Connect parent signals to slots
self.continue_button.setEnabled(False)

self.header_icon.update_image('savetodisk.svg', QSize(64, 64))

# Dialog content
self.starting_header = _(
'Preparing to export:'
'<br />'
'<span style="font-weight:normal">{}</span>'.format(self.file_name))
self.ready_header = _(
'Ready to export:'
'<br />'
'<span style="font-weight:normal">{}</span>'.format(self.file_name))
self.insert_usb_header = _('Insert encrypted USB drive')
self.passphrase_header = _('Enter passphrase for USB drive')
self.success_header = _('Export successful')
Expand Down Expand Up @@ -2794,6 +2862,7 @@ def __init__(self, controller: Controller, file_uuid: str, file_name: str):
self.passphrase_form.hide()

self._show_starting_instructions()
self.start_animate_header()
self._run_preflight()

def _show_starting_instructions(self):
Expand Down Expand Up @@ -2887,11 +2956,16 @@ def _run_preflight(self):

@pyqtSlot()
def _export_file(self, checked: bool = False):
self.start_animate_activestate()
self.passphrase_field.setDisabled(True)
self.controller.export_file_to_usb_drive(self.file_uuid, self.passphrase_field.text())

@pyqtSlot()
def _on_preflight_success(self):
# If the continue button is disabled then this is the result of a background preflight check
self.stop_animate_header()
self.header_icon.update_image('savetodisk.svg', QSize(64, 64))
self.header.setText(self.ready_header)
if not self.continue_button.isEnabled():
self.continue_button.clicked.disconnect()
self.continue_button.clicked.connect(self._show_passphrase_request_message)
Expand All @@ -2902,14 +2976,19 @@ def _on_preflight_success(self):

@pyqtSlot(object)
def _on_preflight_failure(self, error: ExportError):
self.stop_animate_header()
self.header_icon.update_image('savetodisk.svg', QSize(64, 64))
self._update_dialog(error.status)

@pyqtSlot()
def _on_export_success(self):
self.stop_animate_activestate()
self._show_success_message()

@pyqtSlot(object)
def _on_export_failure(self, error: ExportError):
self.stop_animate_activestate()
self.passphrase_field.setDisabled(False)
self._update_dialog(error.status)

def _update_dialog(self, error_status: str):
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
58 changes: 58 additions & 0 deletions tests/gui/test_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -2507,6 +2507,64 @@ def test_FramelessDialog_center_dialog_with_no_active_window(mocker):
dialog.move.assert_not_called()


def test_FramelessDialog_animation_of_activestate(mocker):
dialog = FramelessDialog()
assert dialog.button_animation
dialog.button_animation.start = mocker.MagicMock()
dialog.button_animation.stop = mocker.MagicMock()
dialog.continue_button = mocker.MagicMock()

# Check the animation frame is updated as expected.
dialog.animate_activestate()
assert dialog.continue_button.setIcon.call_count == 1
dialog.continue_button.reset_mock()

# Check starting the animated state works as expected.
dialog.start_animate_activestate()
dialog.button_animation.start.assert_called_once_with()
dialog.continue_button.setText.assert_called_once_with("")
assert dialog.continue_button.setMinimumSize.call_count == 1
assert dialog.continue_button.setStyleSheet.call_count == 1

dialog.continue_button.reset_mock()

# Check stopping the animated state works as expected.
dialog.stop_animate_activestate()
dialog.button_animation.stop.assert_called_once_with()
dialog.continue_button.setText.assert_called_once_with("CONTINUE")
assert dialog.continue_button.setIcon.call_count == 1
assert dialog.continue_button.setStyleSheet.call_count == 1


def test_FramelessDialog_animation_of_header(mocker):
dialog = FramelessDialog()
assert dialog.header_animation
dialog.header_animation.start = mocker.MagicMock()
dialog.header_animation.stop = mocker.MagicMock()
dialog.header_icon.setVisible = mocker.MagicMock()
dialog.header_spinner_label.setVisible = mocker.MagicMock()
dialog.header_spinner_label.setPixmap = mocker.MagicMock()

# Check the animation frame is updated as expected.
dialog.animate_header()
assert dialog.header_spinner_label.setPixmap.call_count == 1

# Check starting the animated state works as expected.
dialog.start_animate_header()
dialog.header_animation.start.assert_called_once_with()
dialog.header_icon.setVisible.assert_called_once_with(False)
dialog.header_spinner_label.setVisible.assert_called_once_with(True)

dialog.header_icon.setVisible.reset_mock()
dialog.header_spinner_label.setVisible.reset_mock()

# Check stopping the animated state works as expected.
dialog.stop_animate_header()
dialog.header_animation.stop.assert_called_once_with()
dialog.header_icon.setVisible.assert_called_once_with(True)
dialog.header_spinner_label.setVisible.assert_called_once_with(False)


def test_ExportDialog_init(mocker):
mocker.patch(
'securedrop_client.gui.widgets.QApplication.activeWindow', return_value=QMainWindow())
Expand Down