# 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("<>", 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("", lambda e: self.update_svn_status_callback(self.svn_path_entry.get())) self.svn_path_entry.bind("", 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("<>") # 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("", lambda event, tt=tooltip: tt.showtip(), add='+') widget.bind("", lambda event, tt=tooltip: tt.hidetip(), add='+') widget.bind("", 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("") widget.unbind("") widget.unbind("") 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)