diff --git a/README_UNIFIED_DASHBOARD.md b/README_UNIFIED_DASHBOARD.md index 0480aaf..5227098 100644 --- a/README_UNIFIED_DASHBOARD.md +++ b/README_UNIFIED_DASHBOARD.md @@ -56,7 +56,7 @@ python3 photo_tagger.py dashboard # 👤 Identify - Identify people in photos (✅ Fully Functional) # 🔗 Auto-Match - Find matching faces automatically (✅ Fully Functional) # 🔎 Search - Find photos by person name (✅ Fully Functional) -# ✏️ Modify - Edit face identifications (🚧 Coming Soon) +# ✏️ Modify - Edit face identifications (✅ Fully Functional) # 🏷️ Tags - Manage photo tags (🚧 Coming Soon) ``` @@ -191,11 +191,15 @@ The auto-match feature works in a **person-centric** way: - **Tag Management**: Add and remove tags from selected photos - **Responsive Layout**: Adapts to window resizing with proper scrolling -#### **✏️ Modify Panel** *(Coming Soon)* -- **Review Identifications**: View all identified people -- **Edit Names**: Rename people across all photos -- **Unmatch Faces**: Temporarily remove face associations -- **Bulk Operations**: Handle multiple changes efficiently +#### **✏️ Modify Panel** *(Fully Functional)* +- **Review Identifications**: View all identified people with face counts +- **Edit Names**: Rename people with full name fields (first, last, middle, maiden, date of birth) +- **Unmatch Faces**: Temporarily remove face associations with visual confirmation +- **Bulk Operations**: Handle multiple changes efficiently with undo functionality +- **Search People**: Filter people by last name for large databases +- **Visual Calendar**: Date of birth selection with intuitive calendar interface +- **Responsive Layout**: Face grid adapts to window resizing +- **Unsaved Changes Protection**: Warns before losing unsaved work #### **🏷️ Tags Panel** *(Coming Soon)* - **File Explorer Interface**: Browse photos like a file manager @@ -354,7 +358,7 @@ python3 photo_tagger.py dashboard # 👤 Identify - Identify people (✅ Fully Functional) # 🔗 Auto-Match - Find matches (✅ Fully Functional) # 🔎 Search - Find photos (✅ Fully Functional) -# ✏️ Modify - Edit identifications (🚧 Coming Soon) +# ✏️ Modify - Edit identifications (✅ Fully Functional) # 🏷️ Tags - Manage tags (🚧 Coming Soon) # Legacy command line usage @@ -385,7 +389,7 @@ python3 photo_tagger.py stats - [x] Identify panel integration (fully functional) - [x] Auto-Match panel integration (fully functional) - [x] Search panel integration (fully functional) -- [ ] Modify panel integration +- [x] Modify panel integration (fully functional) - [ ] Tags panel integration ### Phase 3: Web Migration Preparation diff --git a/dashboard_gui.py b/dashboard_gui.py index a3c5276..acda0b2 100644 --- a/dashboard_gui.py +++ b/dashboard_gui.py @@ -12,12 +12,12 @@ from typing import Dict, Optional, Callable from gui_core import GUICore from identify_panel import IdentifyPanel +from modify_panel import ModifyPanel from auto_match_panel import AutoMatchPanel from search_stats import SearchStats from database import DatabaseManager from tag_management import TagManager - - +from face_processing import FaceProcessor class SearchPanel: """Search panel with full functionality from search_gui.py""" @@ -1315,7 +1315,25 @@ class SearchPanel: ttk.Button(bottom_frame, text="Close", command=close_dialog).pack(side=tk.RIGHT) refresh_tag_list() +""" +Unified Dashboard GUI for PunimTag features +Designed with web migration in mind - single window with menu bar and content area +""" +import os +import threading +import tkinter as tk +from tkinter import ttk, messagebox +from typing import Dict, Optional, Callable + +from gui_core import GUICore +from identify_panel import IdentifyPanel +from modify_panel import ModifyPanel +from auto_match_panel import AutoMatchPanel +from search_stats import SearchStats +from database import DatabaseManager +from tag_management import TagManager +from face_processing import FaceProcessor class DashboardGUI: """Unified Dashboard with menu bar and content area for all features. @@ -1419,7 +1437,7 @@ class DashboardGUI: ("👤 Identify", "identify", "Identify faces in photos"), ("🔗 Auto-Match", "auto_match", "Find and confirm matching faces"), ("🔎 Search", "search", "Search photos by person name"), - ("✏️ Modify", "modify", "View and modify identified faces"), + ("✏️ Edit Identified", "modify", "View and modify identified faces"), ("🏷️ Tags", "tags", "Manage photo tags"), ] @@ -1487,6 +1505,9 @@ class DashboardGUI: # Deactivate auto-match panel if it's active if hasattr(self, 'auto_match_panel') and self.auto_match_panel and self.current_panel == "auto_match": self.auto_match_panel.deactivate() + # Deactivate modify panel if it's active + if hasattr(self, 'modify_panel') and self.modify_panel and self.current_panel == "modify": + self.modify_panel.deactivate() # Show new panel - expand both horizontally and vertically self.panels[panel_name].grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=15, pady=15) @@ -1497,6 +1518,8 @@ class DashboardGUI: self.identify_panel.activate() elif panel_name == "auto_match" and hasattr(self, 'auto_match_panel') and self.auto_match_panel: self.auto_match_panel.activate() + elif panel_name == "modify" and hasattr(self, 'modify_panel') and self.modify_panel: + self.modify_panel.activate() # Update status self.status_label.config(text=f"Viewing: {panel_name.replace('_', ' ').title()}") @@ -1804,25 +1827,43 @@ class DashboardGUI: return panel def _create_modify_panel(self) -> ttk.Frame: - """Create the modify panel (placeholder)""" + """Create the modify panel with full functionality""" panel = ttk.Frame(self.content_frame) # Configure panel grid for responsiveness panel.columnconfigure(0, weight=1) - # Remove weight=1 from row to prevent empty space expansion + # Configure rows: title (row 0) fixed, modify content (row 1) should expand + panel.rowconfigure(0, weight=0) + panel.rowconfigure(1, weight=1) + # Title with larger font for full screen title_label = tk.Label(panel, text="✏️ Modify Identified", font=("Arial", 24, "bold")) title_label.grid(row=0, column=0, sticky=tk.W, pady=(0, 20)) - placeholder_frame = ttk.LabelFrame(panel, text="Coming Soon", padding="20") - placeholder_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N)) - placeholder_frame.columnconfigure(0, weight=1) - # Remove weight=1 to prevent vertical centering - # placeholder_frame.rowconfigure(0, weight=1) - - placeholder_text = "Modify functionality will be integrated here." - placeholder_label = tk.Label(placeholder_frame, text=placeholder_text, font=("Arial", 14)) - placeholder_label.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N)) + # Create the modify panel if we have the required dependencies + if self.db_manager and self.face_processor: + self.modify_panel = ModifyPanel(panel, self.db_manager, self.face_processor, self.gui_core) + modify_frame = self.modify_panel.create_panel() + modify_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + else: + # Fallback placeholder if dependencies are not available + placeholder_frame = ttk.LabelFrame(panel, text="Configuration Required", padding="20") + placeholder_frame.grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N)) + placeholder_frame.columnconfigure(0, weight=1) + + placeholder_text = ( + "Modify panel requires database and face processor to be configured.\n\n" + "This will contain the full modify interface\n" + "currently available in the separate Modify window.\n\n" + "Features will include:\n" + "• View and edit identified people\n" + "• Rename people across all photos\n" + "• Unmatch faces from people\n" + "• Bulk operations for face management" + ) + + placeholder_label = tk.Label(placeholder_frame, text=placeholder_text, font=("Arial", 14), justify=tk.LEFT) + placeholder_label.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) return panel diff --git a/modify_panel.py b/modify_panel.py new file mode 100644 index 0000000..ccd0d3d --- /dev/null +++ b/modify_panel.py @@ -0,0 +1,733 @@ +#!/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"))