Coverage for openhcs/pyqt_gui/windows/config_window.py: 0.0%

392 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-04 02:09 +0000

1""" 

2Configuration Window for PyQt6 

3 

4Configuration editing dialog with full feature parity to Textual TUI version. 

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

6""" 

7 

8import logging 

9import dataclasses 

10from typing import Type, Any, Callable, Optional 

11 

12from PyQt6.QtWidgets import ( 

13 QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, 

14 QScrollArea, QWidget, QSplitter, QTreeWidget, QTreeWidgetItem, 

15 QLineEdit, QSpinBox, QDoubleSpinBox, QCheckBox, QComboBox 

16) 

17from PyQt6.QtCore import Qt, pyqtSignal 

18from PyQt6.QtGui import QFont, QColor 

19 

20# Infrastructure classes removed - functionality migrated to ParameterFormManager service layer 

21from openhcs.pyqt_gui.widgets.shared.parameter_form_manager import ParameterFormManager 

22from openhcs.pyqt_gui.shared.style_generator import StyleSheetGenerator 

23from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme 

24from openhcs.pyqt_gui.windows.base_form_dialog import BaseFormDialog 

25from openhcs.core.config import GlobalPipelineConfig 

26# ❌ REMOVED: require_config_context decorator - enhanced decorator events system handles context automatically 

27from openhcs.core.lazy_placeholder_simplified import LazyDefaultPlaceholderService 

28 

29 

30 

31logger = logging.getLogger(__name__) 

32 

33 

34# Infrastructure classes removed - functionality migrated to ParameterFormManager service layer 

35 

36 

37class ConfigWindow(BaseFormDialog): 

38 """ 

39 PyQt6 Configuration Window. 

40 

41 Configuration editing dialog with parameter forms and validation. 

42 Preserves all business logic from Textual version with clean PyQt6 UI. 

43 

44 Inherits from BaseFormDialog to automatically handle unregistration from 

45 cross-window placeholder updates when the dialog closes. 

46 """ 

47 

48 # Signals 

49 config_saved = pyqtSignal(object) # saved config 

50 config_cancelled = pyqtSignal() 

51 

52 def __init__(self, config_class: Type, current_config: Any, 

53 on_save_callback: Optional[Callable] = None, 

54 color_scheme: Optional[PyQt6ColorScheme] = None, parent=None, 

55 scope_id: Optional[str] = None): 

56 """ 

57 Initialize the configuration window. 

58 

59 Args: 

60 config_class: Configuration class type 

61 current_config: Current configuration instance 

62 on_save_callback: Function to call when config is saved 

63 color_scheme: Color scheme for styling (optional, uses default if None) 

64 parent: Parent widget 

65 scope_id: Optional scope identifier (e.g., plate_path) to limit cross-window updates to same orchestrator 

66 """ 

67 super().__init__(parent) 

68 

69 # Business logic state (extracted from Textual version) 

70 self.config_class = config_class 

71 self.current_config = current_config 

72 self.on_save_callback = on_save_callback 

73 self.scope_id = scope_id # Store scope_id for passing to form_manager 

74 

75 # Flag to prevent refresh during save operation 

76 self._saving = False 

77 

78 # Initialize color scheme and style generator 

79 self.color_scheme = color_scheme or PyQt6ColorScheme() 

80 self.style_generator = StyleSheetGenerator(self.color_scheme) 

81 

82 # SIMPLIFIED: Use dual-axis resolution 

83 from openhcs.core.lazy_placeholder import LazyDefaultPlaceholderService 

84 

85 # Determine placeholder prefix based on actual instance type (not class type) 

86 is_lazy_dataclass = LazyDefaultPlaceholderService.has_lazy_resolution(type(current_config)) 

87 placeholder_prefix = "Pipeline default" if is_lazy_dataclass else "Default" 

88 

89 # SIMPLIFIED: Use ParameterFormManager with dual-axis resolution 

90 root_field_id = type(current_config).__name__ # e.g., "GlobalPipelineConfig" or "PipelineConfig" 

91 global_config_type = GlobalPipelineConfig # Always use GlobalPipelineConfig for dual-axis resolution 

92 

93 # CRITICAL FIX: Pipeline Config Editor should NOT use itself as parent context 

94 # context_obj=None means inherit from thread-local GlobalPipelineConfig only 

95 # The overlay (current form state) will be built by ParameterFormManager 

96 # This fixes the circular context bug where reset showed old values instead of global defaults 

97 

98 # CRITICAL: Config window manages its own scroll area, so tell form_manager NOT to create one 

99 # This prevents double scroll areas which cause navigation bugs 

100 self.form_manager = ParameterFormManager.from_dataclass_instance( 

101 dataclass_instance=current_config, 

102 field_id=root_field_id, 

103 placeholder_prefix=placeholder_prefix, 

104 color_scheme=self.color_scheme, 

105 use_scroll_area=False, # Config window handles scrolling 

106 global_config_type=global_config_type, 

107 context_obj=None, # Inherit from thread-local GlobalPipelineConfig only 

108 scope_id=self.scope_id # Pass scope_id to limit cross-window updates to same orchestrator 

109 ) 

110 

111 # No config_editor needed - everything goes through form_manager 

112 self.config_editor = None 

113 

114 # Setup UI 

115 self.setup_ui() 

116 

117 logger.debug(f"Config window initialized for {config_class.__name__}") 

118 

119 def setup_ui(self): 

120 """Setup the user interface.""" 

121 self.setWindowTitle(f"Configuration - {self.config_class.__name__}") 

122 self.setModal(False) # Non-modal like plate manager and pipeline editor 

123 self.setMinimumSize(600, 400) 

124 self.resize(800, 600) 

125 

126 layout = QVBoxLayout(self) 

127 layout.setSpacing(10) 

128 

129 # Header with title, help button, and action buttons 

130 header_widget = QWidget() 

131 header_layout = QHBoxLayout(header_widget) 

132 header_layout.setContentsMargins(10, 10, 10, 10) 

133 

134 header_label = QLabel(f"Configure {self.config_class.__name__}") 

135 header_label.setFont(QFont("Arial", 14, QFont.Weight.Bold)) 

136 header_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_accent)};") 

137 header_layout.addWidget(header_label) 

138 

139 # Add help button for the dataclass itself 

140 if dataclasses.is_dataclass(self.config_class): 

141 from openhcs.pyqt_gui.widgets.shared.clickable_help_components import HelpButton 

142 help_btn = HelpButton(help_target=self.config_class, text="Help", color_scheme=self.color_scheme) 

143 help_btn.setMaximumWidth(80) 

144 header_layout.addWidget(help_btn) 

145 

146 header_layout.addStretch() 

147 

148 # Add action buttons to header (top right) 

149 button_styles = self.style_generator.generate_config_button_styles() 

150 

151 # View Code button 

152 view_code_button = QPushButton("View Code") 

153 view_code_button.setFixedHeight(28) 

154 view_code_button.setMinimumWidth(80) 

155 view_code_button.clicked.connect(self._view_code) 

156 view_code_button.setStyleSheet(button_styles["reset"]) 

157 header_layout.addWidget(view_code_button) 

158 

159 # Reset button 

160 reset_button = QPushButton("Reset to Defaults") 

161 reset_button.setFixedHeight(28) 

162 reset_button.setMinimumWidth(100) 

163 reset_button.clicked.connect(self.reset_to_defaults) 

164 reset_button.setStyleSheet(button_styles["reset"]) 

165 header_layout.addWidget(reset_button) 

166 

167 # Cancel button 

168 cancel_button = QPushButton("Cancel") 

169 cancel_button.setFixedHeight(28) 

170 cancel_button.setMinimumWidth(70) 

171 cancel_button.clicked.connect(self.reject) 

172 cancel_button.setStyleSheet(button_styles["cancel"]) 

173 header_layout.addWidget(cancel_button) 

174 

175 # Save button 

176 save_button = QPushButton("Save") 

177 save_button.setFixedHeight(28) 

178 save_button.setMinimumWidth(70) 

179 save_button.clicked.connect(self.save_config) 

180 save_button.setStyleSheet(button_styles["save"]) 

181 header_layout.addWidget(save_button) 

182 

183 layout.addWidget(header_widget) 

184 

185 # Create splitter with tree view on left and form on right 

186 splitter = QSplitter(Qt.Orientation.Horizontal) 

187 

188 # Left panel - Inheritance hierarchy tree 

189 self.tree_widget = self._create_inheritance_tree() 

190 splitter.addWidget(self.tree_widget) 

191 

192 # Right panel - Parameter form with scroll area 

193 # Always use scroll area for consistent navigation behavior 

194 self.scroll_area = QScrollArea() 

195 self.scroll_area.setWidgetResizable(True) 

196 self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) 

197 self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) 

198 self.scroll_area.setWidget(self.form_manager) 

199 splitter.addWidget(self.scroll_area) 

200 

201 # Set splitter proportions (30% tree, 70% form) 

202 splitter.setSizes([300, 700]) 

203 

204 # Add splitter with stretch factor so it expands to fill available space 

205 layout.addWidget(splitter, 1) # stretch factor = 1 

206 

207 # Apply centralized styling (config window style includes tree styling now) 

208 self.setStyleSheet( 

209 self.style_generator.generate_config_window_style() + "\n" + 

210 self.style_generator.generate_tree_widget_style() 

211 ) 

212 

213 def _create_inheritance_tree(self) -> QTreeWidget: 

214 """Create tree widget showing inheritance hierarchy for navigation.""" 

215 tree = QTreeWidget() 

216 tree.setHeaderLabel("Configuration Hierarchy") 

217 # Remove width restrictions to allow horizontal dragging 

218 tree.setMinimumWidth(200) 

219 

220 # Disable expand on double-click (use arrow only) 

221 tree.setExpandsOnDoubleClick(False) 

222 

223 # Build inheritance hierarchy 

224 self._populate_inheritance_tree(tree) 

225 

226 # Connect double-click to navigation 

227 tree.itemDoubleClicked.connect(self._on_tree_item_double_clicked) 

228 

229 return tree 

230 

231 def _populate_inheritance_tree(self, tree: QTreeWidget): 

232 """Populate the inheritance tree with only dataclasses visible in the UI. 

233 

234 Excludes the root node (PipelineConfig/GlobalPipelineConfig) and shows 

235 its attributes directly as top-level items. 

236 """ 

237 import dataclasses 

238 

239 # Skip creating root item - add children directly to tree as top-level items 

240 if dataclasses.is_dataclass(self.config_class): 

241 self._add_ui_visible_dataclasses_to_tree(tree, self.config_class, is_root=True) 

242 

243 # Leave tree collapsed by default (user can expand as needed) 

244 

245 def _get_base_type(self, dataclass_type): 

246 """Get base (non-lazy) type for a dataclass - type-based detection.""" 

247 from openhcs.core.lazy_placeholder_simplified import LazyDefaultPlaceholderService 

248 

249 # Type-based lazy detection 

250 if LazyDefaultPlaceholderService.has_lazy_resolution(dataclass_type): 

251 # Get first non-Lazy base class 

252 for base in dataclass_type.__bases__: 

253 if base.__name__ != 'object' and not LazyDefaultPlaceholderService.has_lazy_resolution(base): 

254 return base 

255 

256 return dataclass_type 

257 

258 def _add_ui_visible_dataclasses_to_tree(self, parent_item, dataclass_type, is_root=False): 

259 """Add only dataclasses that are visible in the UI form. 

260 

261 Args: 

262 parent_item: Either a QTreeWidgetItem or QTreeWidget (for root level) 

263 dataclass_type: The dataclass type to process 

264 is_root: True if adding direct children of the root config class 

265 """ 

266 import dataclasses 

267 

268 # Get all fields from this dataclass 

269 fields = dataclasses.fields(dataclass_type) 

270 

271 for field in fields: 

272 field_name = field.name 

273 field_type = field.type 

274 

275 # Only show dataclass fields (these appear as sections in the UI) 

276 if dataclasses.is_dataclass(field_type): 

277 # Get the base (non-lazy) type for display and inheritance 

278 base_type = self._get_base_type(field_type) 

279 display_name = base_type.__name__ 

280 

281 # Check if this field is hidden from UI 

282 is_ui_hidden = self._is_field_ui_hidden(dataclass_type, field_name, field_type) 

283 

284 # Skip root-level ui_hidden items entirely (don't show them in tree at all) 

285 # For nested items, show them but styled differently (for inheritance visibility) 

286 if is_root and is_ui_hidden: 

287 continue 

288 

289 # For root-level items, show only the class name (matching child style) 

290 # For nested items, show "field_name (ClassName)" 

291 if is_root: 

292 label = display_name 

293 else: 

294 label = f"{field_name} ({display_name})" 

295 

296 # Create a child item for this nested dataclass 

297 field_item = QTreeWidgetItem([label]) 

298 field_item.setData(0, Qt.ItemDataRole.UserRole, { 

299 'type': 'dataclass', 

300 'class': field_type, # Store original type for field lookup 

301 'field_name': field_name, 

302 'ui_hidden': is_ui_hidden # Store ui_hidden flag 

303 }) 

304 

305 # Style ui_hidden items differently (grayed out, italicized) 

306 if is_ui_hidden: 

307 font = field_item.font(0) 

308 font.setItalic(True) 

309 field_item.setFont(0, font) 

310 field_item.setForeground(0, QColor(128, 128, 128)) 

311 field_item.setToolTip(0, "This configuration is not editable in the UI (inherited by other configs)") 

312 

313 # Add to parent (either QTreeWidget for root or QTreeWidgetItem for nested) 

314 if is_root: 

315 parent_item.addTopLevelItem(field_item) 

316 else: 

317 parent_item.addChild(field_item) 

318 

319 # Show inheritance hierarchy using the BASE type (not lazy type) 

320 # This automatically skips the lazy→base transition 

321 self._add_inheritance_info(field_item, base_type) 

322 

323 # Recursively add nested dataclasses using BASE type (not root anymore) 

324 self._add_ui_visible_dataclasses_to_tree(field_item, base_type, is_root=False) 

325 

326 def _is_field_ui_hidden(self, dataclass_type, field_name: str, field_type) -> bool: 

327 """Check if a field should be hidden from the UI. 

328 

329 Args: 

330 dataclass_type: The parent dataclass containing the field 

331 field_name: Name of the field 

332 field_type: Type of the field 

333 

334 Returns: 

335 True if the field should be hidden from UI 

336 """ 

337 import dataclasses 

338 

339 # Check field metadata for ui_hidden flag 

340 try: 

341 field_obj = next(f for f in dataclasses.fields(dataclass_type) if f.name == field_name) 

342 if field_obj.metadata.get('ui_hidden', False): 

343 return True 

344 except (StopIteration, TypeError): 

345 pass 

346 

347 # Check if the field's type itself has _ui_hidden attribute 

348 # IMPORTANT: Check __dict__ directly to avoid inheriting _ui_hidden from parent classes 

349 # We only want to hide fields whose type DIRECTLY has _ui_hidden=True 

350 base_type = self._get_base_type(field_type) 

351 if hasattr(base_type, '__dict__') and '_ui_hidden' in base_type.__dict__ and base_type._ui_hidden: 

352 return True 

353 

354 return False 

355 

356 def _add_inheritance_info(self, parent_item: QTreeWidgetItem, dataclass_type): 

357 """Add inheritance information for a dataclass with proper hierarchy.""" 

358 # Get direct base classes (dataclass_type is already the base/non-lazy type) 

359 direct_bases = [] 

360 for cls in dataclass_type.__bases__: 

361 if cls.__name__ == 'object': 

362 continue 

363 if not hasattr(cls, '__dataclass_fields__'): 

364 continue 

365 

366 # Always use base type (no lazy wrappers at this point) 

367 base_type = self._get_base_type(cls) 

368 direct_bases.append(base_type) 

369 

370 # Add base classes directly as children (no "Inherits from:" label) 

371 for base_class in direct_bases: 

372 # Check if this base class is ui_hidden 

373 is_ui_hidden = hasattr(base_class, '__dict__') and '_ui_hidden' in base_class.__dict__ and base_class._ui_hidden 

374 

375 base_item = QTreeWidgetItem([base_class.__name__]) 

376 base_item.setData(0, Qt.ItemDataRole.UserRole, { 

377 'type': 'inheritance_link', 

378 'target_class': base_class, 

379 'ui_hidden': is_ui_hidden 

380 }) 

381 

382 # Style ui_hidden items differently (grayed out, italicized) 

383 if is_ui_hidden: 

384 font = base_item.font(0) 

385 font.setItalic(True) 

386 base_item.setFont(0, font) 

387 base_item.setForeground(0, QColor(128, 128, 128)) 

388 base_item.setToolTip(0, "This configuration is not editable in the UI (inherited by other configs)") 

389 

390 parent_item.addChild(base_item) 

391 

392 # Recursively add inheritance for this base class 

393 self._add_inheritance_info(base_item, base_class) 

394 

395 def _on_tree_item_double_clicked(self, item: QTreeWidgetItem, column: int): 

396 """Handle tree item double-clicks for navigation.""" 

397 data = item.data(0, Qt.ItemDataRole.UserRole) 

398 if not data: 

399 return 

400 

401 # Check if this item is ui_hidden - if so, ignore the double-click 

402 if data.get('ui_hidden', False): 

403 logger.debug("Ignoring double-click on ui_hidden item") 

404 return 

405 

406 item_type = data.get('type') 

407 

408 if item_type == 'dataclass': 

409 # Navigate to the dataclass section in the form 

410 field_name = data.get('field_name') 

411 if field_name: 

412 self._scroll_to_section(field_name) 

413 logger.debug(f"Navigating to section: {field_name}") 

414 else: 

415 class_obj = data.get('class') 

416 class_name = getattr(class_obj, '__name__', 'Unknown') if class_obj else 'Unknown' 

417 logger.debug(f"Double-clicked on root dataclass: {class_name}") 

418 

419 elif item_type == 'inheritance_link': 

420 # Navigate to the parent class section in the form 

421 target_class = data.get('target_class') 

422 if target_class: 

423 # Find the field that has this type (or its lazy version) 

424 field_name = self._find_field_for_class(target_class) 

425 if field_name: 

426 self._scroll_to_section(field_name) 

427 logger.debug(f"Navigating to inherited section: {field_name} (class: {target_class.__name__})") 

428 else: 

429 logger.warning(f"Could not find field for class {target_class.__name__}") 

430 

431 def _find_field_for_class(self, target_class) -> str: 

432 """Find the field name that has the given class type (or its lazy version).""" 

433 from openhcs.core.lazy_placeholder_simplified import LazyDefaultPlaceholderService 

434 import dataclasses 

435 

436 # Get the root dataclass type 

437 root_config = self.form_manager.object_instance 

438 if not dataclasses.is_dataclass(root_config): 

439 return None 

440 

441 root_type = type(root_config) 

442 

443 # Search through all fields to find one with matching type 

444 for field in dataclasses.fields(root_type): 

445 field_type = field.type 

446 

447 # Check if field type matches target class directly 

448 if field_type == target_class: 

449 return field.name 

450 

451 # Check if field type is a lazy version of target class 

452 if LazyDefaultPlaceholderService.has_lazy_resolution(field_type): 

453 # Get the base class of the lazy type 

454 for base in field_type.__bases__: 

455 if base == target_class: 

456 return field.name 

457 

458 return None 

459 

460 def _scroll_to_section(self, field_name: str): 

461 """Scroll to a specific section in the form - type-driven, seamless.""" 

462 logger.info(f"🔍 Scrolling to section: {field_name}") 

463 logger.info(f"Available nested managers: {list(self.form_manager.nested_managers.keys())}") 

464 

465 # Type-driven: nested_managers dict has exact field name as key 

466 if field_name in self.form_manager.nested_managers: 

467 nested_manager = self.form_manager.nested_managers[field_name] 

468 

469 # Strategy: Find the first parameter widget in this nested manager (like the test does) 

470 # This is more reliable than trying to find the GroupBox 

471 first_widget = None 

472 

473 if hasattr(nested_manager, 'widgets') and nested_manager.widgets: 

474 # Get the first widget from the nested manager's widgets dict 

475 first_param_name = next(iter(nested_manager.widgets.keys())) 

476 first_widget = nested_manager.widgets[first_param_name] 

477 logger.info(f"Found first widget: {first_param_name}") 

478 

479 if first_widget: 

480 # Scroll to the first widget (this will show the section header too) 

481 self.scroll_area.ensureWidgetVisible(first_widget, 100, 100) 

482 logger.info(f"✅ Scrolled to {field_name} via first widget") 

483 else: 

484 # Fallback: try to find the GroupBox 

485 from PyQt6.QtWidgets import QGroupBox 

486 current = nested_manager.parentWidget() 

487 while current: 

488 if isinstance(current, QGroupBox): 

489 self.scroll_area.ensureWidgetVisible(current, 50, 50) 

490 logger.info(f"✅ Scrolled to {field_name} via GroupBox") 

491 return 

492 current = current.parentWidget() 

493 

494 logger.warning(f"⚠️ Could not find widget or GroupBox for {field_name}") 

495 else: 

496 logger.warning(f"❌ Field '{field_name}' not in nested_managers") 

497 

498 

499 

500 

501 

502 

503 

504 def update_widget_value(self, widget: QWidget, value: Any): 

505 """ 

506 Update widget value without triggering signals. 

507  

508 Args: 

509 widget: Widget to update 

510 value: New value 

511 """ 

512 # Temporarily block signals to avoid recursion 

513 widget.blockSignals(True) 

514 

515 try: 

516 if isinstance(widget, QCheckBox): 

517 widget.setChecked(bool(value)) 

518 elif isinstance(widget, QSpinBox): 

519 widget.setValue(int(value) if value is not None else 0) 

520 elif isinstance(widget, QDoubleSpinBox): 

521 widget.setValue(float(value) if value is not None else 0.0) 

522 elif isinstance(widget, QComboBox): 

523 for i in range(widget.count()): 

524 if widget.itemData(i) == value: 

525 widget.setCurrentIndex(i) 

526 break 

527 elif isinstance(widget, QLineEdit): 

528 widget.setText(str(value) if value is not None else "") 

529 finally: 

530 widget.blockSignals(False) 

531 

532 def reset_to_defaults(self): 

533 """Reset all parameters using centralized service with full sophistication.""" 

534 # Service layer now contains ALL the sophisticated logic previously in infrastructure classes 

535 # This includes nested dataclass reset, lazy awareness, and recursive traversal 

536 self.form_manager.reset_all_parameters() 

537 

538 # Refresh placeholder text to ensure UI shows correct defaults 

539 self.form_manager._refresh_all_placeholders() 

540 

541 logger.debug("Reset all parameters using enhanced ParameterFormManager service") 

542 

543 def save_config(self): 

544 """Save the configuration preserving lazy behavior for unset fields.""" 

545 try: 

546 if LazyDefaultPlaceholderService.has_lazy_resolution(self.config_class): 

547 # BETTER APPROACH: For lazy dataclasses, only save user-modified values 

548 # Get only values that were explicitly set by the user (non-None raw values) 

549 user_modified_values = self.form_manager.get_user_modified_values() 

550 

551 # Create fresh lazy instance with only user-modified values 

552 # This preserves lazy resolution for unmodified fields 

553 new_config = self.config_class(**user_modified_values) 

554 else: 

555 # For non-lazy dataclasses, use all current values 

556 current_values = self.form_manager.get_current_values() 

557 new_config = self.config_class(**current_values) 

558 

559 # CRITICAL: Set flag to prevent refresh_config from recreating the form 

560 # The window already has the correct data - it just saved it! 

561 self._saving = True 

562 logger.info(f"🔍 SAVE_CONFIG: Set _saving=True before callback (id={id(self)})") 

563 try: 

564 # Emit signal and call callback 

565 self.config_saved.emit(new_config) 

566 

567 if self.on_save_callback: 

568 logger.info(f"🔍 SAVE_CONFIG: Calling on_save_callback (id={id(self)})") 

569 self.on_save_callback(new_config) 

570 logger.info(f"🔍 SAVE_CONFIG: Returned from on_save_callback (id={id(self)})") 

571 finally: 

572 self._saving = False 

573 logger.info(f"🔍 SAVE_CONFIG: Reset _saving=False (id={id(self)})") 

574 

575 self.accept() 

576 

577 except Exception as e: 

578 logger.error(f"Failed to save configuration: {e}") 

579 from PyQt6.QtWidgets import QMessageBox 

580 QMessageBox.critical(self, "Save Error", f"Failed to save configuration:\n{e}") 

581 

582 

583 def _patch_lazy_constructors(self): 

584 """Context manager that patches lazy dataclass constructors to preserve None vs concrete distinction.""" 

585 from contextlib import contextmanager 

586 from openhcs.core.lazy_placeholder import LazyDefaultPlaceholderService 

587 from openhcs.config_framework.lazy_factory import _lazy_type_registry 

588 import dataclasses 

589 

590 @contextmanager 

591 def patch_context(): 

592 original_constructors = {} 

593 

594 # FIXED: Dynamically discover all lazy types from registry instead of hardcoding 

595 # This ensures we patch ALL lazy types, including future additions 

596 for lazy_type in _lazy_type_registry.keys(): 

597 if LazyDefaultPlaceholderService.has_lazy_resolution(lazy_type): 

598 # Store original constructor 

599 original_constructors[lazy_type] = lazy_type.__init__ 

600 

601 # Create patched constructor that uses raw values 

602 def create_patched_init(original_init, dataclass_type): 

603 def patched_init(self, **kwargs): 

604 # Use raw value approach instead of calling original constructor 

605 # This prevents lazy resolution during code execution 

606 for field in dataclasses.fields(dataclass_type): 

607 value = kwargs.get(field.name, None) 

608 object.__setattr__(self, field.name, value) 

609 

610 # Initialize any required lazy dataclass attributes 

611 if hasattr(dataclass_type, '_is_lazy_dataclass'): 

612 object.__setattr__(self, '_is_lazy_dataclass', True) 

613 

614 return patched_init 

615 

616 # Apply the patch 

617 lazy_type.__init__ = create_patched_init(original_constructors[lazy_type], lazy_type) 

618 

619 try: 

620 yield 

621 finally: 

622 # Restore original constructors 

623 for lazy_type, original_init in original_constructors.items(): 

624 lazy_type.__init__ = original_init 

625 

626 return patch_context() 

627 

628 def _view_code(self): 

629 """Open code editor to view/edit the configuration as Python code.""" 

630 try: 

631 from openhcs.pyqt_gui.services.simple_code_editor import SimpleCodeEditorService 

632 from openhcs.debug.pickle_to_python import generate_config_code 

633 import os 

634 

635 # Get current config from form 

636 current_values = self.form_manager.get_current_values() 

637 current_config = self.config_class(**current_values) 

638 

639 # Generate code using existing function 

640 python_code = generate_config_code(current_config, self.config_class, clean_mode=True) 

641 

642 # Launch editor 

643 editor_service = SimpleCodeEditorService(self) 

644 use_external = os.environ.get('OPENHCS_USE_EXTERNAL_EDITOR', '').lower() in ('1', 'true', 'yes') 

645 

646 editor_service.edit_code( 

647 initial_content=python_code, 

648 title=f"View/Edit {self.config_class.__name__}", 

649 callback=self._handle_edited_config_code, 

650 use_external=use_external, 

651 code_type='config', 

652 code_data={'config_class': self.config_class, 'clean_mode': True} 

653 ) 

654 

655 except Exception as e: 

656 logger.error(f"Failed to view config code: {e}") 

657 from PyQt6.QtWidgets import QMessageBox 

658 QMessageBox.critical(self, "View Code Error", f"Failed to view code:\n{e}") 

659 

660 def _handle_edited_config_code(self, edited_code: str): 

661 """Handle edited configuration code from the code editor.""" 

662 try: 

663 # CRITICAL: Parse the code to extract explicitly specified fields 

664 # This prevents overwriting None values for unspecified fields 

665 explicitly_set_fields = self._extract_explicitly_set_fields(edited_code) 

666 

667 namespace = {} 

668 

669 # CRITICAL FIX: Use lazy constructor patching 

670 with self._patch_lazy_constructors(): 

671 exec(edited_code, namespace) 

672 

673 new_config = namespace.get('config') 

674 if not new_config: 

675 raise ValueError("No 'config' variable found in edited code") 

676 

677 if not isinstance(new_config, self.config_class): 

678 raise ValueError(f"Expected {self.config_class.__name__}, got {type(new_config).__name__}") 

679 

680 # Update current config 

681 self.current_config = new_config 

682 

683 # FIXED: Proper context propagation based on config type 

684 # ConfigWindow is used for BOTH GlobalPipelineConfig AND PipelineConfig editing 

685 from openhcs.config_framework.global_config import set_global_config_for_editing 

686 from openhcs.core.config import GlobalPipelineConfig 

687 

688 if self.config_class == GlobalPipelineConfig: 

689 # For GlobalPipelineConfig: Update thread-local context 

690 # This ensures all lazy resolution uses the new config 

691 set_global_config_for_editing(GlobalPipelineConfig, new_config) 

692 logger.debug("Updated thread-local GlobalPipelineConfig context") 

693 # For PipelineConfig: No context update needed here 

694 # The orchestrator.apply_pipeline_config() happens in the save callback 

695 # Code edits just update the form, actual application happens on Save 

696 

697 # Update form values from the new config without rebuilding 

698 self._update_form_from_config(new_config, explicitly_set_fields) 

699 

700 logger.info("Updated config from edited code") 

701 

702 except Exception as e: 

703 logger.error(f"Failed to apply edited config code: {e}") 

704 from PyQt6.QtWidgets import QMessageBox 

705 QMessageBox.critical(self, "Code Edit Error", f"Failed to apply edited code:\n{e}") 

706 

707 def _extract_explicitly_set_fields(self, code: str) -> set: 

708 """ 

709 Parse code to extract which fields were explicitly set. 

710 

711 Returns a set of field names that appear in the config constructor call. 

712 For nested fields, uses dot notation like 'zarr_config.chunk_size'. 

713 """ 

714 import re 

715 

716 # Find the config = ClassName(...) pattern 

717 # Match the class name and capture everything inside parentheses 

718 pattern = rf'config\s*=\s*{self.config_class.__name__}\s*\((.*?)\)\s*$' 

719 match = re.search(pattern, code, re.DOTALL | re.MULTILINE) 

720 

721 if not match: 

722 return set() 

723 

724 constructor_args = match.group(1) 

725 

726 # Extract field names (simple pattern: field_name=...) 

727 # This handles both simple fields and nested dataclass fields 

728 field_pattern = r'(\w+)\s*=' 

729 fields_found = set(re.findall(field_pattern, constructor_args)) 

730 

731 logger.debug(f"Explicitly set fields from code: {fields_found}") 

732 return fields_found 

733 

734 def _update_form_from_config(self, new_config, explicitly_set_fields: set): 

735 """Update form values from new config without rebuilding the entire form.""" 

736 from dataclasses import fields, is_dataclass 

737 

738 # CRITICAL: Only update fields that were explicitly set in the code 

739 # This preserves None values for fields not mentioned in the code 

740 for field in fields(new_config): 

741 if field.name in explicitly_set_fields: 

742 new_value = getattr(new_config, field.name) 

743 if field.name in self.form_manager.parameters: 

744 # For nested dataclasses, we need to recursively update nested fields 

745 if is_dataclass(new_value) and not isinstance(new_value, type): 

746 self._update_nested_dataclass(field.name, new_value) 

747 else: 

748 self.form_manager.update_parameter(field.name, new_value) 

749 

750 # Refresh placeholders to reflect the new values 

751 self.form_manager._refresh_all_placeholders() 

752 self.form_manager._apply_to_nested_managers(lambda name, manager: manager._refresh_all_placeholders()) 

753 

754 def _update_nested_dataclass(self, field_name: str, new_value): 

755 """Recursively update a nested dataclass field and all its children.""" 

756 from dataclasses import fields, is_dataclass 

757 

758 # Update the parent field first 

759 self.form_manager.update_parameter(field_name, new_value) 

760 

761 # Get the nested manager for this field 

762 nested_manager = self.form_manager.nested_managers.get(field_name) 

763 if nested_manager: 

764 # Update each field in the nested manager 

765 for field in fields(new_value): 

766 nested_field_value = getattr(new_value, field.name) 

767 if field.name in nested_manager.parameters: 

768 # Recursively handle nested dataclasses 

769 if is_dataclass(nested_field_value) and not isinstance(nested_field_value, type): 

770 self._update_nested_dataclass_in_manager(nested_manager, field.name, nested_field_value) 

771 else: 

772 nested_manager.update_parameter(field.name, nested_field_value) 

773 

774 def _update_nested_dataclass_in_manager(self, manager, field_name: str, new_value): 

775 """Helper to update nested dataclass within a specific manager.""" 

776 from dataclasses import fields, is_dataclass 

777 

778 manager.update_parameter(field_name, new_value) 

779 

780 nested_manager = manager.nested_managers.get(field_name) 

781 if nested_manager: 

782 for field in fields(new_value): 

783 nested_field_value = getattr(new_value, field.name) 

784 if field.name in nested_manager.parameters: 

785 if is_dataclass(nested_field_value) and not isinstance(nested_field_value, type): 

786 self._update_nested_dataclass_in_manager(nested_manager, field.name, nested_field_value) 

787 else: 

788 nested_manager.update_parameter(field.name, nested_field_value) 

789 

790 def reject(self): 

791 """Handle dialog rejection (Cancel button).""" 

792 self.config_cancelled.emit() 

793 super().reject() # BaseFormDialog handles unregistration 

794 

795 def _get_form_managers(self): 

796 """Return list of form managers to unregister (required by BaseFormDialog).""" 

797 if hasattr(self, 'form_manager'): 

798 return [self.form_manager] 

799 return []