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.
This commit is contained in:
tanyar09 2025-10-01 13:57:45 -04:00
parent 68ec18b822
commit b6e6b38a76
2 changed files with 715 additions and 1 deletions

View File

@ -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
```

View File

@ -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(
"<Configure>",
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("<MouseWheel>", 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("<ButtonRelease-1>", global_mouse_release)
# Unbind when window is destroyed
def cleanup_mousewheel():
try:
root.unbind_all("<MouseWheel>")
root.unbind_all("<ButtonRelease-1>")
except:
pass
root.bind("<Destroy>", 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("<Button-1>", close_on_click_outside)
root.bind("<Button-3>", close_on_click_outside)
# Also bind to the main content area
content_canvas.bind("<Button-1>", close_on_click_outside)
content_canvas.bind("<Button-3>", 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("<Button-3>", 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("<Button-1>", lambda e, col_idx=i: start_resize(e, col_idx))
separator_frame.bind("<B1-Motion>", lambda e, col_idx=i: do_resize(e, col_idx))
separator_frame.bind("<ButtonRelease-1>", stop_resize)
separator_frame.bind("<Enter>", lambda e, f=separator_frame, l=inner_line: (f.configure(bg='orange'), l.configure(bg='darkorange'))) # Orange for debugging
separator_frame.bind("<Leave>", 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("<Button-1>", lambda e, col_idx=i: start_resize(e, col_idx))
inner_line.bind("<B1-Motion>", lambda e, col_idx=i: do_resize(e, col_idx))
inner_line.bind("<ButtonRelease-1>", stop_resize)
# Bind right-click to the entire header frame
header_frame.bind("<Button-3>", 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("<Button-3>", lambda e, mode='icons': show_column_context_menu(e, mode))
# Bind right-click to the entire header frame
header_frame.bind("<Button-3>", 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("<Button-3>", lambda e, mode='compact': show_column_context_menu(e, mode))
# Bind right-click to the entire header frame
header_frame.bind("<Button-3>", 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: