Files
I-SecretUpdate/ui/components/unified_dropdown.py
T
2026-01-20 16:05:31 +01:00

782 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)