""" 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] = [] # 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("", lambda e: self._open_dropdown()) self.dropdown_button.bind("", lambda e: self._open_dropdown()) self.dropdown_button.bind("", lambda e: self._toggle_dropdown()) # Bind button click to ensure toggle works even when popup has focus self.dropdown_button.bind("", 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 """ self.items = 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 # 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 # 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 # Add 20px padding: 10px top + 10px bottom (5px pack padding + 5px extra for scrollbar/borders) calculated_height = (items_to_show * item_height) + 20 self._popup_height = calculated_height # Set window size explicitly to ensure proper height self.popup_window.wm_geometry(f"{self.button_width}x{self._popup_height}") # Create scrollable frame for items with fixed width # Frame height = window height - padding (10px pack padding total) frame_height = self._popup_height - 10 self.popup_frame = ctk.CTkScrollableFrame( self.popup_window, width=self.button_width - 20, height=frame_height ) self.popup_frame.pack(fill="both", expand=False, 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" ) 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("", popup_scroll, add="+") self.popup_frame.bind("", popup_scroll, add="+") self.popup_frame._parent_canvas.bind("", popup_scroll, add="+") # Bind to all item buttons so scrolling works when hovering over them for btn in self.item_buttons: btn.bind("", 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 (use correct Tkinter key names) self.popup_window.bind("", lambda e: self._close_dropdown()) self.popup_window.bind("", self._navigate_down) self.popup_window.bind("", self._navigate_up) self.popup_window.bind("", self._navigate_page_down) # PageDown in Tkinter self.popup_window.bind("", self._navigate_page_up) # PageUp in Tkinter self.popup_window.bind("", self._navigate_home) self.popup_window.bind("", self._navigate_end) self.popup_window.bind("", self._confirm_selection) # Removed FocusOut binding - it causes race conditions with button clicks # Set focus to popup self.popup_window.focus_set() # Bind click outside to close self.popup_window.bind("", 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 tooltips for tooltip in self.item_tooltips: tooltip.destroy() self.item_tooltips.clear() # Unbind mouse wheel from popup widgets try: self.popup_window.unbind("") if self.popup_frame: self.popup_frame.unbind("") self.popup_frame._parent_canvas.unbind("") # Unbind from buttons for btn in self.item_buttons: btn.unbind("") 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 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)