Coverage for openhcs/pyqt_gui/widgets/shared/parameter_form_manager.py: 0.0%
594 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-01 18:33 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-01 18:33 +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, Callable, Tuple
11from dataclasses import replace
12from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QLabel, QPushButton, QLineEdit, QCheckBox, QComboBox, QGroupBox
13from PyQt6.QtCore import Qt, pyqtSignal
15# SIMPLIFIED: Removed thread-local imports - dual-axis resolver handles context automatically
16# Mathematical simplification: Shared dispatch tables to eliminate duplication
17WIDGET_UPDATE_DISPATCH = [
18 (QComboBox, 'update_combo_box'),
19 ('get_selected_values', 'update_checkbox_group'),
20 ('set_value', lambda w, v: w.set_value(v)), # Handles NoneAwareCheckBox, NoneAwareIntEdit, etc.
21 ('setValue', lambda w, v: w.setValue(v if v is not None else w.minimum())), # CRITICAL FIX: Set to minimum for None values to enable placeholder
22 ('setText', lambda w, v: v is not None and w.setText(str(v)) or (v is None and w.clear())), # CRITICAL FIX: Handle None values by clearing
23 ('set_path', lambda w, v: w.set_path(v)), # EnhancedPathWidget support
24]
26WIDGET_GET_DISPATCH = [
27 (QComboBox, lambda w: w.itemData(w.currentIndex()) if w.currentIndex() >= 0 else None),
28 ('get_selected_values', lambda w: w.get_selected_values()),
29 ('get_value', lambda w: w.get_value()), # Handles NoneAwareCheckBox, NoneAwareIntEdit, etc.
30 ('value', lambda w: None if (hasattr(w, 'specialValueText') and w.value() == w.minimum() and w.specialValueText()) else w.value()),
31 ('get_path', lambda w: w.get_path()), # EnhancedPathWidget support
32 ('text', lambda w: w.text())
33]
35logger = logging.getLogger(__name__)
37# Import our comprehensive shared infrastructure
38from openhcs.ui.shared.parameter_form_service import ParameterFormService, ParameterInfo
39from openhcs.ui.shared.parameter_form_config_factory import pyqt_config
40from openhcs.ui.shared.parameter_form_constants import CONSTANTS
42from openhcs.ui.shared.widget_creation_registry import create_pyqt6_registry
43from openhcs.ui.shared.ui_utils import format_param_name, format_field_id, format_reset_button_id
44from .widget_strategies import PyQt6WidgetEnhancer
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# Import OpenHCS core components
52from openhcs.core.lazy_placeholder_simplified import LazyDefaultPlaceholderService
53# Old field path detection removed - using simple field name matching
54from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils
60class NoneAwareLineEdit(QLineEdit):
61 """QLineEdit that properly handles None values for lazy dataclass contexts."""
63 def get_value(self):
64 """Get value, returning None for empty text instead of empty string."""
65 text = self.text().strip()
66 return None if text == "" else text
68 def set_value(self, value):
69 """Set value, handling None properly."""
70 self.setText("" if value is None else str(value))
73class NoneAwareIntEdit(QLineEdit):
74 """QLineEdit that only allows digits and properly handles None values for integer fields."""
76 def __init__(self, parent=None):
77 super().__init__(parent)
78 # Set up input validation to only allow digits
79 from PyQt6.QtGui import QIntValidator
80 self.setValidator(QIntValidator())
82 def get_value(self):
83 """Get value, returning None for empty text or converting to int."""
84 text = self.text().strip()
85 if text == "":
86 return None
87 try:
88 return int(text)
89 except ValueError:
90 return None
92 def set_value(self, value):
93 """Set value, handling None properly."""
94 if value is None:
95 self.setText("")
96 else:
97 self.setText(str(value))
100class ParameterFormManager(QWidget):
101 """
102 PyQt6 parameter form manager with simplified implementation using generic object introspection.
104 This implementation leverages the new context management system and supports any object type:
105 - Dataclasses (via dataclasses.fields())
106 - ABC constructors (via inspect.signature())
107 - Step objects (via attribute scanning)
108 - Any object with parameters
110 Key improvements:
111 - Generic object introspection replaces manual parameter specification
112 - Context-driven resolution using config_context() system
113 - Automatic parameter extraction from object instances
114 - Unified interface for all object types
115 - Dramatically simplified constructor (4 parameters vs 12+)
116 """
118 parameter_changed = pyqtSignal(str, object) # param_name, value
120 # Class constants for UI preferences (moved from constructor parameters)
121 DEFAULT_USE_SCROLL_AREA = False
122 DEFAULT_PLACEHOLDER_PREFIX = "Default"
123 DEFAULT_COLOR_SCHEME = None
125 def __init__(self, object_instance: Any, field_id: str, parent=None, context_obj=None):
126 """
127 Initialize PyQt parameter form manager with generic object introspection.
129 Args:
130 object_instance: Any object to build form for (dataclass, ABC constructor, step, etc.)
131 field_id: Unique identifier for the form
132 parent: Optional parent widget
133 context_obj: Context object for placeholder resolution (orchestrator, pipeline_config, etc.)
134 """
135 QWidget.__init__(self, parent)
137 # Store core configuration
138 self.object_instance = object_instance
139 self.field_id = field_id
140 self.context_obj = context_obj
142 # Initialize service layer first (needed for parameter extraction)
143 self.service = ParameterFormService()
145 # Auto-extract parameters and types using generic introspection
146 self.parameters, self.parameter_types, self.dataclass_type = self._extract_parameters_from_object(object_instance)
148 # DELEGATE TO SERVICE LAYER: Analyze form structure using service
149 # Use UnifiedParameterAnalyzer-derived descriptions as the single source of truth
150 parameter_info = getattr(self, '_parameter_descriptions', {})
151 self.form_structure = self.service.analyze_parameters(
152 self.parameters, self.parameter_types, field_id, parameter_info, self.dataclass_type
153 )
155 # Auto-detect configuration settings
156 self.global_config_type = self._auto_detect_global_config_type()
157 self.placeholder_prefix = self.DEFAULT_PLACEHOLDER_PREFIX
159 # Create configuration object with auto-detected settings
160 color_scheme = self.DEFAULT_COLOR_SCHEME or PyQt6ColorScheme()
161 config = pyqt_config(
162 field_id=field_id,
163 color_scheme=color_scheme,
164 function_target=object_instance, # Use object_instance as function_target
165 use_scroll_area=self.DEFAULT_USE_SCROLL_AREA
166 )
167 # IMPORTANT: Keep parameter_info consistent with the analyzer output to avoid losing descriptions
168 config.parameter_info = parameter_info
169 config.dataclass_type = self.dataclass_type
170 config.global_config_type = self.global_config_type
171 config.placeholder_prefix = self.placeholder_prefix
173 # Auto-determine editing mode based on object type analysis
174 config.is_lazy_dataclass = self._is_lazy_dataclass()
175 config.is_global_config_editing = not config.is_lazy_dataclass
177 # Initialize core attributes
178 self.config = config
179 self.param_defaults = self._extract_parameter_defaults()
181 # Initialize tracking attributes
182 self.widgets = {}
183 self.reset_buttons = {} # Track reset buttons for API compatibility
184 self.nested_managers = {}
185 self.reset_fields = set() # Track fields that have been explicitly reset to show inheritance
187 # Track which fields have been explicitly set by users
188 self._user_set_fields: set = set()
190 # Track if initial form load is complete (disable live updates during initial load)
191 self._initial_load_complete = False
193 # SHARED RESET STATE: Track reset fields across all nested managers within this form
194 if hasattr(parent, 'shared_reset_fields'):
195 # Nested manager: use parent's shared reset state
196 self.shared_reset_fields = parent.shared_reset_fields
197 else:
198 # Root manager: create new shared reset state
199 self.shared_reset_fields = set()
201 # Store backward compatibility attributes
202 self.parameter_info = config.parameter_info
203 self.use_scroll_area = config.use_scroll_area
204 self.function_target = config.function_target
205 self.color_scheme = config.color_scheme
207 # Form structure already analyzed above using UnifiedParameterAnalyzer descriptions
209 # Get widget creator from registry
210 self._widget_creator = create_pyqt6_registry()
212 # Context system handles updates automatically
213 self._context_event_coordinator = None
215 # Set up UI
216 self.setup_ui()
218 # Connect parameter changes to live placeholder updates
219 # When any field changes, refresh all placeholders using current form state
220 self.parameter_changed.connect(lambda param_name, value: self._refresh_all_placeholders())
222 # CRITICAL: Detect user-set fields for lazy dataclasses
223 # Check which parameters were explicitly set (raw non-None values)
224 from dataclasses import is_dataclass
225 if is_dataclass(object_instance):
226 for field_name, raw_value in self.parameters.items():
227 # SIMPLE RULE: Raw non-None = user-set, Raw None = inherited
228 if raw_value is not None:
229 self._user_set_fields.add(field_name)
231 # CRITICAL FIX: Refresh placeholders AFTER user-set detection to show correct concrete/placeholder state
232 self._refresh_all_placeholders()
234 # CRITICAL FIX: Ensure nested managers also get their placeholders refreshed after full hierarchy is built
235 # This fixes the issue where nested dataclass placeholders don't load properly on initial form creation
236 self._apply_to_nested_managers(lambda name, manager: manager._refresh_all_placeholders())
238 # Mark initial load as complete - enable live placeholder updates from now on
239 self._initial_load_complete = True
240 print(f"✅ INITIAL LOAD COMPLETE for {self.field_id}: {self._initial_load_complete}")
241 self._apply_to_nested_managers(lambda name, manager: setattr(manager, '_initial_load_complete', True))
243 # ==================== GENERIC OBJECT INTROSPECTION METHODS ====================
245 def _extract_parameters_from_object(self, obj: Any) -> Tuple[Dict[str, Any], Dict[str, Type], Type]:
246 """
247 Extract parameters and types from any object using unified analysis.
249 Uses the existing UnifiedParameterAnalyzer for consistent handling of all object types.
250 """
251 from openhcs.textual_tui.widgets.shared.unified_parameter_analyzer import UnifiedParameterAnalyzer
253 # Use unified analyzer for all object types
254 param_info_dict = UnifiedParameterAnalyzer.analyze(obj)
256 parameters = {}
257 parameter_types = {}
259 # CRITICAL FIX: Store parameter descriptions for docstring display
260 self._parameter_descriptions = {}
262 for name, param_info in param_info_dict.items():
263 # Use the values already extracted by UnifiedParameterAnalyzer
264 # This preserves lazy config behavior (None values for unset fields)
265 parameters[name] = param_info.default_value
266 parameter_types[name] = param_info.param_type
268 # CRITICAL FIX: Preserve parameter descriptions for help display
269 if param_info.description:
270 self._parameter_descriptions[name] = param_info.description
272 return parameters, parameter_types, type(obj)
274 # ==================== WIDGET CREATION METHODS ====================
276 def _auto_detect_global_config_type(self) -> Optional[Type]:
277 """Auto-detect global config type from context."""
278 from openhcs.config_framework import get_base_config_type
279 return getattr(self.context_obj, 'global_config_type', get_base_config_type())
282 def _extract_parameter_defaults(self) -> Dict[str, Any]:
283 """
284 Extract parameter defaults from the object.
286 For reset functionality: returns the initial values used to load widgets.
287 - For functions: signature defaults
288 - For dataclasses: field defaults
289 - For any object: constructor parameter defaults
290 """
291 from openhcs.textual_tui.widgets.shared.unified_parameter_analyzer import UnifiedParameterAnalyzer
293 # Use unified analyzer to get defaults
294 param_info_dict = UnifiedParameterAnalyzer.analyze(self.object_instance)
296 return {name: info.default_value for name, info in param_info_dict.items()}
298 def _is_lazy_dataclass(self) -> bool:
299 """Check if the object represents a lazy dataclass."""
300 if hasattr(self.object_instance, '_resolve_field_value'):
301 return True
302 if self.dataclass_type:
303 from openhcs.core.lazy_placeholder_simplified import LazyDefaultPlaceholderService
304 return LazyDefaultPlaceholderService.has_lazy_resolution(self.dataclass_type)
305 return False
307 def create_widget(self, param_name: str, param_type: Type, current_value: Any,
308 widget_id: str, parameter_info: Any = None) -> Any:
309 """Create widget using the registry creator function."""
310 widget = self._widget_creator(param_name, param_type, current_value, widget_id, parameter_info)
312 if widget is None:
313 from PyQt6.QtWidgets import QLabel
314 widget = QLabel(f"ERROR: Widget creation failed for {param_name}")
316 return widget
321 @classmethod
322 def from_dataclass_instance(cls, dataclass_instance: Any, field_id: str,
323 placeholder_prefix: str = "Default",
324 parent=None, use_scroll_area: bool = True,
325 function_target=None, color_scheme=None,
326 force_show_all_fields: bool = False,
327 global_config_type: Optional[Type] = None,
328 context_event_coordinator=None, context_obj=None):
329 """
330 SIMPLIFIED: Create ParameterFormManager using new generic constructor.
332 This method now simply delegates to the simplified constructor that handles
333 all object types automatically through generic introspection.
335 Args:
336 dataclass_instance: The dataclass instance to edit
337 field_id: Unique identifier for the form
338 context_obj: Context object for placeholder resolution
339 **kwargs: Legacy parameters (ignored - handled automatically)
341 Returns:
342 ParameterFormManager configured for any object type
343 """
344 # Validate input
345 from dataclasses import is_dataclass
346 if not is_dataclass(dataclass_instance):
347 raise ValueError(f"{type(dataclass_instance)} is not a dataclass")
349 # Use simplified constructor with automatic parameter extraction
350 # CRITICAL: Do NOT default context_obj to dataclass_instance
351 # This creates circular context bug where form uses itself as parent
352 # Caller must explicitly pass context_obj if needed (e.g., Step Editor passes pipeline_config)
353 return cls(
354 object_instance=dataclass_instance,
355 field_id=field_id,
356 parent=parent,
357 context_obj=context_obj # No default - None means inherit from thread-local global only
358 )
360 @classmethod
361 def from_object(cls, object_instance: Any, field_id: str, parent=None, context_obj=None):
362 """
363 NEW: Create ParameterFormManager for any object type using generic introspection.
365 This is the new primary factory method that works with:
366 - Dataclass instances and types
367 - ABC constructors and functions
368 - Step objects with config attributes
369 - Any object with parameters
371 Args:
372 object_instance: Any object to build form for
373 field_id: Unique identifier for the form
374 parent: Optional parent widget
375 context_obj: Context object for placeholder resolution
377 Returns:
378 ParameterFormManager configured for the object type
379 """
380 return cls(
381 object_instance=object_instance,
382 field_id=field_id,
383 parent=parent,
384 context_obj=context_obj
385 )
389 def setup_ui(self):
390 """Set up the UI layout."""
391 layout = QVBoxLayout(self)
392 # Apply configurable layout settings
393 layout.setSpacing(CURRENT_LAYOUT.main_layout_spacing)
394 layout.setContentsMargins(*CURRENT_LAYOUT.main_layout_margins)
396 # Apply centralized widget styling for uniform appearance (same as config_window.py)
397 from openhcs.pyqt_gui.shared.style_generator import StyleSheetGenerator
398 style_gen = StyleSheetGenerator(self.color_scheme)
399 self.setStyleSheet(style_gen.generate_config_window_style())
401 # Build form content
402 form_widget = self.build_form()
404 # Add scroll area if requested
405 if self.config.use_scroll_area:
406 scroll_area = QScrollArea()
407 scroll_area.setWidgetResizable(True)
408 scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
409 scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
410 scroll_area.setWidget(form_widget)
411 layout.addWidget(scroll_area)
412 else:
413 layout.addWidget(form_widget)
415 def build_form(self) -> QWidget:
416 """Build form UI by delegating to service layer analysis."""
417 content_widget = QWidget()
418 content_layout = QVBoxLayout(content_widget)
419 content_layout.setSpacing(CURRENT_LAYOUT.content_layout_spacing)
420 content_layout.setContentsMargins(*CURRENT_LAYOUT.content_layout_margins)
422 # DELEGATE TO SERVICE LAYER: Use analyzed form structure
423 for param_info in self.form_structure.parameters:
424 if param_info.is_optional and param_info.is_nested:
425 # Optional[Dataclass]: show checkbox
426 widget = self._create_optional_dataclass_widget(param_info)
427 elif param_info.is_nested:
428 # Direct dataclass (non-optional): nested group without checkbox
429 widget = self._create_nested_dataclass_widget(param_info)
430 else:
431 # All regular types (including Optional[regular]) use regular widgets with None-aware behavior
432 widget = self._create_regular_parameter_widget(param_info)
433 content_layout.addWidget(widget)
435 return content_widget
437 def _create_regular_parameter_widget(self, param_info) -> QWidget:
438 """Create widget for regular parameter - DELEGATE TO SERVICE LAYER."""
439 display_info = self.service.get_parameter_display_info(param_info.name, param_info.type, param_info.description)
440 field_ids = self.service.generate_field_ids_direct(self.config.field_id, param_info.name)
442 container = QWidget()
443 layout = QHBoxLayout(container)
444 layout.setSpacing(CURRENT_LAYOUT.parameter_row_spacing)
445 layout.setContentsMargins(*CURRENT_LAYOUT.parameter_row_margins)
447 # Label
448 label = LabelWithHelp(
449 text=display_info['field_label'], param_name=param_info.name,
450 param_description=display_info['description'], param_type=param_info.type,
451 color_scheme=self.config.color_scheme or PyQt6ColorScheme()
452 )
453 layout.addWidget(label)
455 # Widget
456 current_value = self.parameters.get(param_info.name)
457 widget = self.create_widget(param_info.name, param_info.type, current_value, field_ids['widget_id'])
458 widget.setObjectName(field_ids['widget_id'])
459 layout.addWidget(widget, 1)
461 # Reset button
462 reset_button = QPushButton(CONSTANTS.RESET_BUTTON_TEXT)
463 reset_button.setObjectName(field_ids['reset_button_id'])
464 reset_button.setMaximumWidth(CURRENT_LAYOUT.reset_button_width)
465 reset_button.clicked.connect(lambda: self.reset_parameter(param_info.name))
466 layout.addWidget(reset_button)
468 # Store widgets and connect signals
469 self.widgets[param_info.name] = widget
470 PyQt6WidgetEnhancer.connect_change_signal(widget, param_info.name, self._emit_parameter_change)
472 # CRITICAL FIX: Apply placeholder behavior after widget creation
473 current_value = self.parameters.get(param_info.name)
474 self._apply_context_behavior(widget, current_value, param_info.name)
476 return container
478 def _create_optional_regular_widget(self, param_info) -> QWidget:
479 """Create widget for Optional[regular_type] - checkbox + regular widget."""
480 display_info = self.service.get_parameter_display_info(param_info.name, param_info.type, param_info.description)
481 field_ids = self.service.generate_field_ids_direct(self.config.field_id, param_info.name)
483 container = QWidget()
484 layout = QVBoxLayout(container)
486 # Checkbox (using NoneAwareCheckBox for consistency)
487 from openhcs.pyqt_gui.widgets.shared.no_scroll_spinbox import NoneAwareCheckBox
488 checkbox = NoneAwareCheckBox()
489 checkbox.setText(display_info['checkbox_label'])
490 checkbox.setObjectName(field_ids['optional_checkbox_id'])
491 current_value = self.parameters.get(param_info.name)
492 checkbox.setChecked(current_value is not None)
493 layout.addWidget(checkbox)
495 # Get inner type for the actual widget
496 from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils
497 inner_type = ParameterTypeUtils.get_optional_inner_type(param_info.type)
499 # Create the actual widget for the inner type
500 inner_widget = self._create_regular_parameter_widget_for_type(param_info.name, inner_type, current_value)
501 inner_widget.setEnabled(current_value is not None) # Disable if None
502 layout.addWidget(inner_widget)
504 # Connect checkbox to enable/disable the inner widget
505 def on_checkbox_changed(checked):
506 inner_widget.setEnabled(checked)
507 if checked:
508 # Set to default value for the inner type
509 if inner_type == str:
510 default_value = ""
511 elif inner_type == int:
512 default_value = 0
513 elif inner_type == float:
514 default_value = 0.0
515 elif inner_type == bool:
516 default_value = False
517 else:
518 default_value = None
519 self.update_parameter(param_info.name, default_value)
520 else:
521 self.update_parameter(param_info.name, None)
523 checkbox.toggled.connect(on_checkbox_changed)
524 return container
526 def _create_regular_parameter_widget_for_type(self, param_name: str, param_type: Type, current_value: Any) -> QWidget:
527 """Create a regular parameter widget for a specific type."""
528 field_ids = self.service.generate_field_ids_direct(self.config.field_id, param_name)
530 # Use the existing create_widget method
531 widget = self.create_widget(param_name, param_type, current_value, field_ids['widget_id'])
532 if widget:
533 return widget
535 # Fallback to basic text input
536 from PyQt6.QtWidgets import QLineEdit
537 fallback_widget = QLineEdit()
538 fallback_widget.setText(str(current_value or ""))
539 fallback_widget.setObjectName(field_ids['widget_id'])
540 return fallback_widget
542 def _create_nested_dataclass_widget(self, param_info) -> QWidget:
543 """Create widget for nested dataclass - DELEGATE TO SERVICE LAYER."""
544 display_info = self.service.get_parameter_display_info(param_info.name, param_info.type, param_info.description)
546 # Always use the inner dataclass type for Optional[T] when wiring help/paths
547 from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils
548 unwrapped_type = (
549 ParameterTypeUtils.get_optional_inner_type(param_info.type)
550 if ParameterTypeUtils.is_optional_dataclass(param_info.type)
551 else param_info.type
552 )
554 group_box = GroupBoxWithHelp(
555 title=display_info['field_label'], help_target=unwrapped_type,
556 color_scheme=self.config.color_scheme or PyQt6ColorScheme()
557 )
558 current_value = self.parameters.get(param_info.name)
559 nested_manager = self._create_nested_form_inline(param_info.name, unwrapped_type, current_value)
561 nested_form = nested_manager.build_form()
563 # Use GroupBoxWithHelp's addWidget method instead of creating our own layout
564 group_box.addWidget(nested_form)
566 self.nested_managers[param_info.name] = nested_manager
567 return group_box
569 def _create_optional_dataclass_widget(self, param_info) -> QWidget:
570 """Create widget for optional dataclass - checkbox integrated into GroupBox title."""
571 display_info = self.service.get_parameter_display_info(param_info.name, param_info.type, param_info.description)
572 field_ids = self.service.generate_field_ids_direct(self.config.field_id, param_info.name)
574 # Get the unwrapped type for the GroupBox
575 from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils
576 unwrapped_type = ParameterTypeUtils.get_optional_inner_type(param_info.type)
578 # Create GroupBox with custom title widget that includes checkbox
579 from PyQt6.QtGui import QFont
580 group_box = QGroupBox()
582 # Create custom title widget with checkbox + title + help button (all inline)
583 title_widget = QWidget()
584 title_layout = QHBoxLayout(title_widget)
585 title_layout.setSpacing(5)
586 title_layout.setContentsMargins(10, 5, 10, 5)
588 # Checkbox (compact, no text)
589 from openhcs.pyqt_gui.widgets.shared.no_scroll_spinbox import NoneAwareCheckBox
590 checkbox = NoneAwareCheckBox()
591 checkbox.setObjectName(field_ids['optional_checkbox_id'])
592 current_value = self.parameters.get(param_info.name)
593 checkbox.setChecked(current_value is not None)
594 checkbox.setMaximumWidth(20)
595 title_layout.addWidget(checkbox)
597 # Title label (clickable to toggle checkbox, matches GroupBoxWithHelp styling)
598 title_label = QLabel(display_info['checkbox_label'])
599 title_font = QFont()
600 title_font.setBold(True)
601 title_label.setFont(title_font)
602 title_label.mousePressEvent = lambda e: checkbox.toggle()
603 title_label.setCursor(Qt.CursorShape.PointingHandCursor)
604 title_layout.addWidget(title_label)
606 # Help button (matches GroupBoxWithHelp)
607 from openhcs.pyqt_gui.widgets.shared.clickable_help_components import HelpButton
608 help_btn = HelpButton(help_target=unwrapped_type, text="?", color_scheme=self.color_scheme)
609 help_btn.setMaximumWidth(25)
610 help_btn.setMaximumHeight(20)
611 title_layout.addWidget(help_btn)
613 title_layout.addStretch()
615 # Set the custom title widget as the GroupBox title
616 group_box.setLayout(QVBoxLayout())
617 group_box.layout().setSpacing(0)
618 group_box.layout().setContentsMargins(0, 0, 0, 0)
619 group_box.layout().addWidget(title_widget)
621 # Create nested form
622 nested_manager = self._create_nested_form_inline(param_info.name, unwrapped_type, current_value)
623 nested_form = nested_manager.build_form()
624 nested_form.setEnabled(current_value is not None)
625 group_box.layout().addWidget(nested_form)
627 self.nested_managers[param_info.name] = nested_manager
629 # Connect checkbox to enable/disable with visual feedback
630 def on_checkbox_changed(checked):
631 nested_form.setEnabled(checked)
632 # Apply visual feedback to all input widgets
633 if checked:
634 # Restore normal color (no explicit style needed - font is already bold)
635 title_label.setStyleSheet("")
636 help_btn.setEnabled(True)
637 # Remove dimming from all widgets
638 for widget in nested_form.findChildren(QWidget):
639 widget.setGraphicsEffect(None)
640 # Create default instance
641 default_instance = unwrapped_type()
642 self.update_parameter(param_info.name, default_instance)
643 else:
644 # Dim title text but keep help button enabled
645 title_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_disabled)};")
646 help_btn.setEnabled(True) # Keep help button clickable even when disabled
647 # Dim all input widgets
648 from PyQt6.QtWidgets import QGraphicsOpacityEffect
649 for widget in nested_form.findChildren((QLineEdit, QComboBox, QPushButton)):
650 effect = QGraphicsOpacityEffect()
651 effect.setOpacity(0.4)
652 widget.setGraphicsEffect(effect)
653 self.update_parameter(param_info.name, None)
655 checkbox.toggled.connect(on_checkbox_changed)
656 on_checkbox_changed(checkbox.isChecked())
658 self.widgets[param_info.name] = group_box
659 return group_box
669 def _create_nested_form_inline(self, param_name: str, param_type: Type, current_value: Any) -> Any:
670 """Create nested form - simplified to let constructor handle parameter extraction"""
671 # Get actual field path from FieldPathDetector (no artificial "nested_" prefix)
672 # For function parameters (no parent dataclass), use parameter name directly
673 if self.dataclass_type is None:
674 field_path = param_name
675 else:
676 field_path = self.service.get_field_path_with_fail_loud(self.dataclass_type, param_type)
678 # Use current_value if available, otherwise create a default instance of the dataclass type
679 # The constructor will handle parameter extraction automatically
680 if current_value is not None:
681 # If current_value is a dict (saved config), convert it back to dataclass instance
682 import dataclasses
683 # Unwrap Optional type to get actual dataclass type
684 from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils
685 actual_type = ParameterTypeUtils.get_optional_inner_type(param_type) if ParameterTypeUtils.is_optional(param_type) else param_type
687 if isinstance(current_value, dict) and dataclasses.is_dataclass(actual_type):
688 # Convert dict back to dataclass instance
689 object_instance = actual_type(**current_value)
690 else:
691 object_instance = current_value
692 else:
693 # Create a default instance of the dataclass type for parameter extraction
694 import dataclasses
695 # Unwrap Optional type to get actual dataclass type
696 from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils
697 actual_type = ParameterTypeUtils.get_optional_inner_type(param_type) if ParameterTypeUtils.is_optional(param_type) else param_type
699 if dataclasses.is_dataclass(actual_type):
700 object_instance = actual_type()
701 else:
702 object_instance = actual_type
704 # DELEGATE TO NEW CONSTRUCTOR: Use simplified constructor
705 nested_manager = ParameterFormManager(
706 object_instance=object_instance,
707 field_id=field_path,
708 parent=self,
709 context_obj=self.context_obj
710 )
711 # Inherit lazy/global editing context from parent so resets behave correctly in nested forms
712 try:
713 nested_manager.config.is_lazy_dataclass = self.config.is_lazy_dataclass
714 nested_manager.config.is_global_config_editing = not self.config.is_lazy_dataclass
715 except Exception:
716 pass
718 # Store parent manager reference for placeholder resolution
719 nested_manager._parent_manager = self
721 # Connect nested manager's parameter_changed signal to parent's refresh handler
722 # This ensures changes in nested forms trigger placeholder updates in parent and siblings
723 nested_manager.parameter_changed.connect(self._on_nested_parameter_changed)
725 # Store nested manager
726 self.nested_managers[param_name] = nested_manager
728 return nested_manager
732 def _convert_widget_value(self, value: Any, param_name: str) -> Any:
733 """
734 Convert widget value to proper type.
736 Applies both PyQt-specific conversions (Path, tuple/list parsing) and
737 service layer conversions (enums, basic types, Union handling).
738 """
739 from openhcs.pyqt_gui.widgets.shared.widget_strategies import convert_widget_value_to_type
741 param_type = self.parameter_types.get(param_name, type(value))
743 # PyQt-specific type conversions first
744 converted_value = convert_widget_value_to_type(value, param_type)
746 # Then apply service layer conversion (enums, basic types, Union handling, etc.)
747 converted_value = self.service.convert_value_to_type(converted_value, param_type, param_name, self.dataclass_type)
749 return converted_value
751 def _emit_parameter_change(self, param_name: str, value: Any) -> None:
752 """Handle parameter change from widget and update parameter data model."""
753 # Convert value using unified conversion method
754 converted_value = self._convert_widget_value(value, param_name)
756 # Update parameter in data model
757 self.parameters[param_name] = converted_value
759 # CRITICAL FIX: Track that user explicitly set this field
760 # This prevents placeholder updates from destroying user values
761 self._user_set_fields.add(param_name)
763 # Emit signal only once - this triggers sibling placeholder updates
764 self.parameter_changed.emit(param_name, converted_value)
768 def update_widget_value(self, widget: QWidget, value: Any, param_name: str = None, skip_context_behavior: bool = False, exclude_field: str = None) -> None:
769 """Mathematical simplification: Unified widget update using shared dispatch."""
770 self._execute_with_signal_blocking(widget, lambda: self._dispatch_widget_update(widget, value))
772 # Only apply context behavior if not explicitly skipped (e.g., during reset operations)
773 if not skip_context_behavior:
774 self._apply_context_behavior(widget, value, param_name, exclude_field)
776 def _dispatch_widget_update(self, widget: QWidget, value: Any) -> None:
777 """Algebraic simplification: Single dispatch logic for all widget updates."""
778 for matcher, updater in WIDGET_UPDATE_DISPATCH:
779 if isinstance(widget, matcher) if isinstance(matcher, type) else hasattr(widget, matcher):
780 if isinstance(updater, str):
781 getattr(self, f'_{updater}')(widget, value)
782 else:
783 updater(widget, value)
784 return
786 def _clear_widget_to_default_state(self, widget: QWidget) -> None:
787 """Clear widget to its default/empty state for reset operations."""
788 from PyQt6.QtWidgets import QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox, QCheckBox, QTextEdit
790 if isinstance(widget, QLineEdit):
791 widget.clear()
792 elif isinstance(widget, (QSpinBox, QDoubleSpinBox)):
793 widget.setValue(widget.minimum())
794 elif isinstance(widget, QComboBox):
795 widget.setCurrentIndex(-1) # No selection
796 elif isinstance(widget, QCheckBox):
797 widget.setChecked(False)
798 elif isinstance(widget, QTextEdit):
799 widget.clear()
800 else:
801 # For custom widgets, try to call clear() if available
802 if hasattr(widget, 'clear'):
803 widget.clear()
805 def _update_combo_box(self, widget: QComboBox, value: Any) -> None:
806 """Update combo box with value matching."""
807 widget.setCurrentIndex(-1 if value is None else
808 next((i for i in range(widget.count()) if widget.itemData(i) == value), -1))
810 def _update_checkbox_group(self, widget: QWidget, value: Any) -> None:
811 """Update checkbox group using functional operations."""
812 if hasattr(widget, '_checkboxes') and isinstance(value, list):
813 # Functional: reset all, then set selected
814 [cb.setChecked(False) for cb in widget._checkboxes.values()]
815 [widget._checkboxes[v].setChecked(True) for v in value if v in widget._checkboxes]
817 def _execute_with_signal_blocking(self, widget: QWidget, operation: callable) -> None:
818 """Execute operation with signal blocking - stateless utility."""
819 widget.blockSignals(True)
820 operation()
821 widget.blockSignals(False)
823 def _apply_context_behavior(self, widget: QWidget, value: Any, param_name: str, exclude_field: str = None) -> None:
824 """CONSOLIDATED: Apply placeholder behavior using single resolution path."""
825 if not param_name or not self.dataclass_type:
826 return
828 if value is None:
829 # Allow placeholder application for nested forms even if they're not detected as lazy dataclasses
830 # The placeholder service will determine if placeholders are available
832 # Build overlay from current form state
833 overlay = self.get_current_values()
835 # Build context stack: parent context + overlay
836 with self._build_context_stack(overlay):
837 placeholder_text = self.service.get_placeholder_text(param_name, self.dataclass_type)
838 if placeholder_text:
839 PyQt6WidgetEnhancer.apply_placeholder_text(widget, placeholder_text)
840 elif value is not None:
841 PyQt6WidgetEnhancer._clear_placeholder_state(widget)
844 def get_widget_value(self, widget: QWidget) -> Any:
845 """Mathematical simplification: Unified widget value extraction using shared dispatch."""
846 for matcher, extractor in WIDGET_GET_DISPATCH:
847 if isinstance(widget, matcher) if isinstance(matcher, type) else hasattr(widget, matcher):
848 return extractor(widget)
849 return None
851 # Framework-specific methods for backward compatibility
853 def reset_all_parameters(self) -> None:
854 """Reset all parameters - let reset_parameter handle everything."""
855 try:
856 # CRITICAL FIX: Create a copy of keys to avoid "dictionary changed during iteration" error
857 # reset_parameter can modify self.parameters by removing keys, so we need a stable list
858 param_names = list(self.parameters.keys())
859 for param_name in param_names:
860 self.reset_parameter(param_name)
862 # Also refresh placeholders in nested managers after recursive resets
863 self._apply_to_nested_managers(lambda name, manager: manager._refresh_all_placeholders())
865 # Handle nested managers once at the end
866 if self.dataclass_type and self.nested_managers:
867 current_config = getattr(self, '_current_config_instance', None)
868 if current_config:
869 self.service.reset_nested_managers(self.nested_managers, self.dataclass_type, current_config)
870 finally:
871 # Context system handles placeholder updates automatically
872 self._refresh_all_placeholders()
876 def update_parameter(self, param_name: str, value: Any) -> None:
877 """Update parameter value using shared service layer."""
879 if param_name in self.parameters:
880 # Convert value using service layer
881 converted_value = self.service.convert_value_to_type(value, self.parameter_types.get(param_name, type(value)), param_name, self.dataclass_type)
883 # Update parameter in data model
884 self.parameters[param_name] = converted_value
886 # CRITICAL FIX: Track that user explicitly set this field
887 # This prevents placeholder updates from destroying user values
888 self._user_set_fields.add(param_name)
890 # Update corresponding widget if it exists
891 if param_name in self.widgets:
892 self.update_widget_value(self.widgets[param_name], converted_value)
894 # Emit signal for PyQt6 compatibility
895 self.parameter_changed.emit(param_name, converted_value)
897 def _is_function_parameter(self, param_name: str) -> bool:
898 """
899 Detect if parameter is a function parameter vs dataclass field.
901 Function parameters should not be reset against dataclass types.
902 This prevents the critical bug where step editor tries to reset
903 function parameters like 'group_by' against the global config type.
904 """
905 if not self.function_target or not self.dataclass_type:
906 return False
908 # Check if parameter exists in dataclass fields
909 import dataclasses
910 if dataclasses.is_dataclass(self.dataclass_type):
911 field_names = {field.name for field in dataclasses.fields(self.dataclass_type)}
912 # If parameter is NOT in dataclass fields, it's a function parameter
913 return param_name not in field_names
915 return False
917 def reset_parameter(self, param_name: str, default_value: Any = None) -> None:
918 """Reset parameter with predictable behavior."""
919 if param_name not in self.parameters:
920 return
922 # SIMPLIFIED: Handle function forms vs config forms
923 if hasattr(self, 'param_defaults') and self.param_defaults and param_name in self.param_defaults:
924 # Function form - reset to static defaults
925 reset_value = self.param_defaults[param_name]
926 self.parameters[param_name] = reset_value
928 if param_name in self.widgets:
929 widget = self.widgets[param_name]
930 self.update_widget_value(widget, reset_value, param_name, skip_context_behavior=True)
932 self.parameter_changed.emit(param_name, reset_value)
933 return
935 # Special handling for dataclass fields
936 try:
937 import dataclasses as _dc
938 from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils
939 param_type = self.parameter_types.get(param_name)
941 # If this is an Optional[Dataclass], sync container UI and reset nested manager
942 if param_type and ParameterTypeUtils.is_optional_dataclass(param_type):
943 # Determine reset (lazy -> None)
944 reset_value = self._get_reset_value(param_name)
945 self.parameters[param_name] = reset_value
947 if param_name in self.widgets:
948 container = self.widgets[param_name]
949 # Toggle the optional checkbox to match reset_value (None -> unchecked)
950 from PyQt6.QtWidgets import QCheckBox
951 ids = self.service.generate_field_ids_direct(self.config.field_id, param_name)
952 checkbox = container.findChild(QCheckBox, ids['optional_checkbox_id'])
953 if checkbox:
954 checkbox.blockSignals(True)
955 checkbox.setChecked(reset_value is not None)
956 checkbox.blockSignals(False)
958 # Reset nested manager contents too
959 nested_manager = self.nested_managers.get(param_name)
960 if nested_manager and hasattr(nested_manager, 'reset_all_parameters'):
961 nested_manager.reset_all_parameters()
963 # Enable/disable the nested group visually without relying on signals
964 try:
965 from .clickable_help_components import GroupBoxWithHelp
966 group = container.findChild(GroupBoxWithHelp) if param_name in self.widgets else None
967 if group:
968 group.setEnabled(reset_value is not None)
969 except Exception:
970 pass
972 # Emit parameter change and return (handled)
973 self.parameter_changed.emit(param_name, reset_value)
974 return
976 # If this is a direct dataclass field (non-optional), do NOT replace the instance.
977 # Instead, keep the container value and recursively reset the nested manager.
978 if param_type and _dc.is_dataclass(param_type):
979 nested_manager = self.nested_managers.get(param_name)
980 if nested_manager and hasattr(nested_manager, 'reset_all_parameters'):
981 nested_manager.reset_all_parameters()
982 # Do not modify self.parameters[param_name] (keep current dataclass instance)
983 # Refresh placeholder on the group container if it has a widget
984 if param_name in self.widgets:
985 self._apply_context_behavior(self.widgets[param_name], None, param_name)
986 # Emit parameter change with unchanged container value
987 self.parameter_changed.emit(param_name, self.parameters.get(param_name))
988 return
989 except Exception:
990 # Fall through to generic handling if type checks fail
991 pass
993 # Generic config field reset - use context-aware reset value
994 reset_value = self._get_reset_value(param_name)
995 self.parameters[param_name] = reset_value
997 # Track reset fields only for lazy behavior (when reset_value is None)
998 if reset_value is None:
999 self.reset_fields.add(param_name)
1000 # SHARED RESET STATE: Also add to shared reset state for coordination with nested managers
1001 field_path = f"{self.field_id}.{param_name}"
1002 self.shared_reset_fields.add(field_path)
1003 else:
1004 # For concrete values, remove from reset tracking
1005 self.reset_fields.discard(param_name)
1006 field_path = f"{self.field_id}.{param_name}"
1007 self.shared_reset_fields.discard(field_path)
1009 # Update widget with reset value
1010 if param_name in self.widgets:
1011 widget = self.widgets[param_name]
1012 self.update_widget_value(widget, reset_value, param_name)
1014 # Apply placeholder only if reset value is None (lazy behavior)
1015 if reset_value is None:
1016 # Build overlay from current form state
1017 overlay = self.get_current_values()
1019 # Build context stack: parent context + overlay
1020 with self._build_context_stack(overlay):
1021 placeholder_text = self.service.get_placeholder_text(param_name, self.dataclass_type)
1022 if placeholder_text:
1023 from openhcs.pyqt_gui.widgets.shared.widget_strategies import PyQt6WidgetEnhancer
1024 PyQt6WidgetEnhancer.apply_placeholder_text(widget, placeholder_text)
1026 # Emit parameter change to notify other components
1027 self.parameter_changed.emit(param_name, reset_value)
1029 def _get_reset_value(self, param_name: str) -> Any:
1030 """
1031 Get reset value - simple and uniform for all object types.
1033 Just use the initial value that was used to load the widget.
1034 This works for functions, dataclasses, ABCs, anything.
1035 """
1036 return self.param_defaults.get(param_name)
1040 def get_current_values(self) -> Dict[str, Any]:
1041 """
1042 Get current parameter values preserving lazy dataclass structure.
1044 This fixes the lazy default materialization override saving issue by ensuring
1045 that lazy dataclasses maintain their structure when values are retrieved.
1046 """
1047 # CRITICAL FIX: Read actual current values from widgets, not initial parameters
1048 current_values = {}
1050 # Read current values from widgets
1051 for param_name in self.parameters.keys():
1052 widget = self.widgets.get(param_name)
1053 if widget:
1054 raw_value = self.get_widget_value(widget)
1055 # Apply unified type conversion
1056 current_values[param_name] = self._convert_widget_value(raw_value, param_name)
1057 else:
1058 # Fallback to initial parameter value if no widget
1059 current_values[param_name] = self.parameters.get(param_name)
1061 # Checkbox validation is handled in widget creation
1063 # Collect values from nested managers, respecting optional dataclass checkbox states
1064 self._apply_to_nested_managers(
1065 lambda name, manager: self._process_nested_values_if_checkbox_enabled(
1066 name, manager, current_values
1067 )
1068 )
1070 # Lazy dataclasses are now handled by LazyDataclassEditor, so no structure preservation needed
1071 return current_values
1073 def get_user_modified_values(self) -> Dict[str, Any]:
1074 """
1075 Get only values that were explicitly set by the user (non-None raw values).
1077 For lazy dataclasses, this preserves lazy resolution for unmodified fields
1078 by only returning fields where the raw value is not None.
1080 For nested dataclasses, only include them if they have user-modified fields inside.
1081 """
1082 if not hasattr(self.config, '_resolve_field_value'):
1083 # For non-lazy dataclasses, return all current values
1084 return self.get_current_values()
1086 user_modified = {}
1087 current_values = self.get_current_values()
1089 # Only include fields where the raw value is not None
1090 for field_name, value in current_values.items():
1091 if value is not None:
1092 # CRITICAL: For nested dataclasses, extract raw values to prevent resolution pollution
1093 # We need to rebuild the nested dataclass with only raw non-None values
1094 from dataclasses import is_dataclass, fields as dataclass_fields
1095 if is_dataclass(value) and not isinstance(value, type):
1096 # Extract raw field values from nested dataclass
1097 nested_raw_values = {}
1098 for field in dataclass_fields(value):
1099 raw_value = object.__getattribute__(value, field.name)
1100 if raw_value is not None:
1101 nested_raw_values[field.name] = raw_value
1103 # Only include if nested dataclass has user-modified fields
1104 # Recreate the instance with only raw values
1105 if nested_raw_values:
1106 user_modified[field_name] = type(value)(**nested_raw_values)
1107 else:
1108 # Non-dataclass field, include if not None
1109 user_modified[field_name] = value
1111 return user_modified
1113 def _build_context_stack(self, overlay):
1114 """Build nested config_context() calls for placeholder resolution.
1116 Context stack order:
1117 1. Thread-local global config (automatic base)
1118 2. Parent context(s) from self.context_obj (if provided)
1119 3. Overlay from current form values (always applied last)
1121 Args:
1122 overlay: Current form values (from get_current_values()) - dict or dataclass instance
1124 Returns:
1125 ExitStack with nested contexts
1126 """
1127 from contextlib import ExitStack
1128 from openhcs.config_framework.context_manager import config_context
1130 stack = ExitStack()
1132 # Apply parent context(s) if provided
1133 if self.context_obj is not None:
1134 if isinstance(self.context_obj, list):
1135 # Multiple parent contexts (future: deeply nested editors)
1136 for ctx in self.context_obj:
1137 stack.enter_context(config_context(ctx))
1138 else:
1139 # Single parent context (Step Editor: pipeline_config)
1140 stack.enter_context(config_context(self.context_obj))
1142 # CRITICAL: For nested forms, include parent's USER-MODIFIED values for sibling inheritance
1143 # This allows live placeholder updates when sibling fields change
1144 # ONLY enable this AFTER initial form load to avoid polluting placeholders with initial widget values
1145 parent_manager = getattr(self, '_parent_manager', None)
1146 if (parent_manager and
1147 hasattr(parent_manager, 'get_user_modified_values') and
1148 hasattr(parent_manager, 'dataclass_type') and
1149 parent_manager._initial_load_complete): # Check PARENT's initial load flag
1151 # Get only user-modified values from parent (not all values)
1152 # This prevents polluting context with stale/default values
1153 parent_user_values = parent_manager.get_user_modified_values()
1155 print(f"🔍 PARENT OVERLAY for {self.field_id}:")
1156 print(f" Parent user values: {list(parent_user_values.keys()) if parent_user_values else 'None'}")
1158 if parent_user_values and parent_manager.dataclass_type:
1159 # Use lazy version of parent type to enable sibling inheritance
1160 from openhcs.core.lazy_placeholder_simplified import LazyDefaultPlaceholderService
1161 parent_type = parent_manager.dataclass_type
1162 lazy_parent_type = LazyDefaultPlaceholderService._get_lazy_type_for_base(parent_type)
1163 if lazy_parent_type:
1164 parent_type = lazy_parent_type
1166 # Create parent overlay with only user-modified values
1167 parent_overlay_instance = parent_type(**parent_user_values)
1168 stack.enter_context(config_context(parent_overlay_instance))
1170 # Convert overlay dict to object instance for config_context()
1171 # config_context() expects an object with attributes, not a dict
1172 if isinstance(overlay, dict) and self.dataclass_type:
1173 # For functions and non-dataclass objects: use SimpleNamespace to hold parameters
1174 # For dataclasses: instantiate normally
1175 try:
1176 overlay_instance = self.dataclass_type(**overlay)
1177 except TypeError:
1178 # Function or other non-instantiable type: use SimpleNamespace
1179 from types import SimpleNamespace
1180 overlay_instance = SimpleNamespace(**overlay)
1181 else:
1182 overlay_instance = overlay
1184 # Always apply overlay with current form values (the object being edited)
1185 # config_context() will filter None values and merge onto parent context
1186 stack.enter_context(config_context(overlay_instance))
1188 return stack
1190 def _on_nested_parameter_changed(self, param_name: str, value: Any) -> None:
1191 """
1192 Handle parameter changes from nested forms.
1194 When a nested form's field changes:
1195 1. Refresh parent form's placeholders (in case they inherit from nested values)
1196 2. Refresh all sibling nested forms' placeholders
1197 """
1198 print(f"🔔 NESTED PARAM CHANGED: {param_name} = {value} in parent {self.field_id}")
1199 print(f" Parent initial_load_complete: {self._initial_load_complete}")
1201 # Refresh parent form's placeholders
1202 self._refresh_all_placeholders()
1204 # Refresh all nested managers' placeholders (including siblings)
1205 self._apply_to_nested_managers(lambda name, manager: manager._refresh_all_placeholders())
1207 def _refresh_all_placeholders(self) -> None:
1208 """Refresh placeholder text for all widgets in this form."""
1209 # Allow placeholder refresh for nested forms even if they're not detected as lazy dataclasses
1210 # The placeholder service will determine if placeholders are available
1211 if not self.dataclass_type:
1212 return
1214 # Build overlay from current form state
1215 overlay = self.get_current_values()
1217 # Build context stack: parent context + overlay
1218 with self._build_context_stack(overlay):
1219 for param_name, widget in self.widgets.items():
1220 # CRITICAL: Check current value from overlay (live form state), not stale self.parameters
1221 current_value = overlay.get(param_name) if isinstance(overlay, dict) else getattr(overlay, param_name, None)
1222 if current_value is None:
1223 placeholder_text = self.service.get_placeholder_text(param_name, self.dataclass_type)
1224 if placeholder_text:
1225 from openhcs.pyqt_gui.widgets.shared.widget_strategies import PyQt6WidgetEnhancer
1226 PyQt6WidgetEnhancer.apply_placeholder_text(widget, placeholder_text)
1228 def _apply_to_nested_managers(self, operation_func: callable) -> None:
1229 """Apply operation to all nested managers."""
1230 for param_name, nested_manager in self.nested_managers.items():
1231 operation_func(param_name, nested_manager)
1233 def _process_nested_values_if_checkbox_enabled(self, name: str, manager: Any, current_values: Dict[str, Any]) -> None:
1234 """Process nested values if checkbox is enabled - convert dict back to dataclass."""
1235 if not hasattr(manager, 'get_current_values'):
1236 return
1238 # Check if this is an Optional dataclass with a checkbox
1239 param_type = self.parameter_types.get(name)
1240 from openhcs.ui.shared.parameter_type_utils import ParameterTypeUtils
1242 if param_type and ParameterTypeUtils.is_optional_dataclass(param_type):
1243 # For Optional dataclasses, check if checkbox is enabled
1244 checkbox_widget = self.widgets.get(name)
1245 if checkbox_widget and hasattr(checkbox_widget, 'findChild'):
1246 from PyQt6.QtWidgets import QCheckBox
1247 checkbox = checkbox_widget.findChild(QCheckBox)
1248 if checkbox and not checkbox.isChecked():
1249 # Checkbox is unchecked, set to None
1250 current_values[name] = None
1251 return
1253 # Get nested values from the nested form
1254 nested_values = manager.get_current_values()
1255 if nested_values:
1256 # Convert dictionary back to dataclass instance
1257 if param_type and hasattr(param_type, '__dataclass_fields__'):
1258 # Direct dataclass type
1259 current_values[name] = param_type(**nested_values)
1260 elif param_type and ParameterTypeUtils.is_optional_dataclass(param_type):
1261 # Optional dataclass type
1262 inner_type = ParameterTypeUtils.get_optional_inner_type(param_type)
1263 current_values[name] = inner_type(**nested_values)
1264 else:
1265 # Fallback to dictionary if type conversion fails
1266 current_values[name] = nested_values
1267 else:
1268 # No nested values, but checkbox might be checked - create empty instance
1269 if param_type and ParameterTypeUtils.is_optional_dataclass(param_type):
1270 inner_type = ParameterTypeUtils.get_optional_inner_type(param_type)
1271 current_values[name] = inner_type() # Create with defaults