Coverage for openhcs/pyqt_gui/widgets/shared/parameter_form_manager.py: 0.0%

594 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-10-01 18:33 +0000

1""" 

2Dramatically simplified PyQt parameter form manager. 

3 

4This demonstrates how the widget implementation can be drastically simplified 

5by leveraging the comprehensive shared infrastructure we've built. 

6""" 

7 

8import dataclasses 

9import logging 

10from typing import Any, Dict, Type, Optional, Callable, Tuple 

11from dataclasses import replace 

12from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QLabel, QPushButton, QLineEdit, QCheckBox, QComboBox, QGroupBox 

13from PyQt6.QtCore import Qt, pyqtSignal 

14 

15# SIMPLIFIED: Removed thread-local imports - dual-axis resolver handles context automatically 

16# Mathematical simplification: Shared dispatch tables to eliminate duplication 

17WIDGET_UPDATE_DISPATCH = [ 

18 (QComboBox, 'update_combo_box'), 

19 ('get_selected_values', 'update_checkbox_group'), 

20 ('set_value', lambda w, v: w.set_value(v)), # Handles NoneAwareCheckBox, NoneAwareIntEdit, etc. 

21 ('setValue', lambda w, v: w.setValue(v if v is not None else w.minimum())), # CRITICAL FIX: Set to minimum for None values to enable placeholder 

22 ('setText', lambda w, v: v is not None and w.setText(str(v)) or (v is None and w.clear())), # CRITICAL FIX: Handle None values by clearing 

23 ('set_path', lambda w, v: w.set_path(v)), # EnhancedPathWidget support 

24] 

25 

26WIDGET_GET_DISPATCH = [ 

27 (QComboBox, lambda w: w.itemData(w.currentIndex()) if w.currentIndex() >= 0 else None), 

28 ('get_selected_values', lambda w: w.get_selected_values()), 

29 ('get_value', lambda w: w.get_value()), # Handles NoneAwareCheckBox, NoneAwareIntEdit, etc. 

30 ('value', lambda w: None if (hasattr(w, 'specialValueText') and w.value() == w.minimum() and w.specialValueText()) else w.value()), 

31 ('get_path', lambda w: w.get_path()), # EnhancedPathWidget support 

32 ('text', lambda w: w.text()) 

33] 

34 

35logger = logging.getLogger(__name__) 

36 

37# Import our comprehensive shared infrastructure 

38from openhcs.ui.shared.parameter_form_service import ParameterFormService, ParameterInfo 

39from openhcs.ui.shared.parameter_form_config_factory import pyqt_config 

40from openhcs.ui.shared.parameter_form_constants import CONSTANTS 

41 

42from openhcs.ui.shared.widget_creation_registry import create_pyqt6_registry 

43from openhcs.ui.shared.ui_utils import format_param_name, format_field_id, format_reset_button_id 

44from .widget_strategies import PyQt6WidgetEnhancer 

45 

46# Import PyQt-specific components 

47from .clickable_help_components import GroupBoxWithHelp, LabelWithHelp 

48from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme 

49from .layout_constants import CURRENT_LAYOUT 

50 

51# Import OpenHCS core components 

52from openhcs.core.lazy_placeholder_simplified import LazyDefaultPlaceholderService 

53# Old field path detection removed - using simple field name matching 

54from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils 

55 

56 

57 

58 

59 

60class NoneAwareLineEdit(QLineEdit): 

61 """QLineEdit that properly handles None values for lazy dataclass contexts.""" 

62 

63 def get_value(self): 

64 """Get value, returning None for empty text instead of empty string.""" 

65 text = self.text().strip() 

66 return None if text == "" else text 

67 

68 def set_value(self, value): 

69 """Set value, handling None properly.""" 

70 self.setText("" if value is None else str(value)) 

71 

72 

73class NoneAwareIntEdit(QLineEdit): 

74 """QLineEdit that only allows digits and properly handles None values for integer fields.""" 

75 

76 def __init__(self, parent=None): 

77 super().__init__(parent) 

78 # Set up input validation to only allow digits 

79 from PyQt6.QtGui import QIntValidator 

80 self.setValidator(QIntValidator()) 

81 

82 def get_value(self): 

83 """Get value, returning None for empty text or converting to int.""" 

84 text = self.text().strip() 

85 if text == "": 

86 return None 

87 try: 

88 return int(text) 

89 except ValueError: 

90 return None 

91 

92 def set_value(self, value): 

93 """Set value, handling None properly.""" 

94 if value is None: 

95 self.setText("") 

96 else: 

97 self.setText(str(value)) 

98 

99 

100class ParameterFormManager(QWidget): 

101 """ 

102 PyQt6 parameter form manager with simplified implementation using generic object introspection. 

103 

104 This implementation leverages the new context management system and supports any object type: 

105 - Dataclasses (via dataclasses.fields()) 

106 - ABC constructors (via inspect.signature()) 

107 - Step objects (via attribute scanning) 

108 - Any object with parameters 

109 

110 Key improvements: 

111 - Generic object introspection replaces manual parameter specification 

112 - Context-driven resolution using config_context() system 

113 - Automatic parameter extraction from object instances 

114 - Unified interface for all object types 

115 - Dramatically simplified constructor (4 parameters vs 12+) 

116 """ 

117 

118 parameter_changed = pyqtSignal(str, object) # param_name, value 

119 

120 # Class constants for UI preferences (moved from constructor parameters) 

121 DEFAULT_USE_SCROLL_AREA = False 

122 DEFAULT_PLACEHOLDER_PREFIX = "Default" 

123 DEFAULT_COLOR_SCHEME = None 

124 

125 def __init__(self, object_instance: Any, field_id: str, parent=None, context_obj=None): 

126 """ 

127 Initialize PyQt parameter form manager with generic object introspection. 

128 

129 Args: 

130 object_instance: Any object to build form for (dataclass, ABC constructor, step, etc.) 

131 field_id: Unique identifier for the form 

132 parent: Optional parent widget 

133 context_obj: Context object for placeholder resolution (orchestrator, pipeline_config, etc.) 

134 """ 

135 QWidget.__init__(self, parent) 

136 

137 # Store core configuration 

138 self.object_instance = object_instance 

139 self.field_id = field_id 

140 self.context_obj = context_obj 

141 

142 # Initialize service layer first (needed for parameter extraction) 

143 self.service = ParameterFormService() 

144 

145 # Auto-extract parameters and types using generic introspection 

146 self.parameters, self.parameter_types, self.dataclass_type = self._extract_parameters_from_object(object_instance) 

147 

148 # DELEGATE TO SERVICE LAYER: Analyze form structure using service 

149 # Use UnifiedParameterAnalyzer-derived descriptions as the single source of truth 

150 parameter_info = getattr(self, '_parameter_descriptions', {}) 

151 self.form_structure = self.service.analyze_parameters( 

152 self.parameters, self.parameter_types, field_id, parameter_info, self.dataclass_type 

153 ) 

154 

155 # Auto-detect configuration settings 

156 self.global_config_type = self._auto_detect_global_config_type() 

157 self.placeholder_prefix = self.DEFAULT_PLACEHOLDER_PREFIX 

158 

159 # Create configuration object with auto-detected settings 

160 color_scheme = self.DEFAULT_COLOR_SCHEME or PyQt6ColorScheme() 

161 config = pyqt_config( 

162 field_id=field_id, 

163 color_scheme=color_scheme, 

164 function_target=object_instance, # Use object_instance as function_target 

165 use_scroll_area=self.DEFAULT_USE_SCROLL_AREA 

166 ) 

167 # IMPORTANT: Keep parameter_info consistent with the analyzer output to avoid losing descriptions 

168 config.parameter_info = parameter_info 

169 config.dataclass_type = self.dataclass_type 

170 config.global_config_type = self.global_config_type 

171 config.placeholder_prefix = self.placeholder_prefix 

172 

173 # Auto-determine editing mode based on object type analysis 

174 config.is_lazy_dataclass = self._is_lazy_dataclass() 

175 config.is_global_config_editing = not config.is_lazy_dataclass 

176 

177 # Initialize core attributes 

178 self.config = config 

179 self.param_defaults = self._extract_parameter_defaults() 

180 

181 # Initialize tracking attributes 

182 self.widgets = {} 

183 self.reset_buttons = {} # Track reset buttons for API compatibility 

184 self.nested_managers = {} 

185 self.reset_fields = set() # Track fields that have been explicitly reset to show inheritance 

186 

187 # Track which fields have been explicitly set by users 

188 self._user_set_fields: set = set() 

189 

190 # Track if initial form load is complete (disable live updates during initial load) 

191 self._initial_load_complete = False 

192 

193 # SHARED RESET STATE: Track reset fields across all nested managers within this form 

194 if hasattr(parent, 'shared_reset_fields'): 

195 # Nested manager: use parent's shared reset state 

196 self.shared_reset_fields = parent.shared_reset_fields 

197 else: 

198 # Root manager: create new shared reset state 

199 self.shared_reset_fields = set() 

200 

201 # Store backward compatibility attributes 

202 self.parameter_info = config.parameter_info 

203 self.use_scroll_area = config.use_scroll_area 

204 self.function_target = config.function_target 

205 self.color_scheme = config.color_scheme 

206 

207 # Form structure already analyzed above using UnifiedParameterAnalyzer descriptions 

208 

209 # Get widget creator from registry 

210 self._widget_creator = create_pyqt6_registry() 

211 

212 # Context system handles updates automatically 

213 self._context_event_coordinator = None 

214 

215 # Set up UI 

216 self.setup_ui() 

217 

218 # Connect parameter changes to live placeholder updates 

219 # When any field changes, refresh all placeholders using current form state 

220 self.parameter_changed.connect(lambda param_name, value: self._refresh_all_placeholders()) 

221 

222 # CRITICAL: Detect user-set fields for lazy dataclasses 

223 # Check which parameters were explicitly set (raw non-None values) 

224 from dataclasses import is_dataclass 

225 if is_dataclass(object_instance): 

226 for field_name, raw_value in self.parameters.items(): 

227 # SIMPLE RULE: Raw non-None = user-set, Raw None = inherited 

228 if raw_value is not None: 

229 self._user_set_fields.add(field_name) 

230 

231 # CRITICAL FIX: Refresh placeholders AFTER user-set detection to show correct concrete/placeholder state 

232 self._refresh_all_placeholders() 

233 

234 # CRITICAL FIX: Ensure nested managers also get their placeholders refreshed after full hierarchy is built 

235 # This fixes the issue where nested dataclass placeholders don't load properly on initial form creation 

236 self._apply_to_nested_managers(lambda name, manager: manager._refresh_all_placeholders()) 

237 

238 # Mark initial load as complete - enable live placeholder updates from now on 

239 self._initial_load_complete = True 

240 print(f"✅ INITIAL LOAD COMPLETE for {self.field_id}: {self._initial_load_complete}") 

241 self._apply_to_nested_managers(lambda name, manager: setattr(manager, '_initial_load_complete', True)) 

242 

243 # ==================== GENERIC OBJECT INTROSPECTION METHODS ==================== 

244 

245 def _extract_parameters_from_object(self, obj: Any) -> Tuple[Dict[str, Any], Dict[str, Type], Type]: 

246 """ 

247 Extract parameters and types from any object using unified analysis. 

248 

249 Uses the existing UnifiedParameterAnalyzer for consistent handling of all object types. 

250 """ 

251 from openhcs.textual_tui.widgets.shared.unified_parameter_analyzer import UnifiedParameterAnalyzer 

252 

253 # Use unified analyzer for all object types 

254 param_info_dict = UnifiedParameterAnalyzer.analyze(obj) 

255 

256 parameters = {} 

257 parameter_types = {} 

258 

259 # CRITICAL FIX: Store parameter descriptions for docstring display 

260 self._parameter_descriptions = {} 

261 

262 for name, param_info in param_info_dict.items(): 

263 # Use the values already extracted by UnifiedParameterAnalyzer 

264 # This preserves lazy config behavior (None values for unset fields) 

265 parameters[name] = param_info.default_value 

266 parameter_types[name] = param_info.param_type 

267 

268 # CRITICAL FIX: Preserve parameter descriptions for help display 

269 if param_info.description: 

270 self._parameter_descriptions[name] = param_info.description 

271 

272 return parameters, parameter_types, type(obj) 

273 

274 # ==================== WIDGET CREATION METHODS ==================== 

275 

276 def _auto_detect_global_config_type(self) -> Optional[Type]: 

277 """Auto-detect global config type from context.""" 

278 from openhcs.config_framework import get_base_config_type 

279 return getattr(self.context_obj, 'global_config_type', get_base_config_type()) 

280 

281 

282 def _extract_parameter_defaults(self) -> Dict[str, Any]: 

283 """ 

284 Extract parameter defaults from the object. 

285 

286 For reset functionality: returns the initial values used to load widgets. 

287 - For functions: signature defaults 

288 - For dataclasses: field defaults 

289 - For any object: constructor parameter defaults 

290 """ 

291 from openhcs.textual_tui.widgets.shared.unified_parameter_analyzer import UnifiedParameterAnalyzer 

292 

293 # Use unified analyzer to get defaults 

294 param_info_dict = UnifiedParameterAnalyzer.analyze(self.object_instance) 

295 

296 return {name: info.default_value for name, info in param_info_dict.items()} 

297 

298 def _is_lazy_dataclass(self) -> bool: 

299 """Check if the object represents a lazy dataclass.""" 

300 if hasattr(self.object_instance, '_resolve_field_value'): 

301 return True 

302 if self.dataclass_type: 

303 from openhcs.core.lazy_placeholder_simplified import LazyDefaultPlaceholderService 

304 return LazyDefaultPlaceholderService.has_lazy_resolution(self.dataclass_type) 

305 return False 

306 

307 def create_widget(self, param_name: str, param_type: Type, current_value: Any, 

308 widget_id: str, parameter_info: Any = None) -> Any: 

309 """Create widget using the registry creator function.""" 

310 widget = self._widget_creator(param_name, param_type, current_value, widget_id, parameter_info) 

311 

312 if widget is None: 

313 from PyQt6.QtWidgets import QLabel 

314 widget = QLabel(f"ERROR: Widget creation failed for {param_name}") 

315 

316 return widget 

317 

318 

319 

320 

321 @classmethod 

322 def from_dataclass_instance(cls, dataclass_instance: Any, field_id: str, 

323 placeholder_prefix: str = "Default", 

324 parent=None, use_scroll_area: bool = True, 

325 function_target=None, color_scheme=None, 

326 force_show_all_fields: bool = False, 

327 global_config_type: Optional[Type] = None, 

328 context_event_coordinator=None, context_obj=None): 

329 """ 

330 SIMPLIFIED: Create ParameterFormManager using new generic constructor. 

331 

332 This method now simply delegates to the simplified constructor that handles 

333 all object types automatically through generic introspection. 

334 

335 Args: 

336 dataclass_instance: The dataclass instance to edit 

337 field_id: Unique identifier for the form 

338 context_obj: Context object for placeholder resolution 

339 **kwargs: Legacy parameters (ignored - handled automatically) 

340 

341 Returns: 

342 ParameterFormManager configured for any object type 

343 """ 

344 # Validate input 

345 from dataclasses import is_dataclass 

346 if not is_dataclass(dataclass_instance): 

347 raise ValueError(f"{type(dataclass_instance)} is not a dataclass") 

348 

349 # Use simplified constructor with automatic parameter extraction 

350 # CRITICAL: Do NOT default context_obj to dataclass_instance 

351 # This creates circular context bug where form uses itself as parent 

352 # Caller must explicitly pass context_obj if needed (e.g., Step Editor passes pipeline_config) 

353 return cls( 

354 object_instance=dataclass_instance, 

355 field_id=field_id, 

356 parent=parent, 

357 context_obj=context_obj # No default - None means inherit from thread-local global only 

358 ) 

359 

360 @classmethod 

361 def from_object(cls, object_instance: Any, field_id: str, parent=None, context_obj=None): 

362 """ 

363 NEW: Create ParameterFormManager for any object type using generic introspection. 

364 

365 This is the new primary factory method that works with: 

366 - Dataclass instances and types 

367 - ABC constructors and functions 

368 - Step objects with config attributes 

369 - Any object with parameters 

370 

371 Args: 

372 object_instance: Any object to build form for 

373 field_id: Unique identifier for the form 

374 parent: Optional parent widget 

375 context_obj: Context object for placeholder resolution 

376 

377 Returns: 

378 ParameterFormManager configured for the object type 

379 """ 

380 return cls( 

381 object_instance=object_instance, 

382 field_id=field_id, 

383 parent=parent, 

384 context_obj=context_obj 

385 ) 

386 

387 

388 

389 def setup_ui(self): 

390 """Set up the UI layout.""" 

391 layout = QVBoxLayout(self) 

392 # Apply configurable layout settings 

393 layout.setSpacing(CURRENT_LAYOUT.main_layout_spacing) 

394 layout.setContentsMargins(*CURRENT_LAYOUT.main_layout_margins) 

395 

396 # Apply centralized widget styling for uniform appearance (same as config_window.py) 

397 from openhcs.pyqt_gui.shared.style_generator import StyleSheetGenerator 

398 style_gen = StyleSheetGenerator(self.color_scheme) 

399 self.setStyleSheet(style_gen.generate_config_window_style()) 

400 

401 # Build form content 

402 form_widget = self.build_form() 

403 

404 # Add scroll area if requested 

405 if self.config.use_scroll_area: 

406 scroll_area = QScrollArea() 

407 scroll_area.setWidgetResizable(True) 

408 scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) 

409 scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) 

410 scroll_area.setWidget(form_widget) 

411 layout.addWidget(scroll_area) 

412 else: 

413 layout.addWidget(form_widget) 

414 

415 def build_form(self) -> QWidget: 

416 """Build form UI by delegating to service layer analysis.""" 

417 content_widget = QWidget() 

418 content_layout = QVBoxLayout(content_widget) 

419 content_layout.setSpacing(CURRENT_LAYOUT.content_layout_spacing) 

420 content_layout.setContentsMargins(*CURRENT_LAYOUT.content_layout_margins) 

421 

422 # DELEGATE TO SERVICE LAYER: Use analyzed form structure 

423 for param_info in self.form_structure.parameters: 

424 if param_info.is_optional and param_info.is_nested: 

425 # Optional[Dataclass]: show checkbox 

426 widget = self._create_optional_dataclass_widget(param_info) 

427 elif param_info.is_nested: 

428 # Direct dataclass (non-optional): nested group without checkbox 

429 widget = self._create_nested_dataclass_widget(param_info) 

430 else: 

431 # All regular types (including Optional[regular]) use regular widgets with None-aware behavior 

432 widget = self._create_regular_parameter_widget(param_info) 

433 content_layout.addWidget(widget) 

434 

435 return content_widget 

436 

437 def _create_regular_parameter_widget(self, param_info) -> QWidget: 

438 """Create widget for regular parameter - DELEGATE TO SERVICE LAYER.""" 

439 display_info = self.service.get_parameter_display_info(param_info.name, param_info.type, param_info.description) 

440 field_ids = self.service.generate_field_ids_direct(self.config.field_id, param_info.name) 

441 

442 container = QWidget() 

443 layout = QHBoxLayout(container) 

444 layout.setSpacing(CURRENT_LAYOUT.parameter_row_spacing) 

445 layout.setContentsMargins(*CURRENT_LAYOUT.parameter_row_margins) 

446 

447 # Label 

448 label = LabelWithHelp( 

449 text=display_info['field_label'], param_name=param_info.name, 

450 param_description=display_info['description'], param_type=param_info.type, 

451 color_scheme=self.config.color_scheme or PyQt6ColorScheme() 

452 ) 

453 layout.addWidget(label) 

454 

455 # Widget 

456 current_value = self.parameters.get(param_info.name) 

457 widget = self.create_widget(param_info.name, param_info.type, current_value, field_ids['widget_id']) 

458 widget.setObjectName(field_ids['widget_id']) 

459 layout.addWidget(widget, 1) 

460 

461 # Reset button 

462 reset_button = QPushButton(CONSTANTS.RESET_BUTTON_TEXT) 

463 reset_button.setObjectName(field_ids['reset_button_id']) 

464 reset_button.setMaximumWidth(CURRENT_LAYOUT.reset_button_width) 

465 reset_button.clicked.connect(lambda: self.reset_parameter(param_info.name)) 

466 layout.addWidget(reset_button) 

467 

468 # Store widgets and connect signals 

469 self.widgets[param_info.name] = widget 

470 PyQt6WidgetEnhancer.connect_change_signal(widget, param_info.name, self._emit_parameter_change) 

471 

472 # CRITICAL FIX: Apply placeholder behavior after widget creation 

473 current_value = self.parameters.get(param_info.name) 

474 self._apply_context_behavior(widget, current_value, param_info.name) 

475 

476 return container 

477 

478 def _create_optional_regular_widget(self, param_info) -> QWidget: 

479 """Create widget for Optional[regular_type] - checkbox + regular widget.""" 

480 display_info = self.service.get_parameter_display_info(param_info.name, param_info.type, param_info.description) 

481 field_ids = self.service.generate_field_ids_direct(self.config.field_id, param_info.name) 

482 

483 container = QWidget() 

484 layout = QVBoxLayout(container) 

485 

486 # Checkbox (using NoneAwareCheckBox for consistency) 

487 from openhcs.pyqt_gui.widgets.shared.no_scroll_spinbox import NoneAwareCheckBox 

488 checkbox = NoneAwareCheckBox() 

489 checkbox.setText(display_info['checkbox_label']) 

490 checkbox.setObjectName(field_ids['optional_checkbox_id']) 

491 current_value = self.parameters.get(param_info.name) 

492 checkbox.setChecked(current_value is not None) 

493 layout.addWidget(checkbox) 

494 

495 # Get inner type for the actual widget 

496 from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils 

497 inner_type = ParameterTypeUtils.get_optional_inner_type(param_info.type) 

498 

499 # Create the actual widget for the inner type 

500 inner_widget = self._create_regular_parameter_widget_for_type(param_info.name, inner_type, current_value) 

501 inner_widget.setEnabled(current_value is not None) # Disable if None 

502 layout.addWidget(inner_widget) 

503 

504 # Connect checkbox to enable/disable the inner widget 

505 def on_checkbox_changed(checked): 

506 inner_widget.setEnabled(checked) 

507 if checked: 

508 # Set to default value for the inner type 

509 if inner_type == str: 

510 default_value = "" 

511 elif inner_type == int: 

512 default_value = 0 

513 elif inner_type == float: 

514 default_value = 0.0 

515 elif inner_type == bool: 

516 default_value = False 

517 else: 

518 default_value = None 

519 self.update_parameter(param_info.name, default_value) 

520 else: 

521 self.update_parameter(param_info.name, None) 

522 

523 checkbox.toggled.connect(on_checkbox_changed) 

524 return container 

525 

526 def _create_regular_parameter_widget_for_type(self, param_name: str, param_type: Type, current_value: Any) -> QWidget: 

527 """Create a regular parameter widget for a specific type.""" 

528 field_ids = self.service.generate_field_ids_direct(self.config.field_id, param_name) 

529 

530 # Use the existing create_widget method 

531 widget = self.create_widget(param_name, param_type, current_value, field_ids['widget_id']) 

532 if widget: 

533 return widget 

534 

535 # Fallback to basic text input 

536 from PyQt6.QtWidgets import QLineEdit 

537 fallback_widget = QLineEdit() 

538 fallback_widget.setText(str(current_value or "")) 

539 fallback_widget.setObjectName(field_ids['widget_id']) 

540 return fallback_widget 

541 

542 def _create_nested_dataclass_widget(self, param_info) -> QWidget: 

543 """Create widget for nested dataclass - DELEGATE TO SERVICE LAYER.""" 

544 display_info = self.service.get_parameter_display_info(param_info.name, param_info.type, param_info.description) 

545 

546 # Always use the inner dataclass type for Optional[T] when wiring help/paths 

547 from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils 

548 unwrapped_type = ( 

549 ParameterTypeUtils.get_optional_inner_type(param_info.type) 

550 if ParameterTypeUtils.is_optional_dataclass(param_info.type) 

551 else param_info.type 

552 ) 

553 

554 group_box = GroupBoxWithHelp( 

555 title=display_info['field_label'], help_target=unwrapped_type, 

556 color_scheme=self.config.color_scheme or PyQt6ColorScheme() 

557 ) 

558 current_value = self.parameters.get(param_info.name) 

559 nested_manager = self._create_nested_form_inline(param_info.name, unwrapped_type, current_value) 

560 

561 nested_form = nested_manager.build_form() 

562 

563 # Use GroupBoxWithHelp's addWidget method instead of creating our own layout 

564 group_box.addWidget(nested_form) 

565 

566 self.nested_managers[param_info.name] = nested_manager 

567 return group_box 

568 

569 def _create_optional_dataclass_widget(self, param_info) -> QWidget: 

570 """Create widget for optional dataclass - checkbox integrated into GroupBox title.""" 

571 display_info = self.service.get_parameter_display_info(param_info.name, param_info.type, param_info.description) 

572 field_ids = self.service.generate_field_ids_direct(self.config.field_id, param_info.name) 

573 

574 # Get the unwrapped type for the GroupBox 

575 from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils 

576 unwrapped_type = ParameterTypeUtils.get_optional_inner_type(param_info.type) 

577 

578 # Create GroupBox with custom title widget that includes checkbox 

579 from PyQt6.QtGui import QFont 

580 group_box = QGroupBox() 

581 

582 # Create custom title widget with checkbox + title + help button (all inline) 

583 title_widget = QWidget() 

584 title_layout = QHBoxLayout(title_widget) 

585 title_layout.setSpacing(5) 

586 title_layout.setContentsMargins(10, 5, 10, 5) 

587 

588 # Checkbox (compact, no text) 

589 from openhcs.pyqt_gui.widgets.shared.no_scroll_spinbox import NoneAwareCheckBox 

590 checkbox = NoneAwareCheckBox() 

591 checkbox.setObjectName(field_ids['optional_checkbox_id']) 

592 current_value = self.parameters.get(param_info.name) 

593 checkbox.setChecked(current_value is not None) 

594 checkbox.setMaximumWidth(20) 

595 title_layout.addWidget(checkbox) 

596 

597 # Title label (clickable to toggle checkbox, matches GroupBoxWithHelp styling) 

598 title_label = QLabel(display_info['checkbox_label']) 

599 title_font = QFont() 

600 title_font.setBold(True) 

601 title_label.setFont(title_font) 

602 title_label.mousePressEvent = lambda e: checkbox.toggle() 

603 title_label.setCursor(Qt.CursorShape.PointingHandCursor) 

604 title_layout.addWidget(title_label) 

605 

606 # Help button (matches GroupBoxWithHelp) 

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

608 help_btn = HelpButton(help_target=unwrapped_type, text="?", color_scheme=self.color_scheme) 

609 help_btn.setMaximumWidth(25) 

610 help_btn.setMaximumHeight(20) 

611 title_layout.addWidget(help_btn) 

612 

613 title_layout.addStretch() 

614 

615 # Set the custom title widget as the GroupBox title 

616 group_box.setLayout(QVBoxLayout()) 

617 group_box.layout().setSpacing(0) 

618 group_box.layout().setContentsMargins(0, 0, 0, 0) 

619 group_box.layout().addWidget(title_widget) 

620 

621 # Create nested form 

622 nested_manager = self._create_nested_form_inline(param_info.name, unwrapped_type, current_value) 

623 nested_form = nested_manager.build_form() 

624 nested_form.setEnabled(current_value is not None) 

625 group_box.layout().addWidget(nested_form) 

626 

627 self.nested_managers[param_info.name] = nested_manager 

628 

629 # Connect checkbox to enable/disable with visual feedback 

630 def on_checkbox_changed(checked): 

631 nested_form.setEnabled(checked) 

632 # Apply visual feedback to all input widgets 

633 if checked: 

634 # Restore normal color (no explicit style needed - font is already bold) 

635 title_label.setStyleSheet("") 

636 help_btn.setEnabled(True) 

637 # Remove dimming from all widgets 

638 for widget in nested_form.findChildren(QWidget): 

639 widget.setGraphicsEffect(None) 

640 # Create default instance 

641 default_instance = unwrapped_type() 

642 self.update_parameter(param_info.name, default_instance) 

643 else: 

644 # Dim title text but keep help button enabled 

645 title_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_disabled)};") 

646 help_btn.setEnabled(True) # Keep help button clickable even when disabled 

647 # Dim all input widgets 

648 from PyQt6.QtWidgets import QGraphicsOpacityEffect 

649 for widget in nested_form.findChildren((QLineEdit, QComboBox, QPushButton)): 

650 effect = QGraphicsOpacityEffect() 

651 effect.setOpacity(0.4) 

652 widget.setGraphicsEffect(effect) 

653 self.update_parameter(param_info.name, None) 

654 

655 checkbox.toggled.connect(on_checkbox_changed) 

656 on_checkbox_changed(checkbox.isChecked()) 

657 

658 self.widgets[param_info.name] = group_box 

659 return group_box 

660 

661 

662 

663 

664 

665 

666 

667 

668 

669 def _create_nested_form_inline(self, param_name: str, param_type: Type, current_value: Any) -> Any: 

670 """Create nested form - simplified to let constructor handle parameter extraction""" 

671 # Get actual field path from FieldPathDetector (no artificial "nested_" prefix) 

672 # For function parameters (no parent dataclass), use parameter name directly 

673 if self.dataclass_type is None: 

674 field_path = param_name 

675 else: 

676 field_path = self.service.get_field_path_with_fail_loud(self.dataclass_type, param_type) 

677 

678 # Use current_value if available, otherwise create a default instance of the dataclass type 

679 # The constructor will handle parameter extraction automatically 

680 if current_value is not None: 

681 # If current_value is a dict (saved config), convert it back to dataclass instance 

682 import dataclasses 

683 # Unwrap Optional type to get actual dataclass type 

684 from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils 

685 actual_type = ParameterTypeUtils.get_optional_inner_type(param_type) if ParameterTypeUtils.is_optional(param_type) else param_type 

686 

687 if isinstance(current_value, dict) and dataclasses.is_dataclass(actual_type): 

688 # Convert dict back to dataclass instance 

689 object_instance = actual_type(**current_value) 

690 else: 

691 object_instance = current_value 

692 else: 

693 # Create a default instance of the dataclass type for parameter extraction 

694 import dataclasses 

695 # Unwrap Optional type to get actual dataclass type 

696 from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils 

697 actual_type = ParameterTypeUtils.get_optional_inner_type(param_type) if ParameterTypeUtils.is_optional(param_type) else param_type 

698 

699 if dataclasses.is_dataclass(actual_type): 

700 object_instance = actual_type() 

701 else: 

702 object_instance = actual_type 

703 

704 # DELEGATE TO NEW CONSTRUCTOR: Use simplified constructor 

705 nested_manager = ParameterFormManager( 

706 object_instance=object_instance, 

707 field_id=field_path, 

708 parent=self, 

709 context_obj=self.context_obj 

710 ) 

711 # Inherit lazy/global editing context from parent so resets behave correctly in nested forms 

712 try: 

713 nested_manager.config.is_lazy_dataclass = self.config.is_lazy_dataclass 

714 nested_manager.config.is_global_config_editing = not self.config.is_lazy_dataclass 

715 except Exception: 

716 pass 

717 

718 # Store parent manager reference for placeholder resolution 

719 nested_manager._parent_manager = self 

720 

721 # Connect nested manager's parameter_changed signal to parent's refresh handler 

722 # This ensures changes in nested forms trigger placeholder updates in parent and siblings 

723 nested_manager.parameter_changed.connect(self._on_nested_parameter_changed) 

724 

725 # Store nested manager 

726 self.nested_managers[param_name] = nested_manager 

727 

728 return nested_manager 

729 

730 

731 

732 def _convert_widget_value(self, value: Any, param_name: str) -> Any: 

733 """ 

734 Convert widget value to proper type. 

735 

736 Applies both PyQt-specific conversions (Path, tuple/list parsing) and 

737 service layer conversions (enums, basic types, Union handling). 

738 """ 

739 from openhcs.pyqt_gui.widgets.shared.widget_strategies import convert_widget_value_to_type 

740 

741 param_type = self.parameter_types.get(param_name, type(value)) 

742 

743 # PyQt-specific type conversions first 

744 converted_value = convert_widget_value_to_type(value, param_type) 

745 

746 # Then apply service layer conversion (enums, basic types, Union handling, etc.) 

747 converted_value = self.service.convert_value_to_type(converted_value, param_type, param_name, self.dataclass_type) 

748 

749 return converted_value 

750 

751 def _emit_parameter_change(self, param_name: str, value: Any) -> None: 

752 """Handle parameter change from widget and update parameter data model.""" 

753 # Convert value using unified conversion method 

754 converted_value = self._convert_widget_value(value, param_name) 

755 

756 # Update parameter in data model 

757 self.parameters[param_name] = converted_value 

758 

759 # CRITICAL FIX: Track that user explicitly set this field 

760 # This prevents placeholder updates from destroying user values 

761 self._user_set_fields.add(param_name) 

762 

763 # Emit signal only once - this triggers sibling placeholder updates 

764 self.parameter_changed.emit(param_name, converted_value) 

765 

766 

767 

768 def update_widget_value(self, widget: QWidget, value: Any, param_name: str = None, skip_context_behavior: bool = False, exclude_field: str = None) -> None: 

769 """Mathematical simplification: Unified widget update using shared dispatch.""" 

770 self._execute_with_signal_blocking(widget, lambda: self._dispatch_widget_update(widget, value)) 

771 

772 # Only apply context behavior if not explicitly skipped (e.g., during reset operations) 

773 if not skip_context_behavior: 

774 self._apply_context_behavior(widget, value, param_name, exclude_field) 

775 

776 def _dispatch_widget_update(self, widget: QWidget, value: Any) -> None: 

777 """Algebraic simplification: Single dispatch logic for all widget updates.""" 

778 for matcher, updater in WIDGET_UPDATE_DISPATCH: 

779 if isinstance(widget, matcher) if isinstance(matcher, type) else hasattr(widget, matcher): 

780 if isinstance(updater, str): 

781 getattr(self, f'_{updater}')(widget, value) 

782 else: 

783 updater(widget, value) 

784 return 

785 

786 def _clear_widget_to_default_state(self, widget: QWidget) -> None: 

787 """Clear widget to its default/empty state for reset operations.""" 

788 from PyQt6.QtWidgets import QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox, QCheckBox, QTextEdit 

789 

790 if isinstance(widget, QLineEdit): 

791 widget.clear() 

792 elif isinstance(widget, (QSpinBox, QDoubleSpinBox)): 

793 widget.setValue(widget.minimum()) 

794 elif isinstance(widget, QComboBox): 

795 widget.setCurrentIndex(-1) # No selection 

796 elif isinstance(widget, QCheckBox): 

797 widget.setChecked(False) 

798 elif isinstance(widget, QTextEdit): 

799 widget.clear() 

800 else: 

801 # For custom widgets, try to call clear() if available 

802 if hasattr(widget, 'clear'): 

803 widget.clear() 

804 

805 def _update_combo_box(self, widget: QComboBox, value: Any) -> None: 

806 """Update combo box with value matching.""" 

807 widget.setCurrentIndex(-1 if value is None else 

808 next((i for i in range(widget.count()) if widget.itemData(i) == value), -1)) 

809 

810 def _update_checkbox_group(self, widget: QWidget, value: Any) -> None: 

811 """Update checkbox group using functional operations.""" 

812 if hasattr(widget, '_checkboxes') and isinstance(value, list): 

813 # Functional: reset all, then set selected 

814 [cb.setChecked(False) for cb in widget._checkboxes.values()] 

815 [widget._checkboxes[v].setChecked(True) for v in value if v in widget._checkboxes] 

816 

817 def _execute_with_signal_blocking(self, widget: QWidget, operation: callable) -> None: 

818 """Execute operation with signal blocking - stateless utility.""" 

819 widget.blockSignals(True) 

820 operation() 

821 widget.blockSignals(False) 

822 

823 def _apply_context_behavior(self, widget: QWidget, value: Any, param_name: str, exclude_field: str = None) -> None: 

824 """CONSOLIDATED: Apply placeholder behavior using single resolution path.""" 

825 if not param_name or not self.dataclass_type: 

826 return 

827 

828 if value is None: 

829 # Allow placeholder application for nested forms even if they're not detected as lazy dataclasses 

830 # The placeholder service will determine if placeholders are available 

831 

832 # Build overlay from current form state 

833 overlay = self.get_current_values() 

834 

835 # Build context stack: parent context + overlay 

836 with self._build_context_stack(overlay): 

837 placeholder_text = self.service.get_placeholder_text(param_name, self.dataclass_type) 

838 if placeholder_text: 

839 PyQt6WidgetEnhancer.apply_placeholder_text(widget, placeholder_text) 

840 elif value is not None: 

841 PyQt6WidgetEnhancer._clear_placeholder_state(widget) 

842 

843 

844 def get_widget_value(self, widget: QWidget) -> Any: 

845 """Mathematical simplification: Unified widget value extraction using shared dispatch.""" 

846 for matcher, extractor in WIDGET_GET_DISPATCH: 

847 if isinstance(widget, matcher) if isinstance(matcher, type) else hasattr(widget, matcher): 

848 return extractor(widget) 

849 return None 

850 

851 # Framework-specific methods for backward compatibility 

852 

853 def reset_all_parameters(self) -> None: 

854 """Reset all parameters - let reset_parameter handle everything.""" 

855 try: 

856 # CRITICAL FIX: Create a copy of keys to avoid "dictionary changed during iteration" error 

857 # reset_parameter can modify self.parameters by removing keys, so we need a stable list 

858 param_names = list(self.parameters.keys()) 

859 for param_name in param_names: 

860 self.reset_parameter(param_name) 

861 

862 # Also refresh placeholders in nested managers after recursive resets 

863 self._apply_to_nested_managers(lambda name, manager: manager._refresh_all_placeholders()) 

864 

865 # Handle nested managers once at the end 

866 if self.dataclass_type and self.nested_managers: 

867 current_config = getattr(self, '_current_config_instance', None) 

868 if current_config: 

869 self.service.reset_nested_managers(self.nested_managers, self.dataclass_type, current_config) 

870 finally: 

871 # Context system handles placeholder updates automatically 

872 self._refresh_all_placeholders() 

873 

874 

875 

876 def update_parameter(self, param_name: str, value: Any) -> None: 

877 """Update parameter value using shared service layer.""" 

878 

879 if param_name in self.parameters: 

880 # Convert value using service layer 

881 converted_value = self.service.convert_value_to_type(value, self.parameter_types.get(param_name, type(value)), param_name, self.dataclass_type) 

882 

883 # Update parameter in data model 

884 self.parameters[param_name] = converted_value 

885 

886 # CRITICAL FIX: Track that user explicitly set this field 

887 # This prevents placeholder updates from destroying user values 

888 self._user_set_fields.add(param_name) 

889 

890 # Update corresponding widget if it exists 

891 if param_name in self.widgets: 

892 self.update_widget_value(self.widgets[param_name], converted_value) 

893 

894 # Emit signal for PyQt6 compatibility 

895 self.parameter_changed.emit(param_name, converted_value) 

896 

897 def _is_function_parameter(self, param_name: str) -> bool: 

898 """ 

899 Detect if parameter is a function parameter vs dataclass field. 

900 

901 Function parameters should not be reset against dataclass types. 

902 This prevents the critical bug where step editor tries to reset 

903 function parameters like 'group_by' against the global config type. 

904 """ 

905 if not self.function_target or not self.dataclass_type: 

906 return False 

907 

908 # Check if parameter exists in dataclass fields 

909 import dataclasses 

910 if dataclasses.is_dataclass(self.dataclass_type): 

911 field_names = {field.name for field in dataclasses.fields(self.dataclass_type)} 

912 # If parameter is NOT in dataclass fields, it's a function parameter 

913 return param_name not in field_names 

914 

915 return False 

916 

917 def reset_parameter(self, param_name: str, default_value: Any = None) -> None: 

918 """Reset parameter with predictable behavior.""" 

919 if param_name not in self.parameters: 

920 return 

921 

922 # SIMPLIFIED: Handle function forms vs config forms 

923 if hasattr(self, 'param_defaults') and self.param_defaults and param_name in self.param_defaults: 

924 # Function form - reset to static defaults 

925 reset_value = self.param_defaults[param_name] 

926 self.parameters[param_name] = reset_value 

927 

928 if param_name in self.widgets: 

929 widget = self.widgets[param_name] 

930 self.update_widget_value(widget, reset_value, param_name, skip_context_behavior=True) 

931 

932 self.parameter_changed.emit(param_name, reset_value) 

933 return 

934 

935 # Special handling for dataclass fields 

936 try: 

937 import dataclasses as _dc 

938 from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils 

939 param_type = self.parameter_types.get(param_name) 

940 

941 # If this is an Optional[Dataclass], sync container UI and reset nested manager 

942 if param_type and ParameterTypeUtils.is_optional_dataclass(param_type): 

943 # Determine reset (lazy -> None) 

944 reset_value = self._get_reset_value(param_name) 

945 self.parameters[param_name] = reset_value 

946 

947 if param_name in self.widgets: 

948 container = self.widgets[param_name] 

949 # Toggle the optional checkbox to match reset_value (None -> unchecked) 

950 from PyQt6.QtWidgets import QCheckBox 

951 ids = self.service.generate_field_ids_direct(self.config.field_id, param_name) 

952 checkbox = container.findChild(QCheckBox, ids['optional_checkbox_id']) 

953 if checkbox: 

954 checkbox.blockSignals(True) 

955 checkbox.setChecked(reset_value is not None) 

956 checkbox.blockSignals(False) 

957 

958 # Reset nested manager contents too 

959 nested_manager = self.nested_managers.get(param_name) 

960 if nested_manager and hasattr(nested_manager, 'reset_all_parameters'): 

961 nested_manager.reset_all_parameters() 

962 

963 # Enable/disable the nested group visually without relying on signals 

964 try: 

965 from .clickable_help_components import GroupBoxWithHelp 

966 group = container.findChild(GroupBoxWithHelp) if param_name in self.widgets else None 

967 if group: 

968 group.setEnabled(reset_value is not None) 

969 except Exception: 

970 pass 

971 

972 # Emit parameter change and return (handled) 

973 self.parameter_changed.emit(param_name, reset_value) 

974 return 

975 

976 # If this is a direct dataclass field (non-optional), do NOT replace the instance. 

977 # Instead, keep the container value and recursively reset the nested manager. 

978 if param_type and _dc.is_dataclass(param_type): 

979 nested_manager = self.nested_managers.get(param_name) 

980 if nested_manager and hasattr(nested_manager, 'reset_all_parameters'): 

981 nested_manager.reset_all_parameters() 

982 # Do not modify self.parameters[param_name] (keep current dataclass instance) 

983 # Refresh placeholder on the group container if it has a widget 

984 if param_name in self.widgets: 

985 self._apply_context_behavior(self.widgets[param_name], None, param_name) 

986 # Emit parameter change with unchanged container value 

987 self.parameter_changed.emit(param_name, self.parameters.get(param_name)) 

988 return 

989 except Exception: 

990 # Fall through to generic handling if type checks fail 

991 pass 

992 

993 # Generic config field reset - use context-aware reset value 

994 reset_value = self._get_reset_value(param_name) 

995 self.parameters[param_name] = reset_value 

996 

997 # Track reset fields only for lazy behavior (when reset_value is None) 

998 if reset_value is None: 

999 self.reset_fields.add(param_name) 

1000 # SHARED RESET STATE: Also add to shared reset state for coordination with nested managers 

1001 field_path = f"{self.field_id}.{param_name}" 

1002 self.shared_reset_fields.add(field_path) 

1003 else: 

1004 # For concrete values, remove from reset tracking 

1005 self.reset_fields.discard(param_name) 

1006 field_path = f"{self.field_id}.{param_name}" 

1007 self.shared_reset_fields.discard(field_path) 

1008 

1009 # Update widget with reset value 

1010 if param_name in self.widgets: 

1011 widget = self.widgets[param_name] 

1012 self.update_widget_value(widget, reset_value, param_name) 

1013 

1014 # Apply placeholder only if reset value is None (lazy behavior) 

1015 if reset_value is None: 

1016 # Build overlay from current form state 

1017 overlay = self.get_current_values() 

1018 

1019 # Build context stack: parent context + overlay 

1020 with self._build_context_stack(overlay): 

1021 placeholder_text = self.service.get_placeholder_text(param_name, self.dataclass_type) 

1022 if placeholder_text: 

1023 from openhcs.pyqt_gui.widgets.shared.widget_strategies import PyQt6WidgetEnhancer 

1024 PyQt6WidgetEnhancer.apply_placeholder_text(widget, placeholder_text) 

1025 

1026 # Emit parameter change to notify other components 

1027 self.parameter_changed.emit(param_name, reset_value) 

1028 

1029 def _get_reset_value(self, param_name: str) -> Any: 

1030 """ 

1031 Get reset value - simple and uniform for all object types. 

1032 

1033 Just use the initial value that was used to load the widget. 

1034 This works for functions, dataclasses, ABCs, anything. 

1035 """ 

1036 return self.param_defaults.get(param_name) 

1037 

1038 

1039 

1040 def get_current_values(self) -> Dict[str, Any]: 

1041 """ 

1042 Get current parameter values preserving lazy dataclass structure. 

1043 

1044 This fixes the lazy default materialization override saving issue by ensuring 

1045 that lazy dataclasses maintain their structure when values are retrieved. 

1046 """ 

1047 # CRITICAL FIX: Read actual current values from widgets, not initial parameters 

1048 current_values = {} 

1049 

1050 # Read current values from widgets 

1051 for param_name in self.parameters.keys(): 

1052 widget = self.widgets.get(param_name) 

1053 if widget: 

1054 raw_value = self.get_widget_value(widget) 

1055 # Apply unified type conversion 

1056 current_values[param_name] = self._convert_widget_value(raw_value, param_name) 

1057 else: 

1058 # Fallback to initial parameter value if no widget 

1059 current_values[param_name] = self.parameters.get(param_name) 

1060 

1061 # Checkbox validation is handled in widget creation 

1062 

1063 # Collect values from nested managers, respecting optional dataclass checkbox states 

1064 self._apply_to_nested_managers( 

1065 lambda name, manager: self._process_nested_values_if_checkbox_enabled( 

1066 name, manager, current_values 

1067 ) 

1068 ) 

1069 

1070 # Lazy dataclasses are now handled by LazyDataclassEditor, so no structure preservation needed 

1071 return current_values 

1072 

1073 def get_user_modified_values(self) -> Dict[str, Any]: 

1074 """ 

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

1076 

1077 For lazy dataclasses, this preserves lazy resolution for unmodified fields 

1078 by only returning fields where the raw value is not None. 

1079 

1080 For nested dataclasses, only include them if they have user-modified fields inside. 

1081 """ 

1082 if not hasattr(self.config, '_resolve_field_value'): 

1083 # For non-lazy dataclasses, return all current values 

1084 return self.get_current_values() 

1085 

1086 user_modified = {} 

1087 current_values = self.get_current_values() 

1088 

1089 # Only include fields where the raw value is not None 

1090 for field_name, value in current_values.items(): 

1091 if value is not None: 

1092 # CRITICAL: For nested dataclasses, extract raw values to prevent resolution pollution 

1093 # We need to rebuild the nested dataclass with only raw non-None values 

1094 from dataclasses import is_dataclass, fields as dataclass_fields 

1095 if is_dataclass(value) and not isinstance(value, type): 

1096 # Extract raw field values from nested dataclass 

1097 nested_raw_values = {} 

1098 for field in dataclass_fields(value): 

1099 raw_value = object.__getattribute__(value, field.name) 

1100 if raw_value is not None: 

1101 nested_raw_values[field.name] = raw_value 

1102 

1103 # Only include if nested dataclass has user-modified fields 

1104 # Recreate the instance with only raw values 

1105 if nested_raw_values: 

1106 user_modified[field_name] = type(value)(**nested_raw_values) 

1107 else: 

1108 # Non-dataclass field, include if not None 

1109 user_modified[field_name] = value 

1110 

1111 return user_modified 

1112 

1113 def _build_context_stack(self, overlay): 

1114 """Build nested config_context() calls for placeholder resolution. 

1115 

1116 Context stack order: 

1117 1. Thread-local global config (automatic base) 

1118 2. Parent context(s) from self.context_obj (if provided) 

1119 3. Overlay from current form values (always applied last) 

1120 

1121 Args: 

1122 overlay: Current form values (from get_current_values()) - dict or dataclass instance 

1123 

1124 Returns: 

1125 ExitStack with nested contexts 

1126 """ 

1127 from contextlib import ExitStack 

1128 from openhcs.config_framework.context_manager import config_context 

1129 

1130 stack = ExitStack() 

1131 

1132 # Apply parent context(s) if provided 

1133 if self.context_obj is not None: 

1134 if isinstance(self.context_obj, list): 

1135 # Multiple parent contexts (future: deeply nested editors) 

1136 for ctx in self.context_obj: 

1137 stack.enter_context(config_context(ctx)) 

1138 else: 

1139 # Single parent context (Step Editor: pipeline_config) 

1140 stack.enter_context(config_context(self.context_obj)) 

1141 

1142 # CRITICAL: For nested forms, include parent's USER-MODIFIED values for sibling inheritance 

1143 # This allows live placeholder updates when sibling fields change 

1144 # ONLY enable this AFTER initial form load to avoid polluting placeholders with initial widget values 

1145 parent_manager = getattr(self, '_parent_manager', None) 

1146 if (parent_manager and 

1147 hasattr(parent_manager, 'get_user_modified_values') and 

1148 hasattr(parent_manager, 'dataclass_type') and 

1149 parent_manager._initial_load_complete): # Check PARENT's initial load flag 

1150 

1151 # Get only user-modified values from parent (not all values) 

1152 # This prevents polluting context with stale/default values 

1153 parent_user_values = parent_manager.get_user_modified_values() 

1154 

1155 print(f"🔍 PARENT OVERLAY for {self.field_id}:") 

1156 print(f" Parent user values: {list(parent_user_values.keys()) if parent_user_values else 'None'}") 

1157 

1158 if parent_user_values and parent_manager.dataclass_type: 

1159 # Use lazy version of parent type to enable sibling inheritance 

1160 from openhcs.core.lazy_placeholder_simplified import LazyDefaultPlaceholderService 

1161 parent_type = parent_manager.dataclass_type 

1162 lazy_parent_type = LazyDefaultPlaceholderService._get_lazy_type_for_base(parent_type) 

1163 if lazy_parent_type: 

1164 parent_type = lazy_parent_type 

1165 

1166 # Create parent overlay with only user-modified values 

1167 parent_overlay_instance = parent_type(**parent_user_values) 

1168 stack.enter_context(config_context(parent_overlay_instance)) 

1169 

1170 # Convert overlay dict to object instance for config_context() 

1171 # config_context() expects an object with attributes, not a dict 

1172 if isinstance(overlay, dict) and self.dataclass_type: 

1173 # For functions and non-dataclass objects: use SimpleNamespace to hold parameters 

1174 # For dataclasses: instantiate normally 

1175 try: 

1176 overlay_instance = self.dataclass_type(**overlay) 

1177 except TypeError: 

1178 # Function or other non-instantiable type: use SimpleNamespace 

1179 from types import SimpleNamespace 

1180 overlay_instance = SimpleNamespace(**overlay) 

1181 else: 

1182 overlay_instance = overlay 

1183 

1184 # Always apply overlay with current form values (the object being edited) 

1185 # config_context() will filter None values and merge onto parent context 

1186 stack.enter_context(config_context(overlay_instance)) 

1187 

1188 return stack 

1189 

1190 def _on_nested_parameter_changed(self, param_name: str, value: Any) -> None: 

1191 """ 

1192 Handle parameter changes from nested forms. 

1193 

1194 When a nested form's field changes: 

1195 1. Refresh parent form's placeholders (in case they inherit from nested values) 

1196 2. Refresh all sibling nested forms' placeholders 

1197 """ 

1198 print(f"🔔 NESTED PARAM CHANGED: {param_name} = {value} in parent {self.field_id}") 

1199 print(f" Parent initial_load_complete: {self._initial_load_complete}") 

1200 

1201 # Refresh parent form's placeholders 

1202 self._refresh_all_placeholders() 

1203 

1204 # Refresh all nested managers' placeholders (including siblings) 

1205 self._apply_to_nested_managers(lambda name, manager: manager._refresh_all_placeholders()) 

1206 

1207 def _refresh_all_placeholders(self) -> None: 

1208 """Refresh placeholder text for all widgets in this form.""" 

1209 # Allow placeholder refresh for nested forms even if they're not detected as lazy dataclasses 

1210 # The placeholder service will determine if placeholders are available 

1211 if not self.dataclass_type: 

1212 return 

1213 

1214 # Build overlay from current form state 

1215 overlay = self.get_current_values() 

1216 

1217 # Build context stack: parent context + overlay 

1218 with self._build_context_stack(overlay): 

1219 for param_name, widget in self.widgets.items(): 

1220 # CRITICAL: Check current value from overlay (live form state), not stale self.parameters 

1221 current_value = overlay.get(param_name) if isinstance(overlay, dict) else getattr(overlay, param_name, None) 

1222 if current_value is None: 

1223 placeholder_text = self.service.get_placeholder_text(param_name, self.dataclass_type) 

1224 if placeholder_text: 

1225 from openhcs.pyqt_gui.widgets.shared.widget_strategies import PyQt6WidgetEnhancer 

1226 PyQt6WidgetEnhancer.apply_placeholder_text(widget, placeholder_text) 

1227 

1228 def _apply_to_nested_managers(self, operation_func: callable) -> None: 

1229 """Apply operation to all nested managers.""" 

1230 for param_name, nested_manager in self.nested_managers.items(): 

1231 operation_func(param_name, nested_manager) 

1232 

1233 def _process_nested_values_if_checkbox_enabled(self, name: str, manager: Any, current_values: Dict[str, Any]) -> None: 

1234 """Process nested values if checkbox is enabled - convert dict back to dataclass.""" 

1235 if not hasattr(manager, 'get_current_values'): 

1236 return 

1237 

1238 # Check if this is an Optional dataclass with a checkbox 

1239 param_type = self.parameter_types.get(name) 

1240 from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils 

1241 

1242 if param_type and ParameterTypeUtils.is_optional_dataclass(param_type): 

1243 # For Optional dataclasses, check if checkbox is enabled 

1244 checkbox_widget = self.widgets.get(name) 

1245 if checkbox_widget and hasattr(checkbox_widget, 'findChild'): 

1246 from PyQt6.QtWidgets import QCheckBox 

1247 checkbox = checkbox_widget.findChild(QCheckBox) 

1248 if checkbox and not checkbox.isChecked(): 

1249 # Checkbox is unchecked, set to None 

1250 current_values[name] = None 

1251 return 

1252 

1253 # Get nested values from the nested form 

1254 nested_values = manager.get_current_values() 

1255 if nested_values: 

1256 # Convert dictionary back to dataclass instance 

1257 if param_type and hasattr(param_type, '__dataclass_fields__'): 

1258 # Direct dataclass type 

1259 current_values[name] = param_type(**nested_values) 

1260 elif param_type and ParameterTypeUtils.is_optional_dataclass(param_type): 

1261 # Optional dataclass type 

1262 inner_type = ParameterTypeUtils.get_optional_inner_type(param_type) 

1263 current_values[name] = inner_type(**nested_values) 

1264 else: 

1265 # Fallback to dictionary if type conversion fails 

1266 current_values[name] = nested_values 

1267 else: 

1268 # No nested values, but checkbox might be checked - create empty instance 

1269 if param_type and ParameterTypeUtils.is_optional_dataclass(param_type): 

1270 inner_type = ParameterTypeUtils.get_optional_inner_type(param_type) 

1271 current_values[name] = inner_type() # Create with defaults 

1272