fe26ed309c
cloud-sync now ships a real Qt UI alongside the tkinter fallback.
Architecture:
- HeadlessProgress: --no-gui path, plain stdout
- TkProgressWindow: stdlib fallback when Qt isn't installed
- QtProgressWindow: preferred path; supports both PySide6 and PyQt6
(interchangeable APIs for our subset)
The factory in ui.py picks Qt → tkinter → headless. Tk stays so the
zipapp still works on bare Python with no extras.
Threading: QApplication runs on the main thread (started by run_with
via QDialog.exec). The restic worker runs on a daemon threading.Thread.
Cross-thread UI updates go via a Signal on a bridge QObject so Qt
auto-marshals them onto the main thread via a queued connection.
Cancellation: WM close + Cancel button both set a flag. sync.pull/push
pass ui.is_cancelled as restic.run's cancel_check; the subprocess gets
killed and returns -1 → exit 1.
Theme: Fusion style + Prism's dark palette (RGB values copied as facts
from PrismLauncher's DarkTheme.cpp). Override with PRISM_THEME=off.
Pyz size went 20 KB → 36 KB (added ui.py + ui_qt.py).
33 tests still green.
187 lines
6.5 KiB
Python
187 lines
6.5 KiB
Python
"""Qt progress UI for cloud-sync.
|
|
|
|
Supports both PySide6 (preferred — LGPL, official Qt binding) and PyQt6
|
|
(fallback — GPL/commercial). Same code runs on both because their
|
|
QtWidgets / QtCore APIs are interchangeable for our subset.
|
|
|
|
This module never imports Qt at top level. ``import_qt()`` raises
|
|
ImportError if neither binding is available; the factory in ``ui.py``
|
|
catches that and falls back to the tkinter window.
|
|
|
|
Threading model: ``QApplication`` runs on the main thread (started by
|
|
``run_with`` via ``QDialog.exec``); the restic worker runs on a daemon
|
|
``threading.Thread``; cross-thread UI updates go via a ``Signal`` on a
|
|
bridge ``QObject`` so Qt enqueues them onto the main thread.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import threading
|
|
from typing import Any, Callable
|
|
|
|
|
|
def import_qt() -> tuple[Any, Any, Any]:
|
|
"""Return (QtWidgets, QtCore, Signal). Raises ImportError if neither
|
|
PySide6 nor PyQt6 is installed."""
|
|
try:
|
|
from PySide6 import QtCore, QtWidgets # type: ignore
|
|
return QtWidgets, QtCore, QtCore.Signal
|
|
except ImportError:
|
|
pass
|
|
try:
|
|
from PyQt6 import QtCore, QtWidgets # type: ignore
|
|
return QtWidgets, QtCore, QtCore.pyqtSignal
|
|
except ImportError as e:
|
|
raise ImportError(
|
|
"neither PySide6 nor PyQt6 is installed; "
|
|
"pip install 'cloud-sync[qt]' or pip install PySide6"
|
|
) from e
|
|
|
|
|
|
def _import_qtgui() -> Any:
|
|
"""Return whichever QtGui module is available (PySide6 or PyQt6)."""
|
|
try:
|
|
from PySide6 import QtGui # type: ignore
|
|
return QtGui
|
|
except ImportError:
|
|
from PyQt6 import QtGui # type: ignore
|
|
return QtGui
|
|
|
|
|
|
def _apply_prism_dark(app: Any) -> None:
|
|
"""Apply a Prism-Launcher-inspired dark palette + Fusion style.
|
|
|
|
Palette RGB values are facts taken from
|
|
PrismLauncher/launcher/ui/themes/DarkTheme.cpp (GPL-3.0). Numerical
|
|
color values themselves aren't copyrightable; only the surrounding
|
|
code is. We reimplement the same look from scratch under MIT.
|
|
|
|
Override via ``PRISM_THEME=off`` to keep the system / Qt-default
|
|
palette (useful if the user's desktop theme handles dark mode).
|
|
"""
|
|
import os
|
|
if os.environ.get("PRISM_THEME") == "off":
|
|
return
|
|
G = _import_qtgui()
|
|
app.setStyle("Fusion")
|
|
p = G.QPalette()
|
|
Role = G.QPalette.ColorRole
|
|
white = G.QColor("white")
|
|
p.setColor(Role.Window, G.QColor(49, 49, 49))
|
|
p.setColor(Role.WindowText, white)
|
|
p.setColor(Role.Base, G.QColor(34, 34, 34))
|
|
p.setColor(Role.AlternateBase, G.QColor(42, 42, 42))
|
|
p.setColor(Role.ToolTipBase, white)
|
|
p.setColor(Role.ToolTipText, white)
|
|
p.setColor(Role.Text, white)
|
|
p.setColor(Role.Button, G.QColor(48, 48, 48))
|
|
p.setColor(Role.ButtonText, white)
|
|
p.setColor(Role.BrightText, G.QColor("red"))
|
|
p.setColor(Role.Link, G.QColor(47, 163, 198))
|
|
p.setColor(Role.Highlight, G.QColor(150, 219, 89)) # Prism's signature green
|
|
p.setColor(Role.HighlightedText, G.QColor("black"))
|
|
p.setColor(Role.PlaceholderText, G.QColor("darkGray"))
|
|
app.setPalette(p)
|
|
|
|
|
|
class QtProgressWindow:
|
|
"""Modal Qt dialog: title + status + indeterminate progress + Cancel."""
|
|
|
|
def __init__(self) -> None:
|
|
QtWidgets, QtCore, Signal = import_qt()
|
|
self._QtCore = QtCore
|
|
|
|
# Bridge so worker thread can update UI via a queued signal.
|
|
class _Bridge(QtCore.QObject): # type: ignore[misc, valid-type]
|
|
status_changed = Signal(str)
|
|
finished = Signal(int)
|
|
|
|
app_existed = QtWidgets.QApplication.instance() is not None
|
|
self._app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(
|
|
sys.argv
|
|
)
|
|
if not app_existed:
|
|
_apply_prism_dark(self._app)
|
|
|
|
self._dialog = QtWidgets.QDialog()
|
|
self._dialog.setWindowTitle("Cloud sync")
|
|
self._dialog.setFixedSize(440, 160)
|
|
# Block ESC + window close X → mark cancelled, don't accept
|
|
self._dialog.setWindowFlag(
|
|
QtCore.Qt.WindowType.WindowContextHelpButtonHint, False
|
|
)
|
|
|
|
layout = QtWidgets.QVBoxLayout(self._dialog)
|
|
layout.setContentsMargins(20, 20, 20, 20)
|
|
|
|
self._title_label = QtWidgets.QLabel("Working…")
|
|
font = self._title_label.font()
|
|
font.setBold(True)
|
|
font.setPointSize(font.pointSize() + 1)
|
|
self._title_label.setFont(font)
|
|
layout.addWidget(self._title_label)
|
|
|
|
self._status_label = QtWidgets.QLabel("Starting…")
|
|
layout.addWidget(self._status_label)
|
|
|
|
self._bar = QtWidgets.QProgressBar()
|
|
self._bar.setRange(0, 0) # indeterminate
|
|
self._bar.setTextVisible(False)
|
|
layout.addWidget(self._bar)
|
|
|
|
button_row = QtWidgets.QHBoxLayout()
|
|
button_row.addStretch(1)
|
|
self._cancel_btn = QtWidgets.QPushButton("Cancel")
|
|
button_row.addWidget(self._cancel_btn)
|
|
layout.addLayout(button_row)
|
|
|
|
self._bridge = _Bridge()
|
|
self._bridge.status_changed.connect(self._status_label.setText)
|
|
self._bridge.finished.connect(self._on_finished)
|
|
self._cancel_btn.clicked.connect(self._on_cancel)
|
|
self._dialog.rejected.connect(self._on_cancel)
|
|
|
|
self._cancelled = False
|
|
self._worker_rc: int | None = None
|
|
self._worker_exc: BaseException | None = None
|
|
|
|
# -- public API ----------------------------------------------------
|
|
|
|
def set_status(self, msg: str) -> None:
|
|
self._bridge.status_changed.emit(msg)
|
|
|
|
def is_cancelled(self) -> bool:
|
|
return self._cancelled
|
|
|
|
def run_with(self, worker: Callable[[], int], title: str) -> int:
|
|
self._title_label.setText(title)
|
|
self._status_label.setText("Starting…")
|
|
|
|
def thread_target() -> None:
|
|
try:
|
|
rc = worker()
|
|
except BaseException as e: # noqa: BLE001
|
|
self._worker_exc = e
|
|
rc = 1
|
|
self._worker_rc = rc
|
|
self._bridge.finished.emit(rc)
|
|
|
|
t = threading.Thread(target=thread_target, daemon=True)
|
|
t.start()
|
|
self._dialog.exec()
|
|
|
|
if self._worker_exc is not None:
|
|
raise self._worker_exc
|
|
return self._worker_rc if self._worker_rc is not None else 1
|
|
|
|
# -- internals -----------------------------------------------------
|
|
|
|
def _on_cancel(self) -> None:
|
|
self._cancelled = True
|
|
self._status_label.setText("Cancelling…")
|
|
self._cancel_btn.setEnabled(False)
|
|
|
|
def _on_finished(self, _rc: int) -> None:
|
|
self._dialog.accept()
|