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

1""" 

2Enhanced Path Widget for PyQt6 GUI 

3 

4Provides intelligent path selection with browse button functionality. 

5Uses standard Qt dialogs for consistency with the rest of OpenHCS. 

6""" 

7 

8import logging 

9import re 

10from dataclasses import dataclass 

11from pathlib import Path 

12from typing import Any, List, Optional 

13 

14from PyQt6.QtWidgets import QWidget, QLineEdit, QPushButton, QHBoxLayout, QFileDialog 

15from PyQt6.QtCore import pyqtSignal 

16 

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 

20 

21logger = logging.getLogger(__name__) 

22 

23 

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" 

31 

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" 

42 

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 (*)" 

53 

54 

55class PathBehaviorDetector: 

56 """Detects appropriate path behavior from parameter names and docstring hints.""" 

57 

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. 

62 

63 Args: 

64 param_name: Parameter name to analyze 

65 param_info: Optional parameter info with docstring description 

66 

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) 

72 

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 ) 

84 

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 ) 

92 

93 @staticmethod 

94 def _parse_docstring_hints(description: str) -> Optional[PathBehavior]: 

95 """Parse docstring for path behavior hints.""" 

96 desc_lower = description.lower() 

97 

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") 

101 

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 ] 

109 

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()] 

117 

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) 

121 

122 return None 

123 

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() 

128 

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") 

132 

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") 

136 

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") 

144 

145 return None 

146 

147 

148class EnhancedPathWidget(QWidget): 

149 """Enhanced path widget with browse button using standard Qt dialogs.""" 

150 

151 path_changed = pyqtSignal(str) 

152 

153 def __init__(self, param_name: str, current_value: Any, param_info: Optional[ParameterInfo] = None, color_scheme=None): 

154 """ 

155 Initialize enhanced path widget. 

156 

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() 

166 

167 # Layout: [QLineEdit] [Browse Button] 

168 layout = QHBoxLayout(self) 

169 layout.setContentsMargins(0, 0, 0, 0) 

170 layout.setSpacing(5) 

171 

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) 

176 

177 layout.addWidget(self.path_input, 1) 

178 layout.addWidget(self.browse_button, 0) 

179 

180 self._apply_styling() 

181 self._setup_signals() 

182 self.set_path(current_value) 

183 

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 """) 

194 

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 """) 

203 

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) 

208 

209 def _on_text_changed(self, text: str): 

210 """Handle text change in path input.""" 

211 self.path_changed.emit(text) 

212 

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) 

227 

228 def get_path(self) -> str: 

229 """Get current path value.""" 

230 return self.path_input.text() 

231 

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())) 

237 

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 

242 

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 ) 

258 

259 if selected_path: 

260 path_obj = Path(selected_path) 

261 self.set_path(path_obj) 

262 self.path_changed.emit(str(path_obj)) 

263 

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) 

267 

268 except Exception as e: 

269 logger.error(f"Failed to open dialog: {e}")