punimtag/tag_manager_panel.py
tanyar09 3e88e2cd2c Enhance Dashboard GUI with smart navigation and unified exit behavior
This commit introduces a compact home icon for quick navigation to the welcome screen, improving user experience across all panels. Additionally, all exit buttons now navigate to the home screen instead of closing the application, ensuring a consistent exit behavior. The README has been updated to reflect these enhancements, emphasizing the improved navigation and user experience in the unified dashboard.
2025-10-10 14:47:38 -04:00

1525 lines
80 KiB
Python

#!/usr/bin/env python3
"""
Integrated Tag Manager Panel for PunimTag Dashboard
Embeds the full tag manager GUI functionality into the dashboard frame
"""
import os
import tkinter as tk
from tkinter import ttk, messagebox, simpledialog
from PIL import Image
try:
from PIL import ImageTk
except ImportError:
# Fallback for older PIL versions
import ImageTk
from typing import List, Dict, Tuple, Optional
import sys
import subprocess
from database import DatabaseManager
from gui_core import GUICore
from tag_management import TagManager
from face_processing import FaceProcessor
class TagManagerPanel:
"""Integrated tag manager panel that embeds the full tag manager GUI functionality into the dashboard"""
def __init__(self, parent_frame: ttk.Frame, db_manager: DatabaseManager,
gui_core: GUICore, tag_manager: TagManager, face_processor: FaceProcessor, on_navigate_home=None, verbose: int = 0):
"""Initialize the tag manager panel"""
self.parent_frame = parent_frame
self.db = db_manager
self.gui_core = gui_core
self.tag_manager = tag_manager
self.face_processor = face_processor
self.on_navigate_home = on_navigate_home
self.verbose = verbose
# Panel state
self.is_active = False
self.temp_crops: List[str] = []
self.photo_images: List[ImageTk.PhotoImage] = [] # Keep PhotoImage refs alive
self.folder_states: Dict[str, bool] = {}
# Track pending tag changes/removals using tag IDs
self.pending_tag_changes: Dict[int, List[int]] = {}
self.pending_tag_removals: Dict[int, List[int]] = {}
# Track linkage type for pending additions: 0=single, 1=bulk
self.pending_tag_linkage_type: Dict[int, Dict[int, int]] = {}
# Tag data
self.existing_tags: List[str] = []
self.tag_id_to_name: Dict[int, str] = {}
self.tag_name_to_id: Dict[str, int] = {}
# Photo data
self.photos_data: List[Dict] = []
self.people_names_cache: Dict[int, List[str]] = {} # {photo_id: [list of people names]}
# View configuration
self.view_mode_var = tk.StringVar(value="list")
self.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 resizing state
self.resize_start_x = 0
self.resize_start_widths: List[int] = []
self.current_visible_cols: List[Dict] = []
self.is_resizing = False
self.column_config = {
'list': [
{'key': 'id', 'label': 'ID', 'width': 60, 'weight': 0},
{'key': 'filename', 'label': 'Filename', 'width': 250, 'weight': 1},
{'key': 'path', 'label': 'Path', 'width': 400, 'weight': 2},
{'key': 'processed', 'label': 'Processed', 'width': 100, 'weight': 0},
{'key': 'date_taken', 'label': 'Date Taken', 'width': 140, 'weight': 0},
{'key': 'faces', 'label': 'Faces', 'width': 80, 'weight': 0},
{'key': 'tags', 'label': 'Tags', 'width': 300, 'weight': 1}
],
'icons': [
{'key': 'thumbnail', 'label': 'Thumbnail', 'width': 200, 'weight': 0},
{'key': 'id', 'label': 'ID', 'width': 80, 'weight': 0},
{'key': 'filename', 'label': 'Filename', 'width': 300, 'weight': 1},
{'key': 'processed', 'label': 'Processed', 'width': 100, 'weight': 0},
{'key': 'date_taken', 'label': 'Date Taken', 'width': 140, 'weight': 0},
{'key': 'faces', 'label': 'Faces', 'width': 80, 'weight': 0},
{'key': 'tags', 'label': 'Tags', 'width': 300, 'weight': 1}
],
'compact': [
{'key': 'filename', 'label': 'Filename', 'width': 400, 'weight': 1},
{'key': 'faces', 'label': 'Faces', 'width': 80, 'weight': 0},
{'key': 'tags', 'label': 'Tags', 'width': 500, 'weight': 1}
]
}
# GUI components
self.components = {}
self.main_frame = None
def create_panel(self) -> ttk.Frame:
"""Create the tag manager panel with all GUI components"""
self.main_frame = ttk.Frame(self.parent_frame)
# Configure grid weights for full screen responsiveness
self.main_frame.columnconfigure(0, weight=1)
self.main_frame.rowconfigure(0, weight=0) # Header - fixed height
self.main_frame.rowconfigure(1, weight=1) # Content area - expandable
self.main_frame.rowconfigure(2, weight=0) # Bottom buttons - fixed height
# Create all GUI components
self._create_header()
self._create_content_area()
self._create_bottom_buttons()
# Load initial data
self._load_existing_tags()
self._load_photos()
self._switch_view_mode("list")
return self.main_frame
def _create_header(self):
"""Create the header with title and view controls"""
header_frame = ttk.Frame(self.main_frame)
header_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10))
header_frame.columnconfigure(1, weight=1)
# Title
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 controls
view_frame = ttk.Frame(header_frame)
view_frame.grid(row=0, column=1, sticky=tk.E)
ttk.Label(view_frame, text="View:").pack(side=tk.LEFT, padx=(0, 5))
ttk.Radiobutton(view_frame, text="List", variable=self.view_mode_var, value="list",
command=lambda: self._switch_view_mode("list")).pack(side=tk.LEFT, padx=(0, 5))
ttk.Radiobutton(view_frame, text="Icons", variable=self.view_mode_var, value="icons",
command=lambda: self._switch_view_mode("icons")).pack(side=tk.LEFT, padx=(0, 5))
ttk.Radiobutton(view_frame, text="Compact", variable=self.view_mode_var, value="compact",
command=lambda: self._switch_view_mode("compact")).pack(side=tk.LEFT)
# Manage Tags button
manage_tags_btn = ttk.Button(header_frame, text="Manage Tags", command=self._open_manage_tags_dialog)
manage_tags_btn.grid(row=0, column=2, sticky=tk.E, padx=(10, 0))
self.components['header_frame'] = header_frame
self.components['view_frame'] = view_frame
self.components['manage_tags_btn'] = manage_tags_btn
def _create_content_area(self):
"""Create the main content area with scrollable canvas"""
content_frame = ttk.Frame(self.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)
# Get canvas background color
style = ttk.Style()
canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9'
# Create scrollable content area
self.components['content_canvas'] = tk.Canvas(content_frame, bg=canvas_bg_color, highlightthickness=0)
self.components['content_scrollbar'] = ttk.Scrollbar(content_frame, orient="vertical",
command=self.components['content_canvas'].yview)
self.components['content_inner'] = ttk.Frame(self.components['content_canvas'])
self.components['content_canvas'].create_window((0, 0), window=self.components['content_inner'], anchor="nw")
self.components['content_canvas'].configure(yscrollcommand=self.components['content_scrollbar'].set)
self.components['content_inner'].bind("<Configure>",
lambda e: self.components['content_canvas'].configure(
scrollregion=self.components['content_canvas'].bbox("all")))
self.components['content_canvas'].grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
self.components['content_scrollbar'].grid(row=0, column=1, sticky=(tk.N, tk.S))
# Bind mousewheel scrolling
self._bind_mousewheel_scrolling()
self.components['content_frame'] = content_frame
def _create_bottom_buttons(self):
"""Create the bottom button area"""
bottom_frame = ttk.Frame(self.main_frame)
bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E), pady=(10, 0))
# Save button
self.components['save_button'] = ttk.Button(bottom_frame, text="Save Tagging",
command=self._save_tagging_changes)
self.components['save_button'].pack(side=tk.RIGHT, padx=10, pady=5)
# Quit button (for standalone mode - not used in dashboard)
self.components['quit_button'] = ttk.Button(bottom_frame, text="Exit Tag Manager",
command=self._quit_with_warning)
self.components['quit_button'].pack(side=tk.RIGHT, padx=(0, 10), pady=5)
self.components['bottom_frame'] = bottom_frame
def _bind_mousewheel_scrolling(self):
"""Bind mousewheel scrolling to the content canvas"""
def on_mousewheel(event):
self.components['content_canvas'].yview_scroll(int(-1 * (event.delta / 120)), "units")
# Bind to the main frame and all its children
self.main_frame.bind_all("<MouseWheel>", on_mousewheel)
self.main_frame.bind_all("<ButtonRelease-1>", self._global_mouse_release)
# Store reference for cleanup
self._mousewheel_handler = on_mousewheel
def _start_resize(self, event, col_idx):
"""Start column resizing"""
self.is_resizing = True
self.resize_start_x = event.x_root
self.resize_start_widths = [col['width'] for col in self.current_visible_cols]
self.main_frame.configure(cursor="sb_h_double_arrow")
def _do_resize(self, event, col_idx):
"""Handle column resizing during drag"""
if not self.is_resizing or not self.resize_start_widths or not self.current_visible_cols:
return
delta_x = event.x_root - self.resize_start_x
if col_idx < len(self.current_visible_cols) and col_idx + 1 < len(self.current_visible_cols):
new_width_left = max(50, self.resize_start_widths[col_idx] + delta_x)
new_width_right = max(50, self.resize_start_widths[col_idx + 1] - delta_x)
self.current_visible_cols[col_idx]['width'] = new_width_left
self.current_visible_cols[col_idx + 1]['width'] = new_width_right
# Update column config
for i, col in enumerate(self.column_config['list']):
if col['key'] == self.current_visible_cols[col_idx]['key']:
self.column_config['list'][i]['width'] = new_width_left
elif col['key'] == self.current_visible_cols[col_idx + 1]['key']:
self.column_config['list'][i]['width'] = new_width_right
# Update grid configuration
try:
header_frame_ref = None
row_frames = []
for widget in self.components['content_inner'].winfo_children():
if isinstance(widget, ttk.Frame):
if header_frame_ref is None:
header_frame_ref = widget
else:
row_frames.append(widget)
if header_frame_ref is not None:
header_frame_ref.columnconfigure(col_idx * 2, weight=self.current_visible_cols[col_idx]['weight'], minsize=new_width_left)
header_frame_ref.columnconfigure((col_idx + 1) * 2, weight=self.current_visible_cols[col_idx + 1]['weight'], minsize=new_width_right)
for rf in row_frames:
rf.columnconfigure(col_idx, weight=self.current_visible_cols[col_idx]['weight'], minsize=new_width_left)
rf.columnconfigure(col_idx + 1, weight=self.current_visible_cols[col_idx + 1]['weight'], minsize=new_width_right)
self.main_frame.update_idletasks()
except Exception:
pass
def _stop_resize(self, event):
"""Stop column resizing"""
self.is_resizing = False
self.main_frame.configure(cursor="")
def _global_mouse_release(self, event):
"""Handle global mouse release for column resizing"""
if self.is_resizing:
self._stop_resize(event)
def _show_column_context_menu(self, event, view_mode):
"""Show column context menu for show/hide columns"""
popup = tk.Toplevel(self.main_frame)
popup.wm_overrideredirect(True)
popup.wm_geometry(f"+{event.x_root}+{event.y_root}")
popup.configure(bg='white', relief='flat', bd=0)
menu_frame = tk.Frame(popup, bg='white')
menu_frame.pack(padx=2, pady=2)
checkbox_vars: Dict[str, tk.BooleanVar] = {}
protected_columns = {'icons': ['thumbnail'], 'compact': ['filename'], 'list': ['filename']}
def close_popup():
try:
popup.destroy()
except Exception:
pass
def close_on_click_outside(e):
if e.widget != popup:
try:
popup.winfo_exists()
close_popup()
except tk.TclError:
pass
for col in self.column_config[view_mode]:
key = col['key']
label = col['label']
is_visible = self.column_visibility[view_mode][key]
is_protected = key in protected_columns.get(view_mode, [])
item_frame = tk.Frame(menu_frame, bg='white', relief='flat', bd=0)
item_frame.pack(fill=tk.X, pady=1)
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
self.column_visibility[view_mode][col_key] = var_ref.get()
self._switch_view_mode(view_mode)
return toggle_column
if is_protected:
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:
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)
self.main_frame.bind("<Button-1>", close_on_click_outside)
self.main_frame.bind("<Button-3>", close_on_click_outside)
self.components['content_canvas'].bind("<Button-1>", close_on_click_outside)
self.components['content_canvas'].bind("<Button-3>", close_on_click_outside)
popup.focus_set()
def _unbind_mousewheel_scrolling(self):
"""Unbind mousewheel scrolling and mouse events"""
try:
self.main_frame.unbind_all("<MouseWheel>")
self.main_frame.unbind_all("<ButtonRelease-1>")
except Exception:
pass
def _load_existing_tags(self):
"""Load existing tags from database"""
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, tag_name FROM tags ORDER BY tag_name')
self.existing_tags = []
self.tag_id_to_name = {}
self.tag_name_to_id = {}
for row in cursor.fetchall():
tag_id, tag_name = row
self.existing_tags.append(tag_name)
self.tag_id_to_name[tag_id] = tag_name
self.tag_name_to_id[tag_name] = tag_id
def _load_photos(self):
"""Load photos data from database"""
with self.db.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,
(SELECT COUNT(*) FROM faces f WHERE f.photo_id = p.id) as face_count,
(SELECT GROUP_CONCAT(DISTINCT t.tag_name)
FROM phototaglinkage ptl
JOIN tags t ON t.id = ptl.tag_id
WHERE ptl.photo_id = p.id) as tags
FROM photos p
ORDER BY p.date_taken DESC, p.filename
''')
self.photos_data = []
for row in cursor.fetchall():
self.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 ""
})
# Cache people names for each photo
self.people_names_cache.clear()
cursor.execute('''
SELECT f.photo_id, pe.first_name, pe.last_name
FROM faces f
JOIN people pe ON pe.id = f.person_id
WHERE f.person_id IS NOT NULL
ORDER BY pe.last_name, pe.first_name
''')
cache_rows = cursor.fetchall()
for row in cache_rows:
photo_id, first_name, last_name = row
if photo_id not in self.people_names_cache:
self.people_names_cache[photo_id] = []
# Format as "Last, First" or just "First" if no last name
if last_name and first_name:
name = f"{last_name}, {first_name}"
else:
name = first_name or last_name or "Unknown"
if name not in self.people_names_cache[photo_id]:
self.people_names_cache[photo_id].append(name)
def _get_saved_tag_types_for_photo(self, photo_id: int) -> Dict[int, int]:
"""Get saved linkage types for a photo {tag_id: type_int}"""
types: Dict[int, int] = {}
try:
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT tag_id, linkage_type FROM phototaglinkage WHERE photo_id = ?', (photo_id,))
for row in cursor.fetchall():
try:
types[row[0]] = int(row[1]) if row[1] is not None else 0
except Exception:
types[row[0]] = 0
except Exception:
pass
return types
def _get_people_names_for_photo(self, photo_id: int) -> str:
"""Get people names for a photo as a formatted string for tooltip"""
people_names = self.people_names_cache.get(photo_id, [])
if not people_names:
return "No people identified"
# Remove commas from names (convert "Last, First" to "Last First")
formatted_names = []
for name in people_names:
if ', ' in name:
# Convert "Last, First" to "Last First"
formatted_name = name.replace(', ', ' ')
else:
formatted_name = name
formatted_names.append(formatted_name)
if len(formatted_names) <= 5:
return f"People: {', '.join(formatted_names)}"
else:
return f"People: {', '.join(formatted_names[:5])}... (+{len(formatted_names)-5} more)"
def _open_photo(self, photo_path: str):
"""Open a photo in a preview window"""
if not os.path.exists(photo_path):
try:
messagebox.showerror("File not found", f"Photo does not exist:\n{photo_path}")
except Exception:
pass
return
try:
# Open in an in-app preview window sized reasonably compared to the main GUI
img = Image.open(photo_path)
screen_w = self.main_frame.winfo_screenwidth()
screen_h = self.main_frame.winfo_screenheight()
max_w = int(min(1000, screen_w * 0.6))
max_h = int(min(800, screen_h * 0.6))
preview = tk.Toplevel(self.main_frame)
preview.title(os.path.basename(photo_path))
preview.transient(self.main_frame)
# Resize image to fit nicely while keeping aspect ratio
img_copy = img.copy()
img_copy.thumbnail((max_w, max_h), Image.Resampling.LANCZOS)
photo_img = ImageTk.PhotoImage(img_copy)
self.photo_images.append(photo_img)
pad = 12
w, h = photo_img.width(), photo_img.height()
# Center the window roughly relative to screen
x = int((screen_w - (w + pad)) / 2)
y = int((screen_h - (h + pad)) / 2)
preview.geometry(f"{w + pad}x{h + pad}+{max(x,0)}+{max(y,0)}")
canvas = tk.Canvas(preview, width=w, height=h, highlightthickness=0)
canvas.pack(padx=pad//2, pady=pad//2)
canvas.create_image(w // 2, h // 2, image=photo_img)
preview.focus_set()
except Exception:
# Fallback to system default opener if preview fails for any reason
try:
if sys.platform.startswith('linux'):
subprocess.Popen(['xdg-open', photo_path])
elif sys.platform == 'darwin':
subprocess.Popen(['open', photo_path])
elif os.name == 'nt':
os.startfile(photo_path) # type: ignore[attr-defined]
else:
Image.open(photo_path).show()
except Exception as e:
try:
messagebox.showerror("Error", f"Failed to open photo:\n{e}")
except Exception:
pass
def _open_manage_tags_dialog(self):
"""Open the manage tags dialog"""
dialog = tk.Toplevel(self.main_frame)
dialog.title("Manage Tags")
dialog.transient(self.main_frame)
dialog.grab_set()
dialog.geometry("600x600")
top_frame = ttk.Frame(dialog, padding="8")
top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E))
list_frame = ttk.Frame(dialog, padding="8")
list_frame.grid(row=1, column=0, sticky=(tk.N, tk.S, tk.W, tk.E))
bottom_frame = ttk.Frame(dialog, padding="8")
bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E))
dialog.columnconfigure(0, weight=1)
dialog.rowconfigure(1, weight=1)
new_tag_var = tk.StringVar()
new_tag_entry = ttk.Entry(top_frame, textvariable=new_tag_var, width=30)
new_tag_entry.grid(row=0, column=0, padx=(0, 8), sticky=(tk.W, tk.E))
def add_new_tag():
tag_name = new_tag_var.get().strip()
if not tag_name:
return
try:
# Use database method for case-insensitive tag creation
tag_id = self.db.add_tag(tag_name)
if tag_id:
new_tag_var.set("")
refresh_tag_list()
self._load_existing_tags()
self._switch_view_mode(self.view_mode_var.get())
except Exception as e:
messagebox.showerror("Error", f"Failed to add tag: {e}")
add_btn = ttk.Button(top_frame, text="Add tag", command=add_new_tag)
add_btn.grid(row=0, column=1, sticky=tk.W)
top_frame.columnconfigure(0, weight=1)
canvas = tk.Canvas(list_frame, highlightthickness=0)
scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview)
rows_container = ttk.Frame(canvas)
canvas.create_window((0, 0), window=rows_container, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
canvas.grid(row=0, column=0, sticky=(tk.N, tk.S, tk.W, tk.E))
scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S))
list_frame.columnconfigure(0, weight=1)
list_frame.rowconfigure(0, weight=1)
rows_container.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
selected_tag_vars: Dict[int, tk.BooleanVar] = {}
current_tags: List[Dict] = []
def refresh_tag_list():
for child in list(rows_container.winfo_children()):
child.destroy()
selected_tag_vars.clear()
current_tags.clear()
try:
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, tag_name FROM tags ORDER BY tag_name COLLATE NOCASE')
for row in cursor.fetchall():
current_tags.append({'id': row[0], 'tag_name': row[1]})
except Exception as e:
messagebox.showerror("Error", f"Failed to load tags: {e}")
return
head = ttk.Frame(rows_container)
head.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 6))
ttk.Label(head, text="Delete").pack(side=tk.LEFT, padx=(0, 10))
ttk.Label(head, text="Tag name", width=30).pack(side=tk.LEFT)
ttk.Label(head, text="Edit", width=6).pack(side=tk.LEFT, padx=(10, 0))
for idx, tag in enumerate(current_tags, start=1):
row = ttk.Frame(rows_container)
row.grid(row=idx, column=0, sticky=(tk.W, tk.E), pady=2)
var = tk.BooleanVar(value=False)
selected_tag_vars[tag['id']] = var
ttk.Checkbutton(row, variable=var).pack(side=tk.LEFT, padx=(0, 10))
name_label = ttk.Label(row, text=tag['tag_name'], width=30)
name_label.pack(side=tk.LEFT)
def make_edit_handler(tag_id, name_widget):
def handler():
new_name = simpledialog.askstring("Edit Tag", "Enter new tag name:",
initialvalue=name_widget.cget('text'), parent=dialog)
if new_name is None:
return
new_name = new_name.strip()
if not new_name:
return
try:
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute('UPDATE tags SET tag_name = ? WHERE id = ?', (new_name, tag_id))
conn.commit()
except Exception as e:
messagebox.showerror("Error", f"Failed to rename tag: {e}")
return
refresh_tag_list()
self._load_existing_tags()
self._switch_view_mode(self.view_mode_var.get())
return handler
ttk.Button(row, text="Edit", width=6, command=make_edit_handler(tag['id'], name_label)).pack(side=tk.LEFT, padx=(10, 0))
refresh_tag_list()
def delete_selected():
ids_to_delete = [tid for tid, v in selected_tag_vars.items() if v.get()]
if not ids_to_delete:
return
if not self.gui_core.create_large_messagebox(self.main_frame, "Confirm Delete", f"Delete {len(ids_to_delete)} selected tag(s)? This will unlink them from photos.", "askyesno"):
return
try:
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute(f"DELETE FROM phototaglinkage WHERE tag_id IN ({','.join('?' for _ in ids_to_delete)})", ids_to_delete)
cursor.execute(f"DELETE FROM tags WHERE id IN ({','.join('?' for _ in ids_to_delete)})", ids_to_delete)
conn.commit()
for photo_id in list(self.pending_tag_changes.keys()):
self.pending_tag_changes[photo_id] = [tid for tid in self.pending_tag_changes[photo_id] if tid not in ids_to_delete]
if not self.pending_tag_changes[photo_id]:
del self.pending_tag_changes[photo_id]
for photo_id in list(self.pending_tag_removals.keys()):
self.pending_tag_removals[photo_id] = [tid for tid in self.pending_tag_removals[photo_id] if tid not in ids_to_delete]
if not self.pending_tag_removals[photo_id]:
del self.pending_tag_removals[photo_id]
refresh_tag_list()
self._load_existing_tags()
self._load_photos()
self._switch_view_mode(self.view_mode_var.get())
except Exception as e:
messagebox.showerror("Error", f"Failed to delete tags: {e}")
ttk.Button(bottom_frame, text="Delete selected tags", command=delete_selected).pack(side=tk.LEFT)
ttk.Button(bottom_frame, text="Close", command=dialog.destroy).pack(side=tk.RIGHT)
new_tag_entry.focus_set()
def _save_tagging_changes(self):
"""Save pending tag changes to database"""
if not self.pending_tag_changes and not self.pending_tag_removals:
messagebox.showinfo("Info", "No tag changes to save.")
return
try:
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
for photo_id, tag_ids in self.pending_tag_changes.items():
for tag_id in tag_ids:
lt = 0
try:
lt = self.pending_tag_linkage_type.get(photo_id, {}).get(tag_id, 0)
except Exception:
lt = 0
cursor.execute('''
INSERT INTO phototaglinkage (photo_id, tag_id, linkage_type)
VALUES (?, ?, ?)
ON CONFLICT(photo_id, tag_id) DO UPDATE SET linkage_type=excluded.linkage_type, created_date=CURRENT_TIMESTAMP
''', (photo_id, tag_id, lt))
for photo_id, tag_ids in self.pending_tag_removals.items():
for tag_id in tag_ids:
cursor.execute('DELETE FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', (photo_id, tag_id))
conn.commit()
saved_additions = len(self.pending_tag_changes)
saved_removals = len(self.pending_tag_removals)
self.pending_tag_changes.clear()
self.pending_tag_removals.clear()
self.pending_tag_linkage_type.clear()
self._load_existing_tags()
self._load_photos()
self._switch_view_mode(self.view_mode_var.get())
self._update_save_button_text()
message = f"Saved {saved_additions} tag additions"
if saved_removals > 0:
message += f" and {saved_removals} tag removals"
message += "."
messagebox.showinfo("Success", message)
except Exception as e:
messagebox.showerror("Error", f"Failed to save tags: {str(e)}")
def _update_save_button_text(self):
"""Update the save button text to show pending changes"""
total_additions = sum(len(tags) for tags in self.pending_tag_changes.values())
total_removals = sum(len(tags) for tags in self.pending_tag_removals.values())
total_changes = total_additions + total_removals
if total_changes > 0:
self.components['save_button'].configure(text=f"Save Tagging ({total_changes} pending)")
else:
self.components['save_button'].configure(text="Save Tagging")
def _quit_with_warning(self):
"""Quit with warning about unsaved changes (for standalone mode)"""
has_pending_changes = bool(self.pending_tag_changes or self.pending_tag_removals)
if has_pending_changes:
total_additions = sum(len(tags) for tags in self.pending_tag_changes.values())
total_removals = sum(len(tags) for tags in self.pending_tag_removals.values())
changes_text = []
if total_additions > 0:
changes_text.append(f"{total_additions} tag addition(s)")
if total_removals > 0:
changes_text.append(f"{total_removals} tag removal(s)")
changes_summary = " and ".join(changes_text)
result = self.gui_core.create_large_messagebox(
self.main_frame,
"Unsaved Changes",
f"You have unsaved changes: {changes_summary}.\n\nDo you want to save your changes before quitting?\n\nYes = Save and quit\nNo = Quit without saving\nCancel = Stay in dialog",
"askyesnocancel"
)
if result is True:
self._save_tagging_changes()
# In dashboard mode, we don't actually quit the window
elif result is False:
# In dashboard mode, we don't actually quit the window
pass
# In dashboard mode, we don't actually quit the window
# Navigate to home if callback is available (dashboard mode)
if self.on_navigate_home:
self.on_navigate_home()
def _switch_view_mode(self, mode: str):
"""Switch between different view modes"""
if mode == "list":
self._show_list_view()
elif mode == "icons":
self._show_icon_view()
elif mode == "compact":
self._show_compact_view()
def _clear_content(self):
"""Clear the content area"""
for widget in self.components['content_inner'].winfo_children():
widget.destroy()
for crop in list(self.temp_crops):
try:
if os.path.exists(crop):
os.remove(crop)
except Exception:
pass
self.temp_crops.clear()
self.photo_images.clear()
def _prepare_folder_grouped_data(self):
"""Prepare data grouped by folders"""
from collections import defaultdict
folder_groups = defaultdict(list)
for photo in self.photos_data:
folder_path = os.path.dirname(photo['path'])
folder_groups[folder_path].append(photo)
sorted_folders = []
for folder_path in sorted(folder_groups.keys()):
folder_name = os.path.basename(folder_path) if folder_path else "Root"
photos_in_folder = sorted(folder_groups[folder_path], key=lambda x: x['date_taken'] or '', reverse=True)
if folder_path not in self.folder_states:
# Collapse folders by default on first load
self.folder_states[folder_path] = False
sorted_folders.append({
'folder_path': folder_path,
'folder_name': folder_name,
'photos': photos_in_folder,
'photo_count': len(photos_in_folder)
})
return sorted_folders
def _create_folder_header(self, parent, folder_info, current_row, col_count, view_mode):
"""Create a folder header with expand/collapse and bulk operations"""
folder_header_frame = ttk.Frame(parent)
folder_header_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(10, 5))
folder_header_frame.configure(relief='raised', borderwidth=1)
def open_bulk_link_dialog():
# Bulk tagging dialog: add selected tag to all photos in this folder (pending changes only)
popup = tk.Toplevel(self.main_frame)
popup.title("Bulk Link Tags to Folder")
popup.transient(self.main_frame)
popup.grab_set()
popup.geometry("650x500")
top_frame = ttk.Frame(popup, padding="8")
top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E))
status_frame = ttk.Frame(popup, padding="8")
status_frame.grid(row=1, column=0, sticky=(tk.W, tk.E))
bottom_frame = ttk.Frame(popup, padding="8")
# Place bottom frame below the list to keep actions at the bottom
bottom_frame.grid(row=4, column=0, sticky=(tk.W, tk.E))
popup.columnconfigure(0, weight=1)
ttk.Label(top_frame, text=f"Folder: {folder_info['folder_name']} ({folder_info['photo_count']} photos)").grid(row=0, column=0, columnspan=3, sticky=tk.W, pady=(0,6))
ttk.Label(top_frame, text="Add tag to all photos:").grid(row=1, column=0, padx=(0, 8), sticky=tk.W)
bulk_tag_var = tk.StringVar()
combo = ttk.Combobox(top_frame, textvariable=bulk_tag_var, values=self.existing_tags, width=30, state='readonly')
combo.grid(row=1, column=1, padx=(0, 8), sticky=(tk.W, tk.E))
combo.focus_set()
result_var = tk.StringVar(value="")
ttk.Label(status_frame, textvariable=result_var, foreground="gray").grid(row=0, column=0, sticky=tk.W)
def add_bulk_tag():
tag_name = bulk_tag_var.get().strip()
if not tag_name:
return
# Resolve or create tag id
# Case-insensitive tag lookup
normalized_tag_name = tag_name.lower().strip()
if normalized_tag_name in self.tag_name_to_id:
tag_id = self.tag_name_to_id[normalized_tag_name]
else:
# Create new tag using database method
tag_id = self.db.add_tag(tag_name)
if tag_id:
self.tag_name_to_id[normalized_tag_name] = tag_id
self.tag_id_to_name[tag_id] = tag_name
if tag_name not in self.existing_tags:
self.existing_tags.append(tag_name)
self.existing_tags.sort()
else:
return # Failed to create tag
affected = 0
for photo in folder_info.get('photos', []):
photo_id = photo['id']
saved_types = self._get_saved_tag_types_for_photo(photo_id)
existing_tag_ids = list(saved_types.keys())
pending_tag_ids = self.pending_tag_changes.get(photo_id, [])
# Case 1: not present anywhere → add as pending bulk
if tag_id not in existing_tag_ids and tag_id not in pending_tag_ids:
if photo_id not in self.pending_tag_changes:
self.pending_tag_changes[photo_id] = []
self.pending_tag_changes[photo_id].append(tag_id)
if photo_id not in self.pending_tag_linkage_type:
self.pending_tag_linkage_type[photo_id] = {}
self.pending_tag_linkage_type[photo_id][tag_id] = 1
affected += 1
# Case 2: already pending as single → upgrade pending type to bulk
elif tag_id in pending_tag_ids:
if photo_id not in self.pending_tag_linkage_type:
self.pending_tag_linkage_type[photo_id] = {}
prev_type = self.pending_tag_linkage_type[photo_id].get(tag_id)
if prev_type != 1:
self.pending_tag_linkage_type[photo_id][tag_id] = 1
affected += 1
# Case 3: saved as single → schedule an upgrade by adding to pending and setting type to bulk
elif saved_types.get(tag_id) == 0:
if photo_id not in self.pending_tag_changes:
self.pending_tag_changes[photo_id] = []
if tag_id not in self.pending_tag_changes[photo_id]:
self.pending_tag_changes[photo_id].append(tag_id)
if photo_id not in self.pending_tag_linkage_type:
self.pending_tag_linkage_type[photo_id] = {}
self.pending_tag_linkage_type[photo_id][tag_id] = 1
affected += 1
# Case 4: saved as bulk → nothing to do
self._update_save_button_text()
# Refresh main view to reflect updated pending tags in each row
self._switch_view_mode(view_mode)
result_var.set(f"Added pending tag to {affected} photo(s)")
bulk_tag_var.set("")
# Refresh the bulk list to immediately reflect pending adds
try:
refresh_bulk_tag_list()
except Exception:
pass
ttk.Button(top_frame, text="Add", command=add_bulk_tag).grid(row=1, column=2)
# Section: Remove bulk-linked tags across this folder
ttk.Separator(status_frame, orient='horizontal').grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(8, 6))
list_frame = ttk.Frame(popup, padding="8")
list_frame.grid(row=3, column=0, sticky=(tk.N, tk.S, tk.W, tk.E))
popup.rowconfigure(3, weight=1)
list_canvas = tk.Canvas(list_frame, highlightthickness=0)
list_scroll = ttk.Scrollbar(list_frame, orient="vertical", command=list_canvas.yview)
list_inner = ttk.Frame(list_canvas)
list_canvas.create_window((0, 0), window=list_inner, anchor="nw")
list_canvas.configure(yscrollcommand=list_scroll.set)
list_canvas.grid(row=0, column=0, sticky=(tk.N, tk.S, tk.W, tk.E))
list_scroll.grid(row=0, column=1, sticky=(tk.N, tk.S))
list_frame.columnconfigure(0, weight=1)
list_frame.rowconfigure(0, weight=1)
list_inner.bind("<Configure>", lambda e: list_canvas.configure(scrollregion=list_canvas.bbox("all")))
bulk_tag_vars: Dict[int, tk.BooleanVar] = {}
def refresh_bulk_tag_list():
for child in list(list_inner.winfo_children()):
child.destroy()
bulk_tag_vars.clear()
# Aggregate bulk-linked tags across all photos in this folder
tag_id_counts: Dict[int, int] = {}
pending_add_counts: Dict[int, int] = {}
for photo in folder_info.get('photos', []):
saved_types = self._get_saved_tag_types_for_photo(photo['id'])
for tid, ltype in saved_types.items():
if ltype == 1:
tag_id_counts[tid] = tag_id_counts.get(tid, 0) + 1
# Count pending additions (bulk type) for this photo
for tid in self.pending_tag_changes.get(photo['id'], []):
if self.pending_tag_linkage_type.get(photo['id'], {}).get(tid) == 1:
pending_add_counts[tid] = pending_add_counts.get(tid, 0) + 1
# Include tags that exist only in pending adds
all_tag_ids = set(tag_id_counts.keys()) | set(pending_add_counts.keys())
if not all_tag_ids:
ttk.Label(list_inner, text="No bulk-linked tags in this folder", foreground="gray").pack(anchor=tk.W, pady=5)
return
for tid in sorted(all_tag_ids, key=lambda x: self.tag_id_to_name.get(x, "")):
row = ttk.Frame(list_inner)
row.pack(fill=tk.X, pady=1)
var = tk.BooleanVar(value=False)
bulk_tag_vars[tid] = var
ttk.Checkbutton(row, variable=var).pack(side=tk.LEFT, padx=(0, 6))
pend_suffix = " (pending)" if pending_add_counts.get(tid, 0) > 0 else ""
ttk.Label(row, text=f"{self.tag_id_to_name.get(tid, 'Unknown')}{pend_suffix}").pack(side=tk.LEFT)
def remove_selected_bulk_tags():
selected_tids = [tid for tid, v in bulk_tag_vars.items() if v.get()]
if not selected_tids:
return
affected = 0
for photo in folder_info.get('photos', []):
photo_id = photo['id']
saved_types = self._get_saved_tag_types_for_photo(photo_id)
for tid in selected_tids:
# If it's pending add (bulk), cancel the pending change
if tid in self.pending_tag_changes.get(photo_id, []) and self.pending_tag_linkage_type.get(photo_id, {}).get(tid) == 1:
self.pending_tag_changes[photo_id] = [x for x in self.pending_tag_changes[photo_id] if x != tid]
if not self.pending_tag_changes[photo_id]:
del self.pending_tag_changes[photo_id]
if photo_id in self.pending_tag_linkage_type and tid in self.pending_tag_linkage_type[photo_id]:
del self.pending_tag_linkage_type[photo_id][tid]
if not self.pending_tag_linkage_type[photo_id]:
del self.pending_tag_linkage_type[photo_id]
affected += 1
# Else if it's a saved bulk linkage, mark for removal
elif saved_types.get(tid) == 1:
if photo_id not in self.pending_tag_removals:
self.pending_tag_removals[photo_id] = []
if tid not in self.pending_tag_removals[photo_id]:
self.pending_tag_removals[photo_id].append(tid)
affected += 1
self._update_save_button_text()
self._switch_view_mode(view_mode)
result_var.set(f"Marked bulk tag removals affecting {affected} linkage(s)")
refresh_bulk_tag_list()
ttk.Button(bottom_frame, text="Remove selected bulk tags", command=remove_selected_bulk_tags).pack(side=tk.LEFT)
ttk.Button(bottom_frame, text="Close", command=popup.destroy).pack(side=tk.RIGHT)
refresh_bulk_tag_list()
is_expanded = self.folder_states.get(folder_info['folder_path'], True)
toggle_text = "" if is_expanded else ""
toggle_button = tk.Button(folder_header_frame, text=toggle_text, width=2, height=1,
command=lambda: self._toggle_folder(folder_info['folder_path'], view_mode),
font=("Arial", 8), relief='flat', bd=1)
toggle_button.pack(side=tk.LEFT, padx=(5, 2), pady=5)
folder_label = ttk.Label(folder_header_frame, text=f"📁 {folder_info['folder_name']} ({folder_info['photo_count']} photos)", font=("Arial", 11, "bold"))
folder_label.pack(side=tk.LEFT, padx=(0, 10), pady=5)
# Bulk linkage icon (applies selected tag to all photos in this folder)
bulk_link_btn = tk.Button(folder_header_frame, text="🔗", width=2, command=open_bulk_link_dialog)
bulk_link_btn.pack(side=tk.LEFT, padx=(6, 6))
# Compute and show bulk tags for this folder
def compute_folder_bulk_tags() -> str:
bulk_tag_ids = set()
# Gather saved bulk tags
for photo in folder_info.get('photos', []):
photo_id = photo['id']
saved_types = self._get_saved_tag_types_for_photo(photo_id)
for tid, ltype in saved_types.items():
if ltype == 1:
bulk_tag_ids.add(tid)
# Include pending bulk adds; exclude pending removals
for photo in folder_info.get('photos', []):
photo_id = photo['id']
for tid in self.pending_tag_changes.get(photo_id, []):
if self.pending_tag_linkage_type.get(photo_id, {}).get(tid) == 1:
if tid not in self.pending_tag_removals.get(photo_id, []):
bulk_tag_ids.add(tid)
# Exclude any saved bulk tags marked for removal
for tid in self.pending_tag_removals.get(photo_id, []):
if tid in bulk_tag_ids:
bulk_tag_ids.discard(tid)
names = [self.tag_id_to_name.get(tid, f"Unknown {tid}") for tid in sorted(bulk_tag_ids, key=lambda x: self.tag_id_to_name.get(x, ""))]
return ", ".join(names) if names else "None"
# Append bulk tags to the folder label so it's clearly visible
try:
tags_str = compute_folder_bulk_tags()
if tags_str and tags_str != "None":
folder_label.configure(text=f"📁 {folder_info['folder_name']} ({folder_info['photo_count']} photos) — Tags: {tags_str}")
else:
folder_label.configure(text=f"📁 {folder_info['folder_name']} ({folder_info['photo_count']} photos)")
except Exception:
pass
return folder_header_frame
def _toggle_folder(self, folder_path, view_mode):
"""Toggle folder expand/collapse state"""
self.folder_states[folder_path] = not self.folder_states.get(folder_path, True)
self._switch_view_mode(view_mode)
def _create_tag_buttons_frame(self, parent, photo_id, photo_tags, existing_tags, use_grid=False, row=0, col=0):
"""Create tag management buttons for a photo"""
tags_frame = ttk.Frame(parent)
existing_tags_list = self.tag_manager.parse_tags_string(photo_tags)
pending_tag_names = [self.tag_id_to_name.get(tid, f"Unknown {tid}") for tid in self.pending_tag_changes.get(photo_id, [])]
pending_removal_names = [self.tag_id_to_name.get(tid, f"Unknown {tid}") for tid in self.pending_tag_removals.get(photo_id, [])]
all_tags = existing_tags_list + pending_tag_names
unique_tags = self.tag_manager.deduplicate_tags(all_tags)
unique_tags = [tag for tag in unique_tags if tag not in pending_removal_names]
current_display = ", ".join(unique_tags) if unique_tags else "None"
tags_text = ttk.Label(tags_frame, text=current_display)
tags_text.pack(side=tk.LEFT)
add_btn = tk.Button(tags_frame, text="🔗", width=2, command=self._create_add_tag_handler(photo_id, tags_text, photo_tags, existing_tags, 0))
add_btn.pack(side=tk.LEFT, padx=(6, 0))
if use_grid:
tags_frame.grid(row=row, column=col, padx=5, sticky=tk.W)
else:
tags_frame.pack(side=tk.LEFT, padx=5)
return tags_frame
def _create_add_tag_handler(self, photo_id, label_widget, photo_tags, available_tags, allowed_delete_type: int = 0):
"""Create a tag management dialog handler for a photo"""
def handler():
popup = tk.Toplevel(self.main_frame)
popup.title("Manage Photo Tags")
popup.transient(self.main_frame)
popup.grab_set()
popup.geometry("600x500")
popup.resizable(True, True)
top_frame = ttk.Frame(popup, padding="8")
top_frame.grid(row=0, column=0, sticky=(tk.W, tk.E))
list_frame = ttk.Frame(popup, padding="8")
list_frame.grid(row=1, column=0, sticky=(tk.N, tk.S, tk.W, tk.E))
bottom_frame = ttk.Frame(popup, padding="8")
bottom_frame.grid(row=2, column=0, sticky=(tk.W, tk.E))
popup.columnconfigure(0, weight=1)
popup.rowconfigure(1, weight=1)
ttk.Label(top_frame, text="Add tag:").grid(row=0, column=0, padx=(0, 8), sticky=tk.W)
tag_var = tk.StringVar()
combo = ttk.Combobox(top_frame, textvariable=tag_var, values=available_tags, width=30, state='readonly')
combo.grid(row=0, column=1, padx=(0, 8), sticky=(tk.W, tk.E))
combo.focus_set()
def _update_tags_label_in_main_list():
# Recompute combined display for the main list label (saved + pending minus removals)
existing_tags_list = self.tag_manager.parse_tags_string(photo_tags)
pending_tag_names = [self.tag_id_to_name.get(tid, f"Unknown {tid}") for tid in self.pending_tag_changes.get(photo_id, [])]
pending_removal_names = [self.tag_id_to_name.get(tid, f"Unknown {tid}") for tid in self.pending_tag_removals.get(photo_id, [])]
all_tags = existing_tags_list + pending_tag_names
unique_tags = self.tag_manager.deduplicate_tags(all_tags)
# Remove tags marked for removal from display
unique_tags = [tag for tag in unique_tags if tag not in pending_removal_names]
current_tags = ", ".join(unique_tags) if unique_tags else "None"
try:
label_widget.configure(text=current_tags)
except Exception:
pass
def add_selected_tag():
tag_name = tag_var.get().strip()
if not tag_name:
return
# Case-insensitive tag lookup
normalized_tag_name = tag_name.lower().strip()
if normalized_tag_name in self.tag_name_to_id:
tag_id = self.tag_name_to_id[normalized_tag_name]
else:
# Create new tag using database method
tag_id = self.db.add_tag(tag_name)
if tag_id:
self.tag_name_to_id[normalized_tag_name] = tag_id
self.tag_id_to_name[tag_id] = tag_name
if tag_name not in self.existing_tags:
self.existing_tags.append(tag_name)
self.existing_tags.sort()
else:
return # Failed to create tag
saved_types = self._get_saved_tag_types_for_photo(photo_id)
existing_tag_ids = list(saved_types.keys())
pending_tag_ids = self.pending_tag_changes.get(photo_id, [])
all_existing_tag_ids = existing_tag_ids + pending_tag_ids
if tag_id not in all_existing_tag_ids:
if photo_id not in self.pending_tag_changes:
self.pending_tag_changes[photo_id] = []
self.pending_tag_changes[photo_id].append(tag_id)
# mark pending type as single (0)
if photo_id not in self.pending_tag_linkage_type:
self.pending_tag_linkage_type[photo_id] = {}
self.pending_tag_linkage_type[photo_id][tag_id] = 0
refresh_tag_list()
self._update_save_button_text()
_update_tags_label_in_main_list()
tag_var.set("")
ttk.Button(top_frame, text="Add", command=add_selected_tag).grid(row=0, column=2, padx=(0, 8))
canvas = tk.Canvas(list_frame, height=200)
scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview)
scrollable_frame = ttk.Frame(canvas)
scrollable_frame.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0, 0), window=scrollable_frame, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
selected_tag_vars: Dict[str, tk.BooleanVar] = {}
def refresh_tag_list():
for widget in scrollable_frame.winfo_children():
widget.destroy()
selected_tag_vars.clear()
saved_types = self._get_saved_tag_types_for_photo(photo_id)
existing_tag_ids = list(saved_types.keys())
pending_tag_ids = self.pending_tag_changes.get(photo_id, [])
pending_removal_ids = self.pending_tag_removals.get(photo_id, [])
all_tag_ids = existing_tag_ids + pending_tag_ids
unique_tag_ids = list(set(all_tag_ids))
unique_tag_ids = [tid for tid in unique_tag_ids if tid not in pending_removal_ids]
unique_tag_names = [self.tag_id_to_name.get(tid, f"Unknown {tid}") for tid in unique_tag_ids]
if not unique_tag_names:
ttk.Label(scrollable_frame, text="No tags linked to this photo", foreground="gray").pack(anchor=tk.W, pady=5)
return
for i, tag_id in enumerate(unique_tag_ids):
tag_name = self.tag_id_to_name.get(tag_id, f"Unknown {tag_id}")
var = tk.BooleanVar()
selected_tag_vars[tag_name] = var
frame = ttk.Frame(scrollable_frame)
frame.pack(fill=tk.X, pady=1)
is_pending = tag_id in pending_tag_ids
saved_type = saved_types.get(tag_id)
pending_type = self.pending_tag_linkage_type.get(photo_id, {}).get(tag_id)
# In single-photo dialog, only allow selecting pending if pending type is single (0)
is_pending_single = is_pending and pending_type == 0
can_select = is_pending_single or (saved_type == allowed_delete_type)
cb = ttk.Checkbutton(frame, variable=var)
if not can_select:
try:
cb.state(["disabled"]) # disable selection for disallowed types
except Exception:
pass
cb.pack(side=tk.LEFT, padx=(0, 5))
type_label = 'single' if saved_type == 0 else ('bulk' if saved_type == 1 else '?')
pending_label = "pending bulk" if (is_pending and pending_type == 1) else "pending"
status_text = f" ({pending_label})" if is_pending else f" (saved {type_label})"
status_color = "blue" if is_pending else ("black" if can_select else "gray")
ttk.Label(frame, text=tag_name + status_text, foreground=status_color).pack(side=tk.LEFT)
def remove_selected_tags():
tag_ids_to_remove = []
for tag_name, var in selected_tag_vars.items():
if var.get() and tag_name in self.tag_name_to_id:
tag_ids_to_remove.append(self.tag_name_to_id[tag_name])
if not tag_ids_to_remove:
return
if photo_id in self.pending_tag_changes:
self.pending_tag_changes[photo_id] = [tid for tid in self.pending_tag_changes[photo_id] if tid not in tag_ids_to_remove]
if not self.pending_tag_changes[photo_id]:
del self.pending_tag_changes[photo_id]
saved_types = self._get_saved_tag_types_for_photo(photo_id)
for tag_id in tag_ids_to_remove:
if saved_types.get(tag_id) == allowed_delete_type:
if photo_id not in self.pending_tag_removals:
self.pending_tag_removals[photo_id] = []
if tag_id not in self.pending_tag_removals[photo_id]:
self.pending_tag_removals[photo_id].append(tag_id)
refresh_tag_list()
self._update_save_button_text()
_update_tags_label_in_main_list()
ttk.Button(bottom_frame, text="Remove selected tags", command=remove_selected_tags).pack(side=tk.LEFT, padx=(0, 8))
ttk.Button(bottom_frame, text="Close", command=popup.destroy).pack(side=tk.RIGHT)
refresh_tag_list()
def on_close():
existing_tags_list = self.tag_manager.parse_tags_string(photo_tags)
pending_tag_names = [self.tag_id_to_name.get(tid, f"Unknown {tid}") for tid in self.pending_tag_changes.get(photo_id, [])]
pending_removal_names = [self.tag_id_to_name.get(tid, f"Unknown {tid}") for tid in self.pending_tag_removals.get(photo_id, [])]
all_tags = existing_tags_list + pending_tag_names
unique_tags = self.tag_manager.deduplicate_tags(all_tags)
unique_tags = [tag for tag in unique_tags if tag not in pending_removal_names]
current_tags = ", ".join(unique_tags) if unique_tags else "None"
label_widget.configure(text=current_tags)
popup.destroy()
popup.protocol("WM_DELETE_WINDOW", on_close)
return handler
def _show_list_view(self):
"""Show the list view"""
self._clear_content()
self.current_visible_cols = [col.copy() for col in self.column_config['list'] if self.column_visibility['list'][col['key']]]
col_count = len(self.current_visible_cols)
if col_count == 0:
ttk.Label(self.components['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 columns
for i, col in enumerate(self.current_visible_cols):
self.components['content_inner'].columnconfigure(i, weight=col['weight'], minsize=col['width'])
# Create header with resizing separators
header_frame = ttk.Frame(self.components['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(self.current_visible_cols):
header_frame.columnconfigure(i * 2, weight=col['weight'], minsize=col['width'])
if i < len(self.current_visible_cols) - 1:
header_frame.columnconfigure(i * 2 + 1, weight=0, minsize=1)
for i, col in enumerate(self.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)
header_label.bind("<Button-3>", lambda e, mode='list': self._show_column_context_menu(e, mode))
# Add resizing separator between columns
if i < len(self.current_visible_cols) - 1:
separator_frame = tk.Frame(header_frame, width=10, bg='red', cursor="sb_h_double_arrow")
separator_frame.grid(row=0, column=i * 2 + 1, sticky='ns', padx=0)
separator_frame.grid_propagate(False)
inner_line = tk.Frame(separator_frame, bg='darkred', width=2)
inner_line.pack(fill=tk.Y, expand=True)
# Bind resize events
separator_frame.bind("<Button-1>", lambda e, col_idx=i: self._start_resize(e, col_idx))
separator_frame.bind("<B1-Motion>", lambda e, col_idx=i: self._do_resize(e, col_idx))
separator_frame.bind("<ButtonRelease-1>", self._stop_resize)
separator_frame.bind("<Enter>", lambda e, f=separator_frame, l=inner_line: (f.configure(bg='orange'), l.configure(bg='darkorange')))
separator_frame.bind("<Leave>", lambda e, f=separator_frame, l=inner_line: (f.configure(bg='red'), l.configure(bg='darkred')))
inner_line.bind("<Button-1>", lambda e, col_idx=i: self._start_resize(e, col_idx))
inner_line.bind("<B1-Motion>", lambda e, col_idx=i: self._do_resize(e, col_idx))
inner_line.bind("<ButtonRelease-1>", self._stop_resize)
header_frame.bind("<Button-3>", lambda e, mode='list': self._show_column_context_menu(e, mode))
ttk.Separator(self.components['content_inner'], orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5))
# Create folder grouped data and display
folder_data = self._prepare_folder_grouped_data()
current_row = 2
for folder_info in folder_data:
self._create_folder_header(self.components['content_inner'], folder_info, current_row, col_count, 'list')
current_row += 1
if self.folder_states.get(folder_info['folder_path'], True):
for photo in folder_info['photos']:
row_frame = ttk.Frame(self.components['content_inner'])
row_frame.grid(row=current_row, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=1)
for i, col in enumerate(self.current_visible_cols):
row_frame.columnconfigure(i, weight=col['weight'], minsize=col['width'])
for i, col in enumerate(self.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':
self._create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], self.existing_tags, use_grid=True, row=0, col=i)
continue
# Render text wrapped to header width
if key == 'filename':
lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2",
wraplength=col['width'], anchor='w', justify='left')
lbl.grid(row=0, column=i, padx=5, sticky=tk.W)
lbl.bind("<Button-1>", lambda e, p=photo['path']: self._open_photo(p))
elif key == 'faces' and photo['face_count'] > 0:
lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2",
wraplength=col['width'], anchor='w', justify='left')
lbl.grid(row=0, column=i, padx=5, sticky=tk.W)
# Show people names in tooltip on hover
try:
people_tooltip_text = self._get_people_names_for_photo(photo['id'])
self._create_tooltip(lbl, people_tooltip_text)
except Exception:
pass
else:
ttk.Label(row_frame, text=text, wraplength=col['width'], anchor='w', justify='left').grid(row=0, column=i, padx=5, sticky=tk.W)
current_row += 1
def _show_icon_view(self):
"""Show the icon view"""
self._clear_content()
visible_cols = [col for col in self.column_config['icons'] if self.column_visibility['icons'][col['key']]]
col_count = len(visible_cols)
if col_count == 0:
ttk.Label(self.components['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 columns
for i, col in enumerate(visible_cols):
self.components['content_inner'].columnconfigure(i, weight=col['weight'], minsize=col['width'])
# Create header
header_frame = ttk.Frame(self.components['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'])
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)
header_label.bind("<Button-3>", lambda e, mode='icons': self._show_column_context_menu(e, mode))
header_frame.bind("<Button-3>", lambda e, mode='icons': self._show_column_context_menu(e, mode))
ttk.Separator(self.components['content_inner'], orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5))
# Create folder grouped data and display
folder_data = self._prepare_folder_grouped_data()
current_row = 2
for folder_info in folder_data:
self._create_folder_header(self.components['content_inner'], folder_info, current_row, col_count, 'icons')
current_row += 1
if self.folder_states.get(folder_info['folder_path'], True):
for photo in folder_info['photos']:
row_frame = ttk.Frame(self.components['content_inner'])
row_frame.grid(row=current_row, 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_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)
self.photo_images.append(photo_img)
canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg='lightgray', highlightthickness=0)
canvas.pack()
canvas.create_image(75, 75, image=photo_img)
else:
canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg='lightgray', highlightthickness=0)
canvas.pack()
canvas.create_text(75, 75, text="🖼️", fill="gray", font=("Arial", 24))
except Exception:
canvas = tk.Canvas(thumbnail_frame, width=150, height=150, bg='lightgray', highlightthickness=0)
canvas.pack()
canvas.create_text(75, 75, text="", fill="red", font=("Arial", 24))
else:
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':
self._create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], self.existing_tags, use_grid=True, row=0, col=col_idx)
col_idx += 1
continue
# Render text wrapped to header width
if key == 'filename':
lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2",
wraplength=col['width'], anchor='w', justify='left')
lbl.grid(row=0, column=col_idx, padx=5, sticky=tk.W)
lbl.bind("<Button-1>", lambda e, p=photo['path']: self._open_photo(p))
elif key == 'faces' and photo['face_count'] > 0:
lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2",
wraplength=col['width'], anchor='w', justify='left')
lbl.grid(row=0, column=col_idx, padx=5, sticky=tk.W)
# Show people names in tooltip on hover
try:
people_tooltip_text = self._get_people_names_for_photo(photo['id'])
self._create_tooltip(lbl, people_tooltip_text)
except Exception:
pass
else:
ttk.Label(row_frame, text=text, wraplength=col['width'], anchor='w', justify='left').grid(row=0, column=col_idx, padx=5, sticky=tk.W)
col_idx += 1
current_row += 1
def _show_compact_view(self):
"""Show the compact view"""
self._clear_content()
visible_cols = [col for col in self.column_config['compact'] if self.column_visibility['compact'][col['key']]]
col_count = len(visible_cols)
if col_count == 0:
ttk.Label(self.components['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 columns
for i, col in enumerate(visible_cols):
self.components['content_inner'].columnconfigure(i, weight=col['weight'], minsize=col['width'])
# Create header
header_frame = ttk.Frame(self.components['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'])
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)
header_label.bind("<Button-3>", lambda e, mode='compact': self._show_column_context_menu(e, mode))
header_frame.bind("<Button-3>", lambda e, mode='compact': self._show_column_context_menu(e, mode))
ttk.Separator(self.components['content_inner'], orient='horizontal').grid(row=1, column=0, columnspan=col_count, sticky=(tk.W, tk.E), pady=(0, 5))
# Create folder grouped data and display
folder_data = self._prepare_folder_grouped_data()
current_row = 2
for folder_info in folder_data:
self._create_folder_header(self.components['content_inner'], folder_info, current_row, col_count, 'compact')
current_row += 1
if self.folder_states.get(folder_info['folder_path'], True):
for photo in folder_info['photos']:
row_frame = ttk.Frame(self.components['content_inner'])
row_frame.grid(row=current_row, 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':
self._create_tag_buttons_frame(row_frame, photo['id'], photo['tags'], self.existing_tags, use_grid=True, row=0, col=col_idx)
col_idx += 1
continue
# Render text wrapped to header width
if key == 'filename':
lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2",
wraplength=col['width'], anchor='w', justify='left')
lbl.grid(row=0, column=col_idx, padx=5, sticky=tk.W)
lbl.bind("<Button-1>", lambda e, p=photo['path']: self._open_photo(p))
elif key == 'faces' and photo['face_count'] > 0:
lbl = ttk.Label(row_frame, text=text, foreground="blue", cursor="hand2",
wraplength=col['width'], anchor='w', justify='left')
lbl.grid(row=0, column=col_idx, padx=5, sticky=tk.W)
# Show people names in tooltip on hover
try:
people_tooltip_text = self._get_people_names_for_photo(photo['id'])
self._create_tooltip(lbl, people_tooltip_text)
except Exception:
pass
else:
ttk.Label(row_frame, text=text, wraplength=col['width'], anchor='w', justify='left').grid(row=0, column=col_idx, padx=5, sticky=tk.W)
col_idx += 1
current_row += 1
def _create_tooltip(self, widget, text: str):
"""Create a simple tooltip for a widget"""
def show_tooltip(event):
tooltip = tk.Toplevel()
tooltip.wm_overrideredirect(True)
tooltip.wm_geometry(f"+{event.x_root+10}+{event.y_root+10}")
label = ttk.Label(tooltip, text=text, background="#ffffe0", relief="solid", borderwidth=1)
label.pack()
widget.tooltip = tooltip
def hide_tooltip(event):
if hasattr(widget, 'tooltip'):
widget.tooltip.destroy()
del widget.tooltip
widget.bind("<Enter>", show_tooltip)
widget.bind("<Leave>", hide_tooltip)
def activate(self):
"""Activate the panel"""
self.is_active = True
# Rebind mousewheel scrolling when activated
self._bind_mousewheel_scrolling()
def deactivate(self):
"""Deactivate the panel"""
if self.is_active:
self._cleanup()
self.is_active = False
def _cleanup(self):
"""Clean up resources"""
# Unbind mousewheel scrolling
self._unbind_mousewheel_scrolling()
# Clean up temp crops
for crop in list(self.temp_crops):
try:
if os.path.exists(crop):
os.remove(crop)
except Exception:
pass
self.temp_crops.clear()
# Clear photo images
self.photo_images.clear()
# Clear state
self.pending_tag_changes.clear()
self.pending_tag_removals.clear()
self.pending_tag_linkage_type.clear()
self.folder_states.clear()
def update_layout(self):
"""Update panel layout for responsiveness"""
if hasattr(self, 'components') and 'content_canvas' in self.components:
# Update content canvas scroll region
canvas = self.components['content_canvas']
canvas.update_idletasks()
canvas.configure(scrollregion=canvas.bbox("all"))