536 lines
18 KiB
Python
536 lines
18 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] = []
|
|
|
|
# Popup window
|
|
self.popup_window: Optional[tk.Toplevel] = None
|
|
self.popup_frame: Optional[ctk.CTkScrollableFrame] = None
|
|
|
|
# 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())
|
|
|
|
# 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 _toggle_dropdown(self):
|
|
"""Toggle dropdown popup visibility."""
|
|
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
|
|
|
|
# Calculate dynamic height based on number of items
|
|
# Each item: 40px button + 4px padding = 44px per item
|
|
item_height = 44
|
|
calculated_height = len(self.items) * item_height + 10 # +10 for padding
|
|
|
|
# Use calculated height, but cap at max_dropdown_height
|
|
popup_height = min(calculated_height, self.max_dropdown_height)
|
|
|
|
# Create scrollable frame for items
|
|
self.popup_frame = ctk.CTkScrollableFrame(
|
|
self.popup_window,
|
|
width=self.button_width - 20,
|
|
height=popup_height
|
|
)
|
|
self.popup_frame.pack(fill="both", expand=True)
|
|
|
|
# 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] + "..."
|
|
|
|
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=2, 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 (use correct Tkinter key names)
|
|
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) # PageDown in Tkinter
|
|
self.popup_window.bind("<Prior>", self._navigate_page_up) # PageUp in Tkinter
|
|
self.popup_window.bind("<Home>", self._navigate_home)
|
|
self.popup_window.bind("<End>", self._navigate_end)
|
|
self.popup_window.bind("<Return>", self._confirm_selection)
|
|
self.popup_window.bind("<FocusOut>", lambda e: self._close_dropdown())
|
|
|
|
# Set focus to popup
|
|
self.popup_window.focus_set()
|
|
|
|
# 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:
|
|
# 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()
|
|
|
|
def _position_popup(self):
|
|
"""Position the popup window below the button."""
|
|
# Update to get accurate dimensions
|
|
self.popup_window.update_idletasks()
|
|
|
|
# Get button position
|
|
x = self.dropdown_button.winfo_rootx()
|
|
y = self.dropdown_button.winfo_rooty() + self.dropdown_button.winfo_height()
|
|
|
|
# Get screen dimensions
|
|
screen_width = self.winfo_screenwidth()
|
|
screen_height = self.winfo_screenheight()
|
|
|
|
# Get popup dimensions
|
|
popup_width = self.popup_window.winfo_width()
|
|
popup_height = self.popup_window.winfo_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 = self.dropdown_button.winfo_rooty() - popup_height
|
|
|
|
self.popup_window.wm_geometry(f"+{x}+{y}")
|
|
|
|
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()
|
|
|
|
if not (popup_x <= x <= popup_x + popup_width and
|
|
popup_y <= y <= popup_y + popup_height):
|
|
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=("gray75", "gray25"),
|
|
border_color="blue"
|
|
)
|
|
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)
|