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
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-04 02:09 +0000
1"""
2Dramatically simplified PyQt parameter form manager.
4This demonstrates how the widget implementation can be drastically simplified
5by leveraging the comprehensive shared infrastructure we've built.
6"""
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
14# Performance monitoring
15from openhcs.utils.performance_monitor import timer, get_monitor
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]
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]
37logger = logging.getLogger(__name__)
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
43from openhcs.ui.shared.widget_creation_registry import create_pyqt6_registry
44from .widget_strategies import PyQt6WidgetEnhancer
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
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
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)
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
72class NoneAwareLineEdit(QLineEdit):
73 """QLineEdit that properly handles None values for lazy dataclass contexts."""
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
80 def set_value(self, value):
81 """Set value, handling None properly."""
82 self.setText("" if value is None else str(value))
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.
89 This factory creates reset buttons with consistent styling and configuration,
90 avoiding repeated property setting overhead.
91 """
92 from PyQt6.QtWidgets import QPushButton
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
101class NoneAwareIntEdit(QLineEdit):
102 """QLineEdit that only allows digits and properly handles None values for integer fields."""
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())
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
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))
128class ParameterFormManager(QWidget):
129 """
130 PyQt6 parameter form manager with simplified implementation using generic object introspection.
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
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 """
146 parameter_changed = pyqtSignal(str, object) # param_name, value
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)
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)
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 = []
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
168 # Performance optimization: Skip expensive operations for nested configs
169 OPTIMIZE_NESTED_WIDGETS = True
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
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.
180 Args:
181 param_count: Number of parameters in the form
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
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.
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)
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
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
219 # OPTIMIZATION: Store parent manager reference early so setup_ui() can detect nested configs
220 self._parent_manager = parent_manager
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 = []
227 # Initialize service layer first (needed for parameter extraction)
228 with timer(" Service initialization", threshold_ms=1.0):
229 self.service = ParameterFormService()
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)
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
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 )
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
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
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
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()
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
285 # Track which fields have been explicitly set by users
286 self._user_set_fields: set = set()
288 # Track if initial form load is complete (disable live updates during initial load)
289 self._initial_load_complete = False
291 # OPTIMIZATION: Block cross-window updates during batch operations (e.g., reset_all)
292 self._block_cross_window_updates = False
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()
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
308 # Form structure already analyzed above using UnifiedParameterAnalyzer descriptions
310 # Get widget creator from registry
311 self._widget_creator = create_pyqt6_registry()
313 # Context system handles updates automatically
314 self._context_event_coordinator = None
316 # Set up UI
317 with timer(" Setup UI (widget creation)", threshold_ms=10.0):
318 self.setup_ui()
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)
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)
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()
344 # Connect parameter_changed to emit cross-window context changes
345 self.parameter_changed.connect(self._emit_cross_window_change)
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)
356 # Add this instance to the registry
357 self._active_form_managers.append(self)
359 # Debounce timer for cross-window placeholder refresh
360 self._cross_window_refresh_timer = None
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)
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
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.
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))
387 # Connect to destroyed signal for cleanup
388 self.destroyed.connect(self._on_destroyed)
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()
405 # ==================== GENERIC OBJECT INTROSPECTION METHODS ====================
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.
411 Uses the existing UnifiedParameterAnalyzer for consistent handling of all object types.
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
419 # Use unified analyzer for all object types with exclusions
420 param_info_dict = UnifiedParameterAnalyzer.analyze(obj, exclude_params=exclude_params)
422 parameters = {}
423 parameter_types = {}
425 # CRITICAL FIX: Store parameter descriptions for docstring display
426 self._parameter_descriptions = {}
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
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
439 return parameters, parameter_types, type(obj)
441 # ==================== WIDGET CREATION METHODS ====================
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())
449 def _extract_parameter_defaults(self) -> Dict[str, Any]:
450 """
451 Extract parameter defaults from the object.
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
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
475 # Use unified analyzer to get signature defaults with same exclusions
476 param_info_dict = UnifiedParameterAnalyzer.analyze(analysis_target, exclude_params=self.exclude_params)
478 return {name: info.default_value for name, info in param_info_dict.items()}
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
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)
494 if widget is None:
495 from PyQt6.QtWidgets import QLabel
496 widget = QLabel(f"ERROR: Widget creation failed for {param_name}")
498 return widget
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.
515 This method now simply delegates to the simplified constructor that handles
516 all object types automatically through generic introspection.
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)
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")
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 )
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.
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
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
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 )
575 def setup_ui(self):
576 """Set up the UI layout."""
577 from openhcs.utils.performance_monitor import timer
579 # OPTIMIZATION: Skip expensive operations for nested configs
580 is_nested = hasattr(self, '_parent_manager')
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)
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())
600 # Build form content
601 with timer(" Build form", threshold_ms=5.0):
602 form_widget = self.build_form()
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)
617 def build_form(self) -> QWidget:
618 """Build form UI by delegating to service layer analysis."""
619 from openhcs.utils.performance_monitor import timer
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)
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 = {}
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:]
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)
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()
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
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()
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())
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)
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()
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())
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())
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()
731 return content_widget
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)
745 def _create_widgets_async(self, layout, param_infos, on_complete=None):
746 """Create widgets asynchronously to avoid blocking the UI.
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
757 def create_next_batch():
758 nonlocal index
759 batch_end = min(index + batch_size, len(param_infos))
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)
766 index = batch_end
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)
776 # Start creating widgets
777 QTimer.singleShot(0, create_next_batch)
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
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)
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)
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)
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)
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
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)
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)
835 # Make widget read-only if in read-only mode
836 if self.read_only:
837 self._make_widget_readonly(widget)
839 return container
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)
846 container = QWidget()
847 layout = QVBoxLayout(container)
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)
858 # Get inner type for the actual widget
859 inner_type = ParameterTypeUtils.get_optional_inner_type(param_info.type)
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)
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)
885 checkbox.toggled.connect(on_checkbox_changed)
886 return container
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)
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
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
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)
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 )
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)
922 nested_form = nested_manager.build_form()
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)
933 # Use GroupBoxWithHelp's addWidget method instead of creating our own layout
934 group_box.addWidget(nested_form)
936 self.nested_managers[param_info.name] = nested_manager
938 # CRITICAL: Store the GroupBox in self.widgets so enabled handler can find it
939 self.widgets[param_info.name] = group_box
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")
946 return group_box
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)
953 # Get the unwrapped type for the GroupBox
954 unwrapped_type = ParameterTypeUtils.get_optional_inner_type(param_info.type)
956 # Create GroupBox with custom title widget that includes checkbox
957 from PyQt6.QtGui import QFont
958 group_box = QGroupBox()
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)
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)
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)
986 title_layout.addStretch()
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)
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)
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)
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)
1017 self.nested_managers[param_info.name] = nested_manager
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())
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)
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
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)
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)
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)
1066 checkbox.toggled.connect(on_checkbox_changed)
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
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())
1080 # Register callback with parent manager (will be called after all widgets are created)
1081 self._on_build_complete_callbacks.append(apply_initial_styling)
1083 self.widgets[param_info.name] = group_box
1084 return group_box
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)
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
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
1124 if dataclasses.is_dataclass(actual_type):
1125 object_instance = actual_type()
1126 else:
1127 object_instance = actual_type
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
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)
1148 # Store nested manager
1149 self.nested_managers[param_name] = nested_manager
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))
1160 # Find root manager
1161 root_manager = self
1162 while root_manager._parent_manager is not None:
1163 root_manager = root_manager._parent_manager
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
1171 return nested_manager
1175 def _convert_widget_value(self, value: Any, param_name: str) -> Any:
1176 """
1177 Convert widget value to proper type.
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
1184 param_type = self.parameter_types.get(param_name, type(value))
1186 # PyQt-specific type conversions first
1187 converted_value = convert_widget_value_to_type(value, param_type)
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)
1192 return converted_value
1194 def _emit_parameter_change(self, param_name: str, value: Any) -> None:
1195 """Handle parameter change from widget and update parameter data model."""
1197 # Convert value using unified conversion method
1198 converted_value = self._convert_widget_value(value, param_name)
1200 # Update parameter in data model
1201 self.parameters[param_name] = converted_value
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)
1207 # Emit signal only once - this triggers sibling placeholder updates
1208 self.parameter_changed.emit(param_name, converted_value)
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))
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)
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
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
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()
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))
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]
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)
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
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
1276 # Build overlay from current form state
1277 overlay = self.get_current_values()
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)
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
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
1300 # Framework-specific methods for backward compatibility
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
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
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
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
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())
1334 def update_parameter(self, param_name: str, value: Any) -> None:
1335 """Update parameter value using shared service layer."""
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)
1341 # Update parameter in data model
1342 self.parameters[param_name] = converted_value
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)
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)
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)
1356 def _is_function_parameter(self, param_name: str) -> bool:
1357 """
1358 Detect if parameter is a function parameter vs dataclass field.
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
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
1373 return False
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
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
1387 def _reset_parameter_impl(self, param_name: str) -> None:
1388 """Internal reset implementation."""
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
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)
1399 self.parameter_changed.emit(param_name, reset_value)
1400 return
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)
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
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)
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()
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
1438 # Emit parameter change and return (handled)
1439 self.parameter_changed.emit(param_name, reset_value)
1440 return
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
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
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)
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)
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()
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
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)
1496 # Emit parameter change to notify other components
1497 self.parameter_changed.emit(param_name, reset_value)
1499 def _get_reset_value(self, param_name: str) -> Any:
1500 """Get reset value based on editing context.
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
1516 # For everything else, use signature defaults
1517 return self.param_defaults.get(param_name)
1521 def get_current_values(self) -> Dict[str, Any]:
1522 """
1523 Get current parameter values preserving lazy dataclass structure.
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 = {}
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)
1543 # Checkbox validation is handled in widget creation
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 )
1552 # Lazy dataclasses are now handled by LazyDataclassEditor, so no structure preservation needed
1553 return current_values
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).
1559 For lazy dataclasses, this preserves lazy resolution for unmodified fields
1560 by only returning fields where the raw value is not None.
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()
1568 user_modified = {}
1569 current_values = self.get_current_values()
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
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
1595 return user_modified
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.
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.
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
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
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
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.
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)
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)
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
1656 Returns:
1657 ExitStack with nested contexts
1658 """
1659 from contextlib import ExitStack
1660 from openhcs.config_framework.context_manager import config_context
1662 stack = ExitStack()
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
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)
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}")
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)
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)
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))
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
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()
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)
1769 filtered_parent_values = {k: v for k, v in parent_user_values.items() if k not in excluded_keys}
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
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)
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))
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)
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
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))
1839 return stack
1841 def _apply_initial_enabled_styling(self) -> None:
1842 """Apply initial enabled field styling based on resolved value from widget.
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.
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__)
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
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
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)")
1891 # Call the enabled handler with the resolved value
1892 self._on_enabled_field_changed_universal('enabled', resolved_value)
1894 def _is_any_ancestor_disabled(self) -> bool:
1895 """
1896 Check if any ancestor form has enabled=False.
1898 This is used to determine if a nested config should remain dimmed
1899 even if its own enabled field is True.
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
1914 def _refresh_enabled_styling(self) -> None:
1915 """
1916 Refresh enabled styling for this form and all nested forms.
1918 This should be called when context changes that might affect inherited enabled values.
1919 Similar to placeholder refresh, but for enabled field styling.
1921 CRITICAL: Skip optional dataclass nested managers when instance is None.
1922 """
1923 import logging
1924 logger = logging.getLogger(__name__)
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
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
1956 # Apply styling with the resolved value
1957 self._on_enabled_field_changed_universal('enabled', resolved_value)
1959 # Recursively refresh all nested forms' enabled styling
1960 for nested_manager in self.nested_managers.values():
1961 nested_manager._refresh_enabled_styling()
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.
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.
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
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}")
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
1994 logger.info(f"[ENABLED HANDLER] field_id={self.field_id}, resolved_value={resolved_value}")
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())}")
2006 for widget in all_widgets:
2007 widget_name = f"{widget.__class__.__name__}({widget.objectName() or 'no-name'})"
2008 object_name = widget.objectName()
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
2019 if not belongs_to_nested:
2020 direct_widgets.append(widget)
2021 logger.info(f"[GET_DIRECT_WIDGETS] ✅ INCLUDE {widget_name}")
2023 logger.info(f"[GET_DIRECT_WIDGETS] field_id={self.field_id}, returning {len(direct_widgets)} direct widgets")
2024 return direct_widgets
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}")
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 )
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
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
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}")
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)
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)
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)
2122 def _on_nested_parameter_changed(self, param_name: str, value: Any) -> None:
2123 """
2124 Handle parameter changes from nested forms.
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
2137 # OPTIMIZATION: Skip cross-window context collection during batch operations
2138 if getattr(self, '_block_cross_window_updates', False):
2139 return
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
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
2157 # Refresh parent form's placeholders with live context
2158 self._refresh_all_placeholders(live_context=live_context)
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))
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())
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)
2174 def _refresh_with_live_context(self, live_context: dict = None) -> None:
2175 """Refresh placeholders using live context from other open windows.
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).
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")
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()
2192 # Refresh this form's placeholders
2193 self._refresh_all_placeholders(live_context=live_context)
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))
2199 def _refresh_all_placeholders(self, live_context: dict = None) -> None:
2200 """Refresh placeholder text for all widgets in this form.
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
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
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)
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")
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)
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)
2241 def _apply_all_styling_callbacks(self) -> None:
2242 """Recursively apply all styling callbacks for this manager and all nested managers.
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()
2252 # Recursively apply nested managers' callbacks
2253 for nested_manager in self.nested_managers.values():
2254 nested_manager._apply_all_styling_callbacks()
2256 def _apply_all_post_placeholder_callbacks(self) -> None:
2257 """Recursively apply all post-placeholder callbacks for this manager and all nested managers.
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()
2266 # Recursively apply nested managers' callbacks
2267 for nested_manager in self.nested_managers.values():
2268 nested_manager._apply_all_post_placeholder_callbacks()
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
2280 if key_to_remove:
2281 del self._pending_nested_managers[key_to_remove]
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()
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())
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()
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())
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
2309 # Check if this is an Optional dataclass with a checkbox
2310 param_type = self.parameter_types.get(name)
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
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
2348 def _make_widget_readonly(self, widget: QWidget):
2349 """
2350 Make a widget read-only without greying it out.
2352 Args:
2353 widget: Widget to make read-only
2354 """
2355 from PyQt6.QtWidgets import QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox, QTextEdit, QAbstractSpinBox
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)
2380 # ==================== CROSS-WINDOW CONTEXT UPDATE METHODS ====================
2382 def _emit_cross_window_change(self, param_name: str, value: object):
2383 """Emit cross-window context change signal.
2385 This is connected to parameter_changed signal for root managers.
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
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)
2399 def unregister_from_cross_window_updates(self):
2400 """Manually unregister this form manager from cross-window updates.
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)}")
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
2427 # Remove from registry
2428 self._active_form_managers.remove(self)
2429 logger.info(f"🔍 UNREGISTER: Active managers after: {len(self._active_form_managers)}")
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
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()
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.
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
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
2463 # Debounce the refresh to avoid excessive updates
2464 self._schedule_cross_window_refresh()
2466 def _on_cross_window_context_refreshed(self, editing_object: object, context_object: object):
2467 """Handle cascading placeholder refreshes from upstream windows.
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.
2472 Example: GlobalPipelineConfig changes → PipelineConfig placeholders refresh →
2473 PipelineConfig emits context_refreshed → Step editor refreshes
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
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
2487 # Debounce the refresh to avoid excessive updates
2488 self._schedule_cross_window_refresh()
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.
2493 Hierarchical rules:
2494 - GlobalPipelineConfig changes affect: PipelineConfig, Steps
2495 - PipelineConfig changes affect: Steps in that pipeline
2496 - Step changes affect: nothing (leaf node)
2498 Args:
2499 editing_object: The object being edited in the other window
2500 context_object: The context object used by the other window
2502 Returns:
2503 True if this form should refresh placeholders due to the change
2504 """
2505 from openhcs.core.config import GlobalPipelineConfig, PipelineConfig
2507 # If other window is editing GlobalPipelineConfig, everyone is affected
2508 if isinstance(editing_object, GlobalPipelineConfig):
2509 return True
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
2516 # Step changes don't affect other windows (leaf node)
2517 return False
2519 def _schedule_cross_window_refresh(self):
2520 """Schedule a debounced placeholder refresh for cross-window updates."""
2521 from PyQt6.QtCore import QTimer
2523 # Cancel existing timer if any
2524 if self._cross_window_refresh_timer is not None:
2525 self._cross_window_refresh_timer.stop()
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
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.
2536 Args:
2537 ctx_type: The type to find live values for
2538 live_context: Dict mapping types to their live values
2540 Returns:
2541 Live values dict if found, None otherwise
2542 """
2543 if not live_context:
2544 return None
2546 # Check exact type match first
2547 if ctx_type in live_context:
2548 return live_context[ctx_type]
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
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]
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]
2564 return None
2566 def _collect_live_context_from_other_windows(self):
2567 """Collect live values from other open form managers for context resolution.
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.
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.
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.
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__)
2590 live_context = {}
2591 my_type = type(self.object_instance)
2593 logger.info(f"🔍 COLLECT_CONTEXT: {self.field_id} (id={id(self)}) collecting from {len(self._active_form_managers)} managers")
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
2603 logger.info(f"🔍 COLLECT_CONTEXT: Calling get_user_modified_values() on {manager.field_id} (id={id(manager)})")
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)
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
2617 # Map by the actual type
2618 live_context[obj_type] = live_values
2620 # Also map by the base/lazy equivalent type for flexible matching
2621 # E.g., PipelineConfig and LazyPipelineConfig should both match
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
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
2633 return live_context
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()
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))
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())
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)