diff --git a/README.md b/README.md index 6fb7063..cc524f7 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,8 @@ python3 photo_tagger.py auto-match --auto --show-faces - 🖼️ **Visual Face Display** - See individual face crops in the GUI - 📝 **Separate Name Fields** - Dedicated text input fields for first name, last name, middle name, and maiden name - 🎯 **Direct Field Storage** - Names are stored directly in separate fields for maximum reliability +- 🔤 **Last Name Autocomplete** - Smart autocomplete for last names with live filtering as you type +- ⭐ **Required Field Indicators** - Red asterisks (*) mark required fields (first name, last name, date of birth) - ☑️ **Compare with Similar Faces** - Compare current face with similar unidentified faces - 🎨 **Modern Interface** - Clean, intuitive GUI with buttons and input fields - 💾 **Window Size Memory** - Remembers your preferred window size diff --git a/photo_tagger.py b/photo_tagger.py index a44af24..2f3451d 100644 --- a/photo_tagger.py +++ b/photo_tagger.py @@ -483,6 +483,10 @@ class PhotoTagger: cursor.execute('SELECT first_name, last_name, date_of_birth FROM people ORDER BY first_name, last_name') people = cursor.fetchall() identify_data_cache['people_names'] = [f"{first} {last}".strip() for first, last, dob in people] + # Pre-fetch unique last names for autocomplete (no DB during typing) + cursor.execute('SELECT DISTINCT last_name FROM people WHERE last_name IS NOT NULL AND TRIM(last_name) <> ""') + _last_rows = cursor.fetchall() + identify_data_cache['last_names'] = sorted({r[0].strip() for r in _last_rows if r and isinstance(r[0], str) and r[0].strip()}) print(f"✅ Pre-fetched {len(identify_data_cache.get('photo_paths', {}))} photo paths and {len(identify_data_cache.get('people_names', []))} people names") @@ -541,6 +545,13 @@ class PhotoTagger: if display_name not in identify_data_cache['people_names']: identify_data_cache['people_names'].append(display_name) identify_data_cache['people_names'].sort() # Keep sorted + # Keep last names cache updated in-session + if last_name: + if 'last_names' not in identify_data_cache: + identify_data_cache['last_names'] = [] + if last_name not in identify_data_cache['last_names']: + identify_data_cache['last_names'].append(last_name) + identify_data_cache['last_names'].sort() # Assign face to person cursor.execute( @@ -1149,12 +1160,223 @@ class PhotoTagger: first_name_entry = ttk.Entry(input_frame, textvariable=first_name_var, width=12) first_name_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=(0, 5)) - # Last name input + # Red asterisk for required first name field (overlayed, no layout impact) + first_name_asterisk = ttk.Label(root, text="*", foreground="red") + first_name_asterisk.place_forget() + + # Last name input (with live listbox autocomplete) ttk.Label(input_frame, text="Last name:").grid(row=0, column=2, sticky=tk.W, padx=(10, 10)) last_name_var = tk.StringVar() last_name_entry = ttk.Entry(input_frame, textvariable=last_name_var, width=12) last_name_entry.grid(row=0, column=3, sticky=(tk.W, tk.E), padx=(0, 5)) + # Red asterisk for required last name field (overlayed, no layout impact) + last_name_asterisk = ttk.Label(root, text="*", foreground="red") + last_name_asterisk.place_forget() + + def _position_required_asterisks(event=None): + """Position required asterisks at top-right corner of their entries.""" + try: + root.update_idletasks() + input_frame.update_idletasks() + first_name_entry.update_idletasks() + last_name_entry.update_idletasks() + date_of_birth_entry.update_idletasks() + + # Get absolute coordinates relative to root window + first_root_x = first_name_entry.winfo_rootx() + first_root_y = first_name_entry.winfo_rooty() + first_w = first_name_entry.winfo_width() + root_x = root.winfo_rootx() + root_y = root.winfo_rooty() + + # First name asterisk at the true top-right corner of entry + first_name_asterisk.place(x=first_root_x - root_x + first_w + 2, y=first_root_y - root_y - 2, anchor='nw') + first_name_asterisk.lift() + + # Last name asterisk at the true top-right corner of entry + last_root_x = last_name_entry.winfo_rootx() + last_root_y = last_name_entry.winfo_rooty() + last_w = last_name_entry.winfo_width() + last_name_asterisk.place(x=last_root_x - root_x + last_w + 2, y=last_root_y - root_y - 2, anchor='nw') + last_name_asterisk.lift() + + # Date of birth asterisk at the true top-right corner of date entry + dob_root_x = date_of_birth_entry.winfo_rootx() + dob_root_y = date_of_birth_entry.winfo_rooty() + dob_w = date_of_birth_entry.winfo_width() + date_asterisk.place(x=dob_root_x - root_x + dob_w + 2, y=dob_root_y - root_y - 2, anchor='nw') + date_asterisk.lift() + except Exception: + pass + + # Bind repositioning after all entries are created + def _bind_asterisk_positioning(): + try: + input_frame.bind('', _position_required_asterisks) + first_name_entry.bind('', _position_required_asterisks) + last_name_entry.bind('', _position_required_asterisks) + date_of_birth_entry.bind('', _position_required_asterisks) + _position_required_asterisks() + except Exception: + pass + root.after(100, _bind_asterisk_positioning) + + # Create listbox for suggestions (as overlay attached to root, not clipped by frames) + last_name_listbox = tk.Listbox(root, height=8) + last_name_listbox.place_forget() # Hide initially + + def _show_suggestions(): + """Show filtered suggestions in listbox""" + all_last_names = identify_data_cache.get('last_names', []) + typed = last_name_var.get().strip() + + if not typed: + filtered = [] # Show nothing if no typing + else: + low = typed.lower() + # Only show names that start with the typed text + filtered = [n for n in all_last_names if n.lower().startswith(low)][:10] + + # Update listbox + last_name_listbox.delete(0, tk.END) + for name in filtered: + last_name_listbox.insert(tk.END, name) + + # Show listbox if we have suggestions (as overlay) + if filtered: + # Ensure geometry is up to date before positioning + root.update_idletasks() + # Absolute coordinates of entry relative to screen + entry_root_x = last_name_entry.winfo_rootx() + entry_root_y = last_name_entry.winfo_rooty() + entry_height = last_name_entry.winfo_height() + # Convert to coordinates relative to root + root_origin_x = root.winfo_rootx() + root_origin_y = root.winfo_rooty() + place_x = entry_root_x - root_origin_x + place_y = entry_root_y - root_origin_y + entry_height + place_width = last_name_entry.winfo_width() + # Calculate how many rows fit to bottom of window + available_px = max(60, root.winfo_height() - place_y - 8) + # Approximate row height in pixels; 18 is a reasonable default for Tk listbox rows + approx_row_px = 18 + rows_fit = max(3, min(len(filtered), available_px // approx_row_px)) + last_name_listbox.configure(height=rows_fit) + last_name_listbox.place(x=place_x, y=place_y, width=place_width) + last_name_listbox.selection_clear(0, tk.END) + last_name_listbox.selection_set(0) # Select first item + last_name_listbox.activate(0) # Activate first item + else: + last_name_listbox.place_forget() + + def _hide_suggestions(): + """Hide the suggestions listbox""" + last_name_listbox.place_forget() + + def _on_listbox_select(event=None): + """Handle listbox selection and hide list""" + selection = last_name_listbox.curselection() + if selection: + selected_name = last_name_listbox.get(selection[0]) + last_name_var.set(selected_name) + _hide_suggestions() + last_name_entry.focus_set() + + def _on_listbox_click(event): + """Handle mouse click selection""" + try: + index = last_name_listbox.nearest(event.y) + if index is not None and index >= 0: + selected_name = last_name_listbox.get(index) + last_name_var.set(selected_name) + except: + pass + _hide_suggestions() + last_name_entry.focus_set() + return 'break' + + def _on_key_press(event): + """Handle key navigation in entry""" + nonlocal navigating_to_listbox, escape_pressed, enter_pressed + if event.keysym == 'Down': + if last_name_listbox.winfo_ismapped(): + navigating_to_listbox = True + last_name_listbox.focus_set() + last_name_listbox.selection_clear(0, tk.END) + last_name_listbox.selection_set(0) + last_name_listbox.activate(0) + return 'break' + elif event.keysym == 'Escape': + escape_pressed = True + _hide_suggestions() + return 'break' + elif event.keysym == 'Return': + enter_pressed = True + return 'break' + + def _on_listbox_key(event): + """Handle key navigation in listbox""" + nonlocal enter_pressed, escape_pressed + if event.keysym == 'Return': + enter_pressed = True + _on_listbox_select(event) + return 'break' + elif event.keysym == 'Escape': + escape_pressed = True + _hide_suggestions() + last_name_entry.focus_set() + return 'break' + elif event.keysym == 'Up': + selection = last_name_listbox.curselection() + if selection and selection[0] > 0: + # Move up in listbox + last_name_listbox.selection_clear(0, tk.END) + last_name_listbox.selection_set(selection[0] - 1) + last_name_listbox.see(selection[0] - 1) + else: + # At top, go back to entry field + _hide_suggestions() + last_name_entry.focus_set() + return 'break' + elif event.keysym == 'Down': + selection = last_name_listbox.curselection() + max_index = last_name_listbox.size() - 1 + if selection and selection[0] < max_index: + # Move down in listbox + last_name_listbox.selection_clear(0, tk.END) + last_name_listbox.selection_set(selection[0] + 1) + last_name_listbox.see(selection[0] + 1) + return 'break' + + # Track if we're navigating to listbox to prevent auto-hide + navigating_to_listbox = False + escape_pressed = False + enter_pressed = False + + def _safe_hide_suggestions(): + """Hide suggestions only if not navigating to listbox""" + nonlocal navigating_to_listbox + if not navigating_to_listbox: + _hide_suggestions() + navigating_to_listbox = False + + def _safe_show_suggestions(): + """Show suggestions only if escape or enter wasn't just pressed""" + nonlocal escape_pressed, enter_pressed + if not escape_pressed and not enter_pressed: + _show_suggestions() + escape_pressed = False + enter_pressed = False + + # Bind events + last_name_entry.bind('', lambda e: _safe_show_suggestions()) + last_name_entry.bind('', _on_key_press) + last_name_entry.bind('', lambda e: root.after(150, _safe_hide_suggestions)) # Delay to allow listbox clicks + last_name_listbox.bind('', _on_listbox_click) + last_name_listbox.bind('', _on_listbox_key) + last_name_listbox.bind('', _on_listbox_click) + # Middle name input ttk.Label(input_frame, text="Middle name:").grid(row=0, column=4, sticky=tk.W, padx=(10, 10)) middle_name_var = tk.StringVar() @@ -1179,9 +1401,13 @@ class PhotoTagger: date_of_birth_entry = ttk.Entry(date_frame, textvariable=date_of_birth_var, width=12, state='readonly') date_of_birth_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) + # Red asterisk for required date of birth field (overlayed, no layout impact) + date_asterisk = ttk.Label(root, text="*", foreground="red") + date_asterisk.place_forget() + # Calendar button calendar_btn = ttk.Button(date_frame, text="📅", width=3, command=lambda: open_calendar()) - calendar_btn.pack(side=tk.RIGHT, padx=(5, 0)) + calendar_btn.pack(side=tk.RIGHT, padx=(15, 0)) def open_calendar(): """Open a visual calendar dialog to select date of birth""" @@ -1435,7 +1661,7 @@ class PhotoTagger: maiden_name = maiden_name_var.get().strip() date_of_birth = date_of_birth_var.get().strip() - if first_name or last_name: + if first_name or last_name or date_of_birth: # Store as dictionary to maintain consistency face_person_names[current_face_id] = { 'first_name': first_name, @@ -1450,6 +1676,7 @@ class PhotoTagger: first_name_var.trace('w', on_name_change) last_name_var.trace('w', on_name_change) + date_of_birth_var.trace('w', on_name_change) # Buttons moved to bottom of window @@ -2200,6 +2427,14 @@ class PhotoTagger: if person_name not in identify_data_cache['people_names']: identify_data_cache['people_names'].append(person_name) identify_data_cache['people_names'].sort() # Keep sorted + # Update last names cache from person_name ("Last, First" or single) + inferred_last = person_name.split(',')[0].strip() if ',' in person_name else person_name.strip() + if inferred_last: + if 'last_names' not in identify_data_cache: + identify_data_cache['last_names'] = [] + if inferred_last not in identify_data_cache['last_names']: + identify_data_cache['last_names'].append(inferred_last) + identify_data_cache['last_names'].sort() # Identify all selected faces (including current face) all_face_ids = [face_id] + selected_face_ids @@ -2249,6 +2484,14 @@ class PhotoTagger: if command not in identify_data_cache['people_names']: identify_data_cache['people_names'].append(command) identify_data_cache['people_names'].sort() # Keep sorted + # Update last names cache from command ("Last, First" or single) + inferred_last = command.split(',')[0].strip() if ',' in command else command.strip() + if inferred_last: + if 'last_names' not in identify_data_cache: + identify_data_cache['last_names'] = [] + if inferred_last not in identify_data_cache['last_names']: + identify_data_cache['last_names'].append(inferred_last) + identify_data_cache['last_names'].sort() # Assign face to person cursor.execute(