"""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()