This commit enhances the SearchGUI class by introducing sorting capabilities for search results. Users can now sort results by 'Person' and 'Photo path' columns, with visual indicators for sort direction. The sorting state is maintained, allowing for toggling between ascending and descending orders. Additionally, the default sorting is set to 'Person' when results are first loaded, improving the overall user experience in navigating search results.
321 lines
12 KiB
Python
321 lines
12 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
|
|
|
|
# Sorting state
|
|
self.sort_column = None
|
|
self.sort_reverse = False
|
|
|
|
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", 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.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)
|
|
|
|
# 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
|
|
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)
|
|
|
|
# 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
|
|
|
|
|