#!/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("", self.on_enter) self.widget.bind("", 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( "", 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( "", 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("", 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('', 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("", lambda e, fid=face_id: unmatch_face(fid)) # Hover highlight: change bg, show white outline, and hand cursor x_canvas.bind("", lambda e, c=x_canvas: c.configure(bg="#cc0000", highlightthickness=1, highlightbackground="white", cursor="hand2")) x_canvas.bind("", 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('', lambda e: try_save()) last_entry.bind('', lambda e: try_save()) middle_entry.bind('', lambda e: try_save()) maiden_entry.bind('', lambda e: try_save()) dob_entry.bind('', lambda e: try_save()) first_entry.bind('', lambda e: cancel_edit()) last_entry.bind('', lambda e: cancel_edit()) middle_entry.bind('', lambda e: cancel_edit()) maiden_entry.bind('', lambda e: cancel_edit()) dob_entry.bind('', 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("", 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 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).") 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)) quit_btn = ttk.Button(control_frame, text="❌ Quit", command=on_quit) quit_btn.pack(side=tk.RIGHT) # 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