# gui.py import tkinter as tk from tkinter import ttk, scrolledtext, filedialog, messagebox, simpledialog import logging import os 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): self.hidetip() if not self.widget.winfo_exists(): return try: x, y, _, _ = self.widget.bbox("insert") x += self.widget.winfo_rootx() + 25 y += self.widget.winfo_rooty() + 25 except tk.TclError: 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) tw.wm_overrideredirect(True) tw.wm_geometry(f"+{int(x)}+{int(y)}") label = tk.Label( tw, text=self.text, justify=tk.LEFT, background="#ffffe0", relief=tk.SOLID, borderwidth=1, font=("tahoma", "8", "normal"), ) label.pack(ipadx=1) def hidetip(self): tw = self.tooltip_window self.tooltip_window = None if tw: try: if tw.winfo_exists(): tw.destroy() except tk.TclError: pass # --- End Tooltip Class --- # --- Gitignore Editor Window Class --- class GitignoreEditorWindow(tk.Toplevel): """Toplevel window for editing the .gitignore file.""" def __init__(self, master, gitignore_path, logger): super().__init__(master) self.gitignore_path = gitignore_path self.logger = logger self.original_content = "" self.title(f"Edit {os.path.basename(gitignore_path)}") self.geometry("600x450") self.minsize(400, 300) self.grab_set() self.transient(master) self.protocol("WM_DELETE_WINDOW", self._on_close) main_frame = ttk.Frame(self, padding="10") main_frame.pack(fill=tk.BOTH, expand=True) main_frame.rowconfigure(0, weight=1) main_frame.columnconfigure(0, weight=1) self.text_editor = scrolledtext.ScrolledText( main_frame, wrap=tk.WORD, font=("Consolas", 10), undo=True ) self.text_editor.grid(row=0, column=0, sticky="nsew", pady=(0, 10)) button_frame = ttk.Frame(main_frame) button_frame.grid(row=1, column=0, sticky="ew") button_frame.columnconfigure(0, weight=1) button_frame.columnconfigure(3, weight=1) self.save_button = ttk.Button( button_frame, text="Save and Close", command=self._save_and_close ) self.save_button.grid(row=0, column=2, padx=5) self.cancel_button = ttk.Button( button_frame, text="Cancel", command=self._on_close ) self.cancel_button.grid(row=0, column=1, padx=5) self._load_file() self._center_window(master) self.text_editor.focus_set() def _center_window(self, parent): self.update_idletasks() parent_x = parent.winfo_rootx() parent_y = parent.winfo_rooty() parent_width = parent.winfo_width() parent_height = parent.winfo_height() win_width = self.winfo_width() win_height = self.winfo_height() x_pos = parent_x + (parent_width // 2) - (win_width // 2) y_pos = parent_y + (parent_height // 2) - (win_height // 2) screen_width = self.winfo_screenwidth() screen_height = self.winfo_screenheight() x_pos = max(0, min(x_pos, screen_width - win_width)) y_pos = max(0, min(y_pos, screen_height - win_height)) self.geometry(f"+{int(x_pos)}+{int(y_pos)}") def _load_file(self): self.logger.info(f"Loading content for: {self.gitignore_path}") try: if os.path.exists(self.gitignore_path): with open( self.gitignore_path, "r", encoding="utf-8", errors="replace" ) as f: self.original_content = f.read() else: self.logger.info(f"'{self.gitignore_path}' does not exist.") self.original_content = "" self.text_editor.delete("1.0", tk.END) self.text_editor.insert(tk.END, self.original_content) self.text_editor.edit_reset() except IOError as e: self.logger.error( f"Error reading {self.gitignore_path}: {e}", exc_info=True ) messagebox.showerror( "Error Reading File", f"Could not read file:\n{e}", parent=self ) except Exception as e: self.logger.exception( f"Unexpected error loading {self.gitignore_path}: {e}" ) messagebox.showerror( "Unexpected Error", f"Error loading file:\n{e}", parent=self ) def _save_file(self): current_content = self.text_editor.get("1.0", tk.END).rstrip() if current_content: current_content += "\n" normalized_original = self.original_content.rstrip() if normalized_original: normalized_original += "\n" if current_content == normalized_original: self.logger.info("No changes detected. Skipping save.") return True self.logger.info(f"Saving changes to: {self.gitignore_path}") try: with open(self.gitignore_path, "w", encoding="utf-8", newline="\n") as f: f.write(current_content) self.logger.info(".gitignore file saved successfully.") self.original_content = current_content self.text_editor.edit_reset() return True except IOError as e: self.logger.error( f"Error writing {self.gitignore_path}: {e}", exc_info=True ) messagebox.showerror( "Error Saving File", f"Could not save file:\n{e}", parent=self ) return False except Exception as e: self.logger.exception(f"Unexpected error saving {self.gitignore_path}: {e}") messagebox.showerror( "Unexpected Error", f"Error saving file:\n{e}", parent=self ) return False def _save_and_close(self): if self._save_file(): self.destroy() def _on_close(self): current_content = self.text_editor.get("1.0", tk.END).rstrip() if current_content: current_content += "\n" normalized_original = self.original_content.rstrip() if normalized_original: normalized_original += "\n" if current_content != normalized_original: response = messagebox.askyesnocancel( "Unsaved Changes", "Save changes before closing?", parent=self ) if response is True: self._save_and_close() elif response is False: self.logger.warning("Discarding unsaved changes in editor.") self.destroy() else: self.destroy() # --- End Gitignore Editor Window --- # --- ADDED: Create Tag Dialog --- class CreateTagDialog(simpledialog.Dialog): """Dialog to get new tag name and message.""" def __init__(self, parent, title="Create New Tag"): self.tag_name_var = tk.StringVar() self.tag_message_var = tk.StringVar() self.result = None super().__init__(parent, title=title) def body(self, master): """Create dialog body.""" ttk.Label(master, text="Tag Name:").grid( row=0, column=0, padx=5, pady=5, sticky="w" ) self.name_entry = ttk.Entry(master, textvariable=self.tag_name_var, width=40) self.name_entry.grid(row=0, column=1, padx=5, pady=5, sticky="ew") ttk.Label(master, text="Tag Message:").grid( row=1, column=0, padx=5, pady=5, sticky="w" ) self.message_entry = ttk.Entry( master, textvariable=self.tag_message_var, width=40 ) self.message_entry.grid(row=1, column=1, padx=5, pady=5, sticky="ew") master.columnconfigure(1, weight=1) # Allow entries to expand return self.name_entry # initial focus def validate(self): """Validate the input.""" name = self.tag_name_var.get().strip() message = self.tag_message_var.get().strip() if not name: messagebox.showwarning( "Input Error", "Tag name cannot be empty.", parent=self ) return 0 # Validation failed if not message: messagebox.showwarning( "Input Error", "Tag message cannot be empty.", parent=self ) return 0 # Validation failed # Add regex validation for tag name? Optional but recommended if not re.match( r"^(?![./]|.*([./]{2,}|[.]$|\.lock$))[^ \t\n\r\f\v~^:?*[\\]+(?>", 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.save_settings_button = ttk.Button( self.profile_frame, text="Save Settings", command=self.save_profile_callback ) self.save_settings_button.grid( row=0, column=2, sticky=tk.W, padx=(5, 2), pady=5 ) self.create_tooltip( self.save_settings_button, "Save the current settings for the selected profile.", ) 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=3, sticky=tk.W, padx=(2, 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=4, sticky=tk.W, padx=(2, 5), pady=5 ) # --- MODIFIED: Simplified Repository Frame --- def _create_repo_frame(self): """Creates the frame ONLY for repository paths and bundle names.""" self.repo_frame = ttk.LabelFrame( self, text="Repository & Bundle Paths", padding=(10, 5) ) # Renamed slightly self.repo_frame.pack(pady=5, fill="x") # Define columns col_label = 0 col_entry = 1 col_button = 2 col_indicator = 3 self.repo_frame.columnconfigure(col_entry, weight=1) # Entry expands # Row 0: SVN Path ttk.Label(self.repo_frame, text="SVN Working Copy:").grid( row=0, column=col_label, 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=col_entry, sticky=tk.EW, padx=5, pady=3) 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_button, 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=col_label, 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=col_entry, sticky=tk.EW, padx=5, pady=3) 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_button, 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=col_label, 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=col_entry, columnspan=2, sticky=tk.EW, padx=5, pady=3 ) # Span entry+button cols # Row 3: Fetch Bundle Name ttk.Label(self.repo_frame, text="Fetch Bundle Name:").grid( row=3, column=col_label, 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=col_entry, columnspan=2, sticky=tk.EW, padx=5, pady=3 ) # --- Backup frame remains the same --- 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_label = 0 col_entry = 1 col_button = 2 self.backup_frame.columnconfigure(col_entry, weight=1) 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=col_label, columnspan=col_button + 1, sticky=tk.W, padx=5, pady=(5, 0), ) self.backup_dir_label = ttk.Label(self.backup_frame, text="Backup Directory:") self.backup_dir_label.grid(row=1, column=col_label, 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=col_entry, 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_button, sticky=tk.W, padx=(0, 5), pady=5 ) self.backup_exclude_label = ttk.Label( self.backup_frame, text="Exclude Extensions:" ) self.backup_exclude_label.grid( row=2, column=col_label, sticky=tk.W, padx=5, pady=5 ) self.backup_exclude_entry = ttk.Entry( self.backup_frame, textvariable=self.backup_exclude_extensions_var, width=60 ) self.backup_exclude_entry.grid( row=2, column=col_entry, columnspan=col_button - col_entry + 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)", ) # --- MODIFIED/RENAMED: Create Commit & Tag Management Frame --- def _create_commit_tag_frame(self): """Creates the frame for commit settings and tag management.""" # Renamed from _create_tag_frame self.commit_tag_frame = ttk.LabelFrame( self, text="Commit / Tag Management", padding=(10, 5) ) self.commit_tag_frame.pack(pady=5, fill="x") # Configure grid columns (adjust as needed) # Col 0: Labels/Checkboxes # Col 1: Entries / Listbox # Col 2: Buttons (Edit Gitignore, Create Tag, Refresh) # Col 3: Second column of buttons if needed (Checkout Tag) self.commit_tag_frame.columnconfigure( 1, weight=1 ) # Allow entry/listbox to expand # --- Commit Area --- (Moved from Repo Frame) # Row 0: Commit Message + Edit Gitignore Button ttk.Label(self.commit_tag_frame, text="Commit Message:").grid( row=0, column=0, sticky="w", padx=5, pady=3 ) self.commit_message_entry = ttk.Entry( self.commit_tag_frame, textvariable=self.commit_message_var, width=50 ) self.commit_message_entry.grid(row=0, column=1, sticky="ew", padx=5, pady=3) self.create_tooltip( self.commit_message_entry, "Default message for Autocommit or manual commit before tagging.", ) self.edit_gitignore_button = ttk.Button( self.commit_tag_frame, text="Edit .gitignore", width=12, command=self.open_gitignore_editor_callback, state=tk.DISABLED, ) self.edit_gitignore_button.grid( row=0, column=2, sticky="w", padx=(5, 0), pady=3 ) self.create_tooltip( self.edit_gitignore_button, "Open editor for the .gitignore file." ) # Row 1: Autocommit Checkbox (Moved from Repo Frame) self.autocommit_checkbox = ttk.Checkbutton( self.commit_tag_frame, text="Autocommit before 'Create Bundle'", variable=self.autocommit_var, ) self.autocommit_checkbox.grid( row=1, column=0, columnspan=3, sticky="w", padx=5, pady=(3, 10) ) # Add bottom padding # --- Tag Listing Area --- # Row 2: Listbox Label + Refresh Button ttk.Label(self.commit_tag_frame, text="Existing Tags:").grid( row=2, column=0, sticky="w", padx=5, pady=(5, 0) ) self.refresh_tags_button = ttk.Button( self.commit_tag_frame, text="Refresh List", command=self.refresh_tags_callback, state=tk.DISABLED, ) self.refresh_tags_button.grid( row=2, column=2, sticky="e", padx=5, pady=(5, 0) ) # Align right self.create_tooltip( self.refresh_tags_button, "Reload the list of tags from the repository." ) # Row 3: Listbox + Scrollbar tag_list_frame = ttk.Frame( self.commit_tag_frame ) # Use subframe for listbox+scrollbar tag_list_frame.grid( row=3, column=0, columnspan=2, sticky="nsew", padx=5, pady=(0, 5) ) # Span label+entry cols tag_list_frame.rowconfigure(0, weight=1) tag_list_frame.columnconfigure(0, weight=1) self.tag_listbox = tk.Listbox( tag_list_frame, height=6, exportselection=False, selectmode=tk.SINGLE, font=("Consolas", 9), ) # Use monospaced font self.tag_listbox.grid(row=0, column=0, sticky="nsew") tag_scrollbar = ttk.Scrollbar( tag_list_frame, orient=tk.VERTICAL, command=self.tag_listbox.yview ) tag_scrollbar.grid(row=0, column=1, sticky="ns") self.tag_listbox.config(yscrollcommand=tag_scrollbar.set) self.create_tooltip( self.tag_listbox, "List of tags (newest first). Select a tag to checkout." ) # --- Tag Action Buttons --- (Placed next to listbox) # Row 3, Column 2: Create Tag Button self.create_tag_button = ttk.Button( self.commit_tag_frame, text="Create Tag...", command=self.create_tag_callback, state=tk.DISABLED, ) # Ellipsis indicates dialog self.create_tag_button.grid( row=3, column=2, sticky="nw", padx=(5, 0), pady=(0, 5) ) # Align top-left of its cell self.create_tooltip( self.create_tag_button, "Commit current changes (if any) and create a new annotated tag.", ) # Row 3, Column 3: Checkout Tag Button self.checkout_tag_button = ttk.Button( self.commit_tag_frame, text="Checkout Selected Tag", command=self.checkout_tag_callback, state=tk.DISABLED, ) self.checkout_tag_button.grid( row=3, column=3, sticky="nw", padx=5, pady=(0, 5) ) # Align top-left self.create_tooltip( self.checkout_tag_button, "Switch working copy to the state of the selected tag (Detached HEAD).", ) # --- Function Frame (Main Actions) remains the same --- def _create_function_frame(self): """Creates the frame holding the main action buttons.""" self.function_frame = ttk.LabelFrame( self, text="Core Actions", padding=(10, 10) ) # Renamed slightly self.function_frame.pack(pady=5, fill="x", anchor=tk.N) button_subframe = ttk.Frame(self.function_frame) button_subframe.pack(fill=tk.X) 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) 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) 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) self.manual_backup_button = ttk.Button( button_subframe, text="Backup Now (ZIP)", command=self.manual_backup_callback, ) self.manual_backup_button.pack(side=tk.LEFT, padx=5, pady=5) # --- Log Area remains the same --- 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) # --- Initialization remains the same --- def _initialize_profile_selection(self): """Sets the initial value of the profile dropdown.""" 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]) # --- GUI Update Methods --- # --- toggle_backup_dir, browse_backup_dir remain the same --- def toggle_backup_dir(self): 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) def browse_backup_dir(self): 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) # --- update_svn_indicator remains the same (state handled by controller) --- def update_svn_indicator(self, is_prepared): if is_prepared: indicator_color = self.GREEN prepare_button_state = tk.DISABLED tooltip_text = "Prepared ('.git' found)" else: indicator_color = self.RED prepare_button_state = tk.NORMAL tooltip_text = "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) # --- update_profile_dropdown remains the same --- def update_profile_dropdown(self, sections): if hasattr(self, "profile_dropdown"): current_profile = self.profile_var.get() self.profile_dropdown["values"] = sections 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("") # --- MODIFIED: update_tag_list --- # Accepts list of tuples, formats display string def update_tag_list(self, tags_with_subjects): """ Clears and repopulates the tag listbox with name and subject. Args: tags_with_subjects (list): List of tuples `(tag_name, tag_subject)`. """ if not hasattr(self, "tag_listbox"): return try: self.tag_listbox.delete(0, tk.END) if tags_with_subjects: # Reset color if needed try: if self.tag_listbox.cget("fg") == "grey": self.tag_listbox.config(fg="SystemWindowText") except tk.TclError: pass # Ignore color errors # Insert formatted tags for name, subject in tags_with_subjects: # Use fixed-width formatting or simple tab separation # display_string = f"{name:<25}\t{subject}" # Example fixed width (adjust 25) display_string = f"{name}\t({subject})" # Simpler tab separation self.tag_listbox.insert(tk.END, display_string) else: self.tag_listbox.insert(tk.END, "(No tags found)") try: self.tag_listbox.config(fg="grey") except tk.TclError: pass except tk.TclError as e: logging.error( f"TclError updating tag listbox: {e}" ) # Use logging directly for GUI errors? except Exception as e: logging.error(f"Unexpected error updating tag listbox: {e}", exc_info=True) # --- MODIFIED: get_selected_tag --- # Parses the listbox string to return only the tag name def get_selected_tag(self): """ Returns the name of the currently selected tag from the listbox. Returns: str or None: The selected tag name, or None if no selection or placeholder. """ if hasattr(self, "tag_listbox"): selected_indices = self.tag_listbox.curselection() if selected_indices: selected_index = selected_indices[0] selected_item_text = self.tag_listbox.get(selected_index) # Check for placeholder if selected_item_text == "(No tags found)": return None # Parse the tag name (assumes name is before the first tab) tag_name = selected_item_text.split("\t", 1)[0] return tag_name.strip() # Return just the name, stripped return None # --- REMOVED: clear_tag_creation_fields (no longer needed) --- # --- Dialog Wrappers remain the same --- def ask_new_profile_name(self): return simpledialog.askstring( "Add Profile", "Enter new profile name:", parent=self.master ) def show_error(self, title, message): messagebox.showerror(title, message, parent=self.master) def show_info(self, title, message): messagebox.showinfo(title, message, parent=self.master) def show_warning(self, title, message): messagebox.showwarning(title, message, parent=self.master) def ask_yes_no(self, title, message): return messagebox.askyesno(title, message, parent=self.master) # --- Tooltip Helpers remain the same --- def create_tooltip(self, widget, text): 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="+") def update_tooltip(self, widget, text): widget.unbind("") widget.unbind("") widget.unbind("") self.create_tooltip(widget, text)