added image browser

This commit is contained in:
robert 2026-01-06 22:35:40 +01:00
parent f4b407a196
commit 44c05ed66e

View file

@ -2,23 +2,27 @@ import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from pathlib import Path
import json
from PIL import Image
from PIL import Image, ImageTk
from PIL.PngImagePlugin import PngInfo
import platform
import subprocess
# Application metadata
APP_VERSION = "1.0.0"
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} - No file loaded")
self.root.geometry("900x700")
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()
@ -28,14 +32,71 @@ class PNGMetadataEditor:
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) # New About button
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)
@ -44,36 +105,75 @@ class PNGMetadataEditor:
self.changes_label = ttk.Label(top_frame, text="", foreground="orange")
self.changes_label.pack(side=tk.LEFT, padx=10)
# PanedWindow for splitting tree and detail view
paned = ttk.PanedWindow(self.root, orient=tk.VERTICAL)
paned.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
# 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)
# Top pane - Treeview
# 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("<Configure>", self.on_thumb_frame_configure)
self.thumb_canvas.bind("<Configure>", 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=2)
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)
yscrollcommand=tree_scroll.set)
self.tree.heading("Key", text="Key")
self.tree.heading("Value", text="Value Preview")
self.tree.column("Key", width=200)
self.tree.column("Value", width=600)
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("<<TreeviewSelect>>", self.on_selection_change)
# Bind double-click to edit
self.tree.bind("<Double-1>", self.edit_entry)
# Bottom pane - Detail view
# Bottom pane - Detail view (2/3 of height)
detail_frame = ttk.Frame(paned)
paned.add(detail_frame, weight=1)
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)
@ -82,12 +182,12 @@ class PNGMetadataEditor:
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)
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(self.root, padding="10")
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)
@ -102,10 +202,239 @@ class PNGMetadataEditor:
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("<MouseWheel>", self.on_mousewheel)
widget.bind("<Button-4>", self.on_mousewheel)
widget.bind("<Button-5>", self.on_mousewheel)
elif system == "Windows":
widget.bind("<MouseWheel>", self.on_mousewheel)
elif system == "Linux":
widget.bind("<Button-4>", self.on_mousewheel)
widget.bind("<Button-5>", self.on_mousewheel)
widget.bind("<Enter>", lambda e: widget.bind_all("<MouseWheel>", self.on_mousewheel))
widget.bind("<Leave>", lambda e: widget.unbind_all("<MouseWheel>"))
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("<Enter>", on_enter)
thumb_frame.bind("<Leave>", on_leave)
btn.bind("<Enter>", on_enter)
btn.bind("<Leave>", 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("<Enter>", on_enter)
name_label.bind("<Leave>", 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"
@ -117,14 +446,11 @@ class PNGMetadataEditor:
def set_status(self, message, duration=3000, color=""):
"""Set status bar message that auto-clears after duration (ms)"""
# Cancel any existing timer
if self.status_timer:
self.root.after_cancel(self.status_timer)
# Set the message
self.status_label.config(text=message, foreground=color)
# Schedule clearing the message
if duration > 0:
self.status_timer = self.root.after(duration, lambda: self.status_label.config(text="Ready", foreground=""))
@ -156,12 +482,9 @@ class PNGMetadataEditor:
def format_value_for_display(self, value):
"""Try to format value as JSON if possible, otherwise return as-is"""
try:
# Try to parse as JSON
parsed = json.loads(value)
# If successful, return pretty-printed JSON
return json.dumps(parsed, indent=2, ensure_ascii=False)
except (json.JSONDecodeError, TypeError):
# Not JSON, return original value
return value
def truncate_value(self, value, max_length=80):
@ -177,12 +500,10 @@ class PNGMetadataEditor:
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)
# Update detail text
self.detail_text.config(state=tk.NORMAL)
self.detail_text.delete(1.0, tk.END)
self.detail_text.insert(1.0, formatted_value)
@ -195,38 +516,21 @@ class PNGMetadataEditor:
)
if filepath:
try:
self.current_file = filepath
parent_dir = str(Path(filepath).parent)
if parent_dir != self.current_directory:
self.current_directory = parent_dir
self.load_directory_thumbnails(parent_dir)
# 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")
self.load_file_from_path(Path(filepath), auto_scroll=True)
def refresh_tree(self):
# Clear existing items
for item in self.tree.get_children():
self.tree.delete(item)
# Clear detail view
self.detail_text.config(state=tk.NORMAL)
self.detail_text.delete(1.0, tk.END)
self.detail_text.config(state=tk.DISABLED)
# Add metadata entries from dictionary
for key, value in self.metadata_dict.items():
truncated = self.truncate_value(value)
self.tree.insert("", tk.END, values=(key, truncated))
@ -241,19 +545,15 @@ class PNGMetadataEditor:
dialog.geometry("600x400")
dialog.minsize(400, 300)
# Make dialog resizable
dialog.rowconfigure(1, weight=1)
dialog.columnconfigure(1, weight=1)
# Key entry
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)
# Value text with label
ttk.Label(dialog, text="Value:").grid(row=1, column=0, padx=10, pady=10, sticky=tk.NW)
# Frame for text widget and scrollbar
text_frame = ttk.Frame(dialog)
text_frame.grid(row=1, column=1, padx=10, pady=10, sticky=tk.NSEW)
@ -265,7 +565,6 @@ class PNGMetadataEditor:
value_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
value_scroll.config(command=value_text.yview)
# Button frame
button_frame = ttk.Frame(dialog)
button_frame.grid(row=2, column=0, columnspan=2, pady=10, sticky=tk.E)
@ -298,7 +597,6 @@ class PNGMetadataEditor:
key, _ = self.tree.item(item, "values")
current_value = self.metadata_dict[key]
# Format value as JSON if possible
formatted_value = self.format_value_for_display(current_value)
dialog = tk.Toplevel(self.root)
@ -306,19 +604,15 @@ class PNGMetadataEditor:
dialog.geometry("600x400")
dialog.minsize(400, 300)
# Make dialog resizable
dialog.rowconfigure(1, weight=1)
dialog.columnconfigure(1, weight=1)
# Key display
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)
# Value text with label
ttk.Label(dialog, text="Value:").grid(row=1, column=0, padx=10, pady=10, sticky=tk.NW)
# Frame for text widget and scrollbar
text_frame = ttk.Frame(dialog)
text_frame.grid(row=1, column=1, padx=10, pady=10, sticky=tk.NSEW)
@ -331,7 +625,6 @@ class PNGMetadataEditor:
value_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
value_scroll.config(command=value_text.yview)
# Button frame
button_frame = ttk.Frame(dialog)
button_frame.grid(row=2, column=0, columnspan=2, pady=10, sticky=tk.E)
@ -384,19 +677,17 @@ class PNGMetadataEditor:
return
try:
# Open the image
img = Image.open(self.current_file)
# Create new PngInfo object with only our metadata
metadata = PngInfo()
for key, value in self.metadata_dict.items():
metadata.add_text(key, value)
# Save the image with new metadata (this replaces all text chunks)
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")
@ -404,4 +695,4 @@ class PNGMetadataEditor:
if __name__ == "__main__":
root = tk.Tk()
app = PNGMetadataEditor(root)
root.mainloop()
root.mainloop()