Coverage for openhcs/pyqt_gui/windows/dual_editor_window.py: 0.0%
296 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"""
2Dual Editor Window for PyQt6
4Step and function editing dialog with tabbed interface.
5Uses hybrid approach: extracted business logic + clean PyQt6 UI.
6"""
8import logging
9from typing import Optional, Callable, Dict
11from PyQt6.QtWidgets import (
12 QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
13 QTabWidget, QWidget, QStackedWidget
14)
15from PyQt6.QtCore import pyqtSignal, Qt
16from PyQt6.QtGui import QFont
18from openhcs.core.steps.function_step import FunctionStep
19from openhcs.ui.shared.pattern_data_manager import PatternDataManager
21from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme
22from openhcs.pyqt_gui.shared.style_generator import StyleSheetGenerator
23from openhcs.pyqt_gui.windows.base_form_dialog import BaseFormDialog
24logger = logging.getLogger(__name__)
27class DualEditorWindow(BaseFormDialog):
28 """
29 PyQt6 Multi-Tab Parameter Editor Window.
31 Generic parameter editing dialog with inheritance hierarchy-based tabbed interface.
32 Creates one tab per class in the inheritance hierarchy, showing parameters specific
33 to each class level. Preserves all business logic from Textual version with clean PyQt6 UI.
35 Inherits from BaseFormDialog to automatically handle unregistration from
36 cross-window placeholder updates when the dialog closes.
37 """
39 # Signals
40 step_saved = pyqtSignal(object) # FunctionStep
41 step_cancelled = pyqtSignal()
42 changes_detected = pyqtSignal(bool) # has_changes
44 def __init__(self, step_data: Optional[FunctionStep] = None, is_new: bool = False,
45 on_save_callback: Optional[Callable] = None, color_scheme: Optional[PyQt6ColorScheme] = None,
46 orchestrator=None, gui_config=None, parent=None):
47 """
48 Initialize the dual editor window.
50 Args:
51 step_data: FunctionStep to edit (None for new step)
52 is_new: Whether this is a new step
53 on_save_callback: Function to call when step is saved
54 color_scheme: Color scheme for UI components
55 orchestrator: Orchestrator instance for context management
56 gui_config: Optional GUI configuration passed from PipelineEditor
57 parent: Parent widget
58 """
59 super().__init__(parent)
61 # Make window non-modal (like plate manager and pipeline editor)
62 self.setModal(False)
64 # Initialize color scheme and style generator
65 self.color_scheme = color_scheme or PyQt6ColorScheme()
66 self.style_generator = StyleSheetGenerator(self.color_scheme)
67 self.gui_config = gui_config
69 # Business logic state (extracted from Textual version)
70 self.is_new = is_new
71 self.on_save_callback = on_save_callback
72 self.orchestrator = orchestrator # Store orchestrator for context management
74 # Pattern management (extracted from Textual version)
75 self.pattern_manager = PatternDataManager()
77 # Store original step reference (never modified)
78 self.original_step_reference = step_data
80 if step_data:
81 # CRITICAL FIX: Work on a copy to prevent immediate modification of original
82 self.editing_step = self._clone_step(step_data)
83 self.original_step = self._clone_step(step_data)
84 else:
85 self.editing_step = self._create_new_step()
86 self.original_step = None
88 # Change tracking
89 self.has_changes = False
90 self.current_tab = "step"
92 # UI components
93 self.tab_widget: Optional[QTabWidget] = None
94 self.parameter_editors: Dict[str, QWidget] = {} # Map tab titles to editor widgets
95 self.class_hierarchy: List = [] # Store inheritance hierarchy info
97 # Setup UI
98 self.setup_ui()
99 self.setup_connections()
101 logger.debug(f"Dual editor window initialized (new={is_new})")
103 def set_original_step_for_change_detection(self):
104 """Set the original step for change detection. Must be called within proper context."""
105 # Original step is already set in __init__ when working on a copy
106 # This method is kept for compatibility but no longer needed
107 pass
109 def setup_ui(self):
110 """Setup the user interface."""
111 title = "New Step" if self.is_new else f"Edit Step: {getattr(self.editing_step, 'name', 'Unknown')}"
112 self.setWindowTitle(title)
113 # Keep non-modal (already set in __init__)
114 # No minimum size - let it be determined by content
115 self.resize(1000, 700)
117 layout = QVBoxLayout(self)
118 layout.setSpacing(5)
119 layout.setContentsMargins(5, 5, 5, 5)
121 # Single row: tabs + title + status + buttons
122 tab_row = QHBoxLayout()
123 tab_row.setContentsMargins(5, 5, 5, 5)
124 tab_row.setSpacing(10)
126 # Tab widget (tabs on the left)
127 self.tab_widget = QTabWidget()
128 # Get the tab bar and add it to our horizontal layout
129 self.tab_bar = self.tab_widget.tabBar()
130 # Prevent tab scrolling by setting expanding to false and using minimum size hint
131 self.tab_bar.setExpanding(False)
132 self.tab_bar.setUsesScrollButtons(False)
133 tab_row.addWidget(self.tab_bar, 0) # 0 stretch - don't expand
135 # Title on the right of tabs (allow it to be cropped if needed)
136 header_label = QLabel(title)
137 header_label.setFont(QFont("Arial", 14, QFont.Weight.Bold))
138 header_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_accent)};")
139 from PyQt6.QtWidgets import QSizePolicy
140 header_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
141 tab_row.addWidget(header_label, 1) # 1 stretch - allow to expand and be cropped
143 tab_row.addStretch()
145 # Status indicator
146 self.changes_label = QLabel("")
147 self.changes_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.status_warning)}; font-style: italic;")
148 tab_row.addWidget(self.changes_label)
150 # Get centralized button styles
151 button_styles = self.style_generator.generate_config_button_styles()
153 # Cancel button
154 cancel_button = QPushButton("Cancel")
155 cancel_button.setFixedHeight(28)
156 cancel_button.setMinimumWidth(70)
157 cancel_button.clicked.connect(self.cancel_edit)
158 cancel_button.setStyleSheet(button_styles["cancel"])
159 tab_row.addWidget(cancel_button)
161 # Save button
162 self.save_button = QPushButton("Save")
163 self.save_button.setFixedHeight(28)
164 self.save_button.setMinimumWidth(70)
165 self.save_button.setEnabled(False) # Initially disabled
166 self.save_button.clicked.connect(self.save_edit)
167 self.save_button.setStyleSheet(button_styles["save"] + f"""
168 QPushButton:disabled {{
169 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
170 color: {self.color_scheme.to_hex(self.color_scheme.border_light)};
171 border: none;
172 }}
173 """)
174 tab_row.addWidget(self.save_button)
176 layout.addLayout(tab_row)
177 # Style the tab bar
178 self.tab_bar.setStyleSheet(f"""
179 QTabBar::tab {{
180 background-color: {self.color_scheme.to_hex(self.color_scheme.input_bg)};
181 color: white;
182 padding: 8px 16px;
183 margin-right: 2px;
184 border-top-left-radius: 4px;
185 border-top-right-radius: 4px;
186 border: none;
187 }}
188 QTabBar::tab:selected {{
189 background-color: {self.color_scheme.to_hex(self.color_scheme.selection_bg)};
190 }}
191 QTabBar::tab:hover {{
192 background-color: {self.color_scheme.to_hex(self.color_scheme.button_hover_bg)};
193 }}
194 """)
196 # Create tabs (this adds content to the tab widget)
197 self.create_step_tab()
198 self.create_function_tab()
200 # Add the tab widget's content area (stacked widget) below the tab row
201 # The tab bar is already in tab_row, so we only add the content pane here
202 content_container = QWidget()
203 content_layout = QVBoxLayout(content_container)
204 content_layout.setContentsMargins(0, 0, 0, 0)
205 content_layout.setSpacing(0)
207 # Get the stacked widget from the tab widget and add it
208 stacked_widget = self.tab_widget.findChild(QStackedWidget)
209 if stacked_widget:
210 content_layout.addWidget(stacked_widget)
212 layout.addWidget(content_container)
214 # Apply centralized styling
215 self.setStyleSheet(self.style_generator.generate_config_window_style())
217 def create_step_tab(self):
218 """Create the step settings tab (using dedicated widget)."""
219 from openhcs.pyqt_gui.widgets.step_parameter_editor import StepParameterEditorWidget
220 from openhcs.config_framework.context_manager import config_context
222 # Create step parameter editor widget with proper nested context
223 # Step must be nested: GlobalPipelineConfig -> PipelineConfig -> Step
224 # CRITICAL: Pass orchestrator's plate_path as scope_id to limit cross-window updates to same orchestrator
225 scope_id = str(self.orchestrator.plate_path) if self.orchestrator else None
226 with config_context(self.orchestrator.pipeline_config): # Pipeline level
227 with config_context(self.editing_step): # Step level
228 self.step_editor = StepParameterEditorWidget(
229 self.editing_step,
230 service_adapter=None,
231 color_scheme=self.color_scheme,
232 pipeline_config=self.orchestrator.pipeline_config,
233 scope_id=scope_id
234 )
236 # Connect parameter changes - use form manager signal for immediate response
237 self.step_editor.form_manager.parameter_changed.connect(self.on_form_parameter_changed)
239 self.tab_widget.addTab(self.step_editor, "Step Settings")
241 def create_function_tab(self):
242 """Create the function pattern tab (using dedicated widget)."""
243 from openhcs.pyqt_gui.widgets.function_list_editor import FunctionListEditorWidget
245 # Convert step func to function list format
246 initial_functions = self._convert_step_func_to_list()
248 # Create function list editor widget (mirrors Textual TUI)
249 step_id = getattr(self.editing_step, 'name', 'unknown_step')
250 self.func_editor = FunctionListEditorWidget(
251 initial_functions=initial_functions,
252 step_identifier=step_id,
253 service_adapter=None
254 )
256 # Store main window reference for orchestrator access (find it through parent chain)
257 main_window = self._find_main_window()
258 if main_window:
259 self.func_editor.main_window = main_window
261 # Initialize step configuration settings in function editor (mirrors Textual TUI)
262 self.func_editor.current_group_by = self.editing_step.group_by
263 self.func_editor.current_variable_components = self.editing_step.variable_components or []
265 # Refresh component button to show correct text and state (mirrors Textual TUI reactive updates)
266 self.func_editor._refresh_component_button()
268 # Connect function pattern changes
269 self.func_editor.function_pattern_changed.connect(self._on_function_pattern_changed)
271 self.tab_widget.addTab(self.func_editor, "Function Pattern")
273 def _on_function_pattern_changed(self):
274 """Handle function pattern changes from function editor."""
275 # Update step func from function editor - use current_pattern to get full pattern data
276 current_pattern = self.func_editor.current_pattern
278 # CRITICAL FIX: Use fresh imports to avoid unpicklable registry wrappers
279 if callable(current_pattern) and hasattr(current_pattern, '__module__'):
280 try:
281 import importlib
282 module = importlib.import_module(current_pattern.__module__)
283 current_pattern = getattr(module, current_pattern.__name__)
284 except Exception:
285 pass # Use original if refresh fails
287 self.editing_step.func = current_pattern
288 self.detect_changes()
289 logger.debug(f"Function pattern changed: {current_pattern}")
295 def setup_connections(self):
296 """Setup signal/slot connections."""
297 # Tab change tracking
298 self.tab_widget.currentChanged.connect(self.on_tab_changed)
300 # Change detection
301 self.changes_detected.connect(self.on_changes_detected)
303 def _convert_step_func_to_list(self):
304 """Convert step func to initial pattern format for function list editor."""
305 if not hasattr(self.editing_step, 'func') or not self.editing_step.func:
306 return []
308 # Return the step func directly - the function list editor will handle the conversion
309 result = self.editing_step.func
310 print(f"🔍 DUAL EDITOR _convert_step_func_to_list: returning {result}")
311 return result
315 def _find_main_window(self):
316 """Find the main window through the parent chain."""
317 try:
318 # Navigate up the parent chain to find OpenHCSMainWindow
319 current = self.parent()
320 while current:
321 # Check if this is the main window (has floating_windows attribute)
322 if hasattr(current, 'floating_windows') and hasattr(current, 'service_adapter'):
323 logger.debug(f"Found main window: {type(current).__name__}")
324 return current
325 current = current.parent()
327 logger.warning("Could not find main window in parent chain")
328 return None
330 except Exception as e:
331 logger.error(f"Error finding main window: {e}")
332 return None
334 def _get_current_plate_from_pipeline_editor(self):
335 """Get current plate from pipeline editor (mirrors Textual TUI pattern)."""
336 try:
337 # Navigate up to find pipeline editor widget
338 current = self.parent()
339 while current:
340 # Check if this is a pipeline editor widget
341 if hasattr(current, 'current_plate') and hasattr(current, 'pipeline_steps'):
342 current_plate = getattr(current, 'current_plate', None)
343 if current_plate:
344 logger.debug(f"Found current plate from pipeline editor: {current_plate}")
345 return current_plate
347 # Check children for pipeline editor widget
348 for child in current.findChildren(QWidget):
349 if hasattr(child, 'current_plate') and hasattr(child, 'pipeline_steps'):
350 current_plate = getattr(child, 'current_plate', None)
351 if current_plate:
352 logger.debug(f"Found current plate from pipeline editor child: {current_plate}")
353 return current_plate
355 current = current.parent()
357 logger.warning("Could not find current plate from pipeline editor")
358 return None
360 except Exception as e:
361 logger.error(f"Error getting current plate from pipeline editor: {e}")
362 return None
364 # Old function pane methods removed - now using dedicated FunctionListEditorWidget
366 def get_function_info(self) -> str:
367 """
368 Get function information for display.
370 Returns:
371 Function information string
372 """
373 if not self.editing_step or not hasattr(self.editing_step, 'func'):
374 return "No function assigned"
376 func = self.editing_step.func
377 func_name = getattr(func, '__name__', 'Unknown Function')
378 func_module = getattr(func, '__module__', 'Unknown Module')
380 info = f"Function: {func_name}\n"
381 info += f"Module: {func_module}\n"
383 # Add parameter info if available
384 if hasattr(self.editing_step, 'parameters'):
385 params = self.editing_step.parameters
386 if params:
387 info += f"\nParameters ({len(params)}):\n"
388 for param_name, param_value in params.items():
389 info += f" {param_name}: {param_value}\n"
391 return info
393 def on_form_parameter_changed(self, param_name: str, value):
394 """Handle form parameter changes directly from form manager."""
395 # CRITICAL FIX: For function parameters, use fresh imports to avoid unpicklable registry wrappers
396 if param_name == 'func' and callable(value) and hasattr(value, '__module__'):
397 try:
398 import importlib
399 module = importlib.import_module(value.__module__)
400 value = getattr(module, value.__name__)
401 except Exception:
402 pass # Use original if refresh fails
404 setattr(self.editing_step, param_name, value)
406 if param_name in ('group_by', 'variable_components'):
407 self.func_editor.current_group_by = self.editing_step.group_by
408 self.func_editor.current_variable_components = self.editing_step.variable_components or []
409 self.func_editor._refresh_component_button()
411 self.detect_changes()
413 def on_tab_changed(self, index: int):
414 """Handle tab changes."""
415 tab_names = ["step", "function"]
416 if 0 <= index < len(tab_names):
417 self.current_tab = tab_names[index]
418 logger.debug(f"Tab changed to: {self.current_tab}")
420 def detect_changes(self):
421 """Detect if changes have been made."""
422 has_changes = self.original_step != self.editing_step
424 # Check function pattern
425 if not has_changes:
426 original_func = getattr(self.original_step, 'func', None)
427 current_func = getattr(self.editing_step, 'func', None)
428 # Simple comparison - could be enhanced for deep comparison
429 has_changes = str(original_func) != str(current_func)
431 if has_changes != self.has_changes:
432 self.has_changes = has_changes
433 self.changes_detected.emit(has_changes)
435 def on_changes_detected(self, has_changes: bool):
436 """Handle changes detection."""
437 if has_changes:
438 self.changes_label.setText("● Unsaved changes")
439 self.save_button.setEnabled(True)
440 else:
441 self.changes_label.setText("")
442 self.save_button.setEnabled(False)
444 def save_edit(self):
445 """Save the edited step."""
446 try:
447 # CRITICAL FIX: Sync function pattern from function editor BEFORE collecting form values
448 # The function editor doesn't use a form manager, so we need to explicitly sync it
449 if self.func_editor:
450 current_pattern = self.func_editor.current_pattern
452 # CRITICAL FIX: Use fresh imports to avoid unpicklable registry wrappers
453 if callable(current_pattern) and hasattr(current_pattern, '__module__'):
454 try:
455 import importlib
456 module = importlib.import_module(current_pattern.__module__)
457 current_pattern = getattr(module, current_pattern.__name__)
458 except Exception:
459 pass # Use original if refresh fails
461 self.editing_step.func = current_pattern
462 logger.debug(f"Synced function pattern before save: {current_pattern}")
464 # CRITICAL FIX: Collect current values from all form managers before saving
465 # This ensures nested dataclass field values are properly saved to the step object
466 for tab_index in range(self.tab_widget.count()):
467 tab_widget = self.tab_widget.widget(tab_index)
468 if hasattr(tab_widget, 'form_manager'):
469 # Get current values from this tab's form manager
470 current_values = tab_widget.form_manager.get_current_values()
472 # Apply values to the editing step
473 for param_name, value in current_values.items():
474 if hasattr(self.editing_step, param_name):
475 setattr(self.editing_step, param_name, value)
476 logger.debug(f"Applied {param_name}={value} to editing step")
478 # Validate step
479 step_name = getattr(self.editing_step, 'name', None)
480 if not step_name or not step_name.strip():
481 from PyQt6.QtWidgets import QMessageBox
482 QMessageBox.warning(self, "Validation Error", "Step name cannot be empty.")
483 return
485 # CRITICAL FIX: For existing steps, apply changes to original step object
486 # This ensures the pipeline gets the updated step with the same object identity
487 if self.original_step_reference is not None:
488 # Copy all attributes from editing_step to original_step_reference
489 self._apply_changes_to_original()
490 step_to_save = self.original_step_reference
491 else:
492 # For new steps, use the editing_step directly
493 step_to_save = self.editing_step
495 # Emit signals and call callback
496 self.step_saved.emit(step_to_save)
498 if self.on_save_callback:
499 self.on_save_callback(step_to_save)
501 self.accept() # BaseFormDialog handles unregistration
502 logger.debug(f"Step saved: {getattr(step_to_save, 'name', 'Unknown')}")
504 except Exception as e:
505 logger.error(f"Failed to save step: {e}")
506 from PyQt6.QtWidgets import QMessageBox
507 QMessageBox.critical(self, "Save Error", f"Failed to save step:\n{e}")
509 def _apply_changes_to_original(self):
510 """Apply all changes from editing_step to original_step_reference."""
511 if self.original_step_reference is None:
512 return
514 # Copy all attributes from editing_step to original_step_reference
515 from dataclasses import fields, is_dataclass
517 if is_dataclass(self.editing_step):
518 # For dataclass steps, copy all field values
519 for field in fields(self.editing_step):
520 value = getattr(self.editing_step, field.name)
521 setattr(self.original_step_reference, field.name, value)
522 else:
523 # CRITICAL FIX: Use reflection to copy ALL attributes, not just hardcoded list
524 # This ensures optional dataclass attributes like step_materialization_config are copied
525 for attr_name in dir(self.editing_step):
526 # Skip private/magic attributes and methods
527 if not attr_name.startswith('_') and not callable(getattr(self.editing_step, attr_name, None)):
528 if hasattr(self.editing_step, attr_name) and hasattr(self.original_step_reference, attr_name):
529 value = getattr(self.editing_step, attr_name)
530 setattr(self.original_step_reference, attr_name, value)
531 logger.debug(f"Copied attribute {attr_name}: {value}")
533 logger.debug("Applied changes to original step object")
535 def _clone_step(self, step):
536 """Clone a step object using deep copy."""
537 import copy
538 return copy.deepcopy(step)
540 def _create_new_step(self):
541 """Create a new empty step."""
542 from openhcs.core.steps.function_step import FunctionStep
543 return FunctionStep(
544 func=[], # Start with empty function list
545 name="New_Step"
546 )
548 def cancel_edit(self):
549 """Cancel editing and close dialog."""
550 if self.has_changes:
551 from PyQt6.QtWidgets import QMessageBox
552 reply = QMessageBox.question(
553 self,
554 "Unsaved Changes",
555 "You have unsaved changes. Are you sure you want to cancel?",
556 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
557 QMessageBox.StandardButton.No
558 )
560 if reply != QMessageBox.StandardButton.Yes:
561 return
563 self.step_cancelled.emit()
564 self.reject() # BaseFormDialog handles unregistration
565 logger.debug("Step editing cancelled")
567 def closeEvent(self, event):
568 """Handle dialog close event."""
569 if self.has_changes:
570 from PyQt6.QtWidgets import QMessageBox
571 reply = QMessageBox.question(
572 self,
573 "Unsaved Changes",
574 "You have unsaved changes. Are you sure you want to close?",
575 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
576 QMessageBox.StandardButton.No
577 )
579 if reply != QMessageBox.StandardButton.Yes:
580 event.ignore()
581 return
583 super().closeEvent(event) # BaseFormDialog handles unregistration
585 # No need to override _get_form_managers() - BaseFormDialog automatically
586 # discovers all ParameterFormManager instances recursively in the widget tree