dangerzone/dangerzone/gui/logic.py
Alex Pyrgiotis 4f66353639
Add dark mode logic in our dialogs
Make our dialogs set the OSColorMode CSS property, so that we can
properly style them.

Refs #528
2023-10-02 16:34:56 +03:00

364 lines
12 KiB
Python

import logging
import os
import platform
import shlex
import subprocess
import typing
from pathlib import Path
from typing import Dict, Optional
from colorama import Fore
# FIXME: See https://github.com/freedomofpress/dangerzone/issues/320 for more details.
if typing.TYPE_CHECKING:
from PySide2 import QtCore, QtGui, QtWidgets
from . import Application
else:
try:
from PySide6 import QtCore, QtGui, QtWidgets
except ImportError:
from PySide2 import QtCore, QtGui, QtWidgets
if platform.system() == "Linux":
from xdg.DesktopEntry import DesktopEntry
from ..isolation_provider.base import IsolationProvider
from ..logic import DangerzoneCore
from ..settings import Settings
from ..util import get_resource_path, replace_control_chars
log = logging.getLogger(__name__)
class DangerzoneGui(DangerzoneCore):
"""
Singleton of shared state / functionality for the GUI and core app logic
"""
def __init__(
self, app: "Application", isolation_provider: IsolationProvider
) -> None:
super().__init__(isolation_provider)
# Qt app
self.app = app
# Only one output dir is supported in the GUI
self.output_dir: str = ""
# Preload font
self.fixed_font = QtGui.QFontDatabase.systemFont(QtGui.QFontDatabase.FixedFont)
# Preload list of PDF viewers on computer
self.pdf_viewers = self._find_pdf_viewers()
# Are we done waiting (for Docker Desktop to be installed, or for container to install)
self.is_waiting_finished = False
def get_window_icon(self) -> QtGui.QIcon:
if platform.system() == "Windows":
path = get_resource_path("dangerzone.ico")
else:
path = get_resource_path("icon.png")
return QtGui.QIcon(path)
def open_pdf_viewer(self, filename: str) -> None:
if platform.system() == "Darwin":
# Open in Preview
args = ["open", "-a", "Preview.app", filename]
# Run
args_str = replace_control_chars(" ".join(shlex.quote(s) for s in args))
log.info(Fore.YELLOW + "> " + Fore.CYAN + args_str)
subprocess.run(args)
elif platform.system() == "Windows":
os.startfile(Path(filename)) # type: ignore [attr-defined]
elif platform.system() == "Linux":
# Get the PDF reader command
args = shlex.split(self.pdf_viewers[self.settings.get("open_app")])
# %f, %F, %u, and %U are filenames or URLS -- so replace with the file to open
for i in range(len(args)):
if (
args[i] == "%f"
or args[i] == "%F"
or args[i] == "%u"
or args[i] == "%U"
):
args[i] = filename
# Open as a background process
args_str = replace_control_chars(" ".join(shlex.quote(s) for s in args))
log.info(Fore.YELLOW + "> " + Fore.CYAN + args_str)
subprocess.Popen(args)
def _find_pdf_viewers(self) -> Dict[str, str]:
pdf_viewers: Dict[str, str] = {}
if platform.system() == "Linux":
# Find all .desktop files
for search_path in [
"/usr/share/applications",
"/usr/local/share/applications",
os.path.expanduser("~/.local/share/applications"),
]:
try:
for filename in os.listdir(search_path):
full_filename = os.path.join(search_path, filename)
if os.path.splitext(filename)[1] == ".desktop":
# See which ones can open PDFs
desktop_entry = DesktopEntry(full_filename)
if (
"application/pdf" in desktop_entry.getMimeTypes()
and desktop_entry.getName() != "dangerzone"
):
pdf_viewers[
desktop_entry.getName()
] = desktop_entry.getExec()
except FileNotFoundError:
pass
return pdf_viewers
class Dialog(QtWidgets.QDialog):
def __init__(
self,
dangerzone: DangerzoneGui,
title: str,
ok_text: str = "Ok",
has_cancel: bool = True,
cancel_text: str = "Cancel",
extra_button_text: Optional[str] = None,
) -> None:
super().__init__()
self.dangerzone = dangerzone
self.setProperty("OSColorMode", self.dangerzone.app.os_color_mode.value)
self.setWindowTitle(title)
self.setWindowIcon(self.dangerzone.get_window_icon())
self.setModal(True)
flags = (
QtCore.Qt.CustomizeWindowHint
| QtCore.Qt.WindowTitleHint
| QtCore.Qt.WindowSystemMenuHint
| QtCore.Qt.WindowCloseButtonHint
| QtCore.Qt.WindowStaysOnTopHint
)
self.setWindowFlags(flags)
message_layout = self.create_layout()
self.ok_button = QtWidgets.QPushButton(ok_text)
self.ok_button.clicked.connect(self.clicked_ok)
self.extra_button: Optional[QtWidgets.QPushButton] = None
if extra_button_text:
self.extra_button = QtWidgets.QPushButton(extra_button_text)
self.extra_button.clicked.connect(self.clicked_extra)
self.cancel_button: Optional[QtWidgets.QPushButton] = None
if has_cancel:
self.cancel_button = QtWidgets.QPushButton(cancel_text)
self.cancel_button.clicked.connect(self.clicked_cancel)
buttons_layout = self.create_buttons_layout()
layout = QtWidgets.QVBoxLayout()
layout.addLayout(message_layout)
layout.addSpacing(10)
layout.addLayout(buttons_layout)
self.setLayout(layout)
def create_buttons_layout(self) -> QtWidgets.QHBoxLayout:
buttons_layout = QtWidgets.QHBoxLayout()
buttons_layout.addStretch()
buttons_layout.addWidget(self.ok_button)
if self.extra_button:
buttons_layout.addWidget(self.extra_button)
if self.cancel_button:
buttons_layout.addWidget(self.cancel_button)
return buttons_layout
def create_layout(self) -> QtWidgets.QBoxLayout:
raise NotImplementedError("Dangerzone dialogs must implement this method")
def clicked_ok(self) -> None:
self.done(int(QtWidgets.QDialog.Accepted))
def clicked_extra(self) -> None:
self.done(2)
def clicked_cancel(self) -> None:
self.done(int(QtWidgets.QDialog.Rejected))
def launch(self) -> int:
return self.exec_()
class Alert(Dialog):
def __init__( # type: ignore [no-untyped-def]
self,
*args,
message: str = "",
**kwargs,
) -> None:
self.message = message
kwargs.setdefault("title", "dangerzone")
super().__init__(*args, **kwargs)
def create_layout(self) -> QtWidgets.QBoxLayout:
logo = QtWidgets.QLabel()
logo.setPixmap(
QtGui.QPixmap.fromImage(QtGui.QImage(get_resource_path("icon.png")))
)
label = QtWidgets.QLabel()
label.setText(self.message)
label.setWordWrap(True)
label.setOpenExternalLinks(True)
message_layout = QtWidgets.QHBoxLayout()
message_layout.addWidget(logo)
message_layout.addSpacing(10)
message_layout.addWidget(label, stretch=1)
return message_layout
class UpdateDialog(Dialog):
def __init__( # type: ignore [no-untyped-def]
self,
*args,
intro_msg: Optional[str] = None,
middle_widget: Optional[QtWidgets.QWidget] = None,
epilogue_msg: Optional[str] = None,
**kwargs,
) -> None:
self.intro_msg = intro_msg
self.middle_widget = middle_widget
self.epilogue_msg = epilogue_msg
super().__init__(*args, **kwargs)
def create_layout(self) -> QtWidgets.QBoxLayout:
self.setMinimumWidth(500)
message_layout = QtWidgets.QVBoxLayout()
if self.intro_msg is not None:
intro = QtWidgets.QLabel()
intro.setText(self.intro_msg)
intro.setWordWrap(True)
intro.setAlignment(QtCore.Qt.AlignCenter)
intro.setOpenExternalLinks(True)
message_layout.addWidget(intro)
message_layout.addSpacing(10)
if self.middle_widget is not None:
self.middle_widget.setParent(self)
message_layout.addWidget(self.middle_widget)
message_layout.addSpacing(10)
if self.epilogue_msg is not None:
epilogue = QtWidgets.QLabel()
epilogue.setText(self.epilogue_msg)
epilogue.setWordWrap(True)
epilogue.setOpenExternalLinks(True)
message_layout.addWidget(epilogue)
message_layout.addSpacing(10)
return message_layout
class CollapsibleBox(QtWidgets.QWidget):
"""Create a widget that can show/hide its contents when you click on it.
The credits for this code go to eyllanesc's answer in StackOverflow:
https://stackoverflow.com/a/52617714. We have made the following improvements:
1. Adapt the code to PySide.
2. Resize the window once the box uncollapses.
3. Add type hints.
"""
def __init__(self, title: str, parent: Optional[QtWidgets.QWidget] = None):
super(CollapsibleBox, self).__init__(parent)
self.toggle_button = QtWidgets.QToolButton(
text=title,
checkable=True,
checked=False,
)
self.toggle_button.setStyleSheet("QToolButton { border: none; }")
self.toggle_button.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon)
self.toggle_button.setArrowType(QtCore.Qt.RightArrow)
self.toggle_button.clicked.connect(self.on_click)
self.toggle_animation = QtCore.QParallelAnimationGroup(self)
self.content_area = QtWidgets.QScrollArea(maximumHeight=0, minimumHeight=0)
self.content_area.setSizePolicy(
QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed
)
self.content_area.setFrameShape(QtWidgets.QFrame.NoFrame)
lay = QtWidgets.QVBoxLayout(self)
lay.setSpacing(0)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(self.toggle_button)
lay.addWidget(self.content_area)
self.toggle_animation.addAnimation(
QtCore.QPropertyAnimation(self, b"minimumHeight")
)
self.toggle_animation.addAnimation(
QtCore.QPropertyAnimation(self, b"maximumHeight")
)
self.toggle_animation.addAnimation(
QtCore.QPropertyAnimation(self.content_area, b"maximumHeight")
)
self.toggle_animation.finished.connect(self.on_animation_finished)
def on_click(self) -> None:
checked = self.toggle_button.isChecked()
self.toggle_button.setArrowType(
QtCore.Qt.DownArrow if checked else QtCore.Qt.RightArrow
)
self.toggle_animation.setDirection(
QtCore.QAbstractAnimation.Forward
if checked
else QtCore.QAbstractAnimation.Backward
)
self.toggle_animation.start()
def on_animation_finished(self) -> None:
if not self.toggle_button.isChecked():
content_height = self.content_area.layout().sizeHint().height()
parent = self.parent()
assert isinstance(parent, QtWidgets.QWidget)
parent.resize(parent.width(), parent.height() - content_height)
def setContentLayout(self, layout: QtWidgets.QBoxLayout) -> None:
lay = self.content_area.layout()
del lay
self.content_area.setLayout(layout)
collapsed_height = self.sizeHint().height() - self.content_area.maximumHeight()
content_height = layout.sizeHint().height()
for i in range(self.toggle_animation.animationCount()):
animation = self.toggle_animation.animationAt(i)
assert isinstance(animation, QtCore.QPropertyAnimation)
animation.setDuration(60)
animation.setStartValue(collapsed_height)
animation.setEndValue(collapsed_height + content_height)
content_animation = self.toggle_animation.animationAt(
self.toggle_animation.animationCount() - 1
)
assert isinstance(content_animation, QtCore.QPropertyAnimation)
content_animation.setDuration(60)
content_animation.setStartValue(0)
content_animation.setEndValue(content_height)