From fe26ed309c63c1a185225dd42e70798ae6ed17df Mon Sep 17 00:00:00 2001 From: claude-timemachine Date: Thu, 4 Jun 2026 23:12:58 +0200 Subject: [PATCH] feat(ui): Qt progress window with Prism-Launcher-inspired dark palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cloud_sync/cli.py | 9 +- cloud_sync/restic.py | 48 +++++++++- cloud_sync/sync.py | 29 +++++- cloud_sync/ui.py | 214 +++++++++++++++++++++++++++++++++++++++++++ cloud_sync/ui_qt.py | 186 +++++++++++++++++++++++++++++++++++++ 5 files changed, 475 insertions(+), 11 deletions(-) create mode 100644 cloud_sync/ui.py create mode 100644 cloud_sync/ui_qt.py diff --git a/cloud_sync/cli.py b/cloud_sync/cli.py index 8184337..ea91250 100644 --- a/cloud_sync/cli.py +++ b/cloud_sync/cli.py @@ -83,8 +83,8 @@ def parse(argv: list[str]) -> tuple[str, Args]: def main(argv: list[str] | None = None) -> int: """CLI entrypoint. Returns exit code (0=ok, 1=user cancel, 2=error).""" - # Import here to keep CLI import light (test isolation). - from . import sync + # Imports kept here so tests of parse() don't drag UI in. + from . import sync, ui try: cmd, args = parse(sys.argv[1:] if argv is None else argv) @@ -92,8 +92,11 @@ def main(argv: list[str] | None = None) -> int: return int(e.code) if isinstance(e.code, int) else 2 action = {"pull": sync.pull, "push": sync.push}[cmd] + progress = ui.make_progress(headless=args.headless) + title = "Cloud sync — pulling" if cmd == "pull" else "Cloud sync — pushing" + try: - return action(args) + return progress.run_with(lambda: action(args, progress), title) except KeyboardInterrupt: print("cloud-sync: cancelled", file=sys.stderr) return 1 diff --git a/cloud_sync/restic.py b/cloud_sync/restic.py index fb68880..081c041 100644 --- a/cloud_sync/restic.py +++ b/cloud_sync/restic.py @@ -23,10 +23,12 @@ import stat import subprocess import sys import tempfile +import time import urllib.request import zipfile from dataclasses import dataclass from pathlib import Path +from typing import Callable from .cli import Args @@ -107,23 +109,59 @@ def run( env: dict[str, str] | None = None, cwd: Path | None = None, timeout: int = 900, + cancel_check: Callable[[], bool] | None = None, ) -> tuple[int, str]: """Run restic. Inherits stderr to caller's terminal for live progress. - Returns (returncode, captured_stdout).""" + Returns (returncode, captured_stdout). + + When cancel_check is supplied, polls every 100 ms; if it returns True, + kills restic and returns ``(-1, "")``. + """ merged_env = dict(os.environ) if env: merged_env.update(env) - p = subprocess.run( # noqa: S603 — controlled invocation + + if cancel_check is None: + p_run = subprocess.run( # noqa: S603 + [str(binary), *args], + cwd=str(cwd) if cwd else None, + env=merged_env, + stdout=subprocess.PIPE, + stderr=sys.stderr, + text=True, + timeout=timeout, + check=False, + ) + return p_run.returncode, p_run.stdout + + # Cancel-capable path: spawn + poll + p = subprocess.Popen( # noqa: S603 [str(binary), *args], cwd=str(cwd) if cwd else None, env=merged_env, stdout=subprocess.PIPE, stderr=sys.stderr, text=True, - timeout=timeout, - check=False, ) - return p.returncode, p.stdout + deadline = time.monotonic() + timeout + while p.poll() is None: + if cancel_check(): + p.kill() + try: + p.wait(timeout=5) + except subprocess.TimeoutExpired: + pass + return -1, "" + if time.monotonic() > deadline: + p.kill() + try: + p.wait(timeout=5) + except subprocess.TimeoutExpired: + pass + raise subprocess.TimeoutExpired([str(binary), *args], timeout) + time.sleep(0.1) + out = p.stdout.read() if p.stdout else "" + return p.returncode, out # --------------------------------------------------------------------------- diff --git a/cloud_sync/sync.py b/cloud_sync/sync.py index 102c6c6..0b4e277 100644 --- a/cloud_sync/sync.py +++ b/cloud_sync/sync.py @@ -23,25 +23,33 @@ from pathlib import Path from . import restic, scope as scopemod from .cli import Args from .creds import read_credentials +from .ui import HeadlessProgress, Progress -def pull(args: Args) -> int: +def pull(args: Args, progress: Progress | None = None) -> int: """Restore latest snapshot's files into pack_folder. If the repo has no snapshots yet, this is a no-op (first run on this machine; nothing to restore). """ + ui = progress or HeadlessProgress() + ui.set_status("Reading credentials…") discord_id, password = read_credentials(args.token_file) + + ui.set_status("Resolving restic binary…") binary = restic.resolve_binary(args) repo = _restic_repo(args.url, discord_id, password) env = _restic_env() - # Check whether any snapshots exist + ui.set_status("Checking remote snapshots…") code, out = restic.run( binary, ["-r", repo, "--insecure-no-password", "snapshots", "--json", "--latest", "1"], env=env, + cancel_check=ui.is_cancelled, ) + if code == -1: + return 1 if code != 0: print( f"cloud-sync: failed to list snapshots (restic exit {code})", @@ -50,6 +58,7 @@ def pull(args: Args) -> int: return 2 stripped = out.strip() if stripped in ("", "null", "[]"): + ui.set_status("No snapshots yet — nothing to pull") print( "cloud-sync: no snapshots yet for this user " "(first run on this machine?); nothing to pull" @@ -59,6 +68,7 @@ def pull(args: Args) -> int: scope = scopemod.load(args.pack_folder) _, exclude_from = scopemod.materialize_for_restic(args.pack_folder, scope) + ui.set_status("Restoring files…") code, _ = restic.run( binary, [ @@ -68,17 +78,25 @@ def pull(args: Args) -> int: "--exclude-file", str(exclude_from), ], env=env, + cancel_check=ui.is_cancelled, ) + if code == -1: + return 1 if code != 0: print(f"cloud-sync: restic restore failed (exit {code})", file=sys.stderr) return 2 + ui.set_status("Pull complete") print("cloud-sync: pull ok") return 0 -def push(args: Args) -> int: +def push(args: Args, progress: Progress | None = None) -> int: """Snapshot the in-scope files into the user's repo.""" + ui = progress or HeadlessProgress() + ui.set_status("Reading credentials…") discord_id, password = read_credentials(args.token_file) + + ui.set_status("Resolving restic binary…") binary = restic.resolve_binary(args) repo = _restic_repo(args.url, discord_id, password) env = _restic_env() @@ -86,6 +104,7 @@ def push(args: Args) -> int: scope = scopemod.load(args.pack_folder) files_from, exclude_from = scopemod.materialize_for_restic(args.pack_folder, scope) + ui.set_status("Uploading snapshot…") code, _ = restic.run( binary, [ @@ -98,10 +117,14 @@ def push(args: Args) -> int: ], env=env, cwd=args.pack_folder, + cancel_check=ui.is_cancelled, ) + if code == -1: + return 1 if code != 0: print(f"cloud-sync: restic backup failed (exit {code})", file=sys.stderr) return 2 + ui.set_status("Push complete") print("cloud-sync: push ok") return 0 diff --git a/cloud_sync/ui.py b/cloud_sync/ui.py new file mode 100644 index 0000000..be829e0 --- /dev/null +++ b/cloud_sync/ui.py @@ -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() diff --git a/cloud_sync/ui_qt.py b/cloud_sync/ui_qt.py new file mode 100644 index 0000000..da3e8ef --- /dev/null +++ b/cloud_sync/ui_qt.py @@ -0,0 +1,186 @@ +"""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()