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.

This commit is contained in:
tanyar09 2025-09-30 15:19:37 -04:00
parent 0fb6a19624
commit 360fcb0881
2 changed files with 248 additions and 3 deletions

View File

@ -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

View File

@ -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('<Configure>', _position_required_asterisks)
first_name_entry.bind('<Configure>', _position_required_asterisks)
last_name_entry.bind('<Configure>', _position_required_asterisks)
date_of_birth_entry.bind('<Configure>', _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('<KeyRelease>', lambda e: _safe_show_suggestions())
last_name_entry.bind('<KeyPress>', _on_key_press)
last_name_entry.bind('<FocusOut>', lambda e: root.after(150, _safe_hide_suggestions)) # Delay to allow listbox clicks
last_name_listbox.bind('<Button-1>', _on_listbox_click)
last_name_listbox.bind('<KeyPress>', _on_listbox_key)
last_name_listbox.bind('<Double-Button-1>', _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(