feat(ui): Qt progress window with Prism-Launcher-inspired dark palette
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:
@@ -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()
|
||||
Reference in New Issue
Block a user