Coverage for openhcs/pyqt_gui/services/service_adapter.py: 0.0%

184 statements  

« prev     ^ index     » next       coverage.py v7.10.3, created at 2025-08-14 05:57 +0000

1""" 

2PyQt6 Service Adapter 

3 

4Bridges OpenHCS services to PyQt6 context, replacing prompt_toolkit dependencies 

5with Qt equivalents while preserving all business logic. 

6""" 

7 

8import logging 

9from typing import Any, Optional 

10from pathlib import Path 

11 

12from PyQt6.QtWidgets import QMessageBox, QFileDialog, QApplication, QWidget 

13from PyQt6.QtCore import QProcess, QThread, pyqtSignal 

14from PyQt6.QtGui import QDesktopServices 

15from PyQt6.QtCore import QUrl 

16 

17from openhcs.core.path_cache import PathCacheKey, get_cached_dialog_path, cache_dialog_path 

18from openhcs.pyqt_gui.shared.palette_manager import ThemeManager 

19from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme 

20 

21logger = logging.getLogger(__name__) 

22 

23 

24class PyQtServiceAdapter: 

25 """ 

26 Adapter to bridge OpenHCS services to PyQt6 context. 

27  

28 Replaces prompt_toolkit dependencies (dialogs, system commands, etc.) 

29 with PyQt6 equivalents while maintaining the same interface for services. 

30 """ 

31 

32 def __init__(self, main_window: QWidget): 

33 """ 

34 Initialize the service adapter. 

35 

36 Args: 

37 main_window: Main PyQt6 window for dialog parenting 

38 """ 

39 self.main_window = main_window 

40 self.app = QApplication.instance() 

41 

42 # Initialize theme manager for centralized color management 

43 self.theme_manager = ThemeManager() 

44 

45 # Apply dark theme globally to ensure consistent dialog styling 

46 self._apply_dark_theme() 

47 

48 logger.debug("PyQt6 service adapter initialized") 

49 

50 def _apply_dark_theme(self): 

51 """Apply dark theme globally for consistent dialog styling.""" 

52 try: 

53 # Create dark color scheme (same as enhanced path widget) 

54 dark_scheme = PyQt6ColorScheme() 

55 

56 # Apply the dark theme globally so all dialogs use consistent styling 

57 self.theme_manager.apply_color_scheme(dark_scheme) 

58 

59 logger.debug("Applied dark theme globally for consistent dialog styling") 

60 except Exception as e: 

61 logger.warning(f"Failed to apply dark theme: {e}") 

62 

63 def get_theme_manager(self): 

64 """Get the theme manager instance.""" 

65 return self.theme_manager 

66 

67 def get_current_color_scheme(self): 

68 """Get the current color scheme.""" 

69 return self.theme_manager.color_scheme 

70 

71 def execute_async_operation(self, async_func, *args, **kwargs): 

72 """ 

73 Execute async operation using ThreadPoolExecutor (simpler and more reliable). 

74 

75 Args: 

76 async_func: Async function to execute 

77 *args: Function arguments 

78 **kwargs: Function keyword arguments 

79 """ 

80 import asyncio 

81 from concurrent.futures import ThreadPoolExecutor 

82 

83 def run_async_in_thread(): 

84 """Run async function in thread with its own event loop.""" 

85 try: 

86 # Create new event loop for this thread (like TUI executor) 

87 loop = asyncio.new_event_loop() 

88 asyncio.set_event_loop(loop) 

89 

90 # Run the async function 

91 result = loop.run_until_complete(async_func(*args, **kwargs)) 

92 

93 # Clean up 

94 loop.close() 

95 

96 return result 

97 

98 except Exception as e: 

99 logger.error(f"Async operation failed: {e}", exc_info=True) 

100 raise 

101 

102 # Use ThreadPoolExecutor (simpler than Qt threading) 

103 if not hasattr(self, '_thread_pool'): 

104 self._thread_pool = ThreadPoolExecutor(max_workers=4) 

105 

106 # Submit to thread pool (non-blocking like TUI executor) 

107 future = self._thread_pool.submit(run_async_in_thread) 

108 

109 def show_dialog(self, content: str, title: str = "OpenHCS") -> bool: 

110 """ 

111 Replace prompt_toolkit dialogs with QMessageBox. 

112  

113 Args: 

114 content: Dialog content text 

115 title: Dialog title 

116  

117 Returns: 

118 True if user clicked OK, False otherwise 

119 """ 

120 msg = QMessageBox(self.main_window) 

121 msg.setWindowTitle(title) 

122 msg.setText(content) 

123 msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel) 

124 msg.setDefaultButton(QMessageBox.StandardButton.Ok) 

125 

126 result = msg.exec() 

127 return result == QMessageBox.StandardButton.Ok 

128 

129 def show_error_dialog(self, error_message: str, title: str = "Error") -> None: 

130 """ 

131 Show error dialog with error icon. 

132  

133 Args: 

134 error_message: Error message to display 

135 title: Dialog title 

136 """ 

137 msg = QMessageBox(self.main_window) 

138 msg.setWindowTitle(title) 

139 msg.setText(error_message) 

140 msg.setIcon(QMessageBox.Icon.Critical) 

141 msg.setStandardButtons(QMessageBox.StandardButton.Ok) 

142 msg.exec() 

143 

144 def show_info_dialog(self, info_message: str, title: str = "Information") -> None: 

145 """ 

146 Show information dialog. 

147  

148 Args: 

149 info_message: Information message to display 

150 title: Dialog title 

151 """ 

152 msg = QMessageBox(self.main_window) 

153 msg.setWindowTitle(title) 

154 msg.setText(info_message) 

155 msg.setIcon(QMessageBox.Icon.Information) 

156 msg.setStandardButtons(QMessageBox.StandardButton.Ok) 

157 msg.exec() 

158 

159 def show_cached_file_dialog( 

160 self, 

161 cache_key: PathCacheKey, 

162 title: str = "Select File", 

163 file_filter: str = "All Files (*)", 

164 mode: str = "open", 

165 fallback_path: Optional[Path] = None 

166 ) -> Optional[Path]: 

167 """ 

168 Show file dialog with path caching (mirrors Textual TUI pattern). 

169 

170 Args: 

171 cache_key: Cache key for remembering last used path 

172 title: Dialog title 

173 file_filter: File filter string (e.g., "Pipeline Files (*.pipeline)") 

174 mode: "open" or "save" 

175 fallback_path: Fallback path if no cached path exists 

176 

177 Returns: 

178 Selected file path or None if cancelled 

179 """ 

180 # Get cached initial directory 

181 initial_dir = str(get_cached_dialog_path(cache_key, fallback_path)) 

182 

183 try: 

184 if mode == "save": 

185 file_path, _ = QFileDialog.getSaveFileName( 

186 self.main_window, 

187 title, 

188 initial_dir, 

189 file_filter 

190 ) 

191 else: # mode == "open" 

192 file_path, _ = QFileDialog.getOpenFileName( 

193 self.main_window, 

194 title, 

195 initial_dir, 

196 file_filter 

197 ) 

198 

199 if file_path: 

200 selected_path = Path(file_path) 

201 # Cache the parent directory for future dialogs 

202 cache_dialog_path(cache_key, selected_path.parent) 

203 return selected_path 

204 

205 return None 

206 

207 except Exception as e: 

208 logger.error(f"File dialog failed: {e}") 

209 raise 

210 

211 def show_cached_directory_dialog( 

212 self, 

213 cache_key: PathCacheKey, 

214 title: str = "Select Directory", 

215 fallback_path: Optional[Path] = None 

216 ) -> Optional[Path]: 

217 """ 

218 Show directory dialog with path caching. 

219 

220 Args: 

221 cache_key: Cache key for remembering last used path 

222 title: Dialog title 

223 fallback_path: Fallback path if no cached path exists 

224 

225 Returns: 

226 Selected directory path or None if cancelled 

227 """ 

228 # Get cached initial directory 

229 initial_dir = str(get_cached_dialog_path(cache_key, fallback_path)) 

230 

231 try: 

232 dir_path = QFileDialog.getExistingDirectory( 

233 self.main_window, 

234 title, 

235 initial_dir 

236 ) 

237 

238 if dir_path: 

239 selected_path = Path(dir_path) 

240 # Cache the selected directory 

241 cache_dialog_path(cache_key, selected_path) 

242 return selected_path 

243 

244 return None 

245 

246 except Exception as e: 

247 logger.error(f"Directory dialog failed: {e}") 

248 raise 

249 

250 def run_system_command(self, command: str, wait_for_finish: bool = True) -> bool: 

251 """ 

252 Replace prompt_toolkit system command with QProcess. 

253  

254 Args: 

255 command: System command to execute 

256 wait_for_finish: Whether to wait for command completion 

257  

258 Returns: 

259 True if command executed successfully, False otherwise 

260 """ 

261 try: 

262 process = QProcess(self.main_window) 

263 

264 if wait_for_finish: 

265 process.start(command) 

266 success = process.waitForFinished(30000) # 30 second timeout 

267 return success and process.exitCode() == 0 

268 else: 

269 # Start detached process 

270 return process.startDetached(command) 

271 

272 except Exception as e: 

273 logger.error(f"System command failed: {command} - {e}") 

274 self.show_error_dialog(f"Command failed: {e}") 

275 return False 

276 

277 def open_external_editor(self, file_path: Path) -> bool: 

278 """ 

279 Open file in external editor using system default. 

280  

281 Args: 

282 file_path: Path to file to edit 

283  

284 Returns: 

285 True if editor opened successfully, False otherwise 

286 """ 

287 try: 

288 url = QUrl.fromLocalFile(str(file_path)) 

289 return QDesktopServices.openUrl(url) 

290 except Exception as e: 

291 logger.error(f"Failed to open external editor: {e}") 

292 self.show_error_dialog(f"Failed to open editor: {e}") 

293 return False 

294 

295 def get_global_config(self): 

296 """ 

297 Get global configuration from application. 

298  

299 Returns: 

300 Global configuration object 

301 """ 

302 # Access global config through application property 

303 if hasattr(self.app, 'global_config'): 

304 return self.app.global_config 

305 else: 

306 # Fallback to default config 

307 from openhcs.core.config import get_default_global_config 

308 return get_default_global_config() 

309 

310 def set_global_config(self, config): 

311 """ 

312 Set global configuration on application. 

313  

314 Args: 

315 config: Global configuration object 

316 """ 

317 if hasattr(self.app, 'global_config'): 

318 self.app.global_config = config 

319 else: 

320 # Set as application property 

321 setattr(self.app, 'global_config', config) 

322 

323 # ========== THEME MANAGEMENT METHODS ========== 

324 

325 def get_theme_manager(self) -> ThemeManager: 

326 """ 

327 Get the theme manager for color scheme management. 

328 

329 Returns: 

330 ThemeManager: Current theme manager instance 

331 """ 

332 return self.theme_manager 

333 

334 def get_current_color_scheme(self) -> PyQt6ColorScheme: 

335 """ 

336 Get the current color scheme. 

337 

338 Returns: 

339 PyQt6ColorScheme: Current color scheme 

340 """ 

341 return self.theme_manager.color_scheme 

342 

343 def apply_color_scheme(self, color_scheme: PyQt6ColorScheme): 

344 """ 

345 Apply a new color scheme to the entire application. 

346 

347 Args: 

348 color_scheme: New color scheme to apply 

349 """ 

350 self.theme_manager.apply_color_scheme(color_scheme) 

351 

352 def switch_to_dark_theme(self): 

353 """Switch to dark theme variant.""" 

354 self.theme_manager.switch_to_dark_theme() 

355 

356 def switch_to_light_theme(self): 

357 """Switch to light theme variant.""" 

358 self.theme_manager.switch_to_light_theme() 

359 

360 def load_theme_from_config(self, config_path: str) -> bool: 

361 """ 

362 Load and apply theme from configuration file. 

363 

364 Args: 

365 config_path: Path to JSON configuration file 

366 

367 Returns: 

368 bool: True if successful, False otherwise 

369 """ 

370 return self.theme_manager.load_theme_from_config(config_path) 

371 

372 def save_current_theme(self, config_path: str) -> bool: 

373 """ 

374 Save current theme to configuration file. 

375 

376 Args: 

377 config_path: Path to save JSON configuration file 

378 

379 Returns: 

380 bool: True if successful, False otherwise 

381 """ 

382 return self.theme_manager.save_current_theme(config_path) 

383 

384 def get_current_style_sheet(self) -> str: 

385 """ 

386 Get the current complete application style sheet. 

387 

388 Returns: 

389 str: Complete QStyleSheet for current theme 

390 """ 

391 return self.theme_manager.get_current_style_sheet() 

392 

393 def register_theme_change_callback(self, callback): 

394 """ 

395 Register a callback to be called when theme changes. 

396 

397 Args: 

398 callback: Function to call with new color scheme 

399 """ 

400 self.theme_manager.register_theme_change_callback(callback) 

401 

402 def get_file_manager(self): 

403 """ 

404 Get FileManager instance from application. 

405  

406 Returns: 

407 FileManager instance 

408 """ 

409 if hasattr(self.app, 'file_manager'): 

410 return self.app.file_manager 

411 else: 

412 # Create default FileManager 

413 from openhcs.io.filemanager import FileManager 

414 from openhcs.io.base import storage_registry 

415 file_manager = FileManager(storage_registry) 

416 setattr(self.app, 'file_manager', file_manager) 

417 return file_manager 

418 

419 

420class ExternalEditorProcess(QThread): 

421 """ 

422 Thread for handling external editor processes. 

423  

424 Replaces prompt_toolkit's run_system_command for external editor integration. 

425 """ 

426 

427 finished = pyqtSignal(bool, str) # success, error_message 

428 

429 def __init__(self, command: str, file_path: Path): 

430 super().__init__() 

431 self.command = command 

432 self.file_path = file_path 

433 

434 def run(self): 

435 """Execute external editor command in thread.""" 

436 try: 

437 process = QProcess() 

438 process.start(self.command) 

439 

440 success = process.waitForFinished(300000) # 5 minute timeout 

441 

442 if success and process.exitCode() == 0: 

443 self.finished.emit(True, "") 

444 else: 

445 error_msg = process.readAllStandardError().data().decode() 

446 self.finished.emit(False, f"Editor failed: {error_msg}") 

447 

448 except Exception as e: 

449 self.finished.emit(False, f"Editor process failed: {e}") 

450 

451 

452class AsyncOperationThread(QThread): 

453 """ 

454 Generic thread for async operations. 

455  

456 Converts async operations to Qt thread-based operations. 

457 """ 

458 

459 result_ready = pyqtSignal(object) 

460 error_occurred = pyqtSignal(str) 

461 

462 def __init__(self, async_func, *args, **kwargs): 

463 super().__init__() 

464 self.async_func = async_func 

465 self.args = args 

466 self.kwargs = kwargs 

467 

468 def run(self): 

469 """Execute async function in thread with event loop.""" 

470 try: 

471 import asyncio 

472 

473 # Create new event loop for this thread 

474 loop = asyncio.new_event_loop() 

475 asyncio.set_event_loop(loop) 

476 

477 # Run async function 

478 result = loop.run_until_complete( 

479 self.async_func(*self.args, **self.kwargs) 

480 ) 

481 

482 self.result_ready.emit(result) 

483 

484 except Exception as e: 

485 logger.error(f"Async operation failed: {e}") 

486 self.error_occurred.emit(str(e)) 

487 finally: 

488 # Clean up event loop 

489 loop.close()