From 846ba251b519c6436649a80ec3b08ff1843e4ab9 Mon Sep 17 00:00:00 2001 From: Mirrowel <28632877+Mirrowel@users.noreply.github.com> Date: Mon, 8 Dec 2025 20:36:31 +0100 Subject: [PATCH 01/12] =?UTF-8?q?feat(ui):=20=E2=9C=A8=20add=20GUI=20for?= =?UTF-8?q?=20visual=20model=20filter=20configuration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a comprehensive CustomTkinter-based GUI application for managing model ignore/whitelist rules per provider, accessible from the settings tool. - Created model_filter_gui.py with full-featured visual editor (2600+ lines) - Implemented dual synchronized model lists showing unfiltered and filtered states - Added color-coded rule chips with visual association to affected models - Real-time pattern preview as users type filter rules - Interactive click/right-click functionality for model-rule relationships - Context menus for quick actions (add to ignore/whitelist, copy names) - Comprehensive help documentation with keyboard shortcuts - Unsaved changes detection with save/discard/cancel workflow - Background prefetching of models for all providers to improve responsiveness - Integration with settings tool as menu option #6 The GUI provides pattern matching with exact match, prefix wildcard (*), and match-all support. Whitelist rules take priority over ignore rules. All changes are persisted to .env file using IGNORE_MODELS_* and WHITELIST_MODELS_* variables. --- requirements.txt | 3 + src/proxy_app/model_filter_gui.py | 2601 +++++++++++++++++++++++++++++ src/proxy_app/settings_tool.py | 37 +- 3 files changed, 2633 insertions(+), 8 deletions(-) create mode 100644 src/proxy_app/model_filter_gui.py diff --git a/requirements.txt b/requirements.txt index edb2bcea..64f6aca7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,3 +19,6 @@ aiohttp colorlog rich + +# GUI for model filter configuration +customtkinter diff --git a/src/proxy_app/model_filter_gui.py b/src/proxy_app/model_filter_gui.py new file mode 100644 index 00000000..45d57b66 --- /dev/null +++ b/src/proxy_app/model_filter_gui.py @@ -0,0 +1,2601 @@ +""" +Model Filter GUI - Visual editor for model ignore/whitelist rules. + +A CustomTkinter application that provides a friendly interface for managing +which models are available per provider through ignore lists and whitelists. + +Features: +- Two synchronized model lists showing all fetched models and their filtered status +- Color-coded rules with visual association to affected models +- Real-time filtering preview as you type patterns +- Click interactions to highlight rule-model relationships +- Right-click context menus for quick actions +- Comprehensive help documentation +""" + +import customtkinter as ctk +from tkinter import Menu +import asyncio +import threading +import os +import re +from pathlib import Path +from dataclasses import dataclass, field +from typing import List, Dict, Tuple, Optional, Callable, Set +from dotenv import load_dotenv, set_key, unset_key + + +# ════════════════════════════════════════════════════════════════════════════════ +# CONSTANTS & CONFIGURATION +# ════════════════════════════════════════════════════════════════════════════════ + +# Window settings +WINDOW_TITLE = "Model Filter Configuration" +WINDOW_DEFAULT_SIZE = "1000x750" +WINDOW_MIN_WIDTH = 850 +WINDOW_MIN_HEIGHT = 600 + +# Color scheme (dark mode) +BG_PRIMARY = "#1a1a2e" # Main background +BG_SECONDARY = "#16213e" # Card/panel background +BG_TERTIARY = "#0f0f1a" # Input fields, lists +BG_HOVER = "#1f2b47" # Hover state +BORDER_COLOR = "#2a2a4a" # Subtle borders +TEXT_PRIMARY = "#e8e8e8" # Main text +TEXT_SECONDARY = "#a0a0a0" # Muted text +TEXT_MUTED = "#666680" # Very muted text +ACCENT_BLUE = "#4a9eff" # Primary accent +ACCENT_GREEN = "#2ecc71" # Success/normal +ACCENT_RED = "#e74c3c" # Danger/ignore +ACCENT_YELLOW = "#f1c40f" # Warning + +# Status colors +NORMAL_COLOR = "#2ecc71" # Green - models not affected by any rule +HIGHLIGHT_BG = "#2a3a5a" # Background for highlighted items + +# Ignore rules - warm color progression (reds/oranges) +IGNORE_COLORS = [ + "#e74c3c", # Bright red + "#c0392b", # Dark red + "#e67e22", # Orange + "#d35400", # Dark orange + "#f39c12", # Gold + "#e91e63", # Pink + "#ff5722", # Deep orange + "#f44336", # Material red + "#ff6b6b", # Coral + "#ff8a65", # Light deep orange +] + +# Whitelist rules - cool color progression (blues/teals) +WHITELIST_COLORS = [ + "#3498db", # Blue + "#2980b9", # Dark blue + "#1abc9c", # Teal + "#16a085", # Dark teal + "#9b59b6", # Purple + "#8e44ad", # Dark purple + "#00bcd4", # Cyan + "#2196f3", # Material blue + "#64b5f6", # Light blue + "#4dd0e1", # Light cyan +] + +# Font configuration +FONT_FAMILY = "Segoe UI" +FONT_SIZE_SMALL = 11 +FONT_SIZE_NORMAL = 12 +FONT_SIZE_LARGE = 14 +FONT_SIZE_TITLE = 16 +FONT_SIZE_HEADER = 20 + + +# ════════════════════════════════════════════════════════════════════════════════ +# DATA CLASSES +# ════════════════════════════════════════════════════════════════════════════════ + + +@dataclass +class FilterRule: + """Represents a single filter rule (ignore or whitelist pattern).""" + + pattern: str + color: str + rule_type: str # 'ignore' or 'whitelist' + affected_count: int = 0 + affected_models: List[str] = field(default_factory=list) + + def __hash__(self): + return hash((self.pattern, self.rule_type)) + + def __eq__(self, other): + if not isinstance(other, FilterRule): + return False + return self.pattern == other.pattern and self.rule_type == other.rule_type + + +@dataclass +class ModelStatus: + """Status information for a single model.""" + + model_id: str + status: str # 'normal', 'ignored', 'whitelisted' + color: str + affecting_rule: Optional[FilterRule] = None + + @property + def display_name(self) -> str: + """Get the model name without provider prefix for display.""" + if "/" in self.model_id: + return self.model_id.split("/", 1)[1] + return self.model_id + + @property + def provider(self) -> str: + """Extract provider from model ID.""" + if "/" in self.model_id: + return self.model_id.split("/")[0] + return "" + + +# ════════════════════════════════════════════════════════════════════════════════ +# FILTER ENGINE +# ════════════════════════════════════════════════════════════════════════════════ + + +class FilterEngine: + """ + Core filtering logic with rule management. + + Handles pattern matching, rule storage, and status calculation. + Tracks changes for save/discard functionality. + """ + + def __init__(self): + self.ignore_rules: List[FilterRule] = [] + self.whitelist_rules: List[FilterRule] = [] + self._ignore_color_index = 0 + self._whitelist_color_index = 0 + self._original_ignore_patterns: Set[str] = set() + self._original_whitelist_patterns: Set[str] = set() + self._current_provider: Optional[str] = None + + def reset(self): + """Clear all rules and reset state.""" + self.ignore_rules.clear() + self.whitelist_rules.clear() + self._ignore_color_index = 0 + self._whitelist_color_index = 0 + self._original_ignore_patterns.clear() + self._original_whitelist_patterns.clear() + + def _get_next_ignore_color(self) -> str: + """Get next color for ignore rules (cycles through palette).""" + color = IGNORE_COLORS[self._ignore_color_index % len(IGNORE_COLORS)] + self._ignore_color_index += 1 + return color + + def _get_next_whitelist_color(self) -> str: + """Get next color for whitelist rules (cycles through palette).""" + color = WHITELIST_COLORS[self._whitelist_color_index % len(WHITELIST_COLORS)] + self._whitelist_color_index += 1 + return color + + def add_ignore_rule(self, pattern: str) -> Optional[FilterRule]: + """Add a new ignore rule. Returns the rule if added, None if duplicate.""" + pattern = pattern.strip() + if not pattern: + return None + + # Check for duplicates + for rule in self.ignore_rules: + if rule.pattern == pattern: + return None + + rule = FilterRule( + pattern=pattern, color=self._get_next_ignore_color(), rule_type="ignore" + ) + self.ignore_rules.append(rule) + return rule + + def add_whitelist_rule(self, pattern: str) -> Optional[FilterRule]: + """Add a new whitelist rule. Returns the rule if added, None if duplicate.""" + pattern = pattern.strip() + if not pattern: + return None + + # Check for duplicates + for rule in self.whitelist_rules: + if rule.pattern == pattern: + return None + + rule = FilterRule( + pattern=pattern, + color=self._get_next_whitelist_color(), + rule_type="whitelist", + ) + self.whitelist_rules.append(rule) + return rule + + def remove_ignore_rule(self, pattern: str) -> bool: + """Remove an ignore rule by pattern. Returns True if removed.""" + for i, rule in enumerate(self.ignore_rules): + if rule.pattern == pattern: + self.ignore_rules.pop(i) + return True + return False + + def remove_whitelist_rule(self, pattern: str) -> bool: + """Remove a whitelist rule by pattern. Returns True if removed.""" + for i, rule in enumerate(self.whitelist_rules): + if rule.pattern == pattern: + self.whitelist_rules.pop(i) + return True + return False + + def _pattern_matches(self, model_id: str, pattern: str) -> bool: + """ + Check if a pattern matches a model ID. + + Supports: + - Exact match: "gpt-4" matches only "gpt-4" + - Prefix wildcard: "gpt-4*" matches "gpt-4", "gpt-4-turbo", etc. + - Match all: "*" matches everything + """ + # Extract model name without provider prefix + if "/" in model_id: + provider_model_name = model_id.split("/", 1)[1] + else: + provider_model_name = model_id + + if pattern == "*": + return True + elif pattern.endswith("*"): + prefix = pattern[:-1] + return provider_model_name.startswith(prefix) or model_id.startswith(prefix) + else: + # Exact match against full ID or provider model name + return model_id == pattern or provider_model_name == pattern + + def get_model_status(self, model_id: str) -> ModelStatus: + """ + Determine the status of a model based on current rules. + + Priority: Whitelist > Ignore > Normal + """ + # Check whitelist first (takes priority) + for rule in self.whitelist_rules: + if self._pattern_matches(model_id, rule.pattern): + return ModelStatus( + model_id=model_id, + status="whitelisted", + color=rule.color, + affecting_rule=rule, + ) + + # Then check ignore + for rule in self.ignore_rules: + if self._pattern_matches(model_id, rule.pattern): + return ModelStatus( + model_id=model_id, + status="ignored", + color=rule.color, + affecting_rule=rule, + ) + + # Default: normal + return ModelStatus( + model_id=model_id, status="normal", color=NORMAL_COLOR, affecting_rule=None + ) + + def get_all_statuses(self, models: List[str]) -> List[ModelStatus]: + """Get status for all models.""" + return [self.get_model_status(m) for m in models] + + def update_affected_counts(self, models: List[str]): + """Update the affected_count and affected_models for all rules.""" + # Reset counts + for rule in self.ignore_rules + self.whitelist_rules: + rule.affected_count = 0 + rule.affected_models = [] + + # Count affected models + for model_id in models: + status = self.get_model_status(model_id) + if status.affecting_rule: + status.affecting_rule.affected_count += 1 + status.affecting_rule.affected_models.append(model_id) + + def get_available_count(self, models: List[str]) -> Tuple[int, int]: + """Returns (available_count, total_count).""" + available = 0 + for model_id in models: + status = self.get_model_status(model_id) + if status.status != "ignored": + available += 1 + return available, len(models) + + def preview_pattern( + self, pattern: str, rule_type: str, models: List[str] + ) -> List[str]: + """ + Preview which models would be affected by a pattern without adding it. + Returns list of affected model IDs. + """ + affected = [] + pattern = pattern.strip() + if not pattern: + return affected + + for model_id in models: + if self._pattern_matches(model_id, pattern): + affected.append(model_id) + + return affected + + def load_from_env(self, provider: str): + """Load ignore/whitelist rules for a provider from environment.""" + self.reset() + self._current_provider = provider + load_dotenv(override=True) + + # Load ignore list + ignore_key = f"IGNORE_MODELS_{provider.upper()}" + ignore_value = os.getenv(ignore_key, "") + if ignore_value: + patterns = [p.strip() for p in ignore_value.split(",") if p.strip()] + for pattern in patterns: + self.add_ignore_rule(pattern) + self._original_ignore_patterns = set(patterns) + + # Load whitelist + whitelist_key = f"WHITELIST_MODELS_{provider.upper()}" + whitelist_value = os.getenv(whitelist_key, "") + if whitelist_value: + patterns = [p.strip() for p in whitelist_value.split(",") if p.strip()] + for pattern in patterns: + self.add_whitelist_rule(pattern) + self._original_whitelist_patterns = set(patterns) + + def save_to_env(self, provider: str) -> bool: + """ + Save current rules to .env file. + Returns True if successful. + """ + env_path = Path.cwd() / ".env" + + try: + ignore_key = f"IGNORE_MODELS_{provider.upper()}" + whitelist_key = f"WHITELIST_MODELS_{provider.upper()}" + + # Save ignore patterns + ignore_patterns = [rule.pattern for rule in self.ignore_rules] + if ignore_patterns: + set_key(str(env_path), ignore_key, ",".join(ignore_patterns)) + else: + # Remove the key if no patterns + unset_key(str(env_path), ignore_key) + + # Save whitelist patterns + whitelist_patterns = [rule.pattern for rule in self.whitelist_rules] + if whitelist_patterns: + set_key(str(env_path), whitelist_key, ",".join(whitelist_patterns)) + else: + unset_key(str(env_path), whitelist_key) + + # Update original state + self._original_ignore_patterns = set(ignore_patterns) + self._original_whitelist_patterns = set(whitelist_patterns) + + return True + except Exception as e: + print(f"Error saving to .env: {e}") + return False + + def has_unsaved_changes(self) -> bool: + """Check if current rules differ from saved state.""" + current_ignore = set(rule.pattern for rule in self.ignore_rules) + current_whitelist = set(rule.pattern for rule in self.whitelist_rules) + + return ( + current_ignore != self._original_ignore_patterns + or current_whitelist != self._original_whitelist_patterns + ) + + def discard_changes(self): + """Reload rules from environment, discarding unsaved changes.""" + if self._current_provider: + self.load_from_env(self._current_provider) + + +# ════════════════════════════════════════════════════════════════════════════════ +# MODEL FETCHER +# ════════════════════════════════════════════════════════════════════════════════ + +# Global cache for fetched models (persists across provider switches) +_model_cache: Dict[str, List[str]] = {} + + +class ModelFetcher: + """ + Handles async model fetching from providers. + + Runs fetching in a background thread to avoid blocking the GUI. + Includes caching to avoid refetching on every provider switch. + """ + + @staticmethod + def get_cached_models(provider: str) -> Optional[List[str]]: + """Get cached models for a provider, if available.""" + return _model_cache.get(provider) + + @staticmethod + def clear_cache(provider: Optional[str] = None): + """Clear model cache. If provider specified, only clear that provider.""" + if provider: + _model_cache.pop(provider, None) + else: + _model_cache.clear() + + @staticmethod + def get_available_providers() -> List[str]: + """Get list of providers that have credentials configured.""" + providers = set() + load_dotenv(override=True) + + # Scan environment for API keys (handles numbered keys like GEMINI_API_KEY_1) + for key in os.environ: + if "_API_KEY" in key and "PROXY_API_KEY" not in key: + # Extract provider: NVIDIA_NIM_API_KEY_1 -> nvidia_nim + provider = key.split("_API_KEY")[0].lower() + providers.add(provider) + + # Check for OAuth providers + oauth_dir = Path("oauth_creds") + if oauth_dir.exists(): + for file in oauth_dir.glob("*_oauth_*.json"): + provider = file.name.split("_oauth_")[0] + providers.add(provider) + + return sorted(list(providers)) + + @staticmethod + def _find_credential(provider: str) -> Optional[str]: + """Find a credential for a provider (handles numbered keys).""" + load_dotenv(override=True) + provider_upper = provider.upper() + + # Try exact match first (e.g., GEMINI_API_KEY) + exact_key = f"{provider_upper}_API_KEY" + if os.getenv(exact_key): + return os.getenv(exact_key) + + # Look for numbered keys (e.g., GEMINI_API_KEY_1, NVIDIA_NIM_API_KEY_1) + for key, value in os.environ.items(): + if key.startswith(f"{provider_upper}_API_KEY") and value: + return value + + # Check for OAuth credentials + oauth_dir = Path("oauth_creds") + if oauth_dir.exists(): + oauth_files = list(oauth_dir.glob(f"{provider}_oauth_*.json")) + if oauth_files: + return str(oauth_files[0]) + + return None + + @staticmethod + async def _fetch_models_async(provider: str) -> Tuple[List[str], Optional[str]]: + """ + Async implementation of model fetching. + Returns: (models_list, error_message_or_none) + """ + try: + import httpx + from rotator_library.providers import PROVIDER_PLUGINS + + # Get credential + credential = ModelFetcher._find_credential(provider) + if not credential: + return [], f"No credentials found for '{provider}'" + + # Get provider class + provider_class = PROVIDER_PLUGINS.get(provider.lower()) + if not provider_class: + return [], f"Unknown provider: '{provider}'" + + # Fetch models + async with httpx.AsyncClient(timeout=30.0) as client: + instance = provider_class() + models = await instance.get_models(credential, client) + return models, None + + except ImportError as e: + return [], f"Import error: {e}" + except Exception as e: + return [], f"Failed to fetch: {str(e)}" + + @staticmethod + def fetch_models( + provider: str, + on_success: Callable[[List[str]], None], + on_error: Callable[[str], None], + on_start: Optional[Callable[[], None]] = None, + force_refresh: bool = False, + ): + """ + Fetch models in a background thread. + + Args: + provider: Provider name (e.g., 'openai', 'gemini') + on_success: Callback with list of model IDs + on_error: Callback with error message + on_start: Optional callback when fetching starts + force_refresh: If True, bypass cache and fetch fresh + """ + # Check cache first (unless force refresh) + if not force_refresh: + cached = ModelFetcher.get_cached_models(provider) + if cached is not None: + on_success(cached) + return + + def run_fetch(): + if on_start: + on_start() + + try: + # Run async fetch in new event loop + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + models, error = loop.run_until_complete( + ModelFetcher._fetch_models_async(provider) + ) + # Clean up any pending tasks to avoid warnings + pending = asyncio.all_tasks(loop) + for task in pending: + task.cancel() + if pending: + loop.run_until_complete( + asyncio.gather(*pending, return_exceptions=True) + ) + finally: + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.close() + + if error: + on_error(error) + else: + # Cache the results + _model_cache[provider] = models + on_success(models) + + except Exception as e: + on_error(str(e)) + + thread = threading.Thread(target=run_fetch, daemon=True) + thread.start() + + +# ════════════════════════════════════════════════════════════════════════════════ +# HELP WINDOW +# ════════════════════════════════════════════════════════════════════════════════ + + +class HelpWindow(ctk.CTkToplevel): + """ + Modal help popup with comprehensive filtering documentation. + """ + + def __init__(self, parent): + super().__init__(parent) + + self.title("Help - Model Filtering") + self.geometry("700x600") + self.minsize(600, 500) + + # Make modal + self.transient(parent) + self.grab_set() + + # Configure appearance + self.configure(fg_color=BG_PRIMARY) + + # Build content + self._create_content() + + # Center on parent + self.update_idletasks() + x = parent.winfo_x() + (parent.winfo_width() - self.winfo_width()) // 2 + y = parent.winfo_y() + (parent.winfo_height() - self.winfo_height()) // 2 + self.geometry(f"+{x}+{y}") + + # Focus + self.focus_force() + + # Bind escape to close + self.bind("", lambda e: self.destroy()) + + def _create_content(self): + """Build the help content.""" + # Main scrollable frame + main_frame = ctk.CTkScrollableFrame( + self, + fg_color=BG_PRIMARY, + scrollbar_fg_color=BG_SECONDARY, + scrollbar_button_color=BORDER_COLOR, + ) + main_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # Title + title = ctk.CTkLabel( + main_frame, + text="📖 Model Filtering Guide", + font=(FONT_FAMILY, FONT_SIZE_HEADER, "bold"), + text_color=TEXT_PRIMARY, + ) + title.pack(anchor="w", pady=(0, 20)) + + # Sections + sections = [ + ( + "🎯 Overview", + """ +Model filtering allows you to control which models are available through your proxy for each provider. + +• Use the IGNORE list to block specific models +• Use the WHITELIST to ensure specific models are always available +• Whitelist ALWAYS takes priority over Ignore""", + ), + ( + "⚖️ Filtering Priority", + """ +When a model is checked, the following order is used: + +1. WHITELIST CHECK + If the model matches any whitelist pattern → AVAILABLE + (Whitelist overrides everything else) + +2. IGNORE CHECK + If the model matches any ignore pattern → BLOCKED + +3. DEFAULT + If no patterns match → AVAILABLE""", + ), + ( + "✏️ Pattern Syntax", + """ +Three types of patterns are supported: + +EXACT MATCH + Pattern: gpt-4 + Matches: only "gpt-4", nothing else + +PREFIX WILDCARD + Pattern: gpt-4* + Matches: "gpt-4", "gpt-4-turbo", "gpt-4-preview", etc. + +MATCH ALL + Pattern: * + Matches: every model for this provider""", + ), + ( + "💡 Common Patterns", + """ +BLOCK ALL, ALLOW SPECIFIC: + Ignore: * + Whitelist: gpt-4o, gpt-4o-mini + Result: Only gpt-4o and gpt-4o-mini available + +BLOCK PREVIEW MODELS: + Ignore: *-preview, *-preview* + Result: All preview variants blocked + +BLOCK SPECIFIC SERIES: + Ignore: o1*, dall-e* + Result: All o1 and DALL-E models blocked + +ALLOW ONLY LATEST: + Ignore: * + Whitelist: *-latest + Result: Only models ending in "-latest" available""", + ), + ( + "🖱️ Interface Guide", + """ +PROVIDER DROPDOWN + Select which provider to configure + +MODEL LISTS + • Left list: All fetched models (unfiltered) + • Right list: Same models with colored status + • Green = Available (normal) + • Red/Orange tones = Blocked (ignored) + • Blue/Teal tones = Whitelisted + +SEARCH BOX + Filter both lists to find specific models quickly + +CLICKING MODELS + • Left-click: Highlight the rule affecting this model + • Right-click: Context menu with quick actions + +CLICKING RULES + • Highlights all models affected by that rule + • Shows which models will be blocked/allowed + +RULE INPUT + • Enter patterns separated by commas + • Press Add or Enter to create rules + • Preview updates in real-time as you type + +DELETE RULES + • Click the × button on any rule to remove it""", + ), + ( + "⌨️ Keyboard Shortcuts", + """ +Ctrl+S Save changes +Ctrl+R Refresh models from provider +Ctrl+F Focus search box +F1 Open this help window +Escape Clear search / Close dialogs""", + ), + ( + "💾 Saving Changes", + """ +Changes are saved to your .env file in this format: + + IGNORE_MODELS_OPENAI=pattern1,pattern2* + WHITELIST_MODELS_OPENAI=specific-model + +Click "Save" to persist changes, or "Discard" to revert. +Closing the window with unsaved changes will prompt you.""", + ), + ] + + for title_text, content in sections: + self._add_section(main_frame, title_text, content) + + # Close button + close_btn = ctk.CTkButton( + main_frame, + text="Got it!", + font=(FONT_FAMILY, FONT_SIZE_NORMAL, "bold"), + fg_color=ACCENT_BLUE, + hover_color="#3a8aee", + height=40, + width=120, + command=self.destroy, + ) + close_btn.pack(pady=20) + + def _add_section(self, parent, title: str, content: str): + """Add a help section.""" + # Section title + title_label = ctk.CTkLabel( + parent, + text=title, + font=(FONT_FAMILY, FONT_SIZE_LARGE, "bold"), + text_color=ACCENT_BLUE, + ) + title_label.pack(anchor="w", pady=(15, 5)) + + # Separator + sep = ctk.CTkFrame(parent, height=1, fg_color=BORDER_COLOR) + sep.pack(fill="x", pady=(0, 10)) + + # Content + content_label = ctk.CTkLabel( + parent, + text=content.strip(), + font=(FONT_FAMILY, FONT_SIZE_NORMAL), + text_color=TEXT_SECONDARY, + justify="left", + anchor="w", + ) + content_label.pack(anchor="w", fill="x") + + +# ════════════════════════════════════════════════════════════════════════════════ +# CUSTOM DIALOG +# ════════════════════════════════════════════════════════════════════════════════ + + +class UnsavedChangesDialog(ctk.CTkToplevel): + """Modal dialog for unsaved changes confirmation.""" + + def __init__(self, parent): + super().__init__(parent) + + self.result: Optional[str] = None # 'save', 'discard', 'cancel' + + self.title("Unsaved Changes") + self.geometry("400x180") + self.resizable(False, False) + + # Make modal + self.transient(parent) + self.grab_set() + + # Configure appearance + self.configure(fg_color=BG_PRIMARY) + + # Build content + self._create_content() + + # Center on parent + self.update_idletasks() + x = parent.winfo_x() + (parent.winfo_width() - self.winfo_width()) // 2 + y = parent.winfo_y() + (parent.winfo_height() - self.winfo_height()) // 2 + self.geometry(f"+{x}+{y}") + + # Focus + self.focus_force() + + # Bind escape to cancel + self.bind("", lambda e: self._on_cancel()) + + # Handle window close + self.protocol("WM_DELETE_WINDOW", self._on_cancel) + + def _create_content(self): + """Build dialog content.""" + # Icon and message + msg_frame = ctk.CTkFrame(self, fg_color="transparent") + msg_frame.pack(fill="x", padx=30, pady=(25, 15)) + + icon = ctk.CTkLabel( + msg_frame, text="⚠️", font=(FONT_FAMILY, 32), text_color=ACCENT_YELLOW + ) + icon.pack(side="left", padx=(0, 15)) + + text_frame = ctk.CTkFrame(msg_frame, fg_color="transparent") + text_frame.pack(side="left", fill="x", expand=True) + + title = ctk.CTkLabel( + text_frame, + text="Unsaved Changes", + font=(FONT_FAMILY, FONT_SIZE_LARGE, "bold"), + text_color=TEXT_PRIMARY, + anchor="w", + ) + title.pack(anchor="w") + + subtitle = ctk.CTkLabel( + text_frame, + text="You have unsaved filter changes.\nWhat would you like to do?", + font=(FONT_FAMILY, FONT_SIZE_NORMAL), + text_color=TEXT_SECONDARY, + anchor="w", + justify="left", + ) + subtitle.pack(anchor="w") + + # Buttons + btn_frame = ctk.CTkFrame(self, fg_color="transparent") + btn_frame.pack(fill="x", padx=30, pady=(10, 25)) + + cancel_btn = ctk.CTkButton( + btn_frame, + text="Cancel", + font=(FONT_FAMILY, FONT_SIZE_NORMAL), + fg_color=BG_SECONDARY, + hover_color=BG_HOVER, + border_width=1, + border_color=BORDER_COLOR, + width=100, + command=self._on_cancel, + ) + cancel_btn.pack(side="right", padx=(10, 0)) + + discard_btn = ctk.CTkButton( + btn_frame, + text="Discard", + font=(FONT_FAMILY, FONT_SIZE_NORMAL), + fg_color=ACCENT_RED, + hover_color="#c0392b", + width=100, + command=self._on_discard, + ) + discard_btn.pack(side="right", padx=(10, 0)) + + save_btn = ctk.CTkButton( + btn_frame, + text="Save", + font=(FONT_FAMILY, FONT_SIZE_NORMAL), + fg_color=ACCENT_GREEN, + hover_color="#27ae60", + width=100, + command=self._on_save, + ) + save_btn.pack(side="right") + + def _on_save(self): + self.result = "save" + self.destroy() + + def _on_discard(self): + self.result = "discard" + self.destroy() + + def _on_cancel(self): + self.result = "cancel" + self.destroy() + + def show(self) -> Optional[str]: + """Show dialog and return result.""" + self.wait_window() + return self.result + + +# ════════════════════════════════════════════════════════════════════════════════ +# TOOLTIP +# ════════════════════════════════════════════════════════════════════════════════ + + +class ToolTip: + """Simple tooltip implementation for CustomTkinter widgets.""" + + def __init__(self, widget, text: str, delay: int = 500): + self.widget = widget + self.text = text + self.delay = delay + self.tooltip_window = None + self.after_id = None + + widget.bind("", self._schedule_show) + widget.bind("", self._hide) + widget.bind("