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.
1525 lines
80 KiB
Python
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"))
|