This commit updates the SearchGUI class to include a checkbox for selecting photos, allowing users to tag multiple photos at once. New buttons for tagging selected photos and clearing selections have been added, improving the user experience in managing photo tags. Additionally, the GUI now displays tags associated with each photo, enhancing the functionality of the search interface. The PhotoTagger class is updated to accommodate these changes, streamlining the tagging process.
655 lines
28 KiB
Python
655 lines
28 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Search GUI implementation for PunimTag
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox
|
|
from typing import List
|
|
|
|
from gui_core import GUICore
|
|
from search_stats import SearchStats
|
|
from database import DatabaseManager
|
|
from tag_management import TagManager
|
|
|
|
|
|
class SearchGUI:
|
|
"""GUI for searching photos by different criteria."""
|
|
|
|
SEARCH_TYPES = [
|
|
"Search photos by name",
|
|
"Search photos by date (planned)",
|
|
"Search photos by tags (planned)",
|
|
"Search photos by multiple people (planned)",
|
|
"Most common tags (planned)",
|
|
"Most photographed people (planned)",
|
|
"Photos without faces (planned)",
|
|
"Photos without tags (planned)",
|
|
"Duplicate faces (planned)",
|
|
"Face quality distribution (planned)",
|
|
]
|
|
|
|
def __init__(self, db_manager: DatabaseManager, search_stats: SearchStats, gui_core: GUICore, tag_manager: TagManager = None, verbose: int = 0):
|
|
self.db = db_manager
|
|
self.search_stats = search_stats
|
|
self.gui_core = gui_core
|
|
self.tag_manager = tag_manager or TagManager(db_manager, verbose)
|
|
self.verbose = verbose
|
|
|
|
# Sorting state
|
|
self.sort_column = None
|
|
self.sort_reverse = False
|
|
|
|
# Selection tracking
|
|
self.selected_photos = {} # photo_path -> photo_data
|
|
|
|
def search_gui(self) -> int:
|
|
"""Open the Search GUI window."""
|
|
root = tk.Tk()
|
|
root.title("Search Photos")
|
|
root.resizable(True, True)
|
|
|
|
# Hide to center and size
|
|
root.withdraw()
|
|
|
|
main = ttk.Frame(root, padding="10")
|
|
main.pack(fill=tk.BOTH, expand=True)
|
|
|
|
# Search type selector
|
|
type_frame = ttk.Frame(main)
|
|
type_frame.pack(fill=tk.X)
|
|
ttk.Label(type_frame, text="Search type:", font=("Arial", 10, "bold")).pack(side=tk.LEFT)
|
|
search_type_var = tk.StringVar(value=self.SEARCH_TYPES[0])
|
|
type_combo = ttk.Combobox(type_frame, textvariable=search_type_var, values=self.SEARCH_TYPES, state="readonly")
|
|
type_combo.pack(side=tk.LEFT, padx=(8, 0), fill=tk.X, expand=True)
|
|
|
|
# Inputs area
|
|
inputs = ttk.Frame(main)
|
|
inputs.pack(fill=tk.X, pady=(10, 6))
|
|
|
|
# Name search input
|
|
name_frame = ttk.Frame(inputs)
|
|
ttk.Label(name_frame, text="Person name:").pack(side=tk.LEFT)
|
|
name_var = tk.StringVar()
|
|
name_entry = ttk.Entry(name_frame, textvariable=name_var)
|
|
name_entry.pack(side=tk.LEFT, padx=(6, 0), fill=tk.X, expand=True)
|
|
|
|
# Planned inputs (stubs)
|
|
planned_label = ttk.Label(inputs, text="This search type is planned and not yet implemented.", foreground="#888")
|
|
|
|
# Results area
|
|
results_frame = ttk.Frame(main)
|
|
results_frame.pack(fill=tk.BOTH, expand=True)
|
|
ttk.Label(results_frame, text="Results:", font=("Arial", 10, "bold")).pack(anchor="w")
|
|
|
|
columns = ("person", "open_dir", "open_photo", "path", "select")
|
|
tree = ttk.Treeview(results_frame, columns=columns, show="headings", selectmode="browse")
|
|
tree.heading("person", text="Person", command=lambda: sort_treeview("person"))
|
|
tree.heading("open_dir", text="📁")
|
|
tree.heading("open_photo", text="🏷️")
|
|
tree.heading("path", text="Photo path", command=lambda: sort_treeview("path"))
|
|
tree.heading("select", text="☑")
|
|
tree.column("person", width=220, anchor="w")
|
|
tree.column("open_dir", width=50, anchor="center")
|
|
tree.column("open_photo", width=50, anchor="center")
|
|
tree.column("path", width=520, anchor="w")
|
|
tree.column("select", width=50, anchor="center")
|
|
tree.pack(fill=tk.BOTH, expand=True, pady=(4, 0))
|
|
|
|
# Buttons
|
|
btns = ttk.Frame(main)
|
|
btns.pack(fill=tk.X, pady=(8, 0))
|
|
search_btn = ttk.Button(btns, text="Search", command=lambda: do_search())
|
|
search_btn.pack(side=tk.LEFT)
|
|
open_btn = ttk.Button(btns, text="Open Photo", command=lambda: open_selected())
|
|
open_btn.pack(side=tk.LEFT, padx=(6, 0))
|
|
tag_btn = ttk.Button(btns, text="Tag selected photos", command=lambda: tag_selected_photos())
|
|
tag_btn.pack(side=tk.LEFT, padx=(6, 0))
|
|
clear_btn = ttk.Button(btns, text="Clear all selected", command=lambda: clear_all_selected())
|
|
clear_btn.pack(side=tk.LEFT, padx=(6, 0))
|
|
close_btn = ttk.Button(btns, text="Close", command=lambda: root.destroy())
|
|
close_btn.pack(side=tk.RIGHT)
|
|
|
|
# Sorting functionality
|
|
def sort_treeview(col: str):
|
|
"""Sort the treeview by the specified column."""
|
|
# Get all items and their values
|
|
items = [(tree.set(child, col), child) for child in tree.get_children('')]
|
|
|
|
# Determine sort direction
|
|
if self.sort_column == col:
|
|
# Same column clicked - toggle direction
|
|
self.sort_reverse = not self.sort_reverse
|
|
else:
|
|
# Different column clicked - start with ascending
|
|
self.sort_reverse = False
|
|
self.sort_column = col
|
|
|
|
# Sort the items
|
|
# For person and path columns, sort alphabetically
|
|
# For icon columns, maintain original order
|
|
if col in ['person', 'path']:
|
|
items.sort(key=lambda x: x[0].lower(), reverse=self.sort_reverse)
|
|
else:
|
|
# For icon columns, just reverse if clicking same column
|
|
if self.sort_column == col and self.sort_reverse:
|
|
items.reverse()
|
|
|
|
# Reorder items in treeview
|
|
for index, (val, child) in enumerate(items):
|
|
tree.move(child, '', index)
|
|
|
|
# Update header display
|
|
update_header_display()
|
|
|
|
def update_header_display():
|
|
"""Update header display to show sort indicators."""
|
|
# Reset all headers
|
|
tree.heading("person", text="Person")
|
|
tree.heading("path", text="Photo path")
|
|
|
|
# Add sort indicator to current sort column
|
|
if self.sort_column == "person":
|
|
indicator = " ↓" if self.sort_reverse else " ↑"
|
|
tree.heading("person", text="Person" + indicator)
|
|
elif self.sort_column == "path":
|
|
indicator = " ↓" if self.sort_reverse else " ↑"
|
|
tree.heading("path", text="Photo path" + indicator)
|
|
|
|
# Behavior
|
|
def switch_inputs(*_):
|
|
for w in inputs.winfo_children():
|
|
w.pack_forget()
|
|
choice = search_type_var.get()
|
|
if choice == self.SEARCH_TYPES[0]:
|
|
name_frame.pack(fill=tk.X)
|
|
name_entry.configure(state="normal")
|
|
search_btn.configure(state="normal")
|
|
else:
|
|
planned_label.pack(anchor="w")
|
|
name_entry.configure(state="disabled")
|
|
search_btn.configure(state="disabled")
|
|
|
|
def clear_results():
|
|
for i in tree.get_children():
|
|
tree.delete(i)
|
|
# Reset sorting state for new search
|
|
self.sort_column = None
|
|
self.sort_reverse = False
|
|
# Clear selection tracking
|
|
self.selected_photos.clear()
|
|
update_header_display()
|
|
|
|
def add_results(rows: List[tuple]):
|
|
# rows expected: List[(full_name, path)]
|
|
for full_name, p in rows:
|
|
tree.insert("", tk.END, values=(full_name, "📁", "🏷️", p, "☐"))
|
|
|
|
# Sort by person name by default when results are first loaded
|
|
if rows and self.sort_column is None:
|
|
# Force ascending sort on first load
|
|
self.sort_column = "person"
|
|
self.sort_reverse = False
|
|
# Get all items and sort them directly
|
|
items = [(tree.set(child, "person"), child) for child in tree.get_children('')]
|
|
items.sort(key=lambda x: x[0].lower(), reverse=False) # Ascending
|
|
# Reorder items in treeview
|
|
for index, (val, child) in enumerate(items):
|
|
tree.move(child, '', index)
|
|
# Update header display
|
|
update_header_display()
|
|
|
|
def do_search():
|
|
clear_results()
|
|
choice = search_type_var.get()
|
|
if choice == self.SEARCH_TYPES[0]:
|
|
query = name_var.get().strip()
|
|
if not query:
|
|
messagebox.showinfo("Search", "Please enter a name to search.", parent=root)
|
|
return
|
|
rows = self.search_stats.search_faces(query)
|
|
if not rows:
|
|
messagebox.showinfo("Search", f"No photos found for '{query}'.", parent=root)
|
|
add_results(rows)
|
|
|
|
def open_selected():
|
|
sel = tree.selection()
|
|
if not sel:
|
|
return
|
|
vals = tree.item(sel[0], "values")
|
|
path = vals[3] if len(vals) >= 4 else None
|
|
try:
|
|
if os.name == "nt":
|
|
os.startfile(path) # type: ignore[attr-defined]
|
|
elif sys.platform == "darwin":
|
|
import subprocess
|
|
subprocess.run(["open", path], check=False)
|
|
else:
|
|
import subprocess
|
|
subprocess.run(["xdg-open", path], check=False)
|
|
except Exception:
|
|
messagebox.showerror("Open Photo", "Failed to open the selected photo.", parent=root)
|
|
|
|
def open_dir(path: str):
|
|
try:
|
|
folder = os.path.dirname(path)
|
|
if os.name == "nt":
|
|
os.startfile(folder) # type: ignore[attr-defined]
|
|
elif sys.platform == "darwin":
|
|
import subprocess
|
|
subprocess.run(["open", folder], check=False)
|
|
else:
|
|
import subprocess
|
|
subprocess.run(["xdg-open", folder], check=False)
|
|
except Exception:
|
|
messagebox.showerror("Open Location", "Failed to open the file location.", parent=root)
|
|
|
|
def toggle_photo_selection(row_id, vals):
|
|
"""Toggle checkbox selection for a photo."""
|
|
if len(vals) < 5:
|
|
return
|
|
path = vals[3]
|
|
current_state = vals[4]
|
|
if current_state == "☐":
|
|
# Select photo
|
|
new_state = "☑"
|
|
self.selected_photos[path] = {
|
|
'person': vals[0],
|
|
'path': path
|
|
}
|
|
else:
|
|
# Deselect photo
|
|
new_state = "☐"
|
|
if path in self.selected_photos:
|
|
del self.selected_photos[path]
|
|
|
|
# Update the treeview
|
|
new_vals = list(vals)
|
|
new_vals[4] = new_state
|
|
tree.item(row_id, values=new_vals)
|
|
|
|
def tag_selected_photos():
|
|
"""Open linkage dialog for selected photos."""
|
|
if not self.selected_photos:
|
|
messagebox.showinfo("Tag Photos", "Please select photos to tag first.", parent=root)
|
|
return
|
|
|
|
# Get photo IDs for selected photos
|
|
selected_photo_ids = []
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
for path in self.selected_photos.keys():
|
|
cursor.execute('SELECT id FROM photos WHERE path = ?', (path,))
|
|
result = cursor.fetchone()
|
|
if result:
|
|
selected_photo_ids.append(result[0])
|
|
|
|
if not selected_photo_ids:
|
|
messagebox.showerror("Tag Photos", "Could not find photo IDs for selected photos.", parent=root)
|
|
return
|
|
|
|
# Open the linkage dialog
|
|
open_linkage_dialog(selected_photo_ids)
|
|
|
|
def clear_all_selected():
|
|
"""Clear all selected photos and update checkboxes."""
|
|
if not self.selected_photos:
|
|
return
|
|
|
|
# Clear the selection tracking
|
|
self.selected_photos.clear()
|
|
|
|
# Update all checkboxes to unselected state
|
|
for item in tree.get_children():
|
|
vals = tree.item(item, "values")
|
|
if len(vals) >= 5 and vals[4] == "☑":
|
|
new_vals = list(vals)
|
|
new_vals[4] = "☐"
|
|
tree.item(item, values=new_vals)
|
|
|
|
def show_photo_tags(photo_path):
|
|
"""Show tags for a specific photo in a popup."""
|
|
# Get photo ID
|
|
photo_id = None
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id FROM photos WHERE path = ?', (photo_path,))
|
|
result = cursor.fetchone()
|
|
if result:
|
|
photo_id = result[0]
|
|
|
|
if not photo_id:
|
|
messagebox.showerror("Error", "Could not find photo ID", parent=root)
|
|
return
|
|
|
|
# Get tags for this photo
|
|
tag_names = []
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT t.tag_name
|
|
FROM tags t
|
|
JOIN phototaglinkage ptl ON t.id = ptl.tag_id
|
|
WHERE ptl.photo_id = ?
|
|
ORDER BY t.tag_name
|
|
''', (photo_id,))
|
|
tag_names = [row[0] for row in cursor.fetchall()]
|
|
|
|
# Create popup
|
|
popup = tk.Toplevel(root)
|
|
popup.title("Photo Tags")
|
|
popup.transient(root)
|
|
popup.grab_set()
|
|
popup.geometry("300x200")
|
|
|
|
# Center the popup
|
|
popup.update_idletasks()
|
|
x = (popup.winfo_screenwidth() // 2) - (popup.winfo_width() // 2)
|
|
y = (popup.winfo_screenheight() // 2) - (popup.winfo_height() // 2)
|
|
popup.geometry(f"+{x}+{y}")
|
|
|
|
frame = ttk.Frame(popup, padding="10")
|
|
frame.pack(fill=tk.BOTH, expand=True)
|
|
|
|
# Photo filename
|
|
filename = os.path.basename(photo_path)
|
|
ttk.Label(frame, text=f"Tags for: {filename}", font=("Arial", 10, "bold")).pack(anchor="w", pady=(0, 10))
|
|
|
|
if tag_names:
|
|
# Create scrollable list
|
|
canvas = tk.Canvas(frame, height=100)
|
|
scrollbar = ttk.Scrollbar(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")
|
|
|
|
for tag_name in tag_names:
|
|
ttk.Label(scrollable_frame, text=f"• {tag_name}").pack(anchor="w", pady=1)
|
|
else:
|
|
ttk.Label(frame, text="No tags found for this photo", foreground="gray").pack(anchor="w")
|
|
|
|
# Close button
|
|
ttk.Button(frame, text="Close", command=popup.destroy).pack(pady=(10, 0))
|
|
|
|
def get_photo_tags_tooltip(photo_path):
|
|
"""Get tags for a photo to display in tooltip."""
|
|
# Get photo ID
|
|
photo_id = None
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('SELECT id FROM photos WHERE path = ?', (photo_path,))
|
|
result = cursor.fetchone()
|
|
if result:
|
|
photo_id = result[0]
|
|
|
|
if not photo_id:
|
|
return "No photo found"
|
|
|
|
# Get tags for this photo
|
|
tag_names = []
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('''
|
|
SELECT t.tag_name
|
|
FROM tags t
|
|
JOIN phototaglinkage ptl ON t.id = ptl.tag_id
|
|
WHERE ptl.photo_id = ?
|
|
ORDER BY t.tag_name
|
|
''', (photo_id,))
|
|
tag_names = [row[0] for row in cursor.fetchall()]
|
|
|
|
if tag_names:
|
|
if len(tag_names) <= 5:
|
|
return f"Tags: {', '.join(tag_names)}"
|
|
else:
|
|
return f"Tags: {', '.join(tag_names[:5])}... (+{len(tag_names)-5} more)"
|
|
else:
|
|
return "No tags"
|
|
|
|
def open_linkage_dialog(photo_ids):
|
|
"""Open the linkage dialog for selected photos using tag manager functionality."""
|
|
popup = tk.Toplevel(root)
|
|
popup.title("Tag Selected Photos")
|
|
popup.transient(root)
|
|
popup.grab_set()
|
|
popup.geometry("500x400")
|
|
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=f"Selected Photos: {len(photo_ids)}").grid(row=0, column=0, columnspan=3, sticky=tk.W, pady=(0,6))
|
|
ttk.Label(top_frame, text="Add tag:").grid(row=1, column=0, padx=(0, 8), sticky=tk.W)
|
|
|
|
# Get existing tags using tag manager
|
|
tag_id_to_name, tag_name_to_id = self.db.load_tag_mappings()
|
|
existing_tags = sorted(tag_name_to_id.keys())
|
|
|
|
tag_var = tk.StringVar()
|
|
combo = ttk.Combobox(top_frame, textvariable=tag_var, values=existing_tags, width=30)
|
|
combo.grid(row=1, column=1, padx=(0, 8), sticky=(tk.W, tk.E))
|
|
combo.focus_set()
|
|
|
|
def add_selected_tag():
|
|
tag_name = tag_var.get().strip()
|
|
if not tag_name:
|
|
return
|
|
|
|
# Resolve or create tag id
|
|
if tag_name in tag_name_to_id:
|
|
tag_id = tag_name_to_id[tag_name]
|
|
else:
|
|
# Create new tag in database
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('INSERT OR IGNORE INTO tags (tag_name) VALUES (?)', (tag_name,))
|
|
cursor.execute('SELECT id FROM tags WHERE tag_name = ?', (tag_name,))
|
|
result = cursor.fetchone()
|
|
if result:
|
|
tag_id = result[0]
|
|
tag_name_to_id[tag_name] = tag_id
|
|
tag_id_to_name[tag_id] = tag_name
|
|
if tag_name not in existing_tags:
|
|
existing_tags.append(tag_name)
|
|
existing_tags.sort()
|
|
# Update the combobox values to include the new tag
|
|
combo['values'] = existing_tags
|
|
else:
|
|
messagebox.showerror("Error", f"Failed to create tag '{tag_name}'", parent=popup)
|
|
return
|
|
|
|
# Add tag to all selected photos
|
|
affected = 0
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
for photo_id in photo_ids:
|
|
# Check if tag already exists for this photo
|
|
cursor.execute('SELECT linkage_id FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', (photo_id, tag_id))
|
|
if not cursor.fetchone():
|
|
# Add the tag with single linkage type (0)
|
|
cursor.execute('INSERT INTO phototaglinkage (photo_id, tag_id, linkage_type) VALUES (?, ?, 0)', (photo_id, tag_id))
|
|
affected += 1
|
|
|
|
# Refresh the tag list to show the new tag
|
|
refresh_tag_list()
|
|
tag_var.set("")
|
|
|
|
ttk.Button(top_frame, text="Add", command=add_selected_tag).grid(row=1, column=2, padx=(8, 0))
|
|
|
|
# Allow Enter key to add tag
|
|
combo.bind('<Return>', lambda e: add_selected_tag())
|
|
|
|
# Create scrollable tag list
|
|
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 = {}
|
|
|
|
def refresh_tag_list():
|
|
for widget in scrollable_frame.winfo_children():
|
|
widget.destroy()
|
|
selected_tag_vars.clear()
|
|
|
|
# Get all unique tags from selected photos
|
|
all_tag_ids = set()
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
for photo_id in photo_ids:
|
|
cursor.execute('SELECT tag_id FROM phototaglinkage WHERE photo_id = ?', (photo_id,))
|
|
for row in cursor.fetchall():
|
|
all_tag_ids.add(row[0])
|
|
|
|
if not all_tag_ids:
|
|
ttk.Label(scrollable_frame, text="No tags linked to selected photos", foreground="gray").pack(anchor=tk.W, pady=5)
|
|
return
|
|
|
|
for tag_id in sorted(all_tag_ids):
|
|
tag_name = 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)
|
|
cb = ttk.Checkbutton(frame, variable=var)
|
|
cb.pack(side=tk.LEFT, padx=(0, 5))
|
|
ttk.Label(frame, text=tag_name).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 tag_name_to_id:
|
|
tag_ids_to_remove.append(tag_name_to_id[tag_name])
|
|
|
|
if not tag_ids_to_remove:
|
|
return
|
|
|
|
# Remove tags from all selected photos
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
for photo_id in photo_ids:
|
|
for tag_id in tag_ids_to_remove:
|
|
cursor.execute('DELETE FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', (photo_id, tag_id))
|
|
|
|
refresh_tag_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()
|
|
|
|
# Click handling on icon columns
|
|
def on_tree_click(event):
|
|
region = tree.identify("region", event.x, event.y)
|
|
if region != "cell":
|
|
return
|
|
row_id = tree.identify_row(event.y)
|
|
col_id = tree.identify_column(event.x) # '#1', '#2', ...
|
|
if not row_id or not col_id:
|
|
return
|
|
vals = tree.item(row_id, "values")
|
|
if not vals or len(vals) < 5:
|
|
return
|
|
path = vals[3]
|
|
if col_id == "#2":
|
|
open_dir(path)
|
|
elif col_id == "#3": # Tag icon column
|
|
show_photo_tags(path)
|
|
elif col_id == "#4": # Photo path column - clickable to open photo
|
|
try:
|
|
if os.name == "nt":
|
|
os.startfile(path) # type: ignore[attr-defined]
|
|
elif sys.platform == "darwin":
|
|
import subprocess
|
|
subprocess.run(["open", path], check=False)
|
|
else:
|
|
import subprocess
|
|
subprocess.run(["xdg-open", path], check=False)
|
|
except Exception:
|
|
messagebox.showerror("Open Photo", "Failed to open the selected photo.", parent=root)
|
|
elif col_id == "#5": # Checkbox column
|
|
toggle_photo_selection(row_id, vals)
|
|
|
|
# Tooltip for icon cells
|
|
tooltip = None
|
|
def show_tooltip(widget, x, y, text: str):
|
|
nonlocal tooltip
|
|
hide_tooltip()
|
|
try:
|
|
tooltip = tk.Toplevel(widget)
|
|
tooltip.wm_overrideredirect(True)
|
|
tooltip.wm_geometry(f"+{x+12}+{y+12}")
|
|
lbl = tk.Label(tooltip, text=text, background="lightyellow", relief="solid", borderwidth=1, font=("Arial", 9))
|
|
lbl.pack()
|
|
except Exception:
|
|
tooltip = None
|
|
|
|
def hide_tooltip(*_):
|
|
nonlocal tooltip
|
|
if tooltip is not None:
|
|
try:
|
|
tooltip.destroy()
|
|
except Exception:
|
|
pass
|
|
tooltip = None
|
|
|
|
def on_tree_motion(event):
|
|
region = tree.identify("region", event.x, event.y)
|
|
if region != "cell":
|
|
hide_tooltip()
|
|
tree.config(cursor="")
|
|
return
|
|
col_id = tree.identify_column(event.x)
|
|
row_id = tree.identify_row(event.y)
|
|
if col_id == "#2":
|
|
tree.config(cursor="hand2")
|
|
show_tooltip(tree, event.x_root, event.y_root, "Open file location")
|
|
elif col_id == "#3":
|
|
tree.config(cursor="hand2")
|
|
# Show tags tooltip
|
|
if row_id:
|
|
vals = tree.item(row_id, "values")
|
|
if len(vals) >= 4:
|
|
path = vals[3]
|
|
tags_text = get_photo_tags_tooltip(path)
|
|
show_tooltip(tree, event.x_root, event.y_root, tags_text)
|
|
elif col_id == "#4":
|
|
tree.config(cursor="hand2")
|
|
show_tooltip(tree, event.x_root, event.y_root, "Open photo")
|
|
else:
|
|
tree.config(cursor="")
|
|
hide_tooltip()
|
|
|
|
type_combo.bind("<<ComboboxSelected>>", switch_inputs)
|
|
switch_inputs()
|
|
tree.bind("<Button-1>", on_tree_click)
|
|
tree.bind("<Motion>", on_tree_motion)
|
|
tree.bind("<Leave>", hide_tooltip)
|
|
|
|
# Enter key in name field triggers search
|
|
name_entry.bind("<Return>", lambda e: do_search())
|
|
|
|
# Show and center
|
|
root.update_idletasks()
|
|
# Widened to ensure the checkbox column is visible by default
|
|
self.gui_core.center_window(root, 900, 520)
|
|
root.deiconify()
|
|
root.mainloop()
|
|
return 0
|
|
|
|
|