punimtag/search_gui.py
tanyar09 1972a69685 Refactor IdentifyGUI for improved face identification and management
This commit enhances the IdentifyGUI class by updating the face identification process to handle similar faces more effectively. The logic for updating the current face index has been streamlined, allowing for better flow when identifying faces. Additionally, new methods have been introduced to manage selected similar faces, ensuring that all identified faces are properly marked and displayed. The form clearing functionality has also been updated to include similar face selections, improving user experience during the identification process.
2025-10-08 12:55:56 -04:00

723 lines
32 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 get_saved_tag_types_for_photo(photo_id: int):
"""Get saved linkage types for a photo {tag_id: type_int}"""
types = {}
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 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 with single linkage type (0)
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 tags that exist in ALL selected photos
# First, get all tags for each photo
photo_tags = {} # photo_id -> set of tag_ids
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
for photo_id in photo_ids:
photo_tags[photo_id] = set()
cursor.execute('SELECT tag_id, linkage_type FROM phototaglinkage WHERE photo_id = ?', (photo_id,))
for row in cursor.fetchall():
photo_tags[photo_id].add(row[0])
# Find intersection - tags that exist in ALL selected photos
if not photo_tags:
ttk.Label(scrollable_frame, text="No tags linked to selected photos", foreground="gray").pack(anchor=tk.W, pady=5)
return
# Start with tags from first photo, then intersect with others
common_tag_ids = set(photo_tags[photo_ids[0]])
for photo_id in photo_ids[1:]:
common_tag_ids = common_tag_ids.intersection(photo_tags[photo_id])
if not common_tag_ids:
ttk.Label(scrollable_frame, text="No common tags found across all selected photos", foreground="gray").pack(anchor=tk.W, pady=5)
return
# Get linkage type information for common tags
# For tags that exist in all photos, we need to determine the linkage type
# If a tag has different linkage types across photos, we'll show the most restrictive
common_tag_data = {} # tag_id -> {linkage_type, photo_count}
with self.db.get_db_connection() as conn:
cursor = conn.cursor()
for photo_id in photo_ids:
cursor.execute('SELECT tag_id, linkage_type FROM phototaglinkage WHERE photo_id = ? AND tag_id IN ({})'.format(','.join('?' * len(common_tag_ids))), [photo_id] + list(common_tag_ids))
for row in cursor.fetchall():
tag_id = row[0]
linkage_type = int(row[1]) if row[1] is not None else 0
if tag_id not in common_tag_data:
common_tag_data[tag_id] = {'linkage_type': linkage_type, 'photo_count': 0}
common_tag_data[tag_id]['photo_count'] += 1
# If we find a bulk linkage type (1), use that as it's more restrictive
if linkage_type == 1:
common_tag_data[tag_id]['linkage_type'] = 1
# Sort tags by name for consistent display
for tag_id in sorted(common_tag_data.keys()):
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)
# Determine if this tag can be selected for deletion
# In single linkage dialog, only allow deleting single linkage type (0) tags
linkage_type = common_tag_data[tag_id]['linkage_type']
can_select = (linkage_type == 0) # Only single linkage type can be deleted
cb = ttk.Checkbutton(frame, variable=var)
if not can_select:
try:
cb.state(["disabled"]) # disable selection for bulk tags
except Exception:
pass
cb.pack(side=tk.LEFT, padx=(0, 5))
# Display tag name with status information
type_label = 'single' if linkage_type == 0 else 'bulk'
photo_count = common_tag_data[tag_id]['photo_count']
status_text = f" (saved {type_label}, on {photo_count}/{len(photo_ids)} photos)"
status_color = "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 tag_name_to_id:
tag_ids_to_remove.append(tag_name_to_id[tag_name])
if not tag_ids_to_remove:
return
# Only remove single linkage type tags (bulk tags should be disabled anyway)
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:
# Double-check that this is a single linkage type before deleting
cursor.execute('SELECT linkage_type FROM phototaglinkage WHERE photo_id = ? AND tag_id = ?', (photo_id, tag_id))
result = cursor.fetchone()
if result and int(result[0]) == 0: # Only delete single linkage type
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