Initial public release

This commit is contained in:
2026-07-17 15:29:53 -04:00
commit 2d71ce77a1
81 changed files with 32056 additions and 0 deletions
+220
View File
@@ -0,0 +1,220 @@
"""Backup restore dialog - lists all backups and offers restore/delete actions."""
import tkinter as tk
from tkinter import messagebox
class BackupDialog(tk.Toplevel):
"""Modal dialog for browsing and restoring backups."""
PAGE_SIZE = 50
def __init__(self, parent, backup_manager, on_restore=None):
super().__init__(parent)
self.backup_manager = backup_manager
self.on_restore = on_restore
self._visible_count = self.PAGE_SIZE
self.title("Backup Manager")
self.geometry("520x520")
self.minsize(400, 400)
self.configure(bg="#2D2D3D")
self.transient(parent)
self.grab_set()
self._build_ui()
self._refresh()
def _build_ui(self):
header = tk.Frame(self, bg="#1E1E2E")
header.pack(fill="x")
tk.Label(header, text="Backups", bg="#1E1E2E", fg="white",
font=("Segoe UI", 12, "bold"), pady=10).pack(side="left", padx=14)
info = tk.Label(self, text="Automatic backups are taken after changes are made.\n"
"Select a backup to restore or delete it.",
bg="#2D2D3D", fg="#AAAAAA", font=("Segoe UI", 9),
justify="left")
info.pack(anchor="w", padx=14, pady=(8, 4))
list_frame = tk.Frame(self, bg="#2D2D3D")
list_frame.pack(fill="both", expand=True, padx=14, pady=(4, 8))
header_row = tk.Frame(list_frame, bg="#1E1E2E")
header_row.pack(fill="x")
tk.Label(header_row, text="When", bg="#1E1E2E", fg="#888888",
font=("Segoe UI", 9, "bold"), anchor="w").pack(side="left", fill="x",
expand=True, padx=(8, 4), pady=4)
tk.Label(header_row, text="Size", bg="#1E1E2E", fg="#888888",
font=("Segoe UI", 9, "bold"), anchor="w",
width=10).pack(side="left", padx=4, pady=4)
canvas_frame = tk.Frame(list_frame, bg="#1E1E2E")
canvas_frame.pack(fill="both", expand=True)
self._list_canvas = tk.Canvas(canvas_frame, bg="#1E1E2E", highlightthickness=0)
scrollbar = tk.Scrollbar(canvas_frame, orient="vertical",
command=self._list_canvas.yview)
self._list_inner = tk.Frame(self._list_canvas, bg="#1E1E2E")
self._list_inner.bind("<Configure>",
lambda e: self._list_canvas.configure(
scrollregion=self._list_canvas.bbox("all")))
self._list_canvas.create_window((0, 0), window=self._list_inner, anchor="nw", tags="inner")
self._list_canvas.configure(yscrollcommand=scrollbar.set)
self._list_canvas.bind("<Configure>",
lambda e: self._list_canvas.itemconfig("inner", width=e.width))
self._list_canvas.bind_all("<MouseWheel>",
lambda e: self._list_canvas.yview_scroll(
-1 * (e.delta // 120), "units"))
self._list_canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
footer = tk.Frame(self, bg="#1E1E2E")
footer.pack(fill="x", side="bottom")
self._status_label = tk.Label(footer, text="", bg="#1E1E2E", fg="#AAAAAA",
font=("Segoe UI", 9))
self._status_label.pack(side="left", padx=14, pady=10)
tk.Button(footer, text="Close", command=self.destroy, bg="#3D3D5C", fg="white",
font=("Segoe UI", 9), relief="flat",
padx=14, pady=4).pack(side="right", padx=8, pady=8)
tk.Button(footer, text="Create Backup Now", command=self._create_now,
bg="#4A4A6A", fg="white", font=("Segoe UI", 9), relief="flat",
padx=10, pady=4).pack(side="right", padx=4, pady=8)
def _refresh(self):
"""Rebuild the backup list."""
for widget in self._list_inner.winfo_children():
widget.destroy()
backups = self.backup_manager.list_backups()
total_count = len(backups)
if self._visible_count < self.PAGE_SIZE:
self._visible_count = self.PAGE_SIZE
shown = min(self._visible_count, total_count)
if not backups:
tk.Label(self._list_inner, text="No backups yet.",
bg="#1E1E2E", fg="#666666", font=("Segoe UI", 10, "italic"),
pady=20).pack()
else:
for i, info in enumerate(backups[:shown]):
self._create_row(info, i)
remaining = total_count - shown
if remaining > 0:
self._create_load_more_row(remaining)
total = self.backup_manager.total_size_formatted()
if total_count and shown < total_count:
label = (f"Showing {shown} of {total_count} backup"
f"{'s' if total_count != 1 else ''}{total} on disk")
else:
label = (f"{total_count} backup{'s' if total_count != 1 else ''}"
f"{total} on disk")
self._status_label.config(text=label)
def _create_load_more_row(self, remaining):
row = tk.Frame(self._list_inner, bg="#1E1E2E")
row.pack(fill="x", pady=6)
take = min(self.PAGE_SIZE, remaining)
tk.Button(row, text=f"Load {take} more ({remaining} remaining)",
command=self._load_more,
bg="#3D3D5C", fg="white", font=("Segoe UI", 9),
relief="flat", padx=14, pady=4).pack(pady=4)
def _load_more(self):
self._visible_count += self.PAGE_SIZE
self._refresh()
# Jump to the bottom so the newly-loaded rows are visible
self._list_canvas.update_idletasks()
self._list_canvas.yview_moveto(1.0)
def _create_row(self, info, index):
bg = "#222230" if index % 2 == 0 else "#1E1E2E"
hover_bg = "#2A2A3A"
row = tk.Frame(self._list_inner, bg=bg, cursor="hand2")
row.pack(fill="x")
ts_label = tk.Label(row, text=info.human_readable_time(),
bg=bg, fg="white", font=("Segoe UI", 10),
anchor="w", padx=8, pady=6)
ts_label.pack(side="left", fill="x", expand=True)
size_label = tk.Label(row, text=info.human_readable_size(),
bg=bg, fg="#AAAAAA", font=("Segoe UI", 9),
anchor="w", width=10, padx=4, pady=6)
size_label.pack(side="left")
del_btn = tk.Button(row, text="×", bg="#3D3D5C", fg="#FF7777",
font=("Segoe UI", 11, "bold"), relief="flat",
width=2,
command=lambda p=info.path: self._delete_one(p))
del_btn.pack(side="right", padx=(4, 8), pady=3)
restore_btn = tk.Button(row, text="Restore", bg="#27AE60", fg="white",
font=("Segoe UI", 9), relief="flat",
padx=10, pady=2,
command=lambda p=info.path: self._restore_one(p))
restore_btn.pack(side="right", padx=4, pady=3)
for w in (row, ts_label, size_label):
w.bind("<Enter>", lambda e, r=row: self._set_bg(r, hover_bg))
w.bind("<Leave>", lambda e, r=row, b=bg: self._set_bg(r, b))
def _set_bg(self, row, color):
row.config(bg=color)
for c in row.winfo_children():
if isinstance(c, tk.Label):
c.config(bg=color)
def _delete_one(self, path):
if not messagebox.askyesno("Delete Backup",
f"Delete this backup?\n\n{path.name}\n\n"
"This cannot be undone.",
parent=self):
return
self.backup_manager.delete_backup(path)
self._refresh()
def _restore_one(self, path):
if not messagebox.askyesno("Restore Backup",
"Restoring will replace ALL current profiles,\n"
"settings, and images with the backup's contents.\n\n"
f"Restore from:\n{path.name}?\n\n"
"A safety backup of the current state will be\n"
"created automatically before restoring.",
parent=self):
return
self.backup_manager.create_backup() # safety snapshot so the user can undo
if self.on_restore:
ok = self.on_restore(path)
if ok:
messagebox.showinfo("Restore Complete",
"Backup restored successfully.",
parent=self)
self.destroy()
else:
messagebox.showerror("Restore Failed",
"Could not restore the backup.\n"
"Check the console for details.",
parent=self)
self._refresh()
def _create_now(self):
path = self.backup_manager.create_backup()
if path:
self._refresh()
else:
messagebox.showerror("Backup Failed",
"Could not create a backup.\n"
"Check the console for details.",
parent=self)