449 lines
21 KiB
Python
449 lines
21 KiB
Python
# gui.py
|
|
import tkinter as tk
|
|
from tkinter import ttk, scrolledtext, filedialog, messagebox, simpledialog
|
|
import logging
|
|
import os # Keep import
|
|
|
|
# Import constant from the central location
|
|
from config_manager import DEFAULT_BACKUP_DIR
|
|
|
|
# --- Tooltip Class Definition ---
|
|
class Tooltip:
|
|
"""Simple tooltip implementation for Tkinter widgets."""
|
|
def __init__(self, widget, text):
|
|
self.widget = widget
|
|
self.text = text
|
|
self.tooltip_window = None
|
|
self.id = None
|
|
self.x = self.y = 0
|
|
|
|
def showtip(self):
|
|
"""Display text in a tooltip window."""
|
|
self.hidetip() # Hide any existing tooltip first
|
|
if not self.widget.winfo_exists(): return # Avoid error if widget destroyed
|
|
try:
|
|
x, y, _, _ = self.widget.bbox("insert") # Get widget location
|
|
x += self.widget.winfo_rootx() + 25 # Position tooltip slightly below and right
|
|
y += self.widget.winfo_rooty() + 25
|
|
except tk.TclError: # Handle cases where bbox might fail (e.g., widget not visible)
|
|
x = self.widget.winfo_rootx() + self.widget.winfo_width() // 2
|
|
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 5
|
|
|
|
self.tooltip_window = tw = tk.Toplevel(self.widget) # Create new top-level window
|
|
tw.wm_overrideredirect(True) # Remove window decorations (border, title bar)
|
|
tw.wm_geometry(f"+{x}+{y}") # Position the window
|
|
label = tk.Label(tw, text=self.text, justify=tk.LEFT,
|
|
background="#ffffe0", relief=tk.SOLID, borderwidth=1, # Light yellow background
|
|
font=("tahoma", "8", "normal"))
|
|
label.pack(ipadx=1)
|
|
|
|
def hidetip(self):
|
|
"""Hide the tooltip window."""
|
|
tw = self.tooltip_window
|
|
self.tooltip_window = None
|
|
if tw:
|
|
tw.destroy()
|
|
# --- End Tooltip Class ---
|
|
|
|
|
|
class MainFrame(ttk.Frame):
|
|
"""
|
|
The main frame containing all GUI elements for the Git SVN Sync Tool.
|
|
Manages widget creation, layout, and basic GUI state updates.
|
|
Callbacks for actions are provided by the main application controller.
|
|
"""
|
|
# Define constants for colors used in the GUI
|
|
GREEN = "#90EE90" # Light Green for success/prepared status
|
|
RED = "#F08080" # Light Coral for error/unprepared status
|
|
|
|
def __init__(self, master, load_profile_settings_cb, browse_folder_cb,
|
|
update_svn_status_cb, prepare_svn_for_git_cb, create_git_bundle_cb,
|
|
fetch_from_git_bundle_cb, config_manager_instance, profile_sections_list,
|
|
add_profile_cb, remove_profile_cb, manual_backup_cb): # Added callback
|
|
"""
|
|
Initializes the MainFrame.
|
|
|
|
Args:
|
|
master (tk.Tk or ttk.Frame): The parent widget.
|
|
load_profile_settings_cb (callable): Called when profile selection changes. Signature: func(profile_name)
|
|
browse_folder_cb (callable): Called by Browse buttons. Signature: func(entry_widget_to_update)
|
|
update_svn_status_cb (callable): Called when SVN path might change. Signature: func(svn_path)
|
|
prepare_svn_for_git_cb (callable): Called by 'Prepare SVN' button. Signature: func()
|
|
create_git_bundle_cb (callable): Called by 'Create Bundle' button. Signature: func()
|
|
fetch_from_git_bundle_cb (callable): Called by 'Fetch Bundle' button. Signature: func()
|
|
config_manager_instance (ConfigManager): Instance to access config data if needed.
|
|
profile_sections_list (list): Initial list of profile names for the dropdown.
|
|
add_profile_cb (callable): Called by 'Add Profile' button. Signature: func()
|
|
remove_profile_cb (callable): Called by 'Remove Profile' button. Signature: func()
|
|
manual_backup_cb (callable): Called by 'Backup Now' button. Signature: func()
|
|
"""
|
|
super().__init__(master)
|
|
self.master = master
|
|
|
|
# Store callbacks provided by the controller (GitSvnSyncApp)
|
|
self.load_profile_settings_callback = load_profile_settings_cb
|
|
self.browse_folder_callback = browse_folder_cb
|
|
self.update_svn_status_callback = update_svn_status_cb
|
|
self.prepare_svn_for_git_callback = prepare_svn_for_git_cb
|
|
self.create_git_bundle_callback = create_git_bundle_cb
|
|
self.fetch_from_git_bundle_callback = fetch_from_git_bundle_cb
|
|
self.add_profile_callback = add_profile_cb
|
|
self.remove_profile_callback = remove_profile_cb
|
|
self.manual_backup_callback = manual_backup_cb # Store manual backup callback
|
|
|
|
# Store config manager and initial profiles if needed locally
|
|
self.config_manager = config_manager_instance
|
|
self.initial_profile_sections = profile_sections_list
|
|
|
|
# Style configuration
|
|
self.style = ttk.Style()
|
|
self.style.theme_use('clam')
|
|
|
|
# Pack the main frame
|
|
self.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=10, pady=10)
|
|
|
|
# --- Tkinter Variables ---
|
|
self.profile_var = tk.StringVar()
|
|
self.autobackup_var = tk.BooleanVar()
|
|
self.backup_dir_var = tk.StringVar()
|
|
self.autocommit_var = tk.BooleanVar()
|
|
self.commit_message_var = tk.StringVar() # For custom commit message
|
|
self.backup_exclude_extensions_var = tk.StringVar() # For backup exclusions
|
|
|
|
# --- Widget Creation ---
|
|
self._create_profile_frame()
|
|
self._create_repo_frame()
|
|
self._create_backup_frame()
|
|
self._create_function_frame()
|
|
self._create_log_area()
|
|
|
|
# --- Initial State Configuration ---
|
|
self._initialize_profile_selection()
|
|
self.toggle_backup_dir()
|
|
# Initial status update is handled by the controller after loading profile
|
|
|
|
def _create_profile_frame(self):
|
|
"""Creates the frame for profile selection and management."""
|
|
self.profile_frame = ttk.LabelFrame(self, text="Profile Configuration", padding=(10, 5))
|
|
self.profile_frame.pack(pady=5, fill="x")
|
|
|
|
ttk.Label(self.profile_frame, text="Profile:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=5)
|
|
|
|
self.profile_dropdown = ttk.Combobox(
|
|
self.profile_frame,
|
|
textvariable=self.profile_var,
|
|
state="readonly",
|
|
width=35,
|
|
values=self.initial_profile_sections
|
|
)
|
|
self.profile_dropdown.grid(row=0, column=1, sticky=tk.EW, padx=5, pady=5)
|
|
self.profile_dropdown.bind("<<ComboboxSelected>>",
|
|
lambda event: self.load_profile_settings_callback(self.profile_var.get()))
|
|
self.profile_var.trace_add("write",
|
|
lambda *args: self.load_profile_settings_callback(self.profile_var.get()))
|
|
|
|
self.add_profile_button = ttk.Button(self.profile_frame, text="Add", width=5, command=self.add_profile_callback)
|
|
self.add_profile_button.grid(row=0, column=2, sticky=tk.W, padx=(5, 0), pady=5)
|
|
|
|
self.remove_profile_button = ttk.Button(self.profile_frame, text="Remove", width=8, command=self.remove_profile_callback)
|
|
self.remove_profile_button.grid(row=0, column=3, sticky=tk.W, padx=(2, 5), pady=5)
|
|
|
|
self.profile_frame.columnconfigure(1, weight=1)
|
|
|
|
|
|
def _create_repo_frame(self):
|
|
"""Creates the frame for repository paths, bundle names, and commit message."""
|
|
self.repo_frame = ttk.LabelFrame(self, text="Repository Configuration", padding=(10, 5))
|
|
self.repo_frame.pack(pady=5, fill="x")
|
|
col_entry_span = 2 # Span for entry widgets if browse/indicator are present
|
|
col_browse = col_entry_span # Column index for browse buttons
|
|
col_indicator = col_browse + 1 # Column index for indicator
|
|
|
|
# Row 0: SVN Path
|
|
ttk.Label(self.repo_frame, text="SVN Working Copy:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=3)
|
|
self.svn_path_entry = ttk.Entry(self.repo_frame, width=60)
|
|
self.svn_path_entry.grid(row=0, column=1, columnspan=col_entry_span, sticky=tk.EW, padx=5, pady=3) # Span entry
|
|
self.svn_path_entry.bind("<FocusOut>", lambda e: self.update_svn_status_callback(self.svn_path_entry.get()))
|
|
self.svn_path_entry.bind("<Return>", lambda e: self.update_svn_status_callback(self.svn_path_entry.get()))
|
|
self.svn_path_browse_button = ttk.Button(
|
|
self.repo_frame, text="Browse...", width=9,
|
|
command=lambda: self.browse_folder_callback(self.svn_path_entry)
|
|
)
|
|
self.svn_path_browse_button.grid(row=0, column=col_browse, sticky=tk.W, padx=(0, 5), pady=3)
|
|
self.svn_status_indicator = tk.Label(self.repo_frame, text="", width=2, height=1, relief=tk.SUNKEN, background=self.RED, anchor=tk.CENTER)
|
|
self.svn_status_indicator.grid(row=0, column=col_indicator, sticky=tk.E, padx=(0, 5), pady=3)
|
|
self.create_tooltip(self.svn_status_indicator, "Indicates if '.git' folder exists (Green=Yes, Red=No)")
|
|
|
|
# Row 1: USB/Bundle Target Path
|
|
ttk.Label(self.repo_frame, text="Bundle Target Dir:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=3)
|
|
self.usb_path_entry = ttk.Entry(self.repo_frame, width=60)
|
|
self.usb_path_entry.grid(row=1, column=1, columnspan=col_entry_span, sticky=tk.EW, padx=5, pady=3) # Span entry
|
|
self.usb_path_browse_button = ttk.Button(
|
|
self.repo_frame, text="Browse...", width=9,
|
|
command=lambda: self.browse_folder_callback(self.usb_path_entry)
|
|
)
|
|
self.usb_path_browse_button.grid(row=1, column=col_browse, sticky=tk.W, padx=(0, 5), pady=3)
|
|
|
|
# Row 2: Create Bundle Name
|
|
ttk.Label(self.repo_frame, text="Create Bundle Name:").grid(row=2, column=0, sticky=tk.W, padx=5, pady=3)
|
|
self.bundle_name_entry = ttk.Entry(self.repo_frame, width=60)
|
|
self.bundle_name_entry.grid(row=2, column=1, columnspan=col_browse, sticky=tk.EW, padx=5, pady=3) # Span entry+browse cols
|
|
|
|
# Row 3: Fetch Bundle Name
|
|
ttk.Label(self.repo_frame, text="Fetch Bundle Name:").grid(row=3, column=0, sticky=tk.W, padx=5, pady=3)
|
|
self.bundle_updated_name_entry = ttk.Entry(self.repo_frame, width=60)
|
|
self.bundle_updated_name_entry.grid(row=3, column=1, columnspan=col_browse, sticky=tk.EW, padx=5, pady=3) # Span
|
|
|
|
# Row 4: Commit Message
|
|
ttk.Label(self.repo_frame, text="Commit Message:").grid(row=4, column=0, sticky=tk.W, padx=5, pady=3)
|
|
self.commit_message_entry = ttk.Entry(
|
|
self.repo_frame,
|
|
textvariable=self.commit_message_var, # Use Tkinter variable
|
|
width=60
|
|
)
|
|
self.commit_message_entry.grid(row=4, column=1, columnspan=col_browse, sticky=tk.EW, padx=5, pady=3) # Span
|
|
self.create_tooltip(self.commit_message_entry, "Optional message for autocommit. If empty, a default message is used.")
|
|
|
|
# Row 5: Autocommit Checkbox
|
|
self.autocommit_checkbox = ttk.Checkbutton(
|
|
self.repo_frame,
|
|
text="Autocommit changes before 'Create Bundle'",
|
|
variable=self.autocommit_var
|
|
)
|
|
self.autocommit_checkbox.grid(row=5, column=0, columnspan=col_indicator + 1, sticky=tk.W, padx=5, pady=(5, 3)) # Span all columns
|
|
|
|
# Configure column weights for horizontal resizing
|
|
self.repo_frame.columnconfigure(1, weight=1) # Allow entry fields (column 1) to expand
|
|
|
|
|
|
def _create_backup_frame(self):
|
|
"""Creates the frame for backup configuration including exclusions."""
|
|
self.backup_frame = ttk.LabelFrame(self, text="Backup Configuration (ZIP)", padding=(10, 5))
|
|
self.backup_frame.pack(pady=5, fill="x")
|
|
col_entry_span = 1 # Default span for entry
|
|
col_browse = 2 # Column for browse button
|
|
|
|
# Row 0: Autobackup Checkbox
|
|
self.autobackup_checkbox = ttk.Checkbutton(
|
|
self.backup_frame,
|
|
text="Automatic Backup before Create/Fetch",
|
|
variable=self.autobackup_var,
|
|
command=self.toggle_backup_dir
|
|
)
|
|
self.autobackup_checkbox.grid(row=0, column=0, columnspan=col_browse + 1, sticky=tk.W, padx=5, pady=(5, 0))
|
|
|
|
# Row 1: Backup Directory
|
|
self.backup_dir_label = ttk.Label(self.backup_frame, text="Backup Directory:")
|
|
self.backup_dir_label.grid(row=1, column=0, sticky=tk.W, padx=5, pady=5)
|
|
self.backup_dir_entry = ttk.Entry(
|
|
self.backup_frame,
|
|
textvariable=self.backup_dir_var,
|
|
width=60,
|
|
state=tk.DISABLED
|
|
)
|
|
self.backup_dir_entry.grid(row=1, column=1, columnspan=col_entry_span, sticky=tk.EW, padx=5, pady=5)
|
|
self.backup_dir_button = ttk.Button(
|
|
self.backup_frame,
|
|
text="Browse...", width=9,
|
|
command=self.browse_backup_dir,
|
|
state=tk.DISABLED
|
|
)
|
|
self.backup_dir_button.grid(row=1, column=col_browse, sticky=tk.W, padx=(0, 5), pady=5)
|
|
|
|
# Row 2: Exclude Extensions
|
|
self.backup_exclude_label = ttk.Label(self.backup_frame, text="Exclude Extensions:")
|
|
self.backup_exclude_label.grid(row=2, column=0, sticky=tk.W, padx=5, pady=5)
|
|
self.backup_exclude_entry = ttk.Entry(
|
|
self.backup_frame,
|
|
textvariable=self.backup_exclude_extensions_var, # Use Tkinter variable
|
|
width=60
|
|
)
|
|
# Span across entry and browse columns
|
|
self.backup_exclude_entry.grid(row=2, column=1, columnspan=col_entry_span + (col_browse - 1), sticky=tk.EW, padx=5, pady=5)
|
|
self.create_tooltip(self.backup_exclude_entry, "Comma-separated extensions to exclude (e.g., .log, .tmp, .bak)")
|
|
|
|
# Configure column weights
|
|
self.backup_frame.columnconfigure(1, weight=1) # Allow entry fields to expand
|
|
|
|
|
|
def _create_function_frame(self):
|
|
"""Creates the frame holding the main action buttons."""
|
|
self.function_frame = ttk.LabelFrame(self, text="Actions", padding=(10, 10))
|
|
self.function_frame.pack(pady=5, fill="x", anchor=tk.N) # Anchor North
|
|
|
|
# Use a sub-frame to group buttons
|
|
button_subframe = ttk.Frame(self.function_frame)
|
|
button_subframe.pack(fill=tk.X) # Fill horizontally
|
|
|
|
# Prepare SVN button
|
|
self.prepare_svn_button = ttk.Button(
|
|
button_subframe,
|
|
text="Prepare SVN Repo",
|
|
command=self.prepare_svn_for_git_callback
|
|
)
|
|
self.prepare_svn_button.pack(side=tk.LEFT, padx=(0,5), pady=5)
|
|
|
|
# Create Bundle button
|
|
self.create_bundle_button = ttk.Button(
|
|
button_subframe,
|
|
text="Create Bundle",
|
|
command=self.create_git_bundle_callback
|
|
)
|
|
self.create_bundle_button.pack(side=tk.LEFT, padx=5, pady=5)
|
|
|
|
# Fetch Bundle button
|
|
self.fetch_bundle_button = ttk.Button(
|
|
button_subframe,
|
|
text="Fetch from Bundle",
|
|
command=self.fetch_from_git_bundle_callback
|
|
)
|
|
self.fetch_bundle_button.pack(side=tk.LEFT, padx=5, pady=5)
|
|
|
|
# Manual Backup Button
|
|
self.manual_backup_button = ttk.Button(
|
|
button_subframe,
|
|
text="Backup Now (ZIP)", # Indicate ZIP format
|
|
command=self.manual_backup_callback # Use the new callback
|
|
)
|
|
self.manual_backup_button.pack(side=tk.LEFT, padx=5, pady=5)
|
|
|
|
|
|
def _create_log_area(self):
|
|
"""Creates the scrolled text area for logging output."""
|
|
log_frame = ttk.Frame(self.master)
|
|
log_frame.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=True, padx=10, pady=(5, 10))
|
|
|
|
self.log_text = scrolledtext.ScrolledText(
|
|
log_frame,
|
|
height=12,
|
|
width=100,
|
|
font=("Consolas", 9),
|
|
wrap=tk.WORD,
|
|
state=tk.DISABLED
|
|
)
|
|
self.log_text.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=True)
|
|
|
|
|
|
def _initialize_profile_selection(self):
|
|
"""Sets the initial value of the profile dropdown."""
|
|
# Use constant defined in config_manager if available, otherwise hardcode 'default'
|
|
try:
|
|
from config_manager import DEFAULT_PROFILE
|
|
except ImportError:
|
|
DEFAULT_PROFILE = "default"
|
|
|
|
if DEFAULT_PROFILE in self.initial_profile_sections:
|
|
self.profile_var.set(DEFAULT_PROFILE)
|
|
elif self.initial_profile_sections:
|
|
self.profile_var.set(self.initial_profile_sections[0])
|
|
# else: variable remains empty
|
|
|
|
# --- GUI Update Methods ---
|
|
|
|
def toggle_backup_dir(self):
|
|
"""
|
|
Enables or disables the backup directory entry/button based on the autobackup checkbox.
|
|
"""
|
|
new_state = tk.NORMAL if self.autobackup_var.get() else tk.DISABLED
|
|
if hasattr(self, 'backup_dir_entry'):
|
|
self.backup_dir_entry.config(state=new_state)
|
|
if hasattr(self, 'backup_dir_button'):
|
|
self.backup_dir_button.config(state=new_state)
|
|
# Exclude entry state is independent of autobackup checkbox
|
|
# if hasattr(self, 'backup_exclude_entry'):
|
|
# self.backup_exclude_entry.config(state=tk.NORMAL) # Always editable
|
|
|
|
|
|
def browse_backup_dir(self):
|
|
"""Opens a directory selection dialog for the backup directory entry."""
|
|
initial_dir = self.backup_dir_var.get() or DEFAULT_BACKUP_DIR
|
|
dirname = filedialog.askdirectory(
|
|
initialdir=initial_dir,
|
|
title="Select Backup Directory",
|
|
parent=self.master
|
|
)
|
|
if dirname:
|
|
self.backup_dir_var.set(dirname)
|
|
|
|
|
|
def update_svn_indicator(self, is_prepared):
|
|
"""Updates the visual indicator and 'Prepare' button state."""
|
|
if is_prepared:
|
|
indicator_color = self.GREEN
|
|
prepare_button_state = tk.DISABLED
|
|
tooltip_text = "Repository is prepared ('.git' found)"
|
|
else:
|
|
indicator_color = self.RED
|
|
prepare_button_state = tk.NORMAL
|
|
tooltip_text = "Repository not prepared ('.git' not found)"
|
|
|
|
if hasattr(self, 'svn_status_indicator'):
|
|
self.svn_status_indicator.config(background=indicator_color)
|
|
self.update_tooltip(self.svn_status_indicator, tooltip_text)
|
|
if hasattr(self, 'prepare_svn_button'):
|
|
self.prepare_svn_button.config(state=prepare_button_state)
|
|
|
|
|
|
def update_profile_dropdown(self, sections):
|
|
"""Updates the list of profiles shown in the combobox."""
|
|
if hasattr(self, 'profile_dropdown'):
|
|
current_profile = self.profile_var.get()
|
|
self.profile_dropdown['values'] = sections
|
|
# Try to maintain selection
|
|
if sections:
|
|
if current_profile in sections:
|
|
self.profile_var.set(current_profile)
|
|
elif "default" in sections:
|
|
self.profile_var.set("default")
|
|
else:
|
|
self.profile_var.set(sections[0])
|
|
else:
|
|
self.profile_var.set("")
|
|
# self.profile_dropdown.event_generate("<<ComboboxSelected>>") # Not always needed
|
|
|
|
|
|
# --- Dialog Wrappers ---
|
|
def ask_new_profile_name(self):
|
|
"""Asks the user for a new profile name using a simple dialog."""
|
|
return simpledialog.askstring("Add Profile", "Enter new profile name:", parent=self.master)
|
|
|
|
def show_error(self, title, message):
|
|
"""Displays an error message box."""
|
|
messagebox.showerror(title, message, parent=self.master)
|
|
|
|
def show_info(self, title, message):
|
|
"""Displays an information message box."""
|
|
messagebox.showinfo(title, message, parent=self.master)
|
|
|
|
def show_warning(self, title, message):
|
|
"""Displays a warning message box."""
|
|
messagebox.showwarning(title, message, parent=self.master)
|
|
|
|
def ask_yes_no(self, title, message):
|
|
"""Displays a yes/no confirmation dialog."""
|
|
return messagebox.askyesno(title, message, parent=self.master)
|
|
|
|
|
|
# --- Tooltip Helper Methods ---
|
|
def create_tooltip(self, widget, text):
|
|
"""Creates a tooltip for a given widget."""
|
|
tooltip = Tooltip(widget, text)
|
|
widget.bind("<Enter>", lambda event, tt=tooltip: tt.showtip(), add='+')
|
|
widget.bind("<Leave>", lambda event, tt=tooltip: tt.hidetip(), add='+')
|
|
widget.bind("<ButtonPress>", lambda event, tt=tooltip: tt.hidetip(), add='+') # Hide on click
|
|
# Store tooltip instance if needed for update_tooltip
|
|
# setattr(widget, '_tooltip_instance', tooltip)
|
|
|
|
|
|
def update_tooltip(self, widget, text):
|
|
"""Updates the text of an existing tooltip (by re-creating it)."""
|
|
# Simple approach: Remove old bindings and create new tooltip
|
|
widget.unbind("<Enter>")
|
|
widget.unbind("<Leave>")
|
|
widget.unbind("<ButtonPress>")
|
|
self.create_tooltip(widget, text)
|
|
# More complex approach: find stored instance and update its text
|
|
# if hasattr(widget, '_tooltip_instance'):
|
|
# widget._tooltip_instance.text = text
|
|
# else:
|
|
# self.create_tooltip(widget, text) |