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

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, Dict 

10 

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 

17 

18from openhcs.core.steps.function_step import FunctionStep 

19from openhcs.ui.shared.pattern_data_manager import PatternDataManager 

20 

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

25 

26 

27class DualEditorWindow(BaseFormDialog): 

28 """ 

29 PyQt6 Multi-Tab Parameter Editor Window. 

30 

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. 

34 

35 Inherits from BaseFormDialog to automatically handle unregistration from 

36 cross-window placeholder updates when the dialog closes. 

37 """ 

38 

39 # Signals 

40 step_saved = pyqtSignal(object) # FunctionStep 

41 step_cancelled = pyqtSignal() 

42 changes_detected = pyqtSignal(bool) # has_changes 

43 

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. 

49 

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) 

60 

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

62 self.setModal(False) 

63 

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 

68 

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 

73 

74 # Pattern management (extracted from Textual version) 

75 self.pattern_manager = PatternDataManager() 

76 

77 # Store original step reference (never modified) 

78 self.original_step_reference = step_data 

79 

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 

87 

88 # Change tracking 

89 self.has_changes = False 

90 self.current_tab = "step" 

91 

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 

96 

97 # Setup UI 

98 self.setup_ui() 

99 self.setup_connections() 

100 

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

102 

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 

108 

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) 

116 

117 layout = QVBoxLayout(self) 

118 layout.setSpacing(5) 

119 layout.setContentsMargins(5, 5, 5, 5) 

120 

121 # Single row: tabs + title + status + buttons 

122 tab_row = QHBoxLayout() 

123 tab_row.setContentsMargins(5, 5, 5, 5) 

124 tab_row.setSpacing(10) 

125 

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 

134 

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 

142 

143 tab_row.addStretch() 

144 

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) 

149 

150 # Get centralized button styles 

151 button_styles = self.style_generator.generate_config_button_styles() 

152 

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) 

160 

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) 

175 

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

195 

196 # Create tabs (this adds content to the tab widget) 

197 self.create_step_tab() 

198 self.create_function_tab() 

199 

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) 

206 

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) 

211 

212 layout.addWidget(content_container) 

213 

214 # Apply centralized styling 

215 self.setStyleSheet(self.style_generator.generate_config_window_style()) 

216 

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 

221 

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 ) 

235 

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) 

238 

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

240 

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 

244 

245 # Convert step func to function list format 

246 initial_functions = self._convert_step_func_to_list() 

247 

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 ) 

255 

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 

260 

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

264 

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

266 self.func_editor._refresh_component_button() 

267 

268 # Connect function pattern changes 

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

270 

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

272 

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 

277 

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 

286 

287 self.editing_step.func = current_pattern 

288 self.detect_changes() 

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

290 

291 

292 

293 

294 

295 def setup_connections(self): 

296 """Setup signal/slot connections.""" 

297 # Tab change tracking 

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

299 

300 # Change detection 

301 self.changes_detected.connect(self.on_changes_detected) 

302 

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

307 

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 

312 

313 

314 

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

326 

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

328 return None 

329 

330 except Exception as e: 

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

332 return None 

333 

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 

346 

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 

354 

355 current = current.parent() 

356 

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

358 return None 

359 

360 except Exception as e: 

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

362 return None 

363 

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

365 

366 def get_function_info(self) -> str: 

367 """ 

368 Get function information for display. 

369  

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" 

375 

376 func = self.editing_step.func 

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

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

379 

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

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

382 

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" 

390 

391 return info 

392 

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 

403 

404 setattr(self.editing_step, param_name, value) 

405 

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

410 

411 self.detect_changes() 

412 

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

419 

420 def detect_changes(self): 

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

422 has_changes = self.original_step != self.editing_step 

423 

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) 

430 

431 if has_changes != self.has_changes: 

432 self.has_changes = has_changes 

433 self.changes_detected.emit(has_changes) 

434 

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) 

443 

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 

451 

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 

460 

461 self.editing_step.func = current_pattern 

462 logger.debug(f"Synced function pattern before save: {current_pattern}") 

463 

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

471 

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

477 

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 

484 

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 

494 

495 # Emit signals and call callback 

496 self.step_saved.emit(step_to_save) 

497 

498 if self.on_save_callback: 

499 self.on_save_callback(step_to_save) 

500 

501 self.accept() # BaseFormDialog handles unregistration 

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

503 

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

508 

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 

513 

514 # Copy all attributes from editing_step to original_step_reference 

515 from dataclasses import fields, is_dataclass 

516 

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

532 

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

534 

535 def _clone_step(self, step): 

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

537 import copy 

538 return copy.deepcopy(step) 

539 

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 ) 

547 

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 ) 

559 

560 if reply != QMessageBox.StandardButton.Yes: 

561 return 

562 

563 self.step_cancelled.emit() 

564 self.reject() # BaseFormDialog handles unregistration 

565 logger.debug("Step editing cancelled") 

566 

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 ) 

578 

579 if reply != QMessageBox.StandardButton.Yes: 

580 event.ignore() 

581 return 

582 

583 super().closeEvent(event) # BaseFormDialog handles unregistration 

584 

585 # No need to override _get_form_managers() - BaseFormDialog automatically 

586 # discovers all ParameterFormManager instances recursively in the widget tree