punimtag/modify_identified_gui.py
tanyar09 70cb11adbd Refactor AutoMatchGUI and ModifyIdentifiedGUI for improved unsaved changes handling
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.
2025-10-06 12:25:44 -04:00

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