#!/usr/bin/env python3 """ Integrated Modify Panel for PunimTag Dashboard Embeds the full modify identified GUI functionality into the dashboard frame """ import os import tkinter as tk from tkinter import ttk, messagebox from PIL import Image, ImageTk from typing import List, Dict, Tuple, Optional from config import DEFAULT_FACE_TOLERANCE from database import DatabaseManager from face_processing import FaceProcessor from gui_core import GUICore class ToolTip: """Simple tooltip implementation""" 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 class ModifyPanel: """Integrated modify panel that embeds the full modify identified GUI functionality into the dashboard""" def __init__(self, parent_frame: ttk.Frame, db_manager: DatabaseManager, face_processor: FaceProcessor, gui_core: GUICore, verbose: int = 0): """Initialize the modify panel""" self.parent_frame = parent_frame self.db = db_manager self.face_processor = face_processor self.gui_core = gui_core self.verbose = verbose # Panel state self.is_active = False self.temp_crops = [] self.right_panel_images = [] # Keep PhotoImage refs alive self.selected_person_id = None # Track unmatched faces (temporary changes) self.unmatched_faces = set() # All face IDs unmatched across people (for global save) self.unmatched_by_person = {} # person_id -> set(face_id) for per-person undo self.original_faces_data = [] # store original faces data for potential future use # People data self.people_data = [] # list of dicts: {id, name, count, first_name, last_name} self.people_filtered = None # filtered subset based on last name search self.current_person_id = None self.current_person_name = "" self.resize_job = None # GUI components self.components = {} self.main_frame = None def create_panel(self) -> ttk.Frame: """Create the modify panel with all GUI components""" self.main_frame = ttk.Frame(self.parent_frame) # Configure grid weights for full screen responsiveness self.main_frame.columnconfigure(0, weight=1) # Left panel self.main_frame.columnconfigure(1, weight=2) # Right panel self.main_frame.rowconfigure(1, weight=1) # Main panels row - expandable # Create all GUI components self._create_gui_components() # Create main content panels self._create_main_panels() return self.main_frame def _create_gui_components(self): """Create all GUI components for the modify interface""" # Search controls (Last Name) with label under the input (match auto-match style) self.components['last_name_search_var'] = tk.StringVar() # Control buttons self.components['quit_btn'] = None self.components['save_btn_bottom'] = None def _create_main_panels(self): """Create the main left and right panels""" # Left panel: People list self.components['people_frame'] = ttk.LabelFrame(self.main_frame, text="People", padding="10") self.components['people_frame'].grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 8)) self.components['people_frame'].columnconfigure(0, weight=1) # Right panel: Faces for selected person self.components['faces_frame'] = ttk.LabelFrame(self.main_frame, text="Faces", padding="10") self.components['faces_frame'].grid(row=1, column=1, sticky=(tk.W, tk.E, tk.N, tk.S)) self.components['faces_frame'].columnconfigure(0, weight=1) self.components['faces_frame'].rowconfigure(0, weight=1) # Create left panel content self._create_left_panel_content() # Create right panel content self._create_right_panel_content() # Create control buttons self._create_control_buttons() def _create_left_panel_content(self): """Create the left panel content for people list""" people_frame = self.components['people_frame'] # Search controls 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=self.components['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, command=self.apply_last_name_filter) search_btn.pack(side=tk.LEFT, padx=(0, 5)) clear_btn = ttk.Button(buttons_row, text="Clear", width=6, command=self.clear_last_name_filter) 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 list with scrollbar people_canvas = tk.Canvas(people_frame, bg='white') people_scrollbar = ttk.Scrollbar(people_frame, orient="vertical", command=people_canvas.yview) self.components['people_list_inner'] = ttk.Frame(people_canvas) people_canvas.create_window((0, 0), window=self.components['people_list_inner'], anchor="nw") people_canvas.configure(yscrollcommand=people_scrollbar.set) self.components['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) # Store canvas reference self.components['people_canvas'] = people_canvas # Bind Enter key for search search_entry.bind('', lambda e: self.apply_last_name_filter()) def _create_right_panel_content(self): """Create the right panel content for faces display""" faces_frame = self.components['faces_frame'] # Style configuration style = ttk.Style() canvas_bg_color = style.lookup('TFrame', 'background') or '#d9d9d9' self.components['faces_canvas'] = tk.Canvas(faces_frame, bg=canvas_bg_color, highlightthickness=0) faces_scrollbar = ttk.Scrollbar(faces_frame, orient="vertical", command=self.components['faces_canvas'].yview) self.components['faces_inner'] = ttk.Frame(self.components['faces_canvas']) self.components['faces_canvas'].create_window((0, 0), window=self.components['faces_inner'], anchor="nw") self.components['faces_canvas'].configure(yscrollcommand=faces_scrollbar.set) self.components['faces_inner'].bind( "", lambda e: self.components['faces_canvas'].configure(scrollregion=self.components['faces_canvas'].bbox("all")) ) # Bind resize handler for responsive face grid self.components['faces_canvas'].bind("", self.on_faces_canvas_resize) self.components['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)) def _create_control_buttons(self): """Create control buttons at the bottom""" # Control buttons control_frame = ttk.Frame(self.main_frame) control_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0), sticky=tk.E) self.components['quit_btn'] = ttk.Button(control_frame, text="❌ Exit Edit Identified", command=self.on_quit) self.components['quit_btn'].pack(side=tk.RIGHT) self.components['save_btn_bottom'] = ttk.Button(control_frame, text="💾 Save changes", command=self.on_save_all_changes, state="disabled") self.components['save_btn_bottom'].pack(side=tk.RIGHT, padx=(0, 10)) self.components['undo_btn'] = ttk.Button(control_frame, text="↶ Undo changes", command=self.undo_changes, state="disabled") self.components['undo_btn'].pack(side=tk.RIGHT, padx=(0, 10)) def on_faces_canvas_resize(self, event): """Handle canvas resize for responsive face grid""" if self.current_person_id is None: return # Debounce re-render on resize try: if self.resize_job is not None: self.main_frame.after_cancel(self.resize_job) except Exception: pass self.resize_job = self.main_frame.after(150, lambda: self.show_person_faces(self.current_person_id, self.current_person_name)) def load_people(self): """Load people from database with counts""" 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 """ ) self.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}" self.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: self.apply_last_name_filter() except Exception: pass def apply_last_name_filter(self): """Apply last name filter to people list""" query = self.components['last_name_search_var'].get().strip().lower() if query: self.people_filtered = [p for p in self.people_data if p.get('last_name', '').lower().find(query) != -1] else: self.people_filtered = None self.populate_people_list() def clear_last_name_filter(self): """Clear the last name filter""" self.components['last_name_search_var'].set("") self.people_filtered = None self.populate_people_list() def populate_people_list(self): """Populate the people list with current data""" # Clear existing widgets for widget in self.components['people_list_inner'].winfo_children(): widget.destroy() # Use filtered data if available, otherwise use all data people_to_show = self.people_filtered if self.people_filtered is not None else self.people_data for i, person in enumerate(people_to_show): row_frame = ttk.Frame(self.components['people_list_inner']) row_frame.pack(fill=tk.X, padx=2, pady=1) # Edit button (on the left) edit_btn = ttk.Button(row_frame, text="✏️", width=3, command=lambda p=person: self.start_edit_person(p)) 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"{person['name']} ({person['count']})", font=("Arial", 10)) name_lbl.pack(side=tk.LEFT, fill=tk.X, expand=True) name_lbl.bind("", lambda e, p=person: self.show_person_faces(p['id'], p['name'])) name_lbl.config(cursor="hand2") # Bold if selected if (self.selected_person_id is None and i == 0) or (self.selected_person_id == person['id']): name_lbl.config(font=("Arial", 10, "bold")) def start_edit_person(self, person_record): """Start editing a person's information""" # Create a new window for editing edit_window = tk.Toplevel(self.main_frame) edit_window.title(f"Edit {person_record['name']}") edit_window.geometry("400x300") edit_window.transient(self.main_frame) edit_window.grab_set() # Center the window edit_window.update_idletasks() x = (edit_window.winfo_screenwidth() // 2) - (edit_window.winfo_width() // 2) y = (edit_window.winfo_screenheight() // 2) - (edit_window.winfo_height() // 2) edit_window.geometry(f"+{x}+{y}") # Create form fields form_frame = ttk.Frame(edit_window, padding="20") form_frame.pack(fill=tk.BOTH, expand=True) # First name ttk.Label(form_frame, text="First name:").grid(row=0, column=0, sticky=tk.W, pady=5) first_name_var = tk.StringVar(value=person_record.get('first_name', '')) first_entry = ttk.Entry(form_frame, textvariable=first_name_var, width=30) first_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), pady=5) # Last name ttk.Label(form_frame, text="Last name:").grid(row=1, column=0, sticky=tk.W, pady=5) last_name_var = tk.StringVar(value=person_record.get('last_name', '')) last_entry = ttk.Entry(form_frame, textvariable=last_name_var, width=30) last_entry.grid(row=1, column=1, sticky=(tk.W, tk.E), pady=5) # Middle name ttk.Label(form_frame, text="Middle name:").grid(row=2, column=0, sticky=tk.W, pady=5) middle_name_var = tk.StringVar(value=person_record.get('middle_name', '')) middle_entry = ttk.Entry(form_frame, textvariable=middle_name_var, width=30) middle_entry.grid(row=2, column=1, sticky=(tk.W, tk.E), pady=5) # Maiden name ttk.Label(form_frame, text="Maiden name:").grid(row=3, column=0, sticky=tk.W, pady=5) maiden_name_var = tk.StringVar(value=person_record.get('maiden_name', '')) maiden_entry = ttk.Entry(form_frame, textvariable=maiden_name_var, width=30) maiden_entry.grid(row=3, column=1, sticky=(tk.W, tk.E), pady=5) # Date of birth ttk.Label(form_frame, text="Date of birth:").grid(row=4, column=0, sticky=tk.W, pady=5) dob_var = tk.StringVar(value=person_record.get('date_of_birth', '')) dob_entry = ttk.Entry(form_frame, textvariable=dob_var, width=30, state='readonly') dob_entry.grid(row=4, column=1, sticky=(tk.W, tk.E), pady=5) # Calendar button for date of birth def open_dob_calendar(): selected_date = self.gui_core.create_calendar_dialog(edit_window, "Select Date of Birth", dob_var.get()) if selected_date is not None: dob_var.set(selected_date) dob_calendar_btn = ttk.Button(form_frame, text="📅", width=3, command=open_dob_calendar) dob_calendar_btn.grid(row=4, column=2, padx=(5, 0), pady=5) # Configure grid weights form_frame.columnconfigure(1, weight=1) # Buttons button_frame = ttk.Frame(edit_window) button_frame.pack(fill=tk.X, padx=20, pady=10) def save_rename(): """Save the renamed person""" try: 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 = ? """, ( first_name_var.get().strip(), last_name_var.get().strip(), middle_name_var.get().strip(), maiden_name_var.get().strip(), dob_var.get().strip(), person_record['id'] )) conn.commit() # Refresh the people list self.load_people() self.populate_people_list() # Close the edit window edit_window.destroy() messagebox.showinfo("Success", "Person information updated successfully.") except Exception as e: messagebox.showerror("Error", f"Failed to update person: {e}") def cancel_edit(): """Cancel editing""" edit_window.destroy() save_btn = ttk.Button(button_frame, text="Save", command=save_rename) save_btn.pack(side=tk.LEFT, padx=(0, 10)) cancel_btn = ttk.Button(button_frame, text="Cancel", command=cancel_edit) cancel_btn.pack(side=tk.LEFT) # Focus on first name field first_entry.focus_set() # Add keyboard shortcuts 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()) # Add validation def validate_save_button(): first_name = first_name_var.get().strip() last_name = last_name_var.get().strip() if first_name and last_name: save_btn.config(state='normal') else: save_btn.config(state='disabled') # Bind validation to all fields first_name_var.trace('w', lambda *args: validate_save_button()) last_name_var.trace('w', lambda *args: validate_save_button()) middle_name_var.trace('w', lambda *args: validate_save_button()) maiden_name_var.trace('w', lambda *args: validate_save_button()) dob_var.trace('w', lambda *args: validate_save_button()) # Initial validation validate_save_button() def show_person_faces(self, person_id, person_name): """Show faces for the selected person""" self.current_person_id = person_id self.current_person_name = person_name self.selected_person_id = person_id # Clear existing face widgets for widget in self.components['faces_inner'].winfo_children(): widget.destroy() # Load faces for this person with self.db.get_db_connection() as conn: cursor = conn.cursor() cursor.execute(""" SELECT f.id, f.photo_id, p.path, p.filename, f.location FROM faces f JOIN photos p ON f.photo_id = p.id WHERE f.person_id = ? ORDER BY p.filename """, (person_id,)) faces = cursor.fetchall() # Filter out unmatched faces visible_faces = [face for face in faces if face[0] not in self.unmatched_faces] if not visible_faces: if not faces: no_faces_label = ttk.Label(self.components['faces_inner'], text="No faces found for this person", font=("Arial", 12)) else: no_faces_label = ttk.Label(self.components['faces_inner'], text="All faces unmatched", font=("Arial", 12)) no_faces_label.pack(pady=20) return # Display faces in a grid self._display_faces_grid(visible_faces) # Update people list to show selection self.populate_people_list() # Update button states based on unmatched faces self._update_undo_button_state() self._update_save_button_state() def _display_faces_grid(self, faces): """Display faces in a responsive grid layout""" # Calculate grid dimensions based on canvas width canvas_width = self.components['faces_canvas'].winfo_width() if canvas_width < 100: # Canvas not yet rendered canvas_width = 400 # Default width face_size = 80 padding = 10 faces_per_row = max(1, (canvas_width - padding) // (face_size + padding)) # Clear existing images self.right_panel_images.clear() for i, (face_id, photo_id, photo_path, filename, location) in enumerate(faces): row = i // faces_per_row col = i % faces_per_row # Create face frame face_frame = ttk.Frame(self.components['faces_inner']) face_frame.grid(row=row, column=col, padx=5, pady=5, sticky=(tk.W, tk.E, tk.N, tk.S)) # Face image try: face_crop_path = self.face_processor._extract_face_crop(photo_path, location, face_id) if face_crop_path and os.path.exists(face_crop_path): self.temp_crops.append(face_crop_path) image = Image.open(face_crop_path) image.thumbnail((face_size, face_size), Image.Resampling.LANCZOS) photo = ImageTk.PhotoImage(image) self.right_panel_images.append(photo) # Keep reference # Create canvas for face image face_canvas = tk.Canvas(face_frame, width=face_size, height=face_size, highlightthickness=0) face_canvas.pack() face_canvas.create_image(face_size//2, face_size//2, image=photo, anchor=tk.CENTER) # Add photo icon self.gui_core.create_photo_icon(face_canvas, photo_path, icon_size=15, face_x=0, face_y=0, face_width=face_size, face_height=face_size, canvas_width=face_size, canvas_height=face_size) # Unmatch button unmatch_btn = ttk.Button(face_frame, text="Unmatch", command=lambda fid=face_id: self.unmatch_face(fid)) unmatch_btn.pack(pady=2) else: # Placeholder for missing face crop placeholder_label = ttk.Label(face_frame, text=f"Face {face_id}", font=("Arial", 8)) placeholder_label.pack() except Exception as e: print(f"Error displaying face {face_id}: {e}") # Placeholder for error error_label = ttk.Label(face_frame, text=f"Error {face_id}", font=("Arial", 8), foreground="red") error_label.pack() def unmatch_face(self, face_id): """Unmatch a face from its person""" if face_id not in self.unmatched_faces: self.unmatched_faces.add(face_id) if self.current_person_id not in self.unmatched_by_person: self.unmatched_by_person[self.current_person_id] = set() self.unmatched_by_person[self.current_person_id].add(face_id) print(f"Face {face_id} marked for unmatching") # Immediately refresh the display to hide the unmatched face if self.current_person_id: self.show_person_faces(self.current_person_id, self.current_person_name) # Update button states self._update_undo_button_state() self._update_save_button_state() def _update_undo_button_state(self): """Update the undo button state based on unmatched faces for current person""" if 'undo_btn' in self.components: current_has_unmatched = bool(self.unmatched_by_person.get(self.current_person_id)) if current_has_unmatched: self.components['undo_btn'].config(state="normal") else: self.components['undo_btn'].config(state="disabled") def _update_save_button_state(self): """Update the save button state based on whether there are any unmatched faces to save""" if 'save_btn_bottom' in self.components: if self.unmatched_faces: self.components['save_btn_bottom'].config(state="normal") else: self.components['save_btn_bottom'].config(state="disabled") def undo_changes(self): """Undo all unmatched faces for the current person""" if self.current_person_id and self.current_person_id in self.unmatched_by_person: # Remove faces for current person from unmatched sets person_faces = self.unmatched_by_person[self.current_person_id] self.unmatched_faces -= person_faces del self.unmatched_by_person[self.current_person_id] # Refresh the display to show the restored faces if self.current_person_id: self.show_person_faces(self.current_person_id, self.current_person_name) # Update button states self._update_undo_button_state() self._update_save_button_state() messagebox.showinfo("Undo", f"Undid changes for {len(person_faces)} face(s).") else: messagebox.showinfo("No Changes", "No changes to undo for this person.") def on_quit(self): """Handle quit button click""" # Check for unsaved changes if self.unmatched_faces: result = messagebox.askyesnocancel( "Unsaved Changes", f"You have {len(self.unmatched_faces)} unsaved changes.\n\n" "Do you want to save them before quitting?\n\n" "• Yes: Save changes and quit\n" "• No: Quit without saving\n" "• Cancel: Return to modify" ) if result is True: # Yes - Save and quit self.on_save_all_changes() elif result is False: # No - Quit without saving pass else: # Cancel - Don't quit return # Clean up and deactivate self._cleanup() self.is_active = False def on_save_all_changes(self): """Save all unmatched faces to database""" if not self.unmatched_faces: messagebox.showinfo("No Changes", "No changes to save.") return try: with self.db.get_db_connection() as conn: cursor = conn.cursor() count = 0 for face_id in self.unmatched_faces: cursor.execute("UPDATE faces SET person_id = NULL WHERE id = ?", (face_id,)) count += 1 conn.commit() # Clear the unmatched faces self.unmatched_faces.clear() self.unmatched_by_person.clear() # Refresh the display if self.current_person_id: self.show_person_faces(self.current_person_id, self.current_person_name) # Update button states self._update_undo_button_state() self._update_save_button_state() messagebox.showinfo("Changes Saved", f"Successfully unlinked {count} face(s).") except Exception as e: messagebox.showerror("Error", f"Failed to save changes: {e}") def _cleanup(self): """Clean up resources""" # Clear temporary crops for crop_path in self.temp_crops: try: if os.path.exists(crop_path): os.remove(crop_path) except Exception: pass self.temp_crops.clear() # Clear right panel images self.right_panel_images.clear() # Clear state self.unmatched_faces.clear() self.unmatched_by_person.clear() self.original_faces_data.clear() self.people_data.clear() self.people_filtered = None self.current_person_id = None self.current_person_name = "" self.selected_person_id = None def activate(self): """Activate the panel""" self.is_active = True # Initial load self.load_people() self.populate_people_list() # Show first person's faces by default and mark selected if self.people_data: self.selected_person_id = self.people_data[0]['id'] self.show_person_faces(self.people_data[0]['id'], self.people_data[0]['name']) def deactivate(self): """Deactivate the panel""" if self.is_active: self._cleanup() self.is_active = False def update_layout(self): """Update panel layout for responsiveness""" if hasattr(self, 'components') and 'faces_canvas' in self.components: # Update faces canvas scroll region canvas = self.components['faces_canvas'] canvas.update_idletasks() canvas.configure(scrollregion=canvas.bbox("all"))