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

1211 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-04 02:09 +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, Tuple 

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

12from PyQt6.QtCore import Qt, pyqtSignal, QTimer 

13 

14# Performance monitoring 

15from openhcs.utils.performance_monitor import timer, get_monitor 

16 

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

18# Mathematical simplification: Shared dispatch tables to eliminate duplication 

19WIDGET_UPDATE_DISPATCH = [ 

20 (QComboBox, 'update_combo_box'), 

21 ('get_selected_values', 'update_checkbox_group'), 

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

23 ('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 

24 ('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 

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

26] 

27 

28WIDGET_GET_DISPATCH = [ 

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

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

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

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

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

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

35] 

36 

37logger = logging.getLogger(__name__) 

38 

39# Import our comprehensive shared infrastructure 

40from openhcs.ui.shared.parameter_form_service import ParameterFormService 

41from openhcs.ui.shared.parameter_form_config_factory import pyqt_config 

42 

43from openhcs.ui.shared.widget_creation_registry import create_pyqt6_registry 

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# SINGLE SOURCE OF TRUTH: All input widget types that can receive styling (dimming, etc.) 

52# This includes all widgets created by the widget creation registry 

53from PyQt6.QtWidgets import QLineEdit, QComboBox, QPushButton, QCheckBox, QLabel, QSpinBox, QDoubleSpinBox 

54from openhcs.pyqt_gui.widgets.shared.no_scroll_spinbox import NoScrollSpinBox, NoScrollDoubleSpinBox, NoScrollComboBox 

55from openhcs.pyqt_gui.widgets.enhanced_path_widget import EnhancedPathWidget 

56 

57# Tuple of all input widget types for findChildren() calls 

58ALL_INPUT_WIDGET_TYPES = ( 

59 QLineEdit, QComboBox, QPushButton, QCheckBox, QLabel, 

60 QSpinBox, QDoubleSpinBox, NoScrollSpinBox, NoScrollDoubleSpinBox, 

61 NoScrollComboBox, EnhancedPathWidget 

62) 

63 

64# Import OpenHCS core components 

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

66from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils 

67 

68 

69 

70 

71 

72class NoneAwareLineEdit(QLineEdit): 

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

74 

75 def get_value(self): 

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

77 text = self.text().strip() 

78 return None if text == "" else text 

79 

80 def set_value(self, value): 

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

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

83 

84 

85def _create_optimized_reset_button(field_id: str, param_name: str, reset_callback) -> 'QPushButton': 

86 """ 

87 Optimized reset button factory - reuses configuration to save ~0.15ms per button. 

88 

89 This factory creates reset buttons with consistent styling and configuration, 

90 avoiding repeated property setting overhead. 

91 """ 

92 from PyQt6.QtWidgets import QPushButton 

93 

94 button = QPushButton("Reset") 

95 button.setObjectName(f"{field_id}_reset") 

96 button.setMaximumWidth(60) # Standard reset button width 

97 button.clicked.connect(reset_callback) 

98 return button 

99 

100 

101class NoneAwareIntEdit(QLineEdit): 

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

103 

104 def __init__(self, parent=None): 

105 super().__init__(parent) 

106 # Set up input validation to only allow digits 

107 from PyQt6.QtGui import QIntValidator 

108 self.setValidator(QIntValidator()) 

109 

110 def get_value(self): 

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

112 text = self.text().strip() 

113 if text == "": 

114 return None 

115 try: 

116 return int(text) 

117 except ValueError: 

118 return None 

119 

120 def set_value(self, value): 

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

122 if value is None: 

123 self.setText("") 

124 else: 

125 self.setText(str(value)) 

126 

127 

128class ParameterFormManager(QWidget): 

129 """ 

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

131 

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

133 - Dataclasses (via dataclasses.fields()) 

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

135 - Step objects (via attribute scanning) 

136 - Any object with parameters 

137 

138 Key improvements: 

139 - Generic object introspection replaces manual parameter specification 

140 - Context-driven resolution using config_context() system 

141 - Automatic parameter extraction from object instances 

142 - Unified interface for all object types 

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

144 """ 

145 

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

147 

148 # Class-level signal for cross-window context changes 

149 # Emitted when a form changes a value that might affect other open windows 

150 # Args: (field_path, new_value, editing_object, context_object) 

151 context_value_changed = pyqtSignal(str, object, object, object) 

152 

153 # Class-level signal for cascading placeholder refreshes 

154 # Emitted when a form's placeholders are refreshed due to upstream changes 

155 # This allows downstream windows to know they should re-collect live context 

156 # Args: (editing_object, context_object) 

157 context_refreshed = pyqtSignal(object, object) 

158 

159 # Class-level registry of all active form managers for cross-window updates 

160 # CRITICAL: This is scoped per orchestrator/plate using scope_id to prevent cross-contamination 

161 _active_form_managers = [] 

162 

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

164 DEFAULT_USE_SCROLL_AREA = False 

165 DEFAULT_PLACEHOLDER_PREFIX = "Default" 

166 DEFAULT_COLOR_SCHEME = None 

167 

168 # Performance optimization: Skip expensive operations for nested configs 

169 OPTIMIZE_NESTED_WIDGETS = True 

170 

171 # Performance optimization: Async widget creation for large forms 

172 ASYNC_WIDGET_CREATION = True # Create widgets progressively to avoid UI blocking 

173 ASYNC_THRESHOLD = 5 # Minimum number of parameters to trigger async widget creation 

174 INITIAL_SYNC_WIDGETS = 10 # Number of widgets to create synchronously for fast initial render 

175 

176 @classmethod 

177 def should_use_async(cls, param_count: int) -> bool: 

178 """Determine if async widget creation should be used based on parameter count. 

179 

180 Args: 

181 param_count: Number of parameters in the form 

182 

183 Returns: 

184 True if async widget creation should be used, False otherwise 

185 """ 

186 return cls.ASYNC_WIDGET_CREATION and param_count > cls.ASYNC_THRESHOLD 

187 

188 def __init__(self, object_instance: Any, field_id: str, parent=None, context_obj=None, exclude_params: Optional[list] = None, initial_values: Optional[Dict[str, Any]] = None, parent_manager=None, read_only: bool = False, scope_id: Optional[str] = None, color_scheme=None): 

189 """ 

190 Initialize PyQt parameter form manager with generic object introspection. 

191 

192 Args: 

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

194 field_id: Unique identifier for the form 

195 parent: Optional parent widget 

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

197 exclude_params: Optional list of parameter names to exclude from the form 

198 initial_values: Optional dict of parameter values to use instead of extracted defaults 

199 parent_manager: Optional parent ParameterFormManager (for nested configs) 

200 read_only: If True, make all widgets read-only and hide reset buttons 

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

202 color_scheme: Optional color scheme for styling (uses DEFAULT_COLOR_SCHEME or default if None) 

203 """ 

204 with timer(f"ParameterFormManager.__init__ ({field_id})", threshold_ms=5.0): 

205 QWidget.__init__(self, parent) 

206 

207 # Store core configuration 

208 self.object_instance = object_instance 

209 self.field_id = field_id 

210 self.context_obj = context_obj 

211 self.exclude_params = exclude_params or [] 

212 self.read_only = read_only 

213 

214 # CRITICAL: Store scope_id for cross-window update scoping 

215 # If parent_manager exists, inherit its scope_id (nested forms belong to same orchestrator) 

216 # Otherwise use provided scope_id or None (global scope) 

217 self.scope_id = parent_manager.scope_id if parent_manager else scope_id 

218 

219 # OPTIMIZATION: Store parent manager reference early so setup_ui() can detect nested configs 

220 self._parent_manager = parent_manager 

221 

222 # Track completion callbacks for async widget creation 

223 self._on_build_complete_callbacks = [] 

224 # Track callbacks to run after placeholder refresh (for enabled styling that needs resolved values) 

225 self._on_placeholder_refresh_complete_callbacks = [] 

226 

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

228 with timer(" Service initialization", threshold_ms=1.0): 

229 self.service = ParameterFormService() 

230 

231 # Auto-extract parameters and types using generic introspection 

232 with timer(" Extract parameters from object", threshold_ms=2.0): 

233 self.parameters, self.parameter_types, self.dataclass_type = self._extract_parameters_from_object(object_instance, self.exclude_params) 

234 

235 # CRITICAL FIX: Override with initial_values if provided (for function kwargs) 

236 if initial_values: 

237 for param_name, value in initial_values.items(): 

238 if param_name in self.parameters: 

239 self.parameters[param_name] = value 

240 

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

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

243 with timer(" Analyze form structure", threshold_ms=5.0): 

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

245 self.form_structure = self.service.analyze_parameters( 

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

247 ) 

248 

249 # Auto-detect configuration settings 

250 with timer(" Auto-detect config settings", threshold_ms=1.0): 

251 self.global_config_type = self._auto_detect_global_config_type() 

252 self.placeholder_prefix = self.DEFAULT_PLACEHOLDER_PREFIX 

253 

254 # Create configuration object with auto-detected settings 

255 with timer(" Create config object", threshold_ms=1.0): 

256 # Use instance color_scheme if provided, otherwise fall back to class default or create new 

257 resolved_color_scheme = color_scheme or self.DEFAULT_COLOR_SCHEME or PyQt6ColorScheme() 

258 config = pyqt_config( 

259 field_id=field_id, 

260 color_scheme=resolved_color_scheme, 

261 function_target=object_instance, # Use object_instance as function_target 

262 use_scroll_area=self.DEFAULT_USE_SCROLL_AREA 

263 ) 

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

265 config.parameter_info = parameter_info 

266 config.dataclass_type = self.dataclass_type 

267 config.global_config_type = self.global_config_type 

268 config.placeholder_prefix = self.placeholder_prefix 

269 

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

271 config.is_lazy_dataclass = self._is_lazy_dataclass() 

272 config.is_global_config_editing = not config.is_lazy_dataclass 

273 

274 # Initialize core attributes 

275 with timer(" Initialize core attributes", threshold_ms=1.0): 

276 self.config = config 

277 self.param_defaults = self._extract_parameter_defaults() 

278 

279 # Initialize tracking attributes 

280 self.widgets = {} 

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

282 self.nested_managers = {} 

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

284 

285 # Track which fields have been explicitly set by users 

286 self._user_set_fields: set = set() 

287 

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

289 self._initial_load_complete = False 

290 

291 # OPTIMIZATION: Block cross-window updates during batch operations (e.g., reset_all) 

292 self._block_cross_window_updates = False 

293 

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

295 if hasattr(parent, 'shared_reset_fields'): 

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

297 self.shared_reset_fields = parent.shared_reset_fields 

298 else: 

299 # Root manager: create new shared reset state 

300 self.shared_reset_fields = set() 

301 

302 # Store backward compatibility attributes 

303 self.parameter_info = config.parameter_info 

304 self.use_scroll_area = config.use_scroll_area 

305 self.function_target = config.function_target 

306 self.color_scheme = config.color_scheme 

307 

308 # Form structure already analyzed above using UnifiedParameterAnalyzer descriptions 

309 

310 # Get widget creator from registry 

311 self._widget_creator = create_pyqt6_registry() 

312 

313 # Context system handles updates automatically 

314 self._context_event_coordinator = None 

315 

316 # Set up UI 

317 with timer(" Setup UI (widget creation)", threshold_ms=10.0): 

318 self.setup_ui() 

319 

320 # Connect parameter changes to live placeholder updates 

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

322 # CRITICAL: Don't refresh during reset operations - reset handles placeholders itself 

323 # CRITICAL: Always use live context from other open windows for placeholder resolution 

324 # CRITICAL: Don't refresh when 'enabled' field changes - it's styling-only and doesn't affect placeholders 

325 self.parameter_changed.connect(lambda param_name, value: self._refresh_with_live_context() if not getattr(self, '_in_reset', False) and param_name != 'enabled' else None) 

326 

327 # UNIVERSAL ENABLED FIELD BEHAVIOR: Watch for 'enabled' parameter changes and apply styling 

328 # This works for any form (function parameters, dataclass fields, etc.) that has an 'enabled' parameter 

329 # When enabled resolves to False, apply visual dimming WITHOUT blocking input 

330 if 'enabled' in self.parameters: 

331 self.parameter_changed.connect(self._on_enabled_field_changed_universal) 

332 # CRITICAL: Apply initial styling based on current enabled value 

333 # This ensures styling is applied on window open, not just when toggled 

334 # Register callback to run AFTER placeholders are refreshed (not before) 

335 # because enabled styling needs the resolved placeholder value from the widget 

336 self._on_placeholder_refresh_complete_callbacks.append(self._apply_initial_enabled_styling) 

337 

338 # Register this form manager for cross-window updates (only root managers, not nested) 

339 if self._parent_manager is None: 

340 # CRITICAL: Store initial values when window opens for cancel/revert behavior 

341 # When user cancels, other windows should revert to these initial values, not current edited values 

342 self._initial_values_on_open = self.get_user_modified_values() if hasattr(self.config, '_resolve_field_value') else self.get_current_values() 

343 

344 # Connect parameter_changed to emit cross-window context changes 

345 self.parameter_changed.connect(self._emit_cross_window_change) 

346 

347 # Connect this instance's signal to all existing instances 

348 for existing_manager in self._active_form_managers: 

349 # Connect this instance to existing instances 

350 self.context_value_changed.connect(existing_manager._on_cross_window_context_changed) 

351 self.context_refreshed.connect(existing_manager._on_cross_window_context_refreshed) 

352 # Connect existing instances to this instance 

353 existing_manager.context_value_changed.connect(self._on_cross_window_context_changed) 

354 existing_manager.context_refreshed.connect(self._on_cross_window_context_refreshed) 

355 

356 # Add this instance to the registry 

357 self._active_form_managers.append(self) 

358 

359 # Debounce timer for cross-window placeholder refresh 

360 self._cross_window_refresh_timer = None 

361 

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

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

364 with timer(" Detect user-set fields", threshold_ms=1.0): 

365 from dataclasses import is_dataclass 

366 if is_dataclass(object_instance): 

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

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

369 if raw_value is not None: 

370 self._user_set_fields.add(field_name) 

371 

372 # OPTIMIZATION: Skip placeholder refresh for nested configs - parent will handle it 

373 # This saves ~5-10ms per nested config × 20 configs = 100-200ms total 

374 is_nested = self._parent_manager is not None 

375 

376 # CRITICAL FIX: Don't refresh placeholders here - they need to be refreshed AFTER 

377 # async widget creation completes. The refresh will be triggered by the build_form() 

378 # completion callback to ensure all widgets (including nested async forms) are ready. 

379 # This fixes the issue where optional dataclass placeholders resolve with wrong context 

380 # because they refresh before nested managers are fully initialized. 

381 

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

383 self._initial_load_complete = True 

384 if not is_nested: 

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

386 

387 # Connect to destroyed signal for cleanup 

388 self.destroyed.connect(self._on_destroyed) 

389 

390 # CRITICAL: Refresh placeholders with live context after initial load 

391 # This ensures new windows immediately show live values from other open windows 

392 is_root_global_config = (self.config.is_global_config_editing and 

393 self.global_config_type is not None and 

394 self.context_obj is None) 

395 if is_root_global_config: 

396 # For root GlobalPipelineConfig, refresh with sibling inheritance 

397 with timer(" Root global config sibling inheritance refresh", threshold_ms=10.0): 

398 self._refresh_all_placeholders() 

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

400 else: 

401 # For other windows (PipelineConfig, Step), refresh with live context from other windows 

402 with timer(" Initial live context refresh", threshold_ms=10.0): 

403 self._refresh_with_live_context() 

404 

405 # ==================== GENERIC OBJECT INTROSPECTION METHODS ==================== 

406 

407 def _extract_parameters_from_object(self, obj: Any, exclude_params: Optional[list] = None) -> Tuple[Dict[str, Any], Dict[str, Type], Type]: 

408 """ 

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

410 

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

412 

413 Args: 

414 obj: Object to extract parameters from 

415 exclude_params: Optional list of parameter names to exclude 

416 """ 

417 from openhcs.introspection.unified_parameter_analyzer import UnifiedParameterAnalyzer 

418 

419 # Use unified analyzer for all object types with exclusions 

420 param_info_dict = UnifiedParameterAnalyzer.analyze(obj, exclude_params=exclude_params) 

421 

422 parameters = {} 

423 parameter_types = {} 

424 

425 # CRITICAL FIX: Store parameter descriptions for docstring display 

426 self._parameter_descriptions = {} 

427 

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

429 # Use the values already extracted by UnifiedParameterAnalyzer 

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

431 parameters[name] = param_info.default_value 

432 parameter_types[name] = param_info.param_type 

433 

434 # LOG PARAMETER TYPES 

435 # CRITICAL FIX: Preserve parameter descriptions for help display 

436 if param_info.description: 

437 self._parameter_descriptions[name] = param_info.description 

438 

439 return parameters, parameter_types, type(obj) 

440 

441 # ==================== WIDGET CREATION METHODS ==================== 

442 

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

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

445 from openhcs.config_framework import get_base_config_type 

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

447 

448 

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

450 """ 

451 Extract parameter defaults from the object. 

452 

453 For reset functionality: returns the SIGNATURE defaults, not current instance values. 

454 - For functions: signature defaults 

455 - For dataclasses: field defaults from class definition 

456 - For any object: constructor parameter defaults from class definition 

457 """ 

458 from openhcs.introspection.unified_parameter_analyzer import UnifiedParameterAnalyzer 

459 

460 # CRITICAL FIX: For reset functionality, we need SIGNATURE defaults, not instance values 

461 # Analyze the CLASS/TYPE, not the instance, to get signature defaults 

462 if callable(self.object_instance) and not dataclasses.is_dataclass(self.object_instance): 

463 # For functions/callables, analyze directly (not their type) 

464 analysis_target = self.object_instance 

465 elif dataclasses.is_dataclass(self.object_instance): 

466 # For dataclass instances, analyze the type to get field defaults 

467 analysis_target = type(self.object_instance) 

468 elif hasattr(self.object_instance, '__class__'): 

469 # For regular object instances (like steps), analyze the class to get constructor defaults 

470 analysis_target = type(self.object_instance) 

471 else: 

472 # For types/classes, analyze directly 

473 analysis_target = self.object_instance 

474 

475 # Use unified analyzer to get signature defaults with same exclusions 

476 param_info_dict = UnifiedParameterAnalyzer.analyze(analysis_target, exclude_params=self.exclude_params) 

477 

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

479 

480 def _is_lazy_dataclass(self) -> bool: 

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

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

483 return True 

484 if self.dataclass_type: 

485 from openhcs.core.lazy_placeholder_simplified import LazyDefaultPlaceholderService 

486 return LazyDefaultPlaceholderService.has_lazy_resolution(self.dataclass_type) 

487 return False 

488 

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

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

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

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

493 

494 if widget is None: 

495 from PyQt6.QtWidgets import QLabel 

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

497 

498 return widget 

499 

500 

501 

502 

503 @classmethod 

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

505 placeholder_prefix: str = "Default", 

506 parent=None, use_scroll_area: bool = True, 

507 function_target=None, color_scheme=None, 

508 force_show_all_fields: bool = False, 

509 global_config_type: Optional[Type] = None, 

510 context_event_coordinator=None, context_obj=None, 

511 scope_id: Optional[str] = None): 

512 """ 

513 SIMPLIFIED: Create ParameterFormManager using new generic constructor. 

514 

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

516 all object types automatically through generic introspection. 

517 

518 Args: 

519 dataclass_instance: The dataclass instance to edit 

520 field_id: Unique identifier for the form 

521 context_obj: Context object for placeholder resolution 

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

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

524 

525 Returns: 

526 ParameterFormManager configured for any object type 

527 """ 

528 # Validate input 

529 from dataclasses import is_dataclass 

530 if not is_dataclass(dataclass_instance): 

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

532 

533 # Use simplified constructor with automatic parameter extraction 

534 # CRITICAL: Do NOT default context_obj to dataclass_instance 

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

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

537 return cls( 

538 object_instance=dataclass_instance, 

539 field_id=field_id, 

540 parent=parent, 

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

542 scope_id=scope_id, 

543 color_scheme=color_scheme # Pass through color_scheme parameter 

544 ) 

545 

546 @classmethod 

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

548 """ 

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

550 

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

552 - Dataclass instances and types 

553 - ABC constructors and functions 

554 - Step objects with config attributes 

555 - Any object with parameters 

556 

557 Args: 

558 object_instance: Any object to build form for 

559 field_id: Unique identifier for the form 

560 parent: Optional parent widget 

561 context_obj: Context object for placeholder resolution 

562 

563 Returns: 

564 ParameterFormManager configured for the object type 

565 """ 

566 return cls( 

567 object_instance=object_instance, 

568 field_id=field_id, 

569 parent=parent, 

570 context_obj=context_obj 

571 ) 

572 

573 

574 

575 def setup_ui(self): 

576 """Set up the UI layout.""" 

577 from openhcs.utils.performance_monitor import timer 

578 

579 # OPTIMIZATION: Skip expensive operations for nested configs 

580 is_nested = hasattr(self, '_parent_manager') 

581 

582 with timer(" Layout setup", threshold_ms=1.0): 

583 layout = QVBoxLayout(self) 

584 # Apply configurable layout settings 

585 layout.setSpacing(CURRENT_LAYOUT.main_layout_spacing) 

586 layout.setContentsMargins(*CURRENT_LAYOUT.main_layout_margins) 

587 

588 # OPTIMIZATION: Skip style generation for nested configs (inherit from parent) 

589 # This saves ~1-2ms per nested config × 20 configs = 20-40ms 

590 # ALSO: Skip if parent is a ConfigWindow (which handles styling itself) 

591 qt_parent = self.parent() 

592 parent_is_config_window = qt_parent is not None and qt_parent.__class__.__name__ == 'ConfigWindow' 

593 should_apply_styling = not is_nested and not parent_is_config_window 

594 if should_apply_styling: 

595 with timer(" Style generation", threshold_ms=1.0): 

596 from openhcs.pyqt_gui.shared.style_generator import StyleSheetGenerator 

597 style_gen = StyleSheetGenerator(self.color_scheme) 

598 self.setStyleSheet(style_gen.generate_config_window_style()) 

599 

600 # Build form content 

601 with timer(" Build form", threshold_ms=5.0): 

602 form_widget = self.build_form() 

603 

604 # OPTIMIZATION: Never add scroll areas for nested configs 

605 # This saves ~2ms per nested config × 20 configs = 40ms 

606 with timer(" Add scroll area", threshold_ms=1.0): 

607 if self.config.use_scroll_area and not is_nested: 

608 scroll_area = QScrollArea() 

609 scroll_area.setWidgetResizable(True) 

610 scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) 

611 scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) 

612 scroll_area.setWidget(form_widget) 

613 layout.addWidget(scroll_area) 

614 else: 

615 layout.addWidget(form_widget) 

616 

617 def build_form(self) -> QWidget: 

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

619 from openhcs.utils.performance_monitor import timer 

620 

621 with timer(" Create content widget", threshold_ms=1.0): 

622 content_widget = QWidget() 

623 content_layout = QVBoxLayout(content_widget) 

624 content_layout.setSpacing(CURRENT_LAYOUT.content_layout_spacing) 

625 content_layout.setContentsMargins(*CURRENT_LAYOUT.content_layout_margins) 

626 

627 # DELEGATE TO SERVICE LAYER: Use analyzed form structure 

628 param_count = len(self.form_structure.parameters) 

629 if self.should_use_async(param_count): 

630 # Hybrid sync/async widget creation for large forms 

631 # Create first N widgets synchronously for fast initial render, then remaining async 

632 with timer(f" Hybrid widget creation: {param_count} total widgets", threshold_ms=1.0): 

633 # Track pending nested managers for async completion 

634 # Only root manager needs to track this, and only for nested managers that will use async 

635 is_root = self._parent_manager is None 

636 if is_root: 

637 self._pending_nested_managers = {} 

638 

639 # Split parameters into sync and async batches 

640 sync_params = self.form_structure.parameters[:self.INITIAL_SYNC_WIDGETS] 

641 async_params = self.form_structure.parameters[self.INITIAL_SYNC_WIDGETS:] 

642 

643 # Create initial widgets synchronously for fast render 

644 if sync_params: 

645 with timer(f" Create {len(sync_params)} initial widgets (sync)", threshold_ms=5.0): 

646 for param_info in sync_params: 

647 widget = self._create_widget_for_param(param_info) 

648 content_layout.addWidget(widget) 

649 

650 # Apply placeholders to initial widgets immediately for fast visual feedback 

651 # These will be refreshed again at the end when all widgets are ready 

652 with timer(f" Initial placeholder refresh ({len(sync_params)} widgets)", threshold_ms=5.0): 

653 self._refresh_all_placeholders() 

654 

655 def on_async_complete(): 

656 """Called when all async widgets are created for THIS manager.""" 

657 # CRITICAL FIX: Don't trigger styling callbacks yet! 

658 # They need to wait until ALL nested managers complete their async widget creation 

659 # Otherwise findChildren() will return empty lists for nested forms still being built 

660 

661 # CRITICAL FIX: Only root manager refreshes placeholders, and only after ALL nested managers are done 

662 is_nested = self._parent_manager is not None 

663 if is_nested: 

664 # Nested manager - notify root that we're done 

665 # Find root manager 

666 root_manager = self._parent_manager 

667 while root_manager._parent_manager is not None: 

668 root_manager = root_manager._parent_manager 

669 if hasattr(root_manager, '_on_nested_manager_complete'): 

670 root_manager._on_nested_manager_complete(self) 

671 else: 

672 # Root manager - check if all nested managers are done 

673 if len(self._pending_nested_managers) == 0: 

674 # STEP 1: Apply all styling callbacks now that ALL widgets exist 

675 with timer(f" Apply styling callbacks", threshold_ms=5.0): 

676 self._apply_all_styling_callbacks() 

677 

678 # STEP 2: Refresh placeholders for ALL widgets (including initial sync widgets) 

679 with timer(f" Complete placeholder refresh (all widgets ready)", threshold_ms=10.0): 

680 self._refresh_all_placeholders() 

681 with timer(f" Nested placeholder refresh (all widgets ready)", threshold_ms=5.0): 

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

683 

684 # Create remaining widgets asynchronously 

685 if async_params: 

686 self._create_widgets_async(content_layout, async_params, on_complete=on_async_complete) 

687 else: 

688 # All widgets were created synchronously, call completion immediately 

689 on_async_complete() 

690 else: 

691 # Sync widget creation for small forms (<=5 parameters) 

692 with timer(f" Create {len(self.form_structure.parameters)} parameter widgets", threshold_ms=5.0): 

693 for param_info in self.form_structure.parameters: 

694 with timer(f" Create widget for {param_info.name} ({'nested' if param_info.is_nested else 'regular'})", threshold_ms=2.0): 

695 widget = self._create_widget_for_param(param_info) 

696 content_layout.addWidget(widget) 

697 

698 # For sync creation, apply styling callbacks and refresh placeholders 

699 # CRITICAL: Order matters - placeholders must be resolved before enabled styling 

700 is_nested = self._parent_manager is not None 

701 if not is_nested: 

702 # STEP 1: Apply styling callbacks (optional dataclass None-state dimming) 

703 with timer(" Apply styling callbacks (sync)", threshold_ms=5.0): 

704 for callback in self._on_build_complete_callbacks: 

705 callback() 

706 self._on_build_complete_callbacks.clear() 

707 

708 # STEP 2: Refresh placeholders (resolve inherited values) 

709 with timer(" Initial placeholder refresh (sync)", threshold_ms=10.0): 

710 self._refresh_all_placeholders() 

711 with timer(" Nested placeholder refresh (sync)", threshold_ms=5.0): 

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

713 

714 # STEP 3: Apply post-placeholder callbacks (enabled styling that needs resolved values) 

715 with timer(" Apply post-placeholder callbacks (sync)", threshold_ms=5.0): 

716 for callback in self._on_placeholder_refresh_complete_callbacks: 

717 callback() 

718 self._on_placeholder_refresh_complete_callbacks.clear() 

719 # Also apply for nested managers 

720 self._apply_to_nested_managers(lambda name, manager: manager._apply_all_post_placeholder_callbacks()) 

721 

722 # STEP 4: Refresh enabled styling (after placeholders are resolved) 

723 with timer(" Enabled styling refresh (sync)", threshold_ms=5.0): 

724 self._apply_to_nested_managers(lambda name, manager: manager._refresh_enabled_styling()) 

725 else: 

726 # Nested managers just apply their callbacks 

727 for callback in self._on_build_complete_callbacks: 

728 callback() 

729 self._on_build_complete_callbacks.clear() 

730 

731 return content_widget 

732 

733 def _create_widget_for_param(self, param_info): 

734 """Create widget for a single parameter based on its type.""" 

735 if param_info.is_optional and param_info.is_nested: 

736 # Optional[Dataclass]: show checkbox 

737 return self._create_optional_dataclass_widget(param_info) 

738 elif param_info.is_nested: 

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

740 return self._create_nested_dataclass_widget(param_info) 

741 else: 

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

743 return self._create_regular_parameter_widget(param_info) 

744 

745 def _create_widgets_async(self, layout, param_infos, on_complete=None): 

746 """Create widgets asynchronously to avoid blocking the UI. 

747 

748 Args: 

749 layout: Layout to add widgets to 

750 param_infos: List of parameter info objects 

751 on_complete: Optional callback to run when all widgets are created 

752 """ 

753 # Create widgets in batches using QTimer to yield to event loop 

754 batch_size = 3 # Create 3 widgets at a time 

755 index = 0 

756 

757 def create_next_batch(): 

758 nonlocal index 

759 batch_end = min(index + batch_size, len(param_infos)) 

760 

761 for i in range(index, batch_end): 

762 param_info = param_infos[i] 

763 widget = self._create_widget_for_param(param_info) 

764 layout.addWidget(widget) 

765 

766 index = batch_end 

767 

768 # Schedule next batch if there are more widgets 

769 if index < len(param_infos): 

770 QTimer.singleShot(0, create_next_batch) 

771 elif on_complete: 

772 # All widgets created - defer completion callback to next event loop tick 

773 # This ensures Qt has processed all layout updates and widgets are findable 

774 QTimer.singleShot(0, on_complete) 

775 

776 # Start creating widgets 

777 QTimer.singleShot(0, create_next_batch) 

778 

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

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

781 from openhcs.utils.performance_monitor import timer 

782 

783 with timer(f" Get display info for {param_info.name}", threshold_ms=0.5): 

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

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

786 

787 with timer(" Create container/layout", threshold_ms=0.5): 

788 container = QWidget() 

789 layout = QHBoxLayout(container) 

790 layout.setSpacing(CURRENT_LAYOUT.parameter_row_spacing) 

791 layout.setContentsMargins(*CURRENT_LAYOUT.parameter_row_margins) 

792 

793 # Label 

794 with timer(f" Create label for {param_info.name}", threshold_ms=0.5): 

795 label = LabelWithHelp( 

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

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

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

799 ) 

800 layout.addWidget(label) 

801 

802 # Widget 

803 with timer(f" Create actual widget for {param_info.name}", threshold_ms=0.5): 

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

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

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

807 layout.addWidget(widget, 1) 

808 

809 # Reset button (optimized factory) - skip if read-only 

810 if not self.read_only: 

811 with timer(" Create reset button", threshold_ms=0.5): 

812 reset_button = _create_optimized_reset_button( 

813 self.config.field_id, 

814 param_info.name, 

815 lambda: self.reset_parameter(param_info.name) 

816 ) 

817 layout.addWidget(reset_button) 

818 self.reset_buttons[param_info.name] = reset_button 

819 

820 # Store widgets and connect signals 

821 with timer(" Store and connect signals", threshold_ms=0.5): 

822 self.widgets[param_info.name] = widget 

823 # DEBUG: Log what we're storing 

824 import logging 

825 logger = logging.getLogger(__name__) 

826 if param_info.is_nested: 

827 logger.info(f"[STORE_WIDGET] Storing nested widget: param_info.name={param_info.name}, widget={widget.__class__.__name__}") 

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

829 

830 # PERFORMANCE OPTIMIZATION: Don't apply context behavior during widget creation 

831 # The completion callback (_refresh_all_placeholders) will handle it when all widgets exist 

832 # This eliminates 400+ expensive calls with incomplete context during async creation 

833 # and fixes the wrong placeholder bug (context is complete at the end) 

834 

835 # Make widget read-only if in read-only mode 

836 if self.read_only: 

837 self._make_widget_readonly(widget) 

838 

839 return container 

840 

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

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

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

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

845 

846 container = QWidget() 

847 layout = QVBoxLayout(container) 

848 

849 # Checkbox (using NoneAwareCheckBox for consistency) 

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

851 checkbox = NoneAwareCheckBox() 

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

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

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

855 checkbox.setChecked(current_value is not None) 

856 layout.addWidget(checkbox) 

857 

858 # Get inner type for the actual widget 

859 inner_type = ParameterTypeUtils.get_optional_inner_type(param_info.type) 

860 

861 # Create the actual widget for the inner type 

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

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

864 layout.addWidget(inner_widget) 

865 

866 # Connect checkbox to enable/disable the inner widget 

867 def on_checkbox_changed(checked): 

868 inner_widget.setEnabled(checked) 

869 if checked: 

870 # Set to default value for the inner type 

871 if inner_type == str: 

872 default_value = "" 

873 elif inner_type == int: 

874 default_value = 0 

875 elif inner_type == float: 

876 default_value = 0.0 

877 elif inner_type == bool: 

878 default_value = False 

879 else: 

880 default_value = None 

881 self.update_parameter(param_info.name, default_value) 

882 else: 

883 self.update_parameter(param_info.name, None) 

884 

885 checkbox.toggled.connect(on_checkbox_changed) 

886 return container 

887 

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

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

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

891 

892 # Use the existing create_widget method 

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

894 if widget: 

895 return widget 

896 

897 # Fallback to basic text input 

898 from PyQt6.QtWidgets import QLineEdit 

899 fallback_widget = QLineEdit() 

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

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

902 return fallback_widget 

903 

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

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

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

907 

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

909 unwrapped_type = ( 

910 ParameterTypeUtils.get_optional_inner_type(param_info.type) 

911 if ParameterTypeUtils.is_optional_dataclass(param_info.type) 

912 else param_info.type 

913 ) 

914 

915 group_box = GroupBoxWithHelp( 

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

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

918 ) 

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

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

921 

922 nested_form = nested_manager.build_form() 

923 

924 # Add Reset All button to GroupBox title 

925 if not self.read_only: 

926 from PyQt6.QtWidgets import QPushButton 

927 reset_all_button = QPushButton("Reset All") 

928 reset_all_button.setMaximumWidth(80) 

929 reset_all_button.setToolTip(f"Reset all parameters in {display_info['field_label']} to defaults") 

930 reset_all_button.clicked.connect(lambda: nested_manager.reset_all_parameters()) 

931 group_box.addTitleWidget(reset_all_button) 

932 

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

934 group_box.addWidget(nested_form) 

935 

936 self.nested_managers[param_info.name] = nested_manager 

937 

938 # CRITICAL: Store the GroupBox in self.widgets so enabled handler can find it 

939 self.widgets[param_info.name] = group_box 

940 

941 # DEBUG: Log what we're storing 

942 import logging 

943 logger = logging.getLogger(__name__) 

944 logger.info(f"[CREATE_NESTED_DATACLASS] param_info.name={param_info.name}, nested_manager.field_id={nested_manager.field_id}, stored GroupBoxWithHelp in self.widgets") 

945 

946 return group_box 

947 

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

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

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

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

952 

953 # Get the unwrapped type for the GroupBox 

954 unwrapped_type = ParameterTypeUtils.get_optional_inner_type(param_info.type) 

955 

956 # Create GroupBox with custom title widget that includes checkbox 

957 from PyQt6.QtGui import QFont 

958 group_box = QGroupBox() 

959 

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

961 title_widget = QWidget() 

962 title_layout = QHBoxLayout(title_widget) 

963 title_layout.setSpacing(5) 

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

965 

966 # Checkbox (compact, no text) 

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

968 checkbox = NoneAwareCheckBox() 

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

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

971 # CRITICAL: Title checkbox ONLY controls None vs Instance, NOT the enabled field 

972 # Checkbox is checked if config exists (regardless of enabled field value) 

973 checkbox.setChecked(current_value is not None) 

974 checkbox.setMaximumWidth(20) 

975 title_layout.addWidget(checkbox) 

976 

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

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

979 title_font = QFont() 

980 title_font.setBold(True) 

981 title_label.setFont(title_font) 

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

983 title_label.setCursor(Qt.CursorShape.PointingHandCursor) 

984 title_layout.addWidget(title_label) 

985 

986 title_layout.addStretch() 

987 

988 # Reset All button (before help button) 

989 if not self.read_only: 

990 from PyQt6.QtWidgets import QPushButton 

991 reset_all_button = QPushButton("Reset") 

992 reset_all_button.setMaximumWidth(60) 

993 reset_all_button.setFixedHeight(20) 

994 reset_all_button.setToolTip(f"Reset all parameters in {display_info['checkbox_label']} to defaults") 

995 # Will be connected after nested_manager is created 

996 title_layout.addWidget(reset_all_button) 

997 

998 # Help button (matches GroupBoxWithHelp) 

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

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

1001 help_btn.setMaximumWidth(25) 

1002 help_btn.setMaximumHeight(20) 

1003 title_layout.addWidget(help_btn) 

1004 

1005 # Set the custom title widget as the GroupBox title 

1006 group_box.setLayout(QVBoxLayout()) 

1007 group_box.layout().setSpacing(0) 

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

1009 group_box.layout().addWidget(title_widget) 

1010 

1011 # Create nested form 

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

1013 nested_form = nested_manager.build_form() 

1014 nested_form.setEnabled(current_value is not None) 

1015 group_box.layout().addWidget(nested_form) 

1016 

1017 self.nested_managers[param_info.name] = nested_manager 

1018 

1019 # Connect reset button to nested manager's reset_all_parameters 

1020 if not self.read_only: 

1021 reset_all_button.clicked.connect(lambda: nested_manager.reset_all_parameters()) 

1022 

1023 # Connect checkbox to enable/disable with visual feedback 

1024 def on_checkbox_changed(checked): 

1025 # Title checkbox controls whether config exists (None vs instance) 

1026 # When checked: config exists, inputs are editable 

1027 # When unchecked: config is None, inputs are blocked 

1028 # CRITICAL: This is INDEPENDENT of the enabled field - they both use similar visual styling but are separate concepts 

1029 nested_form.setEnabled(checked) 

1030 

1031 if checked: 

1032 # Config exists - create instance preserving the enabled field value 

1033 current_param_value = self.parameters.get(param_info.name) 

1034 if current_param_value is None: 

1035 # Create new instance with default enabled value (from dataclass default) 

1036 new_instance = unwrapped_type() 

1037 self.update_parameter(param_info.name, new_instance) 

1038 else: 

1039 # Instance already exists, no need to modify it 

1040 pass 

1041 

1042 # Remove dimming for None state (title only) 

1043 # CRITICAL: Don't clear graphics effects on nested form widgets - let enabled field handler manage them 

1044 title_label.setStyleSheet("") 

1045 help_btn.setEnabled(True) 

1046 

1047 # CRITICAL: Trigger the nested config's enabled handler to apply enabled styling 

1048 # This ensures that when toggling from None to Instance, the enabled styling is applied 

1049 # based on the instance's enabled field value 

1050 if hasattr(nested_manager, '_apply_initial_enabled_styling'): 

1051 from PyQt6.QtCore import QTimer 

1052 QTimer.singleShot(0, nested_manager._apply_initial_enabled_styling) 

1053 else: 

1054 # Config is None - set to None and block inputs 

1055 self.update_parameter(param_info.name, None) 

1056 

1057 # Apply dimming for None state 

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

1059 help_btn.setEnabled(True) 

1060 from PyQt6.QtWidgets import QGraphicsOpacityEffect 

1061 for widget in nested_form.findChildren(ALL_INPUT_WIDGET_TYPES): 

1062 effect = QGraphicsOpacityEffect() 

1063 effect.setOpacity(0.4) 

1064 widget.setGraphicsEffect(effect) 

1065 

1066 checkbox.toggled.connect(on_checkbox_changed) 

1067 

1068 # NOTE: Enabled field styling is now handled by the universal _on_enabled_field_changed_universal handler 

1069 # which is connected in __init__ for any form that has an 'enabled' parameter 

1070 

1071 # Apply initial styling after nested form is fully constructed 

1072 # CRITICAL FIX: Only register callback, don't call immediately 

1073 # Calling immediately schedules QTimer callbacks that block async widget creation 

1074 # The callback will be triggered after all async batches complete 

1075 def apply_initial_styling(): 

1076 # Apply styling directly without QTimer delay 

1077 # The callback is already deferred by the async completion mechanism 

1078 on_checkbox_changed(checkbox.isChecked()) 

1079 

1080 # Register callback with parent manager (will be called after all widgets are created) 

1081 self._on_build_complete_callbacks.append(apply_initial_styling) 

1082 

1083 self.widgets[param_info.name] = group_box 

1084 return group_box 

1085 

1086 

1087 

1088 

1089 

1090 

1091 

1092 

1093 

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

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

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

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

1098 if self.dataclass_type is None: 

1099 field_path = param_name 

1100 else: 

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

1102 

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

1104 # The constructor will handle parameter extraction automatically 

1105 if current_value is not None: 

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

1107 import dataclasses 

1108 # Unwrap Optional type to get actual dataclass type 

1109 from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils 

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

1111 

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

1113 # Convert dict back to dataclass instance 

1114 object_instance = actual_type(**current_value) 

1115 else: 

1116 object_instance = current_value 

1117 else: 

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

1119 import dataclasses 

1120 # Unwrap Optional type to get actual dataclass type 

1121 from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils 

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

1123 

1124 if dataclasses.is_dataclass(actual_type): 

1125 object_instance = actual_type() 

1126 else: 

1127 object_instance = actual_type 

1128 

1129 # DELEGATE TO NEW CONSTRUCTOR: Use simplified constructor 

1130 nested_manager = ParameterFormManager( 

1131 object_instance=object_instance, 

1132 field_id=field_path, 

1133 parent=self, 

1134 context_obj=self.context_obj, 

1135 parent_manager=self # Pass parent manager so setup_ui() can detect nested configs 

1136 ) 

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

1138 try: 

1139 nested_manager.config.is_lazy_dataclass = self.config.is_lazy_dataclass 

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

1141 except Exception: 

1142 pass 

1143 

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

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

1146 nested_manager.parameter_changed.connect(self._on_nested_parameter_changed) 

1147 

1148 # Store nested manager 

1149 self.nested_managers[param_name] = nested_manager 

1150 

1151 # CRITICAL: Register with root manager if it's tracking async completion 

1152 # Only register if this nested manager will use async widget creation 

1153 # Use centralized logic to determine if async will be used 

1154 import dataclasses 

1155 from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils 

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

1157 if dataclasses.is_dataclass(actual_type): 

1158 param_count = len(dataclasses.fields(actual_type)) 

1159 

1160 # Find root manager 

1161 root_manager = self 

1162 while root_manager._parent_manager is not None: 

1163 root_manager = root_manager._parent_manager 

1164 

1165 # Register with root if it's tracking and this will use async (centralized logic) 

1166 if self.should_use_async(param_count) and hasattr(root_manager, '_pending_nested_managers'): 

1167 # Use a unique key that includes the full path to avoid duplicates 

1168 unique_key = f"{self.field_id}.{param_name}" 

1169 root_manager._pending_nested_managers[unique_key] = nested_manager 

1170 

1171 return nested_manager 

1172 

1173 

1174 

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

1176 """ 

1177 Convert widget value to proper type. 

1178 

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

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

1181 """ 

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

1183 

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

1185 

1186 # PyQt-specific type conversions first 

1187 converted_value = convert_widget_value_to_type(value, param_type) 

1188 

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

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

1191 

1192 return converted_value 

1193 

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

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

1196 

1197 # Convert value using unified conversion method 

1198 converted_value = self._convert_widget_value(value, param_name) 

1199 

1200 # Update parameter in data model 

1201 self.parameters[param_name] = converted_value 

1202 

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

1204 # This prevents placeholder updates from destroying user values 

1205 self._user_set_fields.add(param_name) 

1206 

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

1208 self.parameter_changed.emit(param_name, converted_value) 

1209 

1210 

1211 

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

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

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

1215 

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

1217 if not skip_context_behavior: 

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

1219 

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

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

1222 for matcher, updater in WIDGET_UPDATE_DISPATCH: 

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

1224 if isinstance(updater, str): 

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

1226 else: 

1227 updater(widget, value) 

1228 return 

1229 

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

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

1232 from PyQt6.QtWidgets import QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox, QTextEdit 

1233 

1234 if isinstance(widget, QLineEdit): 

1235 widget.clear() 

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

1237 widget.setValue(widget.minimum()) 

1238 elif isinstance(widget, QComboBox): 

1239 widget.setCurrentIndex(-1) # No selection 

1240 elif isinstance(widget, QCheckBox): 

1241 widget.setChecked(False) 

1242 elif isinstance(widget, QTextEdit): 

1243 widget.clear() 

1244 else: 

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

1246 if hasattr(widget, 'clear'): 

1247 widget.clear() 

1248 

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

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

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

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

1253 

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

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

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

1257 # Functional: reset all, then set selected 

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

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

1260 

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

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

1263 widget.blockSignals(True) 

1264 operation() 

1265 widget.blockSignals(False) 

1266 

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

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

1269 if not param_name or not self.dataclass_type: 

1270 return 

1271 

1272 if value is None: 

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

1274 # The placeholder service will determine if placeholders are available 

1275 

1276 # Build overlay from current form state 

1277 overlay = self.get_current_values() 

1278 

1279 # Build context stack: parent context + overlay 

1280 with self._build_context_stack(overlay): 

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

1282 if placeholder_text: 

1283 PyQt6WidgetEnhancer.apply_placeholder_text(widget, placeholder_text) 

1284 elif value is not None: 

1285 PyQt6WidgetEnhancer._clear_placeholder_state(widget) 

1286 

1287 

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

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

1290 # CRITICAL: Check if widget is in placeholder state first 

1291 # If it's showing a placeholder, the actual parameter value is None 

1292 if widget.property("is_placeholder_state"): 

1293 return None 

1294 

1295 for matcher, extractor in WIDGET_GET_DISPATCH: 

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

1297 return extractor(widget) 

1298 return None 

1299 

1300 # Framework-specific methods for backward compatibility 

1301 

1302 def reset_all_parameters(self) -> None: 

1303 """Reset all parameters - just call reset_parameter for each parameter.""" 

1304 from openhcs.utils.performance_monitor import timer 

1305 

1306 with timer(f"reset_all_parameters ({self.field_id})", threshold_ms=50.0): 

1307 # OPTIMIZATION: Set flag to prevent per-parameter refreshes 

1308 # This makes reset_all much faster by batching all refreshes to the end 

1309 self._in_reset = True 

1310 

1311 # OPTIMIZATION: Block cross-window updates during reset 

1312 # This prevents expensive _collect_live_context_from_other_windows() calls 

1313 # during the reset operation. We'll do a single refresh at the end. 

1314 self._block_cross_window_updates = True 

1315 

1316 try: 

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

1318 for param_name in param_names: 

1319 # Call _reset_parameter_impl directly to avoid setting/clearing _in_reset per parameter 

1320 self._reset_parameter_impl(param_name) 

1321 finally: 

1322 self._in_reset = False 

1323 self._block_cross_window_updates = False 

1324 

1325 # OPTIMIZATION: Single placeholder refresh at the end instead of per-parameter 

1326 # This is much faster than refreshing after each reset 

1327 # Use _refresh_all_placeholders directly to avoid cross-window context collection 

1328 # (reset to defaults doesn't need live context from other windows) 

1329 self._refresh_all_placeholders() 

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

1331 

1332 

1333 

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

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

1336 

1337 if param_name in self.parameters: 

1338 # Convert value using service layer 

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

1340 

1341 # Update parameter in data model 

1342 self.parameters[param_name] = converted_value 

1343 

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

1345 # This prevents placeholder updates from destroying user values 

1346 self._user_set_fields.add(param_name) 

1347 

1348 # Update corresponding widget if it exists 

1349 if param_name in self.widgets: 

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

1351 

1352 # Emit signal for PyQt6 compatibility 

1353 # This will trigger both local placeholder refresh AND cross-window updates (via _emit_cross_window_change) 

1354 self.parameter_changed.emit(param_name, converted_value) 

1355 

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

1357 """ 

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

1359 

1360 Function parameters should not be reset against dataclass types. 

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

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

1363 """ 

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

1365 return False 

1366 

1367 # Check if parameter exists in dataclass fields 

1368 if dataclasses.is_dataclass(self.dataclass_type): 

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

1370 is_function_param = param_name not in field_names 

1371 return is_function_param 

1372 

1373 return False 

1374 

1375 def reset_parameter(self, param_name: str) -> None: 

1376 """Reset parameter to signature default.""" 

1377 if param_name not in self.parameters: 

1378 return 

1379 

1380 # Set flag to prevent _refresh_all_placeholders during reset 

1381 self._in_reset = True 

1382 try: 

1383 return self._reset_parameter_impl(param_name) 

1384 finally: 

1385 self._in_reset = False 

1386 

1387 def _reset_parameter_impl(self, param_name: str) -> None: 

1388 """Internal reset implementation.""" 

1389 

1390 # Function parameters reset to static defaults from param_defaults 

1391 if self._is_function_parameter(param_name): 

1392 reset_value = self.param_defaults.get(param_name) if hasattr(self, 'param_defaults') else None 

1393 self.parameters[param_name] = reset_value 

1394 

1395 if param_name in self.widgets: 

1396 widget = self.widgets[param_name] 

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

1398 

1399 self.parameter_changed.emit(param_name, reset_value) 

1400 return 

1401 

1402 # Special handling for dataclass fields 

1403 try: 

1404 import dataclasses as _dc 

1405 from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils 

1406 param_type = self.parameter_types.get(param_name) 

1407 

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

1409 if param_type and ParameterTypeUtils.is_optional_dataclass(param_type): 

1410 reset_value = self._get_reset_value(param_name) 

1411 self.parameters[param_name] = reset_value 

1412 

1413 if param_name in self.widgets: 

1414 container = self.widgets[param_name] 

1415 # Toggle the optional checkbox to match reset_value (None -> unchecked, enabled=False -> unchecked) 

1416 from PyQt6.QtWidgets import QCheckBox 

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

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

1419 if checkbox: 

1420 checkbox.blockSignals(True) 

1421 checkbox.setChecked(reset_value is not None and reset_value.enabled) 

1422 checkbox.blockSignals(False) 

1423 

1424 # Reset nested manager contents too 

1425 nested_manager = self.nested_managers.get(param_name) 

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

1427 nested_manager.reset_all_parameters() 

1428 

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

1430 try: 

1431 from .clickable_help_components import GroupBoxWithHelp 

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

1433 if group: 

1434 group.setEnabled(reset_value is not None) 

1435 except Exception: 

1436 pass 

1437 

1438 # Emit parameter change and return (handled) 

1439 self.parameter_changed.emit(param_name, reset_value) 

1440 return 

1441 

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

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

1444 if param_type and _dc.is_dataclass(param_type): 

1445 nested_manager = self.nested_managers.get(param_name) 

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

1447 nested_manager.reset_all_parameters() 

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

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

1450 if param_name in self.widgets: 

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

1452 # Emit parameter change with unchanged container value 

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

1454 return 

1455 except Exception: 

1456 # Fall through to generic handling if type checks fail 

1457 pass 

1458 

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

1460 reset_value = self._get_reset_value(param_name) 

1461 self.parameters[param_name] = reset_value 

1462 

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

1464 if reset_value is None: 

1465 self.reset_fields.add(param_name) 

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

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

1468 self.shared_reset_fields.add(field_path) 

1469 else: 

1470 # For concrete values, remove from reset tracking 

1471 self.reset_fields.discard(param_name) 

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

1473 self.shared_reset_fields.discard(field_path) 

1474 

1475 # Update widget with reset value 

1476 if param_name in self.widgets: 

1477 widget = self.widgets[param_name] 

1478 self.update_widget_value(widget, reset_value, param_name) 

1479 

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

1481 # OPTIMIZATION: Skip during batch reset - we'll refresh all placeholders once at the end 

1482 if reset_value is None and not self._in_reset: 

1483 # Build overlay from current form state 

1484 overlay = self.get_current_values() 

1485 

1486 # Collect live context from other open windows for cross-window placeholder resolution 

1487 live_context = self._collect_live_context_from_other_windows() if self._parent_manager is None else None 

1488 

1489 # Build context stack (handles static defaults for global config editing + live context) 

1490 with self._build_context_stack(overlay, live_context=live_context): 

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

1492 if placeholder_text: 

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

1494 PyQt6WidgetEnhancer.apply_placeholder_text(widget, placeholder_text) 

1495 

1496 # Emit parameter change to notify other components 

1497 self.parameter_changed.emit(param_name, reset_value) 

1498 

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

1500 """Get reset value based on editing context. 

1501 

1502 For global config editing: Use static class defaults (not None) 

1503 For lazy config editing: Use signature defaults (None for inheritance) 

1504 For functions: Use signature defaults 

1505 """ 

1506 # For global config editing, use static class defaults instead of None 

1507 if self.config.is_global_config_editing and self.dataclass_type: 

1508 # Get static default from class attribute 

1509 try: 

1510 static_default = object.__getattribute__(self.dataclass_type, param_name) 

1511 return static_default 

1512 except AttributeError: 

1513 # Fallback to signature default if no class attribute 

1514 pass 

1515 

1516 # For everything else, use signature defaults 

1517 return self.param_defaults.get(param_name) 

1518 

1519 

1520 

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

1522 """ 

1523 Get current parameter values preserving lazy dataclass structure. 

1524 

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

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

1527 """ 

1528 with timer(f"get_current_values ({self.field_id})", threshold_ms=2.0): 

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

1530 current_values = {} 

1531 

1532 # Read current values from widgets 

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

1534 widget = self.widgets.get(param_name) 

1535 if widget: 

1536 raw_value = self.get_widget_value(widget) 

1537 # Apply unified type conversion 

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

1539 else: 

1540 # Fallback to initial parameter value if no widget 

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

1542 

1543 # Checkbox validation is handled in widget creation 

1544 

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

1546 self._apply_to_nested_managers( 

1547 lambda name, manager: self._process_nested_values_if_checkbox_enabled( 

1548 name, manager, current_values 

1549 ) 

1550 ) 

1551 

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

1553 return current_values 

1554 

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

1556 """ 

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

1558 

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

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

1561 

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

1563 """ 

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

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

1566 return self.get_current_values() 

1567 

1568 user_modified = {} 

1569 current_values = self.get_current_values() 

1570 

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

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

1573 if value is not None: 

1574 # CRITICAL: For nested dataclasses, we need to extract only user-modified fields 

1575 # by checking the raw values (using object.__getattribute__ to avoid resolution) 

1576 from dataclasses import is_dataclass, fields as dataclass_fields 

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

1578 # Extract raw field values from nested dataclass 

1579 nested_user_modified = {} 

1580 for field in dataclass_fields(value): 

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

1582 if raw_value is not None: 

1583 nested_user_modified[field.name] = raw_value 

1584 

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

1586 if nested_user_modified: 

1587 # CRITICAL: Pass as dict, not as reconstructed instance 

1588 # This allows the context merging to handle it properly 

1589 # We'll need to reconstruct it when applying to context 

1590 user_modified[field_name] = (type(value), nested_user_modified) 

1591 else: 

1592 # Non-dataclass field, include if not None 

1593 user_modified[field_name] = value 

1594 

1595 return user_modified 

1596 

1597 def _reconstruct_nested_dataclasses(self, live_values: dict, base_instance=None) -> dict: 

1598 """ 

1599 Reconstruct nested dataclasses from tuple format (type, dict) to instances. 

1600 

1601 get_user_modified_values() returns nested dataclasses as (type, dict) tuples 

1602 to preserve only user-modified fields. This function reconstructs them as instances 

1603 by merging the user-modified fields into the base instance's nested dataclasses. 

1604 

1605 Args: 

1606 live_values: Dict with values, may contain (type, dict) tuples for nested dataclasses 

1607 base_instance: Base dataclass instance to merge into (for nested dataclass fields) 

1608 """ 

1609 import dataclasses 

1610 from dataclasses import is_dataclass 

1611 

1612 reconstructed = {} 

1613 for field_name, value in live_values.items(): 

1614 if isinstance(value, tuple) and len(value) == 2: 

1615 # Nested dataclass in tuple format: (type, dict) 

1616 dataclass_type, field_dict = value 

1617 

1618 # CRITICAL: If we have a base instance, merge into its nested dataclass 

1619 # This prevents creating fresh instances with None defaults 

1620 if base_instance and hasattr(base_instance, field_name): 

1621 base_nested = getattr(base_instance, field_name) 

1622 if base_nested is not None and is_dataclass(base_nested): 

1623 # Merge user-modified fields into base nested dataclass 

1624 reconstructed[field_name] = dataclasses.replace(base_nested, **field_dict) 

1625 else: 

1626 # No base nested dataclass, create fresh instance 

1627 reconstructed[field_name] = dataclass_type(**field_dict) 

1628 else: 

1629 # No base instance, create fresh instance 

1630 reconstructed[field_name] = dataclass_type(**field_dict) 

1631 else: 

1632 # Regular value, pass through 

1633 reconstructed[field_name] = value 

1634 return reconstructed 

1635 

1636 def _build_context_stack(self, overlay, skip_parent_overlay: bool = False, live_context: dict = None): 

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

1638 

1639 Context stack order for PipelineConfig (lazy): 

1640 1. Thread-local global config (automatic base - loaded instance) 

1641 2. Parent context(s) from self.context_obj (if provided) - with live values if available 

1642 3. Parent overlay (if nested form) 

1643 4. Overlay from current form values (always applied last) 

1644 

1645 Context stack order for GlobalPipelineConfig (non-lazy): 

1646 1. Thread-local global config (automatic base - loaded instance) 

1647 2. Static defaults (masks thread-local with fresh GlobalPipelineConfig()) 

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

1649 

1650 Args: 

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

1652 skip_parent_overlay: If True, skip applying parent's user-modified values. 

1653 Used during reset to prevent parent from re-introducing old values. 

1654 live_context: Optional dict mapping object instances to their live values from other open windows 

1655 

1656 Returns: 

1657 ExitStack with nested contexts 

1658 """ 

1659 from contextlib import ExitStack 

1660 from openhcs.config_framework.context_manager import config_context 

1661 

1662 stack = ExitStack() 

1663 

1664 # CRITICAL: For GlobalPipelineConfig editing (root form only), apply static defaults as base context 

1665 # This masks the thread-local loaded instance with class defaults 

1666 # Only do this for the ROOT GlobalPipelineConfig form, not nested configs or step editor 

1667 is_root_global_config = (self.config.is_global_config_editing and 

1668 self.global_config_type is not None and 

1669 self.context_obj is None) # No parent context = root form 

1670 

1671 if is_root_global_config: 

1672 static_defaults = self.global_config_type() 

1673 stack.enter_context(config_context(static_defaults, mask_with_none=True)) 

1674 else: 

1675 # CRITICAL: Apply GlobalPipelineConfig live values FIRST (as base layer) 

1676 # Then parent context (PipelineConfig) will be applied AFTER, allowing it to override 

1677 # This ensures proper hierarchy: GlobalPipelineConfig → PipelineConfig → Step 

1678 # 

1679 # Order matters: 

1680 # 1. GlobalPipelineConfig live (base layer) - provides defaults 

1681 # 2. PipelineConfig (next layer) - overrides GlobalPipelineConfig where it has concrete values 

1682 # 3. Step overlay (top layer) - overrides everything 

1683 if live_context and self.global_config_type: 

1684 global_live_values = self._find_live_values_for_type(self.global_config_type, live_context) 

1685 if global_live_values is not None: 

1686 try: 

1687 # CRITICAL: Merge live values into thread-local GlobalPipelineConfig instead of creating fresh instance 

1688 # This preserves all fields from thread-local and only updates concrete live values 

1689 from openhcs.config_framework.context_manager import get_base_global_config 

1690 import dataclasses 

1691 thread_local_global = get_base_global_config() 

1692 if thread_local_global is not None: 

1693 # CRITICAL: Reconstruct nested dataclasses from tuple format, merging into thread-local's nested dataclasses 

1694 global_live_values = self._reconstruct_nested_dataclasses(global_live_values, thread_local_global) 

1695 

1696 global_live_instance = dataclasses.replace(thread_local_global, **global_live_values) 

1697 stack.enter_context(config_context(global_live_instance)) 

1698 except Exception as e: 

1699 logger.warning(f"Failed to apply live GlobalPipelineConfig: {e}") 

1700 

1701 # Apply parent context(s) if provided 

1702 if self.context_obj is not None: 

1703 if isinstance(self.context_obj, list): 

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

1705 for ctx in self.context_obj: 

1706 # Check if we have live values for this context TYPE (or its lazy/base equivalent) 

1707 ctx_type = type(ctx) 

1708 live_values = self._find_live_values_for_type(ctx_type, live_context) 

1709 if live_values is not None: 

1710 try: 

1711 # CRITICAL: Reconstruct nested dataclasses from tuple format, merging into saved instance's nested dataclasses 

1712 live_values = self._reconstruct_nested_dataclasses(live_values, ctx) 

1713 

1714 # CRITICAL: Use dataclasses.replace to merge live values into saved instance 

1715 import dataclasses 

1716 live_instance = dataclasses.replace(ctx, **live_values) 

1717 stack.enter_context(config_context(live_instance)) 

1718 except: 

1719 stack.enter_context(config_context(ctx)) 

1720 else: 

1721 stack.enter_context(config_context(ctx)) 

1722 else: 

1723 # Single parent context (Step Editor: pipeline_config) 

1724 # CRITICAL: If live_context has updated values for this context TYPE, merge them into the saved instance 

1725 # This preserves inheritance: only concrete (non-None) live values override the saved instance 

1726 ctx_type = type(self.context_obj) 

1727 live_values = self._find_live_values_for_type(ctx_type, live_context) 

1728 if live_values is not None: 

1729 try: 

1730 # CRITICAL: Reconstruct nested dataclasses from tuple format, merging into saved instance's nested dataclasses 

1731 live_values = self._reconstruct_nested_dataclasses(live_values, self.context_obj) 

1732 

1733 # CRITICAL: Use dataclasses.replace to merge live values into saved instance 

1734 # This ensures None values in live_values don't override concrete values in self.context_obj 

1735 import dataclasses 

1736 live_instance = dataclasses.replace(self.context_obj, **live_values) 

1737 stack.enter_context(config_context(live_instance)) 

1738 except Exception as e: 

1739 logger.warning(f"Failed to apply live parent context: {e}") 

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

1741 else: 

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

1743 

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

1745 # This allows live placeholder updates when sibling fields change 

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

1747 # SKIP if skip_parent_overlay=True (used during reset to prevent re-introducing old values) 

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

1749 if (not skip_parent_overlay and 

1750 parent_manager and 

1751 hasattr(parent_manager, 'get_user_modified_values') and 

1752 hasattr(parent_manager, 'dataclass_type') and 

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

1754 

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

1756 # This prevents polluting context with stale/default values 

1757 parent_user_values = parent_manager.get_user_modified_values() 

1758 

1759 if parent_user_values and parent_manager.dataclass_type: 

1760 # CRITICAL: Exclude the current nested config from parent overlay 

1761 # This prevents the parent from re-introducing old values when resetting fields in nested form 

1762 # Example: When resetting well_filter in StepMaterializationConfig, don't include 

1763 # step_materialization_config from parent's user-modified values 

1764 # CRITICAL FIX: Also exclude params from parent's exclude_params list (e.g., 'func' for FunctionStep) 

1765 excluded_keys = {self.field_id} 

1766 if hasattr(parent_manager, 'exclude_params') and parent_manager.exclude_params: 

1767 excluded_keys.update(parent_manager.exclude_params) 

1768 

1769 filtered_parent_values = {k: v for k, v in parent_user_values.items() if k not in excluded_keys} 

1770 

1771 if filtered_parent_values: 

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

1773 from openhcs.core.lazy_placeholder_simplified import LazyDefaultPlaceholderService 

1774 parent_type = parent_manager.dataclass_type 

1775 lazy_parent_type = LazyDefaultPlaceholderService._get_lazy_type_for_base(parent_type) 

1776 if lazy_parent_type: 

1777 parent_type = lazy_parent_type 

1778 

1779 # CRITICAL FIX: Add excluded params from parent's object_instance 

1780 # This allows instantiating parent_type even when some params are excluded from the form 

1781 parent_values_with_excluded = filtered_parent_values.copy() 

1782 if hasattr(parent_manager, 'exclude_params') and parent_manager.exclude_params: 

1783 for excluded_param in parent_manager.exclude_params: 

1784 if excluded_param not in parent_values_with_excluded and hasattr(parent_manager.object_instance, excluded_param): 

1785 parent_values_with_excluded[excluded_param] = getattr(parent_manager.object_instance, excluded_param) 

1786 

1787 # Create parent overlay with only user-modified values (excluding current nested config) 

1788 # For global config editing (root form only), use mask_with_none=True to preserve None overrides 

1789 parent_overlay_instance = parent_type(**parent_values_with_excluded) 

1790 if is_root_global_config: 

1791 stack.enter_context(config_context(parent_overlay_instance, mask_with_none=True)) 

1792 else: 

1793 stack.enter_context(config_context(parent_overlay_instance)) 

1794 

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

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

1797 # CRITICAL FIX: If overlay is a dict but empty (no widgets yet), use object_instance directly 

1798 if isinstance(overlay, dict): 

1799 if not overlay and self.object_instance is not None: 

1800 # Empty dict means widgets don't exist yet - use original instance for context 

1801 import dataclasses 

1802 if dataclasses.is_dataclass(self.object_instance): 

1803 overlay_instance = self.object_instance 

1804 else: 

1805 # For non-dataclass objects, use as-is 

1806 overlay_instance = self.object_instance 

1807 elif self.dataclass_type: 

1808 # Normal case: convert dict to dataclass instance 

1809 # CRITICAL FIX: For excluded params (e.g., 'func' for FunctionStep), use values from object_instance 

1810 # This allows us to instantiate the dataclass type while excluding certain params from the overlay 

1811 overlay_with_excluded = overlay.copy() 

1812 for excluded_param in self.exclude_params: 

1813 if excluded_param not in overlay_with_excluded and hasattr(self.object_instance, excluded_param): 

1814 # Use the value from the original object instance for excluded params 

1815 overlay_with_excluded[excluded_param] = getattr(self.object_instance, excluded_param) 

1816 

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

1818 # For dataclasses: instantiate normally 

1819 try: 

1820 overlay_instance = self.dataclass_type(**overlay_with_excluded) 

1821 except TypeError: 

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

1823 from types import SimpleNamespace 

1824 # For SimpleNamespace, we don't need excluded params 

1825 filtered_overlay = {k: v for k, v in overlay.items() if k not in self.exclude_params} 

1826 overlay_instance = SimpleNamespace(**filtered_overlay) 

1827 else: 

1828 # Dict but no dataclass_type - use SimpleNamespace 

1829 from types import SimpleNamespace 

1830 overlay_instance = SimpleNamespace(**overlay) 

1831 else: 

1832 # Already an instance - use as-is 

1833 overlay_instance = overlay 

1834 

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

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

1837 stack.enter_context(config_context(overlay_instance)) 

1838 

1839 return stack 

1840 

1841 def _apply_initial_enabled_styling(self) -> None: 

1842 """Apply initial enabled field styling based on resolved value from widget. 

1843 

1844 This is called once after all widgets are created to ensure initial styling matches the enabled state. 

1845 We get the resolved value from the checkbox widget, not from self.parameters, because the parameter 

1846 might be None (lazy) but the checkbox shows the resolved placeholder value. 

1847 

1848 CRITICAL: This should NOT be called for optional dataclass nested managers when instance is None. 

1849 The None state dimming is handled by the optional dataclass checkbox handler. 

1850 """ 

1851 import logging 

1852 logger = logging.getLogger(__name__) 

1853 

1854 # CRITICAL: Check if this is a nested manager inside an optional dataclass 

1855 # If the parent's parameter for this nested manager is None, skip enabled styling 

1856 # The optional dataclass checkbox handler already applied None-state dimming 

1857 if self._parent_manager is not None: 

1858 # Find which parameter in parent corresponds to this nested manager 

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

1860 if nested_manager is self: 

1861 # Check if this is an optional dataclass and if the instance is None 

1862 param_type = self._parent_manager.parameter_types.get(param_name) 

1863 if param_type: 

1864 from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils 

1865 if ParameterTypeUtils.is_optional_dataclass(param_type): 

1866 # This is an optional dataclass - check if instance is None 

1867 instance = self._parent_manager.parameters.get(param_name) 

1868 logger.info(f"[INITIAL ENABLED STYLING] field_id={self.field_id}, optional dataclass check: param_name={param_name}, instance={instance}, is_none={instance is None}") 

1869 if instance is None: 

1870 logger.info(f"[INITIAL ENABLED STYLING] field_id={self.field_id}, skipping (optional dataclass instance is None)") 

1871 return 

1872 break 

1873 

1874 # Get the enabled widget 

1875 enabled_widget = self.widgets.get('enabled') 

1876 if not enabled_widget: 

1877 logger.info(f"[INITIAL ENABLED STYLING] field_id={self.field_id}, no enabled widget found") 

1878 return 

1879 

1880 # Get resolved value from widget 

1881 if hasattr(enabled_widget, 'isChecked'): 

1882 resolved_value = enabled_widget.isChecked() 

1883 logger.info(f"[INITIAL ENABLED STYLING] field_id={self.field_id}, resolved_value={resolved_value} (from checkbox)") 

1884 else: 

1885 # Fallback to parameter value 

1886 resolved_value = self.parameters.get('enabled') 

1887 if resolved_value is None: 

1888 resolved_value = True # Default to enabled if we can't resolve 

1889 logger.info(f"[INITIAL ENABLED STYLING] field_id={self.field_id}, resolved_value={resolved_value} (from parameter)") 

1890 

1891 # Call the enabled handler with the resolved value 

1892 self._on_enabled_field_changed_universal('enabled', resolved_value) 

1893 

1894 def _is_any_ancestor_disabled(self) -> bool: 

1895 """ 

1896 Check if any ancestor form has enabled=False. 

1897 

1898 This is used to determine if a nested config should remain dimmed 

1899 even if its own enabled field is True. 

1900 

1901 Returns: 

1902 True if any ancestor has enabled=False, False otherwise 

1903 """ 

1904 current = self._parent_manager 

1905 while current is not None: 

1906 if 'enabled' in current.parameters: 

1907 enabled_widget = current.widgets.get('enabled') 

1908 if enabled_widget and hasattr(enabled_widget, 'isChecked'): 

1909 if not enabled_widget.isChecked(): 

1910 return True 

1911 current = current._parent_manager 

1912 return False 

1913 

1914 def _refresh_enabled_styling(self) -> None: 

1915 """ 

1916 Refresh enabled styling for this form and all nested forms. 

1917 

1918 This should be called when context changes that might affect inherited enabled values. 

1919 Similar to placeholder refresh, but for enabled field styling. 

1920 

1921 CRITICAL: Skip optional dataclass nested managers when instance is None. 

1922 """ 

1923 import logging 

1924 logger = logging.getLogger(__name__) 

1925 

1926 # CRITICAL: Check if this is a nested manager inside an optional dataclass with None instance 

1927 # If so, skip enabled styling - the None state dimming takes precedence 

1928 if self._parent_manager is not None: 

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

1930 if nested_manager is self: 

1931 param_type = self._parent_manager.parameter_types.get(param_name) 

1932 if param_type: 

1933 from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils 

1934 if ParameterTypeUtils.is_optional_dataclass(param_type): 

1935 instance = self._parent_manager.parameters.get(param_name) 

1936 logger.info(f"[REFRESH ENABLED STYLING] field_id={self.field_id}, optional dataclass check: param_name={param_name}, instance={instance}, is_none={instance is None}") 

1937 if instance is None: 

1938 logger.info(f"[REFRESH ENABLED STYLING] field_id={self.field_id}, skipping (optional dataclass instance is None)") 

1939 # Skip enabled styling - None state dimming is already applied 

1940 return 

1941 break 

1942 

1943 # Refresh this form's enabled styling if it has an enabled field 

1944 if 'enabled' in self.parameters: 

1945 # Get the enabled widget to read the CURRENT resolved value 

1946 enabled_widget = self.widgets.get('enabled') 

1947 if enabled_widget and hasattr(enabled_widget, 'isChecked'): 

1948 # Use the checkbox's current state (which reflects resolved placeholder) 

1949 resolved_value = enabled_widget.isChecked() 

1950 else: 

1951 # Fallback to parameter value 

1952 resolved_value = self.parameters.get('enabled') 

1953 if resolved_value is None: 

1954 resolved_value = True 

1955 

1956 # Apply styling with the resolved value 

1957 self._on_enabled_field_changed_universal('enabled', resolved_value) 

1958 

1959 # Recursively refresh all nested forms' enabled styling 

1960 for nested_manager in self.nested_managers.values(): 

1961 nested_manager._refresh_enabled_styling() 

1962 

1963 def _on_enabled_field_changed_universal(self, param_name: str, value: Any) -> None: 

1964 """ 

1965 UNIVERSAL ENABLED FIELD BEHAVIOR: Apply visual styling when 'enabled' parameter changes. 

1966 

1967 This handler is connected for ANY form that has an 'enabled' parameter (function, dataclass, etc.). 

1968 When enabled resolves to False (concrete or lazy), apply visual dimming WITHOUT blocking input. 

1969 

1970 This creates consistent semantics across all ParameterFormManager instances: 

1971 - enabled=True or lazy-resolved True: Normal styling 

1972 - enabled=False or lazy-resolved False: Dimmed styling, inputs stay editable 

1973 """ 

1974 if param_name != 'enabled': 

1975 return 

1976 

1977 # DEBUG: Log when this handler is called 

1978 import logging 

1979 logger = logging.getLogger(__name__) 

1980 logger.info(f"[ENABLED HANDLER CALLED] field_id={self.field_id}, param_name={param_name}, value={value}") 

1981 

1982 # Resolve lazy value: None means inherit from parent context 

1983 if value is None: 

1984 # Lazy field - get the resolved placeholder value from the widget 

1985 enabled_widget = self.widgets.get('enabled') 

1986 if enabled_widget and hasattr(enabled_widget, 'isChecked'): 

1987 resolved_value = enabled_widget.isChecked() 

1988 else: 

1989 # Fallback: assume True if we can't resolve 

1990 resolved_value = True 

1991 else: 

1992 resolved_value = value 

1993 

1994 logger.info(f"[ENABLED HANDLER] field_id={self.field_id}, resolved_value={resolved_value}") 

1995 

1996 # Apply styling to the entire form based on resolved enabled value 

1997 # Inputs stay editable - only visual dimming changes 

1998 # CRITICAL FIX: Only apply to widgets in THIS form, not nested ParameterFormManager forms 

1999 # This prevents crosstalk when a step has 'enabled' field and nested configs also have 'enabled' fields 

2000 def get_direct_widgets(parent_widget): 

2001 """Get widgets that belong to this form, excluding nested ParameterFormManager widgets.""" 

2002 direct_widgets = [] 

2003 all_widgets = parent_widget.findChildren(ALL_INPUT_WIDGET_TYPES) 

2004 logger.info(f"[GET_DIRECT_WIDGETS] field_id={self.field_id}, total widgets found: {len(all_widgets)}, nested_managers: {list(self.nested_managers.keys())}") 

2005 

2006 for widget in all_widgets: 

2007 widget_name = f"{widget.__class__.__name__}({widget.objectName() or 'no-name'})" 

2008 object_name = widget.objectName() 

2009 

2010 # Check if widget belongs to a nested manager by checking if its object name starts with nested manager's field_id 

2011 belongs_to_nested = False 

2012 for nested_name, nested_manager in self.nested_managers.items(): 

2013 nested_field_id = nested_manager.field_id 

2014 if object_name and object_name.startswith(nested_field_id + '_'): 

2015 belongs_to_nested = True 

2016 logger.info(f"[GET_DIRECT_WIDGETS] ❌ EXCLUDE {widget_name} - belongs to nested manager {nested_field_id}") 

2017 break 

2018 

2019 if not belongs_to_nested: 

2020 direct_widgets.append(widget) 

2021 logger.info(f"[GET_DIRECT_WIDGETS] ✅ INCLUDE {widget_name}") 

2022 

2023 logger.info(f"[GET_DIRECT_WIDGETS] field_id={self.field_id}, returning {len(direct_widgets)} direct widgets") 

2024 return direct_widgets 

2025 

2026 direct_widgets = get_direct_widgets(self) 

2027 widget_names = [f"{w.__class__.__name__}({w.objectName() or 'no-name'})" for w in direct_widgets[:5]] # First 5 

2028 logger.info(f"[ENABLED HANDLER] field_id={self.field_id}, found {len(direct_widgets)} direct widgets, first 5: {widget_names}") 

2029 

2030 # CRITICAL: For nested configs (inside GroupBox), apply styling to the GroupBox container 

2031 # For top-level forms (step, function), apply styling to direct widgets 

2032 is_nested_config = self._parent_manager is not None and any( 

2033 nested_manager == self for nested_manager in self._parent_manager.nested_managers.values() 

2034 ) 

2035 

2036 if is_nested_config: 

2037 # This is a nested config - find the GroupBox container and apply styling to it 

2038 # The GroupBox is stored in parent's widgets dict 

2039 group_box = None 

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

2041 if nested_manager == self: 

2042 group_box = self._parent_manager.widgets.get(param_name) 

2043 break 

2044 

2045 if group_box: 

2046 logger.info(f"[ENABLED HANDLER] field_id={self.field_id}, applying to GroupBox container") 

2047 from PyQt6.QtWidgets import QGraphicsOpacityEffect 

2048 

2049 # CRITICAL: Check if ANY ancestor has enabled=False 

2050 # If any ancestor is disabled, child should remain dimmed regardless of its own enabled value 

2051 ancestor_is_disabled = self._is_any_ancestor_disabled() 

2052 logger.info(f"[ENABLED HANDLER] field_id={self.field_id}, ancestor_is_disabled={ancestor_is_disabled}") 

2053 

2054 if resolved_value and not ancestor_is_disabled: 

2055 # Enabled=True AND no ancestor is disabled: Remove dimming from GroupBox 

2056 logger.info(f"[ENABLED HANDLER] field_id={self.field_id}, removing dimming from GroupBox") 

2057 # Clear effects from all widgets in the GroupBox 

2058 for widget in group_box.findChildren(ALL_INPUT_WIDGET_TYPES): 

2059 widget.setGraphicsEffect(None) 

2060 elif ancestor_is_disabled: 

2061 # Ancestor is disabled - keep dimming regardless of child's enabled value 

2062 logger.info(f"[ENABLED HANDLER] field_id={self.field_id}, keeping dimming (ancestor disabled)") 

2063 for widget in group_box.findChildren(ALL_INPUT_WIDGET_TYPES): 

2064 effect = QGraphicsOpacityEffect() 

2065 effect.setOpacity(0.4) 

2066 widget.setGraphicsEffect(effect) 

2067 else: 

2068 # Enabled=False: Apply dimming to GroupBox widgets 

2069 logger.info(f"[ENABLED HANDLER] field_id={self.field_id}, applying dimming to GroupBox") 

2070 for widget in group_box.findChildren(ALL_INPUT_WIDGET_TYPES): 

2071 effect = QGraphicsOpacityEffect() 

2072 effect.setOpacity(0.4) 

2073 widget.setGraphicsEffect(effect) 

2074 else: 

2075 # This is a top-level form (step, function) - apply styling to direct widgets + nested configs 

2076 if resolved_value: 

2077 # Enabled=True: Remove dimming from direct widgets 

2078 logger.info(f"[ENABLED HANDLER] field_id={self.field_id}, removing dimming (enabled=True)") 

2079 for widget in direct_widgets: 

2080 widget.setGraphicsEffect(None) 

2081 

2082 # CRITICAL: Trigger refresh of all nested configs' enabled styling 

2083 # This ensures that nested configs re-evaluate their styling based on: 

2084 # 1. Their own enabled field value 

2085 # 2. Whether any ancestor is disabled (now False since parent is enabled) 

2086 # This handles deeply nested configs correctly 

2087 logger.info(f"[ENABLED HANDLER] Refreshing nested configs' enabled styling") 

2088 for nested_manager in self.nested_managers.values(): 

2089 nested_manager._refresh_enabled_styling() 

2090 else: 

2091 # Enabled=False: Apply dimming to direct widgets + ALL nested configs 

2092 logger.info(f"[ENABLED HANDLER] field_id={self.field_id}, applying dimming (enabled=False)") 

2093 from PyQt6.QtWidgets import QGraphicsOpacityEffect 

2094 for widget in direct_widgets: 

2095 # Skip QLabel widgets when dimming (only dim inputs) 

2096 if isinstance(widget, QLabel): 

2097 continue 

2098 effect = QGraphicsOpacityEffect() 

2099 effect.setOpacity(0.4) 

2100 widget.setGraphicsEffect(effect) 

2101 

2102 # Also dim all nested configs (entire step is disabled) 

2103 logger.info(f"[ENABLED HANDLER] Dimming nested configs, found {len(self.nested_managers)} nested managers") 

2104 logger.info(f"[ENABLED HANDLER] Available widget keys: {list(self.widgets.keys())}") 

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

2106 group_box = self.widgets.get(param_name) 

2107 logger.info(f"[ENABLED HANDLER] Checking nested config {param_name}, group_box={group_box.__class__.__name__ if group_box else 'None'}") 

2108 if not group_box: 

2109 logger.info(f"[ENABLED HANDLER] ⚠️ No group_box found for nested config {param_name}, trying nested_manager.field_id={nested_manager.field_id}") 

2110 # Try using the nested manager's field_id instead 

2111 group_box = self.widgets.get(nested_manager.field_id) 

2112 if not group_box: 

2113 logger.info(f"[ENABLED HANDLER] ⚠️ Still no group_box found, skipping") 

2114 continue 

2115 widgets_to_dim = group_box.findChildren(ALL_INPUT_WIDGET_TYPES) 

2116 logger.info(f"[ENABLED HANDLER] Applying dimming to nested config {param_name}, found {len(widgets_to_dim)} widgets") 

2117 for widget in widgets_to_dim: 

2118 effect = QGraphicsOpacityEffect() 

2119 effect.setOpacity(0.4) 

2120 widget.setGraphicsEffect(effect) 

2121 

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

2123 """ 

2124 Handle parameter changes from nested forms. 

2125 

2126 When a nested form's field changes: 

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

2128 2. Refresh all sibling nested forms' placeholders 

2129 3. Refresh enabled styling (in case siblings inherit enabled values) 

2130 4. Propagate the change signal up to root for cross-window updates 

2131 """ 

2132 # OPTIMIZATION: Skip expensive placeholder refreshes during batch reset 

2133 # The reset operation will do a single refresh at the end 

2134 if getattr(self, '_in_reset', False): 

2135 return 

2136 

2137 # OPTIMIZATION: Skip cross-window context collection during batch operations 

2138 if getattr(self, '_block_cross_window_updates', False): 

2139 return 

2140 

2141 # CRITICAL OPTIMIZATION: Also check if ANY nested manager is in reset mode 

2142 # When a nested dataclass's "Reset All" button is clicked, the nested manager 

2143 # sets _in_reset=True, but the parent doesn't know about it. We need to skip 

2144 # expensive updates while the child is resetting. 

2145 for nested_manager in self.nested_managers.values(): 

2146 if getattr(nested_manager, '_in_reset', False): 

2147 return 

2148 if getattr(nested_manager, '_block_cross_window_updates', False): 

2149 return 

2150 

2151 # Collect live context from other windows (only for root managers) 

2152 if self._parent_manager is None: 

2153 live_context = self._collect_live_context_from_other_windows() 

2154 else: 

2155 live_context = None 

2156 

2157 # Refresh parent form's placeholders with live context 

2158 self._refresh_all_placeholders(live_context=live_context) 

2159 

2160 # Refresh all nested managers' placeholders (including siblings) with live context 

2161 self._apply_to_nested_managers(lambda name, manager: manager._refresh_all_placeholders(live_context=live_context)) 

2162 

2163 # CRITICAL: Also refresh enabled styling for all nested managers 

2164 # This ensures that when one config's enabled field changes, siblings that inherit from it update their styling 

2165 # Example: fiji_streaming_config.enabled inherits from napari_streaming_config.enabled 

2166 self._apply_to_nested_managers(lambda name, manager: manager._refresh_enabled_styling()) 

2167 

2168 # CRITICAL: Propagate parameter change signal up the hierarchy 

2169 # This ensures cross-window updates work for nested config changes 

2170 # The root manager will emit context_value_changed via _emit_cross_window_change 

2171 # IMPORTANT: We DO propagate 'enabled' field changes for cross-window styling updates 

2172 self.parameter_changed.emit(param_name, value) 

2173 

2174 def _refresh_with_live_context(self, live_context: dict = None) -> None: 

2175 """Refresh placeholders using live context from other open windows. 

2176 

2177 This is the standard refresh method that should be used for all placeholder updates. 

2178 It automatically collects live values from other open windows (unless already provided). 

2179 

2180 Args: 

2181 live_context: Optional pre-collected live context. If None, will collect it. 

2182 """ 

2183 import logging 

2184 logger = logging.getLogger(__name__) 

2185 logger.info(f"🔍 REFRESH: {self.field_id} (id={id(self)}) refreshing with live context") 

2186 

2187 # Only root managers should collect live context (nested managers inherit from parent) 

2188 # If live_context is already provided (e.g., from parent), use it to avoid redundant collection 

2189 if live_context is None and self._parent_manager is None: 

2190 live_context = self._collect_live_context_from_other_windows() 

2191 

2192 # Refresh this form's placeholders 

2193 self._refresh_all_placeholders(live_context=live_context) 

2194 

2195 # CRITICAL: Also refresh all nested managers' placeholders 

2196 # Pass the same live_context to avoid redundant get_current_values() calls 

2197 self._apply_to_nested_managers(lambda name, manager: manager._refresh_all_placeholders(live_context=live_context)) 

2198 

2199 def _refresh_all_placeholders(self, live_context: dict = None) -> None: 

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

2201 

2202 Args: 

2203 live_context: Optional dict mapping object instances to their live values from other open windows 

2204 """ 

2205 with timer(f"_refresh_all_placeholders ({self.field_id})", threshold_ms=5.0): 

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

2207 # The placeholder service will determine if placeholders are available 

2208 if not self.dataclass_type: 

2209 return 

2210 

2211 # CRITICAL FIX: Use self.parameters instead of get_current_values() for overlay 

2212 # get_current_values() reads widget values, but widgets don't have placeholder state set yet 

2213 # during initial refresh, so it reads displayed values instead of None 

2214 # self.parameters has the correct None values from initialization 

2215 overlay = self.parameters 

2216 

2217 # Build context stack: parent context + overlay (with live context from other windows) 

2218 with self._build_context_stack(overlay, live_context=live_context): 

2219 monitor = get_monitor("Placeholder resolution per field") 

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

2221 # CRITICAL: Check current value from self.parameters (has correct None values) 

2222 current_value = self.parameters.get(param_name) 

2223 

2224 # CRITICAL: Also check if widget is in placeholder state 

2225 # This handles the case where live context changed and we need to re-resolve the placeholder 

2226 # even though self.parameters still has None 

2227 widget_in_placeholder_state = widget.property("is_placeholder_state") 

2228 

2229 if current_value is None or widget_in_placeholder_state: 

2230 with monitor.measure(): 

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

2232 if placeholder_text: 

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

2234 PyQt6WidgetEnhancer.apply_placeholder_text(widget, placeholder_text) 

2235 

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

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

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

2239 operation_func(param_name, nested_manager) 

2240 

2241 def _apply_all_styling_callbacks(self) -> None: 

2242 """Recursively apply all styling callbacks for this manager and all nested managers. 

2243 

2244 This must be called AFTER all async widget creation is complete, otherwise 

2245 findChildren() calls in styling callbacks will return empty lists. 

2246 """ 

2247 # Apply this manager's callbacks 

2248 for callback in self._on_build_complete_callbacks: 

2249 callback() 

2250 self._on_build_complete_callbacks.clear() 

2251 

2252 # Recursively apply nested managers' callbacks 

2253 for nested_manager in self.nested_managers.values(): 

2254 nested_manager._apply_all_styling_callbacks() 

2255 

2256 def _apply_all_post_placeholder_callbacks(self) -> None: 

2257 """Recursively apply all post-placeholder callbacks for this manager and all nested managers. 

2258 

2259 This must be called AFTER placeholders are refreshed, so enabled styling can use resolved values. 

2260 """ 

2261 # Apply this manager's callbacks 

2262 for callback in self._on_placeholder_refresh_complete_callbacks: 

2263 callback() 

2264 self._on_placeholder_refresh_complete_callbacks.clear() 

2265 

2266 # Recursively apply nested managers' callbacks 

2267 for nested_manager in self.nested_managers.values(): 

2268 nested_manager._apply_all_post_placeholder_callbacks() 

2269 

2270 def _on_nested_manager_complete(self, nested_manager) -> None: 

2271 """Called by nested managers when they complete async widget creation.""" 

2272 if hasattr(self, '_pending_nested_managers'): 

2273 # Find and remove this manager from pending dict 

2274 key_to_remove = None 

2275 for key, manager in self._pending_nested_managers.items(): 

2276 if manager is nested_manager: 

2277 key_to_remove = key 

2278 break 

2279 

2280 if key_to_remove: 

2281 del self._pending_nested_managers[key_to_remove] 

2282 

2283 # If all nested managers are done, apply styling and refresh placeholders 

2284 if len(self._pending_nested_managers) == 0: 

2285 # STEP 1: Apply all styling callbacks now that ALL widgets exist 

2286 with timer(f" Apply styling callbacks", threshold_ms=5.0): 

2287 self._apply_all_styling_callbacks() 

2288 

2289 # STEP 2: Refresh placeholders 

2290 with timer(f" Complete placeholder refresh (all nested ready)", threshold_ms=10.0): 

2291 self._refresh_all_placeholders() 

2292 with timer(f" Nested placeholder refresh (all nested ready)", threshold_ms=5.0): 

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

2294 

2295 # STEP 2.5: Apply post-placeholder callbacks (enabled styling that needs resolved values) 

2296 with timer(f" Apply post-placeholder callbacks (async)", threshold_ms=5.0): 

2297 self._apply_all_post_placeholder_callbacks() 

2298 

2299 # STEP 3: Refresh enabled styling (after placeholders are resolved) 

2300 # This ensures that nested configs with inherited enabled values get correct styling 

2301 with timer(f" Enabled styling refresh (all nested ready)", threshold_ms=5.0): 

2302 self._apply_to_nested_managers(lambda name, manager: manager._refresh_enabled_styling()) 

2303 

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

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

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

2307 return 

2308 

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

2310 param_type = self.parameter_types.get(name) 

2311 

2312 if param_type and ParameterTypeUtils.is_optional_dataclass(param_type): 

2313 # For Optional dataclasses, check if checkbox is enabled 

2314 checkbox_widget = self.widgets.get(name) 

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

2316 from PyQt6.QtWidgets import QCheckBox 

2317 checkbox = checkbox_widget.findChild(QCheckBox) 

2318 if checkbox and not checkbox.isChecked(): 

2319 # Checkbox is unchecked, set to None 

2320 current_values[name] = None 

2321 return 

2322 # Also check if the value itself has enabled=False 

2323 elif current_values.get(name) and not current_values[name].enabled: 

2324 # Config exists but is disabled, set to None for serialization 

2325 current_values[name] = None 

2326 return 

2327 

2328 # Get nested values from the nested form 

2329 nested_values = manager.get_current_values() 

2330 if nested_values: 

2331 # Convert dictionary back to dataclass instance 

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

2333 # Direct dataclass type 

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

2335 elif param_type and ParameterTypeUtils.is_optional_dataclass(param_type): 

2336 # Optional dataclass type 

2337 inner_type = ParameterTypeUtils.get_optional_inner_type(param_type) 

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

2339 else: 

2340 # Fallback to dictionary if type conversion fails 

2341 current_values[name] = nested_values 

2342 else: 

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

2344 if param_type and ParameterTypeUtils.is_optional_dataclass(param_type): 

2345 inner_type = ParameterTypeUtils.get_optional_inner_type(param_type) 

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

2347 

2348 def _make_widget_readonly(self, widget: QWidget): 

2349 """ 

2350 Make a widget read-only without greying it out. 

2351 

2352 Args: 

2353 widget: Widget to make read-only 

2354 """ 

2355 from PyQt6.QtWidgets import QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox, QTextEdit, QAbstractSpinBox 

2356 

2357 if isinstance(widget, (QLineEdit, QTextEdit)): 

2358 widget.setReadOnly(True) 

2359 # Keep normal text color 

2360 widget.setStyleSheet(f"color: {self.config.color_scheme.to_hex(self.config.color_scheme.text_primary)};") 

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

2362 widget.setReadOnly(True) 

2363 widget.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons) 

2364 # Keep normal text color 

2365 widget.setStyleSheet(f"color: {self.config.color_scheme.to_hex(self.config.color_scheme.text_primary)};") 

2366 elif isinstance(widget, QComboBox): 

2367 # Disable but keep normal appearance 

2368 widget.setEnabled(False) 

2369 widget.setStyleSheet(f""" 

2370 QComboBox:disabled {{ 

2371 color: {self.config.color_scheme.to_hex(self.config.color_scheme.text_primary)}; 

2372 background-color: {self.config.color_scheme.to_hex(self.config.color_scheme.input_bg)}; 

2373 }} 

2374 """) 

2375 elif isinstance(widget, QCheckBox): 

2376 # Make non-interactive but keep normal appearance 

2377 widget.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) 

2378 widget.setFocusPolicy(Qt.FocusPolicy.NoFocus) 

2379 

2380 # ==================== CROSS-WINDOW CONTEXT UPDATE METHODS ==================== 

2381 

2382 def _emit_cross_window_change(self, param_name: str, value: object): 

2383 """Emit cross-window context change signal. 

2384 

2385 This is connected to parameter_changed signal for root managers. 

2386 

2387 Args: 

2388 param_name: Name of the parameter that changed 

2389 value: New value 

2390 """ 

2391 # OPTIMIZATION: Skip cross-window updates during batch operations (e.g., reset_all) 

2392 if getattr(self, '_block_cross_window_updates', False): 

2393 return 

2394 

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

2396 self.context_value_changed.emit(field_path, value, 

2397 self.object_instance, self.context_obj) 

2398 

2399 def unregister_from_cross_window_updates(self): 

2400 """Manually unregister this form manager from cross-window updates. 

2401 

2402 This should be called when the window is closing (before destruction) to ensure 

2403 other windows refresh their placeholders without this window's live values. 

2404 """ 

2405 import logging 

2406 logger = logging.getLogger(__name__) 

2407 logger.info(f"🔍 UNREGISTER: {self.field_id} (id={id(self)}) unregistering from cross-window updates") 

2408 logger.info(f"🔍 UNREGISTER: Active managers before: {len(self._active_form_managers)}") 

2409 

2410 try: 

2411 if self in self._active_form_managers: 

2412 # CRITICAL FIX: Disconnect all signal connections BEFORE removing from registry 

2413 # This prevents the closed window from continuing to receive signals and execute 

2414 # _refresh_with_live_context() which causes runaway get_current_values() calls 

2415 for manager in self._active_form_managers: 

2416 if manager is not self: 

2417 try: 

2418 # Disconnect this manager's signals from other manager 

2419 self.context_value_changed.disconnect(manager._on_cross_window_context_changed) 

2420 self.context_refreshed.disconnect(manager._on_cross_window_context_refreshed) 

2421 # Disconnect other manager's signals from this manager 

2422 manager.context_value_changed.disconnect(self._on_cross_window_context_changed) 

2423 manager.context_refreshed.disconnect(self._on_cross_window_context_refreshed) 

2424 except (TypeError, RuntimeError): 

2425 pass # Signal already disconnected or object destroyed 

2426 

2427 # Remove from registry 

2428 self._active_form_managers.remove(self) 

2429 logger.info(f"🔍 UNREGISTER: Active managers after: {len(self._active_form_managers)}") 

2430 

2431 # CRITICAL: Trigger refresh in all remaining windows 

2432 # They were using this window's live values, now they need to revert to saved values 

2433 for manager in self._active_form_managers: 

2434 # Refresh immediately (not deferred) since we're in a controlled close event 

2435 manager._refresh_with_live_context() 

2436 except (ValueError, AttributeError): 

2437 pass # Already removed or list doesn't exist 

2438 

2439 def _on_destroyed(self): 

2440 """Cleanup when widget is destroyed - unregister from active managers.""" 

2441 # Call the manual unregister method 

2442 # This is a fallback in case the window didn't call it explicitly 

2443 self.unregister_from_cross_window_updates() 

2444 

2445 def _on_cross_window_context_changed(self, field_path: str, new_value: object, 

2446 editing_object: object, context_object: object): 

2447 """Handle context changes from other open windows. 

2448 

2449 Args: 

2450 field_path: Full path to the changed field (e.g., "pipeline.well_filter") 

2451 new_value: New value that was set 

2452 editing_object: The object being edited in the other window 

2453 context_object: The context object used by the other window 

2454 """ 

2455 # Don't refresh if this is the window that made the change 

2456 if editing_object is self.object_instance: 

2457 return 

2458 

2459 # Check if the change affects this form based on context hierarchy 

2460 if not self._is_affected_by_context_change(editing_object, context_object): 

2461 return 

2462 

2463 # Debounce the refresh to avoid excessive updates 

2464 self._schedule_cross_window_refresh() 

2465 

2466 def _on_cross_window_context_refreshed(self, editing_object: object, context_object: object): 

2467 """Handle cascading placeholder refreshes from upstream windows. 

2468 

2469 This is triggered when an upstream window's placeholders are refreshed due to 

2470 changes in its parent context. This allows the refresh to cascade downstream. 

2471 

2472 Example: GlobalPipelineConfig changes → PipelineConfig placeholders refresh → 

2473 PipelineConfig emits context_refreshed → Step editor refreshes 

2474 

2475 Args: 

2476 editing_object: The object whose placeholders were refreshed 

2477 context_object: The context object used by that window 

2478 """ 

2479 # Don't refresh if this is the window that was refreshed 

2480 if editing_object is self.object_instance: 

2481 return 

2482 

2483 # Check if the refresh affects this form based on context hierarchy 

2484 if not self._is_affected_by_context_change(editing_object, context_object): 

2485 return 

2486 

2487 # Debounce the refresh to avoid excessive updates 

2488 self._schedule_cross_window_refresh() 

2489 

2490 def _is_affected_by_context_change(self, editing_object: object, context_object: object) -> bool: 

2491 """Determine if a context change from another window affects this form. 

2492 

2493 Hierarchical rules: 

2494 - GlobalPipelineConfig changes affect: PipelineConfig, Steps 

2495 - PipelineConfig changes affect: Steps in that pipeline 

2496 - Step changes affect: nothing (leaf node) 

2497 

2498 Args: 

2499 editing_object: The object being edited in the other window 

2500 context_object: The context object used by the other window 

2501 

2502 Returns: 

2503 True if this form should refresh placeholders due to the change 

2504 """ 

2505 from openhcs.core.config import GlobalPipelineConfig, PipelineConfig 

2506 

2507 # If other window is editing GlobalPipelineConfig, everyone is affected 

2508 if isinstance(editing_object, GlobalPipelineConfig): 

2509 return True 

2510 

2511 # If other window is editing PipelineConfig, check if we're a step in that pipeline 

2512 if isinstance(editing_object, PipelineConfig): 

2513 # We're affected if our context_obj is the same PipelineConfig instance 

2514 return self.context_obj is editing_object 

2515 

2516 # Step changes don't affect other windows (leaf node) 

2517 return False 

2518 

2519 def _schedule_cross_window_refresh(self): 

2520 """Schedule a debounced placeholder refresh for cross-window updates.""" 

2521 from PyQt6.QtCore import QTimer 

2522 

2523 # Cancel existing timer if any 

2524 if self._cross_window_refresh_timer is not None: 

2525 self._cross_window_refresh_timer.stop() 

2526 

2527 # Schedule new refresh after 200ms delay (debounce) 

2528 self._cross_window_refresh_timer = QTimer() 

2529 self._cross_window_refresh_timer.setSingleShot(True) 

2530 self._cross_window_refresh_timer.timeout.connect(self._do_cross_window_refresh) 

2531 self._cross_window_refresh_timer.start(200) # 200ms debounce 

2532 

2533 def _find_live_values_for_type(self, ctx_type: type, live_context: dict) -> dict: 

2534 """Find live values for a context type, checking both exact type and lazy/base equivalents. 

2535 

2536 Args: 

2537 ctx_type: The type to find live values for 

2538 live_context: Dict mapping types to their live values 

2539 

2540 Returns: 

2541 Live values dict if found, None otherwise 

2542 """ 

2543 if not live_context: 

2544 return None 

2545 

2546 # Check exact type match first 

2547 if ctx_type in live_context: 

2548 return live_context[ctx_type] 

2549 

2550 # Check lazy/base equivalents 

2551 from openhcs.core.lazy_placeholder_simplified import LazyDefaultPlaceholderService 

2552 from openhcs.config_framework.lazy_factory import get_base_type_for_lazy 

2553 

2554 # If ctx_type is lazy, check its base type 

2555 base_type = get_base_type_for_lazy(ctx_type) 

2556 if base_type and base_type in live_context: 

2557 return live_context[base_type] 

2558 

2559 # If ctx_type is base, check its lazy type 

2560 lazy_type = LazyDefaultPlaceholderService._get_lazy_type_for_base(ctx_type) 

2561 if lazy_type and lazy_type in live_context: 

2562 return live_context[lazy_type] 

2563 

2564 return None 

2565 

2566 def _collect_live_context_from_other_windows(self): 

2567 """Collect live values from other open form managers for context resolution. 

2568 

2569 Returns a dict mapping object types to their current live values. 

2570 This allows matching by type rather than instance identity. 

2571 Maps both the actual type AND its lazy/non-lazy equivalent for flexible matching. 

2572 

2573 CRITICAL: Only collects context from PARENT types in the hierarchy, not from the same type. 

2574 E.g., PipelineConfig editor collects GlobalPipelineConfig but not other PipelineConfig instances. 

2575 This prevents a window from using its own live values for placeholder resolution. 

2576 

2577 CRITICAL: Uses get_user_modified_values() to only collect concrete (non-None) values. 

2578 This ensures proper inheritance: if PipelineConfig has None for a field, it won't 

2579 override GlobalPipelineConfig's concrete value in the Step editor's context. 

2580 

2581 CRITICAL: Only collects from managers with the SAME scope_id (same orchestrator/plate). 

2582 This prevents cross-contamination between different orchestrators. 

2583 GlobalPipelineConfig (scope_id=None) is shared across all scopes. 

2584 """ 

2585 from openhcs.core.lazy_placeholder_simplified import LazyDefaultPlaceholderService 

2586 from openhcs.config_framework.lazy_factory import get_base_type_for_lazy 

2587 import logging 

2588 logger = logging.getLogger(__name__) 

2589 

2590 live_context = {} 

2591 my_type = type(self.object_instance) 

2592 

2593 logger.info(f"🔍 COLLECT_CONTEXT: {self.field_id} (id={id(self)}) collecting from {len(self._active_form_managers)} managers") 

2594 

2595 for manager in self._active_form_managers: 

2596 if manager is not self: 

2597 # CRITICAL: Only collect from managers in the same scope OR from global scope (None) 

2598 # GlobalPipelineConfig has scope_id=None and affects all orchestrators 

2599 # PipelineConfig/Step editors have scope_id=plate_path and only affect same orchestrator 

2600 if manager.scope_id is not None and self.scope_id is not None and manager.scope_id != self.scope_id: 

2601 continue # Different orchestrator - skip 

2602 

2603 logger.info(f"🔍 COLLECT_CONTEXT: Calling get_user_modified_values() on {manager.field_id} (id={id(manager)})") 

2604 

2605 # CRITICAL: Get only user-modified (concrete, non-None) values 

2606 # This preserves inheritance hierarchy: None values don't override parent values 

2607 live_values = manager.get_user_modified_values() 

2608 obj_type = type(manager.object_instance) 

2609 

2610 # CRITICAL: Only skip if this is EXACTLY the same type as us 

2611 # E.g., PipelineConfig editor should not use live values from another PipelineConfig editor 

2612 # But it SHOULD use live values from GlobalPipelineConfig editor (parent in hierarchy) 

2613 # Don't check lazy/base equivalents here - that's for type matching, not hierarchy filtering 

2614 if obj_type == my_type: 

2615 continue 

2616 

2617 # Map by the actual type 

2618 live_context[obj_type] = live_values 

2619 

2620 # Also map by the base/lazy equivalent type for flexible matching 

2621 # E.g., PipelineConfig and LazyPipelineConfig should both match 

2622 

2623 # If this is a lazy type, also map by its base type 

2624 base_type = get_base_type_for_lazy(obj_type) 

2625 if base_type and base_type != obj_type: 

2626 live_context[base_type] = live_values 

2627 

2628 # If this is a base type, also map by its lazy type 

2629 lazy_type = LazyDefaultPlaceholderService._get_lazy_type_for_base(obj_type) 

2630 if lazy_type and lazy_type != obj_type: 

2631 live_context[lazy_type] = live_values 

2632 

2633 return live_context 

2634 

2635 def _do_cross_window_refresh(self): 

2636 """Actually perform the cross-window placeholder refresh using live values from other windows.""" 

2637 # Collect live context values from other open windows 

2638 live_context = self._collect_live_context_from_other_windows() 

2639 

2640 # Refresh placeholders for this form and all nested forms using live context 

2641 self._refresh_all_placeholders(live_context=live_context) 

2642 self._apply_to_nested_managers(lambda name, manager: manager._refresh_all_placeholders(live_context=live_context)) 

2643 

2644 # CRITICAL: Also refresh enabled styling for all nested managers 

2645 # This ensures that when 'enabled' field changes in another window, styling updates here 

2646 # Example: User changes napari_streaming_config.enabled in one window, other windows update styling 

2647 self._apply_to_nested_managers(lambda name, manager: manager._refresh_enabled_styling()) 

2648 

2649 # CRITICAL: Emit context_refreshed signal to cascade the refresh downstream 

2650 # This allows Step editors to know that PipelineConfig's effective context changed 

2651 # even though no actual field values were modified (only placeholders updated) 

2652 # Example: GlobalPipelineConfig change → PipelineConfig placeholders update → Step editor needs to refresh 

2653 self.context_refreshed.emit(self.object_instance, self.context_obj)