feat(ui): Qt progress window with Prism-Launcher-inspired dark palette
CI / test (3.10) (push) Successful in 8s
CI / test (3.11) (push) Successful in 6s
CI / test (3.12) (push) Successful in 7s
CI / build-pyz (push) Successful in 4s
CI / release (push) Has been skipped

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.
This commit is contained in:
2026-06-04 23:12:58 +02:00
parent 49d1cb3280
commit fe26ed309c
5 changed files with 475 additions and 11 deletions
+214
View File
@@ -0,0 +1,214 @@
"""Progress UI for cloud-sync operations.
Two implementations sharing the ``Progress`` protocol:
- :class:`HeadlessProgress` — no window; prints to stdout/stderr. Used when
``--no-gui`` is set or when tkinter import fails.
- :class:`TkProgressWindow` — tkinter modal window with status text +
indeterminate progress bar + Cancel button. Stdlib only.
The window runs in the main thread; the restic subprocess runs in a
worker thread. The window polls every 100 ms to check whether the worker
finished and whether the user clicked Cancel.
Future option: PySide6 / PyQt6 for a real Qt window matching Prism's
look. Gated behind ``cloud-sync[qt]`` extra in ``pyproject.toml``; not
implemented yet.
"""
from __future__ import annotations
import sys
import threading
from typing import Callable, Protocol
class Progress(Protocol):
"""Interface for status + cancellation reporting during a sync run."""
def set_status(self, msg: str) -> None: ...
def is_cancelled(self) -> bool: ...
def run_with(self, worker: Callable[[], int], title: str) -> int: ...
# ---------------------------------------------------------------------------
# Headless (text-only)
# ---------------------------------------------------------------------------
class HeadlessProgress:
"""No-op progress. Status messages go to stdout, errors to stderr."""
def set_status(self, msg: str) -> None:
print(f"cloud-sync: {msg}", flush=True)
def is_cancelled(self) -> bool:
return False
def run_with(self, worker: Callable[[], int], title: str) -> int:
self.set_status(title)
return worker()
# ---------------------------------------------------------------------------
# Tk window
# ---------------------------------------------------------------------------
class TkProgressWindow:
"""Modal tkinter window with status + indeterminate progress bar.
Usage::
ui = TkProgressWindow()
rc = ui.run_with(lambda: do_the_work(ui), title="Cloud pull")
"""
def __init__(self) -> None:
# Defer imports so headless fall-through doesn't blow up on
# tkinter-less Python builds.
import tkinter as tk
from tkinter import ttk
self._tk = tk
self._ttk = ttk
self._root = tk.Tk()
self._root.title("Cloud sync")
self._root.geometry("440x160")
self._root.resizable(False, False)
self._root.attributes("-topmost", True)
self._root.protocol("WM_DELETE_WINDOW", self._on_close)
frame = ttk.Frame(self._root, padding=20)
frame.pack(fill="both", expand=True)
self._title_var = tk.StringVar(value="Working…")
ttk.Label(
frame,
textvariable=self._title_var,
font=("TkDefaultFont", 11, "bold"),
).pack(anchor="w")
self._status_var = tk.StringVar(value="Starting…")
ttk.Label(frame, textvariable=self._status_var).pack(anchor="w", pady=(4, 8))
self._bar = ttk.Progressbar(frame, mode="indeterminate", length=400)
self._bar.pack(fill="x")
self._bar.start(15)
button_row = ttk.Frame(frame)
button_row.pack(fill="x", pady=(12, 0))
self._cancel_btn = ttk.Button(
button_row, text="Cancel", command=self._on_close
)
self._cancel_btn.pack(side="right")
self._cancelled = False
self._worker_rc: int | None = None
self._worker_exc: BaseException | None = None
self._center_on_screen()
# -- public API ----------------------------------------------------
def set_status(self, msg: str) -> None:
try:
self._status_var.set(msg)
except self._tk.TclError:
# window was destroyed
pass
def is_cancelled(self) -> bool:
return self._cancelled
def run_with(self, worker: Callable[[], int], title: str) -> int:
self._title_var.set(title)
self._status_var.set("Starting…")
def thread_target() -> None:
try:
self._worker_rc = worker()
except BaseException as e: # noqa: BLE001
self._worker_exc = e
t = threading.Thread(target=thread_target, daemon=True)
t.start()
self._root.after(100, self._poll, t)
self._root.mainloop()
if self._worker_exc is not None:
raise self._worker_exc
if self._worker_rc is None:
# User cancelled before worker reported
return 1
return self._worker_rc
# -- internals -----------------------------------------------------
def _poll(self, thread: threading.Thread) -> None:
if not thread.is_alive():
try:
self._bar.stop()
self._root.quit()
self._root.destroy()
except self._tk.TclError:
pass
return
self._root.after(100, self._poll, thread)
def _on_close(self) -> None:
# Mark cancelled; worker checks via is_cancelled. Don't destroy
# window — polling loop will clean up once worker exits.
self._cancelled = True
try:
self._status_var.set("Cancelling…")
self._cancel_btn.configure(state="disabled")
except self._tk.TclError:
pass
def _center_on_screen(self) -> None:
self._root.update_idletasks()
w = self._root.winfo_width()
h = self._root.winfo_height()
sw = self._root.winfo_screenwidth()
sh = self._root.winfo_screenheight()
x = max(0, (sw // 2) - (w // 2))
y = max(0, (sh // 2) - (h // 2))
self._root.geometry(f"+{x}+{y}")
# ---------------------------------------------------------------------------
# Factory
# ---------------------------------------------------------------------------
def make_progress(headless: bool) -> Progress:
"""Pick the best Progress impl for the runtime + flags.
Preference order:
1. Qt (PySide6 or PyQt6) — modern look, matches Prism's aesthetic.
2. tkinter — stdlib fallback; ships with most Python distributions.
3. headless — print to stdout/stderr only.
Override via ``--no-gui`` (forces headless).
"""
if headless:
return HeadlessProgress()
try:
from .ui_qt import QtProgressWindow
return QtProgressWindow()
except ImportError:
pass
except Exception as e: # noqa: BLE001
print(
f"cloud-sync: Qt init failed ({e}); falling back to tkinter",
file=sys.stderr,
)
try:
return TkProgressWindow()
except Exception as e: # noqa: BLE001
print(
f"cloud-sync: tkinter unavailable ({e}); falling back to headless",
file=sys.stderr,
)
return HeadlessProgress()