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:
parent
68ec18b822
commit
b6e6b38a76
60
README.md
60
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
|
||||
```
|
||||
656
photo_tagger.py
656
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(
|
||||
"<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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user