Source code for pyqt_reactive.widgets.enhanced_path_widget

"""
Enhanced Path Widget for PyQt6 GUI

Provides intelligent path selection with browse button functionality.
Uses standard Qt dialogs for consistency with the rest of OpenHCS.
"""

import logging
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, List, Optional

from PyQt6.QtWidgets import QWidget, QLineEdit, QPushButton, QHBoxLayout, QFileDialog
from PyQt6.QtCore import pyqtSignal

from pyqt_reactive.theming import ColorScheme

# Optional path cache - stub if not available
try:
    from pyqt_reactive.core.path_cache import PathCacheKey, get_cached_dialog_path, cache_dialog_path
except ImportError:
    PathCacheKey = None  # type: ignore
    get_cached_dialog_path = lambda *args, **kwargs: None  # type: ignore
    cache_dialog_path = lambda *args, **kwargs: None  # type: ignore

from python_introspect import ParameterInfo

logger = logging.getLogger(__name__)


[docs] @dataclass class PathBehavior: """Defines behavior for path widget based on parameter analysis.""" is_directory: bool = False extensions: Optional[List[str]] = None cache_key: PathCacheKey = PathCacheKey.GENERAL description: str = "path" @property def title(self) -> str: """Generate appropriate dialog title.""" if self.is_directory: return "Select Directory" elif self.extensions: ext_str = ", ".join(self.extensions) return f"Select File ({ext_str})" else: return "Select Path" @property def file_filter(self) -> str: """Generate Qt file filter string.""" if self.extensions: # Create filter like "Image Files (*.tiff *.png);;All Files (*)" ext_pattern = " ".join(f"*{ext}" for ext in self.extensions) filter_name = f"{self.extensions[0].upper()} Files" if len(self.extensions) == 1 else "Files" return f"{filter_name} ({ext_pattern});;All Files (*)" else: return "All Files (*)"
[docs] class PathBehaviorDetector: """Detects appropriate path behavior from parameter names and docstring hints."""
[docs] @staticmethod def detect_behavior(param_name: str, param_info: Optional[ParameterInfo] = None) -> PathBehavior: """ Detect path behavior from parameter name and optional parameter info. Args: param_name: Parameter name to analyze param_info: Optional parameter info with docstring description Returns: PathBehavior with detected settings """ # Get base behavior from parameter name base_behavior = PathBehaviorDetector._detect_from_parameter_name(param_name) # Try to enhance with docstring info if param_info and param_info.description: docstring_behavior = PathBehaviorDetector._parse_docstring_hints(param_info.description) if docstring_behavior: # Merge: docstring extensions + parameter name cache key return PathBehavior( is_directory=docstring_behavior.is_directory, extensions=docstring_behavior.extensions, cache_key=base_behavior.cache_key if base_behavior else PathCacheKey.GENERAL, description=docstring_behavior.description ) # Fall back to base behavior or smart default return base_behavior or PathBehavior( is_directory=False, extensions=None, cache_key=PathCacheKey.GENERAL, description="file or directory" )
@staticmethod def _parse_docstring_hints(description: str) -> Optional[PathBehavior]: """Parse docstring for path behavior hints.""" desc_lower = description.lower() # Directory specification if any(pattern in desc_lower for pattern in ["directory only", "folder only", "dir only"]): return PathBehavior(is_directory=True, cache_key=PathCacheKey.DIRECTORY_SELECTION, description="directory") # Extension patterns: (.ext only), (.ext1, .ext2), (.ext1/.ext2), etc. patterns = [ r'\(\.([a-zA-Z0-9]+(?:\s*[,/]\s*\.?[a-zA-Z0-9]+)*)\)', # (.json, .yaml) or (.json/.yaml) r'\(\.([a-zA-Z0-9]+)\s+only\)', # (.tiff only) r'\(([a-zA-Z0-9]+)\s+only\)', # (tiff only) r'\.([a-zA-Z0-9]+)\s+only', # .tiff only ] for pattern in patterns: match = re.search(pattern, description, re.IGNORECASE) if match: ext_string = match.group(1) # Split by comma or slash and clean up raw_exts = re.split(r'[,/]', ext_string) extensions = [f".{ext.strip().lstrip('.')}" for ext in raw_exts if ext.strip()] if extensions: desc = f"{extensions[0].upper()} file" if len(extensions) == 1 else f"file ({', '.join(ext.upper() for ext in extensions)})" return PathBehavior(is_directory=False, extensions=extensions, cache_key=PathCacheKey.FILE_SELECTION, description=desc) return None @staticmethod def _detect_from_parameter_name(param_name: str) -> Optional[PathBehavior]: """Detect behavior from parameter name patterns.""" name_lower = param_name.lower() # Directory patterns if any(pattern in name_lower for pattern in ['dir', 'folder', 'directory']): return PathBehavior(is_directory=True, cache_key=PathCacheKey.DIRECTORY_SELECTION, description="directory") # File patterns if any(pattern in name_lower for pattern in ['file']): return PathBehavior(is_directory=False, cache_key=PathCacheKey.FILE_SELECTION, description="file") # Context-specific cache keys (NO EXTENSIONS - docstring handles that) if 'pipeline' in name_lower: return PathBehavior(is_directory=False, cache_key=PathCacheKey.PIPELINE_FILES, description="pipeline file") if 'step' in name_lower: return PathBehavior(is_directory=False, cache_key=PathCacheKey.STEP_SETTINGS, description="step file") if 'function' in name_lower or 'func' in name_lower: return PathBehavior(is_directory=False, cache_key=PathCacheKey.FUNCTION_PATTERNS, description="function file") return None
[docs] class EnhancedPathWidget(QWidget): """Enhanced path widget with browse button using standard Qt dialogs.""" path_changed = pyqtSignal(str)
[docs] def __init__(self, param_name: str, current_value: Any, param_info: Optional[ParameterInfo] = None, color_scheme=None): """ Initialize enhanced path widget. Args: param_name: Parameter name for behavior detection current_value: Current path value param_info: Optional parameter info with docstring color_scheme: Color scheme for styling """ super().__init__() self.behavior = PathBehaviorDetector.detect_behavior(param_name, param_info) self.color_scheme = color_scheme or ColorScheme() # Layout: [QLineEdit] [Browse Button] layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(5) self.path_input = QLineEdit() self.path_input.setPlaceholderText(f"Enter {self.behavior.description} path...") self.browse_button = QPushButton("📁 Browse") self.browse_button.setMaximumWidth(80) layout.addWidget(self.path_input, 1) layout.addWidget(self.browse_button, 0) self._apply_styling() self._setup_signals() self.set_path(current_value)
def _apply_styling(self): """Apply color scheme styling to widgets.""" self.path_input.setStyleSheet(f""" QLineEdit {{ background-color: {self.color_scheme.to_hex(self.color_scheme.input_bg)}; color: {self.color_scheme.to_hex(self.color_scheme.input_text)}; border: 1px solid {self.color_scheme.to_hex(self.color_scheme.input_border)}; border-radius: 3px; padding: 5px; font-family: 'Courier New', monospace; }} """) self.browse_button.setStyleSheet(f""" QPushButton {{ background-color: {self.color_scheme.to_hex(self.color_scheme.button_normal_bg)}; color: {self.color_scheme.to_hex(self.color_scheme.button_text)}; border: 1px solid {self.color_scheme.to_hex(self.color_scheme.input_border)}; border-radius: 3px; padding: 5px 10px; font-size: 11px; }} """) def _setup_signals(self): """Setup signal connections.""" self.path_input.textChanged.connect(self._on_text_changed) self.browse_button.clicked.connect(self._open_dialog) def _on_text_changed(self, text: str): """Handle text change in path input.""" self.path_changed.emit(text)
[docs] def set_path(self, value: Any): """Set path value without triggering signals.""" self.path_input.blockSignals(True) try: if value is not None: # Set actual value text = str(value) self.path_input.setText(text) else: # For None values, clear the text to show placeholder self.path_input.clear() finally: self.path_input.blockSignals(False)
[docs] def get_path(self): """Get current path value, returning None for empty strings.""" text = self.path_input.text().strip() return None if text == "" else text
[docs] def get_value(self): """Implement ValueGettable ABC - alias for get_path().""" return self.get_path()
[docs] def set_value(self, value: Any): """Implement ValueSettable ABC - alias for set_path().""" self.set_path(value)
def _open_dialog(self): """Open appropriate Qt dialog based on behavior.""" try: # Get cached initial directory initial_dir = str(get_cached_dialog_path(self.behavior.cache_key, fallback=Path.home())) # Use None as parent to create a clean, top-level dialog # This prevents inheriting the dark styling from nested containers # and matches the simple appearance of ServiceAdapter dialogs parent = None if self.behavior.is_directory: # Use directory dialog selected_path = QFileDialog.getExistingDirectory( parent, self.behavior.title, initial_dir ) else: # Use file dialog selected_path, _ = QFileDialog.getOpenFileName( parent, self.behavior.title, initial_dir, self.behavior.file_filter ) if selected_path: path_obj = Path(selected_path) self.set_path(path_obj) self.path_changed.emit(str(path_obj)) # Cache the selection (directory for files, path itself for directories) cache_path = path_obj.parent if path_obj.is_file() else path_obj cache_dialog_path(self.behavior.cache_key, cache_path) except Exception as e: logger.error(f"Failed to open dialog: {e}")
# Register EnhancedPathWidget as implementing ValueGettable and ValueSettable from pyqt_reactive.protocols import ValueGettable, ValueSettable ValueGettable.register(EnhancedPathWidget) ValueSettable.register(EnhancedPathWidget)