Coverage for openhcs/pyqt_gui/main.py: 0.0%

438 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-04 02:09 +0000

1""" 

2OpenHCS PyQt6 Main Window 

3 

4Main application window implementing QDockWidget system to replace 

5textual-window floating windows with native Qt docking. 

6""" 

7 

8import logging 

9from typing import Optional, Dict 

10from pathlib import Path 

11import webbrowser 

12 

13from PyQt6.QtWidgets import ( 

14 QMainWindow, QWidget, QVBoxLayout, 

15 QMessageBox, QFileDialog, QDialog 

16) 

17from PyQt6.QtCore import Qt, QSettings, QTimer, pyqtSignal, QUrl 

18from PyQt6.QtGui import QAction, QKeySequence, QDesktopServices 

19 

20from openhcs.core.config import GlobalPipelineConfig 

21from openhcs.io.filemanager import FileManager 

22from openhcs.io.base import storage_registry 

23 

24from openhcs.pyqt_gui.services.service_adapter import PyQtServiceAdapter 

25 

26logger = logging.getLogger(__name__) 

27 

28 

29class OpenHCSMainWindow(QMainWindow): 

30 """ 

31 Main OpenHCS PyQt6 application window. 

32  

33 Implements QDockWidget system to replace textual-window floating windows 

34 with native Qt docking, providing better desktop integration. 

35 """ 

36 

37 # Signals for application events 

38 config_changed = pyqtSignal(object) # GlobalPipelineConfig 

39 status_message = pyqtSignal(str) # Status message 

40 

41 def __init__(self, global_config: Optional[GlobalPipelineConfig] = None): 

42 """ 

43 Initialize the main OpenHCS window. 

44 

45 Args: 

46 global_config: Global configuration (uses default if None) 

47 """ 

48 super().__init__() 

49 

50 # Core configuration 

51 self.global_config = global_config or GlobalPipelineConfig() 

52 

53 # Create shared components 

54 self.storage_registry = storage_registry 

55 self.file_manager = FileManager(self.storage_registry) 

56 

57 # Service adapter for Qt integration 

58 self.service_adapter = PyQtServiceAdapter(self) 

59 

60 # Floating windows registry (replaces dock widgets) 

61 self.floating_windows: Dict[str, QDialog] = {} 

62 

63 # Settings for window state persistence 

64 self.settings = QSettings("OpenHCS", "PyQt6GUI") 

65 

66 # Initialize UI 

67 self.setup_ui() 

68 self.setup_dock_system() 

69 self.create_floating_windows() 

70 self.setup_menu_bar() 

71 self.setup_status_bar() 

72 self.setup_connections() 

73 

74 # Apply initial theme 

75 self.apply_initial_theme() 

76 

77 # Restore window state 

78 self.restore_window_state() 

79 

80 logger.info("OpenHCS PyQt6 main window initialized (deferred initialization pending)") 

81 

82 def _deferred_initialization(self): 

83 """ 

84 Deferred initialization that happens after window is visible. 

85 

86 This includes: 

87 - Log viewer initialization (file I/O) - IMMEDIATE 

88 - Default windows (pipeline editor with config cache warming) - IMMEDIATE 

89 

90 Note: System monitor is now created during __init__ so startup screen appears immediately 

91 """ 

92 # Initialize Log Viewer (hidden) for continuous log monitoring - IMMEDIATE 

93 self._initialize_log_viewer() 

94 

95 # Show default windows (plate manager and pipeline editor visible by default) - IMMEDIATE 

96 self.show_default_windows() 

97 

98 logger.info("Deferred initialization complete (UI ready)") 

99 

100 

101 

102 def setup_ui(self): 

103 """Setup basic UI structure.""" 

104 self.setWindowTitle("OpenHCS") 

105 self.setMinimumSize(640, 480) 

106 

107 # Make main window floating (not tiled) like other OpenHCS components 

108 self.setWindowFlags(Qt.WindowType.Dialog) 

109 

110 # Central widget with system monitor (shows startup screen immediately) 

111 central_widget = QWidget() 

112 central_layout = QVBoxLayout(central_widget) 

113 central_layout.setContentsMargins(0, 0, 0, 0) 

114 

115 # Create system monitor immediately so startup screen shows right away 

116 from openhcs.pyqt_gui.widgets.system_monitor import SystemMonitorWidget 

117 self.system_monitor = SystemMonitorWidget() 

118 central_layout.addWidget(self.system_monitor) 

119 

120 # Store layout for potential future use 

121 self.central_layout = central_layout 

122 

123 self.setCentralWidget(central_widget) 

124 

125 def apply_initial_theme(self): 

126 """Apply initial color scheme to the main window.""" 

127 # Get theme manager from service adapter 

128 theme_manager = self.service_adapter.get_theme_manager() 

129 

130 # Note: ServiceAdapter already applied dark theme globally in its __init__ 

131 # Just register for theme change notifications, don't re-apply 

132 theme_manager.register_theme_change_callback(self.on_theme_changed) 

133 

134 logger.debug("Registered for theme change notifications (theme already applied by ServiceAdapter)") 

135 

136 def on_theme_changed(self, color_scheme): 

137 """ 

138 Handle theme change notifications. 

139 

140 Args: 

141 color_scheme: New color scheme that was applied 

142 """ 

143 # Update any main window specific styling if needed 

144 # Most styling is handled automatically by the theme manager 

145 logger.debug("Main window received theme change notification") 

146 

147 def setup_dock_system(self): 

148 """Setup window system mirroring Textual TUI floating windows.""" 

149 # In Textual TUI, widgets are floating windows, not docked 

150 # We'll create windows on-demand when menu items are clicked 

151 # Only the system monitor stays as the central background widget 

152 pass 

153 

154 def create_floating_windows(self): 

155 """Create floating windows mirroring Textual TUI window system.""" 

156 # Windows are created on-demand when menu items are clicked 

157 # This mirrors the Textual TUI pattern where windows are mounted dynamically 

158 self.floating_windows = {} # Track created windows 

159 

160 def show_default_windows(self): 

161 """Show plate manager by default.""" 

162 # Show plate manager by default 

163 self.show_plate_manager() 

164 

165 # Pipeline editor is NOT shown by default because it imports ALL GPU libraries 

166 # (torch, tensorflow, jax, cupy, pyclesperanto) which takes 8+ seconds 

167 # User can open it from View menu when needed 

168 

169 def show_plate_manager(self): 

170 """Show plate manager window (mirrors Textual TUI pattern).""" 

171 if "plate_manager" not in self.floating_windows: 

172 from openhcs.pyqt_gui.widgets.plate_manager import PlateManagerWidget 

173 

174 # Create floating window 

175 window = QDialog(self) 

176 window.setWindowTitle("Plate Manager") 

177 window.setModal(False) 

178 window.resize(600, 400) 

179 

180 # Add widget to window 

181 layout = QVBoxLayout(window) 

182 plate_widget = PlateManagerWidget( 

183 self.file_manager, 

184 self.service_adapter, 

185 self.service_adapter.get_current_color_scheme() 

186 ) 

187 layout.addWidget(plate_widget) 

188 

189 self.floating_windows["plate_manager"] = window 

190 

191 # Connect progress signals to status bar 

192 if hasattr(self, 'status_bar') and self.status_bar: 

193 # Create progress bar in status bar if it doesn't exist 

194 if not hasattr(self, '_status_progress_bar'): 

195 from PyQt6.QtWidgets import QProgressBar 

196 self._status_progress_bar = QProgressBar() 

197 self._status_progress_bar.setMaximumWidth(200) 

198 self._status_progress_bar.setVisible(False) 

199 self.status_bar.addPermanentWidget(self._status_progress_bar) 

200 

201 # Connect progress signals 

202 plate_widget.progress_started.connect( 

203 lambda max_val: self._on_plate_progress_started(max_val) 

204 ) 

205 plate_widget.progress_updated.connect( 

206 lambda val: self._on_plate_progress_updated(val) 

207 ) 

208 plate_widget.progress_finished.connect( 

209 lambda: self._on_plate_progress_finished() 

210 ) 

211 

212 # Connect to pipeline editor if it exists (mirrors Textual TUI) 

213 self._connect_plate_to_pipeline_manager(plate_widget) 

214 

215 # Show the window 

216 self.floating_windows["plate_manager"].show() 

217 self.floating_windows["plate_manager"].raise_() 

218 self.floating_windows["plate_manager"].activateWindow() 

219 

220 def show_pipeline_editor(self): 

221 """Show pipeline editor window (mirrors Textual TUI pattern).""" 

222 if "pipeline_editor" not in self.floating_windows: 

223 from openhcs.pyqt_gui.widgets.pipeline_editor import PipelineEditorWidget 

224 

225 # Create floating window 

226 window = QDialog(self) 

227 window.setWindowTitle("Pipeline Editor") 

228 window.setModal(False) 

229 window.resize(800, 600) 

230 

231 # Add widget to window 

232 layout = QVBoxLayout(window) 

233 pipeline_widget = PipelineEditorWidget( 

234 self.file_manager, 

235 self.service_adapter, 

236 self.service_adapter.get_current_color_scheme() 

237 ) 

238 layout.addWidget(pipeline_widget) 

239 

240 self.floating_windows["pipeline_editor"] = window 

241 

242 # Connect to plate manager for current plate selection (mirrors Textual TUI) 

243 self._connect_pipeline_to_plate_manager(pipeline_widget) 

244 

245 # Show the window 

246 self.floating_windows["pipeline_editor"].show() 

247 self.floating_windows["pipeline_editor"].raise_() 

248 self.floating_windows["pipeline_editor"].activateWindow() 

249 

250 

251 

252 def show_image_browser(self): 

253 """Show image browser window.""" 

254 if "image_browser" not in self.floating_windows: 

255 from openhcs.pyqt_gui.widgets.image_browser import ImageBrowserWidget 

256 from openhcs.pyqt_gui.widgets.plate_manager import PlateManagerWidget 

257 

258 # Create floating window 

259 window = QDialog(self) 

260 window.setWindowTitle("Image Browser") 

261 window.setModal(False) 

262 window.resize(900, 600) 

263 

264 # Add widget to window 

265 layout = QVBoxLayout(window) 

266 image_browser_widget = ImageBrowserWidget( 

267 orchestrator=None, 

268 color_scheme=self.service_adapter.get_current_color_scheme() 

269 ) 

270 layout.addWidget(image_browser_widget) 

271 

272 self.floating_windows["image_browser"] = window 

273 

274 # Connect to plate manager to get current orchestrator 

275 if "plate_manager" in self.floating_windows: 

276 plate_dialog = self.floating_windows["plate_manager"] 

277 plate_widget = plate_dialog.findChild(PlateManagerWidget) 

278 if plate_widget: 

279 # Connect to plate selection changes 

280 def on_plate_selected(): 

281 if hasattr(plate_widget, 'get_selected_orchestrator'): 

282 orchestrator = plate_widget.get_selected_orchestrator() 

283 if orchestrator: 

284 image_browser_widget.set_orchestrator(orchestrator) 

285 

286 # Try to connect to selection signal if it exists 

287 if hasattr(plate_widget, 'plate_selected'): 

288 plate_widget.plate_selected.connect(on_plate_selected) 

289 

290 # Set initial orchestrator if available 

291 on_plate_selected() 

292 

293 # Show the window 

294 self.floating_windows["image_browser"].show() 

295 self.floating_windows["image_browser"].raise_() 

296 self.floating_windows["image_browser"].activateWindow() 

297 

298 def _initialize_log_viewer(self): 

299 """ 

300 Initialize Log Viewer on startup (hidden) for continuous log monitoring. 

301 

302 This ensures all server logs are captured regardless of when the 

303 Log Viewer window is opened by the user. 

304 """ 

305 from openhcs.pyqt_gui.widgets.log_viewer import LogViewerWindow 

306 

307 # Create floating window (hidden) 

308 window = QDialog(self) 

309 window.setWindowTitle("Log Viewer") 

310 window.setModal(False) 

311 window.resize(900, 700) 

312 

313 # Add widget to window 

314 layout = QVBoxLayout(window) 

315 log_viewer_widget = LogViewerWindow(self.file_manager, self.service_adapter) 

316 layout.addWidget(log_viewer_widget) 

317 

318 self.floating_windows["log_viewer"] = window 

319 

320 # Window stays hidden until user opens it 

321 logger.info("Log Viewer initialized (hidden) - monitoring for new logs") 

322 

323 def show_log_viewer(self): 

324 """Show log viewer window (mirrors Textual TUI pattern).""" 

325 # Log viewer is already initialized on startup, just show it 

326 if "log_viewer" in self.floating_windows: 

327 self.floating_windows["log_viewer"].show() 

328 self.floating_windows["log_viewer"].raise_() 

329 self.floating_windows["log_viewer"].activateWindow() 

330 else: 

331 # Fallback: initialize if somehow not created 

332 self._initialize_log_viewer() 

333 self.show_log_viewer() 

334 

335 def show_zmq_server_manager(self): 

336 """Show ZMQ server manager window.""" 

337 if "zmq_server_manager" not in self.floating_windows: 

338 from openhcs.pyqt_gui.widgets.shared.zmq_server_manager import ZMQServerManagerWidget 

339 

340 # Create floating window 

341 window = QDialog(self) 

342 window.setWindowTitle("ZMQ Server Manager") 

343 window.setModal(False) 

344 window.resize(600, 400) 

345 

346 # Add widget to window 

347 layout = QVBoxLayout(window) 

348 

349 # Scan all streaming ports using current global config 

350 # This ensures we find viewers launched with custom ports 

351 from openhcs.core.config import get_all_streaming_ports 

352 ports_to_scan = get_all_streaming_ports(num_ports_per_type=10) # Uses global config by default 

353 

354 zmq_manager_widget = ZMQServerManagerWidget( 

355 ports_to_scan=ports_to_scan, 

356 title="ZMQ Servers (Execution + Napari + Fiji)", 

357 style_generator=self.service_adapter.get_style_generator() 

358 ) 

359 

360 # Connect log file opened signal to log viewer 

361 zmq_manager_widget.log_file_opened.connect(self._open_log_file_in_viewer) 

362 

363 layout.addWidget(zmq_manager_widget) 

364 

365 self.floating_windows["zmq_server_manager"] = window 

366 

367 # Show window 

368 self.floating_windows["zmq_server_manager"].show() 

369 self.floating_windows["zmq_server_manager"].raise_() 

370 self.floating_windows["zmq_server_manager"].activateWindow() 

371 

372 def _open_log_file_in_viewer(self, log_file_path: str): 

373 """ 

374 Open a log file in the log viewer. 

375 

376 Args: 

377 log_file_path: Path to log file to open 

378 """ 

379 # Show log viewer if not already open 

380 self.show_log_viewer() 

381 

382 # Switch to the log file 

383 if "log_viewer" in self.floating_windows: 

384 log_dialog = self.floating_windows["log_viewer"] 

385 from openhcs.pyqt_gui.widgets.log_viewer import LogViewerWindow 

386 log_viewer_widget = log_dialog.findChild(LogViewerWindow) 

387 if log_viewer_widget: 

388 # Switch to the log file 

389 from pathlib import Path 

390 log_viewer_widget.switch_to_log(Path(log_file_path)) 

391 logger.info(f"Switched log viewer to: {log_file_path}") 

392 

393 def setup_menu_bar(self): 

394 """Setup application menu bar.""" 

395 menubar = self.menuBar() 

396 

397 # File menu 

398 file_menu = menubar.addMenu("&File") 

399 

400 # Theme submenu 

401 theme_menu = file_menu.addMenu("&Theme") 

402 

403 # Dark theme action 

404 dark_theme_action = QAction("&Dark Theme", self) 

405 dark_theme_action.triggered.connect(self.switch_to_dark_theme) 

406 theme_menu.addAction(dark_theme_action) 

407 

408 # Light theme action 

409 light_theme_action = QAction("&Light Theme", self) 

410 light_theme_action.triggered.connect(self.switch_to_light_theme) 

411 theme_menu.addAction(light_theme_action) 

412 

413 theme_menu.addSeparator() 

414 

415 # Load theme from file action 

416 load_theme_action = QAction("&Load Theme from File...", self) 

417 load_theme_action.triggered.connect(self.load_theme_from_file) 

418 theme_menu.addAction(load_theme_action) 

419 

420 # Save theme to file action 

421 save_theme_action = QAction("&Save Theme to File...", self) 

422 save_theme_action.triggered.connect(self.save_theme_to_file) 

423 theme_menu.addAction(save_theme_action) 

424 

425 file_menu.addSeparator() 

426 

427 # Exit action 

428 exit_action = QAction("E&xit", self) 

429 exit_action.setShortcut(QKeySequence.StandardKey.Quit) 

430 exit_action.triggered.connect(self.close) 

431 file_menu.addAction(exit_action) 

432 

433 # View menu 

434 view_menu = menubar.addMenu("&View") 

435 

436 # Plate Manager window 

437 plate_action = QAction("&Plate Manager", self) 

438 plate_action.setShortcut("Ctrl+P") 

439 plate_action.triggered.connect(self.show_plate_manager) 

440 view_menu.addAction(plate_action) 

441 

442 # Pipeline Editor window 

443 pipeline_action = QAction("Pipeline &Editor", self) 

444 pipeline_action.setShortcut("Ctrl+E") 

445 pipeline_action.triggered.connect(self.show_pipeline_editor) 

446 view_menu.addAction(pipeline_action) 

447 

448 # Image Browser window 

449 image_browser_action = QAction("&Image Browser", self) 

450 image_browser_action.setShortcut("Ctrl+I") 

451 image_browser_action.triggered.connect(self.show_image_browser) 

452 view_menu.addAction(image_browser_action) 

453 

454 # Log Viewer window 

455 log_action = QAction("&Log Viewer", self) 

456 log_action.setShortcut("Ctrl+L") 

457 log_action.triggered.connect(self.show_log_viewer) 

458 view_menu.addAction(log_action) 

459 

460 # ZMQ Server Manager window 

461 zmq_server_action = QAction("&ZMQ Server Manager", self) 

462 zmq_server_action.setShortcut("Ctrl+Z") 

463 zmq_server_action.triggered.connect(self.show_zmq_server_manager) 

464 view_menu.addAction(zmq_server_action) 

465 

466 # Configuration action 

467 config_action = QAction("&Global Configuration", self) 

468 config_action.setShortcut("Ctrl+G") 

469 config_action.triggered.connect(self.show_configuration) 

470 view_menu.addAction(config_action) 

471 

472 # Generate Synthetic Plate action 

473 generate_plate_action = QAction("Generate &Synthetic Plate", self) 

474 generate_plate_action.setShortcut("Ctrl+Shift+G") 

475 generate_plate_action.triggered.connect(self.show_synthetic_plate_generator) 

476 view_menu.addAction(generate_plate_action) 

477 

478 view_menu.addSeparator() 

479 

480 # Help menu 

481 help_menu = menubar.addMenu("&Help") 

482 

483 # General help action 

484 help_action = QAction("&Documentation", self) 

485 help_action.setShortcut("F1") 

486 help_action.triggered.connect(self.show_help) 

487 help_menu.addAction(help_action) 

488 

489 

490 def setup_status_bar(self): 

491 """Setup application status bar.""" 

492 self.status_bar = self.statusBar() 

493 self.status_bar.showMessage("OpenHCS PyQt6 GUI Ready") 

494 

495 # Add graph layout toggle button to the right side of status bar 

496 # Only add if system monitor widget exists and has the method 

497 if hasattr(self, 'system_monitor') and hasattr(self.system_monitor, 'create_layout_toggle_button'): 

498 toggle_button = self.system_monitor.create_layout_toggle_button() 

499 self.status_bar.addPermanentWidget(toggle_button) 

500 

501 # Connect status message signal 

502 self.status_message.connect(self.status_bar.showMessage) 

503 

504 def setup_connections(self): 

505 """Setup signal/slot connections.""" 

506 # Connect config changes 

507 self.config_changed.connect(self.on_config_changed) 

508 

509 # Connect service adapter to application 

510 self.service_adapter.set_global_config(self.global_config) 

511 

512 # Setup auto-save timer for window state 

513 self.auto_save_timer = QTimer() 

514 self.auto_save_timer.timeout.connect(self.save_window_state) 

515 self.auto_save_timer.start(30000) # Save every 30 seconds 

516 

517 def restore_window_state(self): 

518 """Restore window state from settings.""" 

519 try: 

520 geometry = self.settings.value("geometry") 

521 if geometry: 

522 self.restoreGeometry(geometry) 

523 

524 window_state = self.settings.value("windowState") 

525 if window_state: 

526 self.restoreState(window_state) 

527 

528 except Exception as e: 

529 logger.warning(f"Failed to restore window state: {e}") 

530 

531 def save_window_state(self): 

532 """Save window state to settings.""" 

533 # Skip settings save for now to prevent hanging 

534 # TODO: Investigate QSettings hanging issue 

535 logger.debug("Skipping window state save to prevent hanging") 

536 

537 # Menu action handlers 

538 def new_pipeline(self): 

539 """Create new pipeline.""" 

540 if "pipeline_editor" in self.dock_widgets: 

541 pipeline_widget = self.dock_widgets["pipeline_editor"].widget() 

542 if hasattr(pipeline_widget, 'new_pipeline'): 

543 pipeline_widget.new_pipeline() 

544 

545 def open_pipeline(self): 

546 """Open existing pipeline.""" 

547 file_path, _ = QFileDialog.getOpenFileName( 

548 self, 

549 "Open Pipeline", 

550 "", 

551 "Function Files (*.func);;All Files (*)" 

552 ) 

553 

554 if file_path and "pipeline_editor" in self.dock_widgets: 

555 pipeline_widget = self.dock_widgets["pipeline_editor"].widget() 

556 if hasattr(pipeline_widget, 'load_pipeline'): 

557 pipeline_widget.load_pipeline(Path(file_path)) 

558 

559 def save_pipeline(self): 

560 """Save current pipeline.""" 

561 if "pipeline_editor" in self.dock_widgets: 

562 pipeline_widget = self.dock_widgets["pipeline_editor"].widget() 

563 if hasattr(pipeline_widget, 'save_pipeline'): 

564 pipeline_widget.save_pipeline() 

565 

566 def show_configuration(self): 

567 """Show configuration dialog for global config editing.""" 

568 from openhcs.pyqt_gui.windows.config_window import ConfigWindow 

569 

570 def handle_config_save(new_config): 

571 """Handle configuration save (mirrors Textual TUI pattern).""" 

572 # new_config is already a GlobalPipelineConfig (concrete class) 

573 self.global_config = new_config 

574 

575 # Update thread-local storage for MaterializationPathConfig defaults 

576 from openhcs.core.config import GlobalPipelineConfig 

577 from openhcs.config_framework.global_config import set_global_config_for_editing 

578 set_global_config_for_editing(GlobalPipelineConfig, new_config) 

579 

580 # Emit signal for other components to update 

581 self.config_changed.emit(new_config) 

582 

583 # Save config to cache for future sessions (matches TUI) 

584 self._save_config_to_cache(new_config) 

585 

586 # Use concrete GlobalPipelineConfig for global config editing (static context) 

587 config_window = ConfigWindow( 

588 GlobalPipelineConfig, # config_class (concrete class for static context) 

589 self.service_adapter.get_global_config(), # current_config (concrete instance) 

590 handle_config_save, # on_save_callback 

591 self.service_adapter.get_current_color_scheme(), # color_scheme 

592 self # parent 

593 ) 

594 # Show as non-modal window (like plate manager and pipeline editor) 

595 config_window.show() 

596 config_window.raise_() 

597 config_window.activateWindow() 

598 

599 def _connect_pipeline_to_plate_manager(self, pipeline_widget): 

600 """Connect pipeline editor to plate manager (mirrors Textual TUI pattern).""" 

601 # Get plate manager if it exists 

602 if "plate_manager" in self.floating_windows: 

603 plate_manager_window = self.floating_windows["plate_manager"] 

604 

605 # Find the actual plate manager widget 

606 plate_manager_widget = None 

607 for child in plate_manager_window.findChildren(QWidget): 

608 if hasattr(child, 'selected_plate_path') and hasattr(child, 'orchestrators'): 

609 plate_manager_widget = child 

610 break 

611 

612 if plate_manager_widget: 

613 # Connect plate selection signal to pipeline editor (mirrors Textual TUI) 

614 plate_manager_widget.plate_selected.connect(pipeline_widget.set_current_plate) 

615 

616 # Connect orchestrator config changed signal for placeholder refresh 

617 plate_manager_widget.orchestrator_config_changed.connect(pipeline_widget.on_orchestrator_config_changed) 

618 

619 # Set pipeline editor reference in plate manager 

620 if hasattr(plate_manager_widget, 'set_pipeline_editor'): 

621 plate_manager_widget.set_pipeline_editor(pipeline_widget) 

622 

623 # Set current plate if one is already selected 

624 if plate_manager_widget.selected_plate_path: 

625 pipeline_widget.set_current_plate(plate_manager_widget.selected_plate_path) 

626 

627 logger.debug("Connected pipeline editor to plate manager") 

628 else: 

629 logger.warning("Could not find plate manager widget to connect") 

630 else: 

631 logger.debug("Plate manager not yet created - connection will be made when both exist") 

632 

633 def _connect_plate_to_pipeline_manager(self, plate_manager_widget): 

634 """Connect plate manager to pipeline editor (reverse direction).""" 

635 # Get pipeline editor if it exists 

636 if "pipeline_editor" in self.floating_windows: 

637 pipeline_editor_window = self.floating_windows["pipeline_editor"] 

638 

639 # Find the actual pipeline editor widget 

640 pipeline_editor_widget = None 

641 for child in pipeline_editor_window.findChildren(QWidget): 

642 if hasattr(child, 'set_current_plate') and hasattr(child, 'pipeline_steps'): 

643 pipeline_editor_widget = child 

644 break 

645 

646 if pipeline_editor_widget: 

647 # Connect plate selection signal to pipeline editor (mirrors Textual TUI) 

648 plate_manager_widget.plate_selected.connect(pipeline_editor_widget.set_current_plate) 

649 

650 # Connect orchestrator config changed signal for placeholder refresh 

651 plate_manager_widget.orchestrator_config_changed.connect(pipeline_editor_widget.on_orchestrator_config_changed) 

652 

653 # Set pipeline editor reference in plate manager 

654 if hasattr(plate_manager_widget, 'set_pipeline_editor'): 

655 plate_manager_widget.set_pipeline_editor(pipeline_editor_widget) 

656 

657 # Set current plate if one is already selected 

658 if plate_manager_widget.selected_plate_path: 

659 pipeline_editor_widget.set_current_plate(plate_manager_widget.selected_plate_path) 

660 

661 logger.debug("Connected plate manager to pipeline editor") 

662 else: 

663 logger.warning("Could not find pipeline editor widget to connect") 

664 else: 

665 logger.debug("Pipeline editor not yet created - connection will be made when both exist") 

666 

667 def show_synthetic_plate_generator(self): 

668 """Show synthetic plate generator window.""" 

669 from openhcs.pyqt_gui.windows.synthetic_plate_generator_window import SyntheticPlateGeneratorWindow 

670 

671 # Create and show the generator window 

672 generator_window = SyntheticPlateGeneratorWindow( 

673 color_scheme=self.service_adapter.get_current_color_scheme(), 

674 parent=self 

675 ) 

676 

677 # Connect the plate_generated signal to add the plate to the manager 

678 generator_window.plate_generated.connect(self._on_synthetic_plate_generated) 

679 

680 # Show the window 

681 generator_window.exec() 

682 

683 def _on_synthetic_plate_generated(self, output_dir: str, pipeline_path: str): 

684 """ 

685 Handle synthetic plate generation completion. 

686 

687 Args: 

688 output_dir: Path to the generated plate directory 

689 pipeline_path: Path to the test pipeline to load 

690 """ 

691 from pathlib import Path 

692 

693 # Ensure plate manager exists (create if needed) 

694 self.show_plate_manager() 

695 

696 # Get the plate manager widget 

697 plate_dialog = self.floating_windows["plate_manager"] 

698 from openhcs.pyqt_gui.widgets.plate_manager import PlateManagerWidget 

699 plate_manager = plate_dialog.findChild(PlateManagerWidget) 

700 

701 if not plate_manager: 

702 raise RuntimeError("Plate manager widget not found after creation") 

703 

704 # Add the generated plate - this triggers plate_selected signal 

705 # which automatically updates pipeline editor via existing connections 

706 plate_manager.add_plate_callback([Path(output_dir)]) 

707 

708 # Load the test pipeline (this will create pipeline editor if needed) 

709 self._load_pipeline_file(pipeline_path) 

710 

711 logger.info(f"Added synthetic plate and loaded test pipeline: {output_dir}") 

712 

713 def _load_pipeline_file(self, pipeline_path: str): 

714 """ 

715 Load a pipeline file into the pipeline editor. 

716 

717 Args: 

718 pipeline_path: Path to the pipeline file to load 

719 """ 

720 try: 

721 # Ensure pipeline editor exists (create if needed) 

722 self.show_pipeline_editor() 

723 

724 # Get the pipeline editor widget 

725 pipeline_dialog = self.floating_windows["pipeline_editor"] 

726 from openhcs.pyqt_gui.widgets.pipeline_editor import PipelineEditorWidget 

727 pipeline_editor = pipeline_dialog.findChild(PipelineEditorWidget) 

728 

729 if not pipeline_editor: 

730 raise RuntimeError("Pipeline editor widget not found after creation") 

731 

732 # Load the pipeline file 

733 from pathlib import Path 

734 pipeline_file = Path(pipeline_path) 

735 

736 if not pipeline_file.exists(): 

737 raise FileNotFoundError(f"Pipeline file not found: {pipeline_path}") 

738 

739 # For .py files, read code and use existing _handle_edited_pipeline_code 

740 if pipeline_file.suffix == '.py': 

741 with open(pipeline_file, 'r') as f: 

742 code = f.read() 

743 

744 # Use existing infrastructure that already handles code execution 

745 pipeline_editor._handle_edited_pipeline_code(code) 

746 logger.info(f"Loaded pipeline from Python file: {pipeline_path}") 

747 else: 

748 # For pickled files, use existing infrastructure 

749 pipeline_editor.load_pipeline_from_file(pipeline_file) 

750 logger.info(f"Loaded pipeline: {pipeline_path}") 

751 

752 except Exception as e: 

753 logger.error(f"Failed to load pipeline: {e}", exc_info=True) 

754 raise 

755 

756 

757 

758 def show_help(self): 

759 """Opens documentation URL in default web browser.""" 

760 from openhcs.constants.constants import DOCUMENTATION_URL 

761 

762 url = (DOCUMENTATION_URL) 

763 if not QDesktopServices.openUrl(QUrl.fromUserInput(url)): 

764 #fallback for wsl users because it wants to be special 

765 webbrowser.open(url) 

766 

767 

768 def on_config_changed(self, new_config: GlobalPipelineConfig): 

769 """Handle global configuration changes.""" 

770 self.global_config = new_config 

771 self.service_adapter.set_global_config(new_config) 

772 

773 # Notify all floating windows of config change 

774 for window in self.floating_windows.values(): 

775 # Get the widget from the window's layout 

776 layout = window.layout() 

777 widget = layout.itemAt(0).widget() 

778 # Only call on_config_changed if the widget has this method 

779 if hasattr(widget, 'on_config_changed'): 

780 widget.on_config_changed(new_config) 

781 

782 def _save_config_to_cache(self, config): 

783 """Save config to cache asynchronously (matches TUI pattern).""" 

784 try: 

785 from openhcs.pyqt_gui.services.config_cache_adapter import get_global_config_cache 

786 cache = get_global_config_cache() 

787 cache.save_config_to_cache_async(config) 

788 logger.info("Global config save to cache initiated") 

789 except Exception as e: 

790 logger.error(f"Error saving global config to cache: {e}") 

791 

792 def closeEvent(self, event): 

793 """Handle application close event.""" 

794 logger.info("Starting application shutdown...") 

795 

796 try: 

797 # Stop system monitor first with timeout 

798 if hasattr(self, 'system_monitor'): 

799 logger.info("Stopping system monitor...") 

800 self.system_monitor.stop_monitoring() 

801 

802 # Close floating windows and cleanup their resources 

803 for window_name, window in list(self.floating_windows.items()): 

804 try: 

805 layout = window.layout() 

806 if layout and layout.count() > 0: 

807 widget = layout.itemAt(0).widget() 

808 if hasattr(widget, 'cleanup'): 

809 widget.cleanup() 

810 window.close() 

811 window.deleteLater() 

812 except Exception as e: 

813 logger.warning(f"Error cleaning up window {window_name}: {e}") 

814 

815 # Clear floating windows dict 

816 self.floating_windows.clear() 

817 

818 # Save window state 

819 self.save_window_state() 

820 

821 # Force Qt to process pending events before shutdown 

822 from PyQt6.QtWidgets import QApplication 

823 QApplication.processEvents() 

824 

825 # Additional cleanup - force garbage collection 

826 import gc 

827 gc.collect() 

828 

829 except Exception as e: 

830 logger.error(f"Error during shutdown: {e}") 

831 

832 # Accept close event 

833 event.accept() 

834 logger.info("OpenHCS PyQt6 application closed") 

835 

836 # Force application quit with a short delay 

837 from PyQt6.QtCore import QTimer 

838 QTimer.singleShot(100, lambda: QApplication.instance().quit()) 

839 

840 # ========== THEME MANAGEMENT METHODS ========== 

841 

842 def switch_to_dark_theme(self): 

843 """Switch to dark theme variant.""" 

844 self.service_adapter.switch_to_dark_theme() 

845 self.status_message.emit("Switched to dark theme") 

846 

847 def switch_to_light_theme(self): 

848 """Switch to light theme variant.""" 

849 self.service_adapter.switch_to_light_theme() 

850 self.status_message.emit("Switched to light theme") 

851 

852 def load_theme_from_file(self): 

853 """Load theme from JSON configuration file.""" 

854 file_path, _ = QFileDialog.getOpenFileName( 

855 self, 

856 "Load Theme Configuration", 

857 "", 

858 "JSON Files (*.json);;All Files (*)" 

859 ) 

860 

861 if file_path: 

862 success = self.service_adapter.load_theme_from_config(file_path) 

863 if success: 

864 self.status_message.emit(f"Loaded theme from {Path(file_path).name}") 

865 else: 

866 QMessageBox.warning( 

867 self, 

868 "Theme Load Error", 

869 f"Failed to load theme from {Path(file_path).name}" 

870 ) 

871 

872 def save_theme_to_file(self): 

873 """Save current theme to JSON configuration file.""" 

874 file_path, _ = QFileDialog.getSaveFileName( 

875 self, 

876 "Save Theme Configuration", 

877 "pyqt6_color_scheme.json", 

878 "JSON Files (*.json);;All Files (*)" 

879 ) 

880 

881 if file_path: 

882 success = self.service_adapter.save_current_theme(file_path) 

883 if success: 

884 self.status_message.emit(f"Saved theme to {Path(file_path).name}") 

885 else: 

886 QMessageBox.warning( 

887 self, 

888 "Theme Save Error", 

889 f"Failed to save theme to {Path(file_path).name}" 

890 ) 

891 

892 def _on_plate_progress_started(self, max_value: int): 

893 """Handle plate manager progress started signal.""" 

894 if hasattr(self, '_status_progress_bar'): 

895 self._status_progress_bar.setMaximum(max_value) 

896 self._status_progress_bar.setValue(0) 

897 self._status_progress_bar.setVisible(True) 

898 

899 def _on_plate_progress_updated(self, value: int): 

900 """Handle plate manager progress updated signal.""" 

901 if hasattr(self, '_status_progress_bar'): 

902 self._status_progress_bar.setValue(value) 

903 

904 def _on_plate_progress_finished(self): 

905 """Handle plate manager progress finished signal.""" 

906 if hasattr(self, '_status_progress_bar'): 

907 self._status_progress_bar.setVisible(False)