First commit
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
App Selection Frame
|
||||
|
||||
UI component for selecting an app registration.
|
||||
"""
|
||||
|
||||
import customtkinter as ctk
|
||||
from typing import List, Dict, Callable, Optional
|
||||
from ui.components import UnifiedDropdown
|
||||
|
||||
|
||||
class AppSelectionFrame(ctk.CTkFrame):
|
||||
"""Frame for app registration selection."""
|
||||
|
||||
def __init__(self, parent, on_app_selected: Callable = None):
|
||||
"""
|
||||
Initialize the app selection frame.
|
||||
|
||||
Args:
|
||||
parent: Parent widget
|
||||
on_app_selected: Callback function when an app is selected
|
||||
"""
|
||||
super().__init__(parent)
|
||||
|
||||
self.on_app_selected = on_app_selected
|
||||
|
||||
# Configure frame
|
||||
self.configure(corner_radius=10, border_width=2)
|
||||
|
||||
# Create unified dropdown
|
||||
self.dropdown = UnifiedDropdown(
|
||||
self,
|
||||
title="App Registration Selection",
|
||||
on_selection_changed=self._on_app_selected,
|
||||
show_count=True,
|
||||
display_key='display_name',
|
||||
max_dropdown_height=400
|
||||
)
|
||||
self.dropdown.grid(row=0, column=0, sticky="ew")
|
||||
self.dropdown.set_placeholder("Please connect to Azure first")
|
||||
|
||||
# Configure grid
|
||||
self.grid_columnconfigure(0, weight=1)
|
||||
|
||||
def set_apps(self, apps: List[Dict[str, str]]):
|
||||
"""
|
||||
Set the list of applications.
|
||||
|
||||
Args:
|
||||
apps: List of app dictionaries with 'id', 'app_id', and 'display_name'
|
||||
"""
|
||||
self.dropdown.set_items(apps)
|
||||
|
||||
def _on_app_selected(self, app: Dict):
|
||||
"""
|
||||
Handle app selection.
|
||||
|
||||
Args:
|
||||
app: The selected app dictionary
|
||||
"""
|
||||
if self.on_app_selected:
|
||||
self.on_app_selected(app)
|
||||
|
||||
def get_selected_app(self) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
Get the currently selected app.
|
||||
|
||||
Returns:
|
||||
Dict: Selected app or None
|
||||
"""
|
||||
return self.dropdown.get_selected()
|
||||
|
||||
def set_enabled(self, enabled: bool):
|
||||
"""
|
||||
Enable or disable the frame.
|
||||
|
||||
Args:
|
||||
enabled: Whether to enable the frame
|
||||
"""
|
||||
self.dropdown.set_enabled(enabled)
|
||||
|
||||
def set_loading(self, loading: bool):
|
||||
"""
|
||||
Set loading state.
|
||||
|
||||
Args:
|
||||
loading: Whether currently loading
|
||||
"""
|
||||
self.dropdown.set_loading(loading)
|
||||
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
UI Components Package
|
||||
|
||||
Custom reusable UI components for the application.
|
||||
"""
|
||||
|
||||
from .unified_dropdown import UnifiedDropdown
|
||||
from .tooltip import ToolTip
|
||||
|
||||
__all__ = ['UnifiedDropdown', 'ToolTip']
|
||||
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
ToolTip Component
|
||||
|
||||
Lightweight tooltip widget that shows on hover with configurable delay.
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class ToolTip:
|
||||
"""
|
||||
Creates a tooltip for a given widget.
|
||||
|
||||
Shows full text on hover if the displayed text is truncated.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
widget: tk.Widget,
|
||||
text: str,
|
||||
delay: int = 500,
|
||||
wrap_length: int = 300
|
||||
):
|
||||
"""
|
||||
Initialize the tooltip.
|
||||
|
||||
Args:
|
||||
widget: The widget to attach the tooltip to
|
||||
text: The text to display in the tooltip
|
||||
delay: Delay in milliseconds before showing tooltip
|
||||
wrap_length: Maximum width in pixels before wrapping text
|
||||
"""
|
||||
self.widget = widget
|
||||
self.text = text
|
||||
self.delay = delay
|
||||
self.wrap_length = wrap_length
|
||||
|
||||
self.tooltip_window: Optional[tk.Toplevel] = None
|
||||
self.after_id: Optional[str] = None
|
||||
|
||||
# Bind hover events
|
||||
self.widget.bind("<Enter>", self._on_enter, add="+")
|
||||
self.widget.bind("<Leave>", self._on_leave, add="+")
|
||||
self.widget.bind("<ButtonPress>", self._on_leave, add="+")
|
||||
|
||||
def _on_enter(self, event=None):
|
||||
"""Handle mouse enter event."""
|
||||
# Schedule tooltip to appear after delay
|
||||
self._cancel_scheduled()
|
||||
self.after_id = self.widget.after(self.delay, self._show_tooltip)
|
||||
|
||||
def _on_leave(self, event=None):
|
||||
"""Handle mouse leave event."""
|
||||
self._cancel_scheduled()
|
||||
self._hide_tooltip()
|
||||
|
||||
def _cancel_scheduled(self):
|
||||
"""Cancel scheduled tooltip appearance."""
|
||||
if self.after_id:
|
||||
self.widget.after_cancel(self.after_id)
|
||||
self.after_id = None
|
||||
|
||||
def _show_tooltip(self):
|
||||
"""Display the tooltip window."""
|
||||
if self.tooltip_window or not self.text:
|
||||
return
|
||||
|
||||
# Get widget position
|
||||
x = self.widget.winfo_rootx() + 20
|
||||
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 5
|
||||
|
||||
# Create tooltip window
|
||||
self.tooltip_window = tk.Toplevel(self.widget)
|
||||
self.tooltip_window.wm_overrideredirect(True) # Remove window decorations
|
||||
|
||||
# Handle screen edge cases
|
||||
screen_width = self.widget.winfo_screenwidth()
|
||||
screen_height = self.widget.winfo_screenheight()
|
||||
|
||||
# Create label with text
|
||||
label = tk.Label(
|
||||
self.tooltip_window,
|
||||
text=self.text,
|
||||
justify=tk.LEFT,
|
||||
background="#ffffe0", # Light yellow background
|
||||
foreground="#000000", # Black text
|
||||
relief=tk.SOLID,
|
||||
borderwidth=1,
|
||||
wraplength=self.wrap_length,
|
||||
font=("TkDefaultFont", 9),
|
||||
padx=8,
|
||||
pady=6
|
||||
)
|
||||
label.pack()
|
||||
|
||||
# Update to get actual size
|
||||
self.tooltip_window.update_idletasks()
|
||||
tooltip_width = self.tooltip_window.winfo_width()
|
||||
tooltip_height = self.tooltip_window.winfo_height()
|
||||
|
||||
# Adjust position if near screen edges
|
||||
if x + tooltip_width > screen_width:
|
||||
x = screen_width - tooltip_width - 10
|
||||
if y + tooltip_height > screen_height:
|
||||
y = self.widget.winfo_rooty() - tooltip_height - 5
|
||||
|
||||
# Position the tooltip
|
||||
self.tooltip_window.wm_geometry(f"+{x}+{y}")
|
||||
|
||||
def _hide_tooltip(self):
|
||||
"""Hide the tooltip window."""
|
||||
if self.tooltip_window:
|
||||
self.tooltip_window.destroy()
|
||||
self.tooltip_window = None
|
||||
|
||||
def update_text(self, text: str):
|
||||
"""
|
||||
Update the tooltip text.
|
||||
|
||||
Args:
|
||||
text: New text to display
|
||||
"""
|
||||
self.text = text
|
||||
if self.tooltip_window:
|
||||
self._hide_tooltip()
|
||||
|
||||
def destroy(self):
|
||||
"""Clean up the tooltip."""
|
||||
self._cancel_scheduled()
|
||||
self._hide_tooltip()
|
||||
|
||||
# Unbind events
|
||||
try:
|
||||
self.widget.unbind("<Enter>")
|
||||
self.widget.unbind("<Leave>")
|
||||
self.widget.unbind("<ButtonPress>")
|
||||
except:
|
||||
pass
|
||||
@@ -0,0 +1,535 @@
|
||||
"""
|
||||
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)
|
||||
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
Login Frame
|
||||
|
||||
UI component for authentication.
|
||||
"""
|
||||
|
||||
import customtkinter as ctk
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class LoginFrame(ctk.CTkFrame):
|
||||
"""Frame for authentication UI."""
|
||||
|
||||
def __init__(self, parent, on_connect: Callable = None):
|
||||
"""
|
||||
Initialize the login frame.
|
||||
|
||||
Args:
|
||||
parent: Parent widget
|
||||
on_connect: Callback function when connect button is clicked
|
||||
"""
|
||||
super().__init__(parent)
|
||||
|
||||
self.on_connect = on_connect
|
||||
self.is_authenticated = False
|
||||
|
||||
# Configure frame
|
||||
self.configure(corner_radius=10, border_width=2)
|
||||
|
||||
# Title
|
||||
self.title_label = ctk.CTkLabel(
|
||||
self,
|
||||
text="Authentication",
|
||||
font=ctk.CTkFont(size=18, weight="bold")
|
||||
)
|
||||
self.title_label.grid(row=0, column=0, columnspan=3, padx=20, pady=(20, 10), sticky="w")
|
||||
|
||||
# Status indicator (colored circle)
|
||||
self.status_indicator = ctk.CTkLabel(
|
||||
self,
|
||||
text="●",
|
||||
font=ctk.CTkFont(size=20),
|
||||
text_color="red"
|
||||
)
|
||||
self.status_indicator.grid(row=1, column=0, padx=(20, 5), pady=10)
|
||||
|
||||
# Status text
|
||||
self.status_label = ctk.CTkLabel(
|
||||
self,
|
||||
text="Not Connected",
|
||||
font=ctk.CTkFont(size=14)
|
||||
)
|
||||
self.status_label.grid(row=1, column=1, padx=5, pady=10, sticky="w")
|
||||
|
||||
# Connect button
|
||||
self.connect_button = ctk.CTkButton(
|
||||
self,
|
||||
text="Connect to Azure",
|
||||
command=self._on_connect_clicked,
|
||||
width=200,
|
||||
height=40,
|
||||
font=ctk.CTkFont(size=14, weight="bold")
|
||||
)
|
||||
self.connect_button.grid(row=1, column=2, padx=20, pady=10, sticky="e")
|
||||
|
||||
# Configure grid
|
||||
self.grid_columnconfigure(1, weight=1)
|
||||
|
||||
def _on_connect_clicked(self):
|
||||
"""Handle connect button click."""
|
||||
if self.on_connect:
|
||||
self.on_connect()
|
||||
|
||||
def set_authenticated(self, authenticated: bool):
|
||||
"""
|
||||
Update authentication status.
|
||||
|
||||
Args:
|
||||
authenticated: Whether authentication succeeded
|
||||
"""
|
||||
self.is_authenticated = authenticated
|
||||
|
||||
if authenticated:
|
||||
self.status_indicator.configure(text_color="green")
|
||||
self.status_label.configure(text="Connected")
|
||||
self.connect_button.configure(state="disabled", text="Connected")
|
||||
else:
|
||||
self.status_indicator.configure(text_color="red")
|
||||
self.status_label.configure(text="Not Connected")
|
||||
self.connect_button.configure(state="normal", text="Connect to Azure")
|
||||
|
||||
def set_connecting(self):
|
||||
"""Set UI to connecting state."""
|
||||
self.status_indicator.configure(text_color="orange")
|
||||
self.status_label.configure(text="Connecting...")
|
||||
self.connect_button.configure(state="disabled", text="Connecting...")
|
||||
|
||||
def enable_button(self):
|
||||
"""Enable the connect button (for retry after error)."""
|
||||
if not self.is_authenticated:
|
||||
self.connect_button.configure(state="normal", text="Connect to Azure")
|
||||
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
Main Window
|
||||
|
||||
Main GUI window that integrates all frames.
|
||||
"""
|
||||
|
||||
import customtkinter as ctk
|
||||
from ui.login_frame import LoginFrame
|
||||
from ui.subscription_selection_frame import SubscriptionSelectionFrame
|
||||
from ui.app_selection_frame import AppSelectionFrame
|
||||
from ui.secret_generation_frame import SecretGenerationFrame
|
||||
from ui.result_frame import ResultFrame
|
||||
from typing import Callable, Dict, List
|
||||
|
||||
|
||||
class MainWindow(ctk.CTk):
|
||||
"""Main application window."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_connect: Callable = None,
|
||||
on_subscription_selected: Callable = None,
|
||||
on_app_selected: Callable = None,
|
||||
on_generate_secret: Callable = None,
|
||||
on_generate_another: Callable = None
|
||||
):
|
||||
"""
|
||||
Initialize the main window.
|
||||
|
||||
Args:
|
||||
on_connect: Callback for authentication
|
||||
on_subscription_selected: Callback when subscription is selected
|
||||
on_app_selected: Callback when app is selected
|
||||
on_generate_secret: Callback when generate secret is clicked
|
||||
on_generate_another: Callback when generate another is clicked
|
||||
"""
|
||||
super().__init__()
|
||||
|
||||
# Configure window
|
||||
self.title("Azure Key Vault Secret Manager")
|
||||
self.geometry("950x850")
|
||||
|
||||
# Set application icon (if available)
|
||||
self._set_icon()
|
||||
|
||||
# Configure grid
|
||||
self.grid_columnconfigure(0, weight=1)
|
||||
self.grid_rowconfigure(1, weight=1)
|
||||
|
||||
# Title
|
||||
self.title_label = ctk.CTkLabel(
|
||||
self,
|
||||
text="Azure Key Vault Secret Manager",
|
||||
font=ctk.CTkFont(size=28, weight="bold")
|
||||
)
|
||||
self.title_label.grid(row=0, column=0, padx=20, pady=20, sticky="n")
|
||||
|
||||
# Scrollable frame for content with optimized scrolling
|
||||
self.scroll_frame = ctk.CTkScrollableFrame(
|
||||
self,
|
||||
scrollbar_button_color=("gray75", "gray25"),
|
||||
scrollbar_button_hover_color=("gray65", "gray35")
|
||||
)
|
||||
self.scroll_frame.grid(row=1, column=0, padx=20, pady=(0, 20), sticky="nsew")
|
||||
self.scroll_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# Optimize scroll step for smoother scrolling (40px = 2x faster than default 20px)
|
||||
self.scroll_frame._parent_canvas.configure(yscrollincrement=40)
|
||||
|
||||
# Add smooth mouse wheel scrolling
|
||||
def smooth_scroll(event):
|
||||
"""Handle smooth mouse wheel scrolling."""
|
||||
# Platform-specific delta handling
|
||||
if event.delta > 0:
|
||||
delta = -1 # Scroll up
|
||||
else:
|
||||
delta = 1 # Scroll down
|
||||
|
||||
self.scroll_frame._parent_canvas.yview_scroll(delta, "units")
|
||||
return "break" # Prevent event propagation
|
||||
|
||||
# Bind mouse wheel event (Windows/Mac)
|
||||
self.scroll_frame._parent_canvas.bind_all("<MouseWheel>", smooth_scroll, add="+")
|
||||
|
||||
# Login Frame
|
||||
self.login_frame = LoginFrame(self.scroll_frame, on_connect=on_connect)
|
||||
self.login_frame.grid(row=0, column=0, padx=0, pady=(0, 15), sticky="ew")
|
||||
|
||||
# Subscription Selection Frame
|
||||
self.subscription_selection_frame = SubscriptionSelectionFrame(
|
||||
self.scroll_frame,
|
||||
on_subscription_selected=on_subscription_selected
|
||||
)
|
||||
self.subscription_selection_frame.grid(row=1, column=0, padx=0, pady=(0, 15), sticky="ew")
|
||||
self.subscription_selection_frame.set_enabled(False)
|
||||
|
||||
# App Selection Frame
|
||||
self.app_selection_frame = AppSelectionFrame(
|
||||
self.scroll_frame,
|
||||
on_app_selected=on_app_selected
|
||||
)
|
||||
self.app_selection_frame.grid(row=2, column=0, padx=0, pady=(0, 15), sticky="ew")
|
||||
self.app_selection_frame.set_enabled(False)
|
||||
|
||||
# Secret Generation Frame
|
||||
self.secret_generation_frame = SecretGenerationFrame(
|
||||
self.scroll_frame,
|
||||
on_generate=on_generate_secret
|
||||
)
|
||||
self.secret_generation_frame.grid(row=3, column=0, padx=0, pady=(0, 15), sticky="ew")
|
||||
self.secret_generation_frame.set_enabled(False)
|
||||
|
||||
# Result Frame (initially hidden)
|
||||
self.result_frame = ResultFrame(
|
||||
self.scroll_frame,
|
||||
on_generate_another=on_generate_another
|
||||
)
|
||||
self.result_frame.grid(row=4, column=0, padx=0, pady=(0, 15), sticky="ew")
|
||||
self.result_frame.hide()
|
||||
|
||||
def set_authenticated(self, authenticated: bool):
|
||||
"""
|
||||
Update UI after authentication.
|
||||
|
||||
Args:
|
||||
authenticated: Whether authentication succeeded
|
||||
"""
|
||||
self.login_frame.set_authenticated(authenticated)
|
||||
|
||||
if authenticated:
|
||||
self.subscription_selection_frame.set_enabled(True)
|
||||
|
||||
def set_connecting(self):
|
||||
"""Set UI to connecting state."""
|
||||
self.login_frame.set_connecting()
|
||||
|
||||
def enable_connect_button(self):
|
||||
"""Enable connect button for retry."""
|
||||
self.login_frame.enable_button()
|
||||
|
||||
def set_subscriptions(self, subscriptions: List[Dict[str, str]]):
|
||||
"""
|
||||
Set the list of subscriptions.
|
||||
|
||||
Args:
|
||||
subscriptions: List of subscription dictionaries
|
||||
"""
|
||||
self.subscription_selection_frame.set_subscriptions(subscriptions)
|
||||
|
||||
def set_subscription_selected(self):
|
||||
"""Enable UI after subscription is selected."""
|
||||
self.app_selection_frame.set_enabled(True)
|
||||
self.secret_generation_frame.set_enabled(True)
|
||||
|
||||
def set_apps(self, apps: List[Dict[str, str]]):
|
||||
"""
|
||||
Set the list of applications.
|
||||
|
||||
Args:
|
||||
apps: List of app dictionaries
|
||||
"""
|
||||
self.app_selection_frame.set_apps(apps)
|
||||
|
||||
def set_vaults(self, vaults: List[Dict[str, str]]):
|
||||
"""
|
||||
Set the list of Key Vaults.
|
||||
|
||||
Args:
|
||||
vaults: List of vault dictionaries
|
||||
"""
|
||||
self.secret_generation_frame.set_vaults(vaults)
|
||||
|
||||
def set_loading_subscriptions(self, loading: bool):
|
||||
"""
|
||||
Set loading subscriptions state.
|
||||
|
||||
Args:
|
||||
loading: Whether currently loading
|
||||
"""
|
||||
self.subscription_selection_frame.set_loading(loading)
|
||||
|
||||
def set_loading_apps(self, loading: bool):
|
||||
"""
|
||||
Set loading apps state.
|
||||
|
||||
Args:
|
||||
loading: Whether currently loading
|
||||
"""
|
||||
self.app_selection_frame.set_loading(loading)
|
||||
|
||||
def set_loading_vaults(self, loading: bool):
|
||||
"""
|
||||
Set loading vaults state.
|
||||
|
||||
Args:
|
||||
loading: Whether currently loading
|
||||
"""
|
||||
self.secret_generation_frame.set_loading_vaults(loading)
|
||||
|
||||
def set_generating(self, generating: bool):
|
||||
"""
|
||||
Set generating secret state.
|
||||
|
||||
Args:
|
||||
generating: Whether currently generating
|
||||
"""
|
||||
self.secret_generation_frame.set_generating(generating)
|
||||
|
||||
def show_result(
|
||||
self,
|
||||
secret_name: str,
|
||||
vault_name: str,
|
||||
secret_value: str,
|
||||
removed_count: int = 0
|
||||
):
|
||||
"""
|
||||
Show secret generation result.
|
||||
|
||||
Args:
|
||||
secret_name: The sanitized secret name
|
||||
vault_name: The Key Vault name
|
||||
secret_value: The secret value
|
||||
removed_count: Number of old secrets removed
|
||||
"""
|
||||
self.result_frame.show_result(secret_name, vault_name, secret_value, removed_count)
|
||||
|
||||
def reset_form(self):
|
||||
"""Reset the secret generation form."""
|
||||
self.secret_generation_frame.reset()
|
||||
self.result_frame.hide()
|
||||
|
||||
def get_selected_app(self) -> Dict[str, str]:
|
||||
"""Get the currently selected app."""
|
||||
return self.app_selection_frame.get_selected_app()
|
||||
|
||||
def get_description(self) -> str:
|
||||
"""Get the secret description."""
|
||||
return self.secret_generation_frame.get_description()
|
||||
|
||||
def get_selected_vault(self) -> Dict[str, str]:
|
||||
"""Get the currently selected vault."""
|
||||
return self.secret_generation_frame.get_selected_vault()
|
||||
|
||||
def get_remove_old_secrets(self) -> bool:
|
||||
"""Get whether to remove old secrets."""
|
||||
return self.secret_generation_frame.get_remove_old_secrets()
|
||||
|
||||
def _set_icon(self):
|
||||
"""Set the application icon if available."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Get the directory where the script is located
|
||||
script_dir = Path(__file__).parent.parent
|
||||
|
||||
# Try common icon file names and locations
|
||||
icon_paths = [
|
||||
script_dir / "icon.ico",
|
||||
script_dir / "assets" / "icon.ico",
|
||||
script_dir / "icon.png",
|
||||
script_dir / "assets" / "icon.png",
|
||||
]
|
||||
|
||||
for icon_path in icon_paths:
|
||||
if icon_path.exists():
|
||||
try:
|
||||
if icon_path.suffix == '.ico':
|
||||
# Use .ico file directly (Windows)
|
||||
self.iconbitmap(str(icon_path))
|
||||
print(f"Icon loaded: {icon_path}")
|
||||
return
|
||||
elif icon_path.suffix == '.png':
|
||||
# Use .png file with iconphoto (cross-platform)
|
||||
import tkinter as tk
|
||||
from PIL import Image, ImageTk
|
||||
img = Image.open(icon_path)
|
||||
photo = ImageTk.PhotoImage(img)
|
||||
self.iconphoto(True, photo)
|
||||
print(f"Icon loaded: {icon_path}")
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"Failed to load icon from {icon_path}: {str(e)}")
|
||||
|
||||
# No icon found - use default
|
||||
print("No custom icon found. Using default application icon.")
|
||||
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
Result Frame
|
||||
|
||||
UI component for displaying secret generation results.
|
||||
"""
|
||||
|
||||
import customtkinter as ctk
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class ResultFrame(ctk.CTkFrame):
|
||||
"""Frame for displaying secret generation results."""
|
||||
|
||||
def __init__(self, parent, on_generate_another: Callable = None):
|
||||
"""
|
||||
Initialize the result frame.
|
||||
|
||||
Args:
|
||||
parent: Parent widget
|
||||
on_generate_another: Callback function when "Generate Another" is clicked
|
||||
"""
|
||||
super().__init__(parent)
|
||||
|
||||
self.on_generate_another = on_generate_another
|
||||
|
||||
# Configure frame with success colors
|
||||
self.configure(
|
||||
corner_radius=10,
|
||||
border_width=3,
|
||||
border_color="green",
|
||||
fg_color=("#E8F5E9", "#1B5E20") # Light green / Dark green
|
||||
)
|
||||
|
||||
# Success title
|
||||
self.success_label = ctk.CTkLabel(
|
||||
self,
|
||||
text="✓ Secret Generated Successfully!",
|
||||
font=ctk.CTkFont(size=20, weight="bold"),
|
||||
text_color="green"
|
||||
)
|
||||
self.success_label.grid(row=0, column=0, columnspan=2, padx=20, pady=(20, 15), sticky="w")
|
||||
|
||||
# Secret Name Label
|
||||
self.name_title_label = ctk.CTkLabel(
|
||||
self,
|
||||
text="Secret Name:",
|
||||
font=ctk.CTkFont(size=14, weight="bold")
|
||||
)
|
||||
self.name_title_label.grid(row=1, column=0, padx=20, pady=(0, 5), sticky="w")
|
||||
|
||||
self.name_value_label = ctk.CTkLabel(
|
||||
self,
|
||||
text="",
|
||||
font=ctk.CTkFont(size=14)
|
||||
)
|
||||
self.name_value_label.grid(row=1, column=1, padx=20, pady=(0, 5), sticky="w")
|
||||
|
||||
# Key Vault Label
|
||||
self.vault_title_label = ctk.CTkLabel(
|
||||
self,
|
||||
text="Key Vault:",
|
||||
font=ctk.CTkFont(size=14, weight="bold")
|
||||
)
|
||||
self.vault_title_label.grid(row=2, column=0, padx=20, pady=(0, 5), sticky="w")
|
||||
|
||||
self.vault_value_label = ctk.CTkLabel(
|
||||
self,
|
||||
text="",
|
||||
font=ctk.CTkFont(size=14)
|
||||
)
|
||||
self.vault_value_label.grid(row=2, column=1, padx=20, pady=(0, 5), sticky="w")
|
||||
|
||||
# Old Secrets Removed Label (initially hidden)
|
||||
self.removed_title_label = ctk.CTkLabel(
|
||||
self,
|
||||
text="Old Secrets Removed:",
|
||||
font=ctk.CTkFont(size=14, weight="bold")
|
||||
)
|
||||
self.removed_title_label.grid(row=3, column=0, padx=20, pady=(0, 15), sticky="w")
|
||||
self.removed_title_label.grid_remove() # Hide initially
|
||||
|
||||
self.removed_value_label = ctk.CTkLabel(
|
||||
self,
|
||||
text="",
|
||||
font=ctk.CTkFont(size=14)
|
||||
)
|
||||
self.removed_value_label.grid(row=3, column=1, padx=20, pady=(0, 15), sticky="w")
|
||||
self.removed_value_label.grid_remove() # Hide initially
|
||||
|
||||
# Secret Value Label
|
||||
self.secret_label = ctk.CTkLabel(
|
||||
self,
|
||||
text="Secret Value (copy this now):",
|
||||
font=ctk.CTkFont(size=14, weight="bold")
|
||||
)
|
||||
self.secret_label.grid(row=4, column=0, columnspan=2, padx=20, pady=(10, 5), sticky="w")
|
||||
|
||||
# Secret Value Textbox (read-only)
|
||||
self.secret_textbox = ctk.CTkTextbox(
|
||||
self,
|
||||
height=80,
|
||||
font=ctk.CTkFont(family="Consolas", size=12),
|
||||
wrap="word"
|
||||
)
|
||||
self.secret_textbox.grid(row=5, column=0, columnspan=2, padx=20, pady=(0, 15), sticky="ew")
|
||||
|
||||
# Buttons frame
|
||||
self.buttons_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
self.buttons_frame.grid(row=6, column=0, columnspan=2, padx=20, pady=(0, 20))
|
||||
|
||||
# Copy button
|
||||
self.copy_button = ctk.CTkButton(
|
||||
self.buttons_frame,
|
||||
text="Copy to Clipboard",
|
||||
command=self._on_copy_clicked,
|
||||
width=180,
|
||||
height=40,
|
||||
font=ctk.CTkFont(size=14, weight="bold")
|
||||
)
|
||||
self.copy_button.grid(row=0, column=0, padx=(0, 10))
|
||||
|
||||
# Generate Another button
|
||||
self.another_button = ctk.CTkButton(
|
||||
self.buttons_frame,
|
||||
text="Generate Another Secret",
|
||||
command=self._on_another_clicked,
|
||||
width=200,
|
||||
height=40,
|
||||
font=ctk.CTkFont(size=14, weight="bold")
|
||||
)
|
||||
self.another_button.grid(row=0, column=1, padx=(10, 0))
|
||||
|
||||
# Configure grid
|
||||
self.grid_columnconfigure(1, weight=1)
|
||||
|
||||
# Initially hide the frame
|
||||
self.grid_remove()
|
||||
|
||||
def show_result(
|
||||
self,
|
||||
secret_name: str,
|
||||
vault_name: str,
|
||||
secret_value: str,
|
||||
removed_count: int = 0
|
||||
):
|
||||
"""
|
||||
Display secret generation result.
|
||||
|
||||
Args:
|
||||
secret_name: The sanitized secret name
|
||||
vault_name: The Key Vault name
|
||||
secret_value: The secret value
|
||||
removed_count: Number of old secrets removed (0 if not applicable)
|
||||
"""
|
||||
# Update labels
|
||||
self.name_value_label.configure(text=secret_name)
|
||||
self.vault_value_label.configure(text=vault_name)
|
||||
|
||||
# Show/hide removed count
|
||||
if removed_count > 0:
|
||||
self.removed_value_label.configure(text=str(removed_count))
|
||||
self.removed_title_label.grid()
|
||||
self.removed_value_label.grid()
|
||||
else:
|
||||
self.removed_title_label.grid_remove()
|
||||
self.removed_value_label.grid_remove()
|
||||
|
||||
# Set secret value in textbox
|
||||
self.secret_textbox.delete("1.0", "end")
|
||||
self.secret_textbox.insert("1.0", secret_value)
|
||||
self.secret_textbox.configure(state="disabled")
|
||||
|
||||
# Show the frame
|
||||
self.grid()
|
||||
|
||||
def _on_copy_clicked(self):
|
||||
"""Handle copy button click."""
|
||||
# Get secret value from textbox
|
||||
secret_value = self.secret_textbox.get("1.0", "end").strip()
|
||||
|
||||
# Copy to clipboard
|
||||
self.clipboard_clear()
|
||||
self.clipboard_append(secret_value)
|
||||
|
||||
# Show temporary confirmation
|
||||
original_text = self.copy_button.cget("text")
|
||||
self.copy_button.configure(text="✓ Copied!")
|
||||
self.after(2000, lambda: self.copy_button.configure(text=original_text))
|
||||
|
||||
def _on_another_clicked(self):
|
||||
"""Handle generate another button click."""
|
||||
if self.on_generate_another:
|
||||
self.on_generate_another()
|
||||
|
||||
def hide(self):
|
||||
"""Hide the result frame."""
|
||||
self.grid_remove()
|
||||
@@ -0,0 +1,294 @@
|
||||
"""
|
||||
Secret Generation Frame
|
||||
|
||||
UI component for secret generation form.
|
||||
"""
|
||||
|
||||
import customtkinter as ctk
|
||||
from typing import List, Dict, Callable, Optional
|
||||
|
||||
|
||||
class SecretGenerationFrame(ctk.CTkFrame):
|
||||
"""Frame for secret generation form."""
|
||||
|
||||
def __init__(self, parent, on_generate: Callable = None):
|
||||
"""
|
||||
Initialize the secret generation frame.
|
||||
|
||||
Args:
|
||||
parent: Parent widget
|
||||
on_generate: Callback function when generate button is clicked
|
||||
"""
|
||||
super().__init__(parent)
|
||||
|
||||
self.on_generate = on_generate
|
||||
|
||||
# Configure frame
|
||||
self.configure(corner_radius=10, border_width=2)
|
||||
|
||||
# Title
|
||||
self.title_label = ctk.CTkLabel(
|
||||
self,
|
||||
text="Secret Generation",
|
||||
font=ctk.CTkFont(size=18, weight="bold")
|
||||
)
|
||||
self.title_label.grid(row=0, column=0, columnspan=2, padx=20, pady=(20, 15), sticky="w")
|
||||
|
||||
# Secret Description Label
|
||||
self.description_label = ctk.CTkLabel(
|
||||
self,
|
||||
text="Secret Description:",
|
||||
font=ctk.CTkFont(size=14)
|
||||
)
|
||||
self.description_label.grid(row=1, column=0, columnspan=2, padx=20, pady=(0, 5), sticky="w")
|
||||
|
||||
# Secret Description Entry
|
||||
self.description_entry = ctk.CTkEntry(
|
||||
self,
|
||||
placeholder_text="e.g., Production API Key 2025",
|
||||
height=40,
|
||||
font=ctk.CTkFont(size=14)
|
||||
)
|
||||
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(
|
||||
self,
|
||||
text="Select Key Vault:",
|
||||
font=ctk.CTkFont(size=14)
|
||||
)
|
||||
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
|
||||
|
||||
# Remove old secrets checkbox
|
||||
self.remove_old_checkbox = ctk.CTkCheckBox(
|
||||
self,
|
||||
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")
|
||||
|
||||
# Generate button
|
||||
self.generate_button = ctk.CTkButton(
|
||||
self,
|
||||
text="Generate Secret",
|
||||
command=self._on_generate_clicked,
|
||||
height=50,
|
||||
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")
|
||||
|
||||
# Configure grid
|
||||
self.grid_columnconfigure(0, weight=1)
|
||||
|
||||
def set_vaults(self, vaults: List[Dict[str, str]]):
|
||||
"""
|
||||
Set the list of Key Vaults.
|
||||
|
||||
Args:
|
||||
vaults: List of vault dictionaries with 'name' and 'resource_group'
|
||||
"""
|
||||
self.vaults = 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("<MouseWheel>", 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("<Escape>", lambda e: self._close_vault_popup())
|
||||
self.vault_popup.bind("<FocusOut>", 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_generate_clicked(self):
|
||||
"""Handle generate button click."""
|
||||
if self.on_generate:
|
||||
description = self.description_entry.get().strip()
|
||||
remove_old = self.remove_old_checkbox.get()
|
||||
vault = self.get_selected_vault()
|
||||
|
||||
if description and vault:
|
||||
self.on_generate(description, vault, remove_old)
|
||||
|
||||
def get_selected_vault(self) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
Get the currently selected Key Vault.
|
||||
|
||||
Returns:
|
||||
Dict: Selected vault or None
|
||||
"""
|
||||
return self.selected_vault
|
||||
|
||||
def get_description(self) -> str:
|
||||
"""
|
||||
Get the secret description.
|
||||
|
||||
Returns:
|
||||
str: Description text
|
||||
"""
|
||||
return self.description_entry.get().strip()
|
||||
|
||||
def get_remove_old_secrets(self) -> bool:
|
||||
"""
|
||||
Get whether to remove old secrets.
|
||||
|
||||
Returns:
|
||||
bool: True if checkbox is checked
|
||||
"""
|
||||
return self.remove_old_checkbox.get()
|
||||
|
||||
def set_enabled(self, enabled: bool):
|
||||
"""
|
||||
Enable or disable the frame.
|
||||
|
||||
Args:
|
||||
enabled: Whether to enable the frame
|
||||
"""
|
||||
if enabled:
|
||||
self.description_entry.configure(state="normal")
|
||||
if self.vaults:
|
||||
self.vault_dropdown_button.configure(state="normal")
|
||||
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.remove_old_checkbox.configure(state="disabled")
|
||||
self.generate_button.configure(state="disabled")
|
||||
|
||||
def set_generating(self, generating: bool):
|
||||
"""
|
||||
Set generating state.
|
||||
|
||||
Args:
|
||||
generating: Whether currently generating
|
||||
"""
|
||||
if generating:
|
||||
self.generate_button.configure(state="disabled", text="Generating...")
|
||||
else:
|
||||
self.generate_button.configure(state="normal", text="Generate Secret")
|
||||
|
||||
def set_loading_vaults(self, loading: bool):
|
||||
"""
|
||||
Set loading vaults state.
|
||||
|
||||
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")
|
||||
|
||||
def reset(self):
|
||||
"""Reset the form to initial state."""
|
||||
self.description_entry.delete(0, 'end')
|
||||
self.remove_old_checkbox.deselect()
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Subscription Selection Frame
|
||||
|
||||
UI component for selecting an Azure subscription.
|
||||
"""
|
||||
|
||||
import customtkinter as ctk
|
||||
from typing import List, Dict, Callable, Optional
|
||||
from ui.components import UnifiedDropdown
|
||||
|
||||
|
||||
class SubscriptionSelectionFrame(ctk.CTkFrame):
|
||||
"""Frame for subscription selection."""
|
||||
|
||||
def __init__(self, parent, on_subscription_selected: Callable = None):
|
||||
"""
|
||||
Initialize the subscription selection frame.
|
||||
|
||||
Args:
|
||||
parent: Parent widget
|
||||
on_subscription_selected: Callback function when a subscription is selected
|
||||
"""
|
||||
super().__init__(parent)
|
||||
|
||||
self.on_subscription_selected = on_subscription_selected
|
||||
|
||||
# Configure frame
|
||||
self.configure(corner_radius=10, border_width=2)
|
||||
|
||||
# Create unified dropdown
|
||||
self.dropdown = UnifiedDropdown(
|
||||
self,
|
||||
title="Subscription Selection",
|
||||
on_selection_changed=self._on_subscription_selected,
|
||||
show_count=True,
|
||||
display_key='name',
|
||||
max_dropdown_height=300
|
||||
)
|
||||
self.dropdown.grid(row=0, column=0, sticky="ew")
|
||||
self.dropdown.set_placeholder("Please connect to Azure first")
|
||||
|
||||
# Configure grid
|
||||
self.grid_columnconfigure(0, weight=1)
|
||||
|
||||
def set_subscriptions(self, subscriptions: List[Dict[str, str]]):
|
||||
"""
|
||||
Set the list of subscriptions.
|
||||
|
||||
Args:
|
||||
subscriptions: List of subscription dictionaries with 'id' and 'name'
|
||||
"""
|
||||
self.dropdown.set_items(subscriptions)
|
||||
|
||||
def _on_subscription_selected(self, subscription: Dict):
|
||||
"""
|
||||
Handle subscription selection.
|
||||
|
||||
Args:
|
||||
subscription: The selected subscription dictionary
|
||||
"""
|
||||
if self.on_subscription_selected:
|
||||
self.on_subscription_selected(subscription)
|
||||
|
||||
def get_selected_subscription(self) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
Get the currently selected subscription.
|
||||
|
||||
Returns:
|
||||
Dict: Selected subscription or None
|
||||
"""
|
||||
return self.dropdown.get_selected()
|
||||
|
||||
def set_enabled(self, enabled: bool):
|
||||
"""
|
||||
Enable or disable the frame.
|
||||
|
||||
Args:
|
||||
enabled: Whether to enable the frame
|
||||
"""
|
||||
self.dropdown.set_enabled(enabled)
|
||||
|
||||
def set_loading(self, loading: bool):
|
||||
"""
|
||||
Set loading state.
|
||||
|
||||
Args:
|
||||
loading: Whether currently loading
|
||||
"""
|
||||
self.dropdown.set_loading(loading)
|
||||
Reference in New Issue
Block a user