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

1""" 

2Dual Editor Window for PyQt6 

3 

4Step and function editing dialog with tabbed interface. 

5Uses hybrid approach: extracted business logic + clean PyQt6 UI. 

6""" 

7 

8import logging 

9from typing import Optional, Callable, Any, Dict 

10 

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 

17 

18from openhcs.core.steps.function_step import FunctionStep 

19from openhcs.textual_tui.services.pattern_data_manager import PatternDataManager 

20 

21from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme 

22logger = logging.getLogger(__name__) 

23 

24 

25class DualEditorWindow(QDialog): 

26 """ 

27 PyQt6 Multi-Tab Parameter Editor Window. 

28 

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 """ 

33 

34 # Signals 

35 step_saved = pyqtSignal(object) # FunctionStep 

36 step_cancelled = pyqtSignal() 

37 changes_detected = pyqtSignal(bool) # has_changes 

38 

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. 

44 

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) 

55 

56 # Make window non-modal (like plate manager and pipeline editor) 

57 self.setModal(False) 

58 

59 # Initialize color scheme 

60 self.color_scheme = color_scheme or PyQt6ColorScheme() 

61 self.gui_config = gui_config 

62 

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 

67 

68 # Pattern management (extracted from Textual version) 

69 self.pattern_manager = PatternDataManager() 

70 

71 # Store original step reference (never modified) 

72 self.original_step_reference = step_data 

73 

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 

81 

82 # Change tracking 

83 self.has_changes = False 

84 self.current_tab = "step" 

85 

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 

90 

91 # Setup UI 

92 self.setup_ui() 

93 self.setup_connections() 

94 

95 logger.debug(f"Dual editor window initialized (new={is_new})") 

96 

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 

102 

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) 

110 

111 layout = QVBoxLayout(self) 

112 layout.setSpacing(5) 

113 layout.setContentsMargins(5, 5, 5, 5) 

114 

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) 

120 

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 """) 

144 

145 # Create tabs 

146 self.create_step_tab() 

147 self.create_function_tab() 

148 

149 layout.addWidget(self.tab_widget) 

150 

151 # Button panel 

152 button_panel = self.create_button_panel() 

153 layout.addWidget(button_panel) 

154 

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 """) 

162 

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 

167 

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 ) 

178 

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) 

181 

182 self.tab_widget.addTab(self.step_editor, "Step Settings") 

183 

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 

187 

188 # Convert step func to function list format 

189 initial_functions = self._convert_step_func_to_list() 

190 

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 ) 

198 

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 

203 

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 [] 

207 

208 # Refresh component button to show correct text and state (mirrors Textual TUI reactive updates) 

209 self.func_editor._refresh_component_button() 

210 

211 # Connect function pattern changes 

212 self.func_editor.function_pattern_changed.connect(self._on_function_pattern_changed) 

213 

214 self.tab_widget.addTab(self.func_editor, "Function Pattern") 

215 

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 

220 

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 

229 

230 self.editing_step.func = current_pattern 

231 self.detect_changes() 

232 logger.debug(f"Function pattern changed: {current_pattern}") 

233 

234 

235 

236 def create_button_panel(self) -> QWidget: 

237 """ 

238 Create the button panel with save/cancel actions. 

239 

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 """) 

253 

254 layout = QHBoxLayout(panel) 

255 layout.setContentsMargins(5, 5, 5, 5) 

256 layout.setSpacing(5) 

257 

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) 

262 

263 layout.addStretch() 

264 

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) 

282 

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) 

306 

307 return panel 

308 

309 def setup_connections(self): 

310 """Setup signal/slot connections.""" 

311 # Tab change tracking 

312 self.tab_widget.currentChanged.connect(self.on_tab_changed) 

313 

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) 

318 

319 header_layout.addStretch() 

320 

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) 

327 

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) 

333 

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) 

339 

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) 

345 

346 layout.addLayout(header_layout) 

347 

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 """) 

359 

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) 

365 

366 # Initialize function list from step 

367 self.function_panes = [] 

368 self._populate_function_list() 

369 

370 self.function_scroll.setWidget(self.function_container) 

371 layout.addWidget(self.function_scroll) 

372 

373 return frame 

374 

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 """ 

393 

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() 

400 

401 # Convert step func to function list 

402 functions = self._convert_step_func_to_list() 

403 

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) 

416 

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 [] 

421 

422 # Return the step func directly - the function list editor will handle the conversion 

423 return self.editing_step.func 

424 

425 

426 

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() 

438 

439 logger.warning("Could not find main window in parent chain") 

440 return None 

441 

442 except Exception as e: 

443 logger.error(f"Error finding main window: {e}") 

444 return None 

445 

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 

458 

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 

466 

467 current = current.parent() 

468 

469 logger.warning("Could not find current plate from pipeline editor") 

470 return None 

471 

472 except Exception as e: 

473 logger.error(f"Error getting current plate from pipeline editor: {e}") 

474 return None 

475 

476 # Old function pane methods removed - now using dedicated FunctionListEditorWidget 

477 

478 def create_button_panel(self) -> QWidget: 

479 """ 

480 Create the button panel with save/cancel actions. 

481  

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 """) 

495 

496 layout = QHBoxLayout(panel) 

497 

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) 

502 

503 layout.addStretch() 

504 

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) 

522 

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) 

540 

541 return panel 

542 

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) 

547 

548 def get_function_info(self) -> str: 

549 """ 

550 Get function information for display. 

551  

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" 

557 

558 func = self.editing_step.func 

559 func_name = getattr(func, '__name__', 'Unknown Function') 

560 func_module = getattr(func, '__module__', 'Unknown Module') 

561 

562 info = f"Function: {func_name}\n" 

563 info += f"Module: {func_module}\n" 

564 

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" 

572 

573 return info 

574 

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 

585 

586 setattr(self.editing_step, param_name, value) 

587 

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() 

592 

593 self.detect_changes() 

594 

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}") 

601 

602 def detect_changes(self): 

603 """Detect if changes have been made.""" 

604 has_changes = self.original_step != self.editing_step 

605 

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) 

612 

613 if has_changes != self.has_changes: 

614 self.has_changes = has_changes 

615 self.changes_detected.emit(has_changes) 

616 

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) 

625 

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() 

636 

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") 

642 

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 

649 

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 

659 

660 # Emit signals and call callback 

661 self.step_saved.emit(step_to_save) 

662 

663 if self.on_save_callback: 

664 self.on_save_callback(step_to_save) 

665 

666 self.accept() 

667 logger.debug(f"Step saved: {getattr(step_to_save, 'name', 'Unknown')}") 

668 

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}") 

673 

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 

678 

679 # Copy all attributes from editing_step to original_step_reference 

680 from dataclasses import fields, is_dataclass 

681 

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}") 

697 

698 logger.debug("Applied changes to original step object") 

699 

700 def _clone_step(self, step): 

701 """Clone a step object using deep copy.""" 

702 import copy 

703 return copy.deepcopy(step) 

704 

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 ) 

712 

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 ) 

724 

725 if reply != QMessageBox.StandardButton.Yes: 

726 return 

727 

728 self.step_cancelled.emit() 

729 self.reject() 

730 logger.debug("Step editing cancelled") 

731 

732 

733 

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 ) 

745 

746 if reply != QMessageBox.StandardButton.Yes: 

747 event.ignore() 

748 return 

749 

750 event.accept()