Coverage for openhcs/pyqt_gui/widgets/enhanced_path_widget.py: 0.0%
122 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +0000
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +0000
1"""
2Enhanced Path Widget for PyQt6 GUI
4Provides intelligent path selection with browse button functionality.
5Uses standard Qt dialogs for consistency with the rest of OpenHCS.
6"""
8import logging
9import re
10from dataclasses import dataclass
11from pathlib import Path
12from typing import Any, List, Optional
14from PyQt6.QtWidgets import QWidget, QLineEdit, QPushButton, QHBoxLayout, QFileDialog
15from PyQt6.QtCore import pyqtSignal
17from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme
18from openhcs.core.path_cache import PathCacheKey, get_cached_dialog_path, cache_dialog_path
19from openhcs.textual_tui.widgets.shared.signature_analyzer import ParameterInfo
21logger = logging.getLogger(__name__)
24@dataclass
25class PathBehavior:
26 """Defines behavior for path widget based on parameter analysis."""
27 is_directory: bool = False
28 extensions: Optional[List[str]] = None
29 cache_key: PathCacheKey = PathCacheKey.GENERAL
30 description: str = "path"
32 @property
33 def title(self) -> str:
34 """Generate appropriate dialog title."""
35 if self.is_directory:
36 return "Select Directory"
37 elif self.extensions:
38 ext_str = ", ".join(self.extensions)
39 return f"Select File ({ext_str})"
40 else:
41 return "Select Path"
43 @property
44 def file_filter(self) -> str:
45 """Generate Qt file filter string."""
46 if self.extensions:
47 # Create filter like "Image Files (*.tiff *.png);;All Files (*)"
48 ext_pattern = " ".join(f"*{ext}" for ext in self.extensions)
49 filter_name = f"{self.extensions[0].upper()} Files" if len(self.extensions) == 1 else "Files"
50 return f"{filter_name} ({ext_pattern});;All Files (*)"
51 else:
52 return "All Files (*)"
55class PathBehaviorDetector:
56 """Detects appropriate path behavior from parameter names and docstring hints."""
58 @staticmethod
59 def detect_behavior(param_name: str, param_info: Optional[ParameterInfo] = None) -> PathBehavior:
60 """
61 Detect path behavior from parameter name and optional parameter info.
63 Args:
64 param_name: Parameter name to analyze
65 param_info: Optional parameter info with docstring description
67 Returns:
68 PathBehavior with detected settings
69 """
70 # Get base behavior from parameter name
71 base_behavior = PathBehaviorDetector._detect_from_parameter_name(param_name)
73 # Try to enhance with docstring info
74 if param_info and param_info.description:
75 docstring_behavior = PathBehaviorDetector._parse_docstring_hints(param_info.description)
76 if docstring_behavior:
77 # Merge: docstring extensions + parameter name cache key
78 return PathBehavior(
79 is_directory=docstring_behavior.is_directory,
80 extensions=docstring_behavior.extensions,
81 cache_key=base_behavior.cache_key if base_behavior else PathCacheKey.GENERAL,
82 description=docstring_behavior.description
83 )
85 # Fall back to base behavior or smart default
86 return base_behavior or PathBehavior(
87 is_directory=False,
88 extensions=None,
89 cache_key=PathCacheKey.GENERAL,
90 description="file or directory"
91 )
93 @staticmethod
94 def _parse_docstring_hints(description: str) -> Optional[PathBehavior]:
95 """Parse docstring for path behavior hints."""
96 desc_lower = description.lower()
98 # Directory specification
99 if any(pattern in desc_lower for pattern in ["directory only", "folder only", "dir only"]):
100 return PathBehavior(is_directory=True, cache_key=PathCacheKey.DIRECTORY_SELECTION, description="directory")
102 # Extension patterns: (.ext only), (.ext1, .ext2), (.ext1/.ext2), etc.
103 patterns = [
104 r'\(\.([a-zA-Z0-9]+(?:\s*[,/]\s*\.?[a-zA-Z0-9]+)*)\)', # (.json, .yaml) or (.json/.yaml)
105 r'\(\.([a-zA-Z0-9]+)\s+only\)', # (.tiff only)
106 r'\(([a-zA-Z0-9]+)\s+only\)', # (tiff only)
107 r'\.([a-zA-Z0-9]+)\s+only', # .tiff only
108 ]
110 for pattern in patterns:
111 match = re.search(pattern, description, re.IGNORECASE)
112 if match:
113 ext_string = match.group(1)
114 # Split by comma or slash and clean up
115 raw_exts = re.split(r'[,/]', ext_string)
116 extensions = [f".{ext.strip().lstrip('.')}" for ext in raw_exts if ext.strip()]
118 if extensions:
119 desc = f"{extensions[0].upper()} file" if len(extensions) == 1 else f"file ({', '.join(ext.upper() for ext in extensions)})"
120 return PathBehavior(is_directory=False, extensions=extensions, cache_key=PathCacheKey.FILE_SELECTION, description=desc)
122 return None
124 @staticmethod
125 def _detect_from_parameter_name(param_name: str) -> Optional[PathBehavior]:
126 """Detect behavior from parameter name patterns."""
127 name_lower = param_name.lower()
129 # Directory patterns
130 if any(pattern in name_lower for pattern in ['dir', 'folder', 'directory']):
131 return PathBehavior(is_directory=True, cache_key=PathCacheKey.DIRECTORY_SELECTION, description="directory")
133 # File patterns
134 if any(pattern in name_lower for pattern in ['file']):
135 return PathBehavior(is_directory=False, cache_key=PathCacheKey.FILE_SELECTION, description="file")
137 # Context-specific cache keys (NO EXTENSIONS - docstring handles that)
138 if 'pipeline' in name_lower:
139 return PathBehavior(is_directory=False, cache_key=PathCacheKey.PIPELINE_FILES, description="pipeline file")
140 if 'step' in name_lower:
141 return PathBehavior(is_directory=False, cache_key=PathCacheKey.STEP_SETTINGS, description="step file")
142 if 'function' in name_lower or 'func' in name_lower:
143 return PathBehavior(is_directory=False, cache_key=PathCacheKey.FUNCTION_PATTERNS, description="function file")
145 return None
148class EnhancedPathWidget(QWidget):
149 """Enhanced path widget with browse button using standard Qt dialogs."""
151 path_changed = pyqtSignal(str)
153 def __init__(self, param_name: str, current_value: Any, param_info: Optional[ParameterInfo] = None, color_scheme=None):
154 """
155 Initialize enhanced path widget.
157 Args:
158 param_name: Parameter name for behavior detection
159 current_value: Current path value
160 param_info: Optional parameter info with docstring
161 color_scheme: Color scheme for styling
162 """
163 super().__init__()
164 self.behavior = PathBehaviorDetector.detect_behavior(param_name, param_info)
165 self.color_scheme = color_scheme or PyQt6ColorScheme()
167 # Layout: [QLineEdit] [Browse Button]
168 layout = QHBoxLayout(self)
169 layout.setContentsMargins(0, 0, 0, 0)
170 layout.setSpacing(5)
172 self.path_input = QLineEdit()
173 self.path_input.setPlaceholderText(f"Enter {self.behavior.description} path...")
174 self.browse_button = QPushButton("📁 Browse")
175 self.browse_button.setMaximumWidth(80)
177 layout.addWidget(self.path_input, 1)
178 layout.addWidget(self.browse_button, 0)
180 self._apply_styling()
181 self._setup_signals()
182 self.set_path(current_value)
184 def _apply_styling(self):
185 """Apply color scheme styling to widgets."""
186 self.path_input.setStyleSheet(f"""
187 QLineEdit {{
188 background-color: {self.color_scheme.to_hex(self.color_scheme.input_bg)};
189 color: {self.color_scheme.to_hex(self.color_scheme.input_text)};
190 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.input_border)};
191 border-radius: 3px; padding: 5px; font-family: 'Courier New', monospace;
192 }}
193 """)
195 self.browse_button.setStyleSheet(f"""
196 QPushButton {{
197 background-color: {self.color_scheme.to_hex(self.color_scheme.button_normal_bg)};
198 color: {self.color_scheme.to_hex(self.color_scheme.button_text)};
199 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.input_border)};
200 border-radius: 3px; padding: 5px 10px; font-size: 11px;
201 }}
202 """)
204 def _setup_signals(self):
205 """Setup signal connections."""
206 self.path_input.textChanged.connect(self._on_text_changed)
207 self.browse_button.clicked.connect(self._open_dialog)
209 def _on_text_changed(self, text: str):
210 """Handle text change in path input."""
211 self.path_changed.emit(text)
213 def set_path(self, value: Any):
214 """Set path value without triggering signals."""
215 self.path_input.blockSignals(True)
216 try:
217 if value is not None:
218 # Set actual value
219 text = str(value)
220 self.path_input.setText(text)
221 else:
222 # For None values, don't set empty text - let placeholder system handle it
223 # This allows lazy placeholder text to be visible instead of hardcoded placeholder
224 pass
225 finally:
226 self.path_input.blockSignals(False)
228 def get_path(self) -> str:
229 """Get current path value."""
230 return self.path_input.text()
232 def _open_dialog(self):
233 """Open appropriate Qt dialog based on behavior."""
234 try:
235 # Get cached initial directory
236 initial_dir = str(get_cached_dialog_path(self.behavior.cache_key, fallback=Path.home()))
238 # Use None as parent to create a clean, top-level dialog
239 # This prevents inheriting the dark styling from nested containers
240 # and matches the simple appearance of ServiceAdapter dialogs
241 parent = None
243 if self.behavior.is_directory:
244 # Use directory dialog
245 selected_path = QFileDialog.getExistingDirectory(
246 parent,
247 self.behavior.title,
248 initial_dir
249 )
250 else:
251 # Use file dialog
252 selected_path, _ = QFileDialog.getOpenFileName(
253 parent,
254 self.behavior.title,
255 initial_dir,
256 self.behavior.file_filter
257 )
259 if selected_path:
260 path_obj = Path(selected_path)
261 self.set_path(path_obj)
262 self.path_changed.emit(str(path_obj))
264 # Cache the selection (directory for files, path itself for directories)
265 cache_path = path_obj.parent if path_obj.is_file() else path_obj
266 cache_dialog_path(self.behavior.cache_key, cache_path)
268 except Exception as e:
269 logger.error(f"Failed to open dialog: {e}")