Coverage for openhcs/pyqt_gui/windows/dual_editor_window.py: 0.0%
346 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-01 18:33 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-01 18:33 +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, Any, Dict
11from PyQt6.QtWidgets import (
12 QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
13 QTabWidget, QWidget, QFrame, QTextEdit, QScrollArea
14)
15from PyQt6.QtCore import Qt, pyqtSignal
16from PyQt6.QtGui import QFont
18from openhcs.core.steps.function_step import FunctionStep
19from openhcs.textual_tui.services.pattern_data_manager import PatternDataManager
21from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme
22logger = logging.getLogger(__name__)
25class DualEditorWindow(QDialog):
26 """
27 PyQt6 Multi-Tab Parameter Editor Window.
29 Generic parameter editing dialog with inheritance hierarchy-based tabbed interface.
30 Creates one tab per class in the inheritance hierarchy, showing parameters specific
31 to each class level. Preserves all business logic from Textual version with clean PyQt6 UI.
32 """
34 # Signals
35 step_saved = pyqtSignal(object) # FunctionStep
36 step_cancelled = pyqtSignal()
37 changes_detected = pyqtSignal(bool) # has_changes
39 def __init__(self, step_data: Optional[FunctionStep] = None, is_new: bool = False,
40 on_save_callback: Optional[Callable] = None, color_scheme: Optional[PyQt6ColorScheme] = None,
41 orchestrator=None, gui_config=None, parent=None):
42 """
43 Initialize the dual editor window.
45 Args:
46 step_data: FunctionStep to edit (None for new step)
47 is_new: Whether this is a new step
48 on_save_callback: Function to call when step is saved
49 color_scheme: Color scheme for UI components
50 orchestrator: Orchestrator instance for context management
51 gui_config: Optional GUI configuration passed from PipelineEditor
52 parent: Parent widget
53 """
54 super().__init__(parent)
56 # Make window non-modal (like plate manager and pipeline editor)
57 self.setModal(False)
59 # Initialize color scheme
60 self.color_scheme = color_scheme or PyQt6ColorScheme()
61 self.gui_config = gui_config
63 # Business logic state (extracted from Textual version)
64 self.is_new = is_new
65 self.on_save_callback = on_save_callback
66 self.orchestrator = orchestrator # Store orchestrator for context management
68 # Pattern management (extracted from Textual version)
69 self.pattern_manager = PatternDataManager()
71 # Store original step reference (never modified)
72 self.original_step_reference = step_data
74 if step_data:
75 # CRITICAL FIX: Work on a copy to prevent immediate modification of original
76 self.editing_step = self._clone_step(step_data)
77 self.original_step = self._clone_step(step_data)
78 else:
79 self.editing_step = self._create_new_step()
80 self.original_step = None
82 # Change tracking
83 self.has_changes = False
84 self.current_tab = "step"
86 # UI components
87 self.tab_widget: Optional[QTabWidget] = None
88 self.parameter_editors: Dict[str, QWidget] = {} # Map tab titles to editor widgets
89 self.class_hierarchy: List = [] # Store inheritance hierarchy info
91 # Setup UI
92 self.setup_ui()
93 self.setup_connections()
95 logger.debug(f"Dual editor window initialized (new={is_new})")
97 def set_original_step_for_change_detection(self):
98 """Set the original step for change detection. Must be called within proper context."""
99 # Original step is already set in __init__ when working on a copy
100 # This method is kept for compatibility but no longer needed
101 pass
103 def setup_ui(self):
104 """Setup the user interface."""
105 title = "New Step" if self.is_new else f"Edit Step: {getattr(self.editing_step, 'name', 'Unknown')}"
106 self.setWindowTitle(title)
107 # Keep non-modal (already set in __init__)
108 self.setMinimumSize(800, 600)
109 self.resize(1000, 700)
111 layout = QVBoxLayout(self)
112 layout.setSpacing(5)
113 layout.setContentsMargins(5, 5, 5, 5)
115 # Header
116 header_label = QLabel(title)
117 header_label.setFont(QFont("Arial", 14, QFont.Weight.Bold))
118 header_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_accent)}; padding: 5px;")
119 layout.addWidget(header_label)
121 # Tabbed content
122 self.tab_widget = QTabWidget()
123 self.tab_widget.setStyleSheet(f"""
124 QTabWidget::pane {{
125 border: none;
126 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
127 }}
128 QTabBar::tab {{
129 background-color: {self.color_scheme.to_hex(self.color_scheme.input_bg)};
130 color: white;
131 padding: 8px 16px;
132 margin-right: 2px;
133 border-top-left-radius: 4px;
134 border-top-right-radius: 4px;
135 border: none;
136 }}
137 QTabBar::tab:selected {{
138 background-color: {self.color_scheme.to_hex(self.color_scheme.selection_bg)};
139 }}
140 QTabBar::tab:hover {{
141 background-color: {self.color_scheme.to_hex(self.color_scheme.button_hover_bg)};
142 }}
143 """)
145 # Create tabs
146 self.create_step_tab()
147 self.create_function_tab()
149 layout.addWidget(self.tab_widget)
151 # Button panel
152 button_panel = self.create_button_panel()
153 layout.addWidget(button_panel)
155 # Set styling
156 self.setStyleSheet(f"""
157 QDialog {{
158 background-color: {self.color_scheme.to_hex(self.color_scheme.window_bg)};
159 color: white;
160 }}
161 """)
163 def create_step_tab(self):
164 """Create the step settings tab (using dedicated widget)."""
165 from openhcs.pyqt_gui.widgets.step_parameter_editor import StepParameterEditorWidget
166 from openhcs.config_framework.context_manager import config_context
168 # Create step parameter editor widget with proper nested context
169 # Step must be nested: GlobalPipelineConfig -> PipelineConfig -> Step
170 with config_context(self.orchestrator.pipeline_config): # Pipeline level
171 with config_context(self.editing_step): # Step level
172 self.step_editor = StepParameterEditorWidget(
173 self.editing_step,
174 service_adapter=None,
175 color_scheme=self.color_scheme,
176 pipeline_config=self.orchestrator.pipeline_config
177 )
179 # Connect parameter changes - use form manager signal for immediate response
180 self.step_editor.form_manager.parameter_changed.connect(self.on_form_parameter_changed)
182 self.tab_widget.addTab(self.step_editor, "Step Settings")
184 def create_function_tab(self):
185 """Create the function pattern tab (using dedicated widget)."""
186 from openhcs.pyqt_gui.widgets.function_list_editor import FunctionListEditorWidget
188 # Convert step func to function list format
189 initial_functions = self._convert_step_func_to_list()
191 # Create function list editor widget (mirrors Textual TUI)
192 step_id = getattr(self.editing_step, 'name', 'unknown_step')
193 self.func_editor = FunctionListEditorWidget(
194 initial_functions=initial_functions,
195 step_identifier=step_id,
196 service_adapter=None
197 )
199 # Store main window reference for orchestrator access (find it through parent chain)
200 main_window = self._find_main_window()
201 if main_window:
202 self.func_editor.main_window = main_window
204 # Initialize step configuration settings in function editor (mirrors Textual TUI)
205 self.func_editor.current_group_by = self.editing_step.group_by
206 self.func_editor.current_variable_components = self.editing_step.variable_components or []
208 # Refresh component button to show correct text and state (mirrors Textual TUI reactive updates)
209 self.func_editor._refresh_component_button()
211 # Connect function pattern changes
212 self.func_editor.function_pattern_changed.connect(self._on_function_pattern_changed)
214 self.tab_widget.addTab(self.func_editor, "Function Pattern")
216 def _on_function_pattern_changed(self):
217 """Handle function pattern changes from function editor."""
218 # Update step func from function editor - use current_pattern to get full pattern data
219 current_pattern = self.func_editor.current_pattern
221 # CRITICAL FIX: Use fresh imports to avoid unpicklable registry wrappers
222 if callable(current_pattern) and hasattr(current_pattern, '__module__'):
223 try:
224 import importlib
225 module = importlib.import_module(current_pattern.__module__)
226 current_pattern = getattr(module, current_pattern.__name__)
227 except Exception:
228 pass # Use original if refresh fails
230 self.editing_step.func = current_pattern
231 self.detect_changes()
232 logger.debug(f"Function pattern changed: {current_pattern}")
236 def create_button_panel(self) -> QWidget:
237 """
238 Create the button panel with save/cancel actions.
240 Returns:
241 Widget containing action buttons
242 """
243 panel = QFrame()
244 panel.setFrameStyle(QFrame.Shape.NoFrame)
245 panel.setStyleSheet(f"""
246 QFrame {{
247 background-color: {self.color_scheme.to_hex(self.color_scheme.window_bg)};
248 border: none;
249 border-radius: 3px;
250 padding: 5px;
251 }}
252 """)
254 layout = QHBoxLayout(panel)
255 layout.setContentsMargins(5, 5, 5, 5)
256 layout.setSpacing(5)
258 # Changes indicator
259 self.changes_label = QLabel("")
260 self.changes_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.status_warning)}; font-style: italic;")
261 layout.addWidget(self.changes_label)
263 layout.addStretch()
265 # Cancel button
266 cancel_button = QPushButton("Cancel")
267 cancel_button.setMinimumWidth(80)
268 cancel_button.clicked.connect(self.cancel_edit)
269 cancel_button.setStyleSheet(f"""
270 QPushButton {{
271 background-color: {self.color_scheme.to_hex(self.color_scheme.status_error)};
272 color: white;
273 border: none;
274 border-radius: 3px;
275 padding: 8px;
276 }}
277 QPushButton:hover {{
278 background-color: {self.color_scheme.to_hex(self.color_scheme.status_error)};
279 }}
280 """)
281 layout.addWidget(cancel_button)
283 # Save button
284 self.save_button = QPushButton("Save")
285 self.save_button.setMinimumWidth(80)
286 self.save_button.setEnabled(False) # Initially disabled
287 self.save_button.clicked.connect(self.save_edit)
288 self.save_button.setStyleSheet(f"""
289 QPushButton {{
290 background-color: {self.color_scheme.to_hex(self.color_scheme.selection_bg)};
291 color: white;
292 border: none;
293 border-radius: 3px;
294 padding: 8px;
295 }}
296 QPushButton:hover {{
297 background-color: {self.color_scheme.to_hex(self.color_scheme.selection_bg)};
298 }}
299 QPushButton:disabled {{
300 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
301 color: {self.color_scheme.to_hex(self.color_scheme.border_light)};
302 border: none;
303 }}
304 """)
305 layout.addWidget(self.save_button)
307 return panel
309 def setup_connections(self):
310 """Setup signal/slot connections."""
311 # Tab change tracking
312 self.tab_widget.currentChanged.connect(self.on_tab_changed)
314 # Change detection
315 self.changes_detected.connect(self.on_changes_detected)
316 func_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_accent)}; font-weight: bold; font-size: 14px;")
317 header_layout.addWidget(func_label)
319 header_layout.addStretch()
321 # Function management buttons (mirrors Textual TUI)
322 add_btn = QPushButton("Add")
323 add_btn.setMaximumWidth(60)
324 add_btn.setStyleSheet(self._get_button_style())
325 add_btn.clicked.connect(self.add_function)
326 header_layout.addWidget(add_btn)
328 load_btn = QPushButton("Load")
329 load_btn.setMaximumWidth(60)
330 load_btn.setStyleSheet(self._get_button_style())
331 load_btn.clicked.connect(self.load_function_pattern)
332 header_layout.addWidget(load_btn)
334 save_as_btn = QPushButton("Save As")
335 save_as_btn.setMaximumWidth(80)
336 save_as_btn.setStyleSheet(self._get_button_style())
337 save_as_btn.clicked.connect(self.save_function_pattern)
338 header_layout.addWidget(save_as_btn)
340 code_btn = QPushButton("Code")
341 code_btn.setMaximumWidth(60)
342 code_btn.setStyleSheet(self._get_button_style())
343 code_btn.clicked.connect(self.edit_function_code)
344 header_layout.addWidget(code_btn)
346 layout.addLayout(header_layout)
348 # Function list scroll area (mirrors Textual TUI)
349 self.function_scroll = QScrollArea()
350 self.function_scroll.setWidgetResizable(True)
351 self.function_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
352 self.function_scroll.setStyleSheet(f"""
353 QScrollArea {{
354 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
355 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
356 border-radius: 4px;
357 }}
358 """)
360 # Function list container
361 self.function_container = QWidget()
362 self.function_layout = QVBoxLayout(self.function_container)
363 self.function_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
364 self.function_layout.setSpacing(8)
366 # Initialize function list from step
367 self.function_panes = []
368 self._populate_function_list()
370 self.function_scroll.setWidget(self.function_container)
371 layout.addWidget(self.function_scroll)
373 return frame
375 def _get_button_style(self) -> str:
376 """Get consistent button styling."""
377 return """
378 QPushButton {
379 background-color: {self.color_scheme.to_hex(self.color_scheme.input_bg)};
380 color: white;
381 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_light)};
382 border-radius: 3px;
383 padding: 6px 12px;
384 font-size: 11px;
385 }
386 QPushButton:hover {
387 background-color: {self.color_scheme.to_hex(self.color_scheme.button_hover_bg)};
388 }
389 QPushButton:pressed {
390 background-color: {self.color_scheme.to_hex(self.color_scheme.button_pressed_bg)};
391 }
392 """
394 def _populate_function_list(self):
395 """Populate function list from current step (mirrors Textual TUI)."""
396 # Clear existing panes
397 for pane in self.function_panes:
398 pane.setParent(None)
399 self.function_panes.clear()
401 # Convert step func to function list
402 functions = self._convert_step_func_to_list()
404 if not functions:
405 # Show empty state
406 empty_label = QLabel("No functions defined. Click 'Add' to begin.")
407 empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
408 empty_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_disabled)}; font-style: italic; padding: 20px;")
409 self.function_layout.addWidget(empty_label)
410 else:
411 # Create function panes
412 for i, func_item in enumerate(functions):
413 pane = self._create_function_pane(func_item, i)
414 self.function_panes.append(pane)
415 self.function_layout.addWidget(pane)
417 def _convert_step_func_to_list(self):
418 """Convert step func to initial pattern format for function list editor."""
419 if not hasattr(self.editing_step, 'func') or not self.editing_step.func:
420 return []
422 # Return the step func directly - the function list editor will handle the conversion
423 return self.editing_step.func
427 def _find_main_window(self):
428 """Find the main window through the parent chain."""
429 try:
430 # Navigate up the parent chain to find OpenHCSMainWindow
431 current = self.parent()
432 while current:
433 # Check if this is the main window (has floating_windows attribute)
434 if hasattr(current, 'floating_windows') and hasattr(current, 'service_adapter'):
435 logger.debug(f"Found main window: {type(current).__name__}")
436 return current
437 current = current.parent()
439 logger.warning("Could not find main window in parent chain")
440 return None
442 except Exception as e:
443 logger.error(f"Error finding main window: {e}")
444 return None
446 def _get_current_plate_from_pipeline_editor(self):
447 """Get current plate from pipeline editor (mirrors Textual TUI pattern)."""
448 try:
449 # Navigate up to find pipeline editor widget
450 current = self.parent()
451 while current:
452 # Check if this is a pipeline editor widget
453 if hasattr(current, 'current_plate') and hasattr(current, 'pipeline_steps'):
454 current_plate = getattr(current, 'current_plate', None)
455 if current_plate:
456 logger.debug(f"Found current plate from pipeline editor: {current_plate}")
457 return current_plate
459 # Check children for pipeline editor widget
460 for child in current.findChildren(QWidget):
461 if hasattr(child, 'current_plate') and hasattr(child, 'pipeline_steps'):
462 current_plate = getattr(child, 'current_plate', None)
463 if current_plate:
464 logger.debug(f"Found current plate from pipeline editor child: {current_plate}")
465 return current_plate
467 current = current.parent()
469 logger.warning("Could not find current plate from pipeline editor")
470 return None
472 except Exception as e:
473 logger.error(f"Error getting current plate from pipeline editor: {e}")
474 return None
476 # Old function pane methods removed - now using dedicated FunctionListEditorWidget
478 def create_button_panel(self) -> QWidget:
479 """
480 Create the button panel with save/cancel actions.
482 Returns:
483 Widget containing action buttons
484 """
485 panel = QFrame()
486 panel.setFrameStyle(QFrame.Shape.Box)
487 panel.setStyleSheet(f"""
488 QFrame {{
489 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
490 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
491 border-radius: 3px;
492 padding: 10px;
493 }}
494 """)
496 layout = QHBoxLayout(panel)
498 # Changes indicator
499 self.changes_label = QLabel("")
500 self.changes_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.status_warning)}; font-style: italic;")
501 layout.addWidget(self.changes_label)
503 layout.addStretch()
505 # Cancel button
506 cancel_button = QPushButton("Cancel")
507 cancel_button.setMinimumWidth(80)
508 cancel_button.clicked.connect(self.cancel_edit)
509 cancel_button.setStyleSheet(f"""
510 QPushButton {{
511 background-color: {self.color_scheme.to_hex(self.color_scheme.status_error)};
512 color: white;
513 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.status_error)};
514 border-radius: 3px;
515 padding: 8px;
516 }}
517 QPushButton:hover {{
518 background-color: {self.color_scheme.to_hex(self.color_scheme.status_error)};
519 }}
520 """)
521 layout.addWidget(cancel_button)
523 # Save button
524 self.save_button = QPushButton("Save")
525 self.save_button.setMinimumWidth(80)
526 self.save_button.clicked.connect(self.save_step)
527 self.save_button.setStyleSheet(f"""
528 QPushButton {{
529 background-color: {self.color_scheme.to_hex(self.color_scheme.selection_bg)};
530 color: white;
531 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.selection_bg)};
532 border-radius: 3px;
533 padding: 8px;
534 }}
535 QPushButton:hover {{
536 background-color: {self.color_scheme.to_hex(self.color_scheme.selection_bg)};
537 }}
538 """)
539 layout.addWidget(self.save_button)
541 return panel
543 def setup_connections(self):
544 """Setup signal/slot connections."""
545 self.tab_widget.currentChanged.connect(self.on_tab_changed)
546 self.changes_detected.connect(self.on_changes_detected)
548 def get_function_info(self) -> str:
549 """
550 Get function information for display.
552 Returns:
553 Function information string
554 """
555 if not self.editing_step or not hasattr(self.editing_step, 'func'):
556 return "No function assigned"
558 func = self.editing_step.func
559 func_name = getattr(func, '__name__', 'Unknown Function')
560 func_module = getattr(func, '__module__', 'Unknown Module')
562 info = f"Function: {func_name}\n"
563 info += f"Module: {func_module}\n"
565 # Add parameter info if available
566 if hasattr(self.editing_step, 'parameters'):
567 params = self.editing_step.parameters
568 if params:
569 info += f"\nParameters ({len(params)}):\n"
570 for param_name, param_value in params.items():
571 info += f" {param_name}: {param_value}\n"
573 return info
575 def on_form_parameter_changed(self, param_name: str, value):
576 """Handle form parameter changes directly from form manager."""
577 # CRITICAL FIX: For function parameters, use fresh imports to avoid unpicklable registry wrappers
578 if param_name == 'func' and callable(value) and hasattr(value, '__module__'):
579 try:
580 import importlib
581 module = importlib.import_module(value.__module__)
582 value = getattr(module, value.__name__)
583 except Exception:
584 pass # Use original if refresh fails
586 setattr(self.editing_step, param_name, value)
588 if param_name in ('group_by', 'variable_components'):
589 self.func_editor.current_group_by = self.editing_step.group_by
590 self.func_editor.current_variable_components = self.editing_step.variable_components or []
591 self.func_editor._refresh_component_button()
593 self.detect_changes()
595 def on_tab_changed(self, index: int):
596 """Handle tab changes."""
597 tab_names = ["step", "function"]
598 if 0 <= index < len(tab_names):
599 self.current_tab = tab_names[index]
600 logger.debug(f"Tab changed to: {self.current_tab}")
602 def detect_changes(self):
603 """Detect if changes have been made."""
604 has_changes = self.original_step != self.editing_step
606 # Check function pattern
607 if not has_changes:
608 original_func = getattr(self.original_step, 'func', None)
609 current_func = getattr(self.editing_step, 'func', None)
610 # Simple comparison - could be enhanced for deep comparison
611 has_changes = str(original_func) != str(current_func)
613 if has_changes != self.has_changes:
614 self.has_changes = has_changes
615 self.changes_detected.emit(has_changes)
617 def on_changes_detected(self, has_changes: bool):
618 """Handle changes detection."""
619 if has_changes:
620 self.changes_label.setText("● Unsaved changes")
621 self.save_button.setEnabled(True)
622 else:
623 self.changes_label.setText("")
624 self.save_button.setEnabled(False)
626 def save_step(self):
627 """Save the edited step."""
628 try:
629 # CRITICAL FIX: Collect current values from all form managers before saving
630 # This ensures nested dataclass field values are properly saved to the step object
631 for tab_index in range(self.tab_widget.count()):
632 tab_widget = self.tab_widget.widget(tab_index)
633 if hasattr(tab_widget, 'form_manager'):
634 # Get current values from this tab's form manager
635 current_values = tab_widget.form_manager.get_current_values()
637 # Apply values to the editing step
638 for param_name, value in current_values.items():
639 if hasattr(self.editing_step, param_name):
640 setattr(self.editing_step, param_name, value)
641 logger.debug(f"Applied {param_name}={value} to editing step")
643 # Validate step
644 step_name = getattr(self.editing_step, 'name', None)
645 if not step_name or not step_name.strip():
646 from PyQt6.QtWidgets import QMessageBox
647 QMessageBox.warning(self, "Validation Error", "Step name cannot be empty.")
648 return
650 # CRITICAL FIX: For existing steps, apply changes to original step object
651 # This ensures the pipeline gets the updated step with the same object identity
652 if self.original_step_reference is not None:
653 # Copy all attributes from editing_step to original_step_reference
654 self._apply_changes_to_original()
655 step_to_save = self.original_step_reference
656 else:
657 # For new steps, use the editing_step directly
658 step_to_save = self.editing_step
660 # Emit signals and call callback
661 self.step_saved.emit(step_to_save)
663 if self.on_save_callback:
664 self.on_save_callback(step_to_save)
666 self.accept()
667 logger.debug(f"Step saved: {getattr(step_to_save, 'name', 'Unknown')}")
669 except Exception as e:
670 logger.error(f"Failed to save step: {e}")
671 from PyQt6.QtWidgets import QMessageBox
672 QMessageBox.critical(self, "Save Error", f"Failed to save step:\n{e}")
674 def _apply_changes_to_original(self):
675 """Apply all changes from editing_step to original_step_reference."""
676 if self.original_step_reference is None:
677 return
679 # Copy all attributes from editing_step to original_step_reference
680 from dataclasses import fields, is_dataclass
682 if is_dataclass(self.editing_step):
683 # For dataclass steps, copy all field values
684 for field in fields(self.editing_step):
685 value = getattr(self.editing_step, field.name)
686 setattr(self.original_step_reference, field.name, value)
687 else:
688 # CRITICAL FIX: Use reflection to copy ALL attributes, not just hardcoded list
689 # This ensures optional dataclass attributes like step_materialization_config are copied
690 for attr_name in dir(self.editing_step):
691 # Skip private/magic attributes and methods
692 if not attr_name.startswith('_') and not callable(getattr(self.editing_step, attr_name, None)):
693 if hasattr(self.editing_step, attr_name) and hasattr(self.original_step_reference, attr_name):
694 value = getattr(self.editing_step, attr_name)
695 setattr(self.original_step_reference, attr_name, value)
696 logger.debug(f"Copied attribute {attr_name}: {value}")
698 logger.debug("Applied changes to original step object")
700 def _clone_step(self, step):
701 """Clone a step object using deep copy."""
702 import copy
703 return copy.deepcopy(step)
705 def _create_new_step(self):
706 """Create a new empty step."""
707 from openhcs.core.steps.function_step import FunctionStep
708 return FunctionStep(
709 func=[], # Start with empty function list
710 name="New_Step"
711 )
713 def cancel_edit(self):
714 """Cancel editing and close dialog."""
715 if self.has_changes:
716 from PyQt6.QtWidgets import QMessageBox
717 reply = QMessageBox.question(
718 self,
719 "Unsaved Changes",
720 "You have unsaved changes. Are you sure you want to cancel?",
721 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
722 QMessageBox.StandardButton.No
723 )
725 if reply != QMessageBox.StandardButton.Yes:
726 return
728 self.step_cancelled.emit()
729 self.reject()
730 logger.debug("Step editing cancelled")
734 def closeEvent(self, event):
735 """Handle dialog close event."""
736 if self.has_changes:
737 from PyQt6.QtWidgets import QMessageBox
738 reply = QMessageBox.question(
739 self,
740 "Unsaved Changes",
741 "You have unsaved changes. Are you sure you want to close?",
742 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
743 QMessageBox.StandardButton.No
744 )
746 if reply != QMessageBox.StandardButton.Yes:
747 event.ignore()
748 return
750 event.accept()