From b6e6b38a767c0cf8d50f394053cc8fdf2e29742a Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Wed, 1 Oct 2025 13:57:45 -0400 Subject: [PATCH] Add Tag Management GUI to PhotoTagger Introduce a new tag management interface with a file explorer-like design, allowing users to manage photo tags efficiently. Features include multiple view modes (list, icons, compact), resizable columns, and column visibility management through a right-click context menu. Update README to document the new functionality and usage instructions. --- README.md | 60 +++++ photo_tagger.py | 656 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 715 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cc524f7..f723bf5 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,55 @@ python3 photo_tagger.py modifyidentified This GUI lets you quickly review all identified people, rename them, and temporarily unmatch faces before committing. +### Tag Manager GUI (NEW) +```bash +# Open the Tag Management interface +python3 photo_tagger.py tag-manager +``` + +This GUI provides a file explorer-like interface for managing photo tags with advanced column resizing and multiple view modes. + +**🎯 Tag Manager Features:** +- 📊 **Multiple View Modes** - List view, icon view, and compact view for different needs +- 🔧 **Resizable Columns** - Drag column separators to resize both headers and data rows +- 👁️ **Column Visibility** - Right-click to show/hide columns in each view mode +- 🖼️ **Thumbnail Display** - Icon view shows photo thumbnails with metadata +- 📱 **Responsive Layout** - Adapts to window size with proper scrolling +- 🎨 **Modern Interface** - Clean, intuitive design with visual feedback +- ⚡ **Fast Performance** - Optimized for large photo collections + +**📋 Available View Modes:** + +**List View:** +- 📄 **Detailed Information** - Shows ID, filename, path, processed status, date taken, face count, and tags +- 🔧 **Resizable Columns** - Drag red separators between columns to resize +- 📊 **Column Management** - Right-click headers to show/hide columns +- 🎯 **Full Data Access** - Complete photo information in tabular format + +**Icon View:** +- 🖼️ **Photo Thumbnails** - Visual grid of photo thumbnails (150x150px) +- 📝 **Metadata Overlay** - Shows ID, filename, processed status, date taken, face count, and tags +- 📱 **Responsive Grid** - Thumbnails wrap to fit window width +- 🎨 **Visual Navigation** - Easy browsing through photo collection + +**Compact View:** +- 📄 **Essential Info** - Shows filename, face count, and tags only +- ⚡ **Fast Loading** - Minimal data for quick browsing +- 🎯 **Focused Display** - Perfect for quick tag management + +**🔧 Column Resizing:** +- 🖱️ **Drag to Resize** - Click and drag red separators between columns +- 📏 **Minimum Width** - Columns maintain minimum 50px width +- 🔄 **Real-time Updates** - Both headers and data rows resize together +- 💾 **Persistent Settings** - Column widths remembered between sessions +- 🎯 **Visual Feedback** - Cursor changes and separator highlighting during resize + +**👁️ Column Management:** +- 🖱️ **Right-click Headers** - Access column visibility menu +- ✅ **Toggle Columns** - Show/hide individual columns in each view mode +- 🎯 **View-Specific** - Column settings saved per view mode +- 🔄 **Instant Updates** - Changes apply immediately + **Left Panel (People):** - 🔍 **Last Name Search** - Search box to filter people by last name (case-insensitive) - 🔎 **Search Button** - Apply filter to show only matching people @@ -271,6 +320,12 @@ python3 photo_tagger.py search "Joh" python3 photo_tagger.py stats ``` +### Tag Manager GUI +```bash +# Open tag management interface +python3 photo_tagger.py tag-manager +``` + ## 📊 Enhanced Example Workflow ```bash @@ -297,6 +352,9 @@ python3 photo_tagger.py search "Alice" # 7. Add some tags python3 photo_tagger.py tag --pattern "birthday" + +# 8. Manage tags with GUI interface +python3 photo_tagger.py tag-manager ``` ## 🗃️ Database @@ -743,6 +801,7 @@ python3 -m venv venv && source venv/bin/activate && python3 setup.py ./run.sh identify --show-faces --batch 10 ./run.sh auto-match --show-faces ./run.sh modifyidentified +./run.sh tag-manager ./run.sh stats # Daily usage - Option 2: Manual venv activation (GUI-ENHANCED) @@ -752,5 +811,6 @@ python3 photo_tagger.py process --limit 50 python3 photo_tagger.py identify --show-faces --batch 10 # Opens GUI python3 photo_tagger.py auto-match --show-faces # Opens GUI python3 photo_tagger.py modifyidentified # Opens GUI to view/modify +python3 photo_tagger.py tag-manager # Opens GUI for tag management python3 photo_tagger.py stats ``` \ No newline at end of file diff --git a/photo_tagger.py b/photo_tagger.py index e37d67c..0fbe0f8 100644 --- a/photo_tagger.py +++ b/photo_tagger.py @@ -4238,6 +4238,656 @@ class PhotoTagger: return identified_count + def tag_management(self) -> int: + """Tag management GUI - file explorer-like interface for managing photo tags""" + import tkinter as tk + from tkinter import ttk, messagebox + from PIL import Image, ImageTk + import os + + # Create the main window + root = tk.Tk() + root.title("Tag Management - Photo Explorer") + root.resizable(True, True) + + # Track window state to prevent multiple destroy calls + window_destroyed = False + temp_crops = [] + photo_images = [] # Keep PhotoImage refs alive + + # Hide window initially to prevent flash at corner + root.withdraw() + + # Set up protocol handler for window close button (X) + def on_closing(): + nonlocal window_destroyed + # Cleanup temp crops + for crop in list(temp_crops): + try: + if os.path.exists(crop): + os.remove(crop) + except: + pass + temp_crops.clear() + if not window_destroyed: + window_destroyed = True + try: + root.destroy() + except tk.TclError: + pass # Window already destroyed + + root.protocol("WM_DELETE_WINDOW", on_closing) + + # Set up window size saving + saved_size = self._setup_window_size_saving(root) + + # Create main frame + main_frame = ttk.Frame(root, padding="10") + main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + # Configure grid weights + root.columnconfigure(0, weight=1) + root.rowconfigure(0, weight=1) + main_frame.columnconfigure(0, weight=1) + main_frame.rowconfigure(1, weight=1) + + # Title and controls frame + header_frame = ttk.Frame(main_frame) + header_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) + header_frame.columnconfigure(1, weight=1) + + # Title label + title_label = ttk.Label(header_frame, text="Photo Explorer - Tag Management", font=("Arial", 16, "bold")) + title_label.grid(row=0, column=0, sticky=tk.W) + + # View mode controls + view_frame = ttk.Frame(header_frame) + view_frame.grid(row=0, column=1, sticky=tk.E) + + view_mode_var = tk.StringVar(value="list") + ttk.Label(view_frame, text="View:").pack(side=tk.LEFT, padx=(0, 5)) + ttk.Radiobutton(view_frame, text="List", variable=view_mode_var, value="list", + command=lambda: switch_view_mode("list")).pack(side=tk.LEFT, padx=(0, 5)) + ttk.Radiobutton(view_frame, text="Icons", variable=view_mode_var, value="icons", + command=lambda: switch_view_mode("icons")).pack(side=tk.LEFT, padx=(0, 5)) + ttk.Radiobutton(view_frame, text="Compact", variable=view_mode_var, value="compact", + command=lambda: switch_view_mode("compact")).pack(side=tk.LEFT) + + # Main content area + content_frame = ttk.Frame(main_frame) + content_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + content_frame.columnconfigure(0, weight=1) + content_frame.rowconfigure(0, weight=1) + + # Style for consistent gray background + style = ttk.Style() + canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' + + # Create canvas and scrollbar for content + content_canvas = tk.Canvas(content_frame, bg=canvas_bg_color, highlightthickness=0) + content_scrollbar = ttk.Scrollbar(content_frame, orient="vertical", command=content_canvas.yview) + content_inner = ttk.Frame(content_canvas) + content_canvas.create_window((0, 0), window=content_inner, anchor="nw") + content_canvas.configure(yscrollcommand=content_scrollbar.set) + + content_inner.bind( + "", + lambda e: content_canvas.configure(scrollregion=content_canvas.bbox("all")) + ) + + content_canvas.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + content_scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S)) + + # Enable mouse scroll anywhere in the dialog + def on_mousewheel(event): + content_canvas.yview_scroll(int(-1*(event.delta/120)), "units") + + # Column resizing variables + resize_start_x = 0 + resize_start_widths = [] + current_visible_cols = [] + is_resizing = False + + def start_resize(event, col_idx): + """Start column resizing""" + nonlocal resize_start_x, resize_start_widths, is_resizing + print(f"DEBUG: start_resize called for column {col_idx}, event.x_root={event.x_root}") # Debug output + is_resizing = True + resize_start_x = event.x_root + # Store current column widths + resize_start_widths = [] + for i, col in enumerate(current_visible_cols): + resize_start_widths.append(col['width']) + print(f"DEBUG: Stored widths: {resize_start_widths}") # Debug output + # Change cursor globally + root.configure(cursor="sb_h_double_arrow") + + def do_resize(event, col_idx): + """Perform column resizing""" + nonlocal resize_start_x, resize_start_widths, is_resizing + print(f"DEBUG: do_resize called for column {col_idx}, is_resizing={is_resizing}") # Debug output + if not is_resizing or not resize_start_widths or not current_visible_cols: + return + + # Calculate width change + delta_x = event.x_root - resize_start_x + + # Update column widths + if col_idx < len(current_visible_cols) and col_idx + 1 < len(current_visible_cols): + # Resize current and next column + new_width_left = max(50, resize_start_widths[col_idx] + delta_x) + new_width_right = max(50, resize_start_widths[col_idx + 1] - delta_x) + + # Update column configuration + current_visible_cols[col_idx]['width'] = new_width_left + current_visible_cols[col_idx + 1]['width'] = new_width_right + + # Update the actual column configuration in the global config + for i, col in enumerate(column_config['list']): + if col['key'] == current_visible_cols[col_idx]['key']: + column_config['list'][i]['width'] = new_width_left + elif col['key'] == current_visible_cols[col_idx + 1]['key']: + column_config['list'][i]['width'] = new_width_right + + # Force immediate visual update by reconfiguring grid weights + try: + header_frame_ref = None + row_frames = [] + for widget in content_inner.winfo_children(): + # First frame is header, subsequent frames are data rows + if isinstance(widget, ttk.Frame): + if header_frame_ref is None: + header_frame_ref = widget + else: + row_frames.append(widget) + + # Update header columns (accounting for separator columns) + if header_frame_ref is not None: + # Update both minsize and weight to force resize + header_frame_ref.columnconfigure(col_idx*2, + weight=current_visible_cols[col_idx]['weight'], + minsize=new_width_left) + header_frame_ref.columnconfigure((col_idx+1)*2, + weight=current_visible_cols[col_idx+1]['weight'], + minsize=new_width_right) + print(f"DEBUG: Updated header columns {col_idx*2} and {(col_idx+1)*2} with widths {new_width_left} and {new_width_right}") + + # Update each data row frame columns (no separators, direct indices) + for rf in row_frames: + rf.columnconfigure(col_idx, + weight=current_visible_cols[col_idx]['weight'], + minsize=new_width_left) + rf.columnconfigure(col_idx+1, + weight=current_visible_cols[col_idx+1]['weight'], + minsize=new_width_right) + + # Force update of the display + root.update_idletasks() + + except Exception as e: + print(f"DEBUG: Error during resize update: {e}") # Debug output + pass # Ignore errors during resize + + def stop_resize(event): + """Stop column resizing""" + nonlocal is_resizing + if is_resizing: + print(f"DEBUG: stop_resize called, was resizing={is_resizing}") # Debug output + is_resizing = False + root.configure(cursor="") + + # Bind mouse wheel to the entire window + root.bind_all("", on_mousewheel) + + # Global mouse release handler that only stops resize if we're actually resizing + def global_mouse_release(event): + if is_resizing: + stop_resize(event) + root.bind_all("", global_mouse_release) + + # Unbind when window is destroyed + def cleanup_mousewheel(): + try: + root.unbind_all("") + root.unbind_all("") + except: + pass + + root.bind("", lambda e: cleanup_mousewheel()) + + # Load photos from database + photos_data = [] + + # Column visibility state + column_visibility = { + 'list': {'id': True, 'filename': True, 'path': True, 'processed': True, 'date_taken': True, 'faces': True, 'tags': True}, + 'icons': {'thumbnail': True, 'id': True, 'filename': True, 'processed': True, 'date_taken': True, 'faces': True, 'tags': True}, + 'compact': {'filename': True, 'faces': True, 'tags': True} + } + + # Column order and configuration + column_config = { + 'list': [ + {'key': 'id', 'label': 'ID', 'width': 50, 'weight': 0}, + {'key': 'filename', 'label': 'Filename', 'width': 150, 'weight': 1}, + {'key': 'path', 'label': 'Path', 'width': 200, 'weight': 2}, + {'key': 'processed', 'label': 'Processed', 'width': 80, 'weight': 0}, + {'key': 'date_taken', 'label': 'Date Taken', 'width': 120, 'weight': 0}, + {'key': 'faces', 'label': 'Faces', 'width': 60, 'weight': 0}, + {'key': 'tags', 'label': 'Tags', 'width': 150, 'weight': 1} + ], + 'icons': [ + {'key': 'thumbnail', 'label': 'Thumbnail', 'width': 160, 'weight': 0}, + {'key': 'id', 'label': 'ID', 'width': 80, 'weight': 0}, + {'key': 'filename', 'label': 'Filename', 'width': 200, 'weight': 1}, + {'key': 'processed', 'label': 'Processed', 'width': 80, 'weight': 0}, + {'key': 'date_taken', 'label': 'Date Taken', 'width': 120, 'weight': 0}, + {'key': 'faces', 'label': 'Faces', 'width': 60, 'weight': 0}, + {'key': 'tags', 'label': 'Tags', 'width': 150, 'weight': 1} + ], + 'compact': [ + {'key': 'filename', 'label': 'Filename', 'width': 200, 'weight': 1}, + {'key': 'faces', 'label': 'Faces', 'width': 60, 'weight': 0}, + {'key': 'tags', 'label': 'Tags', 'width': 150, 'weight': 1} + ] + } + + def load_photos(): + nonlocal photos_data + with self.get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT p.id, p.filename, p.path, p.processed, p.date_taken, p.date_added, + COUNT(f.id) as face_count, + GROUP_CONCAT(DISTINCT t.tag_name) as tags + FROM photos p + LEFT JOIN faces f ON f.photo_id = p.id + LEFT JOIN tags t ON t.photo_id = p.id + GROUP BY p.id, p.filename, p.path, p.processed, p.date_taken, p.date_added + ORDER BY p.date_taken DESC, p.filename + ''') + photos_data = [] + for row in cursor.fetchall(): + photos_data.append({ + 'id': row[0], + 'filename': row[1], + 'path': row[2], + 'processed': row[3], + 'date_taken': row[4], + 'date_added': row[5], + 'face_count': row[6] or 0, + 'tags': row[7] or "" + }) + + def clear_content(): + for widget in content_inner.winfo_children(): + widget.destroy() + # Cleanup temp crops + for crop in list(temp_crops): + try: + if os.path.exists(crop): + os.remove(crop) + except: + pass + temp_crops.clear() + photo_images.clear() + + def show_column_context_menu(event, view_mode): + """Show context menu for column visibility""" + # Create a custom popup window instead of a menu + popup = tk.Toplevel(root) + popup.wm_overrideredirect(True) + popup.wm_geometry(f"+{event.x_root}+{event.y_root}") + popup.configure(bg='white', relief='flat', bd=0) + + # Define columns that cannot be hidden + protected_columns = { + 'icons': ['thumbnail'], + 'compact': ['filename'], + 'list': ['filename'] + } + + # Create frame for menu items + menu_frame = tk.Frame(popup, bg='white') + menu_frame.pack(padx=2, pady=2) + + # Variables to track checkbox states + checkbox_vars = {} + + for col in column_config[view_mode]: + key = col['key'] + label = col['label'] + is_visible = column_visibility[view_mode][key] + is_protected = key in protected_columns.get(view_mode, []) + + # Create frame for this menu item + item_frame = tk.Frame(menu_frame, bg='white', relief='flat', bd=0) + item_frame.pack(fill=tk.X, pady=1) + + # Create checkbox variable + var = tk.BooleanVar(value=is_visible) + checkbox_vars[key] = var + + def make_toggle_command(col_key, var_ref): + def toggle_column(): + if col_key in protected_columns.get(view_mode, []): + return + # The checkbox has already toggled its state automatically + # Just sync it with our column visibility + column_visibility[view_mode][col_key] = var_ref.get() + # Refresh the view + switch_view_mode(view_mode) + return toggle_column + + if is_protected: + # Protected columns - disabled checkbox + cb = tk.Checkbutton(item_frame, text=label, variable=var, + state='disabled', bg='white', fg='gray', + font=("Arial", 9), relief='flat', bd=0, + highlightthickness=0) + cb.pack(side=tk.LEFT, padx=5, pady=2) + tk.Label(item_frame, text="(always visible)", bg='white', fg='gray', + font=("Arial", 8)).pack(side=tk.LEFT, padx=(0, 5)) + else: + # Regular columns - clickable checkbox + cb = tk.Checkbutton(item_frame, text=label, variable=var, + command=make_toggle_command(key, var), + bg='white', font=("Arial", 9), relief='flat', bd=0, + highlightthickness=0) + cb.pack(side=tk.LEFT, padx=5, pady=2) + + # Function to close popup + def close_popup(): + try: + popup.destroy() + except: + pass + + # Bind events to close popup + def close_on_click_outside(event): + # Close popup when clicking anywhere in the main window + # Check if the click is not on the popup itself + if event.widget != popup: + try: + # Check if popup still exists + popup.winfo_exists() + # If we get here, popup exists, so close it + close_popup() + except tk.TclError: + # Popup was already destroyed, do nothing + pass + + root.bind("", close_on_click_outside) + root.bind("", close_on_click_outside) + + # Also bind to the main content area + content_canvas.bind("", close_on_click_outside) + content_canvas.bind("", close_on_click_outside) + + # Focus the popup + popup.focus_set() + + + def show_list_view(): + clear_content() + + # Get visible columns and store globally for resize functions + nonlocal current_visible_cols + current_visible_cols = [col.copy() for col in column_config['list'] if column_visibility['list'][col['key']]] + col_count = len(current_visible_cols) + + if col_count == 0: + ttk.Label(content_inner, text="No columns visible. Right-click to show columns.", + font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20) + return + + # Configure column weights for visible columns + for i, col in enumerate(current_visible_cols): + content_inner.columnconfigure(i, weight=col['weight'], minsize=col['width']) + + # Create header row + header_frame = ttk.Frame(content_inner) + header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) + + # Configure header frame columns (accounting for separators) + for i, col in enumerate(current_visible_cols): + header_frame.columnconfigure(i*2, weight=col['weight'], minsize=col['width']) + if i < len(current_visible_cols) - 1: + header_frame.columnconfigure(i*2+1, weight=0, minsize=1) # Separator column + + # Create header labels with right-click context menu and resizable separators + for i, col in enumerate(current_visible_cols): + header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold")) + header_label.grid(row=0, column=i*2, padx=5, sticky=tk.W) + # Bind right-click to each label as well + header_label.bind("", lambda e, mode='list': show_column_context_menu(e, mode)) + + # Add resizable vertical separator after each column (except the last one) + if i < len(current_visible_cols) - 1: + # Create a more visible separator frame with inner dark line + separator_frame = tk.Frame(header_frame, width=10, bg='red', cursor="sb_h_double_arrow") # Bright red for debugging + separator_frame.grid(row=0, column=i*2+1, sticky='ns', padx=0) + separator_frame.grid_propagate(False) # Maintain fixed width + # Inner dark line for better contrast + inner_line = tk.Frame(separator_frame, bg='darkred', width=2) # Dark red for debugging + inner_line.pack(fill=tk.Y, expand=True) + + # Make separator resizable + separator_frame.bind("", lambda e, col_idx=i: start_resize(e, col_idx)) + separator_frame.bind("", lambda e, col_idx=i: do_resize(e, col_idx)) + separator_frame.bind("", stop_resize) + separator_frame.bind("", lambda e, f=separator_frame, l=inner_line: (f.configure(bg='orange'), l.configure(bg='darkorange'))) # Orange for debugging + separator_frame.bind("", lambda e, f=separator_frame, l=inner_line: (f.configure(bg='red'), l.configure(bg='darkred'))) # Red for debugging + + # Also bind to the inner line for better hit detection + inner_line.bind("", lambda e, col_idx=i: start_resize(e, col_idx)) + inner_line.bind("", lambda e, col_idx=i: do_resize(e, col_idx)) + inner_line.bind("", stop_resize) + + # Bind right-click to the entire header frame + header_frame.bind("", lambda e, mode='list': show_column_context_menu(e, mode)) + + # Add separator + ttk.Separator(content_inner, orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) + + # Add photo rows + for idx, photo in enumerate(photos_data): + row_frame = ttk.Frame(content_inner) + row_frame.grid(row=idx+2, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=2) + + # Configure row frame columns (no separators in data rows) + for i, col in enumerate(current_visible_cols): + row_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) + + for i, col in enumerate(current_visible_cols): + key = col['key'] + if key == 'id': + text = str(photo['id']) + elif key == 'filename': + text = photo['filename'] + elif key == 'path': + text = photo['path'] + elif key == 'processed': + text = "Yes" if photo['processed'] else "No" + elif key == 'date_taken': + text = photo['date_taken'] or "Unknown" + elif key == 'faces': + text = str(photo['face_count']) + elif key == 'tags': + text = photo['tags'] or "None" + + ttk.Label(row_frame, text=text).grid(row=0, column=i, padx=5, sticky=tk.W) + + def show_icon_view(): + clear_content() + + # Get visible columns + visible_cols = [col for col in column_config['icons'] if column_visibility['icons'][col['key']]] + col_count = len(visible_cols) + + if col_count == 0: + ttk.Label(content_inner, text="No columns visible. Right-click to show columns.", + font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20) + return + + # Configure column weights for visible columns + for i, col in enumerate(visible_cols): + content_inner.columnconfigure(i, weight=col['weight'], minsize=col['width']) + + # Create header row + header_frame = ttk.Frame(content_inner) + header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) + + for i, col in enumerate(visible_cols): + header_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) + + # Create header labels with right-click context menu + for i, col in enumerate(visible_cols): + header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold")) + header_label.grid(row=0, column=i, padx=5, sticky=tk.W) + # Bind right-click to each label as well + header_label.bind("", lambda e, mode='icons': show_column_context_menu(e, mode)) + + # Bind right-click to the entire header frame + header_frame.bind("", lambda e, mode='icons': show_column_context_menu(e, mode)) + + # Add separator + ttk.Separator(content_inner, orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) + + # Show all photos in structured rows + for idx, photo in enumerate(photos_data): + row_frame = ttk.Frame(content_inner) + row_frame.grid(row=idx+2, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=2) + + for i, col in enumerate(visible_cols): + row_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) + + col_idx = 0 + for col in visible_cols: + key = col['key'] + + if key == 'thumbnail': + # Thumbnail column + thumbnail_frame = ttk.Frame(row_frame) + thumbnail_frame.grid(row=0, column=col_idx, padx=5, sticky=tk.W) + + try: + if os.path.exists(photo['path']): + img = Image.open(photo['path']) + img.thumbnail((150, 150), Image.Resampling.LANCZOS) + photo_img = ImageTk.PhotoImage(img) + photo_images.append(photo_img) + + # Create canvas for image + canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg=canvas_bg_color, highlightthickness=0) + canvas.pack() + canvas.create_image(75, 75, image=photo_img) + else: + # Placeholder for missing image + canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg=canvas_bg_color, highlightthickness=0) + canvas.pack() + canvas.create_text(75, 75, text="🖼️", fill="gray", font=("Arial", 24)) + except Exception: + # Error loading image + canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg=canvas_bg_color, highlightthickness=0) + canvas.pack() + canvas.create_text(75, 75, text="❌", fill="red", font=("Arial", 24)) + else: + # Data columns + if key == 'id': + text = str(photo['id']) + elif key == 'filename': + text = photo['filename'] + elif key == 'processed': + text = "Yes" if photo['processed'] else "No" + elif key == 'date_taken': + text = photo['date_taken'] or "Unknown" + elif key == 'faces': + text = str(photo['face_count']) + elif key == 'tags': + text = photo['tags'] or "None" + + ttk.Label(row_frame, text=text).grid(row=0, column=col_idx, padx=5, sticky=tk.W) + + col_idx += 1 + + def show_compact_view(): + clear_content() + + # Get visible columns + visible_cols = [col for col in column_config['compact'] if column_visibility['compact'][col['key']]] + col_count = len(visible_cols) + + if col_count == 0: + ttk.Label(content_inner, text="No columns visible. Right-click to show columns.", + font=("Arial", 12), foreground="gray").grid(row=0, column=0, pady=20) + return + + # Configure column weights for visible columns + for i, col in enumerate(visible_cols): + content_inner.columnconfigure(i, weight=col['weight'], minsize=col['width']) + + # Create header + header_frame = ttk.Frame(content_inner) + header_frame.grid(row=0, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) + + for i, col in enumerate(visible_cols): + header_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) + + # Create header labels with right-click context menu + for i, col in enumerate(visible_cols): + header_label = ttk.Label(header_frame, text=col['label'], font=("Arial", 10, "bold")) + header_label.grid(row=0, column=i, padx=5, sticky=tk.W) + # Bind right-click to each label as well + header_label.bind("", lambda e, mode='compact': show_column_context_menu(e, mode)) + + # Bind right-click to the entire header frame + header_frame.bind("", lambda e, mode='compact': show_column_context_menu(e, mode)) + + # Add separator + ttk.Separator(content_inner, orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5)) + + # Add photo rows + for idx, photo in enumerate(photos_data): + row_frame = ttk.Frame(content_inner) + row_frame.grid(row=idx+2, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=1) + + for i, col in enumerate(visible_cols): + row_frame.columnconfigure(i, weight=col['weight'], minsize=col['width']) + + col_idx = 0 + for col in visible_cols: + key = col['key'] + if key == 'filename': + text = photo['filename'] + elif key == 'faces': + text = str(photo['face_count']) + elif key == 'tags': + text = photo['tags'] or "None" + + ttk.Label(row_frame, text=text).grid(row=0, column=col_idx, padx=5, sticky=tk.W) + col_idx += 1 + + def switch_view_mode(mode): + if mode == "list": + show_list_view() + elif mode == "icons": + show_icon_view() + elif mode == "compact": + show_compact_view() + + # No need for canvas resize handler since icon view is now single column + + # Load initial data and show default view + load_photos() + show_list_view() + + # Show window + root.deiconify() + root.mainloop() + + return 0 + def modifyidentified(self) -> int: """Modify identified faces interface - empty window with Quit button for now""" import tkinter as tk @@ -5309,12 +5959,13 @@ Examples: photo_tagger.py match 15 # Find faces similar to face ID 15 photo_tagger.py tag --pattern "vacation" # Tag photos matching pattern photo_tagger.py search "John" # Find photos with John + photo_tagger.py tag-manager # Open tag management GUI photo_tagger.py stats # Show statistics """ ) parser.add_argument('command', - choices=['scan', 'process', 'identify', 'tag', 'search', 'stats', 'match', 'auto-match', 'modifyidentified'], + choices=['scan', 'process', 'identify', 'tag', 'search', 'stats', 'match', 'auto-match', 'modifyidentified', 'tag-manager'], help='Command to execute') parser.add_argument('target', nargs='?', @@ -5409,6 +6060,9 @@ Examples: elif args.command == 'modifyidentified': tagger.modifyidentified() + elif args.command == 'tag-manager': + tagger.tag_management() + return 0 except KeyboardInterrupt: