rename: cloud sync -> instance sync; cloud -> Timemachine Network; drop Tk
Product / UI / CLI / docs rebrand. Internal package, repo, and on-disk dir names stay 'cloud_sync' / 'cloud-sync' / '.cloud-sync/' to avoid breaking existing installs; a future commit can do the file-system rename when the cost is worth paying. User-facing changes: CLI prog name: cloud-sync -> instance-sync CLI description: cloud-svc URL -> Timemachine Network endpoint Dialog title: CLOUD SYNC -> INSTANCE SYNC Dialog title: CLOUD CONFLICT -> INSTANCE CONFLICT Dialog title: CONNECT CLOUD SAVE -> CONNECT TO THE NETWORK Card label: Cloud Save -> Remote Save Skip button: Skip cloud sync -> Skip instance sync Body copy: 'the cloud' -> 'the Timemachine Network' Window titles: Cloud sync — ... -> Instance sync — ... Log prefix: cloud-sync: -> instance-sync: Error prose: 'cloud-sync token' -> 'instance-sync token' Backend changes: restic --host tag: cloud-sync -> instance-sync State.host_tag dflt: cloud-sync -> instance-sync (Existing snapshots with the old tag still pull fine; we use 'latest'.) Drop tkinter fallback: ui.py now offers Qt OR Headless. tkinter is unnecessary given we already maintain Qt + headless; one less code path to keep styled, smaller pyz. make_progress() picks Qt first, falls through to HeadlessProgress on ImportError with a stderr hint to 'pip install PySide6'. README: rebrand title + prose; note repo/dir rename deferred; call out the PySide6 install step. Conflict/login dialogs are now Qt-only; without Qt, conflict aborts (defensive) and login tells the user to paste the token manually. 52 tests green; no test-file label changes needed since they only exercise internal APIs.
This commit is contained in:
+23
-167
@@ -1,25 +1,22 @@
|
||||
"""Progress UI for cloud-sync operations.
|
||||
"""Progress UI for instance-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.
|
||||
``--no-gui`` is set OR when Qt isn't available (the only graphical path
|
||||
is Qt; there is no tkinter fallback).
|
||||
- :class:`QtProgressWindow` (in :mod:`cloud_sync.ui_qt`) — Qt modal window
|
||||
with the Prism-dark Steam-style layout.
|
||||
|
||||
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.
|
||||
The factory :func:`make_progress` picks Qt → Headless. Qt requires
|
||||
PySide6 or PyQt6 to be importable. Install via
|
||||
``pip install 'cloud-sync[qt]'`` or directly
|
||||
``pip install PySide6``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import threading
|
||||
from typing import Callable, Protocol
|
||||
|
||||
|
||||
@@ -31,16 +28,11 @@ class Progress(Protocol):
|
||||
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)
|
||||
print(f"instance-sync: {msg}", flush=True)
|
||||
|
||||
def is_cancelled(self) -> bool:
|
||||
return False
|
||||
@@ -50,147 +42,13 @@ class HeadlessProgress:
|
||||
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.
|
||||
"""Return 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).
|
||||
Order:
|
||||
1. Qt window (PySide6 or PyQt6) — preferred when available.
|
||||
2. HeadlessProgress — fallback when ``--no-gui`` is set or Qt is
|
||||
missing. Logs to stdout/stderr.
|
||||
"""
|
||||
if headless:
|
||||
return HeadlessProgress()
|
||||
@@ -198,17 +56,15 @@ def make_progress(headless: bool) -> Progress:
|
||||
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",
|
||||
"instance-sync: Qt (PySide6/PyQt6) not installed; "
|
||||
"running headless. Install with: pip install PySide6",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return HeadlessProgress()
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(
|
||||
f"instance-sync: Qt init failed ({e}); falling back to headless",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return HeadlessProgress()
|
||||
|
||||
Reference in New Issue
Block a user