SXXXXXXX_GitUtility/gui.py
2025-04-07 10:52:38 +02:00

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)