This commit is contained in:
2025-12-22 09:57:49 +01:00
parent 87865e2c6d
commit e00c47a56f
4 changed files with 236 additions and 78 deletions
+102 -23
View File
@@ -69,6 +69,8 @@ class UnifiedDropdown(ctk.CTkFrame):
# 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)
@@ -118,6 +120,9 @@ class UnifiedDropdown(ctk.CTkFrame):
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)
@@ -170,8 +175,19 @@ class UnifiedDropdown(ctk.CTkFrame):
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:
@@ -187,21 +203,42 @@ class UnifiedDropdown(ctk.CTkFrame):
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
# Each item: 40px button + 4px padding = 44px per item
item_height = 44
calculated_height = len(self.items) * item_height + 10 # +10 for padding
# 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)
# Use calculated height, but cap at max_dropdown_height
popup_height = min(calculated_height, self.max_dropdown_height)
# 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
# Create scrollable frame for items
self.popup_frame = ctk.CTkScrollableFrame(
self.popup_window,
width=self.button_width - 20,
height=popup_height
height=frame_height
)
self.popup_frame.pack(fill="both", expand=True)
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)
@@ -220,6 +257,14 @@ class UnifiedDropdown(ctk.CTkFrame):
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,
@@ -232,7 +277,7 @@ class UnifiedDropdown(ctk.CTkFrame):
hover_color=("gray70", "gray30"),
anchor="w"
)
btn.grid(row=idx, column=0, padx=5, pady=2, sticky="ew")
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
@@ -276,7 +321,7 @@ class UnifiedDropdown(ctk.CTkFrame):
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())
# Removed FocusOut binding - it causes race conditions with button clicks
# Set focus to popup
self.popup_window.focus_set()
@@ -286,7 +331,10 @@ class UnifiedDropdown(ctk.CTkFrame):
def _close_dropdown(self):
"""Close the dropdown popup."""
if self.popup_window:
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()
@@ -310,22 +358,37 @@ class UnifiedDropdown(ctk.CTkFrame):
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."""
# Update to get accurate dimensions
# Force complete update to ensure all widgets are rendered
self.popup_window.update_idletasks()
self.dropdown_button.update_idletasks()
# Get button position
x = self.dropdown_button.winfo_rootx()
y = self.dropdown_button.winfo_rooty() + self.dropdown_button.winfo_height()
# 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()
# Get popup dimensions
popup_width = self.popup_window.winfo_width()
popup_height = self.popup_window.winfo_height()
# 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:
@@ -333,10 +396,14 @@ class UnifiedDropdown(ctk.CTkFrame):
# Position above if not enough space below
if y + popup_height > screen_height:
y = self.dropdown_button.winfo_rooty() - popup_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:
@@ -346,8 +413,20 @@ class UnifiedDropdown(ctk.CTkFrame):
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):
# 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):
@@ -390,8 +469,8 @@ class UnifiedDropdown(ctk.CTkFrame):
for i, btn in enumerate(self.item_buttons):
if i == index:
btn.configure(
fg_color=("gray75", "gray25"),
border_color="blue"
fg_color=("#3b8ed0", "#1f538d"),
border_color=("#3b8ed0", "#1f538d")
)
else:
btn.configure(