import tkinter as tk from tkinter import ttk, filedialog, messagebox from pathlib import Path import json from PIL import Image, ImageTk from PIL.PngImagePlugin import PngInfo import platform import subprocess # Application metadata APP_VERSION = "2.3.0" APP_NAME = "PNG Metadata Editor" class PNGMetadataEditor: def __init__(self, root): self.root = root self.root.title(f"{APP_NAME} v{APP_VERSION}") self.root.geometry("800x600") # Smaller window size self.current_file = None self.current_directory = None self.metadata_dict = {} self.has_unsaved_changes = False self.status_timer = None self.thumbnail_images = {} # Keep references to PhotoImage objects self.thumbnail_frames = {} # Track thumbnail frames for highlighting self.setup_ui() # Bring window to foreground on startup self.root.lift() self.root.attributes('-topmost', True) self.root.after_idle(self.root.attributes, '-topmost', False) self.root.focus_force() def detect_dark_mode(self): """Detect if the OS is in dark mode""" system = platform.system() if system == "Darwin": # macOS try: result = subprocess.run( ['defaults', 'read', '-g', 'AppleInterfaceStyle'], capture_output=True, text=True, timeout=1 ) return result.returncode == 0 and 'Dark' in result.stdout except: return False elif system == "Windows": try: import winreg registry = winreg.ConnectRegistry(None, winreg.HKEY_CURRENT_USER) key = winreg.OpenKey(registry, r'SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize') value, _ = winreg.QueryValueEx(key, 'AppsUseLightTheme') return value == 0 except: return False else: # Linux try: result = subprocess.run( ['gsettings', 'get', 'org.gnome.desktop.interface', 'gtk-theme'], capture_output=True, text=True, timeout=1 ) return 'dark' in result.stdout.lower() except: return False def get_theme_colors(self): """Get background colors based on dark mode detection""" is_dark = self.detect_dark_mode() if is_dark: return { 'canvas_bg': '#2b2b2b', 'frame_bg': '#2b2b2b', 'text_fg': '#ffffff', 'loading_fg': '#6bb6ff' } else: return { 'canvas_bg': '#f0f0f0', 'frame_bg': '#f0f0f0', 'text_fg': '#000000', 'loading_fg': '#4a90d9' } def setup_ui(self): # Initialize theme colors self.theme_colors = self.get_theme_colors() # Top frame for file operations top_frame = ttk.Frame(self.root, padding="10") top_frame.pack(fill=tk.X) ttk.Button(top_frame, text="Open PNG File", command=self.open_file).pack(side=tk.LEFT, padx=5) ttk.Button(top_frame, text="Browse Directory", command=self.browse_directory).pack(side=tk.LEFT, padx=5) ttk.Button(top_frame, text="Save Changes", command=self.save_changes).pack(side=tk.LEFT, padx=5) ttk.Button(top_frame, text="About", command=self.show_about).pack(side=tk.RIGHT, padx=5) self.file_label = ttk.Label(top_frame, text="No file loaded", foreground="gray") self.file_label.pack(side=tk.LEFT, padx=20) # Unsaved changes indicator self.changes_label = ttk.Label(top_frame, text="", foreground="orange") self.changes_label.pack(side=tk.LEFT, padx=10) # Main container with horizontal split self.main_paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL) self.main_paned.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) # Left side - Thumbnail browser (smaller default width) thumbnail_frame = ttk.Frame(self.main_paned, relief=tk.SUNKEN, borderwidth=2) self.main_paned.add(thumbnail_frame, weight=0) ttk.Label(thumbnail_frame, text="Image Browser", font=("TkDefaultFont", 10, "bold")).pack(anchor=tk.W, padx=5, pady=5) # Canvas with scrollbar for thumbnails thumb_canvas_frame = ttk.Frame(thumbnail_frame) thumb_canvas_frame.pack(fill=tk.BOTH, expand=True) thumb_scroll = ttk.Scrollbar(thumb_canvas_frame, orient=tk.VERTICAL) thumb_scroll.pack(side=tk.RIGHT, fill=tk.Y) self.thumb_canvas = tk.Canvas(thumb_canvas_frame, yscrollcommand=thumb_scroll.set, bg=self.theme_colors["canvas_bg"], width=190) self.thumb_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) thumb_scroll.config(command=self.thumb_canvas.yview) # Frame inside canvas to hold thumbnails self.thumb_container = ttk.Frame(self.thumb_canvas) self.thumb_canvas_window = self.thumb_canvas.create_window((0, 0), window=self.thumb_container, anchor=tk.NW) # Bind canvas resize self.thumb_container.bind("", self.on_thumb_frame_configure) self.thumb_canvas.bind("", self.on_thumb_canvas_configure) # Bind trackpad/mousewheel scrolling self.bind_mousewheel(self.thumb_canvas) # Right side - Metadata editor (existing UI) editor_frame = ttk.Frame(self.main_paned) self.main_paned.add(editor_frame, weight=1) # Set the sash position after a short delay to ensure proper sizing self.root.after(100, lambda: self.main_paned.sashpos(0, 200)) # PanedWindow for splitting tree and detail view paned = ttk.PanedWindow(editor_frame, orient=tk.VERTICAL) paned.pack(fill=tk.BOTH, expand=True) # Top pane - Treeview (1/3 of height) tree_frame = ttk.Frame(paned) paned.add(tree_frame, weight=1) # Changed from weight=2 to weight=1 # Treeview with scrollbar tree_scroll = ttk.Scrollbar(tree_frame) tree_scroll.pack(side=tk.RIGHT, fill=tk.Y) self.tree = ttk.Treeview(tree_frame, columns=("Key", "Value"), show="headings", yscrollcommand=tree_scroll.set) self.tree.heading("Key", text="Key") self.tree.heading("Value", text="Value Preview") self.tree.column("Key", width=150) self.tree.column("Value", width=400) self.tree.pack(fill=tk.BOTH, expand=True) tree_scroll.config(command=self.tree.yview) # Bind selection to update detail view self.tree.bind("<>", self.on_selection_change) # Bind double-click to edit self.tree.bind("", self.edit_entry) # Bottom pane - Detail view (2/3 of height) detail_frame = ttk.Frame(paned) paned.add(detail_frame, weight=2) # Changed from weight=1 to weight=2 ttk.Label(detail_frame, text="Value Detail:", font=("TkDefaultFont", 10, "bold")).pack(anchor=tk.W, padx=5, pady=5) # Text widget with scrollbar for value display detail_scroll = ttk.Scrollbar(detail_frame) detail_scroll.pack(side=tk.RIGHT, fill=tk.Y) self.detail_text = tk.Text(detail_frame, wrap=tk.WORD, yscrollcommand=detail_scroll.set, font=("Menlo", 11), height=8) self.detail_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) detail_scroll.config(command=self.detail_text.yview) # Button frame for operations button_frame = ttk.Frame(editor_frame, padding="10") button_frame.pack(fill=tk.X) ttk.Button(button_frame, text="Add Field", command=self.add_field).pack(side=tk.LEFT, padx=5) ttk.Button(button_frame, text="Edit Field", command=self.edit_entry).pack(side=tk.LEFT, padx=5) ttk.Button(button_frame, text="Delete Field", command=self.delete_field).pack(side=tk.LEFT, padx=5) ttk.Button(button_frame, text="Copy Value", command=self.copy_value).pack(side=tk.LEFT, padx=5) # Status bar at the bottom status_frame = ttk.Frame(self.root, relief=tk.SUNKEN) status_frame.pack(fill=tk.X, side=tk.BOTTOM) self.status_label = ttk.Label(status_frame, text="Ready", padding=(10, 5)) self.status_label.pack(side=tk.LEFT, fill=tk.X, expand=True) def bind_mousewheel(self, widget): """Bind mousewheel/trackpad scrolling to canvas""" system = platform.system() if system == "Darwin": # macOS widget.bind("", self.on_mousewheel) widget.bind("", self.on_mousewheel) widget.bind("", self.on_mousewheel) elif system == "Windows": widget.bind("", self.on_mousewheel) elif system == "Linux": widget.bind("", self.on_mousewheel) widget.bind("", self.on_mousewheel) widget.bind("", lambda e: widget.bind_all("", self.on_mousewheel)) widget.bind("", lambda e: widget.unbind_all("")) def on_mousewheel(self, event): """Handle mousewheel/trackpad scrolling""" system = platform.system() if system == "Darwin": # macOS self.thumb_canvas.yview_scroll(int(-1 * (event.delta)), "units") elif system == "Windows": self.thumb_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") elif system == "Linux": if event.num == 4: self.thumb_canvas.yview_scroll(-1, "units") elif event.num == 5: self.thumb_canvas.yview_scroll(1, "units") def on_thumb_frame_configure(self, event): """Update scrollregion when thumbnail container size changes""" self.thumb_canvas.configure(scrollregion=self.thumb_canvas.bbox("all")) def on_thumb_canvas_configure(self, event): """Update canvas window width to match canvas width""" self.thumb_canvas.itemconfig(self.thumb_canvas_window, width=event.width) def scroll_to_thumbnail(self, filepath): """Scroll the canvas to make the selected thumbnail visible""" filepath_str = str(filepath) if filepath_str not in self.thumbnail_frames: return frame = self.thumbnail_frames[filepath_str] # Update the canvas to ensure all geometries are calculated self.thumb_canvas.update_idletasks() # Get the frame's position frame_y = frame.winfo_y() frame_height = frame.winfo_height() # Get canvas viewport canvas_height = self.thumb_canvas.winfo_height() # Calculate scroll position to center the thumbnail total_height = self.thumb_container.winfo_height() if total_height > 0: # Center the thumbnail in the viewport target_y = frame_y - (canvas_height / 2) + (frame_height / 2) scroll_fraction = max(0, min(1, target_y / total_height)) self.thumb_canvas.yview_moveto(scroll_fraction) def browse_directory(self): """Browse a directory and show PNG thumbnails""" directory = filedialog.askdirectory(title="Select directory with PNG files") if directory: self.current_directory = directory self.load_directory_thumbnails(directory) def load_directory_thumbnails(self, directory): """Load and display thumbnails for all PNG files in directory""" # Clear existing thumbnails for widget in self.thumb_container.winfo_children(): widget.destroy() self.thumbnail_images.clear() self.thumbnail_frames.clear() # Create and show loading indicator with more visibility loading_frame = tk.Frame(self.thumb_container, bg=self.theme_colors["frame_bg"]) loading_frame.pack(fill=tk.BOTH, expand=True, pady=50) loading_label = tk.Label(loading_frame, text="Loading thumbnails...", font=("TkDefaultFont", 11, "bold"), foreground="#4a90d9", bg="#f0f0f0") loading_label.pack() # Force immediate display self.thumb_container.update() self.root.update() # Find all PNG files png_files = sorted(Path(directory).glob("*.png")) if not png_files: loading_frame.destroy() no_files_frame = tk.Frame(self.thumb_container, bg=self.theme_colors["frame_bg"]) no_files_frame.pack(fill=tk.BOTH, expand=True, pady=50) tk.Label(no_files_frame, text="No PNG files found", font=("TkDefaultFont", 10), foreground="gray", bg="#f0f0f0").pack() self.set_status(f"No PNG files found in {Path(directory).name}", color="orange") return # Update loading message with count loading_label.config(text=f"Loading {len(png_files)} thumbnails...") self.root.update() # Create thumbnails thumbnail_size = (150, 150) for idx, png_file in enumerate(png_files): try: # Update progress every 3 files for better visual feedback if idx % 3 == 0: loading_label.config(text=f"Loading... {idx + 1}/{len(png_files)}") self.root.update() # Load and create thumbnail img = Image.open(png_file) img.thumbnail(thumbnail_size, Image.Resampling.LANCZOS) # Convert to PhotoImage photo = ImageTk.PhotoImage(img) self.thumbnail_images[str(png_file)] = photo # Create frame for each thumbnail with better styling thumb_frame = tk.Frame(self.thumb_container, relief=tk.RIDGE, borderwidth=2, bg="white", padx=5, pady=5) thumb_frame.pack(fill=tk.X, padx=5, pady=5) # Store frame reference self.thumbnail_frames[str(png_file)] = thumb_frame # Button with image btn = tk.Button(thumb_frame, image=photo, command=lambda f=png_file: self.load_file_from_path(f), cursor="hand2", bg="white", relief=tk.FLAT, bd=0) btn.pack(side=tk.TOP, padx=2, pady=2) # Hover effects def on_enter(e, frame=thumb_frame, path=str(png_file)): if self.current_file != path: frame.config(bg="#e8f4f8", relief=tk.RAISED) def on_leave(e, frame=thumb_frame, path=str(png_file)): if self.current_file != path: frame.config(bg="white", relief=tk.RIDGE) thumb_frame.bind("", on_enter) thumb_frame.bind("", on_leave) btn.bind("", on_enter) btn.bind("", on_leave) # Filename label name_label = tk.Label(thumb_frame, text=png_file.name, wraplength=140, justify=tk.CENTER, bg="white", font=("TkDefaultFont", 9)) name_label.pack(side=tk.TOP, padx=2, pady=2) name_label.bind("", on_enter) name_label.bind("", on_leave) except Exception as e: print(f"Error loading thumbnail for {png_file}: {e}") # Hide loading indicator loading_frame.destroy() # CRITICAL: Force update of scroll region after all thumbnails are loaded # Without this, scrolling stops after visible thumbnails self.thumb_container.update_idletasks() self.thumb_canvas.update_idletasks() self.thumb_canvas.configure(scrollregion=self.thumb_canvas.bbox("all")) self.set_status(f"Loaded {len(png_files)} PNG file(s) from {Path(directory).name}", color="green") def load_file_from_path(self, filepath, auto_scroll=True): """Load a specific file from thumbnail click""" try: self.current_file = str(filepath) # Update thumbnail highlighting with improved visual feedback for path, frame in self.thumbnail_frames.items(): if path == str(filepath): # Selected style: blue gradient-like background with sunken relief frame.config(relief=tk.SUNKEN, bg="#4a90d9", borderwidth=3) # Update all children backgrounds for child in frame.winfo_children(): if isinstance(child, tk.Label): child.config(bg="#4a90d9", fg="white", font=("TkDefaultFont", 9, "bold")) elif isinstance(child, tk.Button): child.config(bg="#4a90d9") else: # Unselected style: white background with ridge relief frame.config(relief=tk.RIDGE, bg="white", borderwidth=2) for child in frame.winfo_children(): if isinstance(child, tk.Label): child.config(bg="white", fg="black", font=("TkDefaultFont", 9)) elif isinstance(child, tk.Button): child.config(bg="white") # Scroll to the selected thumbnail if requested if auto_scroll: self.root.after(100, lambda: self.scroll_to_thumbnail(filepath)) # Read metadata using PIL img = Image.open(filepath) self.metadata_dict = {} # Extract text chunks (tEXt, zTXt, iTXt) if hasattr(img, 'text'): self.metadata_dict = dict(img.text) self.file_label.config(text=Path(filepath).name, foreground="") self.mark_as_saved() self.refresh_tree() field_count = len(self.metadata_dict) self.set_status(f"Loaded {field_count} metadata field(s) from {Path(filepath).name}", color="green") except Exception as e: messagebox.showerror("Error", f"Failed to open file:\n{str(e)}") self.set_status("Failed to open file", color="red") def show_about(self): """Show application about dialog""" about_text = f"{APP_NAME} v{APP_VERSION}\n\n" \ "A graphical tool for viewing and editing metadata in PNG files.\n\n" \ "Features:\n" \ "• Browse directories with thumbnail previews\n" \ "• Trackpad/mousewheel scrolling support\n" \ "• Auto-scroll to selected images\n" \ "• View and edit PNG metadata\n" \ "• Copy metadata between files\n\n" \ "Author: Robert Tusa\n" \ "License: MIT" messagebox.showinfo( f"About {APP_NAME}", about_text, parent=self.root ) def set_status(self, message, duration=3000, color=""): """Set status bar message that auto-clears after duration (ms)""" if self.status_timer: self.root.after_cancel(self.status_timer) self.status_label.config(text=message, foreground=color) if duration > 0: self.status_timer = self.root.after(duration, lambda: self.status_label.config(text="Ready", foreground="")) def mark_as_modified(self): """Mark the document as having unsaved changes""" if not self.has_unsaved_changes: self.has_unsaved_changes = True self.update_title() self.changes_label.config(text="Unsaved changes") def mark_as_saved(self): """Mark the document as saved""" self.has_unsaved_changes = False self.update_title() self.changes_label.config(text="") def update_title(self): """Update window title with unsaved indicator""" base_title = f"{APP_NAME} v{APP_VERSION}" if self.current_file: filename = Path(self.current_file).name if self.has_unsaved_changes: self.root.title(f"{base_title} - {filename} *") else: self.root.title(f"{base_title} - {filename}") else: self.root.title(base_title) def format_value_for_display(self, value): """Try to format value as JSON if possible, otherwise return as-is""" try: parsed = json.loads(value) return json.dumps(parsed, indent=2, ensure_ascii=False) except (json.JSONDecodeError, TypeError): return value def truncate_value(self, value, max_length=80): """Truncate long values for tree display""" value_str = str(value) if len(value_str) > max_length: return value_str[:max_length] + "..." return value_str def on_selection_change(self, event): """Update detail view when selection changes""" selected = self.tree.selection() if selected: item = selected[0] key, _ = self.tree.item(item, "values") if key in self.metadata_dict: value = self.metadata_dict[key] formatted_value = self.format_value_for_display(value) self.detail_text.config(state=tk.NORMAL) self.detail_text.delete(1.0, tk.END) self.detail_text.insert(1.0, formatted_value) self.detail_text.config(state=tk.DISABLED) def open_file(self): filepath = filedialog.askopenfilename( title="Select PNG file", filetypes=[("PNG files", "*.png"), ("All files", "*.*")] ) if filepath: parent_dir = str(Path(filepath).parent) if parent_dir != self.current_directory: self.current_directory = parent_dir self.load_directory_thumbnails(parent_dir) self.load_file_from_path(Path(filepath), auto_scroll=True) def refresh_tree(self): for item in self.tree.get_children(): self.tree.delete(item) self.detail_text.config(state=tk.NORMAL) self.detail_text.delete(1.0, tk.END) self.detail_text.config(state=tk.DISABLED) for key, value in self.metadata_dict.items(): truncated = self.truncate_value(value) self.tree.insert("", tk.END, values=(key, truncated)) def add_field(self): if self.current_file is None: messagebox.showwarning("No File", "Please open a PNG file first") return dialog = tk.Toplevel(self.root) dialog.title("Add Field") dialog.geometry("600x400") dialog.minsize(400, 300) dialog.rowconfigure(1, weight=1) dialog.columnconfigure(1, weight=1) ttk.Label(dialog, text="Key:").grid(row=0, column=0, padx=10, pady=10, sticky=tk.W) key_entry = ttk.Entry(dialog, width=50) key_entry.grid(row=0, column=1, padx=10, pady=10, sticky=tk.EW) ttk.Label(dialog, text="Value:").grid(row=1, column=0, padx=10, pady=10, sticky=tk.NW) text_frame = ttk.Frame(dialog) text_frame.grid(row=1, column=1, padx=10, pady=10, sticky=tk.NSEW) value_scroll = ttk.Scrollbar(text_frame) value_scroll.pack(side=tk.RIGHT, fill=tk.Y) value_text = tk.Text(text_frame, wrap=tk.WORD, yscrollcommand=value_scroll.set, font=("Menlo", 11)) value_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) value_scroll.config(command=value_text.yview) button_frame = ttk.Frame(dialog) button_frame.grid(row=2, column=0, columnspan=2, pady=10, sticky=tk.E) def save_new_field(): key = key_entry.get().strip() value = value_text.get(1.0, tk.END).strip() if not key: messagebox.showwarning("Invalid Input", "Key cannot be empty") return self.metadata_dict[key] = value self.mark_as_modified() self.refresh_tree() self.set_status(f"Added field '{key}'", color="green") dialog.destroy() ttk.Button(button_frame, text="Cancel", command=dialog.destroy).pack(side=tk.RIGHT, padx=5) ttk.Button(button_frame, text="Add", command=save_new_field).pack(side=tk.RIGHT, padx=5) key_entry.focus() def edit_entry(self, event=None): selected = self.tree.selection() if not selected: messagebox.showwarning("No Selection", "Please select a field to edit") return item = selected[0] key, _ = self.tree.item(item, "values") current_value = self.metadata_dict[key] formatted_value = self.format_value_for_display(current_value) dialog = tk.Toplevel(self.root) dialog.title("Edit Field") dialog.geometry("600x400") dialog.minsize(400, 300) dialog.rowconfigure(1, weight=1) dialog.columnconfigure(1, weight=1) ttk.Label(dialog, text="Key:").grid(row=0, column=0, padx=10, pady=10, sticky=tk.W) key_label = ttk.Label(dialog, text=key, font=("TkDefaultFont", 10, "bold")) key_label.grid(row=0, column=1, padx=10, pady=10, sticky=tk.W) ttk.Label(dialog, text="Value:").grid(row=1, column=0, padx=10, pady=10, sticky=tk.NW) text_frame = ttk.Frame(dialog) text_frame.grid(row=1, column=1, padx=10, pady=10, sticky=tk.NSEW) value_scroll = ttk.Scrollbar(text_frame) value_scroll.pack(side=tk.RIGHT, fill=tk.Y) value_text = tk.Text(text_frame, wrap=tk.WORD, yscrollcommand=value_scroll.set, font=("Menlo", 11)) value_text.insert(1.0, formatted_value) value_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) value_scroll.config(command=value_text.yview) button_frame = ttk.Frame(dialog) button_frame.grid(row=2, column=0, columnspan=2, pady=10, sticky=tk.E) def save_edit(): new_value = value_text.get(1.0, tk.END).strip() self.metadata_dict[key] = new_value self.mark_as_modified() self.refresh_tree() self.set_status(f"Updated field '{key}'", color="green") dialog.destroy() ttk.Button(button_frame, text="Cancel", command=dialog.destroy).pack(side=tk.RIGHT, padx=5) ttk.Button(button_frame, text="Save", command=save_edit).pack(side=tk.RIGHT, padx=5) value_text.focus() def delete_field(self): selected = self.tree.selection() if not selected: messagebox.showwarning("No Selection", "Please select a field to delete") return item = selected[0] key, _ = self.tree.item(item, "values") if messagebox.askyesno("Confirm Delete", f"Delete field '{key}'?"): if key in self.metadata_dict: del self.metadata_dict[key] self.mark_as_modified() self.refresh_tree() self.set_status(f"Deleted field '{key}'", color="green") def copy_value(self): selected = self.tree.selection() if not selected: messagebox.showwarning("No Selection", "Please select a field to copy") return item = selected[0] key, _ = self.tree.item(item, "values") value = self.metadata_dict[key] self.root.clipboard_clear() self.root.clipboard_append(value) self.set_status(f"Copied value of '{key}' to clipboard", color="green") def save_changes(self): if self.current_file is None: messagebox.showwarning("No File", "Please open a PNG file first") return try: img = Image.open(self.current_file) metadata = PngInfo() for key, value in self.metadata_dict.items(): metadata.add_text(key, value) img.save(self.current_file, pnginfo=metadata) self.mark_as_saved() self.set_status("Metadata saved successfully", color="green") except Exception as e: messagebox.showerror("Error", f"Failed to save metadata:\n{str(e)}") self.set_status("Failed to save metadata", color="red") if __name__ == "__main__": root = tk.Tk() app = PNGMetadataEditor(root) root.mainloop()