SXXXXXXX_BackupTools/backuptools/gui/main_window.py
2025-05-07 14:02:56 +02:00

540 lines
29 KiB
Python

# BackupApp/backup_app/gui/main_window.py
import tkinter as tk
from tkinter import ttk, filedialog, simpledialog, messagebox, Text, StringVar, Frame, Label
from pathlib import Path
import threading
from datetime import datetime
# Importi specifici del progetto BackupApp
from backuptools.config import settings as app_settings
from backuptools.core import file_scanner
from backuptools.core import backup_operations
from backuptools.gui import dialogs # Per i dialoghi personalizzati
class BackupApplicationWindow:
def __init__(self, root: tk.Tk):
self.root = root
self.root.title("Backup Manager Pro") # Titolo leggermente diverso
self.root.geometry("750x600") # Leggermente più grande
self.root.minsize(650, 500) # Minima dimensione
# --- Application State Variables ---
self.config_data: dict = app_settings.load_application_data()
self.source_dir_var = StringVar(value=self.config_data.get("source_dir", ""))
self.dest_dir_var = StringVar(value=self.config_data.get("dest_dir", ""))
# self.exclusions è una lista, non una StringVar
self.current_exclusions: list[str] = list(self.config_data.get("exclusions", []))
# self.description_var = StringVar(value=self.config_data.get("description", "")) -> Text widget directly
self.profiles: dict = self.config_data.get("profiles", {})
self.current_profile_var = StringVar()
# Dati per i dialoghi di dettaglio (popolati dopo la scansione)
self._scanned_included_files: list[file_scanner.FileDetail] = []
self._scanned_excluded_files: list[file_scanner.FileDetail] = []
self._included_ext_stats: dict = {}
self._excluded_ext_stats: dict = {}
# --- UI Setup ---
self._setup_styles()
self._create_widgets()
self._load_initial_profile_or_defaults()
self._update_profile_dropdown()
self.root.protocol("WM_DELETE_WINDOW", self._on_closing)
def _setup_styles(self) -> None:
"""Configure ttk styles for the application."""
style = ttk.Style(self.root)
# style.theme_use('clam') # 'clam', 'alt', 'default', 'classic'
style.configure("Accent.TButton", font=('Arial', 10, 'bold'), padding=5)
style.configure("TLabel", padding=2)
style.configure("TEntry", padding=2)
style.configure("TCombobox", padding=2)
def _create_widgets(self) -> None:
"""Create and layout all widgets in the main window."""
main_frame = ttk.Frame(self.root, padding="10 10 10 10")
main_frame.pack(fill=tk.BOTH, expand=True)
# Configure grid weights for main_frame
main_frame.columnconfigure(1, weight=1) # Allow entry column to expand
# --- Profile Section ---
profile_frame = ttk.LabelFrame(main_frame, text="Profile Management", padding=10)
profile_frame.grid(row=0, column=0, columnspan=3, padx=5, pady=(0,10), sticky="ew")
profile_frame.columnconfigure(1, weight=1)
ttk.Label(profile_frame, text="Active Profile:").grid(row=0, column=0, padx=5, pady=5, sticky="w")
self.profile_dropdown = ttk.Combobox(
profile_frame, textvariable=self.current_profile_var, state="readonly", width=35
)
self.profile_dropdown.grid(row=0, column=1, padx=5, pady=5, sticky="ew")
self.profile_dropdown.bind("<<ComboboxSelected>>", self._on_profile_selected)
save_profile_btn = ttk.Button(profile_frame, text="Save Current as Profile", command=self._save_current_as_profile)
save_profile_btn.grid(row=0, column=2, padx=5, pady=5)
delete_profile_btn = ttk.Button(profile_frame, text="Delete Selected Profile", command=self._delete_selected_profile)
delete_profile_btn.grid(row=0, column=3, padx=5, pady=5)
# --- Source Directory ---
ttk.Label(main_frame, text="Source Directory:").grid(row=1, column=0, padx=5, pady=5, sticky="w")
source_entry = ttk.Entry(main_frame, textvariable=self.source_dir_var, width=60)
source_entry.grid(row=1, column=1, padx=5, pady=5, sticky="ew")
ttk.Button(main_frame, text="Browse...", command=lambda: self._browse_directory(self.source_dir_var)).grid(row=1, column=2, padx=5, pady=5)
# --- Destination Directory ---
ttk.Label(main_frame, text="Destination Directory:").grid(row=2, column=0, padx=5, pady=5, sticky="w")
dest_entry = ttk.Entry(main_frame, textvariable=self.dest_dir_var, width=60)
dest_entry.grid(row=2, column=1, padx=5, pady=5, sticky="ew")
ttk.Button(main_frame, text="Browse...", command=lambda: self._browse_directory(self.dest_dir_var)).grid(row=2, column=2, padx=5, pady=5)
# --- Exclusions Section ---
exclusions_frame = ttk.LabelFrame(main_frame, text="File Exclusions (e.g., .log, .tmp, name_part*)", padding=10)
exclusions_frame.grid(row=3, column=0, columnspan=3, padx=5, pady=10, sticky="nsew")
exclusions_frame.columnconfigure(0, weight=3) # Text area gets more space
exclusions_frame.columnconfigure(1, weight=1) # Button column
exclusions_frame.rowconfigure(0, weight=1) # Text area expands vertically
self.exclusions_text_widget = Text(exclusions_frame, height=6, width=50, relief=tk.SOLID, borderwidth=1)
self.exclusions_text_widget.grid(row=0, column=0, padx=5, pady=5, sticky="nsew")
self._update_exclusions_display() # Populate with current_exclusions
exclusion_buttons_frame = ttk.Frame(exclusions_frame)
exclusion_buttons_frame.grid(row=0, column=1, padx=5, pady=5, sticky="nw")
ttk.Button(exclusion_buttons_frame, text="Add Exclusion", command=self._add_exclusion_pattern).pack(fill=tk.X, pady=2)
ttk.Button(exclusion_buttons_frame, text="Apply Changes from Text", command=self._apply_exclusions_from_text).pack(fill=tk.X, pady=2)
ttk.Button(exclusion_buttons_frame, text="Clear All Exclusions", command=self._clear_all_exclusions).pack(fill=tk.X, pady=2)
# --- Description Section ---
desc_frame = ttk.LabelFrame(main_frame, text="Backup Description (optional)", padding=10)
desc_frame.grid(row=4, column=0, columnspan=3, padx=5, pady=5, sticky="nsew")
desc_frame.columnconfigure(0, weight=1)
desc_frame.rowconfigure(0, weight=1)
self.description_text_widget = Text(desc_frame, height=4, width=60, relief=tk.SOLID, borderwidth=1)
self.description_text_widget.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
self.description_text_widget.insert("1.0", self.config_data.get("description", ""))
# --- Action Button ---
self.backup_button = ttk.Button(main_frame, text="Analyze and Create Backup", command=self._start_backup_process, style="Accent.TButton")
self.backup_button.grid(row=5, column=0, columnspan=3, pady=20)
# --- Progress Bar and Status Bar ---
self.progress_bar = ttk.Progressbar(main_frame, orient="horizontal", length=100, mode="determinate")
self.progress_bar.grid(row=6, column=0, columnspan=3, sticky="ew", padx=5, pady=(5,0))
status_bar_frame = ttk.Frame(main_frame, relief="sunken", padding=2) # Simpler status
status_bar_frame.grid(row=7, column=0, columnspan=3, sticky="ew", padx=5, pady=(2,5))
status_bar_frame.columnconfigure(0, weight=1) # File name part
status_bar_frame.columnconfigure(1, weight=0) # Size part (fixed or smaller weight)
self.status_file_label = ttk.Label(status_bar_frame, text="File: Idle", anchor="w", width=60,_truncate='middle')
self.status_file_label.grid(row=0, column=0, sticky="ew", padx=5)
self.status_size_label = ttk.Label(status_bar_frame, text="Size: 0.00 MB / 0.00 MB", anchor="e", width=30)
self.status_size_label.grid(row=0, column=1, sticky="e", padx=5)
# Make main content rows/columns resizable as needed
main_frame.rowconfigure(3, weight=1) # Exclusions text area
main_frame.rowconfigure(4, weight=1) # Description text area
# --- Configuration and Profile Methods ---
def _load_initial_profile_or_defaults(self) -> None:
"""Loads the last active profile or default settings."""
last_active_profile = self.config_data.get("last_active_profile")
if last_active_profile and last_active_profile in self.profiles:
self.current_profile_var.set(last_active_profile)
self._load_profile_data(last_active_profile)
else:
# Load global defaults if no last active profile or profile not found
self.source_dir_var.set(self.config_data.get("source_dir", ""))
self.dest_dir_var.set(self.config_data.get("dest_dir", ""))
self.current_exclusions = list(self.config_data.get("exclusions", []))
self._update_exclusions_display()
self.description_text_widget.delete("1.0", tk.END)
self.description_text_widget.insert("1.0", self.config_data.get("description", ""))
def _on_profile_selected(self, event=None) -> None:
"""Handles loading data when a profile is selected from the dropdown."""
profile_name = self.current_profile_var.get()
if profile_name:
self._load_profile_data(profile_name)
def _load_profile_data(self, profile_name: str) -> None:
"""Loads data from a specific profile into the UI fields."""
profile_data = self.profiles.get(profile_name)
if profile_data:
self.source_dir_var.set(profile_data.get("source_dir", ""))
self.dest_dir_var.set(profile_data.get("dest_dir", ""))
self.current_exclusions = list(profile_data.get("exclusions", []))
self._update_exclusions_display()
self.description_text_widget.delete("1.0", tk.END)
self.description_text_widget.insert("1.0", profile_data.get("description", ""))
print(f"Info: Profile '{profile_name}' loaded.")
else:
messagebox.showwarning("Profile Load Error", f"Profile '{profile_name}' not found.")
def _update_profile_dropdown(self) -> None:
"""Updates the list of profiles in the combobox."""
profile_names = list(self.profiles.keys())
self.profile_dropdown["values"] = sorted(profile_names) # Keep them sorted
if not self.current_profile_var.get() and profile_names:
# If no profile is set, but profiles exist, maybe select the first one?
# Or leave it blank to indicate global settings are active.
pass # For now, leave blank if no specific profile was pre-selected
def _save_current_as_profile(self) -> None:
"""Saves the current UI settings as a new or existing profile."""
profile_name = simpledialog.askstring("Save Profile", "Enter profile name:", parent=self.root)
if not profile_name:
return # User cancelled
if profile_name in self.profiles:
if not messagebox.askyesno("Confirm Overwrite", f"Profile '{profile_name}' already exists. Overwrite?", parent=self.root):
return
self._apply_exclusions_from_text() # Ensure current_exclusions is up-to-date
self.profiles[profile_name] = {
"source_dir": self.source_dir_var.get(),
"dest_dir": self.dest_dir_var.get(),
"exclusions": list(self.current_exclusions), # Save a copy
"description": self.description_text_widget.get("1.0", tk.END).strip()
}
self._update_profile_dropdown()
self.current_profile_var.set(profile_name) # Set current profile to the one just saved
self._save_app_config() # Save all changes to disk
messagebox.showinfo("Profile Saved", f"Profile '{profile_name}' saved successfully.", parent=self.root)
def _delete_selected_profile(self) -> None:
"""Deletes the currently selected profile."""
profile_name = self.current_profile_var.get()
if not profile_name:
messagebox.showwarning("No Profile Selected", "Please select a profile to delete.", parent=self.root)
return
if messagebox.askyesno("Confirm Delete", f"Are you sure you want to delete profile '{profile_name}'?", parent=self.root):
if profile_name in self.profiles:
del self.profiles[profile_name]
self._update_profile_dropdown()
self.current_profile_var.set("") # Clear current profile selection
self._clear_ui_fields(load_defaults=True) # Optionally load global defaults or just clear
self._save_app_config()
messagebox.showinfo("Profile Deleted", f"Profile '{profile_name}' deleted.", parent=self.root)
else:
messagebox.showerror("Error", "Profile not found (this should not happen if selected).", parent=self.root)
def _save_app_config(self, is_closing: bool = False) -> None:
"""Gathers current settings and saves them to the config file."""
# Update config_data with current global settings (if no profile is active)
# or based on the active profile logic
active_profile = self.current_profile_var.get()
current_description = self.description_text_widget.get("1.0", tk.END).strip()
self._apply_exclusions_from_text() # Ensure self.current_exclusions is current
# Save the general settings, these are the ones active if no profile is selected
# or the last used settings if the app is closed without a profile.
self.config_data["source_dir"] = self.source_dir_var.get()
self.config_data["dest_dir"] = self.dest_dir_var.get()
self.config_data["exclusions"] = list(self.current_exclusions)
self.config_data["description"] = current_description
self.config_data["profiles"] = self.profiles
if is_closing and active_profile: # If closing, save which profile was active
self.config_data["last_active_profile"] = active_profile
elif is_closing: # No profile active on close, remove last_active_profile
self.config_data.pop("last_active_profile", None)
app_settings.save_application_data(self.config_data)
# --- UI Interaction Methods (Browse, Exclusions) ---
def _browse_directory(self, dir_var: StringVar) -> None:
"""Opens a dialog to select a directory and updates the given StringVar."""
current_path = dir_var.get()
initial_dir = current_path if Path(current_path).is_dir() else str(Path.home())
directory = filedialog.askdirectory(initialdir=initial_dir, parent=self.root)
if directory:
dir_var.set(directory)
def _update_exclusions_display(self) -> None:
"""Updates the Text widget with current exclusion patterns."""
self.exclusions_text_widget.delete("1.0", tk.END)
# Display one pattern per line for better readability
self.exclusions_text_widget.insert("1.0", "\n".join(self.current_exclusions))
def _add_exclusion_pattern(self) -> None:
"""Adds a new exclusion pattern via a simple dialog."""
pattern = simpledialog.askstring("Add Exclusion", "Enter file extension or pattern (e.g., .tmp, *.log, build/):", parent=self.root)
if pattern:
pattern = pattern.strip()
if pattern not in self.current_exclusions:
self.current_exclusions.append(pattern)
self._update_exclusions_display()
else:
messagebox.showinfo("Pattern Exists", f"Pattern '{pattern}' is already in the list.", parent=self.root)
def _apply_exclusions_from_text(self) -> None:
"""Updates self.current_exclusions from the content of the Text widget."""
text_content = self.exclusions_text_widget.get("1.0", tk.END).strip()
if text_content:
self.current_exclusions = [line.strip() for line in text_content.splitlines() if line.strip()]
else:
self.current_exclusions = []
self._update_exclusions_display() # Re-format and display
# print(f"Debug: Exclusions updated from text: {self.current_exclusions}")
def _clear_all_exclusions(self) -> None:
"""Clears all exclusion patterns from the list and Text widget."""
if messagebox.askyesno("Confirm Clear", "Are you sure you want to clear all exclusion patterns?", parent=self.root):
self.current_exclusions = []
self._update_exclusions_display()
def _clear_ui_fields(self, load_defaults: bool = False) -> None:
"""Clears input fields. If load_defaults is True, loads global defaults."""
if load_defaults:
self._load_initial_profile_or_defaults() # This reloads either last profile or global defaults
else:
self.source_dir_var.set("")
self.dest_dir_var.set("")
self.current_exclusions = []
self._update_exclusions_display()
self.description_text_widget.delete("1.0", tk.END)
self.current_profile_var.set("")
# --- Backup Process Methods ---
def _update_scan_progress(self, current_file_idx: int, total_files: int, current_file_path: str) -> None:
"""Callback for file_scanner to update scan progress."""
if total_files > 0:
progress_percent = (current_file_idx + 1) / total_files * 100
self.progress_bar["value"] = progress_percent
else:
self.progress_bar["value"] = 0
# Truncate filename for display if too long
display_path = Path(current_file_path).name
if len(current_file_path) > 50 : # Heuristic for truncation
display_path = "..." + current_file_path[-47:]
self.status_file_label.config(text=f"Scanning: {display_path}")
self.status_size_label.config(text=f"File {current_file_idx+1}/{total_files}")
self.root.update_idletasks() # Crucial for UI responsiveness during intensive loop
def _update_zip_progress(self, processed_mb: float, total_mb: float, arcname: str) -> None:
"""Callback for backup_operations to update zipping progress."""
if total_mb > 0:
progress_percent = (processed_mb / total_mb) * 100
self.progress_bar["value"] = progress_percent
else:
self.progress_bar["value"] = 0 # Should not happen if total_mb is calculated
display_arcname = Path(arcname).name
if len(arcname) > 50:
display_arcname = "..." + arcname[-47:]
self.status_file_label.config(text=f"Archiving: {display_arcname}")
self.status_size_label.config(text=f"{processed_mb:.2f} MB / {total_mb:.2f} MB")
self.root.update_idletasks()
def _reset_progress_status(self, message: str = "Idle") -> None:
"""Resets progress bar and status labels."""
self.progress_bar["value"] = 0
self.status_file_label.config(text=f"Status: {message}")
self.status_size_label.config(text="Size: 0.00 MB / 0.00 MB")
def _toggle_ui_elements(self, enabled: bool) -> None:
"""Enables or disables main UI elements during operations."""
state = tk.NORMAL if enabled else tk.DISABLED
# Iterate over relevant widgets
widgets_to_toggle = [
self.profile_dropdown,
self.root.nametowidget(self.backup_button.winfo_parent() + "." + self.backup_button.winfo_name()), # backup_button
# Add other buttons: save profile, delete profile, browse, exclusion buttons
]
# Example of finding all buttons in profile_frame:
profile_frame = self.profile_dropdown.master
for widget in profile_frame.winfo_children():
if isinstance(widget, (ttk.Button, ttk.Combobox)):
widget.config(state=state)
# For source/dest entries and their browse buttons
self.source_dir_var.trace_remove # To avoid issues with state change if traces are set
self.dest_dir_var.trace_remove
source_entry = self.source_dir_var.get() # Need to get actual widget
# This is tricky without storing widget refs. A better way is to store them.
# For simplicity now, we just disable the main backup button.
# A more robust solution would be to iterate self.main_frame.winfo_children() recursively.
self.backup_button.config(state=state)
# Need to handle entries, text widgets if they should be disabled
self.exclusions_text_widget.config(state=state)
self.description_text_widget.config(state=state)
# Disable exclusion buttons more directly
exclusion_buttons_frame = self.exclusions_text_widget.master.winfo_children()[1] # Hacky way to get frame
if isinstance(exclusion_buttons_frame, ttk.Frame):
for btn_child in exclusion_buttons_frame.winfo_children():
if isinstance(btn_child, ttk.Button):
btn_child.config(state=state)
def _start_backup_process(self) -> None:
"""Initiates the file scanning and then backup process."""
source_dir = self.source_dir_var.get()
dest_dir = self.dest_dir_var.get()
if not source_dir or not Path(source_dir).is_dir():
messagebox.showerror("Input Error", "Invalid or missing source directory.", parent=self.root)
return
if not dest_dir or not Path(dest_dir).is_dir(): # Check if dest_dir is a directory
# Try to create dest_dir if it doesn't exist, but is a valid path structure
try:
Path(dest_dir).mkdir(parents=True, exist_ok=True)
print(f"Info: Destination directory '{dest_dir}' created.")
except OSError as e:
messagebox.showerror("Input Error", f"Invalid or missing destination directory, and failed to create it: {e}", parent=self.root)
return
self._apply_exclusions_from_text() # Ensure current exclusions are used
self._toggle_ui_elements(enabled=False)
self._reset_progress_status("Scanning files...")
# Run scanning in a separate thread to keep UI responsive
scan_thread = threading.Thread(
target=self._execute_file_scan_thread,
args=(source_dir, list(self.current_exclusions)) # Pass a copy of exclusions
)
scan_thread.start()
def _execute_file_scan_thread(self, source_dir: str, current_exclusions_copy: list[str]):
"""Worker thread for file scanning."""
try:
(
self._scanned_included_files,
self._scanned_excluded_files,
count,
size_mb,
) = file_scanner.scan_directory_for_files(
source_dir, current_exclusions_copy, self._update_scan_progress_threadsafe
)
# After scan, update stats for dialogs
self._included_ext_stats = file_scanner.get_file_extension_stats(self._scanned_included_files)
self._excluded_ext_stats = file_scanner.get_file_extension_stats(self._scanned_excluded_files)
self.root.after(0, lambda: self._show_scan_confirmation(count, size_mb))
except ValueError as e: # e.g. source_dir not found
self.root.after(0, lambda: messagebox.showerror("Scan Error", str(e), parent=self.root))
self.root.after(0, lambda: self._reset_progress_status("Scan failed"))
except Exception as e:
self.root.after(0, lambda: messagebox.showerror("Unexpected Scan Error", f"An error occurred during file scan: {e}", parent=self.root))
self.root.after(0, lambda: self._reset_progress_status("Scan failed"))
finally:
# Re-enable UI elements partially, as confirmation dialog will take over
# Or fully if scan failed before confirmation
# For now, the confirmation dialog will handle re-enabling if cancelled
# If an error occurs, we must re-enable here.
if not hasattr(self, '_confirmation_dialog_active') or not self._confirmation_dialog_active:
self.root.after(0, lambda: self._toggle_ui_elements(enabled=True))
def _update_scan_progress_threadsafe(self, current_idx, total_files, file_path):
"""Thread-safe wrapper for progress update using root.after."""
self.root.after(0, self._update_scan_progress, current_idx, total_files, file_path)
def _show_scan_confirmation(self, included_count: int, included_size_mb: float) -> None:
"""Shows confirmation dialog after scan and before zipping."""
if included_count == 0:
messagebox.showinfo("No Files to Backup", "The scan found no files to include in the backup based on current settings.", parent=self.root)
self._reset_progress_status("Scan complete - no files")
self._toggle_ui_elements(enabled=True)
return
self._confirmation_dialog_active = True
dialogs.show_backup_confirmation_dialog(
parent=self.root,
included_files_count=included_count,
included_total_size_mb=included_size_mb,
on_proceed=self._proceed_with_actual_backup,
on_show_file_details=lambda: dialogs.show_file_details_dialog(self.root, self._scanned_included_files, self._scanned_excluded_files),
on_show_included_ext_stats=lambda: dialogs.show_extension_stats_dialog(self.root, self._included_ext_stats, "Included Files - Extension Stats"),
on_show_excluded_ext_stats=lambda: dialogs.show_extension_stats_dialog(self.root, self._excluded_ext_stats, "Excluded Files - Extension Stats")
)
# If dialog is cancelled, we need to re-enable UI. This is implicitly handled if dialogs.py
# doesn't have a cancel callback that re-enables.
# For now, assume dialog cancel means user might want to change settings.
self._toggle_ui_elements(enabled=True) # Re-enable after dialog closes (proceed or cancel)
self._reset_progress_status("Awaiting backup confirmation...")
self._confirmation_dialog_active = False
def _proceed_with_actual_backup(self) -> None:
"""Called when user confirms to proceed with backup from the dialog."""
self._toggle_ui_elements(enabled=False)
self._reset_progress_status("Starting backup...")
source_dir = self.source_dir_var.get()
dest_dir = self.dest_dir_var.get()
description = self.description_text_widget.get("1.0", tk.END).strip()
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
# Sanitize basename from source_dir for the filename
source_basename = Path(source_dir).name
if not source_basename: # if source_dir is like "C:/"
source_basename = "drive_backup"
sanitized_basename = "".join(c if c.isalnum() or c in ('_', '-') else '_' for c in source_basename)
zip_filename = f"{timestamp}_{sanitized_basename}.zip"
zip_full_path = str(Path(dest_dir) / zip_filename)
# Run zipping in a separate thread
zip_thread = threading.Thread(
target=self._execute_zip_thread,
args=(zip_full_path, source_dir, self._scanned_included_files, description)
)
zip_thread.start()
def _execute_zip_thread(self, zip_path, source_root, files_to_zip, desc):
"""Worker thread for zipping files."""
try:
backup_operations.create_backup_archive(
zip_path,
source_root,
files_to_zip,
desc,
self._update_zip_progress_threadsafe
)
self.root.after(0, lambda: messagebox.showinfo("Backup Successful", f"Backup archive created:\n{zip_path}", parent=self.root))
self.root.after(0, lambda: self._reset_progress_status("Backup complete"))
except backup_operations.BackupError as e:
self.root.after(0, lambda: messagebox.showerror("Backup Failed", str(e), parent=self.root))
self.root.after(0, lambda: self._reset_progress_status("Backup failed"))
except Exception as e:
self.root.after(0, lambda: messagebox.showerror("Unexpected Backup Error", f"An unexpected error occurred: {e}", parent=self.root))
self.root.after(0, lambda: self._reset_progress_status("Backup failed"))
finally:
self.root.after(0, lambda: self._toggle_ui_elements(enabled=True))
# Clear sensitive scan data after backup is done or failed
self._scanned_included_files.clear()
self._scanned_excluded_files.clear()
self._included_ext_stats.clear()
self._excluded_ext_stats.clear()
def _update_zip_progress_threadsafe(self, processed_mb, total_mb, arcname):
"""Thread-safe wrapper for zip progress update."""
self.root.after(0, self._update_zip_progress, processed_mb, total_mb, arcname)
# --- Window Closing ---
def _on_closing(self) -> None:
"""Handles window close event."""
if messagebox.askokcancel("Quit", "Do you want to save current settings and quit?", parent=self.root):
self._save_app_config(is_closing=True)
self.root.destroy()