From 360fcb088129e6c24817fce7c90e724f2abef518 Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Tue, 30 Sep 2025 15:19:37 -0400 Subject: [PATCH] Enhance PhotoTagger with last name autocomplete and required field indicators. Implement live filtering for last names during input, improving user experience. Add red asterisks to indicate required fields for first name, last name, and date of birth, ensuring clarity in form completion. Update README to document these new features. --- README.md | 2 + photo_tagger.py | 249 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 248 insertions(+), 3 deletions(-) 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(