782 lines
28 KiB
Python
782 lines
28 KiB
Python
"""
|
||
Unified Dropdown Component
|
||
|
||
Custom dropdown widget with popup list, keyboard navigation, and tooltips.
|
||
"""
|
||
|
||
import customtkinter as ctk
|
||
import tkinter as tk
|
||
from typing import List, Dict, Callable, Optional, Any
|
||
from .tooltip import ToolTip
|
||
|
||
|
||
class UnifiedDropdown(ctk.CTkFrame):
|
||
"""
|
||
Unified dropdown component with popup list.
|
||
|
||
Provides consistent selection UI across the application with:
|
||
- Popup list that expands below button
|
||
- Scrollable list for large datasets
|
||
- Keyboard navigation (arrows, PageUp/Down, Home/End)
|
||
- Tooltips on hover
|
||
- Automatic positioning
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
parent,
|
||
title: str = "",
|
||
on_selection_changed: Callable = None,
|
||
show_count: bool = True,
|
||
display_key: str = "display_name",
|
||
display_format: Callable = None,
|
||
max_dropdown_height: int = 400,
|
||
button_width: int = 600,
|
||
button_height: int = 40
|
||
):
|
||
"""
|
||
Initialize the unified dropdown.
|
||
|
||
Args:
|
||
parent: Parent widget
|
||
title: Title label text
|
||
on_selection_changed: Callback when selection changes
|
||
show_count: Whether to show item count label
|
||
display_key: Key to use for display text from item dict
|
||
display_format: Optional formatter function for display text
|
||
max_dropdown_height: Maximum height of dropdown popup
|
||
button_width: Width of the dropdown button
|
||
button_height: Height of the dropdown button
|
||
"""
|
||
super().__init__(parent)
|
||
|
||
self.title = title
|
||
self.on_selection_changed = on_selection_changed
|
||
self.show_count = show_count
|
||
self.display_key = display_key
|
||
self.display_format = display_format
|
||
self.max_dropdown_height = max_dropdown_height
|
||
self.button_width = button_width
|
||
self.button_height = button_height
|
||
|
||
# State
|
||
self.items: List[Dict] = []
|
||
self.selected_item: Optional[Dict] = None
|
||
self.current_index: int = -1
|
||
self.item_buttons: List[ctk.CTkButton] = []
|
||
self.item_tooltips: List[ToolTip] = []
|
||
|
||
# Search state
|
||
self.all_items: List[Dict] = [] # Store complete unfiltered list
|
||
self.filtered_items: List[Dict] = [] # Currently filtered items
|
||
self.search_entry: Optional[ctk.CTkEntry] = None
|
||
self.search_query: str = ""
|
||
|
||
# Popup window
|
||
self.popup_window: Optional[tk.Toplevel] = None
|
||
self.popup_frame: Optional[ctk.CTkScrollableFrame] = None
|
||
self._closing = False # Flag to prevent race conditions
|
||
self._popup_height = 0 # Store calculated popup height
|
||
|
||
# Configure frame
|
||
self.configure(corner_radius=10, border_width=2)
|
||
|
||
# Build UI
|
||
self._build_ui()
|
||
|
||
def _build_ui(self):
|
||
"""Build the dropdown UI."""
|
||
# Title label (only show if title is not empty)
|
||
if self.title:
|
||
self.title_label = ctk.CTkLabel(
|
||
self,
|
||
text=self.title,
|
||
font=ctk.CTkFont(size=18, weight="bold")
|
||
)
|
||
self.title_label.grid(row=0, column=0, padx=20, pady=(20, 5), sticky="w")
|
||
else:
|
||
self.title_label = None
|
||
|
||
# Count label (optional)
|
||
if self.show_count:
|
||
self.count_label = ctk.CTkLabel(
|
||
self,
|
||
text="0 items found",
|
||
font=ctk.CTkFont(size=12),
|
||
text_color="gray"
|
||
)
|
||
self.count_label.grid(row=1, column=0, padx=20, pady=(0, 10), sticky="w")
|
||
|
||
# Dropdown button
|
||
self.dropdown_button = ctk.CTkButton(
|
||
self,
|
||
text="Please connect to Azure first",
|
||
command=self._toggle_dropdown,
|
||
width=self.button_width,
|
||
height=self.button_height,
|
||
font=ctk.CTkFont(size=14),
|
||
anchor="w",
|
||
state="disabled"
|
||
)
|
||
row_offset = 2 if self.show_count else 1
|
||
self.dropdown_button.grid(row=row_offset, column=0, padx=20, pady=(0, 20), sticky="ew")
|
||
|
||
# Bind keyboard events to button
|
||
self.dropdown_button.bind("<Down>", lambda e: self._open_dropdown())
|
||
self.dropdown_button.bind("<Up>", lambda e: self._open_dropdown())
|
||
self.dropdown_button.bind("<space>", lambda e: self._toggle_dropdown())
|
||
|
||
# Bind button click to ensure toggle works even when popup has focus
|
||
self.dropdown_button.bind("<Button-1>", self._on_button_click, add="+")
|
||
|
||
# Configure grid
|
||
self.grid_columnconfigure(0, weight=1)
|
||
|
||
def set_items(self, items: List[Dict]):
|
||
"""
|
||
Set the list of items.
|
||
|
||
Args:
|
||
items: List of item dictionaries
|
||
"""
|
||
# Store complete list and initialize filtered list
|
||
self.all_items = items
|
||
self.filtered_items = items.copy()
|
||
self.items = self.filtered_items
|
||
self.current_index = -1
|
||
|
||
# Update count label
|
||
if self.show_count:
|
||
count_text = f"{len(items)} item{'s' if len(items) != 1 else ''} found"
|
||
self.count_label.configure(text=count_text)
|
||
|
||
# Enable/disable button
|
||
if items:
|
||
self.dropdown_button.configure(state="normal")
|
||
# Auto-select first item
|
||
if items:
|
||
self._select_item(0, trigger_callback=False)
|
||
else:
|
||
self.dropdown_button.configure(
|
||
state="disabled",
|
||
text="No items found"
|
||
)
|
||
self.selected_item = None
|
||
|
||
def _get_display_text(self, item: Dict) -> str:
|
||
"""
|
||
Get display text for an item.
|
||
|
||
Args:
|
||
item: Item dictionary
|
||
|
||
Returns:
|
||
Display text string
|
||
"""
|
||
if self.display_format:
|
||
return self.display_format(item)
|
||
elif self.display_key in item:
|
||
return str(item[self.display_key])
|
||
elif 'name' in item:
|
||
return str(item['name'])
|
||
elif 'display_name' in item:
|
||
return str(item['display_name'])
|
||
else:
|
||
return str(item)
|
||
|
||
def _on_button_click(self, event):
|
||
"""Handle explicit button click to ensure toggle works."""
|
||
# This is called on Button-1 event, which happens before the command callback
|
||
# We'll let the command callback (_toggle_dropdown) handle the actual toggle
|
||
# But we need to make sure the event propagates correctly
|
||
pass
|
||
|
||
def _toggle_dropdown(self):
|
||
"""Toggle dropdown popup visibility."""
|
||
# Check if we're in the middle of closing (to prevent race conditions)
|
||
if self._closing:
|
||
return
|
||
|
||
if self.popup_window:
|
||
self._close_dropdown()
|
||
else:
|
||
self._open_dropdown()
|
||
|
||
def _open_dropdown(self):
|
||
"""Open the dropdown popup."""
|
||
if self.popup_window or not self.items:
|
||
return
|
||
|
||
# Create popup window
|
||
self.popup_window = tk.Toplevel(self)
|
||
self.popup_window.wm_overrideredirect(True) # Remove window decorations
|
||
self.popup_window.wm_attributes("-topmost", True) # Always on top
|
||
self.popup_window.wm_resizable(False, False) # Prevent resizing
|
||
|
||
# Set background color to prevent white flash
|
||
appearance_mode = ctk.get_appearance_mode()
|
||
if appearance_mode == "Dark":
|
||
bg_color = "#2b2b2b"
|
||
else:
|
||
bg_color = "#dbdbdb"
|
||
self.popup_window.configure(bg=bg_color)
|
||
|
||
self.popup_window.withdraw() # Hide initially to prevent flash in top-left corner
|
||
|
||
# Create search entry at top of popup
|
||
self.search_entry = ctk.CTkEntry(
|
||
self.popup_window,
|
||
placeholder_text="Search...",
|
||
height=32,
|
||
font=ctk.CTkFont(size=13)
|
||
)
|
||
self.search_entry.pack(fill="x", padx=5, pady=(5, 0))
|
||
self.search_entry.focus_set()
|
||
|
||
# Bind search events
|
||
self.search_entry.bind("<KeyRelease>", self._on_search_changed)
|
||
self.search_entry.bind("<Escape>", lambda e: self._close_dropdown())
|
||
|
||
# Calculate dynamic height based on number of items
|
||
# Show first 10 items (or all if less than 10)
|
||
# Each item: 40px button + 2px borders (1px top + 1px bottom) + 4px padding (2px top + 2px bottom) = 46px per item
|
||
item_height = 46
|
||
max_visible_items = 10
|
||
items_to_show = min(len(self.items), max_visible_items)
|
||
|
||
# Height calculation: (items × 46px) + extra padding for frame borders + search box height
|
||
# Add 62px total: 42px for search box (32px height + 5px top padding + 5px spacing) + 20px for frame borders
|
||
calculated_height = (items_to_show * item_height) + 62
|
||
|
||
self._popup_height = calculated_height
|
||
|
||
# Set window size explicitly and fix dimensions
|
||
self.popup_window.wm_geometry(f"{self.button_width}x{self._popup_height}")
|
||
self.popup_window.wm_minsize(self.button_width, self._popup_height)
|
||
self.popup_window.wm_maxsize(self.button_width, self._popup_height)
|
||
|
||
# Create scrollable frame for items with fixed width
|
||
# Frame height = window height - padding - search box height (10px pack padding total + 42px search)
|
||
frame_height = self._popup_height - 52
|
||
|
||
self.popup_frame = ctk.CTkScrollableFrame(
|
||
self.popup_window,
|
||
width=self.button_width - 20,
|
||
height=frame_height
|
||
)
|
||
self.popup_frame.pack(fill="both", expand=True, padx=5, pady=5)
|
||
|
||
# Configure scroll speed to match main window (40px per scroll unit = 2x faster)
|
||
self.popup_frame._parent_canvas.configure(yscrollincrement=40)
|
||
|
||
# Clear previous items
|
||
self.item_buttons.clear()
|
||
for tooltip in self.item_tooltips:
|
||
tooltip.destroy()
|
||
self.item_tooltips.clear()
|
||
|
||
# Create button for each item
|
||
for idx, item in enumerate(self.items):
|
||
display_text = self._get_display_text(item)
|
||
|
||
# Truncate long text
|
||
max_chars = 60
|
||
truncated = display_text if len(display_text) <= max_chars else display_text[:max_chars] + "..."
|
||
|
||
# Add extra padding for first and last items to prevent border cutoff
|
||
if idx == 0:
|
||
pady_val = (3, 2) # Extra padding at top
|
||
elif idx == len(self.items) - 1:
|
||
pady_val = (2, 3) # Extra padding at bottom
|
||
else:
|
||
pady_val = 2
|
||
|
||
btn = ctk.CTkButton(
|
||
self.popup_frame,
|
||
text=truncated,
|
||
command=lambda i=idx: self._select_item(i, close_popup=True),
|
||
font=ctk.CTkFont(size=14),
|
||
height=40,
|
||
fg_color="transparent",
|
||
border_width=1,
|
||
border_color="gray50",
|
||
hover_color=("gray70", "gray30"),
|
||
anchor="w",
|
||
text_color=("gray10", "gray90")
|
||
)
|
||
btn.grid(row=idx, column=0, padx=5, pady=pady_val, sticky="ew")
|
||
self.popup_frame.grid_columnconfigure(0, weight=1)
|
||
|
||
# Add tooltip if text was truncated
|
||
if len(display_text) > max_chars:
|
||
tooltip = ToolTip(btn, display_text, delay=500)
|
||
self.item_tooltips.append(tooltip)
|
||
|
||
self.item_buttons.append(btn)
|
||
|
||
# Bind mouse wheel to popup (prevent scrolling main window)
|
||
def popup_scroll(event):
|
||
"""Handle mouse wheel in popup."""
|
||
if event.delta > 0:
|
||
self.popup_frame._parent_canvas.yview_scroll(-1, "units")
|
||
else:
|
||
self.popup_frame._parent_canvas.yview_scroll(1, "units")
|
||
return "break" # Stop event propagation
|
||
|
||
# Bind to both popup window and frame to capture all events
|
||
self.popup_window.bind("<MouseWheel>", popup_scroll, add="+")
|
||
self.popup_frame.bind("<MouseWheel>", popup_scroll, add="+")
|
||
self.popup_frame._parent_canvas.bind("<MouseWheel>", popup_scroll, add="+")
|
||
|
||
# Bind to all item buttons so scrolling works when hovering over them
|
||
for btn in self.item_buttons:
|
||
btn.bind("<MouseWheel>", popup_scroll, add="+")
|
||
|
||
# Highlight currently selected item
|
||
if self.current_index >= 0:
|
||
self._highlight_item(self.current_index)
|
||
|
||
# Position popup
|
||
self._position_popup()
|
||
|
||
# Bind keyboard events to search entry for navigation
|
||
self.search_entry.bind("<Down>", lambda e: self._focus_first_item())
|
||
self.search_entry.bind("<Up>", lambda e: self._navigate_end())
|
||
self.search_entry.bind("<Next>", self._navigate_page_down) # PageDown in Tkinter
|
||
self.search_entry.bind("<Prior>", self._navigate_page_up) # PageUp in Tkinter
|
||
self.search_entry.bind("<Home>", self._navigate_home)
|
||
self.search_entry.bind("<End>", self._navigate_end)
|
||
self.search_entry.bind("<Return>", self._confirm_selection)
|
||
|
||
# Bind keyboard events to popup window as well (for when focus moves)
|
||
self.popup_window.bind("<Escape>", lambda e: self._close_dropdown())
|
||
self.popup_window.bind("<Down>", self._navigate_down)
|
||
self.popup_window.bind("<Up>", self._navigate_up)
|
||
self.popup_window.bind("<Next>", self._navigate_page_down)
|
||
self.popup_window.bind("<Prior>", self._navigate_page_up)
|
||
self.popup_window.bind("<Home>", self._navigate_home)
|
||
self.popup_window.bind("<End>", self._navigate_end)
|
||
self.popup_window.bind("<Return>", self._confirm_selection)
|
||
|
||
# Bind any printable character to redirect focus to search entry
|
||
self.popup_window.bind("<Key>", self._redirect_to_search)
|
||
|
||
# Keep focus on search entry (don't override with popup_window.focus_set())
|
||
# Focus was already set on search_entry at line 233
|
||
|
||
# Bind click outside to close
|
||
self.popup_window.bind("<Button-1>", self._check_click_outside, add="+")
|
||
|
||
def _close_dropdown(self):
|
||
"""Close the dropdown popup."""
|
||
if self.popup_window and not self._closing:
|
||
# Set flag to prevent race conditions
|
||
self._closing = True
|
||
|
||
# Clean up search state
|
||
self.search_entry = None
|
||
self.search_query = ""
|
||
self.filtered_items = self.all_items.copy()
|
||
self.items = self.filtered_items
|
||
|
||
# Clean up tooltips
|
||
for tooltip in self.item_tooltips:
|
||
tooltip.destroy()
|
||
self.item_tooltips.clear()
|
||
|
||
# Unbind mouse wheel from popup widgets
|
||
try:
|
||
self.popup_window.unbind("<MouseWheel>")
|
||
if self.popup_frame:
|
||
self.popup_frame.unbind("<MouseWheel>")
|
||
self.popup_frame._parent_canvas.unbind("<MouseWheel>")
|
||
# Unbind from buttons
|
||
for btn in self.item_buttons:
|
||
btn.unbind("<MouseWheel>")
|
||
except:
|
||
pass
|
||
|
||
# Destroy popup
|
||
self.popup_window.destroy()
|
||
self.popup_window = None
|
||
self.popup_frame = None
|
||
self.item_buttons.clear()
|
||
|
||
# Reset flag after a short delay to allow event handling to complete
|
||
self.after(100, lambda: setattr(self, '_closing', False))
|
||
|
||
def _position_popup(self):
|
||
"""Position the popup window below the button."""
|
||
# Force complete update to ensure all widgets are rendered
|
||
self.popup_window.update_idletasks()
|
||
self.dropdown_button.update_idletasks()
|
||
|
||
# Get button position (ensure widgets are fully laid out)
|
||
btn_x = self.dropdown_button.winfo_rootx()
|
||
btn_y = self.dropdown_button.winfo_rooty()
|
||
btn_height = self.dropdown_button.winfo_height()
|
||
|
||
# Position BELOW the button (not in the middle)
|
||
x = btn_x
|
||
y = btn_y + btn_height
|
||
|
||
# Fallback if coordinates are invalid (0,0 means not rendered yet)
|
||
if btn_x == 0 and btn_y == 0:
|
||
# Wait a bit and try again
|
||
self.popup_window.after(10, self._position_popup)
|
||
return
|
||
|
||
# Get screen dimensions
|
||
screen_width = self.winfo_screenwidth()
|
||
screen_height = self.winfo_screenheight()
|
||
|
||
# Use stored dimensions (already calculated in _open_dropdown)
|
||
popup_width = self.button_width
|
||
popup_height = self._popup_height
|
||
|
||
# Adjust if near screen edges
|
||
if x + popup_width > screen_width:
|
||
x = screen_width - popup_width - 10
|
||
|
||
# Position above if not enough space below
|
||
if y + popup_height > screen_height:
|
||
y = btn_y - popup_height
|
||
|
||
# Set position explicitly (size already set in _open_dropdown)
|
||
self.popup_window.wm_geometry(f"+{x}+{y}")
|
||
|
||
# Now show the popup (after positioning to prevent flash in top-left)
|
||
self.popup_window.deiconify()
|
||
|
||
def _check_click_outside(self, event):
|
||
"""Check if click was outside popup and close if so."""
|
||
if self.popup_window:
|
||
x, y = event.x_root, event.y_root
|
||
popup_x = self.popup_window.winfo_rootx()
|
||
popup_y = self.popup_window.winfo_rooty()
|
||
popup_width = self.popup_window.winfo_width()
|
||
popup_height = self.popup_window.winfo_height()
|
||
|
||
# Check if click is inside popup
|
||
inside_popup = (popup_x <= x <= popup_x + popup_width and
|
||
popup_y <= y <= popup_y + popup_height)
|
||
|
||
# Check if click is on the dropdown button (to allow toggling)
|
||
btn_x = self.dropdown_button.winfo_rootx()
|
||
btn_y = self.dropdown_button.winfo_rooty()
|
||
btn_width = self.dropdown_button.winfo_width()
|
||
btn_height = self.dropdown_button.winfo_height()
|
||
inside_button = (btn_x <= x <= btn_x + btn_width and
|
||
btn_y <= y <= btn_y + btn_height)
|
||
|
||
# Close if click is outside both popup and button
|
||
if not inside_popup and not inside_button:
|
||
self._close_dropdown()
|
||
|
||
def _select_item(self, index: int, trigger_callback: bool = True, close_popup: bool = False):
|
||
"""
|
||
Select an item by index.
|
||
|
||
Args:
|
||
index: Item index
|
||
trigger_callback: Whether to trigger selection callback
|
||
close_popup: Whether to close popup after selection
|
||
"""
|
||
if 0 <= index < len(self.items):
|
||
self.current_index = index
|
||
self.selected_item = self.items[index]
|
||
|
||
# Update button text
|
||
display_text = self._get_display_text(self.selected_item)
|
||
max_chars = 60
|
||
truncated = display_text if len(display_text) <= max_chars else display_text[:max_chars] + "..."
|
||
self.dropdown_button.configure(text=truncated)
|
||
|
||
# Trigger callback
|
||
if trigger_callback and self.on_selection_changed:
|
||
self.on_selection_changed(self.selected_item)
|
||
|
||
# Close popup if requested
|
||
if close_popup:
|
||
self._close_dropdown()
|
||
|
||
def _highlight_item(self, index: int):
|
||
"""
|
||
Highlight an item in the popup.
|
||
|
||
Args:
|
||
index: Item index
|
||
"""
|
||
if not self.item_buttons:
|
||
return
|
||
|
||
for i, btn in enumerate(self.item_buttons):
|
||
if i == index:
|
||
btn.configure(
|
||
fg_color=("#3b8ed0", "#1f538d"),
|
||
border_color=("#3b8ed0", "#1f538d")
|
||
)
|
||
else:
|
||
btn.configure(
|
||
fg_color="transparent",
|
||
border_color="gray50"
|
||
)
|
||
|
||
def _navigate_down(self, event=None):
|
||
"""Navigate to next item."""
|
||
if not self.items:
|
||
return
|
||
|
||
new_index = (self.current_index + 1) % len(self.items)
|
||
self.current_index = new_index
|
||
self._highlight_item(new_index)
|
||
self._scroll_to_item(new_index)
|
||
|
||
def _navigate_up(self, event=None):
|
||
"""Navigate to previous item."""
|
||
if not self.items:
|
||
return
|
||
|
||
new_index = (self.current_index - 1) % len(self.items)
|
||
self.current_index = new_index
|
||
self._highlight_item(new_index)
|
||
self._scroll_to_item(new_index)
|
||
|
||
def _navigate_page_down(self, event=None):
|
||
"""Jump down 5 items."""
|
||
if not self.items:
|
||
return
|
||
|
||
new_index = min(self.current_index + 5, len(self.items) - 1)
|
||
self.current_index = new_index
|
||
self._highlight_item(new_index)
|
||
self._scroll_to_item(new_index)
|
||
|
||
def _navigate_page_up(self, event=None):
|
||
"""Jump up 5 items."""
|
||
if not self.items:
|
||
return
|
||
|
||
new_index = max(self.current_index - 5, 0)
|
||
self.current_index = new_index
|
||
self._highlight_item(new_index)
|
||
self._scroll_to_item(new_index)
|
||
|
||
def _navigate_home(self, event=None):
|
||
"""Jump to first item."""
|
||
if not self.items:
|
||
return
|
||
|
||
self.current_index = 0
|
||
self._highlight_item(0)
|
||
self._scroll_to_item(0)
|
||
|
||
def _navigate_end(self, event=None):
|
||
"""Jump to last item."""
|
||
if not self.items:
|
||
return
|
||
|
||
new_index = len(self.items) - 1
|
||
self.current_index = new_index
|
||
self._highlight_item(new_index)
|
||
self._scroll_to_item(new_index)
|
||
|
||
def _confirm_selection(self, event=None):
|
||
"""Confirm current selection and close popup."""
|
||
if self.current_index >= 0:
|
||
self._select_item(self.current_index, trigger_callback=True, close_popup=True)
|
||
|
||
def _scroll_to_item(self, index: int):
|
||
"""
|
||
Scroll popup to make item visible.
|
||
|
||
Args:
|
||
index: Item index
|
||
"""
|
||
if not self.popup_frame or not self.item_buttons:
|
||
return
|
||
|
||
# Get the button widget
|
||
if 0 <= index < len(self.item_buttons):
|
||
btn = self.item_buttons[index]
|
||
|
||
# Calculate scroll position
|
||
# Note: CTkScrollableFrame uses internal canvas
|
||
btn.update_idletasks()
|
||
|
||
def _on_search_changed(self, event=None):
|
||
"""Handle search query change and filter items."""
|
||
query = self.search_entry.get().strip().lower()
|
||
self.search_query = query
|
||
|
||
if not query:
|
||
# Show all items
|
||
self.filtered_items = self.all_items.copy()
|
||
else:
|
||
# Filter items based on display text (case-insensitive)
|
||
self.filtered_items = [
|
||
item for item in self.all_items
|
||
if query in self._get_display_text(item).lower()
|
||
]
|
||
|
||
# Rebuild popup with filtered items
|
||
self._rebuild_popup_items()
|
||
|
||
def _rebuild_popup_items(self):
|
||
"""Rebuild popup item list with filtered items."""
|
||
if not self.popup_frame:
|
||
return
|
||
|
||
# Clear existing buttons and tooltips
|
||
for btn in self.item_buttons:
|
||
btn.destroy()
|
||
self.item_buttons.clear()
|
||
|
||
for tooltip in self.item_tooltips:
|
||
tooltip.destroy()
|
||
self.item_tooltips.clear()
|
||
|
||
# Update items reference
|
||
self.items = self.filtered_items
|
||
|
||
# Show "no results" if empty
|
||
if not self.filtered_items:
|
||
no_results_label = ctk.CTkLabel(
|
||
self.popup_frame,
|
||
text="No matching items found",
|
||
font=ctk.CTkFont(size=14),
|
||
text_color="gray"
|
||
)
|
||
no_results_label.grid(row=0, column=0, padx=10, pady=20)
|
||
return
|
||
|
||
# Recreate buttons
|
||
for idx, item in enumerate(self.filtered_items):
|
||
display_text = self._get_display_text(item)
|
||
max_chars = 60
|
||
truncated = display_text if len(display_text) <= max_chars else display_text[:max_chars] + "..."
|
||
|
||
# Add extra padding for first and last items
|
||
if idx == 0:
|
||
pady_val = (3, 2)
|
||
elif idx == len(self.filtered_items) - 1:
|
||
pady_val = (2, 3)
|
||
else:
|
||
pady_val = 2
|
||
|
||
btn = ctk.CTkButton(
|
||
self.popup_frame,
|
||
text=truncated,
|
||
command=lambda i=idx: self._select_item(i, close_popup=True),
|
||
font=ctk.CTkFont(size=14),
|
||
height=40,
|
||
fg_color="transparent",
|
||
border_width=1,
|
||
border_color="gray50",
|
||
hover_color=("gray70", "gray30"),
|
||
anchor="w",
|
||
text_color=("gray10", "gray90")
|
||
)
|
||
btn.grid(row=idx, column=0, padx=5, pady=pady_val, sticky="ew")
|
||
self.popup_frame.grid_columnconfigure(0, weight=1)
|
||
|
||
# Add tooltip if truncated
|
||
if len(display_text) > max_chars:
|
||
tooltip = ToolTip(btn, display_text, delay=500)
|
||
self.item_tooltips.append(tooltip)
|
||
|
||
# Bind mouse wheel
|
||
def popup_scroll(event):
|
||
if event.delta > 0:
|
||
self.popup_frame._parent_canvas.yview_scroll(-1, "units")
|
||
else:
|
||
self.popup_frame._parent_canvas.yview_scroll(1, "units")
|
||
return "break"
|
||
|
||
btn.bind("<MouseWheel>", popup_scroll, add="+")
|
||
self.item_buttons.append(btn)
|
||
|
||
# Reset current index and highlight first item
|
||
self.current_index = 0
|
||
self._highlight_item(0)
|
||
|
||
def _focus_first_item(self):
|
||
"""Move focus from search to first item."""
|
||
if self.item_buttons:
|
||
self.current_index = 0
|
||
self._highlight_item(0)
|
||
self.popup_window.focus_set()
|
||
|
||
def _redirect_to_search(self, event):
|
||
"""Redirect keyboard input to search entry."""
|
||
# Ignore special keys that are already handled
|
||
if event.keysym in ['Escape', 'Down', 'Up', 'Next', 'Prior', 'Home', 'End', 'Return',
|
||
'Left', 'Right', 'Tab', 'Shift_L', 'Shift_R', 'Control_L',
|
||
'Control_R', 'Alt_L', 'Alt_R']:
|
||
return
|
||
|
||
# Focus search entry if not already focused
|
||
if self.search_entry and str(self.focus_get()) != str(self.search_entry):
|
||
self.search_entry.focus_set()
|
||
# Insert the character that was typed
|
||
if len(event.char) == 1 and event.char.isprintable():
|
||
# Get current cursor position
|
||
current_pos = self.search_entry.index(tk.INSERT)
|
||
# Insert character at cursor position
|
||
self.search_entry.insert(current_pos, event.char)
|
||
# Trigger search update
|
||
self._on_search_changed()
|
||
|
||
def get_selected(self) -> Optional[Dict]:
|
||
"""
|
||
Get the currently selected item.
|
||
|
||
Returns:
|
||
Selected item dictionary or None
|
||
"""
|
||
return self.selected_item
|
||
|
||
def set_enabled(self, enabled: bool):
|
||
"""
|
||
Enable or disable the dropdown.
|
||
|
||
Args:
|
||
enabled: Whether to enable the dropdown
|
||
"""
|
||
if enabled and self.items:
|
||
self.dropdown_button.configure(state="normal")
|
||
else:
|
||
self.dropdown_button.configure(state="disabled")
|
||
|
||
def set_loading(self, loading: bool):
|
||
"""
|
||
Set loading state.
|
||
|
||
Args:
|
||
loading: Whether currently loading
|
||
"""
|
||
if loading:
|
||
self.dropdown_button.configure(state="disabled", text="Loading...")
|
||
if self.show_count:
|
||
self.count_label.configure(text="Loading...")
|
||
else:
|
||
if self.items:
|
||
self.dropdown_button.configure(state="normal")
|
||
if self.show_count:
|
||
count_text = f"{len(self.items)} item{'s' if len(self.items) != 1 else ''} found"
|
||
self.count_label.configure(text=count_text)
|
||
else:
|
||
self.dropdown_button.configure(state="disabled", text="No items found")
|
||
if self.show_count:
|
||
self.count_label.configure(text="0 items found")
|
||
|
||
def set_placeholder(self, text: str):
|
||
"""
|
||
Set placeholder text when no items.
|
||
|
||
Args:
|
||
text: Placeholder text
|
||
"""
|
||
if not self.items:
|
||
self.dropdown_button.configure(text=text)
|