diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..5aec295 --- /dev/null +++ b/build.bat @@ -0,0 +1,16 @@ +@echo off +echo Building Azure Key Vault Secret Manager... +echo. + +REM Clean previous builds +if exist "dist" rmdir /s /q "dist" +if exist "build" rmdir /s /q "build" + +REM Build executable +pyinstaller build.spec --clean --noconfirm + +echo. +echo Build complete! +echo Executable location: dist\AzureKeyVaultSecretManager.exe +echo. +pause diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..589c1be --- /dev/null +++ b/build.sh @@ -0,0 +1,15 @@ +#!/bin/bash +echo "Building Azure Key Vault Secret Manager..." +echo "" + +# Clean previous builds +rm -rf dist build + +# Build executable +pyinstaller build.spec --clean --noconfirm + +echo "" +echo "Build complete!" +echo "Executable location: dist/AzureKeyVaultSecretManager.exe" +echo "" +read -p "Press enter to continue..." diff --git a/hook-customtkinter.py b/hook-customtkinter.py new file mode 100644 index 0000000..6808f9b --- /dev/null +++ b/hook-customtkinter.py @@ -0,0 +1,8 @@ +""" +PyInstaller runtime hook for CustomTkinter. +Ensures theme assets are found in bundled executable. +""" +from PyInstaller.utils.hooks import collect_data_files, collect_submodules + +datas = collect_data_files('customtkinter') +hiddenimports = collect_submodules('customtkinter') diff --git a/ui/components/unified_dropdown.py b/ui/components/unified_dropdown.py index 50ac583..edc804c 100644 --- a/ui/components/unified_dropdown.py +++ b/ui/components/unified_dropdown.py @@ -66,6 +66,12 @@ class UnifiedDropdown(ctk.CTkFrame): 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 @@ -133,7 +139,10 @@ class UnifiedDropdown(ctk.CTkFrame): Args: items: List of item dictionaries """ - self.items = items + # 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 @@ -202,6 +211,7 @@ class UnifiedDropdown(ctk.CTkFrame): 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() @@ -213,6 +223,20 @@ class UnifiedDropdown(ctk.CTkFrame): 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("", self._on_search_changed) + self.search_entry.bind("", 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 @@ -220,25 +244,27 @@ class UnifiedDropdown(ctk.CTkFrame): 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 + # 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 to ensure proper 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 (10px pack padding total) - frame_height = self._popup_height - 10 + # 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=False, padx=5, pady=5) + 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) @@ -275,7 +301,8 @@ class UnifiedDropdown(ctk.CTkFrame): border_width=1, border_color="gray50", hover_color=("gray70", "gray30"), - anchor="w" + 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) @@ -312,19 +339,30 @@ class UnifiedDropdown(ctk.CTkFrame): # Position popup self._position_popup() - # Bind keyboard events (use correct Tkinter key names) + # Bind keyboard events to search entry for navigation + self.search_entry.bind("", lambda e: self._focus_first_item()) + self.search_entry.bind("", lambda e: self._navigate_end()) + self.search_entry.bind("", self._navigate_page_down) # PageDown in Tkinter + self.search_entry.bind("", self._navigate_page_up) # PageUp in Tkinter + self.search_entry.bind("", self._navigate_home) + self.search_entry.bind("", self._navigate_end) + self.search_entry.bind("", self._confirm_selection) + + # Bind keyboard events to popup window as well (for when focus moves) 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_page_down) + self.popup_window.bind("", self._navigate_page_up) 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 any printable character to redirect focus to search entry + self.popup_window.bind("", 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("", self._check_click_outside, add="+") @@ -335,6 +373,12 @@ class UnifiedDropdown(ctk.CTkFrame): # 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() @@ -560,6 +604,129 @@ class UnifiedDropdown(ctk.CTkFrame): # 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("", 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. diff --git a/ui/main_window.py b/ui/main_window.py index 2e11a7e..04a36d7 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -38,7 +38,21 @@ class MainWindow(ctk.CTk): # Configure window self.title("Azure Key Vault Secret Manager") - self.geometry("950x850") + + # Center window on screen + window_width = 950 + window_height = 850 + + # Get screen dimensions + screen_width = self.winfo_screenwidth() + screen_height = self.winfo_screenheight() + + # Calculate center position + center_x = int((screen_width - window_width) / 2) + center_y = int((screen_height - window_height) / 2) + + # Set geometry with center position + self.geometry(f"{window_width}x{window_height}+{center_x}+{center_y}") # Set application icon (if available) self._set_icon() diff --git a/ui/secret_generation_frame.py b/ui/secret_generation_frame.py index 78d82ad..bdb3b00 100644 --- a/ui/secret_generation_frame.py +++ b/ui/secret_generation_frame.py @@ -6,6 +6,7 @@ UI component for secret generation form. import customtkinter as ctk from typing import List, Dict, Callable, Optional +from ui.components.unified_dropdown import UnifiedDropdown class SecretGenerationFrame(ctk.CTkFrame): @@ -51,31 +52,18 @@ class SecretGenerationFrame(ctk.CTkFrame): ) self.description_entry.grid(row=2, column=0, columnspan=2, padx=20, pady=(0, 15), sticky="ew") - # Key Vault Label - self.vault_label = ctk.CTkLabel( + # Key Vault Dropdown (using UnifiedDropdown) + self.vault_dropdown = UnifiedDropdown( self, - text="Select Key Vault:", - font=ctk.CTkFont(size=14) + title="Select Key Vault:", + on_selection_changed=self._on_vault_selected, + show_count=False, + display_format=lambda v: f"{v['name']} (RG: {v['resource_group']})", + max_dropdown_height=300, + button_width=600, + button_height=40 ) - self.vault_label.grid(row=3, column=0, columnspan=2, padx=20, pady=(0, 5), sticky="w") - - # Key Vault Dropdown Button (simplified inline version) - self.vault_dropdown_button = ctk.CTkButton( - self, - text="Please select a subscription first", - command=self._open_vault_dropdown, - width=600, - height=40, - font=ctk.CTkFont(size=14), - anchor="w", - state="disabled" - ) - self.vault_dropdown_button.grid(row=4, column=0, columnspan=2, padx=20, pady=(0, 15), sticky="ew") - - # Store vaults and selection - self.vaults = [] - self.selected_vault = None - self.vault_popup = None + self.vault_dropdown.grid(row=3, column=0, columnspan=2, padx=0, pady=(0, 15), sticky="ew") # Remove old secrets checkbox self.remove_old_checkbox = ctk.CTkCheckBox( @@ -83,7 +71,7 @@ class SecretGenerationFrame(ctk.CTkFrame): text="Remove old secrets after creating new one", font=ctk.CTkFont(size=14) ) - self.remove_old_checkbox.grid(row=5, column=0, columnspan=2, padx=20, pady=(0, 20), sticky="w") + self.remove_old_checkbox.grid(row=4, column=0, columnspan=2, padx=20, pady=(0, 20), sticky="w") # Generate button self.generate_button = ctk.CTkButton( @@ -94,7 +82,7 @@ class SecretGenerationFrame(ctk.CTkFrame): font=ctk.CTkFont(size=16, weight="bold"), state="disabled" ) - self.generate_button.grid(row=6, column=0, columnspan=2, padx=20, pady=(0, 20), sticky="ew") + self.generate_button.grid(row=5, column=0, columnspan=2, padx=20, pady=(0, 20), sticky="ew") # Configure grid self.grid_columnconfigure(0, weight=1) @@ -106,104 +94,12 @@ class SecretGenerationFrame(ctk.CTkFrame): Args: vaults: List of vault dictionaries with 'name' and 'resource_group' """ - self.vaults = vaults + self.vault_dropdown.set_items(vaults) - if vaults: - self.vault_dropdown_button.configure(state="normal") - # Auto-select first vault - self._select_vault(vaults[0]) - else: - self.vault_dropdown_button.configure(state="disabled", text="No Key Vaults found") - self.selected_vault = None - - def _open_vault_dropdown(self): - """Open the vault selection popup.""" - if not self.vaults or self.vault_popup: - return - - import tkinter as tk - - # Create popup - self.vault_popup = tk.Toplevel(self) - self.vault_popup.wm_overrideredirect(True) - self.vault_popup.wm_attributes("-topmost", True) - - # Calculate height - item_height = 44 - calculated_height = len(self.vaults) * item_height + 10 - popup_height = min(calculated_height, 300) - - # Create scrollable frame - scroll_frame = ctk.CTkScrollableFrame( - self.vault_popup, - width=580, - height=popup_height - ) - scroll_frame.pack(fill="both", expand=True) - - # Configure scroll speed to match main window (40px per scroll unit = 2x faster) - scroll_frame._parent_canvas.configure(yscrollincrement=40) - - # Create buttons - for idx, vault in enumerate(self.vaults): - display_text = f"{vault['name']} (RG: {vault['resource_group']})" - max_chars = 60 - truncated = display_text if len(display_text) <= max_chars else display_text[:max_chars] + "..." - - btn = ctk.CTkButton( - scroll_frame, - text=truncated, - command=lambda v=vault: self._select_vault_and_close(v), - 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") - scroll_frame.grid_columnconfigure(0, weight=1) - - # Bind mouse wheel - def popup_scroll(event): - if event.delta > 0: - scroll_frame._parent_canvas.yview_scroll(-1, "units") - else: - scroll_frame._parent_canvas.yview_scroll(1, "units") - return "break" - - btn.bind("", popup_scroll, add="+") - - # Position popup - self.vault_popup.update_idletasks() - x = self.vault_dropdown_button.winfo_rootx() - y = self.vault_dropdown_button.winfo_rooty() + self.vault_dropdown_button.winfo_height() - self.vault_popup.wm_geometry(f"+{x}+{y}") - - # Bind events - self.vault_popup.bind("", lambda e: self._close_vault_popup()) - self.vault_popup.bind("", lambda e: self._close_vault_popup()) - self.vault_popup.focus_set() - - def _select_vault(self, vault: Dict): - """Select a vault and update button text.""" - self.selected_vault = vault - display_text = f"{vault['name']} (RG: {vault['resource_group']})" - max_chars = 60 - truncated = display_text if len(display_text) <= max_chars else display_text[:max_chars] + "..." - self.vault_dropdown_button.configure(text=truncated) - - def _select_vault_and_close(self, vault: Dict): - """Select vault and close popup.""" - self._select_vault(vault) - self._close_vault_popup() - - def _close_vault_popup(self): - """Close the vault popup.""" - if self.vault_popup: - self.vault_popup.destroy() - self.vault_popup = None + def _on_vault_selected(self, vault: Dict): + """Handle vault selection.""" + # Selection is stored in dropdown - no additional action needed + pass def _on_generate_clicked(self): """Handle generate button click.""" @@ -222,7 +118,7 @@ class SecretGenerationFrame(ctk.CTkFrame): Returns: Dict: Selected vault or None """ - return self.selected_vault + return self.vault_dropdown.get_selected() def get_description(self) -> str: """ @@ -251,13 +147,12 @@ class SecretGenerationFrame(ctk.CTkFrame): """ if enabled: self.description_entry.configure(state="normal") - if self.vaults: - self.vault_dropdown_button.configure(state="normal") + self.vault_dropdown.set_enabled(True) self.remove_old_checkbox.configure(state="normal") self.generate_button.configure(state="normal") else: self.description_entry.configure(state="disabled") - self.vault_dropdown_button.configure(state="disabled") + self.vault_dropdown.set_enabled(False) self.remove_old_checkbox.configure(state="disabled") self.generate_button.configure(state="disabled") @@ -280,13 +175,7 @@ class SecretGenerationFrame(ctk.CTkFrame): Args: loading: Whether currently loading """ - if loading: - self.vault_dropdown_button.configure(state="disabled", text="Loading Key Vaults...") - else: - if self.vaults: - self.vault_dropdown_button.configure(state="normal") - else: - self.vault_dropdown_button.configure(state="disabled", text="No Key Vaults found") + self.vault_dropdown.set_loading(loading) def reset(self): """Reset the form to initial state."""