Spaces:
Paused
Paused
| """ | |
| GUI Launcher Custom Widgets | |
| Extended widgets for the GUI including collapsible frames and setting editors. | |
| """ | |
| import platform | |
| from typing import Any, Callable, Dict, List, Optional | |
| import customtkinter as ctk | |
| from .config import COLORS, DIMENSIONS, FONTS | |
| class CTkCollapsibleFrame(ctk.CTkFrame): | |
| """ | |
| A collapsible frame widget that can be expanded/collapsed by clicking the header. | |
| The header shows a title with an expand/collapse indicator, and the content | |
| area can contain any widgets. | |
| """ | |
| def __init__( | |
| self, | |
| master: Any, | |
| title: str, | |
| expanded: bool = False, | |
| on_toggle: Optional[Callable[[bool], None]] = None, | |
| **kwargs, | |
| ): | |
| """ | |
| Initialize the collapsible frame. | |
| Args: | |
| master: Parent widget | |
| title: Title text for the header | |
| expanded: Initial expanded state | |
| on_toggle: Callback when expanded state changes | |
| **kwargs: Additional CTkFrame arguments | |
| """ | |
| # Set default frame styling | |
| kwargs.setdefault("fg_color", COLORS["bg_medium"]) | |
| kwargs.setdefault("corner_radius", DIMENSIONS["corner_radius"]) | |
| super().__init__(master, **kwargs) | |
| self._title = title | |
| self._expanded = expanded | |
| self._on_toggle = on_toggle | |
| self._content_widgets: List[Any] = [] | |
| # Configure grid | |
| self.grid_columnconfigure(0, weight=1) | |
| # Create header | |
| self._create_header() | |
| # Create content container | |
| self._content_frame = ctk.CTkFrame( | |
| self, | |
| fg_color="transparent", | |
| ) | |
| # Initial state | |
| if self._expanded: | |
| self._content_frame.grid( | |
| row=1, column=0, sticky="ew", padx=10, pady=(0, 10) | |
| ) | |
| self._update_header_text() | |
| def _create_header(self) -> None: | |
| """Create the clickable header.""" | |
| self._header_frame = ctk.CTkFrame( | |
| self, | |
| fg_color="transparent", | |
| cursor="hand2", | |
| ) | |
| self._header_frame.grid(row=0, column=0, sticky="ew", padx=10, pady=10) | |
| self._header_frame.grid_columnconfigure(1, weight=1) | |
| # Expand/collapse indicator and title | |
| self._header_label = ctk.CTkLabel( | |
| self._header_frame, | |
| text="", | |
| font=ctk.CTkFont( | |
| family=FONTS["family"], size=FONTS["size_large"], weight="bold" | |
| ), | |
| text_color=COLORS["accent"], | |
| cursor="hand2", | |
| ) | |
| self._header_label.grid(row=0, column=0, sticky="w") | |
| # Bind click events | |
| self._header_frame.bind("<Button-1>", self._toggle) | |
| self._header_label.bind("<Button-1>", self._toggle) | |
| def _update_header_text(self) -> None: | |
| """Update header text based on expanded state.""" | |
| indicator = "▼" if self._expanded else "▶" | |
| self._header_label.configure(text=f"{indicator} {self._title}") | |
| def _toggle(self, event=None) -> None: | |
| """Toggle expanded/collapsed state.""" | |
| self._expanded = not self._expanded | |
| self._update_header_text() | |
| if self._expanded: | |
| self._content_frame.grid( | |
| row=1, column=0, sticky="ew", padx=10, pady=(0, 10) | |
| ) | |
| else: | |
| self._content_frame.grid_forget() | |
| if self._on_toggle: | |
| self._on_toggle(self._expanded) | |
| def expand(self) -> None: | |
| """Expand the frame.""" | |
| if not self._expanded: | |
| self._toggle() | |
| def collapse(self) -> None: | |
| """Collapse the frame.""" | |
| if self._expanded: | |
| self._toggle() | |
| def is_expanded(self) -> bool: | |
| """Check if frame is expanded.""" | |
| return self._expanded | |
| def set_title(self, title: str) -> None: | |
| """Update the title text.""" | |
| self._title = title | |
| self._update_header_text() | |
| def get_content_frame(self) -> ctk.CTkFrame: | |
| """Get the content frame to add widgets to.""" | |
| return self._content_frame | |
| class CTkSettingRow(ctk.CTkFrame): | |
| """ | |
| A single setting row with label and appropriate input widget. | |
| Supports: | |
| - bool: CTkSwitch | |
| - int/float: CTkEntry with validation | |
| - choice: CTkComboBox | |
| - string: CTkEntry | |
| """ | |
| def __init__( | |
| self, | |
| master: Any, | |
| key: str, | |
| label: str, | |
| value: Any, | |
| type_hint: str, | |
| tooltip: Optional[str] = None, | |
| on_change: Optional[Callable[[str, Any], None]] = None, | |
| **kwargs, | |
| ): | |
| """ | |
| Initialize a setting row. | |
| Args: | |
| master: Parent widget | |
| key: Setting key (env variable name) | |
| label: Display label | |
| value: Current value | |
| type_hint: Type hint (bool, int, float, choice:a,b,c, or string) | |
| tooltip: Optional tooltip text | |
| on_change: Callback when value changes (receives key and new value) | |
| """ | |
| kwargs.setdefault("fg_color", "transparent") | |
| super().__init__(master, **kwargs) | |
| self._key = key | |
| self._type_hint = type_hint | |
| self._on_change = on_change | |
| self._value_var: Any = None | |
| # Configure grid | |
| self.grid_columnconfigure(0, weight=0, minsize=280) | |
| self.grid_columnconfigure(1, weight=1) | |
| # Create label | |
| self._label = ctk.CTkLabel( | |
| self, | |
| text=label, | |
| font=ctk.CTkFont(size=FONTS["size_normal"]), | |
| text_color=COLORS["text_primary"], | |
| anchor="w", | |
| ) | |
| self._label.grid(row=0, column=0, sticky="w", padx=(0, 10), pady=5) | |
| # Create input widget based on type | |
| self._create_input(value) | |
| # Add tooltip if provided | |
| if tooltip: | |
| from .utils import CTkTooltip | |
| CTkTooltip(self._label, tooltip) | |
| if hasattr(self, "_input_widget"): | |
| CTkTooltip(self._input_widget, tooltip) | |
| def _create_input(self, value: Any) -> None: | |
| """Create the appropriate input widget.""" | |
| if self._type_hint == "bool": | |
| self._value_var = ctk.BooleanVar(value=bool(value)) | |
| self._input_widget = ctk.CTkSwitch( | |
| self, | |
| text="", | |
| variable=self._value_var, | |
| command=self._on_value_change, | |
| fg_color=COLORS["bg_light"], | |
| progress_color=COLORS["accent"], | |
| button_color=COLORS["text_primary"], | |
| button_hover_color=COLORS["accent_hover"], | |
| ) | |
| self._input_widget.grid(row=0, column=1, sticky="w", pady=5) | |
| elif self._type_hint == "int": | |
| self._value_var = ctk.StringVar(value=str(value)) | |
| self._value_var.trace_add("write", lambda *args: self._on_value_change()) | |
| self._input_widget = ctk.CTkEntry( | |
| self, | |
| textvariable=self._value_var, | |
| width=150, | |
| height=32, | |
| fg_color=COLORS["bg_light"], | |
| border_color=COLORS["border"], | |
| text_color=COLORS["text_primary"], | |
| ) | |
| self._input_widget.grid(row=0, column=1, sticky="w", pady=5) | |
| elif self._type_hint == "float": | |
| self._value_var = ctk.StringVar(value=str(value)) | |
| self._value_var.trace_add("write", lambda *args: self._on_value_change()) | |
| self._input_widget = ctk.CTkEntry( | |
| self, | |
| textvariable=self._value_var, | |
| width=150, | |
| height=32, | |
| fg_color=COLORS["bg_light"], | |
| border_color=COLORS["border"], | |
| text_color=COLORS["text_primary"], | |
| ) | |
| self._input_widget.grid(row=0, column=1, sticky="w", pady=5) | |
| elif self._type_hint.startswith("choice:"): | |
| choices = self._type_hint.replace("choice:", "").split(",") | |
| self._value_var = ctk.StringVar(value=str(value)) | |
| self._input_widget = ctk.CTkComboBox( | |
| self, | |
| values=choices, | |
| variable=self._value_var, | |
| command=lambda v: self._on_value_change(), | |
| width=180, | |
| height=32, | |
| fg_color=COLORS["bg_light"], | |
| border_color=COLORS["border"], | |
| button_color=COLORS["accent"], | |
| button_hover_color=COLORS["accent_hover"], | |
| dropdown_fg_color=COLORS["bg_medium"], | |
| dropdown_hover_color=COLORS["bg_light"], | |
| text_color=COLORS["text_primary"], | |
| dropdown_text_color=COLORS["text_primary"], | |
| ) | |
| self._input_widget.grid(row=0, column=1, sticky="w", pady=5) | |
| else: | |
| # Default: string entry | |
| self._value_var = ctk.StringVar(value=str(value) if value else "") | |
| self._value_var.trace_add("write", lambda *args: self._on_value_change()) | |
| self._input_widget = ctk.CTkEntry( | |
| self, | |
| textvariable=self._value_var, | |
| width=250, | |
| height=32, | |
| fg_color=COLORS["bg_light"], | |
| border_color=COLORS["border"], | |
| text_color=COLORS["text_primary"], | |
| ) | |
| self._input_widget.grid(row=0, column=1, sticky="w", pady=5) | |
| def _on_value_change(self) -> None: | |
| """Handle value change.""" | |
| if self._on_change: | |
| self._on_change(self._key, self.get_value()) | |
| def get_value(self) -> Any: | |
| """Get the current value with proper type conversion.""" | |
| if self._type_hint == "bool": | |
| return self._value_var.get() | |
| elif self._type_hint == "int": | |
| try: | |
| return int(self._value_var.get()) | |
| except ValueError: | |
| return 0 | |
| elif self._type_hint == "float": | |
| try: | |
| return float(self._value_var.get()) | |
| except ValueError: | |
| return 0.0 | |
| else: | |
| return self._value_var.get() | |
| def set_value(self, value: Any) -> None: | |
| """Set the value.""" | |
| if self._type_hint == "bool": | |
| self._value_var.set(bool(value)) | |
| else: | |
| self._value_var.set(str(value)) | |
| def get_key(self) -> str: | |
| """Get the setting key.""" | |
| return self._key | |
| def set_modified_indicator(self, modified: bool) -> None: | |
| """Show/hide modified indicator on the label.""" | |
| base_label = self._label.cget("text").replace(" *", "") | |
| if modified: | |
| self._label.configure(text=f"{base_label} *", text_color=COLORS["warning"]) | |
| else: | |
| self._label.configure(text=base_label, text_color=COLORS["text_primary"]) | |
| class CTkEnvSettingsPanel(ctk.CTkScrollableFrame): | |
| """ | |
| A scrollable panel containing all environment settings organized by category. | |
| Features: | |
| - Collapsible category sections | |
| - Tracks modified values | |
| - Provides save/reload/reset functionality | |
| """ | |
| def __init__( | |
| self, | |
| master: Any, | |
| env_manager: Any, | |
| on_save: Optional[Callable[[], None]] = None, | |
| on_change: Optional[Callable[[bool], None]] = None, | |
| **kwargs, | |
| ): | |
| """ | |
| Initialize the settings panel. | |
| Args: | |
| master: Parent widget | |
| env_manager: EnvManager instance | |
| on_save: Callback when settings are saved | |
| on_change: Callback when dirty state changes (receives is_dirty) | |
| """ | |
| # Set default colors for proper theming | |
| kwargs.setdefault("fg_color", COLORS["bg_dark"]) | |
| kwargs.setdefault("scrollbar_fg_color", COLORS["bg_medium"]) | |
| kwargs.setdefault("scrollbar_button_color", COLORS["border"]) | |
| kwargs.setdefault("scrollbar_button_hover_color", COLORS["accent"]) | |
| super().__init__(master, **kwargs) | |
| self._env_manager = env_manager | |
| self._on_save = on_save | |
| self._on_change = on_change | |
| self._setting_rows: Dict[str, CTkSettingRow] = {} | |
| self._category_frames: Dict[str, CTkCollapsibleFrame] = {} | |
| self._is_dirty = False | |
| # Build the UI | |
| self._build_settings_ui() | |
| # Enable mouse wheel scrolling | |
| self._bind_scroll_events() | |
| def _build_settings_ui(self) -> None: | |
| """Build the settings UI with collapsible categories.""" | |
| from .i18n import get_text | |
| # Get category order | |
| category_order = [ | |
| "server", | |
| "logging", | |
| "auth", | |
| "cookie", | |
| "browser", | |
| "api", | |
| "function_calling", | |
| "timeouts", | |
| "misc", | |
| ] | |
| for cat_key in category_order: | |
| keys = self._env_manager.get_category_keys(cat_key) | |
| if not keys: | |
| continue | |
| # Create collapsible frame for category | |
| cat_title = get_text(f"cat_{cat_key}") | |
| frame = CTkCollapsibleFrame( | |
| self, | |
| title=cat_title, | |
| expanded=False, | |
| ) | |
| frame.pack(fill="x", pady=(0, 10)) | |
| self._category_frames[cat_key] = frame | |
| # Get content frame and add settings | |
| content = frame.get_content_frame() | |
| content.grid_columnconfigure(0, weight=1) | |
| for idx, key in enumerate(sorted(keys)): | |
| schema = self._env_manager.get_schema_info(key) | |
| if not schema: | |
| continue | |
| default_val, type_hint, description, _ = schema | |
| current_val = self._env_manager.get(key) | |
| row = CTkSettingRow( | |
| content, | |
| key=key, | |
| label=description, | |
| value=current_val, | |
| type_hint=type_hint, | |
| on_change=self._on_setting_change, | |
| ) | |
| row.grid(row=idx, column=0, sticky="ew", padx=5, pady=2) | |
| self._setting_rows[key] = row | |
| def _on_setting_change(self, key: str, value: Any) -> None: | |
| """Handle individual setting change.""" | |
| self._env_manager.set(key, value) | |
| # Update modified indicator | |
| schema = self._env_manager.get_schema_info(key) | |
| if schema: | |
| default_val = schema[0] | |
| is_modified = value != default_val | |
| if key in self._setting_rows: | |
| self._setting_rows[key].set_modified_indicator(is_modified) | |
| # Check overall dirty state | |
| new_dirty = self._env_manager.is_dirty() | |
| if new_dirty != self._is_dirty: | |
| self._is_dirty = new_dirty | |
| if self._on_change: | |
| self._on_change(new_dirty) | |
| def save(self) -> bool: | |
| """Save all settings to .env file.""" | |
| success = self._env_manager.save() | |
| if success: | |
| self._is_dirty = False | |
| # Clear all modified indicators | |
| for row in self._setting_rows.values(): | |
| row.set_modified_indicator(False) | |
| if self._on_change: | |
| self._on_change(False) | |
| if self._on_save: | |
| self._on_save() | |
| return success | |
| def reload(self) -> None: | |
| """Reload settings from .env file.""" | |
| self._env_manager.load() | |
| # Update all setting rows | |
| for key, row in self._setting_rows.items(): | |
| value = self._env_manager.get(key) | |
| row.set_value(value) | |
| row.set_modified_indicator(False) | |
| self._is_dirty = False | |
| if self._on_change: | |
| self._on_change(False) | |
| def reset_to_defaults(self) -> None: | |
| """Reset all settings to default values.""" | |
| self._env_manager.reset_to_defaults() | |
| # Update all setting rows | |
| for key, row in self._setting_rows.items(): | |
| schema = self._env_manager.get_schema_info(key) | |
| if schema: | |
| row.set_value(schema[0]) | |
| row.set_modified_indicator(False) | |
| self._is_dirty = True | |
| if self._on_change: | |
| self._on_change(True) | |
| def is_dirty(self) -> bool: | |
| """Check if there are unsaved changes.""" | |
| return self._is_dirty | |
| def expand_all(self) -> None: | |
| """Expand all category frames.""" | |
| for frame in self._category_frames.values(): | |
| frame.expand() | |
| def collapse_all(self) -> None: | |
| """Collapse all category frames.""" | |
| for frame in self._category_frames.values(): | |
| frame.collapse() | |
| def get_modified_keys(self) -> List[str]: | |
| """Get list of modified setting keys.""" | |
| return self._env_manager.get_modified_keys() | |
| def _bind_scroll_events(self) -> None: | |
| """Bind mouse wheel scroll events for cross-platform compatibility.""" | |
| self._bind_scroll_to_widget(self) | |
| # Also bind to all category frames | |
| for frame in self._category_frames.values(): | |
| self._bind_scroll_to_widget(frame) | |
| self._bind_scroll_to_widget(frame.get_content_frame()) | |
| def _bind_scroll_to_widget(self, widget) -> None: | |
| """Bind scroll events to a specific widget and its children.""" | |
| if platform.system() == "Linux": | |
| widget.bind("<Button-4>", self._on_scroll_up, add="+") | |
| widget.bind("<Button-5>", self._on_scroll_down, add="+") | |
| else: | |
| widget.bind("<MouseWheel>", self._on_mousewheel, add="+") | |
| # Recursively bind to children | |
| for child in widget.winfo_children(): | |
| self._bind_scroll_to_widget(child) | |
| def _on_mousewheel(self, event) -> Optional[str]: | |
| """Handle mouse wheel on Windows/macOS.""" | |
| if hasattr(self, "_parent_canvas") and self._parent_canvas: | |
| if platform.system() == "Darwin": | |
| self._parent_canvas.yview_scroll(int(-1 * event.delta), "units") | |
| else: | |
| self._parent_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") | |
| return "break" # Stop event propagation to parent scrollable frames | |
| return None | |
| def _on_scroll_up(self, event) -> Optional[str]: | |
| """Handle scroll up on Linux.""" | |
| if hasattr(self, "_parent_canvas") and self._parent_canvas: | |
| self._parent_canvas.yview_scroll(-3, "units") | |
| return "break" # Stop event propagation to parent scrollable frames | |
| return None | |
| def _on_scroll_down(self, event) -> Optional[str]: | |
| """Handle scroll down on Linux.""" | |
| if hasattr(self, "_parent_canvas") and self._parent_canvas: | |
| self._parent_canvas.yview_scroll(3, "units") | |
| return "break" # Stop event propagation to parent scrollable frames | |
| return None | |