This commit enhances the AutoMatchGUI and ModifyIdentifiedGUI classes by refining the logic for handling unsaved changes when quitting. The AutoMatchGUI now uses a simplified messagebox for unsaved changes, while the ModifyIdentifiedGUI introduces a similar prompt to warn users about pending changes. Additionally, the logic for managing matched IDs has been updated to ensure consistency in face identification. These improvements aim to enhance user experience by providing clearer warnings and preserving user actions more effectively.
1095 lines
52 KiB
Python
1095 lines
52 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Modify Identified Faces GUI implementation for PunimTag
|
|
"""
|
|
|
|
import os
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox
|
|
from PIL import Image, ImageTk
|
|
|
|
from config import DEFAULT_FACE_TOLERANCE
|
|
from database import DatabaseManager
|
|
from face_processing import FaceProcessor
|
|
from gui_core import GUICore
|
|
|
|
|
|
class ModifyIdentifiedGUI:
|
|
"""Handles the View and Modify Identified Faces GUI interface"""
|
|
|
|
def __init__(self, db_manager: DatabaseManager, face_processor: FaceProcessor, verbose: int = 0):
|
|
self.db = db_manager
|
|
self.face_processor = face_processor
|
|
self.verbose = verbose
|
|
self.gui_core = GUICore()
|
|
|
|
def modifyidentified(self) -> int:
|
|
"""Open the View and Modify Identified Faces window"""
|
|
# Simple tooltip implementation
|
|
class ToolTip:
|
|
def __init__(self, widget, text):
|
|
self.widget = widget
|
|
self.text = text
|
|
self.tooltip_window = None
|
|
self.widget.bind("<Enter>", self.on_enter)
|
|
self.widget.bind("<Leave>", self.on_leave)
|
|
|
|
def on_enter(self, event=None):
|
|
if self.tooltip_window or not self.text:
|
|
return
|
|
x, y, _, _ = self.widget.bbox("insert") if hasattr(self.widget, 'bbox') else (0, 0, 0, 0)
|
|
x += self.widget.winfo_rootx() + 25
|
|
y += self.widget.winfo_rooty() + 25
|
|
|
|
self.tooltip_window = tw = tk.Toplevel(self.widget)
|
|
tw.wm_overrideredirect(True)
|
|
tw.wm_geometry(f"+{x}+{y}")
|
|
|
|
label = tk.Label(tw, text=self.text, justify=tk.LEFT,
|
|
background="#ffffe0", relief=tk.SOLID, borderwidth=1,
|
|
font=("tahoma", "8", "normal"))
|
|
label.pack(ipadx=1)
|
|
|
|
def on_leave(self, event=None):
|
|
if self.tooltip_window:
|
|
self.tooltip_window.destroy()
|
|
self.tooltip_window = None
|
|
|
|
# Create the main window
|
|
root = tk.Tk()
|
|
root.title("View and Modify Identified Faces")
|
|
root.resizable(True, True)
|
|
|
|
# Track window state to prevent multiple destroy calls
|
|
window_destroyed = False
|
|
temp_crops = []
|
|
right_panel_images = [] # Keep PhotoImage refs alive
|
|
selected_person_id = None
|
|
|
|
# Hide window initially to prevent flash at corner
|
|
root.withdraw()
|
|
|
|
# Set up protocol handler for window close button (X)
|
|
def on_closing():
|
|
nonlocal window_destroyed
|
|
# Cleanup temp crops
|
|
for crop in list(temp_crops):
|
|
try:
|
|
if os.path.exists(crop):
|
|
os.remove(crop)
|
|
except Exception:
|
|
pass
|
|
temp_crops.clear()
|
|
if not window_destroyed:
|
|
window_destroyed = True
|
|
try:
|
|
root.destroy()
|
|
except tk.TclError:
|
|
pass # Window already destroyed
|
|
|
|
root.protocol("WM_DELETE_WINDOW", on_closing)
|
|
|
|
# Set up window size saving
|
|
saved_size = self.gui_core.setup_window_size_saving(root, "gui_config.json")
|
|
|
|
# Create main frame
|
|
main_frame = ttk.Frame(root, padding="10")
|
|
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
|
|
# Configure grid weights
|
|
root.columnconfigure(0, weight=1)
|
|
root.rowconfigure(0, weight=1)
|
|
main_frame.columnconfigure(0, weight=1)
|
|
main_frame.columnconfigure(1, weight=2)
|
|
main_frame.rowconfigure(1, weight=1)
|
|
|
|
# Title label
|
|
title_label = ttk.Label(main_frame, text="View and Modify Identified Faces", font=("Arial", 16, "bold"))
|
|
title_label.grid(row=0, column=0, columnspan=2, pady=(0, 10), sticky=tk.W)
|
|
|
|
# Left panel: People list
|
|
people_frame = ttk.LabelFrame(main_frame, text="People", padding="10")
|
|
people_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 8))
|
|
people_frame.columnconfigure(0, weight=1)
|
|
|
|
# Search controls (Last Name) with label under the input (match auto-match style)
|
|
last_name_search_var = tk.StringVar()
|
|
search_frame = ttk.Frame(people_frame)
|
|
search_frame.grid(row=0, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 6))
|
|
|
|
# Entry on the left
|
|
search_entry = ttk.Entry(search_frame, textvariable=last_name_search_var, width=20)
|
|
search_entry.grid(row=0, column=0, sticky=tk.W)
|
|
|
|
# Buttons to the right of the entry
|
|
buttons_row = ttk.Frame(search_frame)
|
|
buttons_row.grid(row=0, column=1, sticky=tk.W, padx=(6, 0))
|
|
search_btn = ttk.Button(buttons_row, text="Search", width=8)
|
|
search_btn.pack(side=tk.LEFT, padx=(0, 5))
|
|
clear_btn = ttk.Button(buttons_row, text="Clear", width=6)
|
|
clear_btn.pack(side=tk.LEFT)
|
|
|
|
# Helper label directly under the entry
|
|
last_name_label = ttk.Label(search_frame, text="Type Last Name", font=("Arial", 8), foreground="gray")
|
|
last_name_label.grid(row=1, column=0, sticky=tk.W, pady=(2, 0))
|
|
|
|
people_canvas = tk.Canvas(people_frame, bg='white')
|
|
people_scrollbar = ttk.Scrollbar(people_frame, orient="vertical", command=people_canvas.yview)
|
|
people_list_inner = ttk.Frame(people_canvas)
|
|
people_canvas.create_window((0, 0), window=people_list_inner, anchor="nw")
|
|
people_canvas.configure(yscrollcommand=people_scrollbar.set)
|
|
|
|
people_list_inner.bind(
|
|
"<Configure>",
|
|
lambda e: people_canvas.configure(scrollregion=people_canvas.bbox("all"))
|
|
)
|
|
|
|
people_canvas.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
people_scrollbar.grid(row=1, column=1, sticky=(tk.N, tk.S))
|
|
people_frame.rowconfigure(1, weight=1)
|
|
|
|
# Right panel: Faces for selected person
|
|
faces_frame = ttk.LabelFrame(main_frame, text="Faces", padding="10")
|
|
faces_frame.grid(row=1, column=1, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
faces_frame.columnconfigure(0, weight=1)
|
|
faces_frame.rowconfigure(0, weight=1)
|
|
|
|
style = ttk.Style()
|
|
canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9'
|
|
# Match auto-match UI: set gray background for left canvas and remove highlight border
|
|
try:
|
|
people_canvas.configure(bg=canvas_bg_color, highlightthickness=0)
|
|
except Exception:
|
|
pass
|
|
faces_canvas = tk.Canvas(faces_frame, bg=canvas_bg_color, highlightthickness=0)
|
|
faces_scrollbar = ttk.Scrollbar(faces_frame, orient="vertical", command=faces_canvas.yview)
|
|
faces_inner = ttk.Frame(faces_canvas)
|
|
faces_canvas.create_window((0, 0), window=faces_inner, anchor="nw")
|
|
faces_canvas.configure(yscrollcommand=faces_scrollbar.set)
|
|
|
|
faces_inner.bind(
|
|
"<Configure>",
|
|
lambda e: faces_canvas.configure(scrollregion=faces_canvas.bbox("all"))
|
|
)
|
|
|
|
# Track current person for responsive face grid
|
|
current_person_id = None
|
|
current_person_name = ""
|
|
resize_job = None
|
|
|
|
# Track unmatched faces (temporary changes)
|
|
unmatched_faces = set() # All face IDs unmatched across people (for global save)
|
|
unmatched_by_person = {} # person_id -> set(face_id) for per-person undo
|
|
original_faces_data = [] # store original faces data for potential future use
|
|
|
|
def on_faces_canvas_resize(event):
|
|
nonlocal resize_job
|
|
if current_person_id is None:
|
|
return
|
|
# Debounce re-render on resize
|
|
try:
|
|
if resize_job is not None:
|
|
root.after_cancel(resize_job)
|
|
except Exception:
|
|
pass
|
|
resize_job = root.after(150, lambda: show_person_faces(current_person_id, current_person_name))
|
|
|
|
faces_canvas.bind("<Configure>", on_faces_canvas_resize)
|
|
|
|
faces_canvas.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
faces_scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S))
|
|
|
|
# Load people from DB with counts
|
|
people_data = [] # list of dicts: {id, name, count, first_name, last_name}
|
|
people_filtered = None # filtered subset based on last name search
|
|
|
|
def load_people():
|
|
nonlocal people_data
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"""
|
|
SELECT p.id, p.first_name, p.last_name, p.middle_name, p.maiden_name, p.date_of_birth, COUNT(f.id) as face_count
|
|
FROM people p
|
|
JOIN faces f ON f.person_id = p.id
|
|
GROUP BY p.id, p.last_name, p.first_name, p.middle_name, p.maiden_name, p.date_of_birth
|
|
HAVING face_count > 0
|
|
ORDER BY p.last_name, p.first_name COLLATE NOCASE
|
|
"""
|
|
)
|
|
people_data = []
|
|
for (pid, first_name, last_name, middle_name, maiden_name, date_of_birth, count) in cursor.fetchall():
|
|
# Create full name display with all available information
|
|
name_parts = []
|
|
if first_name:
|
|
name_parts.append(first_name)
|
|
if middle_name:
|
|
name_parts.append(middle_name)
|
|
if last_name:
|
|
name_parts.append(last_name)
|
|
if maiden_name:
|
|
name_parts.append(f"({maiden_name})")
|
|
|
|
full_name = ' '.join(name_parts) if name_parts else "Unknown"
|
|
|
|
# Create detailed display with date of birth if available
|
|
display_name = full_name
|
|
if date_of_birth:
|
|
display_name += f" - Born: {date_of_birth}"
|
|
|
|
people_data.append({
|
|
'id': pid,
|
|
'name': display_name,
|
|
'full_name': full_name,
|
|
'first_name': first_name or "",
|
|
'last_name': last_name or "",
|
|
'middle_name': middle_name or "",
|
|
'maiden_name': maiden_name or "",
|
|
'date_of_birth': date_of_birth or "",
|
|
'count': count
|
|
})
|
|
# Re-apply filter (if any) after loading
|
|
try:
|
|
apply_last_name_filter()
|
|
except Exception:
|
|
pass
|
|
|
|
# Wire up search controls now that helper functions exist
|
|
try:
|
|
search_btn.config(command=lambda: apply_last_name_filter())
|
|
clear_btn.config(command=lambda: clear_last_name_filter())
|
|
search_entry.bind('<Return>', lambda e: apply_last_name_filter())
|
|
except Exception:
|
|
pass
|
|
|
|
def apply_last_name_filter():
|
|
nonlocal people_filtered
|
|
query = last_name_search_var.get().strip().lower()
|
|
if query:
|
|
people_filtered = [p for p in people_data if p.get('last_name', '').lower().find(query) != -1]
|
|
else:
|
|
people_filtered = None
|
|
populate_people_list()
|
|
# Update right panel based on filtered results
|
|
source = people_filtered if people_filtered is not None else people_data
|
|
if source:
|
|
# Load faces for the first person in the list
|
|
first = source[0]
|
|
try:
|
|
# Update selection state
|
|
for child in people_list_inner.winfo_children():
|
|
for widget in child.winfo_children():
|
|
if isinstance(widget, ttk.Label):
|
|
widget.config(font=("Arial", 10))
|
|
# Bold the first label if present
|
|
first_row = people_list_inner.winfo_children()[0]
|
|
for widget in first_row.winfo_children():
|
|
if isinstance(widget, ttk.Label):
|
|
widget.config(font=("Arial", 10, "bold"))
|
|
break
|
|
except Exception:
|
|
pass
|
|
# Show faces for the first person
|
|
show_person_faces(first['id'], first.get('full_name') or first.get('name') or "")
|
|
else:
|
|
# No matches: clear faces panel
|
|
clear_faces_panel()
|
|
|
|
def clear_last_name_filter():
|
|
nonlocal people_filtered
|
|
last_name_search_var.set("")
|
|
people_filtered = None
|
|
populate_people_list()
|
|
# After clearing, load faces for the first available person if any
|
|
if people_data:
|
|
first = people_data[0]
|
|
try:
|
|
for child in people_list_inner.winfo_children():
|
|
for widget in child.winfo_children():
|
|
if isinstance(widget, ttk.Label):
|
|
widget.config(font=("Arial", 10))
|
|
first_row = people_list_inner.winfo_children()[0]
|
|
for widget in first_row.winfo_children():
|
|
if isinstance(widget, ttk.Label):
|
|
widget.config(font=("Arial", 10, "bold"))
|
|
break
|
|
except Exception:
|
|
pass
|
|
show_person_faces(first['id'], first.get('full_name') or first.get('name') or "")
|
|
else:
|
|
clear_faces_panel()
|
|
|
|
def clear_faces_panel():
|
|
for w in faces_inner.winfo_children():
|
|
w.destroy()
|
|
# Cleanup temp crops
|
|
for crop in list(temp_crops):
|
|
try:
|
|
if os.path.exists(crop):
|
|
os.remove(crop)
|
|
except Exception:
|
|
pass
|
|
temp_crops.clear()
|
|
right_panel_images.clear()
|
|
|
|
def unmatch_face(face_id: int):
|
|
"""Temporarily unmatch a face from the current person"""
|
|
nonlocal unmatched_faces, unmatched_by_person
|
|
unmatched_faces.add(face_id)
|
|
# Track per-person for Undo
|
|
person_set = unmatched_by_person.get(current_person_id)
|
|
if person_set is None:
|
|
person_set = set()
|
|
unmatched_by_person[current_person_id] = person_set
|
|
person_set.add(face_id)
|
|
# Refresh the display
|
|
show_person_faces(current_person_id, current_person_name)
|
|
|
|
def undo_changes():
|
|
"""Undo all temporary changes"""
|
|
nonlocal unmatched_faces, unmatched_by_person
|
|
if current_person_id in unmatched_by_person:
|
|
for fid in list(unmatched_by_person[current_person_id]):
|
|
unmatched_faces.discard(fid)
|
|
unmatched_by_person[current_person_id].clear()
|
|
# Refresh the display
|
|
show_person_faces(current_person_id, current_person_name)
|
|
|
|
def save_changes():
|
|
"""Save unmatched faces to database"""
|
|
if not unmatched_faces:
|
|
return
|
|
|
|
# Confirm with user
|
|
result = messagebox.askyesno(
|
|
"Confirm Changes",
|
|
f"Are you sure you want to unlink {len(unmatched_faces)} face(s) from '{current_person_name}'?\n\n"
|
|
"This will make these faces unidentified again."
|
|
)
|
|
|
|
if not result:
|
|
return
|
|
|
|
# Update database
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
for face_id in unmatched_faces:
|
|
cursor.execute('UPDATE faces SET person_id = NULL WHERE id = ?', (face_id,))
|
|
conn.commit()
|
|
|
|
# Store count for message before clearing
|
|
unlinked_count = len(unmatched_faces)
|
|
|
|
# Clear unmatched faces and refresh
|
|
unmatched_faces.clear()
|
|
original_faces_data.clear()
|
|
|
|
# Refresh people list to update counts
|
|
load_people()
|
|
populate_people_list()
|
|
|
|
# Refresh faces display
|
|
show_person_faces(current_person_id, current_person_name)
|
|
|
|
messagebox.showinfo("Changes Saved", f"Successfully unlinked {unlinked_count} face(s) from '{current_person_name}'.")
|
|
|
|
def show_person_faces(person_id: int, person_name: str):
|
|
nonlocal current_person_id, current_person_name, unmatched_faces, original_faces_data
|
|
current_person_id = person_id
|
|
current_person_name = person_name
|
|
clear_faces_panel()
|
|
|
|
# Determine how many columns fit the available width
|
|
available_width = faces_canvas.winfo_width()
|
|
if available_width <= 1:
|
|
available_width = faces_frame.winfo_width()
|
|
tile_width = 150 # approx tile + padding
|
|
cols = max(1, available_width // tile_width)
|
|
|
|
# Header row
|
|
header = ttk.Label(faces_inner, text=f"Faces for: {person_name}", font=("Arial", 12, "bold"))
|
|
header.grid(row=0, column=0, columnspan=cols, sticky=tk.W, pady=(0, 5))
|
|
|
|
# Control buttons row
|
|
button_frame = ttk.Frame(faces_inner)
|
|
button_frame.grid(row=1, column=0, columnspan=cols, sticky=tk.W, pady=(0, 10))
|
|
|
|
# Enable Undo only if current person has unmatched faces
|
|
current_has_unmatched = bool(unmatched_by_person.get(current_person_id))
|
|
undo_btn = ttk.Button(button_frame, text="↶ Undo changes",
|
|
command=lambda: undo_changes(),
|
|
state="disabled" if not current_has_unmatched else "normal")
|
|
undo_btn.pack(side=tk.LEFT, padx=(0, 10))
|
|
|
|
# Note: Save button moved to bottom control bar
|
|
|
|
# Query faces for this person
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"""
|
|
SELECT f.id, f.location, ph.path, ph.filename
|
|
FROM faces f
|
|
JOIN photos ph ON ph.id = f.photo_id
|
|
WHERE f.person_id = ?
|
|
ORDER BY f.id DESC
|
|
""",
|
|
(person_id,)
|
|
)
|
|
rows = cursor.fetchall()
|
|
|
|
# Filter out unmatched faces
|
|
visible_rows = [row for row in rows if row[0] not in unmatched_faces]
|
|
|
|
if not visible_rows:
|
|
ttk.Label(faces_inner, text="No faces found." if not rows else "All faces unmatched.", foreground="gray").grid(row=2, column=0, sticky=tk.W)
|
|
return
|
|
|
|
# Grid thumbnails with responsive column count
|
|
row_index = 2 # Start after header and buttons
|
|
col_index = 0
|
|
for face_id, location, photo_path, filename in visible_rows:
|
|
crop_path = self.face_processor._extract_face_crop(photo_path, location, face_id)
|
|
thumb = None
|
|
if crop_path and os.path.exists(crop_path):
|
|
try:
|
|
img = Image.open(crop_path)
|
|
img.thumbnail((130, 130), Image.Resampling.LANCZOS)
|
|
photo_img = ImageTk.PhotoImage(img)
|
|
temp_crops.append(crop_path)
|
|
right_panel_images.append(photo_img)
|
|
thumb = photo_img
|
|
except Exception:
|
|
thumb = None
|
|
|
|
tile = ttk.Frame(faces_inner, padding="5")
|
|
tile.grid(row=row_index, column=col_index, padx=5, pady=5, sticky=tk.N)
|
|
|
|
# Create a frame for the face image with X button overlay
|
|
face_frame = ttk.Frame(tile)
|
|
face_frame.grid(row=0, column=0)
|
|
|
|
canvas = tk.Canvas(face_frame, width=130, height=130, bg=canvas_bg_color, highlightthickness=0)
|
|
canvas.grid(row=0, column=0)
|
|
if thumb is not None:
|
|
canvas.create_image(65, 65, image=thumb)
|
|
else:
|
|
canvas.create_text(65, 65, text="🖼️", fill="gray")
|
|
|
|
# X button to unmatch face - pin exactly to the canvas' top-right corner
|
|
x_canvas = tk.Canvas(face_frame, width=12, height=12, bg='red',
|
|
highlightthickness=0, relief="flat")
|
|
x_canvas.create_text(6, 6, text="✖", fill="white", font=("Arial", 8, "bold"))
|
|
# Click handler
|
|
x_canvas.bind("<Button-1>", lambda e, fid=face_id: unmatch_face(fid))
|
|
# Hover highlight: change bg, show white outline, and hand cursor
|
|
x_canvas.bind("<Enter>", lambda e, c=x_canvas: c.configure(bg="#cc0000", highlightthickness=1, highlightbackground="white", cursor="hand2"))
|
|
x_canvas.bind("<Leave>", lambda e, c=x_canvas: c.configure(bg="red", highlightthickness=0, cursor=""))
|
|
# Anchor to the canvas' top-right regardless of layout/size
|
|
try:
|
|
x_canvas.place(in_=canvas, relx=1.0, x=0, y=0, anchor='ne')
|
|
except Exception:
|
|
# Fallback to absolute coords if relative placement fails
|
|
x_canvas.place(x=118, y=0)
|
|
|
|
ttk.Label(tile, text=f"ID {face_id}", foreground="gray").grid(row=1, column=0)
|
|
|
|
col_index += 1
|
|
if col_index >= cols:
|
|
col_index = 0
|
|
row_index += 1
|
|
|
|
def populate_people_list():
|
|
for w in people_list_inner.winfo_children():
|
|
w.destroy()
|
|
source = people_filtered if people_filtered is not None else people_data
|
|
if not source:
|
|
empty_label = ttk.Label(people_list_inner, text="No people match filter", foreground="gray")
|
|
empty_label.grid(row=0, column=0, sticky=tk.W, pady=4)
|
|
return
|
|
for idx, person in enumerate(source):
|
|
row = ttk.Frame(people_list_inner)
|
|
row.grid(row=idx, column=0, sticky=(tk.W, tk.E), pady=4)
|
|
# Freeze per-row values to avoid late-binding issues
|
|
row_person = person
|
|
row_idx = idx
|
|
|
|
# Make person name clickable
|
|
def make_click_handler(p_id, p_name, p_idx):
|
|
def on_click(event):
|
|
nonlocal selected_person_id
|
|
# Reset all labels to normal font
|
|
for child in people_list_inner.winfo_children():
|
|
for widget in child.winfo_children():
|
|
if isinstance(widget, ttk.Label):
|
|
widget.config(font=("Arial", 10))
|
|
# Set clicked label to bold
|
|
event.widget.config(font=("Arial", 10, "bold"))
|
|
selected_person_id = p_id
|
|
# Show faces for this person
|
|
show_person_faces(p_id, p_name)
|
|
return on_click
|
|
|
|
# Edit (rename) button
|
|
def start_edit_person(row_frame, person_record, row_index):
|
|
for w in row_frame.winfo_children():
|
|
w.destroy()
|
|
|
|
# Use pre-loaded data instead of database query
|
|
cur_first = person_record.get('first_name', '')
|
|
cur_last = person_record.get('last_name', '')
|
|
cur_middle = person_record.get('middle_name', '')
|
|
cur_maiden = person_record.get('maiden_name', '')
|
|
cur_dob = person_record.get('date_of_birth', '')
|
|
|
|
# Create a larger container frame for the text boxes and labels
|
|
edit_container = ttk.Frame(row_frame)
|
|
edit_container.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
|
|
|
# First name field with label
|
|
first_frame = ttk.Frame(edit_container)
|
|
first_frame.grid(row=0, column=0, padx=(0, 10), pady=(0, 5), sticky=tk.W)
|
|
|
|
first_var = tk.StringVar(value=cur_first)
|
|
first_entry = ttk.Entry(first_frame, textvariable=first_var, width=15)
|
|
first_entry.pack(side=tk.TOP)
|
|
first_entry.focus_set()
|
|
|
|
first_label = ttk.Label(first_frame, text="First Name", font=("Arial", 8), foreground="gray")
|
|
first_label.pack(side=tk.TOP, pady=(2, 0))
|
|
|
|
# Last name field with label
|
|
last_frame = ttk.Frame(edit_container)
|
|
last_frame.grid(row=0, column=1, padx=(0, 10), pady=(0, 5), sticky=tk.W)
|
|
|
|
last_var = tk.StringVar(value=cur_last)
|
|
last_entry = ttk.Entry(last_frame, textvariable=last_var, width=15)
|
|
last_entry.pack(side=tk.TOP)
|
|
|
|
last_label = ttk.Label(last_frame, text="Last Name", font=("Arial", 8), foreground="gray")
|
|
last_label.pack(side=tk.TOP, pady=(2, 0))
|
|
|
|
# Middle name field with label
|
|
middle_frame = ttk.Frame(edit_container)
|
|
middle_frame.grid(row=0, column=2, padx=(0, 10), pady=(0, 5), sticky=tk.W)
|
|
|
|
middle_var = tk.StringVar(value=cur_middle)
|
|
middle_entry = ttk.Entry(middle_frame, textvariable=middle_var, width=15)
|
|
middle_entry.pack(side=tk.TOP)
|
|
|
|
middle_label = ttk.Label(middle_frame, text="Middle Name", font=("Arial", 8), foreground="gray")
|
|
middle_label.pack(side=tk.TOP, pady=(2, 0))
|
|
|
|
# Maiden name field with label
|
|
maiden_frame = ttk.Frame(edit_container)
|
|
maiden_frame.grid(row=1, column=0, padx=(0, 10), pady=(0, 5), sticky=tk.W)
|
|
|
|
maiden_var = tk.StringVar(value=cur_maiden)
|
|
maiden_entry = ttk.Entry(maiden_frame, textvariable=maiden_var, width=15)
|
|
maiden_entry.pack(side=tk.TOP)
|
|
|
|
maiden_label = ttk.Label(maiden_frame, text="Maiden Name", font=("Arial", 8), foreground="gray")
|
|
maiden_label.pack(side=tk.TOP, pady=(2, 0))
|
|
|
|
# Date of birth field with label and calendar button
|
|
dob_frame = ttk.Frame(edit_container)
|
|
dob_frame.grid(row=1, column=1, columnspan=2, padx=(0, 10), pady=(0, 5), sticky=tk.W)
|
|
|
|
# Create a frame for the date picker
|
|
date_picker_frame = ttk.Frame(dob_frame)
|
|
date_picker_frame.pack(side=tk.TOP)
|
|
|
|
dob_var = tk.StringVar(value=cur_dob)
|
|
dob_entry = ttk.Entry(date_picker_frame, textvariable=dob_var, width=20, state='readonly')
|
|
dob_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
|
|
|
# Calendar button
|
|
calendar_btn = ttk.Button(date_picker_frame, text="📅", width=3, command=lambda: open_calendar())
|
|
calendar_btn.pack(side=tk.RIGHT, padx=(5, 0))
|
|
|
|
dob_label = ttk.Label(dob_frame, text="Date of Birth", font=("Arial", 8), foreground="gray")
|
|
dob_label.pack(side=tk.TOP, pady=(2, 0))
|
|
|
|
def open_calendar():
|
|
"""Open a visual calendar dialog to select date of birth"""
|
|
from datetime import datetime, date
|
|
import calendar
|
|
|
|
# Create calendar window
|
|
calendar_window = tk.Toplevel(root)
|
|
calendar_window.title("Select Date of Birth")
|
|
calendar_window.resizable(False, False)
|
|
calendar_window.transient(root)
|
|
calendar_window.grab_set()
|
|
|
|
# Calculate center position before showing the window
|
|
window_width = 400
|
|
window_height = 400
|
|
screen_width = calendar_window.winfo_screenwidth()
|
|
screen_height = calendar_window.winfo_screenheight()
|
|
x = (screen_width // 2) - (window_width // 2)
|
|
y = (screen_height // 2) - (window_height // 2)
|
|
|
|
# Set geometry with center position before showing
|
|
calendar_window.geometry(f"{window_width}x{window_height}+{x}+{y}")
|
|
|
|
# Calendar variables
|
|
current_date = datetime.now()
|
|
|
|
# Check if there's already a date selected
|
|
existing_date_str = dob_var.get().strip()
|
|
if existing_date_str:
|
|
try:
|
|
existing_date = datetime.strptime(existing_date_str, '%Y-%m-%d').date()
|
|
display_year = existing_date.year
|
|
display_month = existing_date.month
|
|
selected_date = existing_date
|
|
except ValueError:
|
|
# If existing date is invalid, use default
|
|
display_year = current_date.year - 25
|
|
display_month = 1
|
|
selected_date = None
|
|
else:
|
|
# Default to 25 years ago
|
|
display_year = current_date.year - 25
|
|
display_month = 1
|
|
selected_date = None
|
|
|
|
# Month names
|
|
month_names = ["January", "February", "March", "April", "May", "June",
|
|
"July", "August", "September", "October", "November", "December"]
|
|
|
|
# Configure custom styles for better visual highlighting
|
|
style = ttk.Style()
|
|
|
|
# Selected date style - bright blue background with white text
|
|
style.configure("Selected.TButton",
|
|
background="#0078d4",
|
|
foreground="white",
|
|
font=("Arial", 9, "bold"),
|
|
relief="raised",
|
|
borderwidth=2)
|
|
style.map("Selected.TButton",
|
|
background=[("active", "#106ebe")],
|
|
relief=[("pressed", "sunken")])
|
|
|
|
# Today's date style - orange background
|
|
style.configure("Today.TButton",
|
|
background="#ff8c00",
|
|
foreground="white",
|
|
font=("Arial", 9, "bold"),
|
|
relief="raised",
|
|
borderwidth=1)
|
|
style.map("Today.TButton",
|
|
background=[("active", "#e67e00")],
|
|
relief=[("pressed", "sunken")])
|
|
|
|
# Calendar-specific normal button style (don't affect global TButton)
|
|
style.configure("Calendar.TButton",
|
|
font=("Arial", 9),
|
|
relief="flat")
|
|
style.map("Calendar.TButton",
|
|
background=[["active", "#e1e1e1"]],
|
|
relief=[["pressed", "sunken"]])
|
|
|
|
# Main frame
|
|
main_cal_frame = ttk.Frame(calendar_window, padding="10")
|
|
main_cal_frame.pack(fill=tk.BOTH, expand=True)
|
|
|
|
# Header frame with navigation
|
|
header_frame = ttk.Frame(main_cal_frame)
|
|
header_frame.pack(fill=tk.X, pady=(0, 10))
|
|
|
|
# Month/Year display and navigation
|
|
nav_frame = ttk.Frame(header_frame)
|
|
nav_frame.pack()
|
|
|
|
def update_calendar():
|
|
"""Update the calendar display"""
|
|
# Clear existing calendar
|
|
for widget in calendar_frame.winfo_children():
|
|
widget.destroy()
|
|
|
|
# Update header
|
|
month_year_label.config(text=f"{month_names[display_month-1]} {display_year}")
|
|
|
|
# Get calendar data
|
|
cal = calendar.monthcalendar(display_year, display_month)
|
|
|
|
# Day headers
|
|
day_headers = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
for i, day in enumerate(day_headers):
|
|
label = ttk.Label(calendar_frame, text=day, font=("Arial", 9, "bold"))
|
|
label.grid(row=0, column=i, padx=1, pady=1, sticky="nsew")
|
|
|
|
# Calendar days
|
|
for week_num, week in enumerate(cal):
|
|
for day_num, day in enumerate(week):
|
|
if day == 0:
|
|
# Empty cell
|
|
label = ttk.Label(calendar_frame, text="")
|
|
label.grid(row=week_num+1, column=day_num, padx=1, pady=1, sticky="nsew")
|
|
else:
|
|
# Day button
|
|
def make_day_handler(day_value):
|
|
def select_day():
|
|
nonlocal selected_date
|
|
selected_date = date(display_year, display_month, day_value)
|
|
# Reset all buttons to normal calendar style
|
|
for widget in calendar_frame.winfo_children():
|
|
if isinstance(widget, ttk.Button):
|
|
widget.config(style="Calendar.TButton")
|
|
# Highlight selected day with prominent style
|
|
for widget in calendar_frame.winfo_children():
|
|
if isinstance(widget, ttk.Button) and widget.cget("text") == str(day_value):
|
|
widget.config(style="Selected.TButton")
|
|
return select_day
|
|
|
|
day_btn = ttk.Button(calendar_frame, text=str(day),
|
|
command=make_day_handler(day),
|
|
width=3, style="Calendar.TButton")
|
|
day_btn.grid(row=week_num+1, column=day_num, padx=1, pady=1, sticky="nsew")
|
|
|
|
# Check if this day should be highlighted
|
|
is_today = (display_year == current_date.year and
|
|
display_month == current_date.month and
|
|
day == current_date.day)
|
|
is_selected = (selected_date and
|
|
selected_date.year == display_year and
|
|
selected_date.month == display_month and
|
|
selected_date.day == day)
|
|
|
|
if is_selected:
|
|
day_btn.config(style="Selected.TButton")
|
|
elif is_today:
|
|
day_btn.config(style="Today.TButton")
|
|
|
|
# Navigation functions
|
|
def prev_year():
|
|
nonlocal display_year
|
|
display_year = max(1900, display_year - 1)
|
|
update_calendar()
|
|
|
|
def next_year():
|
|
nonlocal display_year
|
|
display_year = min(current_date.year, display_year + 1)
|
|
update_calendar()
|
|
|
|
def prev_month():
|
|
nonlocal display_month, display_year
|
|
if display_month > 1:
|
|
display_month -= 1
|
|
else:
|
|
display_month = 12
|
|
display_year = max(1900, display_year - 1)
|
|
update_calendar()
|
|
|
|
def next_month():
|
|
nonlocal display_month, display_year
|
|
if display_month < 12:
|
|
display_month += 1
|
|
else:
|
|
display_month = 1
|
|
display_year = min(current_date.year, display_year + 1)
|
|
update_calendar()
|
|
|
|
# Navigation buttons
|
|
prev_year_btn = ttk.Button(nav_frame, text="<<", width=3, command=prev_year)
|
|
prev_year_btn.pack(side=tk.LEFT, padx=(0, 2))
|
|
|
|
prev_month_btn = ttk.Button(nav_frame, text="<", width=3, command=prev_month)
|
|
prev_month_btn.pack(side=tk.LEFT, padx=(0, 5))
|
|
|
|
month_year_label = ttk.Label(nav_frame, text="", font=("Arial", 12, "bold"))
|
|
month_year_label.pack(side=tk.LEFT, padx=5)
|
|
|
|
next_month_btn = ttk.Button(nav_frame, text=">", width=3, command=next_month)
|
|
next_month_btn.pack(side=tk.LEFT, padx=(5, 2))
|
|
|
|
next_year_btn = ttk.Button(nav_frame, text=">>", width=3, command=next_year)
|
|
next_year_btn.pack(side=tk.LEFT)
|
|
|
|
# Calendar grid frame
|
|
calendar_frame = ttk.Frame(main_cal_frame)
|
|
calendar_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
|
|
|
|
# Configure grid weights
|
|
for i in range(7):
|
|
calendar_frame.columnconfigure(i, weight=1)
|
|
for i in range(7):
|
|
calendar_frame.rowconfigure(i, weight=1)
|
|
|
|
# Buttons frame
|
|
buttons_frame = ttk.Frame(main_cal_frame)
|
|
buttons_frame.pack(fill=tk.X)
|
|
|
|
def select_date():
|
|
"""Select the date and close calendar"""
|
|
if selected_date:
|
|
date_str = selected_date.strftime('%Y-%m-%d')
|
|
dob_var.set(date_str)
|
|
calendar_window.destroy()
|
|
else:
|
|
messagebox.showwarning("No Date Selected", "Please select a date from the calendar.")
|
|
|
|
def cancel_selection():
|
|
"""Cancel date selection"""
|
|
calendar_window.destroy()
|
|
|
|
# Buttons
|
|
ttk.Button(buttons_frame, text="Select", command=select_date).pack(side=tk.LEFT, padx=(0, 5))
|
|
ttk.Button(buttons_frame, text="Cancel", command=cancel_selection).pack(side=tk.LEFT)
|
|
|
|
# Initialize calendar
|
|
update_calendar()
|
|
|
|
def save_rename():
|
|
new_first = first_var.get().strip()
|
|
new_last = last_var.get().strip()
|
|
new_middle = middle_var.get().strip()
|
|
new_maiden = maiden_var.get().strip()
|
|
new_dob = dob_var.get().strip()
|
|
|
|
if not new_first and not new_last:
|
|
messagebox.showwarning("Invalid name", "At least one of First or Last name must be provided.")
|
|
return
|
|
|
|
# Check for duplicates in local data first (based on first and last name only)
|
|
for person in people_data:
|
|
if person['id'] != person_record['id'] and person['first_name'] == new_first and person['last_name'] == new_last:
|
|
display_name = f"{new_last}, {new_first}".strip(", ").strip()
|
|
messagebox.showwarning("Duplicate name", f"A person named '{display_name}' already exists.")
|
|
return
|
|
|
|
# Single database access - save to database
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute('UPDATE people SET first_name = ?, last_name = ?, middle_name = ?, maiden_name = ?, date_of_birth = ? WHERE id = ?',
|
|
(new_first, new_last, new_middle, new_maiden, new_dob, person_record['id']))
|
|
conn.commit()
|
|
|
|
# Update local data structure
|
|
person_record['first_name'] = new_first
|
|
person_record['last_name'] = new_last
|
|
person_record['middle_name'] = new_middle
|
|
person_record['maiden_name'] = new_maiden
|
|
person_record['date_of_birth'] = new_dob
|
|
|
|
# Recreate the full display name with all available information
|
|
name_parts = []
|
|
if new_first:
|
|
name_parts.append(new_first)
|
|
if new_middle:
|
|
name_parts.append(new_middle)
|
|
if new_last:
|
|
name_parts.append(new_last)
|
|
if new_maiden:
|
|
name_parts.append(f"({new_maiden})")
|
|
|
|
full_name = ' '.join(name_parts) if name_parts else "Unknown"
|
|
|
|
# Create detailed display with date of birth if available
|
|
display_name = full_name
|
|
if new_dob:
|
|
display_name += f" - Born: {new_dob}"
|
|
|
|
person_record['name'] = display_name
|
|
person_record['full_name'] = full_name
|
|
|
|
# Refresh list
|
|
current_selected_id = person_record['id']
|
|
populate_people_list()
|
|
# Reselect and refresh right panel header if needed
|
|
if selected_person_id == current_selected_id or selected_person_id is None:
|
|
# Find updated name
|
|
updated = next((p for p in people_data if p['id'] == current_selected_id), None)
|
|
if updated:
|
|
# Bold corresponding label
|
|
for child in people_list_inner.winfo_children():
|
|
# child is row frame: contains label and button
|
|
widgets = child.winfo_children()
|
|
if not widgets:
|
|
continue
|
|
lbl = widgets[0]
|
|
if isinstance(lbl, ttk.Label) and lbl.cget('text').startswith(updated['name'] + " ("):
|
|
lbl.config(font=("Arial", 10, "bold"))
|
|
break
|
|
# Update right panel header by re-showing faces
|
|
show_person_faces(updated['id'], updated['name'])
|
|
|
|
def cancel_edit():
|
|
# Rebuild the row back to label + edit
|
|
for w in row_frame.winfo_children():
|
|
w.destroy()
|
|
rebuild_row(row_frame, person_record, row_index)
|
|
|
|
save_btn = ttk.Button(row_frame, text="💾", width=3, command=save_rename)
|
|
save_btn.pack(side=tk.LEFT, padx=(5, 0))
|
|
cancel_btn = ttk.Button(row_frame, text="✖", width=3, command=cancel_edit)
|
|
cancel_btn.pack(side=tk.LEFT, padx=(5, 0))
|
|
|
|
# Configure custom disabled button style for better visibility
|
|
style = ttk.Style()
|
|
style.configure("Disabled.TButton",
|
|
background="#d3d3d3", # Light gray background
|
|
foreground="#808080", # Dark gray text
|
|
relief="flat",
|
|
borderwidth=1)
|
|
|
|
def validate_save_button():
|
|
"""Enable/disable save button based on required fields"""
|
|
first_val = first_var.get().strip()
|
|
last_val = last_var.get().strip()
|
|
dob_val = dob_var.get().strip()
|
|
|
|
# Enable save button only if both name fields and date of birth are provided
|
|
has_first = bool(first_val)
|
|
has_last = bool(last_val)
|
|
has_dob = bool(dob_val)
|
|
|
|
if has_first and has_last and has_dob:
|
|
save_btn.config(state="normal")
|
|
# Reset to normal styling when enabled
|
|
save_btn.config(style="TButton")
|
|
else:
|
|
save_btn.config(state="disabled")
|
|
# Apply custom disabled styling for better visibility
|
|
save_btn.config(style="Disabled.TButton")
|
|
|
|
# Set up validation callbacks for all input fields
|
|
first_var.trace('w', lambda *args: validate_save_button())
|
|
last_var.trace('w', lambda *args: validate_save_button())
|
|
middle_var.trace('w', lambda *args: validate_save_button())
|
|
maiden_var.trace('w', lambda *args: validate_save_button())
|
|
dob_var.trace('w', lambda *args: validate_save_button())
|
|
|
|
# Initial validation
|
|
validate_save_button()
|
|
|
|
# Keyboard shortcuts (only work when save button is enabled)
|
|
def try_save():
|
|
if save_btn.cget('state') == 'normal':
|
|
save_rename()
|
|
|
|
first_entry.bind('<Return>', lambda e: try_save())
|
|
last_entry.bind('<Return>', lambda e: try_save())
|
|
middle_entry.bind('<Return>', lambda e: try_save())
|
|
maiden_entry.bind('<Return>', lambda e: try_save())
|
|
dob_entry.bind('<Return>', lambda e: try_save())
|
|
first_entry.bind('<Escape>', lambda e: cancel_edit())
|
|
last_entry.bind('<Escape>', lambda e: cancel_edit())
|
|
middle_entry.bind('<Escape>', lambda e: cancel_edit())
|
|
maiden_entry.bind('<Escape>', lambda e: cancel_edit())
|
|
dob_entry.bind('<Escape>', lambda e: cancel_edit())
|
|
|
|
def rebuild_row(row_frame, p, i):
|
|
# Edit button (on the left)
|
|
edit_btn = ttk.Button(row_frame, text="✏️", width=3, command=lambda r=row_frame, pr=p, ii=i: start_edit_person(r, pr, ii))
|
|
edit_btn.pack(side=tk.LEFT, padx=(0, 5))
|
|
# Add tooltip to edit button
|
|
ToolTip(edit_btn, "Update name")
|
|
# Label (clickable) - takes remaining space
|
|
name_lbl = ttk.Label(row_frame, text=f"{p['name']} ({p['count']})", font=("Arial", 10))
|
|
name_lbl.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
|
name_lbl.bind("<Button-1>", make_click_handler(p['id'], p['name'], i))
|
|
name_lbl.config(cursor="hand2")
|
|
# Bold if selected
|
|
if (selected_person_id is None and i == 0) or (selected_person_id == p['id']):
|
|
name_lbl.config(font=("Arial", 10, "bold"))
|
|
|
|
# Build row contents with edit button
|
|
rebuild_row(row, row_person, row_idx)
|
|
|
|
# Initial load
|
|
load_people()
|
|
populate_people_list()
|
|
|
|
# Show first person's faces by default and mark selected
|
|
if people_data:
|
|
selected_person_id = people_data[0]['id']
|
|
show_person_faces(people_data[0]['id'], people_data[0]['name'])
|
|
|
|
# Control buttons
|
|
control_frame = ttk.Frame(main_frame)
|
|
control_frame.grid(row=3, column=0, columnspan=2, pady=(10, 0), sticky=tk.E)
|
|
|
|
def on_quit():
|
|
nonlocal window_destroyed
|
|
# Warn if there are pending unmatched faces (unsaved changes)
|
|
try:
|
|
if unmatched_faces:
|
|
result = messagebox.askyesnocancel(
|
|
"Unsaved Changes",
|
|
"You have pending changes that are not saved.\n\n"
|
|
"Yes: Save and quit\n"
|
|
"No: Quit without saving\n"
|
|
"Cancel: Return to window"
|
|
)
|
|
if result is None:
|
|
# Cancel
|
|
return
|
|
if result is True:
|
|
# Save then quit
|
|
on_save_all_changes()
|
|
# If result is False, fall through and quit without saving
|
|
except Exception:
|
|
# If any issue occurs, proceed to normal quit
|
|
pass
|
|
on_closing()
|
|
if not window_destroyed:
|
|
window_destroyed = True
|
|
try:
|
|
root.destroy()
|
|
except tk.TclError:
|
|
pass # Window already destroyed
|
|
|
|
def on_save_all_changes():
|
|
# Use global unmatched_faces set; commit all across people
|
|
nonlocal unmatched_faces
|
|
if not unmatched_faces:
|
|
messagebox.showinfo("Nothing to Save", "There are no pending changes to save.")
|
|
return
|
|
result = messagebox.askyesno(
|
|
"Confirm Save",
|
|
f"Unlink {len(unmatched_faces)} face(s) across all people?\n\nThis will make them unidentified."
|
|
)
|
|
if not result:
|
|
return
|
|
with self.db.get_db_connection() as conn:
|
|
cursor = conn.cursor()
|
|
for face_id in unmatched_faces:
|
|
cursor.execute('UPDATE faces SET person_id = NULL WHERE id = ?', (face_id,))
|
|
conn.commit()
|
|
count = len(unmatched_faces)
|
|
unmatched_faces.clear()
|
|
# Refresh people list and right panel for current selection
|
|
load_people()
|
|
populate_people_list()
|
|
if current_person_id is not None and current_person_name:
|
|
show_person_faces(current_person_id, current_person_name)
|
|
messagebox.showinfo("Changes Saved", f"Successfully unlinked {count} face(s).")
|
|
|
|
quit_btn = ttk.Button(control_frame, text="❌ Quit", command=on_quit)
|
|
quit_btn.pack(side=tk.RIGHT)
|
|
save_btn_bottom = ttk.Button(control_frame, text="💾 Save changes", command=on_save_all_changes)
|
|
save_btn_bottom.pack(side=tk.RIGHT, padx=(0, 10))
|
|
|
|
# Show the window
|
|
try:
|
|
root.deiconify()
|
|
root.lift()
|
|
root.focus_force()
|
|
except tk.TclError:
|
|
# Window was destroyed before we could show it
|
|
return 0
|
|
|
|
# Main event loop
|
|
try:
|
|
root.mainloop()
|
|
except tk.TclError:
|
|
pass # Window was destroyed
|
|
|
|
return 0
|
|
|
|
|