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
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-04 02:09 +0000
1"""
2OpenHCS PyQt6 Main Window
4Main application window implementing QDockWidget system to replace
5textual-window floating windows with native Qt docking.
6"""
8import logging
9from typing import Optional, Dict
10from pathlib import Path
11import webbrowser
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
20from openhcs.core.config import GlobalPipelineConfig
21from openhcs.io.filemanager import FileManager
22from openhcs.io.base import storage_registry
24from openhcs.pyqt_gui.services.service_adapter import PyQtServiceAdapter
26logger = logging.getLogger(__name__)
29class OpenHCSMainWindow(QMainWindow):
30 """
31 Main OpenHCS PyQt6 application window.
33 Implements QDockWidget system to replace textual-window floating windows
34 with native Qt docking, providing better desktop integration.
35 """
37 # Signals for application events
38 config_changed = pyqtSignal(object) # GlobalPipelineConfig
39 status_message = pyqtSignal(str) # Status message
41 def __init__(self, global_config: Optional[GlobalPipelineConfig] = None):
42 """
43 Initialize the main OpenHCS window.
45 Args:
46 global_config: Global configuration (uses default if None)
47 """
48 super().__init__()
50 # Core configuration
51 self.global_config = global_config or GlobalPipelineConfig()
53 # Create shared components
54 self.storage_registry = storage_registry
55 self.file_manager = FileManager(self.storage_registry)
57 # Service adapter for Qt integration
58 self.service_adapter = PyQtServiceAdapter(self)
60 # Floating windows registry (replaces dock widgets)
61 self.floating_windows: Dict[str, QDialog] = {}
63 # Settings for window state persistence
64 self.settings = QSettings("OpenHCS", "PyQt6GUI")
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()
74 # Apply initial theme
75 self.apply_initial_theme()
77 # Restore window state
78 self.restore_window_state()
80 logger.info("OpenHCS PyQt6 main window initialized (deferred initialization pending)")
82 def _deferred_initialization(self):
83 """
84 Deferred initialization that happens after window is visible.
86 This includes:
87 - Log viewer initialization (file I/O) - IMMEDIATE
88 - Default windows (pipeline editor with config cache warming) - IMMEDIATE
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()
95 # Show default windows (plate manager and pipeline editor visible by default) - IMMEDIATE
96 self.show_default_windows()
98 logger.info("Deferred initialization complete (UI ready)")
102 def setup_ui(self):
103 """Setup basic UI structure."""
104 self.setWindowTitle("OpenHCS")
105 self.setMinimumSize(640, 480)
107 # Make main window floating (not tiled) like other OpenHCS components
108 self.setWindowFlags(Qt.WindowType.Dialog)
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)
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)
120 # Store layout for potential future use
121 self.central_layout = central_layout
123 self.setCentralWidget(central_widget)
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()
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)
134 logger.debug("Registered for theme change notifications (theme already applied by ServiceAdapter)")
136 def on_theme_changed(self, color_scheme):
137 """
138 Handle theme change notifications.
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")
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
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
160 def show_default_windows(self):
161 """Show plate manager by default."""
162 # Show plate manager by default
163 self.show_plate_manager()
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
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
174 # Create floating window
175 window = QDialog(self)
176 window.setWindowTitle("Plate Manager")
177 window.setModal(False)
178 window.resize(600, 400)
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)
189 self.floating_windows["plate_manager"] = window
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)
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 )
212 # Connect to pipeline editor if it exists (mirrors Textual TUI)
213 self._connect_plate_to_pipeline_manager(plate_widget)
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()
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
225 # Create floating window
226 window = QDialog(self)
227 window.setWindowTitle("Pipeline Editor")
228 window.setModal(False)
229 window.resize(800, 600)
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)
240 self.floating_windows["pipeline_editor"] = window
242 # Connect to plate manager for current plate selection (mirrors Textual TUI)
243 self._connect_pipeline_to_plate_manager(pipeline_widget)
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()
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
258 # Create floating window
259 window = QDialog(self)
260 window.setWindowTitle("Image Browser")
261 window.setModal(False)
262 window.resize(900, 600)
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)
272 self.floating_windows["image_browser"] = window
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)
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)
290 # Set initial orchestrator if available
291 on_plate_selected()
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()
298 def _initialize_log_viewer(self):
299 """
300 Initialize Log Viewer on startup (hidden) for continuous log monitoring.
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
307 # Create floating window (hidden)
308 window = QDialog(self)
309 window.setWindowTitle("Log Viewer")
310 window.setModal(False)
311 window.resize(900, 700)
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)
318 self.floating_windows["log_viewer"] = window
320 # Window stays hidden until user opens it
321 logger.info("Log Viewer initialized (hidden) - monitoring for new logs")
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()
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
340 # Create floating window
341 window = QDialog(self)
342 window.setWindowTitle("ZMQ Server Manager")
343 window.setModal(False)
344 window.resize(600, 400)
346 # Add widget to window
347 layout = QVBoxLayout(window)
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
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 )
360 # Connect log file opened signal to log viewer
361 zmq_manager_widget.log_file_opened.connect(self._open_log_file_in_viewer)
363 layout.addWidget(zmq_manager_widget)
365 self.floating_windows["zmq_server_manager"] = window
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()
372 def _open_log_file_in_viewer(self, log_file_path: str):
373 """
374 Open a log file in the log viewer.
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()
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}")
393 def setup_menu_bar(self):
394 """Setup application menu bar."""
395 menubar = self.menuBar()
397 # File menu
398 file_menu = menubar.addMenu("&File")
400 # Theme submenu
401 theme_menu = file_menu.addMenu("&Theme")
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)
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)
413 theme_menu.addSeparator()
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)
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)
425 file_menu.addSeparator()
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)
433 # View menu
434 view_menu = menubar.addMenu("&View")
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)
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)
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)
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)
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)
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)
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)
478 view_menu.addSeparator()
480 # Help menu
481 help_menu = menubar.addMenu("&Help")
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)
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")
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)
501 # Connect status message signal
502 self.status_message.connect(self.status_bar.showMessage)
504 def setup_connections(self):
505 """Setup signal/slot connections."""
506 # Connect config changes
507 self.config_changed.connect(self.on_config_changed)
509 # Connect service adapter to application
510 self.service_adapter.set_global_config(self.global_config)
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
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)
524 window_state = self.settings.value("windowState")
525 if window_state:
526 self.restoreState(window_state)
528 except Exception as e:
529 logger.warning(f"Failed to restore window state: {e}")
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")
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()
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 )
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))
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()
566 def show_configuration(self):
567 """Show configuration dialog for global config editing."""
568 from openhcs.pyqt_gui.windows.config_window import ConfigWindow
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
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)
580 # Emit signal for other components to update
581 self.config_changed.emit(new_config)
583 # Save config to cache for future sessions (matches TUI)
584 self._save_config_to_cache(new_config)
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()
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"]
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
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)
616 # Connect orchestrator config changed signal for placeholder refresh
617 plate_manager_widget.orchestrator_config_changed.connect(pipeline_widget.on_orchestrator_config_changed)
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)
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)
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")
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"]
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
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)
650 # Connect orchestrator config changed signal for placeholder refresh
651 plate_manager_widget.orchestrator_config_changed.connect(pipeline_editor_widget.on_orchestrator_config_changed)
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)
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)
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")
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
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 )
677 # Connect the plate_generated signal to add the plate to the manager
678 generator_window.plate_generated.connect(self._on_synthetic_plate_generated)
680 # Show the window
681 generator_window.exec()
683 def _on_synthetic_plate_generated(self, output_dir: str, pipeline_path: str):
684 """
685 Handle synthetic plate generation completion.
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
693 # Ensure plate manager exists (create if needed)
694 self.show_plate_manager()
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)
701 if not plate_manager:
702 raise RuntimeError("Plate manager widget not found after creation")
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)])
708 # Load the test pipeline (this will create pipeline editor if needed)
709 self._load_pipeline_file(pipeline_path)
711 logger.info(f"Added synthetic plate and loaded test pipeline: {output_dir}")
713 def _load_pipeline_file(self, pipeline_path: str):
714 """
715 Load a pipeline file into the pipeline editor.
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()
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)
729 if not pipeline_editor:
730 raise RuntimeError("Pipeline editor widget not found after creation")
732 # Load the pipeline file
733 from pathlib import Path
734 pipeline_file = Path(pipeline_path)
736 if not pipeline_file.exists():
737 raise FileNotFoundError(f"Pipeline file not found: {pipeline_path}")
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()
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}")
752 except Exception as e:
753 logger.error(f"Failed to load pipeline: {e}", exc_info=True)
754 raise
758 def show_help(self):
759 """Opens documentation URL in default web browser."""
760 from openhcs.constants.constants import DOCUMENTATION_URL
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)
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)
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)
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}")
792 def closeEvent(self, event):
793 """Handle application close event."""
794 logger.info("Starting application shutdown...")
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()
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}")
815 # Clear floating windows dict
816 self.floating_windows.clear()
818 # Save window state
819 self.save_window_state()
821 # Force Qt to process pending events before shutdown
822 from PyQt6.QtWidgets import QApplication
823 QApplication.processEvents()
825 # Additional cleanup - force garbage collection
826 import gc
827 gc.collect()
829 except Exception as e:
830 logger.error(f"Error during shutdown: {e}")
832 # Accept close event
833 event.accept()
834 logger.info("OpenHCS PyQt6 application closed")
836 # Force application quit with a short delay
837 from PyQt6.QtCore import QTimer
838 QTimer.singleShot(100, lambda: QApplication.instance().quit())
840 # ========== THEME MANAGEMENT METHODS ==========
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")
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")
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 )
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 )
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 )
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 )
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)
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)
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)