Skip to content

Commit

Permalink
Stay on same page while export or check is taking place in background…
Browse files Browse the repository at this point in the history
…. Apply activestate animation during async work. Add QWizard styling.
  • Loading branch information
rocodes committed Feb 9, 2024
1 parent f95c3f9 commit 0de11dd
Show file tree
Hide file tree
Showing 6 changed files with 135 additions and 84 deletions.
29 changes: 0 additions & 29 deletions client/securedrop_client/gui/conversation/export/dialog_button.css

This file was deleted.

70 changes: 53 additions & 17 deletions client/securedrop_client/gui/conversation/export/export_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from pkg_resources import resource_string
from PyQt5.QtCore import QSize, Qt, pyqtSlot
from PyQt5.QtGui import QIcon, QKeyEvent
from PyQt5.QtGui import QFont, QIcon, QKeyEvent
from PyQt5.QtWidgets import QApplication, QWizard, QWizardPage

from securedrop_client.export import Export
Expand All @@ -31,7 +31,9 @@ class ExportWizard(QWizard):
PASSPHRASE_LABEL_SPACING = 0.5
NO_MARGIN = 0
FILENAME_WIDTH_PX = 260
BUTTON_CSS = resource_string(__name__, "dialog_button.css").decode("utf-8")
FILE_OPTIONS_FONT_SPACING = 1.6
BUTTON_CSS = resource_string(__name__, "wizard_button.css").decode("utf-8")
WIZARD_CSS = resource_string(__name__, "wizard.css").decode("utf-8")

# If the drive is unlocked, we don't need a passphrase; if we do need one,
# it's populated later.
Expand All @@ -56,6 +58,7 @@ def __init__(self, export: Export, summary_text: str, filepaths: List[str]) -> N
self._set_layout()
self._set_pages()
self._style_buttons()
self.adjustSize()

def keyPressEvent(self, event: QKeyEvent) -> None:
if event.key() == Qt.Key_Enter or event.key() == Qt.Key_Return:
Expand All @@ -71,38 +74,58 @@ def text(self) -> str:
return self.body.text()

def _style_buttons(self) -> None:
button_font = QFont()
button_font.setLetterSpacing(QFont.AbsoluteSpacing, self.FILE_OPTIONS_FONT_SPACING)

self.next_button = self.button(QWizard.WizardButton.NextButton)
self.next_button.clicked.connect(self.request_export)
self.next_button.setObjectName("QWizardButton_PrimaryButton")
self.next_button.setStyleSheet(self.BUTTON_CSS)
self.next_button.setMinimumSize(QSize(130, 40))
self.next_button.setMaximumHeight(40)
self.next_button.clicked.connect(self.request_export)

self.cancel_button = self.button(QWizard.WizardButton.CancelButton)
self.cancel_button.setObjectName("QWizardButton_GenericButton")
self.cancel_button.setStyleSheet(self.BUTTON_CSS)
self.cancel_button.setMinimumSize(QSize(130, 40))
self.cancel_button.setMaximumHeight(40)

self.back_button = self.button(QWizard.WizardButton.BackButton)
self.back_button.setObjectName("QWizardButton_GenericButton")
self.back_button.setStyleSheet(self.BUTTON_CSS)
self.back_button.setMinimumSize(QSize(130, 40))
self.back_button.setMaximumHeight(40)

self.finish_button = self.button(QWizard.WizardButton.FinishButton)
self.finish_button.setObjectName("QWizardButton_GenericButton")
self.finish_button.setStyleSheet(self.BUTTON_CSS)
self.finish_button.setMinimumSize(QSize(130, 40))
self.finish_button.setMaximumHeight(40)

# 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)

self.setButtonText(QWizard.WizardButton.NextButton, _("CONTINUE"))
self.setButtonText(QWizard.WizardButton.CancelButton, _("CANCEL"))
self.setButtonText(QWizard.WizardButton.FinishButton, _("DONE"))
self.setButtonText(QWizard.WizardButton.BackButton, _("BACK")) # TODO il8n

def animate_activestate(self) -> None:
self.next_button.setIcon(QIcon(self.button_animation.currentPixmap()))

def start_animate_activestate(self) -> None:
self.button_animation.start()
self.next_button.setMinimumSize(QSize(142, 43))
# Reset widget stylesheets
self.next_button.setStyleSheet("")
self.next_button.setObjectName("ModalDialog_primary_button_active")
self.next_button.setStyleSheet(self.BUTTON_CSS)

def stop_animate_activestate(self) -> None:
self.next_button.setIcon(QIcon())
self.button_animation.stop()
# Reset widget stylesheets
self.next_button.setStyleSheet("")
self.next_button.setObjectName("ModalDialog_primary_button")
self.next_button.setStyleSheet(self.BUTTON_CSS)

def _set_layout(self) -> None:
self.setWindowTitle(f"Export {self.summary_text}")
self.setWindowTitle(f"Export {self.summary_text}") # TODO (il8n)
self.setObjectName("QWizard_export")
self.setStyleSheet(self.WIZARD_CSS)
self.setModal(False)
self.setOptions(
QWizard.NoBackButtonOnLastPage
Expand All @@ -119,17 +142,23 @@ def _set_pages(self) -> None:
(Pages.EXPORT_DONE, self._create_done()),
]:
self.setPage(id, page)

# Nice to have, but steals the focus from the password field after 1 character is typed.
# Probably another way to have it be based on validating the status
# page.completeChanged.connect(lambda: self._set_focus(QWizard.WizardButton.NextButton))
self.adjustSize()

@pyqtSlot(int)
def _set_focus(self, which: QWizard.WizardButton) -> None:
self.button(which).setFocus()

def request_export(self) -> None:
logger.debug("Request export")
# While we're waiting for the results to come back, stay on the same page.
# This prevents the dialog from briefly flashing one page and then
# advancing to a subsequent page (for example, flashing the "Insert a USB"
# page before detecting the USB and advancing to the "Unlock USB" page)
page = self.currentPage()
if page:
page.set_complete(False)
self.start_animate_activestate()

# Registered fields let us access the passphrase field
# of the PassphraseRequestPage from the wizard parent
passphrase_untrusted = self.field("passphrase")
Expand All @@ -154,6 +183,12 @@ def on_status_received(self, status: ExportStatus) -> None:
"""
logger.debug(f"Wizard received {status.value}. Current page is {type(self.currentPage())}")

# Release the page (page was held during "next" button click event)
page = self.currentPage()
if page:
page.set_complete(True)
self.stop_animate_activestate()

# Unrecoverable - end the wizard
if status in [
ExportStatus.ERROR_MOUNT,
Expand Down Expand Up @@ -190,6 +225,7 @@ def rewind(self, target: Pages) -> None:
"""
logger.debug(f"Wizard: rewind from {self.currentId()} to {target}")
while self.currentId() > target:
logger.debug("back one page")
self.back()

def end_wizard_with_error(self, error: ExportStatus) -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ class ExportWizardPage(QWizardPage):
* Directional buttons (continue/done, cancel)
"""

DIALOG_CSS = resource_string(__name__, "dialog.css").decode("utf-8")
ERROR_DETAILS_CSS = resource_string(__name__, "dialog_message.css").decode("utf-8")
WIZARD_CSS = resource_string(__name__, "wizard.css").decode("utf-8")
ERROR_DETAILS_CSS = resource_string(__name__, "wizard_message.css").decode("utf-8")

MARGIN = 40
PASSPHRASE_LABEL_SPACING = 0.5
Expand All @@ -72,7 +72,8 @@ def __init__(self, export: Export, header: str, body: str) -> None:

def set_complete(self, is_complete: bool) -> None:
"""
Flag a page as being incomplete. (Disables Next button)
Flag a page as being incomplete. (Disables Next button and prevents
user from advancing to next page)
"""
self._is_complete = is_complete

Expand All @@ -83,34 +84,34 @@ def _build_layout(self) -> QVBoxLayout:
"""
Create parent layout, draw elements, return parent layout
"""
self.setStyleSheet(self.DIALOG_CSS)
parent_layout = QVBoxLayout()
self.setStyleSheet(self.WIZARD_CSS)
parent_layout = QVBoxLayout(self)
parent_layout.setContentsMargins(self.MARGIN, self.MARGIN, self.MARGIN, self.MARGIN)

# Header for icon and task title
header_container = QWidget()
header_container_layout = QHBoxLayout()
header_container.setLayout(header_container_layout)
self.header_icon = SvgLabel("blank.svg", svg_size=QSize(64, 64))
self.header_icon.setObjectName("ModalDialog_header_icon")
self.header_icon.setObjectName("QWizard_header_icon")
self.header_spinner = QPixmap()
self.header_spinner_label = QLabel()
self.header_spinner_label.setObjectName("ModalDialog_header_spinner")
self.header_spinner_label.setObjectName("QWizard_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("ModalDialog_header")
self.header.setObjectName("QWizard_header")
header_container_layout.addWidget(self.header, alignment=Qt.AlignCenter)
header_container_layout.addWidget(self.header_icon)
header_container_layout.addWidget(self.header_spinner_label)
header_container_layout.addWidget(self.header, alignment=Qt.AlignLeft) # Prev: AlignCenter
header_container_layout.addStretch()
self.header_line = QWidget()
self.header_line.setObjectName("ModalDialog_header_line")
self.header_line.setObjectName("QWizard_header_line")

# Body to display instructions and forms
self.body = QLabel()
self.body.setObjectName("ModalDialog_body")
self.body.setObjectName("QWizard_body")
self.body.setWordWrap(True)
self.body.setScaledContents(True)

Expand All @@ -121,15 +122,14 @@ def _build_layout(self) -> QVBoxLayout:
)
body_container.setLayout(self.body_layout)
self.body_layout.addWidget(self.body)
self.body_layout.setSizeConstraint(QLayout.SetMinimumSize)

# TODO: it's either like this, or in the parent layout elements
self.body_layout.setSizeConstraint(QLayout.SetMinimumSize)

# Widget for displaying error messages (hidden by default)
self.error_details = QLabel()
self.error_details.setObjectName("ModalDialog_error_details")
self.error_details.setObjectName("QWizard_error_details")
self.error_details.setStyleSheet(self.ERROR_DETAILS_CSS)
self.error_details.setContentsMargins(
self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN
)
self.error_details.setWordWrap(True)
self.error_details.hide()

Expand All @@ -142,12 +142,13 @@ def _build_layout(self) -> QVBoxLayout:
self.header.setText(self.header_text)
self.body.setText(self.body_text)


# Add all the layout elements
parent_layout.addWidget(header_container)
parent_layout.addWidget(self.header_line)
parent_layout.addWidget(body_container)
parent_layout.addWidget(self.error_details)
# parent_layout.setSizeConstraint(QLayout.SetFixedSize)
parent_layout.addStretch()

return parent_layout

Expand All @@ -159,7 +160,7 @@ def animate_activestate(self) -> None:

def start_animate_activestate(self) -> None:
self.error_details.setStyleSheet("")
self.error_details.setObjectName("ModalDialog_error_details_active")
self.error_details.setObjectName("QWizard_error_details_active")
self.error_details.setStyleSheet(self.ERROR_DETAILS_CSS)

def start_animate_header(self) -> None:
Expand All @@ -169,7 +170,7 @@ def start_animate_header(self) -> None:

def stop_animate_activestate(self) -> None:
self.error_details.setStyleSheet("")
self.error_details.setObjectName("ModalDialog_error_details")
self.error_details.setObjectName("QWizard_error_details")
self.error_details.setStyleSheet(self.ERROR_DETAILS_CSS)

def stop_animate_header(self) -> None:
Expand Down Expand Up @@ -286,7 +287,10 @@ def on_status_received(self, status: ExportStatus) -> None:
should_show_hint = status in (
ExportStatus.MULTI_DEVICE_DETECTED,
ExportStatus.INVALID_DEVICE_DETECTED,
) or (self.status == status == ExportStatus.NO_DEVICE_DETECTED)
) or (
self.status == status == ExportStatus.NO_DEVICE_DETECTED
and isinstance(self.wizard().currentPage, InsertUSBPage)
)
self.update_content(status, should_show_hint)
self.status = status
self.completeChanged.emit()
Expand Down Expand Up @@ -387,7 +391,7 @@ def _build_layout(self) -> QVBoxLayout:

# Passphrase Form
self.passphrase_form = QWidget()
self.passphrase_form.setObjectName("ModalDialog_passphrase_form")
self.passphrase_form.setObjectName("QWizard_passphrase_form")
passphrase_form_layout = QVBoxLayout()
passphrase_form_layout.setContentsMargins(
self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN, self.NO_MARGIN
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
#ModalDialog {
#QWizard_export {
min-width: 800px;
max-width: 800px;
min-height: 300px;
max-height: 800px;
background-color: #fff;
}

#ModalDialog_header_icon, #ModalDialog_header_spinner {
#QWizard_header_icon, #QWizard_header_spinner {
min-width: 80px;
max-width: 80px;
min-height: 64px;
max-height: 64px;
margin: 0px 0px 0px 30px;
}

#ModalDialog_header {
#QWizard_header {
min-height: 68px;
max-height: 68px;
margin: 0;
Expand All @@ -24,39 +24,32 @@
color: #2a319d;
}

#ModalDialog_header_line {
#QWizard_header_line {
margin: 0;
min-height: 2px;
max-height: 2px;
background-color: rgba(42, 49, 157, 0.15);
border: none;
}

#ModalDialog_body {
#QWizard_body {
font-family: 'Montserrat';
font-size: 16px;
color: #302aa3;
margin: 0;
padding: 0;
}

#ModalDialogConfirmation {
#QWizardConfirmation {
font-family: 'Montserrat';
font-size: 16px;
font-weight: 600;
color: #302aa3;
margin: 0;
}

#ModalDialog.dangerous #ModalDialogConfirmation {
color: #ff3366;
}

#ModalDialog_button_box {
border: 1px solid #ff0000;
}

#ModalDialog_button_box QPushButton {
#QWizard_button_box QWizardButton {
margin: 0px 0px 0px 12px;
height: 40px;
margin: 0;
Expand All @@ -69,7 +62,7 @@
color: #2a319d;
}

#ModalDialog_button_box QPushButton::disabled {
#QWizard_button_box QWizardButton::disabled {
border: 2px solid rgba(42, 49, 157, 0.4);
color: rgba(42, 49, 157, 0.4);
}
Expand Down
Loading

0 comments on commit 0de11dd

Please sign in to comment.