punimtag/search_gui.py
tanyar09 d4504ee81a Add Search GUI for enhanced photo searching capabilities
This commit introduces the SearchGUI class, allowing users to search for photos by name through a user-friendly interface. The new functionality includes options to view search results, open photo locations, and display relevant information about matched individuals. The PhotoTagger class is updated to integrate this feature, and the README is revised to include usage instructions for the new search-gui command. Additionally, the search_faces method in SearchStats is enhanced to return detailed results, improving the overall search experience.
2025-10-07 11:45:12 -04:00

253 lines
9.5 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
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, verbose: int = 0):
self.db = db_manager
self.search_stats = search_stats
self.gui_core = gui_core
self.verbose = verbose
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")
tree = ttk.Treeview(results_frame, columns=columns, show="headings", selectmode="browse")
tree.heading("person", text="Person")
tree.heading("open_dir", text="📁")
tree.heading("open_photo", text="📷")
tree.heading("path", text="Photo path")
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.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))
close_btn = ttk.Button(btns, text="Close", command=lambda: root.destroy())
close_btn.pack(side=tk.RIGHT)
# 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)
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))
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)
# 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) < 4:
return
path = vals[3]
if col_id == "#2":
open_dir(path)
elif col_id == "#3":
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)
# 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)
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_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()
self.gui_core.center_window(root, 800, 500)
root.deiconify()
root.mainloop()
return 0